Compare commits
54 commits
master
...
contentlay
Author | SHA1 | Date | |
---|---|---|---|
|
59256180de | ||
|
a5244d2b4b | ||
|
a9995e2cb7 | ||
|
91a93b6a4a | ||
|
933c82c400 | ||
|
d63031a772 | ||
|
90c9b85d44 | ||
|
b5a6904fef | ||
|
f2fc9d9060 | ||
|
dd827ee720 | ||
|
099c5eccfb | ||
|
954dda9c3b | ||
|
01ebe81a79 | ||
|
915ada7193 | ||
|
44d16504a3 | ||
|
b4da90f8ef | ||
|
1b597627ce | ||
|
168355dc42 | ||
|
04af88b954 | ||
|
16e4eaee28 | ||
|
722a41d9c5 | ||
|
de3de08bbc | ||
|
4ce77b36c9 | ||
|
96b5e1b4d3 | ||
|
742bfdd0e1 | ||
|
4f85c2ccad | ||
|
1be25408ee | ||
|
bd569ba6bc | ||
|
c979da10e1 | ||
|
c4267ea934 | ||
|
c6b0f479a9 | ||
|
afeec47c6e | ||
|
7eb7aac36d | ||
|
030d630189 | ||
|
b2755d3fa2 | ||
|
a634839ce6 | ||
|
4990ba98d8 | ||
|
08f0fb16e9 | ||
|
86f6c14469 | ||
|
2f2ae049cd | ||
|
755fbfcfb1 | ||
|
70668b65cb | ||
|
81bc20bfc3 | ||
|
3ec5bd5996 | ||
|
41e9b02a2b | ||
|
1c87e48735 | ||
|
0870d15994 | ||
|
68bda2ba72 | ||
|
a9becc7c83 | ||
|
930860dc52 | ||
|
c673d4ea38 | ||
|
ece93b9ff4 | ||
|
4e5f6de1e9 | ||
|
c28652d823 |
95 changed files with 2864 additions and 17533 deletions
|
@ -16,7 +16,7 @@ BUTTONDOWN_API_KEY=
|
|||
CONVERTKIT_API_URL=https://api.convertkit.com/v3/
|
||||
CONVERTKIT_API_KEY=
|
||||
// curl https://api.convertkit.com/v3/forms?api_key=<your_public_api_key> to get your form ID
|
||||
CONVERTKIT_FORM_ID=
|
||||
CONVERTKIT_FORM_ID=
|
||||
|
||||
KLAVIYO_API_KEY=
|
||||
KLAVIYO_LIST_ID=
|
23
.eslintrc.js
23
.eslintrc.js
|
@ -1,17 +1,38 @@
|
|||
module.exports = {
|
||||
root: true,
|
||||
parser: '@typescript-eslint/parser',
|
||||
env: {
|
||||
browser: true,
|
||||
amd: true,
|
||||
node: true,
|
||||
es6: true,
|
||||
},
|
||||
extends: ['eslint:recommended', 'plugin:prettier/recommended', 'next', 'next/core-web-vitals'],
|
||||
plugins: ['@typescript-eslint'],
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/eslint-recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:jsx-a11y/recommended',
|
||||
'plugin:prettier/recommended',
|
||||
'next',
|
||||
'next/core-web-vitals',
|
||||
],
|
||||
rules: {
|
||||
'prettier/prettier': 'error',
|
||||
'react/react-in-jsx-scope': 'off',
|
||||
'jsx-a11y/anchor-is-valid': [
|
||||
'error',
|
||||
{
|
||||
components: ['Link'],
|
||||
specialLink: ['hrefLeft', 'hrefRight'],
|
||||
aspects: ['invalidHref', 'preferButton'],
|
||||
},
|
||||
],
|
||||
'react/prop-types': 0,
|
||||
'no-unused-vars': 0,
|
||||
'react/no-unescaped-entities': 0,
|
||||
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
||||
'@typescript-eslint/no-var-requires': 'off',
|
||||
'@typescript-eslint/ban-ts-comment': 'off',
|
||||
},
|
||||
}
|
||||
|
|
3
.github/FUNDING.yml
vendored
3
.github/FUNDING.yml
vendored
|
@ -1,3 +0,0 @@
|
|||
# These are supported funding model platforms
|
||||
|
||||
github: timlrx
|
37
.github/ISSUE_TEMPLATE/bug_report.md
vendored
37
.github/ISSUE_TEMPLATE/bug_report.md
vendored
|
@ -1,37 +0,0 @@
|
|||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: bug
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**System Info (if dev / build issue):**
|
||||
- OS: [e.g. iOS]
|
||||
- Node version (please ensure you are using 12+)
|
||||
- Npm version
|
||||
|
||||
**Browser Info (if display / formatting issue):**
|
||||
- Device [e.g. Desktop, iPhone6]
|
||||
- Browser [e.g. chrome, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
|
@ -1,20 +0,0 @@
|
|||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
8
.gitignore
vendored
8
.gitignore
vendored
|
@ -18,7 +18,7 @@ public/sitemap.xml
|
|||
/build
|
||||
*.xml
|
||||
# rss feed
|
||||
/public/feed.xml
|
||||
/public/feed.xml
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
|
@ -33,4 +33,8 @@ yarn-error.log*
|
|||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.production.local
|
||||
|
||||
|
||||
# Contentlayer
|
||||
.contentlayer
|
||||
|
|
15
README.md
15
README.md
|
@ -29,16 +29,6 @@ Feature request? Check the past discussions to see if it has been brought up pre
|
|||
- [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.
|
||||
- [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))
|
||||
|
||||
Using the template? Feel free to create a PR and add your blog to this list.
|
||||
|
||||
|
@ -55,7 +45,6 @@ I wanted it to be nearly as feature-rich as popular blogging templates like [bea
|
|||
- Lightweight, 45kB first load JS, uses Preact in production build
|
||||
- Mobile-friendly view
|
||||
- Light and dark theme
|
||||
- Self-hosted font with [Fontsource](https://fontsource.org/)
|
||||
- Supports [plausible](https://plausible.io/), [simple analytics](https://simpleanalytics.com/) and google analytics
|
||||
- [MDX - write JSX in markdown documents!](https://mdxjs.com/)
|
||||
- Server-side syntax highlighting with line numbers and line highlighting via [rehype-prism-plus](https://github.com/timlrx/rehype-prism-plus)
|
||||
|
@ -91,7 +80,7 @@ I wanted it to be nearly as feature-rich as popular blogging templates like [bea
|
|||
npx degit https://github.com/timlrx/tailwind-nextjs-starter-blog.git
|
||||
```
|
||||
|
||||
or with TypeScript (community support)
|
||||
or TS (community support)
|
||||
|
||||
```bash
|
||||
npx degit timlrx/tailwind-nextjs-starter-blog#typescript
|
||||
|
@ -118,7 +107,7 @@ First, run the development server:
|
|||
npm start
|
||||
```
|
||||
|
||||
or
|
||||
or
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
|
|
|
@ -1,23 +0,0 @@
|
|||
import { useEffect } from 'react'
|
||||
import Router from 'next/router'
|
||||
|
||||
/**
|
||||
* Client-side complement to next-remote-watch
|
||||
* Re-triggers getStaticProps when watched mdx files change
|
||||
*
|
||||
*/
|
||||
export const ClientReload = () => {
|
||||
// Exclude socket.io from prod bundle
|
||||
useEffect(() => {
|
||||
import('socket.io-client').then((module) => {
|
||||
const socket = module.io()
|
||||
socket.on('reload', (data) => {
|
||||
Router.replace(Router.asPath, undefined, {
|
||||
scroll: false,
|
||||
})
|
||||
})
|
||||
})
|
||||
}, [])
|
||||
|
||||
return null
|
||||
}
|
|
@ -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>
|
|
@ -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
5
components/Image.tsx
Normal file
|
@ -0,0 +1,5 @@
|
|||
import NextImage, { ImageProps } from 'next/image'
|
||||
|
||||
const Image = ({ ...rest }: ImageProps) => <NextImage {...rest} />
|
||||
|
||||
export default Image
|
|
@ -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">
|
|
@ -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('#')
|
||||
|
|
@ -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} />
|
||||
}
|
43
components/MDXComponents.tsx
Normal file
43
components/MDXComponents.tsx
Normal file
|
@ -0,0 +1,43 @@
|
|||
/* eslint-disable react/display-name */
|
||||
import React from 'react'
|
||||
import { useMDXComponent } from 'next-contentlayer/hooks'
|
||||
import { ComponentMap } from 'mdx-bundler/client'
|
||||
import * as temp from '@/lib/utils/temp'
|
||||
import Image from './Image'
|
||||
import CustomLink from './Link'
|
||||
import TOCInline from './TOCInline'
|
||||
import Pre from './Pre'
|
||||
import { BlogNewsletterForm } from './NewsletterForm'
|
||||
import type { Blog, Authors } from '.contentlayer/types'
|
||||
|
||||
interface MDXLayout {
|
||||
layout: string
|
||||
content: Blog | Authors
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
interface Wrapper {
|
||||
layout: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
const Wrapper = ({ layout, content, ...rest }: MDXLayout) => {
|
||||
const Layout = require(`../layouts/${layout}`).default
|
||||
return <Layout content={content} {...rest} />
|
||||
}
|
||||
|
||||
export const MDXComponents: ComponentMap = {
|
||||
Image,
|
||||
TOCInline,
|
||||
a: CustomLink,
|
||||
pre: Pre,
|
||||
wrapper: Wrapper,
|
||||
BlogNewsletterForm,
|
||||
}
|
||||
|
||||
export const MDXLayoutRenderer = ({ layout, content, ...rest }: MDXLayout) => {
|
||||
const MDXLayout = useMDXComponent(content.body.code)
|
||||
const coreContent = temp.omit(content, ['body', '_raw', '_id'])
|
||||
|
||||
return <MDXLayout layout={layout} content={coreContent} components={MDXComponents} {...rest} />
|
||||
}
|
|
@ -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}`, {
|
|
@ -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}
|
|
@ -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>
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -1,8 +1,23 @@
|
|||
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 }) => {
|
||||
interface CommonSEOProps {
|
||||
title: string
|
||||
description: string
|
||||
ogType: string
|
||||
ogImage:
|
||||
| string
|
||||
| {
|
||||
'@type': string
|
||||
url: string
|
||||
}[]
|
||||
twImage: string
|
||||
}
|
||||
|
||||
const CommonSEO = ({ title, description, ogType, ogImage, twImage }: CommonSEOProps) => {
|
||||
const router = useRouter()
|
||||
return (
|
||||
<Head>
|
||||
|
@ -14,7 +29,7 @@ const CommonSEO = ({ title, description, ogType, ogImage, twImage }) => {
|
|||
<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} />
|
||||
|
@ -28,7 +43,12 @@ const CommonSEO = ({ title, description, ogType, ogImage, twImage }) => {
|
|||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
|
@ -42,7 +62,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()
|
||||
|
@ -67,11 +87,24 @@ export const TagSEO = ({ title, description }) => {
|
|||
)
|
||||
}
|
||||
|
||||
export const BlogSEO = ({ authorDetails, title, summary, date, lastmod, url, images = [] }) => {
|
||||
interface BlogSeoProps extends PostFrontMatter {
|
||||
authorDetails?: AuthorFrontMatter[]
|
||||
url: string
|
||||
}
|
||||
|
||||
export const BlogSEO = ({
|
||||
authorDetails,
|
||||
title,
|
||||
summary,
|
||||
date,
|
||||
lastmod,
|
||||
url,
|
||||
images = [],
|
||||
}: BlogSeoProps) => {
|
||||
const router = useRouter()
|
||||
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'
|
|
@ -1,11 +1,9 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import smoothscroll from 'smoothscroll-polyfill'
|
||||
|
||||
const ScrollTopAndComment = () => {
|
||||
const [show, setShow] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
smoothscroll.polyfill()
|
||||
const handleWindowScroll = () => {
|
||||
if (window.scrollY > 50) setShow(true)
|
||||
else setShow(false)
|
|
@ -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>
|
||||
}
|
9
components/SectionContainer.tsx
Normal file
9
components/SectionContainer.tsx
Normal 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>
|
||||
}
|
|
@ -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')
|
|
@ -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">
|
|
@ -1,9 +1,18 @@
|
|||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import GA from './GoogleAnalytics'
|
||||
import Plausible from './Plausible'
|
||||
import SimpleAnalytics from './SimpleAnalytics'
|
||||
import Umami from './Umami'
|
||||
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 = () => {
|
|
@ -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 })
|
||||
}
|
||||
}
|
|
@ -3,7 +3,11 @@ import { useTheme } from 'next-themes'
|
|||
|
||||
import siteMetadata from '@/data/siteMetadata'
|
||||
|
||||
const Giscus = ({ mapping }) => {
|
||||
interface Props {
|
||||
mapping: string
|
||||
}
|
||||
|
||||
const Giscus = ({ mapping }: Props) => {
|
||||
const [enableLoadComments, setEnabledLoadComments] = useState(true)
|
||||
const { theme, resolvedTheme } = useTheme()
|
||||
const commentsTheme =
|
|
@ -3,7 +3,11 @@ import { useTheme } from 'next-themes'
|
|||
|
||||
import siteMetadata from '@/data/siteMetadata'
|
||||
|
||||
const Utterances = ({ issueTerm }) => {
|
||||
interface Props {
|
||||
issueTerm: string
|
||||
}
|
||||
|
||||
const Utterances = ({ issueTerm }: Props) => {
|
||||
const [enableLoadComments, setEnabledLoadComments] = useState(true)
|
||||
const { theme, resolvedTheme } = useTheme()
|
||||
const commentsTheme =
|
|
@ -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,7 +25,7 @@ const DisqusComponent = dynamic(
|
|||
{ ssr: false }
|
||||
)
|
||||
|
||||
const Comments = ({ frontMatter }) => {
|
||||
const Comments = ({ frontMatter }: Props) => {
|
||||
let term
|
||||
switch (
|
||||
siteMetadata.comment.giscusConfig.mapping ||
|
89
contentlayer.config.ts
Normal file
89
contentlayer.config.ts
Normal file
|
@ -0,0 +1,89 @@
|
|||
import { defineDocumentType, ComputedFields, makeSource } from 'contentlayer/source-files'
|
||||
import readingTime from 'reading-time'
|
||||
import path from 'path'
|
||||
// Remark packages
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkFootnotes from 'remark-footnotes'
|
||||
import remarkMath from 'remark-math'
|
||||
import remarkExtractFrontmatter from './lib/remark-extract-frontmatter'
|
||||
import remarkCodeTitles from './lib/remark-code-title'
|
||||
import { extractTocHeadings } from './lib/remark-toc-headings'
|
||||
import remarkImgToJsx from './lib/remark-img-to-jsx'
|
||||
// Rehype packages
|
||||
import rehypeSlug from 'rehype-slug'
|
||||
import rehypeAutolinkHeadings from 'rehype-autolink-headings'
|
||||
import rehypeKatex from 'rehype-katex'
|
||||
import rehypeCitation from 'rehype-citation'
|
||||
import rehypePrismPlus from 'rehype-prism-plus'
|
||||
import rehypePresetMinify from 'rehype-preset-minify'
|
||||
|
||||
const root = process.cwd()
|
||||
|
||||
const computedFields: ComputedFields = {
|
||||
readingTime: { type: 'json', resolve: (doc) => readingTime(doc.body.raw) },
|
||||
slug: {
|
||||
type: 'string',
|
||||
resolve: (doc) => doc._raw.flattenedPath.replace(/^.+?(\/)/, ''),
|
||||
},
|
||||
toc: { type: 'string', resolve: (doc) => extractTocHeadings(doc.body.raw) },
|
||||
}
|
||||
|
||||
export const Blog = defineDocumentType(() => ({
|
||||
name: 'Blog',
|
||||
filePathPattern: 'blog/**/*.{md,mdx}',
|
||||
bodyType: 'mdx',
|
||||
fields: {
|
||||
title: { type: 'string', required: true },
|
||||
date: { type: 'date', required: true },
|
||||
tags: { type: 'list', of: { type: 'string' } },
|
||||
lastmod: { type: 'date' },
|
||||
draft: { type: 'boolean' },
|
||||
summary: { type: 'string' },
|
||||
images: { type: 'list', of: { type: 'string' } },
|
||||
authors: { type: 'list', of: { type: 'string' } },
|
||||
layout: { type: 'string' },
|
||||
bibliography: { type: 'string' },
|
||||
},
|
||||
computedFields,
|
||||
}))
|
||||
|
||||
export const Authors = defineDocumentType(() => ({
|
||||
name: 'Authors',
|
||||
filePathPattern: 'authors/**/*.{md,mdx}',
|
||||
bodyType: 'mdx',
|
||||
fields: {
|
||||
name: { type: 'string', required: true },
|
||||
avatar: { type: 'string' },
|
||||
occupation: { type: 'string' },
|
||||
company: { type: 'string' },
|
||||
email: { type: 'string' },
|
||||
twitter: { type: 'string' },
|
||||
linkedin: { type: 'string' },
|
||||
github: { type: 'string' },
|
||||
layout: { type: 'string' },
|
||||
},
|
||||
computedFields,
|
||||
}))
|
||||
|
||||
export default makeSource({
|
||||
contentDirPath: 'data',
|
||||
documentTypes: [Blog, Authors],
|
||||
mdx: {
|
||||
remarkPlugins: [
|
||||
remarkExtractFrontmatter,
|
||||
remarkGfm,
|
||||
remarkCodeTitles,
|
||||
[remarkFootnotes, { inlineNotes: true }],
|
||||
remarkMath,
|
||||
remarkImgToJsx,
|
||||
],
|
||||
rehypePlugins: [
|
||||
rehypeSlug,
|
||||
rehypeAutolinkHeadings,
|
||||
rehypeKatex,
|
||||
[rehypeCitation, { path: path.join(root, 'data') }],
|
||||
[rehypePrismPlus, { ignoreMissing: true }],
|
||||
rehypePresetMinify,
|
||||
],
|
||||
},
|
||||
})
|
|
@ -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 {
|
||||
|
|
|
@ -49,12 +49,8 @@ _Note_: If you try to save the image, it is in webp format, if your browser supp
|
|||
|
||||
![ocean](/static/images/ocean.jpeg)
|
||||
|
||||
<p>
|
||||
Photo by [YUCAR
|
||||
FotoGrafik](https://unsplash.com/@yucar?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText)
|
||||
on
|
||||
[Unsplash](https://unsplash.com/s/photos/sea?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText)
|
||||
</p>
|
||||
Photo by [YUCAR FotoGrafik](https://unsplash.com/@yucar?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText)
|
||||
on [Unsplash](https://unsplash.com/s/photos/sea?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText)
|
||||
|
||||
# Benefits
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
title: 'Introducing Tailwind Nextjs Starter Blog'
|
||||
date: '2021-01-12'
|
||||
lastmod: '2021-02-01'
|
||||
lastmod: '2021-12-22'
|
||||
tags: ['next-js', 'tailwind', 'guide']
|
||||
draft: false
|
||||
summary: 'Looking for a performant, out of the box template, with all the best in web technology to support your blogging needs? Checkout the Tailwind Nextjs Starter Blog template.'
|
||||
|
@ -47,7 +47,6 @@ I wanted it to be nearly as feature-rich as popular blogging templates like [bea
|
|||
- Lightweight, 45kB first load JS, uses Preact in production build
|
||||
- Mobile-friendly view
|
||||
- Light and dark theme
|
||||
- Self-hosted font with [Fontsource](https://fontsource.org/)
|
||||
- Supports [plausible](https://plausible.io/), [simple analytics](https://simpleanalytics.com/) and google analytics
|
||||
- [MDX - write JSX in markdown documents!](https://mdxjs.com/)
|
||||
- Server-side syntax highlighting with line numbers and line highlighting via [rehype-prism-plus](https://github.com/timlrx/rehype-prism-plus)
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
title: 'New features in v1'
|
||||
date: 2021-08-07T15:32:14Z
|
||||
lastmod: '2021-02-01'
|
||||
lastmod: '2021-12-15'
|
||||
tags: ['next-js', 'tailwind', 'guide']
|
||||
draft: false
|
||||
summary: 'An overview of the new features released in v1 - code block copy, multiple authors, frontmatter layout and more'
|
||||
|
@ -58,11 +58,11 @@ Some new possibilities include loading components directly in the mdx file using
|
|||
For example, the following jsx snippet can be used directly in an MDX file to render the page title component:
|
||||
|
||||
```jsx
|
||||
import PageTitle from './PageTitle.js'
|
||||
import PageTitle from './PageTitle.tsx'
|
||||
;<PageTitle> Using JSX components in MDX </PageTitle>
|
||||
```
|
||||
|
||||
import PageTitle from './PageTitle.js'
|
||||
import PageTitle from './components/PageTitle.tsx'
|
||||
|
||||
<PageTitle> Using JSX components in MDX </PageTitle>
|
||||
|
||||
|
@ -260,7 +260,7 @@ A long description of yourself...
|
|||
|
||||
You can use this information in multiple places across the template. For example in the about section of the page, we grab the default author information with this line of code:
|
||||
|
||||
```js
|
||||
```
|
||||
const authorDetails = await getFileBySlug('authors', ['default'])
|
||||
```
|
||||
|
||||
|
@ -272,7 +272,7 @@ The frontmatter of a blog post accepts an optional `authors` array field. If no
|
|||
|
||||
For example, the following frontmatter will display the authors given by `data/authors/default.md` and `data/authors/sparrowhawk.md`
|
||||
|
||||
```yaml
|
||||
```
|
||||
title: 'My first post'
|
||||
date: '2021-01-12'
|
||||
draft: false
|
||||
|
@ -321,7 +321,7 @@ To modify the styles, change the following class selectors in the `prism.css` fi
|
|||
}
|
||||
|
||||
.code-line {
|
||||
@apply -mx-4 block border-l-4 border-opacity-0 pl-4 pr-4;
|
||||
@apply block pl-4 pr-4 -mx-4 border-l-4 border-opacity-0;
|
||||
}
|
||||
|
||||
.code-line.inserted {
|
||||
|
@ -333,11 +333,11 @@ To modify the styles, change the following class selectors in the `prism.css` fi
|
|||
}
|
||||
|
||||
.highlight-line {
|
||||
@apply -mx-4 border-l-4 border-primary-500 bg-gray-700 bg-opacity-50;
|
||||
@apply -mx-4 bg-gray-700 bg-opacity-50 border-l-4 border-primary-500;
|
||||
}
|
||||
|
||||
.line-number::before {
|
||||
@apply mr-4 -ml-2 inline-block w-4 text-right text-gray-400;
|
||||
@apply inline-block w-4 mr-4 -ml-2 text-right text-gray-400;
|
||||
content: attr(line);
|
||||
}
|
||||
```
|
||||
|
@ -398,24 +398,6 @@ The plugin uses APA citation formation, but also supports the following CSLs, 'a
|
|||
|
||||
See [rehype-citation readme](https://github.com/timlrx/rehype-citation) for more information on the configuration options.
|
||||
|
||||
## Self-hosted font (v1.5.0)
|
||||
|
||||
Google font has been replaced with self-hosted font from [Fontsource](https://fontsource.org/). This gives the following [advantages](https://fontsource.org/docs/introduction):
|
||||
|
||||
> Self-hosting brings significant performance gains as loading fonts from hosted services, such as Google Fonts, lead to an extra (render blocking) network request. To provide perspective, for simple websites it has been seen to double visual load times.
|
||||
>
|
||||
> Fonts remain version locked. Google often pushes updates to their fonts without notice, which may interfere with your live production projects. Manage your fonts like any other NPM dependency.
|
||||
>
|
||||
> Commit to privacy. Google does track the usage of their fonts and for those who are extremely privacy concerned, self-hosting is an alternative.
|
||||
|
||||
This leads to a smaller font bundle and a 0.1s faster load time ([webpagetest comparison](https://www.webpagetest.org/video/compare.php?tests=220201_AiDcFH_f68a69b758454dd52d8e67493fdef7da,220201_BiDcMC_bf2d53f14483814ba61e794311dfa771)).
|
||||
|
||||
To change the default Inter font:
|
||||
|
||||
1. Install the preferred [font](https://fontsource.org/fonts) - `npm install -save @fontsource/<font-name>`
|
||||
2. Update the import at `pages/_app.js`- `import '@fontsource/<font-name>.css'`
|
||||
3. Update the `fontfamily` property in the tailwind css config file
|
||||
|
||||
## Upgrade guide
|
||||
|
||||
There are significant portions of the code that has been changed from v0 to v1 including support for layouts and a new mdx engine.
|
||||
|
@ -437,7 +419,7 @@ You can see an example of such a migration in this [commit](https://github.com/t
|
|||
|
||||
v1 also uses `feed.xml` rather than `index.xml`, to avoid some build issues with Vercel. If you are migrating you should add a redirect to `next.config.js` like so:
|
||||
|
||||
```js
|
||||
```
|
||||
async redirects() {
|
||||
return [
|
||||
{
|
||||
|
|
|
@ -30,4 +30,4 @@
|
|||
author={Xie, Yihui},
|
||||
year={2016},
|
||||
publisher={CRC Press}
|
||||
}
|
||||
}
|
|
@ -1,9 +1,16 @@
|
|||
import SocialIcon from '@/components/social-icons'
|
||||
import Image from '@/components/Image'
|
||||
import { PageSEO } from '@/components/SEO'
|
||||
import { ReactNode } from 'react'
|
||||
import type { Authors } from '.contentlayer/types'
|
||||
|
||||
export default function AuthorLayout({ children, frontMatter }) {
|
||||
const { name, avatar, occupation, company, email, twitter, linkedin, github } = frontMatter
|
||||
interface Props {
|
||||
children: ReactNode
|
||||
content: Omit<Authors, '_id' | '_raw' | 'body'>
|
||||
}
|
||||
|
||||
export default function AuthorLayout({ children, content }: Props) {
|
||||
const { name, avatar, occupation, company, email, twitter, linkedin, github } = content
|
||||
|
||||
return (
|
||||
<>
|
|
@ -1,14 +1,21 @@
|
|||
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 * as temp from '@/lib/utils/temp'
|
||||
|
||||
export default function ListLayout({ posts, title, initialDisplayPosts = [], pagination }) {
|
||||
interface Props {
|
||||
posts: temp.CoreBlog[]
|
||||
title: string
|
||||
initialDisplayPosts?: temp.CoreBlog[]
|
||||
pagination?: ComponentProps<typeof 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(' ')
|
||||
const filteredBlogPosts = posts.filter((post) => {
|
||||
const searchContent = post.title + post.summary + post.tags.join(' ')
|
||||
return searchContent.toLowerCase().includes(searchValue.toLowerCase())
|
||||
})
|
||||
|
||||
|
@ -49,8 +56,8 @@ export default function ListLayout({ posts, title, initialDisplayPosts = [], pag
|
|||
</div>
|
||||
<ul>
|
||||
{!filteredBlogPosts.length && 'No posts found.'}
|
||||
{displayPosts.map((frontMatter) => {
|
||||
const { slug, date, title, summary, tags } = frontMatter
|
||||
{displayPosts.map((post) => {
|
||||
const { slug, date, title, summary, tags } = post
|
||||
return (
|
||||
<li key={slug} className="py-4">
|
||||
<article className="space-y-2 xl:grid xl:grid-cols-4 xl:items-baseline xl:space-y-0">
|
|
@ -7,24 +7,40 @@ import Tag from '@/components/Tag'
|
|||
import siteMetadata from '@/data/siteMetadata'
|
||||
import Comments from '@/components/comments'
|
||||
import ScrollTopAndComment from '@/components/ScrollTopAndComment'
|
||||
import * as temp from '@/lib/utils/temp'
|
||||
import { ReactNode } from 'react'
|
||||
import { AuthorFrontMatter } from 'types/AuthorFrontMatter'
|
||||
|
||||
const editUrl = (fileName) => `${siteMetadata.siteRepo}/blob/master/data/blog/${fileName}`
|
||||
const editUrl = (slug) => `${siteMetadata.siteRepo}/blob/master/data/blog/${slug}`
|
||||
const discussUrl = (slug) =>
|
||||
`https://mobile.twitter.com/search?q=${encodeURIComponent(
|
||||
`${siteMetadata.siteUrl}/blog/${slug}`
|
||||
)}`
|
||||
|
||||
const postDateTemplate = { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }
|
||||
const postDateTemplate: Intl.DateTimeFormatOptions = {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
}
|
||||
|
||||
export default function PostLayout({ frontMatter, authorDetails, next, prev, children }) {
|
||||
const { slug, fileName, date, title, tags } = frontMatter
|
||||
interface Props {
|
||||
content: temp.CoreBlog
|
||||
authorDetails: AuthorFrontMatter[]
|
||||
next?: { slug: string; title: string }
|
||||
prev?: { slug: string; title: string }
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export default function PostLayout({ content, authorDetails, next, prev, children }: Props) {
|
||||
const { slug, date, title, tags } = content
|
||||
|
||||
return (
|
||||
<SectionContainer>
|
||||
<BlogSEO
|
||||
url={`${siteMetadata.siteUrl}/blog/${slug}`}
|
||||
authorDetails={authorDetails}
|
||||
{...frontMatter}
|
||||
{...content}
|
||||
/>
|
||||
<ScrollTopAndComment />
|
||||
<article>
|
||||
|
@ -92,9 +108,9 @@ export default function PostLayout({ frontMatter, authorDetails, next, prev, chi
|
|||
{'Discuss on Twitter'}
|
||||
</Link>
|
||||
{` • `}
|
||||
<Link href={editUrl(fileName)}>{'View on GitHub'}</Link>
|
||||
<Link href={editUrl(slug)}>{'View on GitHub'}</Link>
|
||||
</div>
|
||||
<Comments frontMatter={frontMatter} />
|
||||
{/* <Comments frontMatter={frontMatter} /> */}
|
||||
</div>
|
||||
<footer>
|
||||
<div className="divide-gray-200 text-sm font-medium leading-5 dark:divide-gray-700 xl:col-start-1 xl:row-start-2 xl:divide-y">
|
|
@ -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 * as temp from '@/lib/utils/temp'
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
export default function PostLayout({ frontMatter, authorDetails, next, prev, children }) {
|
||||
const { date, title } = frontMatter
|
||||
interface Props {
|
||||
content: temp.CoreBlog
|
||||
children: ReactNode
|
||||
next?: { slug: string; title: string }
|
||||
prev?: { slug: string; title: string }
|
||||
}
|
||||
|
||||
export default function PostLayout({ content, next, prev, children }: Props) {
|
||||
const { slug, date, title } = content
|
||||
|
||||
return (
|
||||
<SectionContainer>
|
||||
<BlogSEO url={`${siteMetadata.siteUrl}/blog/${frontMatter.slug}`} {...frontMatter} />
|
||||
<BlogSEO url={`${siteMetadata.siteUrl}/blog/${slug}`} {...content} />
|
||||
<ScrollTopAndComment />
|
||||
<article>
|
||||
<div>
|
||||
|
@ -38,7 +47,7 @@ export default function PostLayout({ frontMatter, authorDetails, next, prev, chi
|
|||
<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>
|
||||
<Comments frontMatter={frontMatter} />
|
||||
{/* <Comments frontMatter={frontMatter} /> */}
|
||||
<footer>
|
||||
<div className="flex flex-col text-sm font-medium sm:flex-row sm:justify-between sm:text-base">
|
||||
{prev && (
|
|
@ -1,8 +1,9 @@
|
|||
import { escape } from '@/lib/utils/htmlEscaper'
|
||||
|
||||
import siteMetadata from '@/data/siteMetadata'
|
||||
import type { Blog } from '.contentlayer/types'
|
||||
|
||||
const generateRssItem = (post) => `
|
||||
const generateRssItem = (post: Blog) => `
|
||||
<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: Blog[], page = 'feed.xml') => `
|
||||
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
|
||||
<channel>
|
||||
<title>${escape(siteMetadata.title)}</title>
|
136
lib/mdx.js
136
lib/mdx.js
|
@ -1,136 +0,0 @@
|
|||
import { bundleMDX } from 'mdx-bundler'
|
||||
import fs from 'fs'
|
||||
import matter from 'gray-matter'
|
||||
import path from 'path'
|
||||
import readingTime from 'reading-time'
|
||||
import { visit } from 'unist-util-visit'
|
||||
import getAllFilesRecursively from './utils/files'
|
||||
// Remark packages
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkFootnotes from 'remark-footnotes'
|
||||
import remarkMath from 'remark-math'
|
||||
import remarkExtractFrontmatter from './remark-extract-frontmatter'
|
||||
import remarkCodeTitles from './remark-code-title'
|
||||
import remarkTocHeadings from './remark-toc-headings'
|
||||
import remarkImgToJsx from './remark-img-to-jsx'
|
||||
// Rehype packages
|
||||
import rehypeSlug from 'rehype-slug'
|
||||
import rehypeAutolinkHeadings from 'rehype-autolink-headings'
|
||||
import rehypeKatex from 'rehype-katex'
|
||||
import rehypeCitation from 'rehype-citation'
|
||||
import rehypePrismPlus from 'rehype-prism-plus'
|
||||
import rehypePresetMinify from 'rehype-preset-minify'
|
||||
|
||||
const root = process.cwd()
|
||||
|
||||
export function getFiles(type) {
|
||||
const prefixPaths = path.join(root, 'data', type)
|
||||
const files = getAllFilesRecursively(prefixPaths)
|
||||
// Only want to return blog/path and ignore root, replace is needed to work on Windows
|
||||
return files.map((file) => file.slice(prefixPaths.length + 1).replace(/\\/g, '/'))
|
||||
}
|
||||
|
||||
export function formatSlug(slug) {
|
||||
return slug.replace(/\.(mdx|md)/, '')
|
||||
}
|
||||
|
||||
export function dateSortDesc(a, b) {
|
||||
if (a > b) return -1
|
||||
if (a < b) return 1
|
||||
return 0
|
||||
}
|
||||
|
||||
export async function getFileBySlug(type, slug) {
|
||||
const mdxPath = path.join(root, 'data', type, `${slug}.mdx`)
|
||||
const mdPath = path.join(root, 'data', type, `${slug}.md`)
|
||||
const source = fs.existsSync(mdxPath)
|
||||
? fs.readFileSync(mdxPath, 'utf8')
|
||||
: fs.readFileSync(mdPath, 'utf8')
|
||||
|
||||
// https://github.com/kentcdodds/mdx-bundler#nextjs-esbuild-enoent
|
||||
if (process.platform === 'win32') {
|
||||
process.env.ESBUILD_BINARY_PATH = path.join(root, 'node_modules', 'esbuild', 'esbuild.exe')
|
||||
} else {
|
||||
process.env.ESBUILD_BINARY_PATH = path.join(root, 'node_modules', 'esbuild', 'bin', 'esbuild')
|
||||
}
|
||||
|
||||
let toc = []
|
||||
|
||||
const { code, frontmatter } = await bundleMDX({
|
||||
source,
|
||||
// mdx imports can be automatically source from the components directory
|
||||
cwd: path.join(root, 'components'),
|
||||
xdmOptions(options, frontmatter) {
|
||||
// this is the recommended way to add custom remark/rehype plugins:
|
||||
// The syntax might look weird, but it protects you in case we add/remove
|
||||
// plugins in the future.
|
||||
options.remarkPlugins = [
|
||||
...(options.remarkPlugins ?? []),
|
||||
remarkExtractFrontmatter,
|
||||
[remarkTocHeadings, { exportRef: toc }],
|
||||
remarkGfm,
|
||||
remarkCodeTitles,
|
||||
[remarkFootnotes, { inlineNotes: true }],
|
||||
remarkMath,
|
||||
remarkImgToJsx,
|
||||
]
|
||||
options.rehypePlugins = [
|
||||
...(options.rehypePlugins ?? []),
|
||||
rehypeSlug,
|
||||
rehypeAutolinkHeadings,
|
||||
rehypeKatex,
|
||||
[rehypeCitation, { path: path.join(root, 'data') }],
|
||||
[rehypePrismPlus, { ignoreMissing: true }],
|
||||
rehypePresetMinify,
|
||||
]
|
||||
return options
|
||||
},
|
||||
esbuildOptions: (options) => {
|
||||
options.loader = {
|
||||
...options.loader,
|
||||
'.js': 'jsx',
|
||||
}
|
||||
return options
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
mdxSource: code,
|
||||
toc,
|
||||
frontMatter: {
|
||||
readingTime: readingTime(code),
|
||||
slug: slug || null,
|
||||
fileName: fs.existsSync(mdxPath) ? `${slug}.mdx` : `${slug}.md`,
|
||||
...frontmatter,
|
||||
date: frontmatter.date ? new Date(frontmatter.date).toISOString() : null,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAllFilesFrontMatter(folder) {
|
||||
const prefixPaths = path.join(root, 'data', folder)
|
||||
|
||||
const files = getAllFilesRecursively(prefixPaths)
|
||||
|
||||
const allFrontMatter = []
|
||||
|
||||
files.forEach((file) => {
|
||||
// Replace is needed to work on Windows
|
||||
const fileName = file.slice(prefixPaths.length + 1).replace(/\\/g, '/')
|
||||
// Remove Unexpected File
|
||||
if (path.extname(fileName) !== '.md' && path.extname(fileName) !== '.mdx') {
|
||||
return
|
||||
}
|
||||
const source = fs.readFileSync(file, 'utf8')
|
||||
const { data: frontmatter } = matter(source)
|
||||
if (frontmatter.draft !== true) {
|
||||
allFrontMatter.push({
|
||||
...frontmatter,
|
||||
slug: formatSlug(fileName),
|
||||
date: frontmatter.date ? new Date(frontmatter.date).toISOString() : null,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return allFrontMatter.sort((a, b) => dateSortDesc(a.date, b.date))
|
||||
}
|
|
@ -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 = ''
|
|
@ -1,10 +0,0 @@
|
|||
import { visit } from 'unist-util-visit'
|
||||
import { load } from 'js-yaml'
|
||||
|
||||
export default function extractFrontmatter() {
|
||||
return (tree, file) => {
|
||||
visit(tree, 'yaml', (node, index, parent) => {
|
||||
file.data.frontmatter = load(node.value)
|
||||
})
|
||||
}
|
||||
}
|
13
lib/remark-extract-frontmatter.ts
Normal file
13
lib/remark-extract-frontmatter.ts
Normal file
|
@ -0,0 +1,13 @@
|
|||
import { Parent } from 'unist'
|
||||
import { VFile } from 'vfile'
|
||||
import { visit } from 'unist-util-visit'
|
||||
import yaml from 'js-yaml'
|
||||
|
||||
export default function extractFrontmatter() {
|
||||
return (tree: Parent, file: VFile) => {
|
||||
visit(tree, 'yaml', (node: Parent) => {
|
||||
//@ts-ignore
|
||||
file.data.frontmatter = yaml.load(node.value)
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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}`)) {
|
|
@ -1,15 +0,0 @@
|
|||
import { visit } from 'unist-util-visit'
|
||||
import { slug } from 'github-slugger'
|
||||
import { toString } from 'mdast-util-to-string'
|
||||
|
||||
export default function remarkTocHeadings(options) {
|
||||
return (tree) =>
|
||||
visit(tree, 'heading', (node, index, parent) => {
|
||||
const textContent = toString(node)
|
||||
options.exportRef.push({
|
||||
value: textContent,
|
||||
url: '#' + slug(textContent),
|
||||
depth: node.depth,
|
||||
})
|
||||
})
|
||||
}
|
33
lib/remark-toc-headings.ts
Normal file
33
lib/remark-toc-headings.ts
Normal file
|
@ -0,0 +1,33 @@
|
|||
import { VFile } from 'vfile'
|
||||
import { Parent } from 'unist'
|
||||
import { visit } from 'unist-util-visit'
|
||||
import { Heading } from 'mdast'
|
||||
import slugger from 'github-slugger'
|
||||
import { toString } from 'mdast-util-to-string'
|
||||
import { remark } from 'remark'
|
||||
import { Toc } from 'types/Toc'
|
||||
|
||||
export function remarkTocHeadings() {
|
||||
return (tree: Parent, file: VFile) => {
|
||||
const toc: Toc = []
|
||||
visit(tree, 'heading', (node: Heading) => {
|
||||
const textContent = toString(node)
|
||||
toc.push({
|
||||
value: textContent,
|
||||
url: '#' + slugger.slug(textContent),
|
||||
depth: node.depth,
|
||||
})
|
||||
})
|
||||
file.data.toc = toc
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} markdown
|
||||
* @return {Toc} toc
|
||||
*/
|
||||
export async function extractTocHeadings(markdown) {
|
||||
const vfile = await remark().use(remarkTocHeadings).process(markdown)
|
||||
return vfile.data.toc
|
||||
}
|
30
lib/tags.js
30
lib/tags.js
|
@ -1,30 +0,0 @@
|
|||
import fs from 'fs'
|
||||
import matter from 'gray-matter'
|
||||
import path from 'path'
|
||||
import { getFiles } from './mdx'
|
||||
import kebabCase from './utils/kebabCase'
|
||||
|
||||
const root = process.cwd()
|
||||
|
||||
export async function getAllTags(type) {
|
||||
const files = await getFiles(type)
|
||||
|
||||
let tagCount = {}
|
||||
// Iterate through each post, putting all found tags into `tags`
|
||||
files.forEach((file) => {
|
||||
const source = fs.readFileSync(path.join(root, 'data', type, file), 'utf8')
|
||||
const { data } = matter(source)
|
||||
if (data.tags && data.draft !== true) {
|
||||
data.tags.forEach((tag) => {
|
||||
const formattedTag = kebabCase(tag)
|
||||
if (formattedTag in tagCount) {
|
||||
tagCount[formattedTag] += 1
|
||||
} else {
|
||||
tagCount[formattedTag] = 1
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return tagCount
|
||||
}
|
|
@ -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
|
|
@ -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',
|
22
lib/utils/htmlEscaper.ts
Normal file
22
lib/utils/htmlEscaper.ts
Normal file
|
@ -0,0 +1,22 @@
|
|||
const { replace } = ''
|
||||
|
||||
// escape
|
||||
const ca = /[&<>'"]/g
|
||||
|
||||
const esca = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
"'": ''',
|
||||
'"': '"',
|
||||
}
|
||||
const pe = (m: keyof typeof esca) => esca[m]
|
||||
|
||||
/**
|
||||
* Safely escape HTML entities such as `&`, `<`, `>`, `"`, and `'`.
|
||||
* @param {string} es the input to safely escape
|
||||
* @returns {string} the escaped input, and it **throws** an error if
|
||||
* the input type is unexpected, except for boolean and numbers,
|
||||
* converted as string.
|
||||
*/
|
||||
export const escape = (es: string): string => replace.call(es, ca, pe)
|
|
@ -1,5 +1,5 @@
|
|||
import { slug } from 'github-slugger'
|
||||
|
||||
const kebabCase = (str) => slug(str)
|
||||
const kebabCase = (str: string) => slug(str)
|
||||
|
||||
export default kebabCase
|
74
lib/utils/temp.ts
Normal file
74
lib/utils/temp.ts
Normal file
|
@ -0,0 +1,74 @@
|
|||
import kebabCase from '@/lib/utils/kebabCase'
|
||||
import type { Blog } from '.contentlayer/types'
|
||||
|
||||
export function dateSortDesc(a: string, b: string) {
|
||||
if (a > b) return -1
|
||||
if (a < b) return 1
|
||||
return 0
|
||||
}
|
||||
|
||||
type ConvertUndefined<T> = OrNull<{
|
||||
[K in keyof T as undefined extends T[K] ? K : never]-?: T[K]
|
||||
}>
|
||||
type OrNull<T> = { [K in keyof T]: Exclude<T[K], undefined> | null }
|
||||
type PickRequired<T> = {
|
||||
[K in keyof T as undefined extends T[K] ? never : K]: T[K]
|
||||
}
|
||||
type ConvertPick<T> = ConvertUndefined<T> & PickRequired<T>
|
||||
|
||||
/**
|
||||
*
|
||||
* https://github.com/contentlayerdev/contentlayer/issues/24
|
||||
*/
|
||||
export const pick = <Obj, Keys extends keyof Obj>(
|
||||
obj: Obj,
|
||||
keys: Keys[]
|
||||
): ConvertPick<{ [K in Keys]: Obj[K] }> => {
|
||||
return keys.reduce((acc, key) => {
|
||||
acc[key] = obj[key] ?? null
|
||||
return acc
|
||||
}, {} as any)
|
||||
}
|
||||
|
||||
export const omit = <Obj, Keys extends keyof Obj>(obj: Obj, keys: Keys[]): Omit<Obj, Keys> => {
|
||||
const result = Object.assign({}, obj)
|
||||
keys.forEach((key) => {
|
||||
delete result[key]
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
// Move this to contentlayer when ready
|
||||
export function coreBlog(blog: Blog) {
|
||||
return omit(blog, ['body', '_raw', '_id'])
|
||||
}
|
||||
|
||||
export type CoreBlog = ReturnType<typeof coreBlog>
|
||||
|
||||
export function coreAllBlog(allBlogs: Blog[]) {
|
||||
return allBlogs.map((blog) => coreBlog(blog))
|
||||
}
|
||||
|
||||
export function sortedBlogPost(allBlogs: Blog[]) {
|
||||
return allBlogs.sort((a, b) => dateSortDesc(a.date, b.date))
|
||||
}
|
||||
|
||||
// TODO: refactor into contentlayer once compute over all docs is enabled
|
||||
export async function getAllTags(allBlogs: Blog[]) {
|
||||
const tagCount: Record<string, number> = {}
|
||||
// Iterate through each post, putting all found tags into `tags`
|
||||
allBlogs.forEach((file) => {
|
||||
if (file.tags && file.draft !== true) {
|
||||
file.tags.forEach((tag) => {
|
||||
const formattedTag = kebabCase(tag)
|
||||
if (formattedTag in tagCount) {
|
||||
tagCount[formattedTag] += 1
|
||||
} else {
|
||||
tagCount[formattedTag] = 1
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return tagCount
|
||||
}
|
5
next-env.d.ts
vendored
Normal file
5
next-env.d.ts
vendored
Normal 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.
|
|
@ -1,3 +1,5 @@
|
|||
const { withContentlayer } = require('next-contentlayer')
|
||||
|
||||
const withBundleAnalyzer = require('@next/bundle-analyzer')({
|
||||
enabled: process.env.ANALYZE === 'true',
|
||||
})
|
||||
|
@ -6,11 +8,11 @@ const withBundleAnalyzer = require('@next/bundle-analyzer')({
|
|||
const ContentSecurityPolicy = `
|
||||
default-src 'self';
|
||||
script-src 'self' 'unsafe-eval' 'unsafe-inline' giscus.app;
|
||||
style-src 'self' 'unsafe-inline' cdn.jsdelivr.net;
|
||||
style-src 'self' 'unsafe-inline' *.googleapis.com cdn.jsdelivr.net;
|
||||
img-src * blob: data:;
|
||||
media-src 'none';
|
||||
connect-src *;
|
||||
font-src 'self' cdn.jsdelivr.net;
|
||||
font-src 'self' fonts.gstatic.com cdn.jsdelivr.net;
|
||||
frame-src giscus.app
|
||||
`
|
||||
|
||||
|
@ -52,49 +54,54 @@ const securityHeaders = [
|
|||
},
|
||||
]
|
||||
|
||||
module.exports = withBundleAnalyzer({
|
||||
reactStrictMode: true,
|
||||
pageExtensions: ['js', 'jsx', 'md', 'mdx'],
|
||||
eslint: {
|
||||
dirs: ['pages', 'components', 'lib', 'layouts', 'scripts'],
|
||||
},
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: '/(.*)',
|
||||
headers: securityHeaders,
|
||||
},
|
||||
]
|
||||
},
|
||||
webpack: (config, { dev, isServer }) => {
|
||||
config.module.rules.push({
|
||||
test: /\.(png|jpe?g|gif|mp4)$/i,
|
||||
use: [
|
||||
/**
|
||||
* @type {import('next/dist/next-server/server/config').NextConfig}
|
||||
**/
|
||||
module.exports = withContentlayer()(
|
||||
withBundleAnalyzer({
|
||||
reactStrictMode: true,
|
||||
pageExtensions: ['ts', 'tsx', 'js', 'jsx', 'md', 'mdx'],
|
||||
eslint: {
|
||||
dirs: ['pages', 'components', 'lib', 'layouts', 'scripts'],
|
||||
},
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
loader: 'file-loader',
|
||||
options: {
|
||||
publicPath: '/_next',
|
||||
name: 'static/media/[name].[hash].[ext]',
|
||||
},
|
||||
source: '/(.*)',
|
||||
headers: securityHeaders,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
config.module.rules.push({
|
||||
test: /\.svg$/,
|
||||
use: ['@svgr/webpack'],
|
||||
})
|
||||
|
||||
if (!dev && !isServer) {
|
||||
// Replace React with Preact only in client production build
|
||||
Object.assign(config.resolve.alias, {
|
||||
'react/jsx-runtime.js': 'preact/compat/jsx-runtime',
|
||||
react: 'preact/compat',
|
||||
'react-dom/test-utils': 'preact/test-utils',
|
||||
'react-dom': 'preact/compat',
|
||||
]
|
||||
},
|
||||
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]',
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
return config
|
||||
},
|
||||
})
|
||||
config.module.rules.push({
|
||||
test: /\.svg$/,
|
||||
use: ['@svgr/webpack'],
|
||||
})
|
||||
|
||||
if (!dev && !isServer) {
|
||||
// Replace React with Preact only in client production build
|
||||
Object.assign(config.resolve.alias, {
|
||||
'react/jsx-runtime.js': 'preact/compat/jsx-runtime',
|
||||
react: 'preact/compat',
|
||||
'react-dom/test-utils': 'preact/test-utils',
|
||||
'react-dom': 'preact/compat',
|
||||
})
|
||||
}
|
||||
|
||||
return config
|
||||
},
|
||||
})
|
||||
)
|
||||
|
|
18582
package-lock.json
generated
18582
package-lock.json
generated
File diff suppressed because it is too large
Load diff
20
package.json
20
package.json
|
@ -1,11 +1,14 @@
|
|||
{
|
||||
"name": "tailwind-nextjs-starter-blog",
|
||||
"version": "1.5.0",
|
||||
"version": "1.4.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "cross-env SOCKET=true node ./scripts/next-remote-watch.js ./data",
|
||||
"start": "next dev",
|
||||
"dev": "next dev",
|
||||
"build": "next build && node ./scripts/generate-sitemap",
|
||||
"build": "next build",
|
||||
"postbuild": "npm run sitemap && npm run rss",
|
||||
"sitemap": "cross-env NODE_OPTIONS='--experimental-json-modules' node ./scripts/generate-sitemap.mjs",
|
||||
"rss": "cross-env NODE_OPTIONS='--experimental-json-modules' node ./scripts/generate-rss.mjs",
|
||||
"serve": "next start",
|
||||
"analyze": "cross-env ANALYZE=true next build",
|
||||
"lint": "next lint --fix --dir pages --dir components --dir lib --dir layouts --dir scripts",
|
||||
|
@ -17,12 +20,14 @@
|
|||
"@tailwindcss/forms": "^0.4.0",
|
||||
"@tailwindcss/typography": "^0.5.0",
|
||||
"autoprefixer": "^10.4.0",
|
||||
"contentlayer": "0.0.34",
|
||||
"esbuild": "^0.13.13",
|
||||
"github-slugger": "^1.3.0",
|
||||
"gray-matter": "^4.0.2",
|
||||
"image-size": "1.0.0",
|
||||
"mdx-bundler": "^8.0.0",
|
||||
"next": "12.0.9",
|
||||
"next-contentlayer": "0.0.34",
|
||||
"next-themes": "^0.0.14",
|
||||
"postcss": "^8.4.5",
|
||||
"preact": "^10.6.2",
|
||||
|
@ -35,6 +40,7 @@
|
|||
"rehype-preset-minify": "6.0.0",
|
||||
"rehype-prism-plus": "^1.1.3",
|
||||
"rehype-slug": "^5.0.0",
|
||||
"remark": "^14.0.2",
|
||||
"remark-footnotes": "^4.0.1",
|
||||
"remark-gfm": "^3.0.1",
|
||||
"remark-math": "^5.1.1",
|
||||
|
@ -46,6 +52,10 @@
|
|||
"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",
|
||||
|
@ -57,11 +67,9 @@
|
|||
"husky": "^6.0.0",
|
||||
"inquirer": "^8.1.1",
|
||||
"lint-staged": "^11.0.0",
|
||||
"next-remote-watch": "^1.0.0",
|
||||
"prettier": "^2.5.1",
|
||||
"prettier-plugin-tailwindcss": "^0.1.4",
|
||||
"socket.io": "^4.4.0",
|
||||
"socket.io-client": "^4.4.0"
|
||||
"typescript": "4.3.5"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.+(js|jsx|ts|tsx)": [
|
||||
|
|
|
@ -4,23 +4,19 @@ import '@/css/prism.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'
|
||||
import Analytics from '@/components/analytics'
|
||||
import LayoutWrapper from '@/components/LayoutWrapper'
|
||||
import { ClientReload } from '@/components/ClientReload'
|
||||
|
||||
const isDevelopment = process.env.NODE_ENV === 'development'
|
||||
const isSocket = process.env.SOCKET
|
||||
|
||||
export default function App({ Component, pageProps }) {
|
||||
export default function App({ Component, pageProps }: AppProps) {
|
||||
return (
|
||||
<ThemeProvider attribute="class" defaultTheme={siteMetadata.theme}>
|
||||
<Head>
|
||||
<meta content="width=device-width, initial-scale=1" name="viewport" />
|
||||
</Head>
|
||||
{isDevelopment && isSocket && <ClientReload />}
|
||||
<Analytics />
|
||||
<LayoutWrapper>
|
||||
<Component {...pageProps} />
|
|
@ -1,5 +1,12 @@
|
|||
import Document, { Html, Head, Main, NextScript } from 'next/document'
|
||||
|
||||
class MyDocument extends Document {
|
||||
// static async getInitialProps(ctx: DocumentContext) {
|
||||
// const initialProps = await Document.getInitialProps(ctx)
|
||||
|
||||
// return initialProps
|
||||
// }
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Html lang="en" className="scroll-smooth">
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
14
pages/about.tsx
Normal file
14
pages/about.tsx
Normal file
|
@ -0,0 +1,14 @@
|
|||
import { MDXLayoutRenderer } from '@/components/MDXComponents'
|
||||
import { InferGetStaticPropsType } from 'next'
|
||||
import { allAuthors } from '.contentlayer/data'
|
||||
|
||||
const DEFAULT_LAYOUT = 'AuthorLayout'
|
||||
|
||||
export const getStaticProps = async () => {
|
||||
const author = allAuthors.find((p) => p.slug === 'default')
|
||||
return { props: { author } }
|
||||
}
|
||||
|
||||
export default function About({ author }: InferGetStaticPropsType<typeof getStaticProps>) {
|
||||
return <MDXLayoutRenderer layout={author.layout || DEFAULT_LAYOUT} content={author} />
|
||||
}
|
|
@ -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' })
|
|
@ -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) {
|
|
@ -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' })
|
|
@ -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',
|
||||
})
|
|
@ -1,12 +1,14 @@
|
|||
import { getAllFilesFrontMatter } from '@/lib/mdx'
|
||||
import siteMetadata from '@/data/siteMetadata'
|
||||
import ListLayout from '@/layouts/ListLayout'
|
||||
import { PageSEO } from '@/components/SEO'
|
||||
import * as temp from '@/lib/utils/temp'
|
||||
import { InferGetStaticPropsType } from 'next'
|
||||
import { allBlogs } from '.contentlayer/data'
|
||||
|
||||
export const POSTS_PER_PAGE = 5
|
||||
|
||||
export async function getStaticProps() {
|
||||
const posts = await getAllFilesFrontMatter('blog')
|
||||
export const getStaticProps = async () => {
|
||||
const posts = temp.sortedBlogPost(allBlogs)
|
||||
const initialDisplayPosts = posts.slice(0, POSTS_PER_PAGE)
|
||||
const pagination = {
|
||||
currentPage: 1,
|
||||
|
@ -16,7 +18,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} />
|
|
@ -1,70 +0,0 @@
|
|||
import fs from 'fs'
|
||||
import PageTitle from '@/components/PageTitle'
|
||||
import generateRss from '@/lib/generate-rss'
|
||||
import { MDXLayoutRenderer } from '@/components/MDXComponents'
|
||||
import { formatSlug, getAllFilesFrontMatter, getFileBySlug, getFiles } from '@/lib/mdx'
|
||||
|
||||
const DEFAULT_LAYOUT = 'PostLayout'
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const posts = getFiles('blog')
|
||||
return {
|
||||
paths: posts.map((p) => ({
|
||||
params: {
|
||||
slug: formatSlug(p).split('/'),
|
||||
},
|
||||
})),
|
||||
fallback: false,
|
||||
}
|
||||
}
|
||||
|
||||
export async function getStaticProps({ params }) {
|
||||
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 authorList = post.frontMatter.authors || ['default']
|
||||
const authorPromise = authorList.map(async (author) => {
|
||||
const authorResults = await getFileBySlug('authors', [author])
|
||||
return authorResults.frontMatter
|
||||
})
|
||||
const authorDetails = await Promise.all(authorPromise)
|
||||
|
||||
// rss
|
||||
if (allPosts.length > 0) {
|
||||
const rss = generateRss(allPosts)
|
||||
fs.writeFileSync('./public/feed.xml', rss)
|
||||
}
|
||||
|
||||
return { props: { post, authorDetails, prev, next } }
|
||||
}
|
||||
|
||||
export default function Blog({ post, authorDetails, prev, next }) {
|
||||
const { mdxSource, toc, frontMatter } = post
|
||||
|
||||
return (
|
||||
<>
|
||||
{frontMatter.draft !== true ? (
|
||||
<MDXLayoutRenderer
|
||||
layout={frontMatter.layout || DEFAULT_LAYOUT}
|
||||
toc={toc}
|
||||
mdxSource={mdxSource}
|
||||
frontMatter={frontMatter}
|
||||
authorDetails={authorDetails}
|
||||
prev={prev}
|
||||
next={next}
|
||||
/>
|
||||
) : (
|
||||
<div className="mt-24 text-center">
|
||||
<PageTitle>
|
||||
Under Construction{' '}
|
||||
<span role="img" aria-label="roadwork sign">
|
||||
🚧
|
||||
</span>
|
||||
</PageTitle>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
72
pages/blog/[...slug].tsx
Normal file
72
pages/blog/[...slug].tsx
Normal file
|
@ -0,0 +1,72 @@
|
|||
import PageTitle from '@/components/PageTitle'
|
||||
import { MDXLayoutRenderer } from '@/components/MDXComponents'
|
||||
import * as temp from '@/lib/utils/temp'
|
||||
import { InferGetStaticPropsType } from 'next'
|
||||
import { allBlogs, allAuthors } from '.contentlayer/data'
|
||||
import type { Blog } from '.contentlayer/types'
|
||||
|
||||
const DEFAULT_LAYOUT = 'PostLayout'
|
||||
|
||||
export async function getStaticPaths() {
|
||||
return {
|
||||
paths: allBlogs.map((p) => ({ params: { slug: p.slug.split('/') } })),
|
||||
fallback: false,
|
||||
}
|
||||
}
|
||||
|
||||
export const getStaticProps = async ({ params }) => {
|
||||
const slug = (params.slug as string[]).join('/')
|
||||
const sortedPosts = temp.sortedBlogPost(allBlogs)
|
||||
const postIndex = sortedPosts.findIndex((p) => p.slug === slug)
|
||||
// TODO: Refactor this extraction of coreContent
|
||||
const prevContent = sortedPosts[postIndex + 1] || null
|
||||
const prev = prevContent ? temp.coreBlog(prevContent) : null
|
||||
const nextContent = sortedPosts[postIndex - 1] || null
|
||||
const next = nextContent ? temp.coreBlog(nextContent) : null
|
||||
const post = sortedPosts.find((p) => p.slug === slug)
|
||||
const authorList = post.authors || ['default']
|
||||
const authorDetails = authorList.map((author) => {
|
||||
const authorResults = allAuthors.find((p) => p.slug === author)
|
||||
return authorResults
|
||||
})
|
||||
|
||||
return {
|
||||
props: {
|
||||
post,
|
||||
authorDetails,
|
||||
prev,
|
||||
next,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default function Blog({
|
||||
post,
|
||||
authorDetails,
|
||||
prev,
|
||||
next,
|
||||
}: InferGetStaticPropsType<typeof getStaticProps>) {
|
||||
return (
|
||||
<>
|
||||
{'draft' in post && post.draft !== true ? (
|
||||
<MDXLayoutRenderer
|
||||
layout={post.layout || DEFAULT_LAYOUT}
|
||||
toc={post.toc}
|
||||
content={post}
|
||||
authorDetails={authorDetails}
|
||||
prev={prev}
|
||||
next={next}
|
||||
/>
|
||||
) : (
|
||||
<div className="mt-24 text-center">
|
||||
<PageTitle>
|
||||
Under Construction{' '}
|
||||
<span role="img" aria-label="roadwork sign">
|
||||
🚧
|
||||
</span>
|
||||
</PageTitle>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -1,11 +1,13 @@
|
|||
import { PageSEO } from '@/components/SEO'
|
||||
import siteMetadata from '@/data/siteMetadata'
|
||||
import { getAllFilesFrontMatter } from '@/lib/mdx'
|
||||
import ListLayout from '@/layouts/ListLayout'
|
||||
import * as temp from '@/lib/utils/temp'
|
||||
import { POSTS_PER_PAGE } from '../../blog'
|
||||
import { InferGetStaticPropsType } from 'next'
|
||||
import { allBlogs } from '.contentlayer/data'
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const totalPosts = await getAllFilesFrontMatter('blog')
|
||||
export const getStaticPaths = async () => {
|
||||
const totalPosts = allBlogs
|
||||
const totalPages = Math.ceil(totalPosts.length / POSTS_PER_PAGE)
|
||||
const paths = Array.from({ length: totalPages }, (_, i) => ({
|
||||
params: { page: (i + 1).toString() },
|
||||
|
@ -17,12 +19,12 @@ export async function getStaticPaths() {
|
|||
}
|
||||
}
|
||||
|
||||
export async function getStaticProps(context) {
|
||||
export const getStaticProps = async (context) => {
|
||||
const {
|
||||
params: { page },
|
||||
} = context
|
||||
const posts = await getAllFilesFrontMatter('blog')
|
||||
const pageNumber = parseInt(page)
|
||||
const posts = temp.coreAllBlog(allBlogs)
|
||||
const pageNumber = parseInt(page as string)
|
||||
const initialDisplayPosts = posts.slice(
|
||||
POSTS_PER_PAGE * (pageNumber - 1),
|
||||
POSTS_PER_PAGE * pageNumber
|
||||
|
@ -41,7 +43,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} />
|
|
@ -2,20 +2,23 @@ import Link from '@/components/Link'
|
|||
import { PageSEO } from '@/components/SEO'
|
||||
import Tag from '@/components/Tag'
|
||||
import siteMetadata from '@/data/siteMetadata'
|
||||
import { getAllFilesFrontMatter } from '@/lib/mdx'
|
||||
import formatDate from '@/lib/utils/formatDate'
|
||||
|
||||
import * as temp from '@/lib/utils/temp'
|
||||
import { InferGetStaticPropsType } from 'next'
|
||||
import NewsletterForm from '@/components/NewsletterForm'
|
||||
import { allBlogs } from '.contentlayer/data'
|
||||
|
||||
const MAX_DISPLAY = 5
|
||||
|
||||
export async function getStaticProps() {
|
||||
const posts = await getAllFilesFrontMatter('blog')
|
||||
export const getStaticProps = async () => {
|
||||
// TODO: move computation to get only the essential frontmatter to contentlayer.config
|
||||
const sortedPosts = temp.sortedBlogPost(allBlogs)
|
||||
const posts = temp.coreAllBlog(sortedPosts)
|
||||
|
||||
return { props: { posts } }
|
||||
}
|
||||
|
||||
export default function Home({ posts }) {
|
||||
export default function Home({ posts }: InferGetStaticPropsType<typeof getStaticProps>) {
|
||||
return (
|
||||
<>
|
||||
<PageSEO title={siteMetadata.title} description={siteMetadata.description} />
|
||||
|
@ -30,8 +33,8 @@ export default function Home({ posts }) {
|
|||
</div>
|
||||
<ul className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{!posts.length && 'No posts found.'}
|
||||
{posts.slice(0, MAX_DISPLAY).map((frontMatter) => {
|
||||
const { slug, date, title, summary, tags } = frontMatter
|
||||
{posts.slice(0, MAX_DISPLAY).map((post) => {
|
||||
const { slug, date, title, summary, tags } = post
|
||||
return (
|
||||
<li key={slug} className="py-12">
|
||||
<article>
|
|
@ -2,16 +2,20 @@ import Link from '@/components/Link'
|
|||
import { PageSEO } from '@/components/SEO'
|
||||
import Tag from '@/components/Tag'
|
||||
import siteMetadata from '@/data/siteMetadata'
|
||||
import { getAllTags } from '@/lib/tags'
|
||||
import kebabCase from '@/lib/utils/kebabCase'
|
||||
import * as temp from '@/lib/utils/temp'
|
||||
import { GetStaticProps, InferGetStaticPropsType } from 'next'
|
||||
import { allBlogs } from '.contentlayer/data'
|
||||
|
||||
export async function getStaticProps() {
|
||||
const tags = await getAllTags('blog')
|
||||
// TODO: refactor into contentlayer once compute over all docs is enabled
|
||||
|
||||
export const getStaticProps: GetStaticProps<{ tags: Record<string, number> }> = async () => {
|
||||
const tags = await temp.getAllTags(allBlogs)
|
||||
|
||||
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 (
|
||||
<>
|
|
@ -1,55 +0,0 @@
|
|||
import { TagSEO } from '@/components/SEO'
|
||||
import siteMetadata from '@/data/siteMetadata'
|
||||
import ListLayout from '@/layouts/ListLayout'
|
||||
import generateRss from '@/lib/generate-rss'
|
||||
import { getAllFilesFrontMatter } from '@/lib/mdx'
|
||||
import { getAllTags } from '@/lib/tags'
|
||||
import kebabCase from '@/lib/utils/kebabCase'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
const root = process.cwd()
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const tags = await getAllTags('blog')
|
||||
|
||||
return {
|
||||
paths: Object.keys(tags).map((tag) => ({
|
||||
params: {
|
||||
tag,
|
||||
},
|
||||
})),
|
||||
fallback: false,
|
||||
}
|
||||
}
|
||||
|
||||
export async function getStaticProps({ params }) {
|
||||
const allPosts = await getAllFilesFrontMatter('blog')
|
||||
const filteredPosts = allPosts.filter(
|
||||
(post) => post.draft !== true && post.tags.map((t) => kebabCase(t)).includes(params.tag)
|
||||
)
|
||||
|
||||
// rss
|
||||
if (filteredPosts.length > 0) {
|
||||
const rss = generateRss(filteredPosts, `tags/${params.tag}/feed.xml`)
|
||||
const rssPath = path.join(root, 'public', 'tags', params.tag)
|
||||
fs.mkdirSync(rssPath, { recursive: true })
|
||||
fs.writeFileSync(path.join(rssPath, 'feed.xml'), rss)
|
||||
}
|
||||
|
||||
return { props: { posts: filteredPosts, tag: params.tag } }
|
||||
}
|
||||
|
||||
export default function Tag({ posts, tag }) {
|
||||
// Capitalize first letter and convert space to dash
|
||||
const title = tag[0].toUpperCase() + tag.split(' ').join('-').slice(1)
|
||||
return (
|
||||
<>
|
||||
<TagSEO
|
||||
title={`${tag} - ${siteMetadata.author}`}
|
||||
description={`${tag} tags - ${siteMetadata.author}`}
|
||||
/>
|
||||
<ListLayout posts={posts} title={title} />
|
||||
</>
|
||||
)
|
||||
}
|
43
pages/tags/[tag].tsx
Normal file
43
pages/tags/[tag].tsx
Normal file
|
@ -0,0 +1,43 @@
|
|||
import { TagSEO } from '@/components/SEO'
|
||||
import siteMetadata from '@/data/siteMetadata'
|
||||
import ListLayout from '@/layouts/ListLayout'
|
||||
import kebabCase from '@/lib/utils/kebabCase'
|
||||
import * as temp from '@/lib/utils/temp'
|
||||
import { InferGetStaticPropsType } from 'next'
|
||||
import { allBlogs } from '.contentlayer/data'
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const tags = await temp.getAllTags(allBlogs)
|
||||
|
||||
return {
|
||||
paths: Object.keys(tags).map((tag) => ({
|
||||
params: {
|
||||
tag,
|
||||
},
|
||||
})),
|
||||
fallback: false,
|
||||
}
|
||||
}
|
||||
|
||||
export const getStaticProps = async (context) => {
|
||||
const tag = context.params.tag as string
|
||||
const filteredPosts = allBlogs.filter(
|
||||
(post) => post.draft !== true && post.tags.map((t) => kebabCase(t)).includes(tag)
|
||||
)
|
||||
|
||||
return { props: { posts: filteredPosts, 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.title}`}
|
||||
description={`${tag} tags - ${siteMetadata.author}`}
|
||||
/>
|
||||
<ListLayout posts={posts} title={title} />
|
||||
</>
|
||||
)
|
||||
}
|
79
scripts/generate-rss.mjs
Normal file
79
scripts/generate-rss.mjs
Normal file
|
@ -0,0 +1,79 @@
|
|||
import { writeFileSync, mkdirSync } from 'fs'
|
||||
import path from 'path'
|
||||
import GithubSlugger from 'github-slugger'
|
||||
import { escape } from './htmlEscaper.mjs'
|
||||
import siteMetadata from '../data/siteMetadata.js'
|
||||
import { allBlogs } from '.contentlayer/data'
|
||||
|
||||
// TODO: refactor into contentlayer once compute over all docs is enabled
|
||||
export async function getAllTags() {
|
||||
const tagCount = {}
|
||||
// Iterate through each post, putting all found tags into `tags`
|
||||
allBlogs.forEach((file) => {
|
||||
if (file.tags && file.draft !== true) {
|
||||
file.tags.forEach((tag) => {
|
||||
const formattedTag = GithubSlugger.slug(tag)
|
||||
if (formattedTag in tagCount) {
|
||||
tagCount[formattedTag] += 1
|
||||
} else {
|
||||
tagCount[formattedTag] = 1
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return tagCount
|
||||
}
|
||||
|
||||
const generateRssItem = (post) => `
|
||||
<item>
|
||||
<guid>${siteMetadata.siteUrl}/blog/${post.slug}</guid>
|
||||
<title>${escape(post.title)}</title>
|
||||
<link>${siteMetadata.siteUrl}/blog/${post.slug}</link>
|
||||
${post.summary && `<description>${escape(post.summary)}</description>`}
|
||||
<pubDate>${new Date(post.date).toUTCString()}</pubDate>
|
||||
<author>${siteMetadata.email} (${siteMetadata.author})</author>
|
||||
${post.tags && post.tags.map((t) => `<category>${t}</category>`).join('')}
|
||||
</item>
|
||||
`
|
||||
|
||||
const generateRss = (posts, page = 'feed.xml') => `
|
||||
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
|
||||
<channel>
|
||||
<title>${escape(siteMetadata.title)}</title>
|
||||
<link>${siteMetadata.siteUrl}/blog</link>
|
||||
<description>${escape(siteMetadata.description)}</description>
|
||||
<language>${siteMetadata.language}</language>
|
||||
<managingEditor>${siteMetadata.email} (${siteMetadata.author})</managingEditor>
|
||||
<webMaster>${siteMetadata.email} (${siteMetadata.author})</webMaster>
|
||||
<lastBuildDate>${new Date(posts[0].date).toUTCString()}</lastBuildDate>
|
||||
<atom:link href="${siteMetadata.siteUrl}/${page}" rel="self" type="application/rss+xml"/>
|
||||
${posts.map(generateRssItem).join('')}
|
||||
</channel>
|
||||
</rss>
|
||||
`
|
||||
|
||||
async function generate() {
|
||||
// RSS for blog post
|
||||
if (allBlogs.length > 0) {
|
||||
const rss = generateRss(allBlogs)
|
||||
writeFileSync('./public/feed.xml', rss)
|
||||
}
|
||||
|
||||
// RSS for tags
|
||||
// TODO: use AllTags from contentlayer when computed docs is ready
|
||||
if (allBlogs.length > 0) {
|
||||
const tags = await getAllTags()
|
||||
for (const tag of Object.keys(tags)) {
|
||||
const filteredPosts = allBlogs.filter(
|
||||
(post) => post.draft !== true && post.tags.map((t) => GithubSlugger.slug(t)).includes(tag)
|
||||
)
|
||||
const rss = generateRss(filteredPosts, `tags/${tag}/feed.xml`)
|
||||
const rssPath = path.join('public', 'tags', tag)
|
||||
mkdirSync(rssPath, { recursive: true })
|
||||
writeFileSync(path.join(rssPath, 'feed.xml'), rss)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
generate()
|
|
@ -1,49 +1,34 @@
|
|||
const fs = require('fs')
|
||||
const globby = require('globby')
|
||||
const matter = require('gray-matter')
|
||||
const prettier = require('prettier')
|
||||
const siteMetadata = require('../data/siteMetadata')
|
||||
import { writeFileSync } from 'fs'
|
||||
import globby from 'globby'
|
||||
import prettier from 'prettier'
|
||||
import siteMetadata from '../data/siteMetadata.js'
|
||||
import { allBlogs } from '.contentlayer/data'
|
||||
|
||||
;(async () => {
|
||||
async function generate() {
|
||||
const prettierConfig = await prettier.resolveConfig('./.prettierrc.js')
|
||||
const contentPages = allBlogs.map((x) => `/${x._raw.flattenedPath}`)
|
||||
const pages = await globby([
|
||||
'pages/*.js',
|
||||
'pages/*.tsx',
|
||||
'data/blog/**/*.mdx',
|
||||
'data/blog/**/*.md',
|
||||
'pages/*.{js|tsx}',
|
||||
'public/tags/**/*.xml',
|
||||
'!pages/_*.js',
|
||||
'!pages/_*.tsx',
|
||||
'!pages/_*.{js|tsx}',
|
||||
'!pages/api',
|
||||
'!pages/404.{js|tsx}',
|
||||
])
|
||||
|
||||
const sitemap = `
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
${pages
|
||||
.concat(contentPages)
|
||||
.map((page) => {
|
||||
// Exclude drafts from the sitemap
|
||||
if (page.search('.md') >= 1 && fs.existsSync(page)) {
|
||||
const source = fs.readFileSync(page, 'utf8')
|
||||
const fm = matter(source)
|
||||
if (fm.data.draft) {
|
||||
return
|
||||
}
|
||||
}
|
||||
const path = page
|
||||
.replace('pages/', '/')
|
||||
.replace('data/blog', '/blog')
|
||||
.replace('public/', '/')
|
||||
.replace('.js', '')
|
||||
.replace('.tsx', '')
|
||||
.replace('.mdx', '')
|
||||
.replace('.md', '')
|
||||
.replace('/feed.xml', '')
|
||||
const route = path === '/index' ? '' : path
|
||||
|
||||
if (page.search('pages/404.') > -1 || page.search(`pages/blog/[...slug].`) > -1) {
|
||||
return
|
||||
}
|
||||
return `
|
||||
<url>
|
||||
<loc>${siteMetadata.siteUrl}${route}</loc>
|
||||
|
@ -59,6 +44,7 @@ const siteMetadata = require('../data/siteMetadata')
|
|||
parser: 'html',
|
||||
})
|
||||
|
||||
// eslint-disable-next-line no-sync
|
||||
fs.writeFileSync('public/sitemap.xml', formatted)
|
||||
})()
|
||||
writeFileSync('public/sitemap.xml', formatted)
|
||||
}
|
||||
|
||||
generate()
|
|
@ -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,6 +10,10 @@ const esca = {
|
|||
"'": ''',
|
||||
'"': '"',
|
||||
}
|
||||
/**
|
||||
*
|
||||
* @param {keyof typeof esca} m
|
||||
*/
|
||||
const pe = (m) => esca[m]
|
||||
|
||||
/**
|
|
@ -1,120 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
// 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
|
||||
|
||||
const chalk = require('chalk')
|
||||
const chokidar = require('chokidar')
|
||||
const program = require('commander')
|
||||
const http = require('http')
|
||||
const SocketIO = require('socket.io')
|
||||
const express = require('express')
|
||||
const spawn = require('child_process').spawn
|
||||
const next = require('next')
|
||||
const path = require('path')
|
||||
const { parse } = require('url')
|
||||
|
||||
const pkg = require('../package.json')
|
||||
|
||||
const defaultWatchEvent = 'change'
|
||||
|
||||
program.storeOptionsAsProperties().version(pkg.version)
|
||||
program
|
||||
.option('-r, --root [dir]', 'root directory of your nextjs app')
|
||||
.option('-s, --script [path]', 'path to the script you want to trigger on a watcher event', false)
|
||||
.option('-c, --command [cmd]', 'command to execute on a watcher event', false)
|
||||
.option(
|
||||
'-e, --event [name]',
|
||||
`name of event to watch, defaults to ${defaultWatchEvent}`,
|
||||
defaultWatchEvent
|
||||
)
|
||||
.option('-p, --polling [name]', `use polling for the watcher, defaults to false`, false)
|
||||
.parse(process.argv)
|
||||
|
||||
const shell = process.env.SHELL
|
||||
const app = next({ dev: true, dir: program.root || process.cwd() })
|
||||
const port = parseInt(process.env.PORT, 10) || 3000
|
||||
const handle = app.getRequestHandler()
|
||||
|
||||
app.prepare().then(() => {
|
||||
// if directories are provided, watch them for changes and trigger reload
|
||||
if (program.args.length > 0) {
|
||||
chokidar
|
||||
.watch(program.args, { usePolling: Boolean(program.polling) })
|
||||
.on(program.event, async (filePathContext, eventContext = defaultWatchEvent) => {
|
||||
// Emit changes via socketio
|
||||
io.sockets.emit('reload', filePathContext)
|
||||
app.server.hotReloader.send('building')
|
||||
|
||||
if (program.command) {
|
||||
// Use spawn here so that we can pipe stdio from the command without buffering
|
||||
spawn(
|
||||
shell,
|
||||
[
|
||||
'-c',
|
||||
program.command
|
||||
.replace(/\{event\}/gi, filePathContext)
|
||||
.replace(/\{path\}/gi, eventContext),
|
||||
],
|
||||
{
|
||||
stdio: 'inherit',
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (program.script) {
|
||||
try {
|
||||
// find the path of your --script script
|
||||
const scriptPath = path.join(process.cwd(), program.script.toString())
|
||||
|
||||
// require your --script script
|
||||
const executeFile = require(scriptPath)
|
||||
|
||||
// run the exported function from your --script script
|
||||
executeFile(filePathContext, eventContext)
|
||||
} catch (e) {
|
||||
console.error('Remote script failed')
|
||||
console.error(e)
|
||||
return e
|
||||
}
|
||||
}
|
||||
|
||||
app.server.hotReloader.send('reloadPage')
|
||||
})
|
||||
}
|
||||
|
||||
// create an express server
|
||||
const expressApp = express()
|
||||
const server = http.createServer(expressApp)
|
||||
|
||||
// watch files with socketIO
|
||||
const io = SocketIO(server)
|
||||
|
||||
// special handling for mdx reload route
|
||||
const reloadRoute = express.Router()
|
||||
reloadRoute.use(express.json())
|
||||
reloadRoute.all('/', (req, res) => {
|
||||
// log message if present
|
||||
const msg = req.body.message
|
||||
const color = req.body.color
|
||||
msg && console.log(color ? chalk[color](msg) : msg)
|
||||
|
||||
// reload the nextjs app
|
||||
app.server.hotReloader.send('building')
|
||||
app.server.hotReloader.send('reloadPage')
|
||||
res.end('Reload initiated')
|
||||
})
|
||||
|
||||
expressApp.use('/__next_reload', reloadRoute)
|
||||
|
||||
// handle all other routes with next.js
|
||||
expressApp.all('*', (req, res) => handle(req, res, parse(req.url, true)))
|
||||
|
||||
// fire it up
|
||||
server.listen(port, (err) => {
|
||||
if (err) throw err
|
||||
console.log(`> Ready on http://localhost:${port}`)
|
||||
})
|
||||
})
|
|
@ -1,11 +1,12 @@
|
|||
// @ts-check
|
||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||
|
||||
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'],
|
||||
content: ['./pages/**/*.tsx', './components/**/*.tsx', './layouts/**/*.tsx', './lib/**/*.ts'],
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
|
@ -23,7 +24,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
28
tsconfig.json
Normal 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"]
|
||||
}
|
11
types/AuthorFrontMatter.ts
Normal file
11
types/AuthorFrontMatter.ts
Normal 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
|
||||
}
|
13
types/PostFrontMatter.ts
Normal file
13
types/PostFrontMatter.ts
Normal file
|
@ -0,0 +1,13 @@
|
|||
export type PostFrontMatter = {
|
||||
title: string
|
||||
date: string
|
||||
tags: string[]
|
||||
lastmod?: string
|
||||
draft?: boolean
|
||||
summary?: string
|
||||
images?: string[]
|
||||
authors?: string[]
|
||||
layout?: string
|
||||
slug: string
|
||||
fileName?: string
|
||||
}
|
5
types/Toc.ts
Normal file
5
types/Toc.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
export type Toc = {
|
||||
value: string
|
||||
depth: number
|
||||
url: string
|
||||
}[]
|
Loading…
Reference in a new issue