Compare commits

..

54 commits

Author SHA1 Message Date
Timothy Lin
59256180de fix: prism token style clash with tailwind 2022-02-05 12:47:44 +08:00
Timothy Lin
a5244d2b4b prettier tailwind and refactor temp contentlayer functions 2022-02-04 19:59:53 +08:00
Timothy Lin
a9995e2cb7 sync with v1.5.0 2022-02-02 13:50:36 +08:00
Timothy Lin
91a93b6a4a fix: code title 2022-01-26 17:55:17 +08:00
Timothy Lin
933c82c400 use contentlayer to generate rss and sitemap 2022-01-26 17:02:43 +08:00
Timothy Lin
d63031a772 add back toc 2022-01-26 17:01:24 +08:00
Timothy Lin
90c9b85d44 order posts 2022-01-26 17:00:39 +08:00
Timothy Lin
b5a6904fef parse toc with contentlayer 2022-01-19 18:18:50 +08:00
Timothy Lin
f2fc9d9060 use contentlayer for tags 2022-01-19 18:18:12 +08:00
Timothy Lin
dd827ee720 remove next-remote-watch 2022-01-19 18:17:38 +08:00
Timothy Lin
099c5eccfb wip use contentlayer 2022-01-16 18:30:39 +08:00
Timothy Lin
954dda9c3b sync with v1.4.2 2022-01-07 17:15:56 +08:00
Timothy Lin
01ebe81a79 sync with v1.4.1 2021-12-29 14:46:35 +08:00
Timothy Lin
915ada7193 chore: sync with v1.4.0 2021-12-16 15:11:21 +08:00
Timothy Lin
44d16504a3 chore: sync with v1.2.0 2021-11-02 17:20:31 +08:00
Timothy
b4da90f8ef
Update package.json 2021-09-17 22:11:27 +08:00
Timothy
1b597627ce
Merge pull request #246 from ryands17/typescript
update typescript branch to match master
2021-09-17 22:10:35 +08:00
Ryan Dsouza
168355dc42 update typescript branch to match master 2021-09-17 00:51:28 +05:30
Timothy
04af88b954
Merge pull request #192 from timlrx/ts/update
chore: sync with v1.1.0
2021-08-15 23:45:34 +08:00
Timothy Lin
16e4eaee28 chore: sync with v1.1.0 2021-08-15 23:43:19 +08:00
Timothy
722a41d9c5
Merge pull request #171 from timlrx/ts/update
chore: sync with v1.0.0
2021-08-08 16:41:22 +08:00
Timothy Lin
de3de08bbc fix: use tsx not jsx 2021-08-08 16:37:18 +08:00
Timothy Lin
4ce77b36c9 chore: sync with v1.0.0 2021-08-08 16:30:49 +08:00
Timothy
96b5e1b4d3
Merge pull request #166 from timlrx/ts/update
chore: sync with v1.0.0-canary.2
2021-08-07 00:07:23 +08:00
Timothy Lin
742bfdd0e1 fix: use tsx instead of js 2021-08-07 00:03:52 +08:00
Timothy Lin
4f85c2ccad chore: sync with v1.0.0-canary.2 2021-08-06 23:57:48 +08:00
Timothy Lin
1be25408ee chore: upgrade mdx-bundler 2021-07-25 19:12:35 +08:00
Timothy
bd569ba6bc
Merge pull request #147 from timlrx/ts/update
chore: sync with v1.0.0-canary.1
2021-07-25 19:11:27 +08:00
Timothy Lin
c979da10e1 chore: sync with v1.0.0-canary.1 2021-07-24 14:53:22 +08:00
Timothy
c4267ea934
Merge pull request #112 from GautierArcin/typescript
Add typings to lib
2021-07-18 16:34:35 +08:00
Gautier Arcin
c6b0f479a9 chore: removed unused variable 2021-07-16 22:38:13 +02:00
Gautier Arcin
afeec47c6e chore: minor typing 2021-07-16 22:37:39 +02:00
Juliano Farias
7eb7aac36d Merge branch 'v1' into typescript 2021-07-16 19:52:23 +02:00
Timothy Lin
030d630189 chore: sync with v1.0.0-canary.0 js 2021-07-13 23:18:01 +08:00
Timothy
b2755d3fa2
Merge pull request #109 from rsipakov/ts-feed-xml
File index.xml renamed to feed.xml to avoid a 404 page in the development stage
2021-07-11 23:15:33 +08:00
Rostyslav
a634839ce6 rename index.xml to feed.xml 2021-07-10 03:15:10 -04:00
Rostyslav
4990ba98d8 File index.xml renamed to feed.xml to avoid a 404 page in the development stage 2021-07-10 02:58:23 -04:00
Rostyslav
08f0fb16e9 typescript 2021-07-09 19:55:16 -04:00
Rostyslav
86f6c14469 Update package-lock.json 2021-07-09 16:56:01 -04:00
Juliano Farias
2f2ae049cd
Merge pull request #99 from frontendwizard/v1-typescript 2021-07-07 09:19:47 +02:00
Juliano Farias
755fbfcfb1 docs: change quick start guide instructions for typescript branch 2021-07-07 09:17:52 +02:00
Juliano Farias
70668b65cb fix: improve mdx.ts types and fix build 2021-07-07 09:12:49 +02:00
Juliano Farias
81bc20bfc3 fix: update MDXComponents types 2021-07-05 18:07:01 +02:00
Juliano Farias
3ec5bd5996 Merge branch 'v1' into v1-typescript 2021-07-05 17:52:08 +02:00
Juliano Farias
41e9b02a2b make sure all pages infer types from getStaticToProps 2021-06-30 14:20:27 +02:00
Juliano Farias
1c87e48735 fix: improve about page typing and pass frontMatter down 2021-06-30 13:44:52 +02:00
Juliano Farias
0870d15994 chore: remove console.log 2021-06-30 13:35:18 +02:00
Juliano Farias
68bda2ba72 chore: remove console.log 2021-06-30 13:34:42 +02:00
Juliano Farias
a9becc7c83 fix: cast params.tag as string 2021-06-30 13:32:39 +02:00
Juliano Farias
930860dc52 chore: improve types on blog pages 2021-06-30 13:30:12 +02:00
Juliano Farias
c673d4ea38 chore: make pagination data numbers instead of string 2021-06-30 12:11:25 +02:00
Juliano Farias
ece93b9ff4 chore: finish typing blog.tsx 2021-06-30 12:10:48 +02:00
Juliano Farias
4e5f6de1e9 fix typescript errors 2021-06-30 12:04:46 +02:00
Juliano Farias
c28652d823 refactor: move to typescript
Fix import on generate-rss.ts
2021-06-30 12:01:28 +02:00
95 changed files with 2864 additions and 17533 deletions

View file

@ -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=

View file

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

3
.github/FUNDING.yml vendored
View file

@ -1,3 +0,0 @@
# These are supported funding model platforms
github: timlrx

View file

@ -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.

View file

@ -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
View file

@ -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

View file

@ -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

View file

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

View file

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

View file

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

5
components/Image.tsx Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,8 +1,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'

View file

@ -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)

View file

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

View file

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

View file

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

View file

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

View file

@ -1,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 = () => {

View file

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

View file

@ -3,7 +3,11 @@ import { useTheme } from 'next-themes'
import siteMetadata from '@/data/siteMetadata'
const Giscus = ({ mapping }) => {
interface Props {
mapping: string
}
const Giscus = ({ mapping }: Props) => {
const [enableLoadComments, setEnabledLoadComments] = useState(true)
const { theme, resolvedTheme } = useTheme()
const commentsTheme =

View file

@ -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 =

View file

@ -1,5 +1,10 @@
import siteMetadata from '@/data/siteMetadata'
import dynamic from 'next/dynamic'
import { PostFrontMatter } from 'types/PostFrontMatter'
interface Props {
frontMatter: PostFrontMatter
}
const UtterancesComponent = dynamic(
() => {
@ -20,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
View 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,
],
},
})

View file

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

View file

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

View file

@ -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)

View file

@ -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 [
{

View file

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

View file

@ -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 (
<>

View file

@ -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">

View file

@ -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">

View file

@ -6,13 +6,22 @@ import siteMetadata from '@/data/siteMetadata'
import formatDate from '@/lib/utils/formatDate'
import Comments from '@/components/comments'
import ScrollTopAndComment from '@/components/ScrollTopAndComment'
import * 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 && (

View file

@ -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>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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
}

View file

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

View file

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

View file

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

22
lib/utils/htmlEscaper.ts Normal file
View file

@ -0,0 +1,22 @@
const { replace } = ''
// escape
const ca = /[&<>'"]/g
const esca = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
"'": '&#39;',
'"': '&quot;',
}
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)

View file

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

74
lib/utils/temp.ts Normal file
View 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
View file

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

View file

@ -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

File diff suppressed because it is too large Load diff

View file

@ -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)": [

View file

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

View file

@ -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">

View file

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

14
pages/about.tsx Normal file
View 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} />
}

View file

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

View file

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

View file

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

View file

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

View file

@ -1,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} />

View file

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

View file

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

View file

@ -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>

View file

@ -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 (
<>

View file

@ -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
View 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
View 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()

View file

@ -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()

View file

@ -1,7 +1,6 @@
const { replace } = ''
// escape
const es = /&(?:amp|#38|lt|#60|gt|#62|apos|#39|quot|#34);/g
const ca = /[&<>'"]/g
const esca = {
@ -11,6 +10,10 @@ const esca = {
"'": '&#39;',
'"': '&quot;',
}
/**
*
* @param {keyof typeof esca} m
*/
const pe = (m) => esca[m]
/**

View file

@ -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}`)
})
})

View file

@ -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
View file

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

View file

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

13
types/PostFrontMatter.ts Normal file
View 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
View file

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