Compare commits

...

55 commits

Author SHA1 Message Date
Timothy Lin
0c181b2004 upgrade to contentlayer v0.0.35-dev.0 2022-02-08 22:38:51 +08:00
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
101 changed files with 7956 additions and 5464 deletions

22
.env.example Normal file
View file

@ -0,0 +1,22 @@
NEXT_PUBLIC_GISCUS_REPO=
NEXT_PUBLIC_GISCUS_REPOSITORY_ID=
NEXT_PUBLIC_GISCUS_CATEGORY=
NEXT_PUBLIC_GISCUS_CATEGORY_ID=
NEXT_PUBLIC_UTTERANCES_REPO=
NEXT_PUBLIC_DISQUS_SHORTNAME=
MAILCHIMP_API_KEY=
MAILCHIMP_API_SERVER=
MAILCHIMP_AUDIENCE_ID=
BUTTONDOWN_API_URL=https://api.buttondown.email/v1/
BUTTONDOWN_API_KEY=
CONVERTKIT_API_URL=https://api.convertkit.com/v3/
CONVERTKIT_API_KEY=
// curl https://api.convertkit.com/v3/forms?api_key=<your_public_api_key> to get your form ID
CONVERTKIT_FORM_ID=
KLAVIYO_API_KEY=
KLAVIYO_LIST_ID=

View file

@ -1,13 +1,17 @@
module.exports = {
root: true,
parser: '@typescript-eslint/parser',
env: {
browser: true,
amd: true,
node: true,
es6: true,
},
plugins: ['@typescript-eslint'],
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/eslint-recommended',
'plugin:@typescript-eslint/recommended',
'plugin:jsx-a11y/recommended',
'plugin:prettier/recommended',
'next',
@ -27,5 +31,8 @@ module.exports = {
'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',
},
}

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

@ -5,45 +5,62 @@
[![GitHub Repo stars](https://img.shields.io/github/stars/timlrx/tailwind-nextjs-starter-blog?style=social)](https://GitHub.com/timlrx/tailwind-nextjs-starter-blog/stargazers/)
[![GitHub forks](https://img.shields.io/github/forks/timlrx/tailwind-nextjs-starter-blog?style=social)](https://GitHub.com/timlrx/tailwind-nextjs-starter-blog/network/)
[![Twitter URL](https://img.shields.io/twitter/url?style=social&url=https%3A%2F%2Ftwitter.com%2Ftimlrxx)](https://twitter.com/timlrxx)
[![Sponsor](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub&link=https://github.com/sponsors/timlrx)](https://github.com/sponsors/timlrx)
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/git/external?repository-url=https://github.com/timlrx/tailwind-nextjs-starter-blog)
This is a [Next.js](https://nextjs.org/), [Tailwind CSS](https://tailwindcss.com/) blogging starter template. Comes out of the box configured with the latest technologies to make technical writing a breeze. Easily configurable and customizable. Perfect as a replacement to existing Jekyll and Hugo individual blogs.
This is a [Next.js](https://nextjs.org/), [Tailwind CSS](https://tailwindcss.com/) blogging starter template. Probably the most feature-rich Next.js markdown blogging template out there. Comes out of the box configured with the latest technologies to make technical writing a breeze. Easily configurable and customizable. Perfect as a replacement to existing Jekyll and Hugo individual blogs.
Check out the documentation below to get started. Facing issues? Checkout of the [FAQ page](https://github.com/timlrx/tailwind-nextjs-starter-blog/wiki) and do a search on past issues. Feel free to open a new issue if none has been posted previously.
Check out the documentation below to get started.
Facing issues? Check the [FAQ page](https://github.com/timlrx/tailwind-nextjs-starter-blog/wiki) and do a search on past issues. Feel free to open a new issue if none has been posted previously.
Feature request? Check the past discussions to see if it has been brought up previously. Otherwise, feel free to start a new discussion thread. All ideas are welcomed!
## Examples
- [Demo Blog](https://tailwind-nextjs-starter-blog.vercel.app/) - this repo
- [My personal blog](https://www.timlrx.com) - modified to auto-generate blog posts with dates
- [Aloisdg's cookbook](https://tambouille.vercel.app/) - with pictures and recipes!
- [GautierArcin's demo with next translate](https://tailwind-nextjs-starter-blog-seven.vercel.app/) - includes translation of mdx posts, [source code](https://github.com/GautierArcin/tailwind-nextjs-starter-blog/tree/demo/next-translate)
- [David Levai's digital garden](https://davidlevai.com/) - customized design and added email subscriptions
- [Thinh's Corner](https://thinhcorner.com/) - [customized layout](https://github.com/Th1nhNg0/th1nhng0.vercel.app/blob/5e73a420828d82f01e7147512a2c3273c4ec19f8/layouts/PostLayout.js) with sticky side table of contents
- [Leo's Blog](https://leohuynh.dev) - Tuan Anh Huynh's personal site. Add Snippets Page, Author Profile Card, Image Lightbox, ...
- [thvu.dev](https://thvu.dev) - Added `mdx-embed`, view count, reading minutes and more.
- [fiqrychoerudin.dev](https://www.fiqrychoerudin.dev/) - simple portfolio.
- [irvin.dev](https://www.irvin.dev/) - Irvin Lin's personal site. Added YouTube embedding.
Using the template? Happy to accept any PR with modifications made e.g. sub-paths, localization or multiple authors
Using the template? Feel free to create a PR and add your blog to this list.
## Motivation
I wanted to port my existing blog to Nextjs and Tailwind CSS but there was no easy out of the box template to use so I decided to create one.
It is inspired by [Lee Robinson's blog](https://github.com/leerob/leerob.io), but focuses only on static site generation. Design is adapted from [Tailwindlabs blog](https://github.com/tailwindlabs/blog.tailwindcss.com).
I wanted to port my existing blog to Nextjs and Tailwind CSS but there was no easy out of the box template to use so I decided to create one. Design is adapted from [Tailwindlabs blog](https://github.com/tailwindlabs/blog.tailwindcss.com).
I wanted it to be nearly as feature-rich as popular blogging templates like [beautiful-jekyll](https://github.com/daattali/beautiful-jekyll) and [Hugo Academic](https://github.com/wowchemy/wowchemy-hugo-modules) but with the best of React's ecosystem and current web development's best practices.
## Features
- Easy styling customization with [Tailwind 2.0](https://blog.tailwindcss.com/tailwindcss-v2) and primary color attribute
- Easy styling customization with [Tailwind 3.0](https://tailwindcss.com/blog/tailwindcss-v3) and primary color attribute
- Near perfect lighthouse score - [Lighthouse report](https://www.webpagetest.org/result/210111_DiC1_08f3670c3430bf4a9b76fc3b927716c5/)
- Lightweight, 38kB first load JS, uses Preact in production build
- Lightweight, 45kB first load JS, uses Preact in production build
- Mobile-friendly view
- Light and dark theme
- 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)
- Math display supported via [KaTeX](https://katex.org/)
- Citation and bibliography support via [rehype-citation](https://github.com/timlrx/rehype-citation)
- Automatic image optimization via [next/image](https://nextjs.org/docs/basic-features/image-optimization)
- Flexible data retrieval with [mdx-bundler](https://github.com/kentcdodds/mdx-bundler)
- Support for tags - each unique tag will be its own page
- Support for multiple authors
- Blog templates
- TOC component
- Support for nested routing of blog posts
- Newsletter component with support for mailchimp, buttondown, convertkit and klaviyo
- Supports [giscus](https://github.com/laymonage/giscus), [utterances](https://github.com/utterance/utterances) or disqus
- Projects page
- Preconfigured security headers
- SEO friendly with RSS feed, sitemaps and more!
## Sample posts
@ -57,21 +74,42 @@ I wanted it to be nearly as feature-rich as popular blogging templates like [bea
## Quick Start Guide
1. JS (official support) - `npx degit https://github.com/timlrx/tailwind-nextjs-starter-blog.git` or TS (community support) - `npx degit timlrx/tailwind-nextjs-starter-blog#typescript`
2. Personalize `siteMetadata.json` (site related information)
1. JS (official support)
```bash
npx degit https://github.com/timlrx/tailwind-nextjs-starter-blog.git
```
or TS (community support)
```bash
npx degit timlrx/tailwind-nextjs-starter-blog#typescript
```
2. Personalize `siteMetadata.js` (site related information)
3. Personalize `authors/default.md` (main author)
4. Modify `projectsData.js`
5. Modify `headerNavLinks.js` to customize navigation links
6. Add blog posts
7. Deploy on Vercel
## Installation
```bash
npm install
```
## Development
First, run the development server:
```bash
npm start
# or
```
or
```bash
npm run dev
```
@ -81,11 +119,11 @@ You can start editing the page by modifying `pages/index.js`. The page auto-upda
## Extend / Customize
`data/siteMetadata.json` - contains most of the site related information which should be modified for a user's need.
`data/siteMetadata.js` - contains most of the site related information which should be modified for a user's need.
`data/authors/default.md` - default author information (required). Additional authors can be added as files in `data/authors`.
`data/projectsData.js` - data used to generate styled card in projects page.
`data/projectsData.js` - data used to generate styled card on the projects page.
`data/headerNavLinks.js` - navigation links.
@ -97,13 +135,17 @@ You can start editing the page by modifying `pages/index.js`. The page auto-upda
`tailwind.config.js` and `css/tailwind.css` - contain the tailwind stylesheet which can be modified to change the overall look and feel of the site.
`components/social-icons` - to add other icons, simply copy an svg file from [Simple Icons](https://simpleicons.org/) and map them in `index.js`. Other icons uses [heroicons](https://heroicons.com/).
`css/prism.css` - controls the styles associated with the code blocks. Feel free to customize it and use your preferred prismjs theme e.g. [prism themes](https://github.com/PrismJS/prism-themes).
`components/social-icons` - to add other icons, simply copy an svg file from [Simple Icons](https://simpleicons.org/) and map them in `index.js`. Other icons use [heroicons](https://heroicons.com/).
`components/MDXComponents.js` - pass your own JSX code or React component by specifying it over here. You can then call them directly in the `.mdx` or `.md` file. By default, a custom link and image component is passed.
`layouts` - main templates used in pages.
`pages` - pages to route to. Read the [Next.js documentation](https://nextjs.org/docs) for more information
`pages` - pages to route to. Read the [Next.js documentation](https://nextjs.org/docs) for more information.
`next.config.js` - configuration related to Next.js. You need to adapt the Content Security Policy if you want to load scripts, images etc. from other domains.
## Post
@ -152,5 +194,15 @@ Follow the interactive prompt to generate a post with pre-filled front matter.
**Vercel**
The easiest way to deploy the template is to use the [Vercel Platform](https://vercel.com) from the creators of Next.js. Check out the [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
**Netlify / Github Pages / Firebase etc.**
As the template uses `next/image` for image optimization, additional configurations has to be made to deploy on other popular static hosting websites like [Netlify](https://www.netlify.com/) or [Github Pages](https://pages.github.com/). An alternative image optimization provider such as Imgix, Cloudinary or Akamai has to be used. Alternatively, replace the `next/image` component with a standard `<img>` tag. See [`next/image` documentation](https://nextjs.org/docs/basic-features/image-optimization) for more details.
**Netlify / GitHub Pages / Firebase etc.**
As the template uses `next/image` for image optimization, additional configurations have to be made to deploy on other popular static hosting websites like [Netlify](https://www.netlify.com/) or [GitHub Pages](https://pages.github.com/). An alternative image optimization provider such as Imgix, Cloudinary or Akamai has to be used. Alternatively, replace the `next/image` component with a standard `<img>` tag. See [`next/image` documentation](https://nextjs.org/docs/basic-features/image-optimization) for more details.
The API routes used in the newsletter component cannot be used in a static site export. You will need to use a form API endpoint provider and substitute the route in the newsletter component accordingly. Other hosting platforms such as Netlify also offer alternative solutions - please refer to their docs for more information.
## Support
Using the template? Support this effort by giving a star on GitHub, sharing your own blog and giving a shoutout on Twitter or becoming a project [sponsor](https://github.com/sponsors/timlrx).
## Licence
[MIT](https://github.com/timlrx/tailwind-nextjs-starter-blog/blob/master/LICENSE) © [Timothy Lin](https://www.timrlx.com)

View file

@ -2,27 +2,32 @@ import Image from './Image'
import Link from './Link'
const Card = ({ title, description, imgSrc, href }) => (
<div className="p-4 md:w-1/2 md" style={{ maxWidth: '544px' }}>
<div className="h-full overflow-hidden border-2 border-gray-200 rounded-md border-opacity-60 dark:border-gray-700">
{href ? (
<Link href={href} aria-label={`Link to ${title}`}>
<div className="md p-4 md:w-1/2" style={{ maxWidth: '544px' }}>
<div
className={`${
imgSrc && 'h-full'
} overflow-hidden rounded-md border-2 border-gray-200 border-opacity-60 dark:border-gray-700`}
>
{imgSrc &&
(href ? (
<Link href={href} aria-label={`Link to ${title}`}>
<Image
alt={title}
src={imgSrc}
className="object-cover object-center md:h-36 lg:h-48"
width={544}
height={306}
/>
</Link>
) : (
<Image
alt={title}
src={imgSrc}
className="object-cover object-center lg:h-48 md:h-36"
className="object-cover object-center md:h-36 lg:h-48"
width={544}
height={306}
/>
</Link>
) : (
<Image
alt={title}
src={imgSrc}
className="object-cover object-center lg:h-48 md:h-36"
width={544}
height={306}
/>
)}
))}
<div className="p-6">
<h2 className="mb-3 text-2xl font-bold leading-8 tracking-tight">
{href ? (
@ -33,7 +38,7 @@ const Card = ({ title, description, imgSrc, href }) => (
title
)}
</h2>
<p className="mb-3 prose text-gray-500 max-w-none dark:text-gray-400">{description}</p>
<p className="prose mb-3 max-w-none text-gray-500 dark:text-gray-400">{description}</p>
{href && (
<Link
href={href}

View file

@ -5,16 +5,16 @@ import SocialIcon from '@/components/social-icons'
export default function Footer() {
return (
<footer>
<div className="flex flex-col items-center mt-16">
<div className="flex mb-3 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" />
<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} />
</div>
<div className="flex mb-2 space-x-2 text-sm text-gray-500 dark:text-gray-400">
<div className="mb-2 flex space-x-2 text-sm text-gray-500 dark:text-gray-400">
<div>{siteMetadata.author}</div>
<div>{``}</div>
<div>{`© ${new Date().getFullYear()}`}</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,14 +6,19 @@ 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 flex-col justify-between h-screen">
<div className="flex h-screen flex-col justify-between">
<header className="flex items-center justify-between py-10">
<div>
<Link href="/" aria-label="Tailwind CSS Blog">
<Link href="/" aria-label={siteMetadata.headerTitle}>
<div className="flex items-center justify-between">
<div className="mr-3">
<Logo />
@ -34,7 +39,7 @@ const LayoutWrapper = ({ children }) => {
<Link
key={link.title}
href={link.href}
className="p-1 font-medium text-gray-900 sm:p-4 dark:text-gray-100"
className="p-1 font-medium text-gray-900 dark:text-gray-100 sm:p-4"
>
{link.title}
</Link>

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,22 +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 Pre from './Pre'
export const MDXComponents = {
Image,
a: CustomLink,
pre: Pre,
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/generated'
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

@ -21,7 +21,7 @@ const MobileNav = () => {
<div className="sm:hidden">
<button
type="button"
className="w-8 h-8 ml-1 mr-1 rounded"
className="ml-1 mr-1 h-8 w-8 rounded py-1"
aria-label="Toggle Menu"
onClick={onToggleNav}
>
@ -47,17 +47,17 @@ const MobileNav = () => {
</svg>
</button>
<div
className={`fixed w-full h-full top-24 right-0 bg-gray-200 dark:bg-gray-800 opacity-95 z-10 transform ease-in-out duration-300 ${
className={`fixed top-24 right-0 z-10 h-full w-full transform bg-gray-200 opacity-95 duration-300 ease-in-out dark:bg-gray-800 ${
navShow ? 'translate-x-0' : 'translate-x-full'
}`}
>
<button
type="button"
aria-label="toggle modal"
className="fixed w-full h-full cursor-auto focus:outline-none"
className="fixed h-full w-full cursor-auto focus:outline-none"
onClick={onToggleNav}
></button>
<nav className="fixed h-full mt-8">
<nav className="fixed mt-8 h-full">
{headerNavLinks.map((link) => (
<div key={link.title} className="px-12 py-4">
<Link

View file

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

View file

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

View file

@ -1,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="pt-6 pb-8 space-y-2 md:space-y-5">
<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)
@ -26,9 +30,9 @@ const Pre = (props) => {
<button
aria-label="Copy code"
type="button"
className={`absolute right-2 top-2 w-8 h-8 p-1 rounded border-2 bg-gray-700 dark:bg-gray-800 ${
className={`absolute right-2 top-2 h-8 w-8 rounded border-2 bg-gray-700 p-1 dark:bg-gray-800 ${
copied
? 'focus:outline-none focus:border-green-400 border-green-400'
? 'border-green-400 focus:border-green-400 focus:outline-none'
: 'border-gray-300'
}`}
onClick={onCopy}
@ -63,7 +67,7 @@ const Pre = (props) => {
</button>
)}
<pre>{props.children}</pre>
<pre>{children}</pre>
</div>
)
}

View file

@ -1,112 +0,0 @@
import Head from 'next/head'
import { useRouter } from 'next/router'
import siteMetadata from '@/data/siteMetadata'
export const PageSeo = ({ title, description }) => {
const router = useRouter()
return (
<Head>
<title>{`${title}`}</title>
<meta name="robots" content="follow, index" />
<meta name="description" content={description} />
<meta property="og:url" content={`${siteMetadata.siteUrl}${router.asPath}`} />
<meta property="og:type" content="website" />
<meta property="og:site_name" content={siteMetadata.title} />
<meta property="og:description" content={description} />
<meta property="og:title" content={title} />
<meta property="og:image" content={`${siteMetadata.siteUrl}${siteMetadata.socialBanner}`} />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:site" content={siteMetadata.twitter} />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
<meta name="twitter:image" content={`${siteMetadata.siteUrl}${siteMetadata.socialBanner}`} />
</Head>
)
}
export const BlogSeo = ({ authorDetails, title, summary, date, lastmod, url, images = [] }) => {
const router = useRouter()
const publishedAt = new Date(date).toISOString()
const modifiedAt = new Date(lastmod || date).toISOString()
let imagesArr =
images.length === 0
? [siteMetadata.socialBanner]
: typeof images === 'string'
? [images]
: images
const featuredImages = imagesArr.map((img) => {
return {
'@type': 'ImageObject',
url: `${siteMetadata.siteUrl}${img}`,
}
})
let authorList
if (authorDetails) {
authorList = authorDetails.map((author) => {
return {
'@type': 'Person',
name: author.name,
}
})
} else {
authorList = {
'@type': 'Person',
name: siteMetadata.author,
}
}
const structuredData = {
'@context': 'https://schema.org',
'@type': 'Article',
mainEntityOfPage: {
'@type': 'WebPage',
'@id': url,
},
headline: title,
image: featuredImages,
datePublished: publishedAt,
dateModified: modifiedAt,
author: authorList,
publisher: {
'@type': 'Organization',
name: siteMetadata.author,
logo: {
'@type': 'ImageObject',
url: `${siteMetadata.siteUrl}${siteMetadata.siteLogo}`,
},
},
description: summary,
}
return (
<>
<Head>
<title>{`${title}`}</title>
<meta name="robots" content="follow, index" />
<meta name="description" content={summary} />
<meta property="og:url" content={`${siteMetadata.siteUrl}${router.asPath}`} />
<meta property="og:type" content="article" />
<meta property="og:site_name" content={siteMetadata.title} />
<meta property="og:description" content={summary} />
<meta property="og:title" content={title} />
{featuredImages.map((img) => (
<meta property="og:image" content={img.url} key={img.url} />
))}
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:site" content={siteMetadata.twitter} />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={summary} />
<meta name="twitter:image" content={featuredImages[0].url} />
{date && <meta property="article:published_time" content={publishedAt} />}
{lastmod && <meta property="article:modified_time" content={modifiedAt} />}
<link rel="canonical" href={`${siteMetadata.siteUrl}${router.asPath}`} />
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData, null, 2) }}
/>
</Head>
</>
)
}

183
components/SEO.tsx Normal file
View file

@ -0,0 +1,183 @@
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'
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>
<title>{title}</title>
<meta name="robots" content="follow, index" />
<meta name="description" content={description} />
<meta property="og:url" content={`${siteMetadata.siteUrl}${router.asPath}`} />
<meta property="og:type" content={ogType} />
<meta property="og:site_name" content={siteMetadata.title} />
<meta property="og:description" content={description} />
<meta property="og:title" content={title} />
{Array.isArray(ogImage) ? (
ogImage.map(({ url }) => <meta property="og:image" content={url} key={url} />)
) : (
<meta property="og:image" content={ogImage} key={ogImage} />
)}
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:site" content={siteMetadata.twitter} />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
<meta name="twitter:image" content={twImage} />
</Head>
)
}
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 (
<CommonSEO
title={title}
description={description}
ogType="website"
ogImage={ogImageUrl}
twImage={twImageUrl}
/>
)
}
export const TagSEO = ({ title, description }: PageSEOProps) => {
const ogImageUrl = siteMetadata.siteUrl + siteMetadata.socialBanner
const twImageUrl = siteMetadata.siteUrl + siteMetadata.socialBanner
const router = useRouter()
return (
<>
<CommonSEO
title={title}
description={description}
ogType="website"
ogImage={ogImageUrl}
twImage={twImageUrl}
/>
<Head>
<link
rel="alternate"
type="application/rss+xml"
title={`${description} - RSS feed`}
href={`${siteMetadata.siteUrl}${router.asPath}/feed.xml`}
/>
</Head>
</>
)
}
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()
const imagesArr =
images.length === 0
? [siteMetadata.socialBanner]
: typeof images === 'string'
? [images]
: images
const featuredImages = imagesArr.map((img) => {
return {
'@type': 'ImageObject',
url: `${siteMetadata.siteUrl}${img}`,
}
})
let authorList
if (authorDetails) {
authorList = authorDetails.map((author) => {
return {
'@type': 'Person',
name: author.name,
}
})
} else {
authorList = {
'@type': 'Person',
name: siteMetadata.author,
}
}
const structuredData = {
'@context': 'https://schema.org',
'@type': 'Article',
mainEntityOfPage: {
'@type': 'WebPage',
'@id': url,
},
headline: title,
image: featuredImages,
datePublished: publishedAt,
dateModified: modifiedAt,
author: authorList,
publisher: {
'@type': 'Organization',
name: siteMetadata.author,
logo: {
'@type': 'ImageObject',
url: `${siteMetadata.siteUrl}${siteMetadata.siteLogo}`,
},
},
description: summary,
}
const twImageUrl = featuredImages[0].url
return (
<>
<CommonSEO
title={title}
description={summary}
ogType="article"
ogImage={featuredImages}
twImage={twImageUrl}
/>
<Head>
{date && <meta property="article:published_time" content={publishedAt} />}
{lastmod && <meta property="article:modified_time" content={modifiedAt} />}
<link rel="canonical" href={`${siteMetadata.siteUrl}${router.asPath}`} />
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(structuredData, null, 2),
}}
/>
</Head>
</>
)
}

View file

@ -0,0 +1,58 @@
import { useEffect, useState } from 'react'
const ScrollTopAndComment = () => {
const [show, setShow] = useState(false)
useEffect(() => {
const handleWindowScroll = () => {
if (window.scrollY > 50) setShow(true)
else setShow(false)
}
window.addEventListener('scroll', handleWindowScroll)
return () => window.removeEventListener('scroll', handleWindowScroll)
}, [])
const handleScrollTop = () => {
window.scrollTo({ top: 0, behavior: 'smooth' })
}
const handleScrollToComment = () => {
document.getElementById('comment').scrollIntoView()
}
return (
<div
className={`fixed right-8 bottom-8 hidden flex-col gap-3 ${show ? 'md:flex' : 'md:hidden'}`}
>
<button
aria-label="Scroll To Comment"
type="button"
onClick={handleScrollToComment}
className="rounded-full bg-gray-200 p-2 text-gray-500 transition-all hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-400 dark:hover:bg-gray-600"
>
<svg className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path
fillRule="evenodd"
d="M18 10c0 3.866-3.582 7-8 7a8.841 8.841 0 01-4.083-.98L2 17l1.338-3.123C2.493 12.767 2 11.434 2 10c0-3.866 3.582-7 8-7s8 3.134 8 7zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z"
clipRule="evenodd"
/>
</svg>
</button>
<button
aria-label="Scroll To Top"
type="button"
onClick={handleScrollTop}
className="rounded-full bg-gray-200 p-2 text-gray-500 transition-all hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-400 dark:hover:bg-gray-600"
>
<svg className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path
fillRule="evenodd"
d="M3.293 9.707a1 1 0 010-1.414l6-6a1 1 0 011.414 0l6 6a1 1 0 01-1.414 1.414L11 5.414V17a1 1 0 11-2 0V5.414L4.707 9.707a1 1 0 01-1.414 0z"
clipRule="evenodd"
/>
</svg>
</button>
</div>
)
}
export default ScrollTopAndComment

View file

@ -1,3 +0,0 @@
export default function SectionContainer({ children }) {
return <div className="max-w-3xl px-4 mx-auto 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>
}

68
components/TOCInline.tsx Normal file
View file

@ -0,0 +1,68 @@
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 {TOCInlineProps} {
* toc,
* indentDepth = 3,
* fromHeading = 1,
* toHeading = 6,
* asDisclosure = false,
* exclude = '',
* }
*
*/
const TOCInline = ({
toc,
indentDepth = 3,
fromHeading = 1,
toHeading = 6,
asDisclosure = false,
exclude = '',
}: TOCInlineProps) => {
const re = Array.isArray(exclude)
? new RegExp('^(' + exclude.join('|') + ')$', 'i')
: new RegExp('^(' + exclude + ')$', 'i')
const filteredToc = toc.filter(
(heading) =>
heading.depth >= fromHeading && heading.depth <= toHeading && !re.test(heading.value)
)
const tocList = (
<ul>
{filteredToc.map((heading) => (
<li key={heading.value} className={`${heading.depth >= indentDepth && 'ml-6'}`}>
<a href={heading.url}>{heading.value}</a>
</li>
))}
</ul>
)
return (
<>
{asDisclosure ? (
<details open>
<summary className="ml-6 pt-2 pb-2 text-xl font-bold">Table of Contents</summary>
<div className="ml-6">{tocList}</div>
</details>
) : (
tocList
)}
</>
)
}
export default TOCInline

View file

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

@ -12,7 +12,7 @@ const ThemeSwitch = () => {
<button
aria-label="Toggle Dark Mode"
type="button"
className="w-8 h-8 p-1 ml-1 mr-1 rounded sm:ml-4"
className="ml-1 mr-1 h-8 w-8 rounded p-1 sm:ml-4"
onClick={() => setTheme(theme === 'dark' || resolvedTheme === 'dark' ? 'light' : 'dark')}
>
<svg

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,29 @@
/* 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 = () => {
return (
<>
{isProduction && siteMetadata.analytics.plausibleDataDomain && <Plausible />}
{isProduction && siteMetadata.analytics.simpleAnalytics && <SimpleAnalytics />}
{isProduction && siteMetadata.analytics.umamiWebsiteId && <Umami />}
{isProduction && siteMetadata.analytics.googleAnalyticsId && <GA />}
</>
)
}
export default Analytics

View file

@ -0,0 +1,46 @@
import React, { useState } from 'react'
import siteMetadata from '@/data/siteMetadata'
import { PostFrontMatter } from 'types/PostFrontMatter'
interface Props {
frontMatter: PostFrontMatter
}
const Disqus = ({ frontMatter }: Props) => {
const [enableLoadComments, setEnabledLoadComments] = useState(true)
const COMMENTS_ID = 'disqus_thread'
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 })
}
}
return (
<div className="pt-6 pb-6 text-center text-gray-700 dark:text-gray-300">
{enableLoadComments && <button onClick={LoadComments}>Load Comments</button>}
<div className="disqus-frame" id={COMMENTS_ID} />
</div>
)
}
export default Disqus

View file

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

View file

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

View file

@ -0,0 +1,59 @@
import siteMetadata from '@/data/siteMetadata'
import dynamic from 'next/dynamic'
import { PostFrontMatter } from 'types/PostFrontMatter'
interface Props {
frontMatter: PostFrontMatter
}
const UtterancesComponent = dynamic(
() => {
return import('@/components/comments/Utterances')
},
{ ssr: false }
)
const GiscusComponent = dynamic(
() => {
return import('@/components/comments/Giscus')
},
{ ssr: false }
)
const DisqusComponent = dynamic(
() => {
return import('@/components/comments/Disqus')
},
{ ssr: false }
)
const Comments = ({ frontMatter }: Props) => {
let term
switch (
siteMetadata.comment.giscusConfig.mapping ||
siteMetadata.comment.utterancesConfig.issueTerm
) {
case 'pathname':
term = frontMatter.slug
break
case 'url':
term = window.location.href
break
case 'title':
term = frontMatter.title
break
}
return (
<div id="comment">
{siteMetadata.comment && siteMetadata.comment.provider === 'giscus' && (
<GiscusComponent mapping={term} />
)}
{siteMetadata.comment && siteMetadata.comment.provider === 'utterances' && (
<UtterancesComponent issueTerm={term} />
)}
{siteMetadata.comment && siteMetadata.comment.provider === 'disqus' && (
<DisqusComponent frontMatter={frontMatter} />
)}
</div>
)
}
export default Comments

View file

@ -17,7 +17,8 @@ const components = {
}
const SocialIcon = ({ kind, href, size = 8 }) => {
if (!href) return null
if (!href || (kind === 'mail' && !/^mailto:\w+([.-]?\w+)@\w+([.-]?\w+)(.\w{2,3})+$/.test(href)))
return null
const SocialSvg = components[kind]
@ -30,7 +31,7 @@ const SocialIcon = ({ kind, href, size = 8 }) => {
>
<span className="sr-only">{kind}</span>
<SocialSvg
className={`fill-current text-gray-700 dark:text-gray-200 hover:text-blue-500 dark:hover:text-blue-400 h-${size} w-${size}`}
className={`fill-current text-gray-700 hover:text-blue-500 dark:text-gray-200 dark:hover:text-blue-400 h-${size} w-${size}`}
/>
</a>
)

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,
],
},
})

140
css/prism.css Normal file
View file

@ -0,0 +1,140 @@
/**
* CSS Styles for code highlighting.
* Feel free to customize token styles
* by copying from a prismjs compatible theme:
* https://github.com/PrismJS/prism-themes
*/
/* Code title styles */
.remark-code-title {
@apply rounded-t bg-gray-700 px-5 py-3 font-mono text-sm font-bold text-gray-200;
}
.remark-code-title + div > pre {
@apply mt-0 rounded-t-none;
}
/* Code block styles */
.code-highlight {
@apply float-left min-w-full;
}
.code-line {
@apply -mx-4 block border-l-4 border-transparent pl-4 pr-4;
}
.code-line.inserted {
@apply bg-green-500 bg-opacity-20;
}
.code-line.deleted {
@apply bg-red-500 bg-opacity-20;
}
.highlight-line {
@apply -mx-4 border-l-4 border-primary-500 bg-gray-700 bg-opacity-50;
}
.line-number::before {
@apply mr-4 -ml-2 inline-block w-4 text-right text-gray-400;
content: attr(line);
}
/* Token styles */
/**
* MIT License
* Copyright (c) 2018 Sarah Drasner
* Sarah Drasner's[@sdras] Night Owl
* Ported by Sara vieria [@SaraVieira]
* Added by Souvik Mandal [@SimpleIndian]
*/
.token.comment,
.token.prolog,
.token.cdata {
color: rgb(99, 119, 119);
font-style: italic;
}
.token.punctuation {
color: rgb(199, 146, 234);
}
.namespace {
color: rgb(178, 204, 214);
}
.token.deleted {
color: rgba(239, 83, 80, 0.56);
font-style: italic;
}
.token.symbol,
.token.property {
color: rgb(128, 203, 196);
}
.token.tag,
.token.operator,
.token.keyword {
color: rgb(127, 219, 202);
}
.token.boolean {
color: rgb(255, 88, 116);
}
.token.number {
color: rgb(247, 140, 108);
}
.token.constant,
.token.function,
.token.builtin,
.token.char {
color: rgb(130, 170, 255);
}
.token.selector,
.token.doctype {
color: rgb(199, 146, 234);
font-style: italic;
}
.token.attr-name,
.token.inserted {
color: rgb(173, 219, 103);
font-style: italic;
}
.token.string,
.token.url,
.token.entity,
.language-css .token.string,
.style .token.string {
color: rgb(173, 219, 103);
}
.token.class-name,
.token.atrule,
.token.attr-value {
color: rgb(255, 203, 139);
}
.token.regex,
.token.important,
.token.variable {
color: rgb(214, 222, 235);
}
.token.important,
.token.bold {
font-weight: bold;
}
.token.italic {
font-style: italic;
}
.token.table {
display: inline;
}

View file

@ -2,31 +2,24 @@
@tailwind components;
@tailwind utilities;
.remark-code-title {
@apply px-5 py-3 font-mono text-sm font-bold text-gray-200 bg-gray-700 rounded-t;
}
.remark-code-title + div > pre {
@apply mt-0 rounded-t-none;
}
.task-list-item:before {
.task-list-item::before {
@apply hidden;
}
.code-line {
@apply pl-4 -mx-4 border-l-4 border-gray-800;
.task-list-item {
@apply list-none;
}
.highlight-line {
@apply -mx-4 bg-gray-700 bg-opacity-50 border-l-4 border-primary-500;
.footnotes {
@apply pt-8 mt-12 border-t border-gray-200 dark:border-gray-700;
}
.line-number::before {
@apply pr-4 -ml-2 text-gray-400;
content: attr(line);
.csl-entry {
@apply my-5;
}
html {
scroll-behavior: smooth;
/* https://stackoverflow.com/questions/61083813/how-to-avoid-internal-autofill-selected-style-to-be-applied */
input:-webkit-autofill,
input:-webkit-autofill:focus {
transition: background-color 600000s 0s, color 600000s 0s;
}

View file

@ -7,6 +7,6 @@ twitter: https://twitter.com/sparrowhawk
linkedin: https://www.linkedin.com/sparrowhawk
---
At birth Ged was given the child-name Duny by his mother. He was born on the island of Gont, son of a bronzesmith. His mother died before he reached the age of one. As a small boy, Ged had overheard the village witch, his maternal aunt, using various words of power to call goats. Ged later used the words without understanding of their meanings, to surprising effect.
At birth, Ged was given the child-name Duny by his mother. He was born on the island of Gont, as a son of a bronzesmith. His mother died before he reached the age of one. As a small boy, Ged had overheard the village witch, his maternal aunt, using various words of power to call goats. Ged later used the words without an understanding of their meanings, to surprising effect.
The witch knew that using words of power effectively without understanding them required innate power, so she endeavored to teach him what little she knew. After learning more from her, he was able to call animals to him. Particularly, he was seen in the company of wild sparrowhawks so often that his "use name" became Sparrowhawk.

View file

@ -1,6 +1,6 @@
---
title: Deriving the OLS Estimator
date: '2019-11-16'
date: '2020-12-21'
tags: ['next js', 'math', 'ols']
draft: false
summary: 'How to derive the OLS Estimator with matrix notation and a tour of math typesetting using markdown with the help of KaTeX.'
@ -9,18 +9,18 @@ summary: 'How to derive the OLS Estimator with matrix notation and a tour of mat
# Introduction
Parsing and display of math equations is included in this blog template. Parsing of math is enabled by `remark-math` and `rehype-katex`.
KaTeX and its associated font is included in `_document.js` so feel free to use it in any pages.
KaTeX and its associated font is included in `_document.js` so feel free to use it on any page.
^[For the full list of supported TeX functions, check out the [KaTeX documentation](https://katex.org/docs/supported.html)]
Inline math symbols can be included by enclosing the term between the `$` symbol.
Math code blocks is denoted by `$$`.
Math code blocks are denoted by `$$`.
The dollar signal displays without issue since only text without space and between two `$` signs are considered as math symbols.[^2]
If you intend to use the `$` sign instead of math, you can escape it (`\$`), or specify the HTML entity (`&dollar;`) [^2]
Inline or manually enumerated footnotes are also supported. Click on the links above to see them in action.
[^2]: Here's $10 and $20.
[^2]: \$10 and &dollar;20.
# Deriving the OLS Estimator
@ -30,23 +30,23 @@ The vector of outcome variables $\mathbf{Y}$ is a $n \times 1$ matrix,
```tex
\mathbf{Y} = \left[\begin{array}
{c}
y_1 \\
. \\
. \\
. \\
y_n
{c}
y_1 \\
. \\
. \\
. \\
y_n
\end{array}\right]
```
$$
\mathbf{Y} = \left[\begin{array}
{c}
y_1 \\
. \\
. \\
. \\
y_n
{c}
y_1 \\
. \\
. \\
. \\
y_n
\end{array}\right]
$$
@ -54,45 +54,45 @@ The matrix of regressors $\mathbf{X}$ is a $n \times k$ matrix (or each row is a
```latex
\mathbf{X} = \left[\begin{array}
{ccccc}
x_{11} & . & . & . & x_{1k} \\
. & . & . & . & . \\
. & . & . & . & . \\
. & . & . & . & . \\
x_{n1} & . & . & . & x_{nn}
{ccccc}
x_{11} & . & . & . & x_{1k} \\
. & . & . & . & . \\
. & . & . & . & . \\
. & . & . & . & . \\
x_{n1} & . & . & . & x_{nn}
\end{array}\right] =
\left[\begin{array}
{c}
\mathbf{x}'_1 \\
. \\
. \\
. \\
\mathbf{x}'_n
{c}
\mathbf{x}'_1 \\
. \\
. \\
. \\
\mathbf{x}'_n
\end{array}\right]
```
$$
\mathbf{X} = \left[\begin{array}
{ccccc}
x_{11} & . & . & . & x_{1k} \\
. & . & . & . & . \\
. & . & . & . & . \\
. & . & . & . & . \\
x_{n1} & . & . & . & x_{nn}
{ccccc}
x_{11} & . & . & . & x_{1k} \\
. & . & . & . & . \\
. & . & . & . & . \\
. & . & . & . & . \\
x_{n1} & . & . & . & x_{nn}
\end{array}\right] =
\left[\begin{array}
{c}
\mathbf{x}'_1 \\
. \\
. \\
. \\
\mathbf{x}'_n
{c}
\mathbf{x}'_1 \\
. \\
. \\
. \\
\mathbf{x}'_n
\end{array}\right]
$$
The vector of error terms $\mathbf{U}$ is also a $n \times 1$ matrix.
At times it might be easier to use vector notation. For consistency I will use the bold small x to denote a vector and capital letters to denote a matrix. Single observations are denoted by the subscript.
At times it might be easier to use vector notation. For consistency, I will use the bold small x to denote a vector and capital letters to denote a matrix. Single observations are denoted by the subscript.
## Least Squares
@ -107,7 +107,7 @@ $$y_i = \mathbf{x}'_i \beta + u_i$$
4. $Var(\mathbf{U}|\mathbf{X}) = \sigma^2 I_n$ (Homoskedascity)
**Aim**:
Find $\beta$ that minimises sum of squared errors:
Find $\beta$ that minimises the sum of squared errors:
$$
Q = \sum_{i=1}^{n}{u_i^2} = \sum_{i=1}^{n}{(y_i - \mathbf{x}'_i\beta)^2} = (Y-X\beta)'(Y-X\beta)
@ -120,22 +120,22 @@ Take matrix derivative w.r.t $\beta$:
```tex
\begin{aligned}
\min Q & = \min_{\beta} \mathbf{Y}'\mathbf{Y} - 2\beta'\mathbf{X}'\mathbf{Y} +
\beta'\mathbf{X}'\mathbf{X}\beta \\
& = \min_{\beta} - 2\beta'\mathbf{X}'\mathbf{Y} + \beta'\mathbf{X}'\mathbf{X}\beta \\
\text{[FOC]}~~~0 & = - 2\mathbf{X}'\mathbf{Y} + 2\mathbf{X}'\mathbf{X}\hat{\beta} \\
\hat{\beta} & = (\mathbf{X}'\mathbf{X})^{-1}\mathbf{X}'\mathbf{Y} \\
& = (\sum^{n} \mathbf{x}_i \mathbf{x}'_i)^{-1} \sum^{n} \mathbf{x}_i y_i
\min Q & = \min_{\beta} \mathbf{Y}'\mathbf{Y} - 2\beta'\mathbf{X}'\mathbf{Y} +
\beta'\mathbf{X}'\mathbf{X}\beta \\
& = \min_{\beta} - 2\beta'\mathbf{X}'\mathbf{Y} + \beta'\mathbf{X}'\mathbf{X}\beta \\
\text{[FOC]}~~~0 & = - 2\mathbf{X}'\mathbf{Y} + 2\mathbf{X}'\mathbf{X}\hat{\beta} \\
\hat{\beta} & = (\mathbf{X}'\mathbf{X})^{-1}\mathbf{X}'\mathbf{Y} \\
& = (\sum^{n} \mathbf{x}_i \mathbf{x}'_i)^{-1} \sum^{n} \mathbf{x}_i y_i
\end{aligned}
```
$$
\begin{aligned}
\min Q & = \min_{\beta} \mathbf{Y}'\mathbf{Y} - 2\beta'\mathbf{X}'\mathbf{Y} +
\beta'\mathbf{X}'\mathbf{X}\beta \\
& = \min_{\beta} - 2\beta'\mathbf{X}'\mathbf{Y} + \beta'\mathbf{X}'\mathbf{X}\beta \\
\text{[FOC]}~~~0 & = - 2\mathbf{X}'\mathbf{Y} + 2\mathbf{X}'\mathbf{X}\hat{\beta} \\
\hat{\beta} & = (\mathbf{X}'\mathbf{X})^{-1}\mathbf{X}'\mathbf{Y} \\
& = (\sum^{n} \mathbf{x}_i \mathbf{x}'_i)^{-1} \sum^{n} \mathbf{x}_i y_i
\min Q & = \min_{\beta} \mathbf{Y}'\mathbf{Y} - 2\beta'\mathbf{X}'\mathbf{Y} +
\beta'\mathbf{X}'\mathbf{X}\beta \\
& = \min_{\beta} - 2\beta'\mathbf{X}'\mathbf{Y} + \beta'\mathbf{X}'\mathbf{X}\beta \\
\text{[FOC]}~~~0 & = - 2\mathbf{X}'\mathbf{Y} + 2\mathbf{X}'\mathbf{X}\hat{\beta} \\
\hat{\beta} & = (\mathbf{X}'\mathbf{X})^{-1}\mathbf{X}'\mathbf{Y} \\
& = (\sum^{n} \mathbf{x}_i \mathbf{x}'_i)^{-1} \sum^{n} \mathbf{x}_i y_i
\end{aligned}
$$

View file

@ -3,14 +3,14 @@ title: 'Markdown Guide'
date: '2019-10-11'
tags: ['github', 'guide']
draft: false
summary: 'Markdown cheatsheet for all your blogging needs - headers, lists, images, tables and more! An illustrated guide based on Github Flavored Markdown.'
summary: 'Markdown cheatsheet for all your blogging needs - headers, lists, images, tables and more! An illustrated guide based on GitHub Flavored Markdown.'
---
# Introduction
Markdown and Mdx parsing is supported via `unified`, and other remark and rehype packages. `next-mdx-remote` allows us to parse `.mdx` and `.md` files in a more flexible manner without touching webpack.
Github flavored markdown is used. `mdx-prism` provides syntax highlighting capabilities for code blocks. Here's a demo of how everything looks.
GitHub flavored markdown is used. `mdx-prism` provides syntax highlighting capabilities for code blocks. Here's a demo of how everything looks.
The following markdown cheatsheet is adapted from: https://guides.github.com/features/mastering-markdown/
@ -153,6 +153,18 @@ function fancyAlert(arg) {
}
```
## Footnotes
```
Here is a simple footnote[^1]. With some additional text after it.
[^1]: My reference.
```
Here is a simple footnote[^1]. With some additional text after it.
[^1]: My reference.
## Task Lists
```

View file

@ -4,7 +4,7 @@ date: '2020-11-11'
tags: ['next js', 'guide']
draft: false
summary: 'In this article we introduce adding images in the tailwind starter blog and the benefits and limitations of the next/image component.'
author: sparrowhawk
authors: ['sparrowhawk']
---
# Introduction
@ -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
@ -66,7 +62,7 @@ _Note_: If you try to save the image, it is in webp format, if your browser supp
# Limitations
- Due to the reliance of `next/image`, unless you are using an external image CDN like Cloudinary or Imgix, it is practically required to use Vercel for hosting. This is because the component acts like a serverless function that calls a highly optimized image CDN.
- Due to the reliance on `next/image`, unless you are using an external image CDN like Cloudinary or Imgix, it is practically required to use Vercel for hosting. This is because the component acts like a serverless function that calls a highly optimized image CDN.
If you do not want to be tied to Vercel, you can remove `imgToJsx` in `remarkPlugins` in `lib/mdx.js`. This would avoid substituting the default `img` tag.

View file

@ -1,7 +1,7 @@
---
title: 'Introducing Tailwind Nexjs Starter Blog'
title: 'Introducing Tailwind Nextjs Starter Blog'
date: '2021-01-12'
lastmod: '2021-07-11'
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.'
@ -15,55 +15,69 @@ authors: ['default', 'sparrowhawk']
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/git/external?repository-url=https://github.com/timlrx/tailwind-nextjs-starter-blog)
This is a [Next.js](https://nextjs.org/), [Tailwind CSS](https://tailwindcss.com/) blogging starter template. Comes out of the box configured with the latest technologies to make technical writing a breeze. Easily configurable and customizable. Perfect as a replacement to existing Jekyll and Hugo individual blogs.
This is a [Next.js](https://nextjs.org/), [Tailwind CSS](https://tailwindcss.com/) blogging starter template. Probably the most feature-rich Next.js markdown blogging template out there. Comes out of the box configured with the latest technologies to make technical writing a breeze. Easily configurable and customizable. Perfect as a replacement to existing Jekyll and Hugo individual blogs.
Check out the documentation below to get started.
Facing issues? Check the [FAQ page](https://github.com/timlrx/tailwind-nextjs-starter-blog/wiki) and do a search on past issues. Feel free to open a new issue if none has been posted previously.
Feature request? Check the past discussions to see if it has been brought up previously. Otherwise, feel free to start a new discussion thread. All ideas are welcomed!
## Examples
- [Demo Blog](https://tailwind-nextjs-starter-blog.vercel.app/) - this repo
- [My personal blog](https://www.timlrx.com) - modified to auto-generate blog posts with dates
- [Aloisdg's cookbook](https://tambouille.vercel.app/) - with pictures and recipes!
- [GautierArcin's demo with next translate](https://tailwind-nextjs-starter-blog-seven.vercel.app/) - includes translation of mdx posts, [source code](https://github.com/GautierArcin/tailwind-nextjs-starter-blog/tree/demo/next-translate)
- [David Levai's digital garden](https://davidlevai.com/) - customized design and added email subscriptions
- [Thinh's Corner](https://thinhcorner.com/) - [customized layout](https://github.com/Th1nhNg0/th1nhng0.vercel.app/blob/5e73a420828d82f01e7147512a2c3273c4ec19f8/layouts/PostLayout.js) with sticky side table of contents
Using the template? Happy to accept any PR with modifications made e.g. sub-paths, localization or multiple authors
Using the template? Feel free to create a PR and add your blog to this list.
## Motivation
I wanted to port my existing blog to Nextjs and Tailwind CSS but there was no easy out of the box template to use so I decided to create one.
It is inspired by [Lee Robinson's blog](https://github.com/leerob/leerob.io), but focuses only on static site generation. Design is adapted from [Tailwindlabs blog](https://github.com/tailwindlabs/blog.tailwindcss.com).
I wanted to port my existing blog to Nextjs and Tailwind CSS but there was no easy out of the box template to use so I decided to create one. Design is adapted from [Tailwindlabs blog](https://github.com/tailwindlabs/blog.tailwindcss.com).
I wanted it to be nearly as feature-rich as popular blogging templates like [beautiful-jekyll](https://github.com/daattali/beautiful-jekyll) and [Hugo Academic](https://github.com/wowchemy/wowchemy-hugo-modules) but with the best of React's ecosystem and current web development's best practices.
## Features
- Easy styling customization with [Tailwind 2.0](https://blog.tailwindcss.com/tailwindcss-v2) and primary color attribute
- Easy styling customization with [Tailwind 3.0](https://tailwindcss.com/blog/tailwindcss-v3) and primary color attribute
- Near perfect lighthouse score - [Lighthouse report](https://www.webpagetest.org/result/210111_DiC1_08f3670c3430bf4a9b76fc3b927716c5/)
- Lightweight, 38kB first load JS, uses Preact in production build
- Lightweight, 45kB first load JS, uses Preact in production build
- Mobile-friendly view
- Light and dark theme
- 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)
- Math display supported via [KaTeX](https://katex.org/)
- Citation and bibliography support via [rehype-citation](https://github.com/timlrx/rehype-citation)
- Automatic image optimization via [next/image](https://nextjs.org/docs/basic-features/image-optimization)
- Flexible data retrieval with [mdx-bundler](https://github.com/kentcdodds/mdx-bundler)
- Support for tags - each unique tag will be its own page
- Support for multiple authors
- Blog templates
- TOC component
- Support for nested routing of blog posts
- Newsletter component with support for mailchimp, buttondown and convertkit
- Supports [giscus](https://github.com/laymonage/giscus), [utterances](https://github.com/utterance/utterances) or disqus
- Projects page
- Preconfigured security headers
- SEO friendly with RSS feed, sitemaps and more!
## Sample posts
- [A markdown guide](/blog/github-markdown-guide)
- [Learn more about images in Next.js](/blog/guide-to-using-images-in-nextjs)
- [A tour of math typesetting](/blog/deriving-ols-estimator)
- [Simple MDX image grid](/blog/pictures-of-canada)
- [Example of long prose](/blog/the-time-machine)
- [Example of Nested Route Post](/blog/nested-route/introducing-multi-part-posts-with-nested-routing)
- [A markdown guide](https://tailwind-nextjs-starter-blog.vercel.app/blog/github-markdown-guide)
- [Learn more about images in Next.js](https://tailwind-nextjs-starter-blog.vercel.app/blog/guide-to-using-images-in-nextjs)
- [A tour of math typesetting](https://tailwind-nextjs-starter-blog.vercel.app/blog/deriving-ols-estimator)
- [Simple MDX image grid](https://tailwind-nextjs-starter-blog.vercel.app/blog/pictures-of-canada)
- [Example of long prose](https://tailwind-nextjs-starter-blog.vercel.app/blog/the-time-machine)
- [Example of Nested Route Post](https://tailwind-nextjs-starter-blog.vercel.app/blog/nested-route/introducing-multi-part-posts-with-nested-routing)
## Quick Start Guide
1. JS (official support) - `npx degit https://github.com/timlrx/tailwind-nextjs-starter-blog.git` or TS (community support) - `npx degit timlrx/tailwind-nextjs-starter-blog#typescript`
2. Personalize `siteMetadata.json` (site related information)
2. Personalize `siteMetadata.js` (site related information)
3. Personalize `authors/default.md` (main author)
4. Modify `projectsData.js`
5. Modify `headerNavLinks.js` to customize navigation links
@ -86,11 +100,11 @@ You can start editing the page by modifying `pages/index.js`. The page auto-upda
## Extend / Customize
`data/siteMetadata.json` - contains most of the site related information which should be modified for a user's need.
`data/siteMetadata.js` - contains most of the site related information which should be modified for a user's need.
`data/authors/default.md` - default author information (required). Additional authors can be added as files in `data/authors`.
`data/projectsData.js` - data used to generate styled card in projects page.
`data/projectsData.js` - data used to generate styled card on the projects page.
`data/headerNavLinks.js` - navigation links.
@ -102,13 +116,17 @@ You can start editing the page by modifying `pages/index.js`. The page auto-upda
`tailwind.config.js` and `css/tailwind.css` - contain the tailwind stylesheet which can be modified to change the overall look and feel of the site.
`components/social-icons` - to add other icons, simply copy an svg file from [Simple Icons](https://simpleicons.org/) and map them in `index.js`. Other icons uses [heroicons](https://heroicons.com/).
`css/prism.css` - controls the styles associated with the code blocks. Feel free to customize it and use your preferred prismjs theme e.g. [prism themes](https://github.com/PrismJS/prism-themes).
`components/social-icons` - to add other icons, simply copy an svg file from [Simple Icons](https://simpleicons.org/) and map them in `index.js`. Other icons use [heroicons](https://heroicons.com/).
`components/MDXComponents.js` - pass your own JSX code or React component by specifying it over here. You can then call them directly in the `.mdx` or `.md` file. By default, a custom link and image component is passed.
`layouts` - main templates used in pages.
`pages` - pages to route to. Read the [Next.js documentation](https://nextjs.org/docs) for more information
`pages` - pages to route to. Read the [Next.js documentation](https://nextjs.org/docs) for more information.
`next.config.js` - configuration related to Next.js. You need to adapt the Content Security Policy if you want to load scripts, images etc. from other domains.
## Post
@ -157,5 +175,15 @@ Follow the interactive prompt to generate a post with pre-filled front matter.
**Vercel**
The easiest way to deploy the template is to use the [Vercel Platform](https://vercel.com) from the creators of Next.js. Check out the [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
**Netlify / Github Pages / Firebase etc.**
As the template uses `next/image` for image optimization, additional configurations has to be made to deploy on other popular static hosting websites like [Netlify](https://www.netlify.com/) or [Github Pages](https://pages.github.com/). An alternative image optimization provider such as Imgix, Cloudinary or Akamai has to be used. Alternatively, replace the `next/image` component with a standard `<img>` tag. See [`next/image` documentation](https://nextjs.org/docs/basic-features/image-optimization) for more details.
**Netlify / GitHub Pages / Firebase etc.**
As the template uses `next/image` for image optimization, additional configurations have to be made to deploy on other popular static hosting websites like [Netlify](https://www.netlify.com/) or [GitHub Pages](https://pages.github.com/). An alternative image optimization provider such as Imgix, Cloudinary or Akamai has to be used. Alternatively, replace the `next/image` component with a standard `<img>` tag. See [`next/image` documentation](https://nextjs.org/docs/basic-features/image-optimization) for more details.
The API routes used in the newsletter component cannot be used in a static site export. You will need to use a form API endpoint provider and substitute the route in the newsletter component accordingly. Other hosting platforms such as Netlify also offer alternative solutions - please refer to their docs for more information.
## Support
Using the template? Support this effort by giving a star on GitHub, sharing your own blog and giving a shoutout on Twitter or be a project [sponsor](https://github.com/sponsors/timlrx).
## Licence
[MIT](https://github.com/timlrx/tailwind-nextjs-starter-blog/blob/master/LICENSE) © [Timothy Lin](https://www.timrlx.com)

View file

@ -18,13 +18,13 @@ We use Next.js catch all routes to handle the routing and path creations.
## Use Cases
Here's some reasons to use nested routes
Here are some reasons to use nested routes
- More logical content organisation (blogs will still be displayed based on the created date)
- Multi-part posts
- Different sub-routes for each author
- Internationalization (though it would be recommended to use [Next.js built in i8n routing](https://nextjs.org/docs/advanced-features/i18n-routing))
- Internationalization (though it would be recommended to use [Next.js built-in i8n routing](https://nextjs.org/docs/advanced-features/i18n-routing))
## Note
- The previous/next post links at bottom of the template is currently sorted by date. One could explore modifying the template to refer the reader to the previous/next post in the series, rather than by date.
- The previous/next post links at bottom of the template are currently sorted by date. One could explore modifying the template to refer the reader to the previous/next post in the series, rather than by date.

View file

@ -1,24 +1,21 @@
---
title: 'New features in v1'
date: '2021-07-11'
date: 2021-08-07T15:32:14Z
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'
layout: PostSimple
bibliography: references-data.bib
---
## Overview
A post on the new features introduced in v1.0. New features:
- [Theme colors](#theme-colors)
- [Xdm MDX compiler](#xdm-mdx-compiler)
- [Layouts](#layouts)
- [Multiple authors](#multiple-authors)
- [Copy button for code blocks](#copy-button-for-code-blocks)
- [Line highlighting and line numbers](#line-highlighting-and-line-numbers)
<TOCInline toc={props.toc} exclude="Overview" toHeading={2} />
First load JS decreased from 43kB to 38kB despite all the new features added!
First load JS decreased from 43kB to 39kB despite all the new features added!^[With the new changes in Nextjs 12, first load JS increase to 45kB.]
See [upgrade guide](#upgrade-guide) below if you are migrating from v0 version of the template.
@ -30,7 +27,7 @@ You can easily modify the theme color by changing the primary attribute in the t
theme: {
colors: {
primary: colors.teal,
gray: colors.trueGray,
gray: colors.neutral,
...
}
...
@ -41,45 +38,72 @@ The primary color attribute should be assigned an object with keys from 50, 100,
Tailwind includes great default color palettes that can be used for theming your own website. Check out [customizing colors documentation page](https://tailwindcss.com/docs/customizing-colors) for the full range of options.
Migrating from v1? You can revert to the previous theme by setting `primary` to `colors.sky` (Tailwind 2.2.2 and above, otherwise `colors.lightBlue`) and changing gray to `colors.coolGray`.
Migrating from v1? You can revert to the previous theme by setting `primary` to `colors.sky` (Tailwind 2.2.2 and above, otherwise `colors.lightBlue`) and changing gray to `colors.gray`.
From v1.1.2+, you can also customize the style of your code blocks easily by modifying the `css/prism.css` stylesheet. Token classnames are compatible with prismjs
so you can copy and adapt token styles from a prismjs stylesheet e.g. [prism themes](https://github.com/PrismJS/prism-themes).
## Xdm MDX compiler
We switch the MDX bundler from [next-mdx-remote](https://github.com/hashicorp/next-mdx-remote) to [mdx-bundler](https://github.com/kentcdodds/mdx-bundler).
This uses [xdm](https://github.com/wooorm/xdm) under the hood uses the latest micromark 3 and remark, rehype libraries.
We switched the MDX bundler from [next-mdx-remote](https://github.com/hashicorp/next-mdx-remote) to [mdx-bundler](https://github.com/kentcdodds/mdx-bundler).
This uses [xdm](https://github.com/wooorm/xdm) under the hood, the latest micromark 3 and remark, rehype libraries.
**Warning:** If you were using custom remark or rehype libraries, please upgrade to micromark 3 compatible ones. If you are upgrading, please delete `node_modules` and `package-lock.json` to avoid having past dependencies related issues.
[xdm](https://github.com/wooorm/xdm) contains multiple improvements over [@mdx-js/mdx](https://github.com/mdx-js/mdx), the compiler used internally by next-mdx-remote, but there might be some breaking behaviour changes.
Please check your markdown output to verify.
Some new possibilities include loading components directly in the mdx file using the import syntax and including js code which could be compiled at the build step.
Some new possibilities include loading components directly in the mdx file using the import syntax and including js code which could be compiled and bundled at the build step.
For example, the following jsx snippet can be used directly in an MDX file to render the page title component:
```js
import PageTitle from './PageTitle.js'
```jsx
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>
The default configuration resolves all components relative to the `components` directory.
**Note**:
Components which require external image loaders would require additional esbuild configuration.
Components which are dependent on global application state on lifecycle like the Nextjs `Link` component would also not work with this setup as each mdx file is built indepedently.
Components which require external image loaders also require additional esbuild configuration.
Components which are dependent on global application state on lifecycle like the Nextjs `Link` component would also not work with this setup as each mdx file is built independently.
For such cases, it is better to use component substitution.
## Table of contents component
Inspired by [Docusaurus](https://docusaurus.io/docs/next/markdown-features/inline-toc) and Gatsby's [gatsby-remark-table-of-contents](https://www.gatsbyjs.com/plugins/gatsby-remark-table-of-contents/),
the `toc` variable containing all the top level headings of the document is passed to the MDX file and can be styled accordingly.
To make generating a table of contents (TOC) simple, you can use the existing `TOCInline` component.
For example, the TOC in this post was generated with the following code:
```jsx
<TOCInline toc={props.toc} exclude="Overview" toHeading={2} />
```
You can customise the headings that are displayed by configuring the `fromHeading` and `toHeading` props, or exclude particular headings
by passing a string or a string array to the `exclude` prop. By default, all headings that are of depth 3 or smaller are indented. This can be configured by changing the `indentDepth` property.
A `asDisclosure` prop can be used to render the TOC within an expandable disclosure element.
Here's the full TOC rendered in a disclosure element.
```jsx
<TOCInline toc={props.toc} asDisclosure />
```
<TOCInline toc={props.toc} asDisclosure />
## Layouts
You can map mdx blog content to layout components by configuring the frontmatter field. For example, this post is written with the new `PostSimple` layout!
### Adding new templates
layout templates are stored in the `./layouts` folder. You can add add your React components that you want to map to markdown content in this folder.
layout templates are stored in the `./layouts` folder. You can add your React components that you want to map to markdown content in this folder.
The component file name must match that specified in the markdown frontmatter `layout` field.
The only required field is `children` which contains the rendered MDX content, though you would probably want to pass in the frontMatter contents and render it in the template.
@ -88,7 +112,7 @@ You can configure the template to take in other fields - see `PostLayout` compon
Here's an example layout which you can further customise:
```js
```jsx
export default function ExampleLayout({ frontMatter, children }) {
const { date, title } = frontMatter
@ -122,29 +146,102 @@ The `DEFAULT_LAYOUT` for blog posts page is set to `PostLayout`.
### Extend
The layout mapping is handled by the `MDXLayoutRenderer` component.
It's a glue component which imports the specified layout, processes the MDX content before passing it back to the layout component as children.
`layout` is mapped to wrapper which wraps the entire MDX content.
```jsx
export const MDXComponents = {
Image,
a: CustomLink,
pre: Pre,
wrapper: ({ components, layout, ...rest }) => {
const Layout = require(`../layouts/${layout}`).default
return <Layout {...rest} />
},
}
```js
export const MDXLayoutRenderer = ({ layout, mdxSource, ...rest }) => {
const LayoutComponent = require(`../layouts/${layout}`).default
const MDXLayout = useMemo(() => getMDXComponent(mdxSource), [mdxSource])
return (
<LayoutComponent {...rest}>
<MDXRemote {...mdxSource} components={MDXComponents} />
</LayoutComponent>
)
return <MDXLayout layout={layout} components={MDXComponents} {...rest} />
}
```
Use the component is a page where you want to accept a layout name to map to the desired layout.
You need to pass the layout name from the layout folder (it has to be an exact match) and the mdxSource content which is an output of the `seralize` function from the `next-mdx-remote` library.
Use the `MDXLayoutRenderer` component on a page where you want to accept a layout name to map to the desired layout.
You need to pass the layout name from the layout folder (it has to be an exact match).
## Analytics
The template now supports [plausible](https://plausible.io/), [simple analytics](https://simpleanalytics.com/) and google analytics.
Configure `siteMetadata.js` with the settings that correspond with the desired analytics provider.
```js
analytics: {
// supports plausible, simpleAnalytics or googleAnalytics
plausibleDataDomain: '', // e.g. tailwind-nextjs-starter-blog.vercel.app
simpleAnalytics: false, // true or false
googleAnalyticsId: '', // e.g. UA-000000-2 or G-XXXXXXX
},
```
Custom events are also supported. You can import the `logEvent` function from `@components/analytics/[ANALYTICS-PROVIDER]` file and call it when
triggering certain events of interest. _Note_: Additional configuration might be required depending on the analytics provider, please check their official
documentation for more information.
## Blog comments system
We have also added support for [giscus](https://github.com/laymonage/giscus), [utterances](https://github.com/utterance/utterances) or disqus.
To enable, simply configure `siteMetadata.js` comments property with the desired provider and settings as specified in the config file.
```js
comment: {
// Select a provider and use the environment variables associated to it
// https://vercel.com/docs/environment-variables
provider: 'giscus', // supported providers: giscus, utterances, disqus
giscusConfig: {
// Visit the link below, and follow the steps in the 'configuration' section
// https://giscus.app/
repo: process.env.NEXT_PUBLIC_GISCUS_REPO,
repositoryId: process.env.NEXT_PUBLIC_GISCUS_REPOSITORY_ID,
category: process.env.NEXT_PUBLIC_GISCUS_CATEGORY,
categoryId: process.env.NEXT_PUBLIC_GISCUS_CATEGORY_ID,
mapping: 'pathname', // supported options: pathname, url, title
reactions: '1', // Emoji reactions: 1 = enable / 0 = disable
// Send discussion metadata periodically to the parent window: 1 = enable / 0 = disable
metadata: '0',
// theme example: light, dark, dark_dimmed, dark_high_contrast
// transparent_dark, preferred_color_scheme, custom
theme: 'light',
// theme when dark mode
darkTheme: 'transparent_dark',
// If the theme option above is set to 'custom`
// please provide a link below to your custom theme css file.
// example: https://giscus.app/themes/custom_example.css
themeURL: '',
},
utterancesConfig: {
// Visit the link below, and follow the steps in the 'configuration' section
// https://utteranc.es/
repo: process.env.NEXT_PUBLIC_UTTERANCES_REPO,
issueTerm: '', // supported options: pathname, url, title
label: '', // label (optional): Comment 💬
// theme example: github-light, github-dark, preferred-color-scheme
// github-dark-orange, icy-dark, dark-blue, photon-dark, boxy-light
theme: '',
// theme when dark mode
darkTheme: '',
},
disqus: {
// https://help.disqus.com/en/articles/1717111-what-s-a-shortname
shortname: process.env.NEXT_PUBLIC_DISQUS_SHORTNAME,
},
},
```
## Multiple authors
Information on authors is now split from `siteMetadata.json` and stored in its own `data/authors` folder as a markdown file. Minimally, you will need to have a `default.md` file with authorship information. You can create additional files as required and the file name will be used as the reference to the author.
Information on authors is now split from `siteMetadata.js` and stored in its own `data/authors` folder as a markdown file. Minimally, you will need to have a `default.md` file with authorship information. You can create additional files as required and the file name will be used as the reference to the author.
Here's how an author markdown file might looks like:
Here's how an author markdown file might look like:
```md:default.md
---
@ -171,7 +268,7 @@ This is rendered in the `AuthorLayout` template.
### Multiple authors in blog post
The frontmatter of a blog post accepts an optional `authors` arrray field. If no field is specified, it is assumed that the default author is used. Simply pass in an array of authors to render multiple authors associated with post.
The frontmatter of a blog post accepts an optional `authors` array field. If no field is specified, it is assumed that the default author is used. Simply pass in an array of authors to render multiple authors associated with a post.
For example, the following frontmatter will display the authors given by `data/authors/default.md` and `data/authors/sparrowhawk.md`
@ -183,16 +280,16 @@ summary: 'My first post'
authors: ['default', 'sparrowhawk']
```
A demo of a multiple author post is shown in the [Introducing Tailwind Nextjs Starter Blog post](/blog/introducing-tailwind-nextjs-starter-blog).
A demo of a multiple authors post is shown in [Introducing Tailwind Nextjs Starter Blog post](/blog/introducing-tailwind-nextjs-starter-blog).
## Copy button for code blocks
Hover over a code block and you will notice a Github inspired copy button! You can modify `./components/Pre.js` to further customise it.
Hover over a code block and you will notice a GitHub-inspired copy button! You can modify `./components/Pre.js` to further customise it.
The component is passed to `MDXComponents` and modifies all `<pre>` blocks.
## Line highlighting and line numbers
Line highlighting and line numbers is now supported out of the box thanks to the new [rehype-prism-plus plugin](https://github.com/timlrx/rehype-prism-plus)
Line highlighting and line numbers are now supported out of the box thanks to the new [rehype-prism-plus plugin](https://github.com/timlrx/rehype-prism-plus)
The following javascript code block:
@ -216,11 +313,23 @@ sum = parseInt(num1) + parseInt(num2) // "+" means "add"
alert('Sum = ' + sum) // "+" means combine into a string
```
To modify the styles, change the following class selectors in the `tailwind.css` file:
To modify the styles, change the following class selectors in the `prism.css` file:
```css
.code-highlight {
@apply float-left min-w-full;
}
.code-line {
@apply pl-4 -mx-4 border-l-4 border-gray-800;
@apply block pl-4 pr-4 -mx-4 border-l-4 border-opacity-0;
}
.code-line.inserted {
@apply bg-green-500 bg-opacity-20;
}
.code-line.deleted {
@apply bg-red-500 bg-opacity-20;
}
.highlight-line {
@ -228,21 +337,77 @@ To modify the styles, change the following class selectors in the `tailwind.css`
}
.line-number::before {
@apply pr-4 -ml-2 text-gray-400;
@apply inline-block w-4 mr-4 -ml-2 text-right text-gray-400;
content: attr(line);
}
```
## Newletter component (v1.1.3)
Introduced in v1.1.3, the newsletter component gives you an easy way to build an audience. It integrates with the following providers:
- [Mailchimp](https://mailchimp.com/)
- [Buttondown](https://buttondown.email/)
- [Convertkit](https://convertkit.com/)
To use it, specify the provider which you are using in the config file and add the necessary environment variables to the `.env` file.
For more information on the required variables, check out `.env.sample.`
Two components are exported, a default `NewsletterForm` and a `BlogNewsletterForm` component, which is also passed in as an MDX component
and can be used in a blog post:
```jsx
<BlogNewsletterForm title="Like what you are reading?" />
```
<BlogNewsletterForm title="Like what you are reading?" />
The component relies on nextjs's [API routes](https://nextjs.org/docs/api-routes/introduction) which requires a server-side instance of nextjs to be setup
and is not compatible with a 100% static site export. Users should either self-host or use a compatible platform like Vercel or Netlify which supports this functionality.
A static site compatible alternative is to substitute the route in the newsletter component with a form API endpoint provider.
## Bibliography and Citations (v1.2.1)
`rehype-citation` plugin is added to the xdm processing pipeline in v1.2.1. This allows you to easily format citations and insert bibliography from an existing bibtex or CSL-json file.
For example, the following markdown code sample:
```md
Standard citation [@Nash1950]
In-text citations e.g. @Nash1951
Multiple citations [see @Nash1950; @Nash1951, page 50]
**References:**
[^ref]
```
is rendered to the following:
Standard citation [@Nash1950]
In-text citations e.g. @Nash1951
Multiple citations [see @Nash1950; @Nash1951, page 50]
**References:**
[^ref]
A bibliography will be inserted at the end of the document, but this can be overwritten by specifying a `[^Ref]` tag at the intended location.
The plugin uses APA citation formation, but also supports the following CSLs, 'apa', 'vancouver', 'harvard1', 'chicago', 'mla', or a path to a user-specified CSL file.
See [rehype-citation readme](https://github.com/timlrx/rehype-citation) for more information on the configuration options.
## 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.
There's also no real reason to change if the previous one serves your needs and it might be easier to copy
the component changes you are interested to your existing blog rather than migrating everything over.
the component changes you are interested in to your existing blog rather than migrating everything over.
Nonetheless if you want to do so and have not changed much of the template, you could clone the new version and copy over the blog post instead.
Nonetheless, if you want to do so and have not changed much of the template, you could clone the new version and copy over the blog post over to the new template.
Another alternative would be to pull the latest tempate version with the following code:
Another alternative would be to pull the latest template version with the following code:
```bash
git remote add template git@github.com:timlrx/tailwind-nextjs-starter-blog.git
@ -252,7 +417,7 @@ rm -rf node_modules
You can see an example of such a migration in this [commit](https://github.com/timlrx/timlrx.com/commit/bba1c185384fd6d5cdaac15abf802fdcff027286) for my personal blog.
v1 also uses `feed.xml` rather than `index.xml`. If you are migrating you should add a redirect to `next.config.js` like so:
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:
```
async redirects() {

View file

@ -20,16 +20,16 @@ Since we are using mdx, we can create a simple responsive flexbox grid to displa
<div className="flex flex-wrap -mx-2 overflow-hidden xl:-mx-2">
<div className="my-1 px-2 w-full overflow-hidden xl:my-1 xl:px-2 xl:w-1/2">
<Image alt="Maple" src="/static/images/canada/maple.jpg" width={640} height={427} />
![Maple](/static/images/canada/maple.jpg)
</div>
<div className="my-1 px-2 w-full overflow-hidden xl:my-1 xl:px-2 xl:w-1/2">
<Image alt="Lake" src="/static/images/canada/lake.jpg" width={640} height={427} />
![Lake](/static/images/canada/lake.jpg)
</div>
<div className="my-1 px-2 w-full overflow-hidden xl:my-1 xl:px-2 xl:w-1/2">
<Image alt="Mountains" src="/static/images/canada/mountains.jpg" width={640} height={427} />
![Mountains](/static/images/canada/mountains.jpg)
</div>
<div className="my-1 px-2 w-full overflow-hidden xl:my-1 xl:px-2 xl:w-1/2">
<Image alt="Toronto" src="/static/images/canada/toronto.jpg" width={640} height={427} />
![Toronto](/static/images/canada/toronto.jpg)
</div>
</div>
@ -38,23 +38,21 @@ Since we are using mdx, we can create a simple responsive flexbox grid to displa
```js
<div className="flex flex-wrap -mx-2 overflow-hidden xl:-mx-2">
<div className="my-1 px-2 w-full overflow-hidden xl:my-1 xl:px-2 xl:w-1/2">
<Image alt="Maple" src="/static/images/canada/maple.jpg" width={640} height={427} />
![Maple](/static/images/canada/maple.jpg)
</div>
<div className="my-1 px-2 w-full overflow-hidden xl:my-1 xl:px-2 xl:w-1/2">
<Image alt="Lake" src="/static/images/canada/lake.jpg" width={640} height={427} />
![Lake](/static/images/canada/lake.jpg)
</div>
<div className="my-1 px-2 w-full overflow-hidden xl:my-1 xl:px-2 xl:w-1/2">
<Image alt="Mountains" src="/static/images/canada/mountains.jpg" width={640} height={427} />
![Mountains](/static/images/canada/mountains.jpg)
</div>
<div className="my-1 px-2 w-full overflow-hidden xl:my-1 xl:px-2 xl:w-1/2">
<Image alt="Toronto" src="/static/images/canada/toronto.jpg" width={640} height={427} />
![Toronto](/static/images/canada/toronto.jpg)
</div>
</div>
```
_Note_: Currently, one has to use the `Image` component instead of the markdown syntax between jsx. Thankfully, it's one of the default components passed to the MDX Provider and can be used directly.
When MDX v2 is ready, one could potentially interleave markdown in jsx directly! Follow [MDX v2 issues](https://github.com/mdx-js/mdx/issues/1041) for updates.
With MDX v2, one can interleave markdown in jsx as shown in the example code.
### Photo Credits

View file

@ -1,7 +1,7 @@
const projectsData = [
{
title: 'A Search Engine',
description: `What is you could look up any information in the world? Webpages, images, videos
description: `What if you could look up any information in the world? Webpages, images, videos
and more. Google has many features to help you find exactly what you're looking
for.`,
imgSrc: '/static/images/google.png',
@ -11,7 +11,7 @@ const projectsData = [
title: 'The Time Machine',
description: `Imagine being able to travel back in time or to the future. Simple turn the knob
to the desired date and press "Go". No more worrying about lost keys or
forgotten handphones with this simple yet affordable solution.`,
forgotten headphones with this simple yet affordable solution.`,
imgSrc: '/static/images/time-machine.jpg',
href: '/blog/the-time-machine',
},

33
data/references-data.bib Normal file
View file

@ -0,0 +1,33 @@
@article{Nash1950,
title={Equilibrium points in n-person games},
author={Nash, John},
journal={Proceedings of the national academy of sciences},
volume={36},
number={1},
pages={48--49},
year={1950},
publisher={USA}
}
@article{Nash1951,
title={Non-cooperative games},
author={Nash, John},
journal={Annals of mathematics},
pages={286--295},
year={1951},
publisher={JSTOR}
}
@Manual{Macfarlane2006,
url={https://pandoc.org/},
title={Pandoc: a universal document converter},
author={MacFarlane, John},
year={2006}
}
@book{Xie2016,
title={Bookdown: authoring books and technical documents with R markdown},
author={Xie, Yihui},
year={2016},
publisher={CRC Press}
}

76
data/siteMetadata.js Normal file
View file

@ -0,0 +1,76 @@
const siteMetadata = {
title: 'Next.js Starter Blog',
author: 'Tails Azimuth',
headerTitle: 'TailwindBlog',
description: 'A blog created with Next.js and Tailwind.css',
language: 'en-us',
theme: 'system', // system, dark or light
siteUrl: 'https://tailwind-nextjs-starter-blog.vercel.app',
siteRepo: 'https://github.com/timlrx/tailwind-nextjs-starter-blog',
siteLogo: '/static/images/logo.png',
image: '/static/images/avatar.png',
socialBanner: '/static/images/twitter-card.png',
email: 'address@yoursite.com',
github: 'https://github.com',
twitter: 'https://twitter.com/Twitter',
facebook: 'https://facebook.com',
youtube: 'https://youtube.com',
linkedin: 'https://www.linkedin.com',
locale: 'en-US',
analytics: {
// supports plausible, simpleAnalytics, umami or googleAnalytics
plausibleDataDomain: '', // e.g. tailwind-nextjs-starter-blog.vercel.app
simpleAnalytics: false, // true or false
umamiWebsiteId: '', // e.g. 123e4567-e89b-12d3-a456-426614174000
googleAnalyticsId: '', // e.g. UA-000000-2 or G-XXXXXXX
},
newsletter: {
// supports mailchimp, buttondown, convertkit, klaviyo
// Please add your .env file and modify it according to your selection
provider: 'buttondown',
},
comment: {
// Select a provider and use the environment variables associated to it
// https://vercel.com/docs/environment-variables
provider: 'giscus', // supported providers: giscus, utterances, disqus
giscusConfig: {
// Visit the link below, and follow the steps in the 'configuration' section
// https://giscus.app/
repo: process.env.NEXT_PUBLIC_GISCUS_REPO,
repositoryId: process.env.NEXT_PUBLIC_GISCUS_REPOSITORY_ID,
category: process.env.NEXT_PUBLIC_GISCUS_CATEGORY,
categoryId: process.env.NEXT_PUBLIC_GISCUS_CATEGORY_ID,
mapping: 'pathname', // supported options: pathname, url, title
reactions: '1', // Emoji reactions: 1 = enable / 0 = disable
// Send discussion metadata periodically to the parent window: 1 = enable / 0 = disable
metadata: '0',
// theme example: light, dark, dark_dimmed, dark_high_contrast
// transparent_dark, preferred_color_scheme, custom
theme: 'light',
// theme when dark mode
darkTheme: 'transparent_dark',
// If the theme option above is set to 'custom`
// please provide a link below to your custom theme css file.
// example: https://giscus.app/themes/custom_example.css
themeURL: '',
},
utterancesConfig: {
// Visit the link below, and follow the steps in the 'configuration' section
// https://utteranc.es/
repo: process.env.NEXT_PUBLIC_UTTERANCES_REPO,
issueTerm: '', // supported options: pathname, url, title
label: '', // label (optional): Comment 💬
// theme example: github-light, github-dark, preferred-color-scheme
// github-dark-orange, icy-dark, dark-blue, photon-dark, boxy-light
theme: '',
// theme when dark mode
darkTheme: '',
},
disqusConfig: {
// https://help.disqus.com/en/articles/1717111-what-s-a-shortname
shortname: process.env.NEXT_PUBLIC_DISQUS_SHORTNAME,
},
},
}
module.exports = siteMetadata

View file

@ -1,19 +0,0 @@
{
"title": "Next.js Starter Blog",
"author": "Tails Azimuth",
"headerTitle": "TailwindBlog",
"description": "A blog created with Next.js and Tailwind.css",
"language": "en-us",
"siteUrl": "https://tailwind-nextjs-starter-blog.vercel.app",
"siteRepo": "https://github.com/timlrx/tailwind-nextjs-starter-blog",
"siteLogo": "/static/images/logo.png",
"image": "/static/images/avatar.png",
"socialBanner": "/static/images/twitter-card.png",
"email": "address@yoursite.com",
"github": "https://github.com",
"twitter": "https://twitter.com/Twitter",
"facebook": "https://facebook.com",
"youtube": "https://youtube.com",
"linkedin": "https://www.linkedin.com",
"locale": "en-US"
}

View file

@ -1,12 +0,0 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/components/*": ["components/*"],
"@/data/*": ["data/*"],
"@/layouts/*": ["layouts/*"],
"@/lib/*": ["lib/*"],
"@/css/*": ["css/*"]
}
}
}

View file

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

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())
})
@ -19,7 +26,7 @@ export default function ListLayout({ posts, title, initialDisplayPosts = [], pag
return (
<>
<div className="divide-y">
<div className="pt-6 pb-8 space-y-2 md:space-y-5">
<div className="space-y-2 pt-6 pb-8 md:space-y-5">
<h1 className="text-3xl font-extrabold leading-9 tracking-tight text-gray-900 dark:text-gray-100 sm:text-4xl sm:leading-10 md:text-6xl md:leading-14">
{title}
</h1>
@ -29,10 +36,10 @@ export default function ListLayout({ posts, title, initialDisplayPosts = [], pag
type="text"
onChange={(e) => setSearchValue(e.target.value)}
placeholder="Search articles"
className="block w-full px-4 py-2 text-gray-900 bg-white border border-gray-300 rounded-md dark:border-gray-900 focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-800 dark:text-gray-100"
className="block w-full rounded-md border border-gray-300 bg-white px-4 py-2 text-gray-900 focus:border-primary-500 focus:ring-primary-500 dark:border-gray-900 dark:bg-gray-800 dark:text-gray-100"
/>
<svg
className="absolute w-5 h-5 text-gray-400 right-3 top-3 dark:text-gray-300"
className="absolute right-3 top-3 h-5 w-5 text-gray-400 dark:text-gray-300"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
@ -49,11 +56,11 @@ 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:space-y-0 xl:items-baseline">
<article className="space-y-2 xl:grid xl:grid-cols-4 xl:items-baseline xl:space-y-0">
<dl>
<dt className="sr-only">Published on</dt>
<dd className="text-base font-medium leading-6 text-gray-500 dark:text-gray-400">
@ -73,7 +80,7 @@ export default function ListLayout({ posts, title, initialDisplayPosts = [], pag
))}
</div>
</div>
<div className="prose text-gray-500 max-w-none dark:text-gray-400">
<div className="prose max-w-none text-gray-500 dark:text-gray-400">
{summary}
</div>
</div>

View file

@ -1,29 +1,48 @@
import Link from '@/components/Link'
import PageTitle from '@/components/PageTitle'
import SectionContainer from '@/components/SectionContainer'
import { BlogSeo } from '@/components/SEO'
import { BlogSEO } from '@/components/SEO'
import Image from '@/components/Image'
import Tag from '@/components/Tag'
import siteMetadata from '@/data/siteMetadata'
import Comments from '@/components/comments'
import ScrollTopAndComment from '@/components/ScrollTopAndComment'
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
<BlogSEO
url={`${siteMetadata.siteUrl}/blog/${slug}`}
authorDetails={authorDetails}
{...frontMatter}
{...content}
/>
<ScrollTopAndComment />
<article>
<div className="xl:divide-y xl:divide-gray-200 xl:dark:divide-gray-700">
<header className="pt-6 xl:pb-6">
@ -44,13 +63,13 @@ export default function PostLayout({ frontMatter, authorDetails, next, prev, chi
</div>
</header>
<div
className="pb-8 divide-y divide-gray-200 xl:divide-y-0 dark:divide-gray-700 xl:grid xl:grid-cols-4 xl:gap-x-6"
className="divide-y divide-gray-200 pb-8 dark:divide-gray-700 xl:grid xl:grid-cols-4 xl:gap-x-6 xl:divide-y-0"
style={{ gridTemplateRows: 'auto 1fr' }}
>
<dl className="pt-6 pb-10 xl:pt-11 xl:border-b xl:border-gray-200 xl:dark:border-gray-700">
<dl className="pt-6 pb-10 xl:border-b xl:border-gray-200 xl:pt-11 xl:dark:border-gray-700">
<dt className="sr-only">Authors</dt>
<dd>
<ul className="flex justify-center space-x-8 xl:block sm:space-x-12 xl:space-x-0 xl:space-y-8">
<ul className="flex justify-center space-x-8 sm:space-x-12 xl:block xl:space-x-0 xl:space-y-8">
{authorDetails.map((author) => (
<li className="flex items-center space-x-2" key={author.name}>
{author.avatar && (
@ -59,10 +78,10 @@ export default function PostLayout({ frontMatter, authorDetails, next, prev, chi
width="38px"
height="38px"
alt="avatar"
className="w-10 h-10 rounded-full"
className="h-10 w-10 rounded-full"
/>
)}
<dl className="text-sm font-medium leading-5 whitespace-nowrap">
<dl className="whitespace-nowrap text-sm font-medium leading-5">
<dt className="sr-only">Name</dt>
<dd className="text-gray-900 dark:text-gray-100">{author.name}</dd>
<dt className="sr-only">Twitter</dt>
@ -82,21 +101,22 @@ export default function PostLayout({ frontMatter, authorDetails, next, prev, chi
</ul>
</dd>
</dl>
<div className="divide-y divide-gray-200 dark:divide-gray-700 xl:pb-0 xl:col-span-3 xl:row-span-2">
<div className="pt-10 pb-8 prose dark:prose-dark max-w-none">{children}</div>
<div className="divide-y divide-gray-200 dark:divide-gray-700 xl:col-span-3 xl:row-span-2 xl:pb-0">
<div className="prose max-w-none pt-10 pb-8 dark:prose-dark">{children}</div>
<div className="pt-6 pb-6 text-sm text-gray-700 dark:text-gray-300">
<Link href={discussUrl(slug)} rel="nofollow">
{'Discuss on Twitter'}
</Link>
{``}
<Link href={editUrl(fileName)}>{'View on GitHub'}</Link>
<Link href={editUrl(slug)}>{'View on GitHub'}</Link>
</div>
{/* <Comments frontMatter={frontMatter} /> */}
</div>
<footer>
<div className="text-sm font-medium leading-5 divide-gray-200 xl:divide-y dark:divide-gray-700 xl:col-start-1 xl:row-start-2">
<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">
{tags && (
<div className="py-4 xl:py-8">
<h2 className="text-xs tracking-wide text-gray-500 uppercase dark:text-gray-400">
<h2 className="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
Tags
</h2>
<div className="flex flex-wrap">
@ -110,7 +130,7 @@ export default function PostLayout({ frontMatter, authorDetails, next, prev, chi
<div className="flex justify-between py-4 xl:block xl:space-y-8 xl:py-8">
{prev && (
<div>
<h2 className="text-xs tracking-wide text-gray-500 uppercase dark:text-gray-400">
<h2 className="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
Previous Article
</h2>
<div className="text-primary-500 hover:text-primary-600 dark:hover:text-primary-400">
@ -120,7 +140,7 @@ export default function PostLayout({ frontMatter, authorDetails, next, prev, chi
)}
{next && (
<div>
<h2 className="text-xs tracking-wide text-gray-500 uppercase dark:text-gray-400">
<h2 className="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
Next Article
</h2>
<div className="text-primary-500 hover:text-primary-600 dark:hover:text-primary-400">

View file

@ -1,20 +1,32 @@
import Link from '@/components/Link'
import PageTitle from '@/components/PageTitle'
import SectionContainer from '@/components/SectionContainer'
import { BlogSeo } from '@/components/SEO'
import { BlogSEO } from '@/components/SEO'
import siteMetadata from '@/data/siteMetadata'
import formatDate from '@/lib/utils/formatDate'
import Comments from '@/components/comments'
import ScrollTopAndComment from '@/components/ScrollTopAndComment'
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>
<header>
<div className="pb-10 space-y-1 text-center border-b border-gray-200 dark:border-gray-700">
<div className="space-y-1 border-b border-gray-200 pb-10 text-center dark:border-gray-700">
<dl>
<div>
<dt className="sr-only">Published on</dt>
@ -29,12 +41,13 @@ export default function PostLayout({ frontMatter, authorDetails, next, prev, chi
</div>
</header>
<div
className="pb-8 divide-y divide-gray-200 xl:divide-y-0 dark:divide-gray-700 "
className="divide-y divide-gray-200 pb-8 dark:divide-gray-700 xl:divide-y-0 "
style={{ gridTemplateRows: 'auto 1fr' }}
>
<div className="divide-y divide-gray-200 dark:divide-gray-700 xl:pb-0 xl:col-span-3 xl:row-span-2">
<div className="pt-10 pb-8 prose dark:prose-dark max-w-none">{children}</div>
<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} /> */}
<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/generated'
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,33 +0,0 @@
const visit = require('unist-util-visit')
const sizeOf = require('image-size')
const fs = require('fs')
module.exports = (options) => (tree) => {
visit(
tree,
// only visit p tags that contain an img element
(node) => node.type === 'paragraph' && node.children.some((n) => n.type === 'image'),
(node) => {
const imageNode = node.children.find((n) => n.type === 'image')
// only local files
if (fs.existsSync(`${process.cwd()}/public${imageNode.url}`)) {
const dimensions = sizeOf(`${process.cwd()}/public${imageNode.url}`)
// Convert original node to next/image
;(imageNode.type = 'mdxJsxFlowElement'),
(imageNode.name = 'Image'),
(imageNode.attributes = [
{ type: 'mdxJsxAttribute', name: 'alt', value: imageNode.alt },
{ type: 'mdxJsxAttribute', name: 'src', value: imageNode.url },
{ type: 'mdxJsxAttribute', name: 'width', value: dimensions.width },
{ type: 'mdxJsxAttribute', name: 'height', value: dimensions.height },
])
// Change node type from p to div to avoid nesting error
node.type = 'div'
node.children = [imageNode]
}
}
)
}

View file

@ -1,145 +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 codeTitles from './remark-code-title'
import imgToJsx from './img-to-jsx'
import getAllFilesRecursively from './utils/files'
const root = process.cwd()
const tokenClassNames = {
tag: 'text-code-red',
'attr-name': 'text-code-yellow',
'attr-value': 'text-code-green',
deleted: 'text-code-red',
inserted: 'text-code-green',
punctuation: 'text-code-white',
keyword: 'text-code-purple',
string: 'text-code-green',
function: 'text-code-blue',
boolean: 'text-code-red',
comment: 'text-gray-400 italic',
}
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(
process.cwd(),
'node_modules',
'esbuild',
'esbuild.exe'
)
} else {
process.env.ESBUILD_BINARY_PATH = path.join(
process.cwd(),
'node_modules',
'esbuild',
'bin',
'esbuild'
)
}
const { frontmatter, code } = await bundleMDX(source, {
// mdx imports can be automatically source from the components directory
cwd: path.join(process.cwd(), 'components'),
xdmOptions(options) {
// 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 ?? []),
require('remark-slug'),
require('remark-autolink-headings'),
require('remark-gfm'),
codeTitles,
[require('remark-footnotes'), { inlineNotes: true }],
require('remark-math'),
imgToJsx,
]
options.rehypePlugins = [
...(options.rehypePlugins ?? []),
require('rehype-katex'),
[require('rehype-prism-plus'), { ignoreMissing: true }],
() => {
return (tree) => {
visit(tree, 'element', (node, index, parent) => {
let [token, type] = node.properties.className || []
if (token === 'token') {
node.properties.className = [tokenClassNames[type]]
}
})
}
},
]
return options
},
esbuildOptions: (options) => {
options.loader = {
...options.loader,
'.js': 'jsx',
}
return options
},
})
return {
mdxSource: code,
frontMatter: {
readingTime: readingTime(code),
slug: slug || null,
fileName: fs.existsSync(mdxPath) ? `${slug}.mdx` : `${slug}.md`,
...frontmatter,
},
}
}
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 } = matter(source)
if (data.draft !== true) {
allFrontMatter.push({ ...data, slug: formatSlug(fileName) })
}
})
return allFrontMatter.sort((a, b) => dateSortDesc(a.date, b.date))
}

View file

@ -1,8 +1,9 @@
import visit from 'unist-util-visit'
import { Parent } from 'unist'
import { visit } from 'unist-util-visit'
module.exports = function (options) {
return (tree) =>
visit(tree, 'code', (node, index) => {
export default function remarkCodeTitles() {
return (tree: Parent & { lang?: string }) =>
visit(tree, 'code', (node: Parent & { lang?: string }, index, parent: Parent) => {
const nodeLang = node.lang || ''
let language = ''
let title = ''
@ -26,7 +27,7 @@ module.exports = function (options) {
data: { _xdmExplicitJsx: true },
}
tree.children.splice(index, 0, titleNode)
parent.children.splice(index, 0, titleNode)
node.lang = language
})
}

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

44
lib/remark-img-to-jsx.ts Normal file
View file

@ -0,0 +1,44 @@
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: Node) => {
visit(
tree,
// only visit p tags that contain an img element
(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}`)) {
const dimensions = sizeOf(`${process.cwd()}/public${imageNode.url}`)
// Convert original node to next/image
;(imageNode.type = 'mdxJsxFlowElement'),
(imageNode.name = 'Image'),
(imageNode.attributes = [
{ type: 'mdxJsxAttribute', name: 'alt', value: imageNode.alt },
{ type: 'mdxJsxAttribute', name: 'src', value: imageNode.url },
{ type: 'mdxJsxAttribute', name: 'width', value: dimensions.width },
{ type: 'mdxJsxAttribute', name: 'height', value: dimensions.height },
])
// Change node type from p to div to avoid nesting error
node.type = 'div'
node.children = [imageNode]
}
}
)
}
}

View file

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

@ -1,20 +1,23 @@
import fs from 'fs'
import path from 'path'
const pipe = (...fns) => (x) => fns.reduce((v, f) => f(v), x)
const pipe =
(...fns) =>
(x) =>
fns.reduce((v, f) => f(v), x)
const flattenArray = (input) =>
input.reduce((acc, item) => [...acc, ...(Array.isArray(item) ? item : [item])], [])
const map = (fn) => (input) => input.map(fn)
const walkDir = (fullPath) => {
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,8 +0,0 @@
const kebabCase = (str) =>
str &&
str
.match(/[A-Z]{2,}(?=[A-Z][a-z]+[0-9]*|\b)|[A-Z]?[a-z]+[0-9]*|[A-Z]|[0-9]+/g)
.map((x) => x.toLowerCase())
.join('-')
export default kebabCase

5
lib/utils/kebabCase.ts Normal file
View file

@ -0,0 +1,5 @@
import { slug } from 'github-slugger'
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/generated'
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,41 +1,107 @@
const { withContentlayer } = require('next-contentlayer')
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
})
module.exports = withBundleAnalyzer({
reactStrictMode: true,
pageExtensions: ['js', 'jsx', 'md', 'mdx'],
eslint: {
dirs: ['pages', 'components', 'lib', 'layouts', 'scripts'],
// You might need to insert additional domains in script-src if you are using external services
const ContentSecurityPolicy = `
default-src 'self';
script-src 'self' 'unsafe-eval' 'unsafe-inline' giscus.app;
style-src 'self' 'unsafe-inline' *.googleapis.com cdn.jsdelivr.net;
img-src * blob: data:;
media-src 'none';
connect-src *;
font-src 'self' fonts.gstatic.com cdn.jsdelivr.net;
frame-src giscus.app
`
const securityHeaders = [
// https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP
{
key: 'Content-Security-Policy',
value: ContentSecurityPolicy.replace(/\n/g, ''),
},
webpack: (config, { dev, isServer }) => {
config.module.rules.push({
test: /\.(png|jpe?g|gif|mp4)$/i,
use: [
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Referrer-Policy
{
key: 'Referrer-Policy',
value: 'strict-origin-when-cross-origin',
},
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Frame-Options
{
key: 'X-Frame-Options',
value: 'DENY',
},
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Content-Type-Options
{
key: 'X-Content-Type-Options',
value: 'nosniff',
},
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-DNS-Prefetch-Control
{
key: 'X-DNS-Prefetch-Control',
value: 'on',
},
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Strict-Transport-Security
{
key: 'Strict-Transport-Security',
value: 'max-age=31536000; includeSubDomains; preload',
},
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Feature-Policy
{
key: 'Permissions-Policy',
value: 'camera=(), microphone=(), geolocation=()',
},
]
/**
* @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: '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
},
})
)

9746
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,62 +1,75 @@
{
"name": "tailwind-nextjs-starter-blog",
"version": "0.4.1",
"version": "1.4.2",
"private": true,
"scripts": {
"start": "next-remote-watch ./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",
"prepare": "husky install"
},
"dependencies": {
"@tailwindcss/forms": "^0.3.2",
"@tailwindcss/typography": "^0.4.0",
"autoprefixer": "^10.2.5",
"@fontsource/inter": "4.5.2",
"@mailchimp/mailchimp_marketing": "^3.0.58",
"@tailwindcss/forms": "^0.4.0",
"@tailwindcss/typography": "^0.5.0",
"autoprefixer": "^10.4.0",
"contentlayer": "0.0.35-dev.0",
"esbuild": "^0.13.13",
"github-slugger": "^1.3.0",
"gray-matter": "^4.0.2",
"image-size": "1.0.0",
"mdx-bundler": "^4.1.0",
"next": "11.0.1",
"mdx-bundler": "^8.0.0",
"next": "12.0.9",
"next-contentlayer": "0.0.35-dev.0",
"next-themes": "^0.0.14",
"postcss": "^8.3.5",
"preact": "^10.5.13",
"postcss": "^8.4.5",
"preact": "^10.6.2",
"react": "17.0.2",
"react-dom": "17.0.2",
"reading-time": "1.3.0",
"rehype-katex": "^5.0.0",
"rehype-prism-plus": "0.0.1",
"remark-autolink-headings": "6.0.1",
"remark-footnotes": "^3.0.0",
"remark-gfm": "^1.0.0",
"remark-math": "^4.0.0",
"remark-slug": "6.0.0",
"tailwindcss": "^2.2.2"
"rehype-autolink-headings": "^6.1.0",
"rehype-citation": "^0.2.0",
"rehype-katex": "^6.0.2",
"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",
"sharp": "^0.28.3",
"smoothscroll-polyfill": "^0.4.4",
"tailwindcss": "^3.0.18",
"unist-util-visit": "^4.0.0"
},
"devDependencies": {
"@next/bundle-analyzer": "11.0.1",
"@svgr/webpack": "^5.5.0",
"@next/bundle-analyzer": "12.0.9",
"@svgr/webpack": "^6.1.2",
"@types/react": "^17.0.14",
"@types/tailwindcss": "^2.2.0",
"@typescript-eslint/eslint-plugin": "^4.28.1",
"@typescript-eslint/parser": "^4.28.1",
"cross-env": "^7.0.3",
"dedent": "^0.7.0",
"eslint": "^7.29.0",
"eslint-config-next": "11.0.1",
"eslint-config-next": "12.0.9",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-jsx-a11y": "^6.4.1",
"eslint-plugin-prettier": "^3.3.1",
"file-loader": "^6.0.0",
"globby": "11.0.3",
"husky": "^6.0.0",
"inquirer": "^8.1.1",
"lint-staged": "^11.0.0",
"next-remote-watch": "^1.0.0",
"prettier": "2.2.1",
"rehype": "11.0.0",
"remark-frontmatter": "3.0.0",
"remark-parse": "9.0.0",
"remark-stringify": "9.0.1",
"unified": "9.2.1",
"unist-util-visit": "2.0.3"
"prettier": "^2.5.1",
"prettier-plugin-tailwindcss": "^0.1.4",
"typescript": "4.3.5"
},
"lint-staged": {
"*.+(js|jsx|ts|tsx)": [

View file

@ -2,9 +2,9 @@ import Link from '@/components/Link'
export default function FourZeroFour() {
return (
<div className="flex flex-col items-start justify-start md:justify-center md:items-center md:flex-row md:space-x-6 md:mt-24">
<div className="pt-6 pb-8 space-x-2 md:space-y-5">
<h1 className="text-6xl font-extrabold leading-9 tracking-tight text-gray-900 dark:text-gray-100 md:text-8xl md:leading-14 md:border-r-2 md:px-6">
<div className="flex flex-col items-start justify-start md:mt-24 md:flex-row md:items-center md:justify-center md:space-x-6">
<div className="space-x-2 pt-6 pb-8 md:space-y-5">
<h1 className="text-6xl font-extrabold leading-9 tracking-tight text-gray-900 dark:text-gray-100 md:border-r-2 md:px-6 md:text-8xl md:leading-14">
404
</h1>
</div>
@ -14,7 +14,7 @@ export default function FourZeroFour() {
</p>
<p className="mb-8">But dont worry, you can find plenty of other things on our homepage.</p>
<Link href="/">
<button className="inline px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-blue-600 border border-transparent rounded-lg shadow focus:outline-none focus:shadow-outline-blue hover:bg-blue-700 dark:hover:bg-blue-500">
<button className="focus:shadow-outline-blue inline rounded-lg border border-transparent bg-blue-600 px-4 py-2 text-sm font-medium leading-5 text-white shadow transition-colors duration-150 hover:bg-blue-700 focus:outline-none dark:hover:bg-blue-500">
Back to homepage
</button>
</Link>

View file

@ -1,16 +1,23 @@
import '@/css/tailwind.css'
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'
export default function App({ Component, pageProps }) {
export default function App({ Component, pageProps }: AppProps) {
return (
<ThemeProvider attribute="class">
<ThemeProvider attribute="class" defaultTheme={siteMetadata.theme}>
<Head>
<meta content="width=device-width, initial-scale=1" name="viewport" />
</Head>
<Analytics />
<LayoutWrapper>
<Component {...pageProps} />
</LayoutWrapper>

View file

@ -1,8 +1,15 @@
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">
<Html lang="en" className="scroll-smooth">
<Head>
<link rel="apple-touch-icon" sizes="76x76" href="/static/favicons/apple-touch-icon.png" />
<link
@ -22,11 +29,6 @@ class MyDocument extends Document {
<meta name="msapplication-TileColor" content="#000000" />
<meta name="theme-color" content="#000000" />
<link rel="alternate" type="application/rss+xml" href="/feed.xml" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap"
rel="stylesheet"
/>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/katex@0.13.11/dist/katex.min.css"
@ -34,7 +36,7 @@ class MyDocument extends Document {
crossOrigin="anonymous"
/>
</Head>
<body className="antialiased text-black bg-white dark:bg-gray-900 dark:text-white">
<body className="bg-white text-black antialiased dark:bg-gray-900 dark:text-white">
<Main />
<NextScript />
</body>

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/generated'
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} />
}

32
pages/api/buttondown.ts Normal file
View file

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

37
pages/api/convertkit.ts Normal file
View file

@ -0,0 +1,37 @@
import { NextApiRequest, NextApiResponse } from 'next'
/* eslint-disable import/no-anonymous-default-export */
export default async (req: NextApiRequest, res: NextApiResponse) => {
const { email } = req.body
if (!email) {
return res.status(400).json({ error: 'Email is required' })
}
try {
const FORM_ID = process.env.CONVERTKIT_FORM_ID
const API_KEY = process.env.CONVERTKIT_API_KEY
const API_URL = process.env.CONVERTKIT_API_URL
// Send request to ConvertKit
const data = { email, api_key: API_KEY }
const response = await fetch(`${API_URL}forms/${FORM_ID}/subscribe`, {
body: JSON.stringify(data),
headers: {
'Content-Type': 'application/json',
},
method: 'POST',
})
if (response.status >= 400) {
return res.status(400).json({
error: `There was an error subscribing to the list.`,
})
}
return res.status(201).json({ error: '' })
} catch (error) {
return res.status(500).json({ error: error.message || error.toString() })
}
}

37
pages/api/klaviyo.ts Normal file
View file

@ -0,0 +1,37 @@
import { NextApiRequest, NextApiResponse } from 'next'
/* eslint-disable import/no-anonymous-default-export */
export default async (req: NextApiRequest, res: NextApiResponse) => {
const { email } = req.body
if (!email) {
return res.status(400).json({ error: 'Email is required' })
}
try {
const API_KEY = process.env.KLAVIYO_API_KEY
const LIST_ID = process.env.KLAVIYO_LIST_ID
const response = await fetch(
`https://a.klaviyo.com/api/v2/list/${LIST_ID}/subscribe?api_key=${API_KEY}`,
{
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
// You can add additional params here i.e. SMS, etc.
// https://developers.klaviyo.com/en/reference/subscribe
body: JSON.stringify({
profiles: [{ email: email }],
}),
}
)
if (response.status >= 400) {
return res.status(400).json({
error: `There was an error subscribing to the list.`,
})
}
return res.status(201).json({ error: '' })
} catch (error) {
return res.status(500).json({ error: error.message || error.toString() })
}
}

26
pages/api/mailchimp.ts Normal file
View file

@ -0,0 +1,26 @@
import { NextApiRequest, NextApiResponse } from 'next'
import mailchimp from '@mailchimp/mailchimp_marketing'
mailchimp.setConfig({
apiKey: process.env.MAILCHIMP_API_KEY,
server: process.env.MAILCHIMP_API_SERVER, // E.g. us1
})
// eslint-disable-next-line import/no-anonymous-default-export
export default async (req: NextApiRequest, res: NextApiResponse) => {
const { email } = req.body
if (!email) {
return res.status(400).json({ error: 'Email is required' })
}
try {
await mailchimp.lists.addListMember(process.env.MAILCHIMP_AUDIENCE_ID, {
email_address: email,
status: 'subscribed',
})
return res.status(201).json({ error: '' })
} catch (error) {
return res.status(500).json({ error: error.message || error.toString() })
}
}

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 { PageSEO } from '@/components/SEO'
import * as temp from '@/lib/utils/temp'
import { InferGetStaticPropsType } from 'next'
import { allBlogs } from 'contentlayer/generated'
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,10 +18,14 @@ 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} />
<PageSEO title={`Blog - ${siteMetadata.author}`} description={siteMetadata.description} />
<ListLayout
posts={posts}
initialDisplayPosts={initialDisplayPosts}

View file

@ -1,67 +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
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, frontMatter } = post
return (
<>
{frontMatter.draft !== true ? (
<MDXLayoutRenderer
layout={frontMatter.layout || DEFAULT_LAYOUT}
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/generated'
import type { Blog } from 'contentlayer/generated'
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 { 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/generated'
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,10 +43,14 @@ 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} />
<PageSEO title={siteMetadata.title} description={siteMetadata.description} />
<ListLayout
posts={posts}
initialDisplayPosts={initialDisplayPosts}

View file

@ -1,24 +1,29 @@
import Link from '@/components/Link'
import { PageSeo } from '@/components/SEO'
import { PageSEO } from '@/components/SEO'
import Tag from '@/components/Tag'
import siteMetadata from '@/data/siteMetadata'
import { getAllFilesFrontMatter } from '@/lib/mdx'
import formatDate from '@/lib/utils/formatDate'
import * as temp from '@/lib/utils/temp'
import { InferGetStaticPropsType } from 'next'
import NewsletterForm from '@/components/NewsletterForm'
import { allBlogs } from 'contentlayer/generated'
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} />
<PageSEO title={siteMetadata.title} description={siteMetadata.description} />
<div className="divide-y divide-gray-200 dark:divide-gray-700">
<div className="pt-6 pb-8 space-y-2 md:space-y-5">
<div className="space-y-2 pt-6 pb-8 md:space-y-5">
<h1 className="text-3xl font-extrabold leading-9 tracking-tight text-gray-900 dark:text-gray-100 sm:text-4xl sm:leading-10 md:text-6xl md:leading-14">
Latest
</h1>
@ -28,12 +33,12 @@ 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>
<div className="space-y-2 xl:grid xl:grid-cols-4 xl:space-y-0 xl:items-baseline">
<div className="space-y-2 xl:grid xl:grid-cols-4 xl:items-baseline xl:space-y-0">
<dl>
<dt className="sr-only">Published on</dt>
<dd className="text-base font-medium leading-6 text-gray-500 dark:text-gray-400">
@ -57,7 +62,7 @@ export default function Home({ posts }) {
))}
</div>
</div>
<div className="prose text-gray-500 max-w-none dark:text-gray-400">
<div className="prose max-w-none text-gray-500 dark:text-gray-400">
{summary}
</div>
</div>
@ -89,6 +94,11 @@ export default function Home({ posts }) {
</Link>
</div>
)}
{siteMetadata.newsletter.provider !== '' && (
<div className="flex items-center justify-center pt-4">
<NewsletterForm />
</div>
)}
</>
)
}

View file

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

View file

@ -1,28 +1,32 @@
import Link from '@/components/Link'
import { PageSeo } from '@/components/SEO'
import { PageSEO } from '@/components/SEO'
import Tag from '@/components/Tag'
import siteMetadata from '@/data/siteMetadata'
import { getAllTags } from '@/lib/tags'
import kebabCase from '@/lib/utils/kebabCase'
import * as temp from '@/lib/utils/temp'
import { GetStaticProps, InferGetStaticPropsType } from 'next'
import { allBlogs } from 'contentlayer/generated'
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 (
<>
<PageSeo title={`Tags - ${siteMetadata.author}`} description="Things I blog about" />
<div className="flex flex-col items-start justify-start divide-y divide-gray-200 dark:divide-gray-700 md:justify-center md:items-center md:divide-y-0 md:flex-row md:space-x-6 md:mt-24">
<div className="pt-6 pb-8 space-x-2 md:space-y-5">
<h1 className="text-3xl font-extrabold leading-9 tracking-tight text-gray-900 dark:text-gray-100 sm:text-4xl sm:leading-10 md:text-6xl md:leading-14 md:border-r-2 md:px-6">
<PageSEO title={`Tags - ${siteMetadata.author}`} description="Things I blog about" />
<div className="flex flex-col items-start justify-start divide-y divide-gray-200 dark:divide-gray-700 md:mt-24 md:flex-row md:items-center md:justify-center md:space-x-6 md:divide-y-0">
<div className="space-x-2 pt-6 pb-8 md:space-y-5">
<h1 className="text-3xl font-extrabold leading-9 tracking-tight text-gray-900 dark:text-gray-100 sm:text-4xl sm:leading-10 md:border-r-2 md:px-6 md:text-6xl md:leading-14">
Tags
</h1>
</div>
<div className="flex flex-wrap max-w-lg">
<div className="flex max-w-lg flex-wrap">
{Object.keys(tags).length === 0 && 'No tags found.'}
{sortedTags.map((t) => {
return (
@ -30,7 +34,7 @@ export default function Tags({ tags }) {
<Tag text={t} />
<Link
href={`/tags/${kebabCase(t)}`}
className="-ml-2 text-sm font-semibold text-gray-600 uppercase dark:text-gray-300"
className="-ml-2 text-sm font-semibold uppercase text-gray-600 dark:text-gray-300"
>
{` (${tags[t]})`}
</Link>

View file

@ -1,53 +0,0 @@
import { PageSeo } 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
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 (
<>
<PageSeo
title={`${tag} - ${siteMetadata.title}`}
description={`${tag} tags - ${siteMetadata.title}`}
/>
<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/generated'
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} />
</>
)
}

View file

@ -101,6 +101,7 @@ inquirer
.replace(/ /g, '-')
.replace(/-+/g, '-')
const frontMatter = genFrontMatter(answers)
if (!fs.existsSync('data/blog')) fs.mkdirSync('data/blog', { recursive: true })
const filePath = `data/blog/${fileName ? fileName : 'untitled'}.${
answers.extension ? answers.extension : 'md'
}`

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/generated'
// 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,36 +1,34 @@
const fs = require('fs')
const globby = require('globby')
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/generated'
;(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',
'data/**/*.mdx',
'data/**/*.md',
'pages/*.{js|tsx}',
'public/tags/**/*.xml',
'!pages/_*.js',
'!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) => {
const path = page
.replace('pages/', '/')
.replace('data/blog', '/blog')
.replace('public/', '/')
.replace('.js', '')
.replace('.mdx', '')
.replace('.md', '')
.replace('/feed.xml', '')
const route = path === '/index' ? '' : path
if (page === `pages/404.js` || page === `pages/blog/[...slug].js`) {
return
}
return `
<url>
<loc>${siteMetadata.siteUrl}${route}</loc>
@ -46,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,9 +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 = {
mode: 'jit',
purge: ['./pages/**/*.js', './components/**/*.js', './layouts/**/*.js', './lib/**/*.js'],
content: ['./pages/**/*.tsx', './components/**/*.tsx', './layouts/**/*.tsx', './lib/**/*.ts'],
darkMode: 'class',
theme: {
extend: {
@ -17,19 +20,12 @@ module.exports = {
14: '3.5rem',
},
fontFamily: {
sans: ['Inter', ...defaultTheme.fontFamily.sans],
sans: ['InterVariable', ...defaultTheme.fontFamily.sans],
},
colors: {
primary: colors.teal,
gray: colors.trueGray,
code: {
green: '#b5f4a5',
yellow: '#ffe484',
purple: '#d9a9ff',
red: '#ff8383',
blue: '#93ddfd',
white: '#fff',
},
//@ts-ignore
gray: colors.neutral, // TODO: Remove ts-ignore after tw types gets updated to v3
},
typography: (theme) => ({
DEFAULT: {
@ -38,7 +34,7 @@ module.exports = {
a: {
color: theme('colors.primary.500'),
'&:hover': {
color: theme('colors.primary.600'),
color: `${theme('colors.primary.600')} !important`,
},
code: { color: theme('colors.primary.400') },
},
@ -59,6 +55,9 @@ module.exports = {
'h4,h5,h6': {
color: theme('colors.gray.900'),
},
pre: {
backgroundColor: theme('colors.gray.800'),
},
code: {
color: theme('colors.pink.500'),
backgroundColor: theme('colors.gray.100'),
@ -68,18 +67,26 @@ module.exports = {
paddingBottom: '2px',
borderRadius: '0.25rem',
},
'code:before': {
'code::before': {
content: 'none',
},
'code:after': {
'code::after': {
content: 'none',
},
details: {
backgroundColor: theme('colors.gray.100'),
paddingLeft: '4px',
paddingRight: '4px',
paddingTop: '2px',
paddingBottom: '2px',
borderRadius: '0.25rem',
},
hr: { borderColor: theme('colors.gray.200') },
'ol li:before': {
'ol li::marker': {
fontWeight: '600',
color: theme('colors.gray.500'),
},
'ul li:before': {
'ul li::marker': {
backgroundColor: theme('colors.gray.500'),
},
strong: { color: theme('colors.gray.600') },
@ -95,7 +102,7 @@ module.exports = {
a: {
color: theme('colors.primary.500'),
'&:hover': {
color: theme('colors.primary.400'),
color: `${theme('colors.primary.400')} !important`,
},
code: { color: theme('colors.primary.400') },
},
@ -116,20 +123,28 @@ module.exports = {
'h4,h5,h6': {
color: theme('colors.gray.100'),
},
pre: {
backgroundColor: theme('colors.gray.800'),
},
code: {
backgroundColor: theme('colors.gray.800'),
},
details: {
backgroundColor: theme('colors.gray.800'),
},
hr: { borderColor: theme('colors.gray.700') },
'ol li:before': {
'ol li::marker': {
fontWeight: '600',
color: theme('colors.gray.400'),
},
'ul li:before': {
'ul li::marker': {
backgroundColor: theme('colors.gray.400'),
},
strong: { color: theme('colors.gray.100') },
thead: {
color: theme('colors.gray.100'),
th: {
color: theme('colors.gray.100'),
},
},
tbody: {
tr: {
@ -145,8 +160,5 @@ module.exports = {
}),
},
},
variants: {
typography: ['dark'],
},
plugins: [require('@tailwindcss/forms'), require('@tailwindcss/typography')],
}

29
tsconfig.json Normal file
View file

@ -0,0 +1,29 @@
{
"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/*"],
"contentlayer/generated": ["./.contentlayer/generated"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".contentlayer/generated"],
"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
}

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