Initial commit
29
.env.example
Normal file
@ -0,0 +1,29 @@
|
||||
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=
|
||||
|
||||
REVUE_API_URL=https://www.getrevue.co/api/v2/
|
||||
REVUE_API_KEY=
|
||||
|
||||
EMAILOCTOPUS_API_URL=https://emailoctopus.com/api/1.6/
|
||||
EMAILOCTOPUS_API_KEY=
|
||||
EMAILOCTOPUS_LIST_ID=
|
1
.eslintignore
Normal file
@ -0,0 +1 @@
|
||||
node_modules
|
17
.eslintrc.js
Normal file
@ -0,0 +1,17 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: {
|
||||
browser: true,
|
||||
amd: true,
|
||||
node: true,
|
||||
es6: true,
|
||||
},
|
||||
extends: ['eslint:recommended', 'plugin:prettier/recommended', 'next', 'next/core-web-vitals'],
|
||||
rules: {
|
||||
'prettier/prettier': 'error',
|
||||
'react/react-in-jsx-scope': 'off',
|
||||
'react/prop-types': 0,
|
||||
'no-unused-vars': 0,
|
||||
'react/no-unescaped-entities': 0,
|
||||
},
|
||||
}
|
202
.gitattributes
vendored
Normal file
@ -0,0 +1,202 @@
|
||||
## Source: https://github.com/alexkaratarakis/gitattributes
|
||||
## Modified * text=auto to * text=auto eol=lf to force LF endings.
|
||||
|
||||
## GITATTRIBUTES FOR WEB PROJECTS
|
||||
#
|
||||
# These settings are for any web project.
|
||||
#
|
||||
# Details per file setting:
|
||||
# text These files should be normalized (i.e. convert CRLF to LF).
|
||||
# binary These files are binary and should be left untouched.
|
||||
#
|
||||
# Note that binary is a macro for -text -diff.
|
||||
######################################################################
|
||||
|
||||
# Auto detect
|
||||
## Force LF line endings automatically for files detected as
|
||||
## text and leave all files detected as binary untouched.
|
||||
## This will handle all files NOT defined below.
|
||||
* text=auto eol=lf
|
||||
|
||||
# Source code
|
||||
*.bash text eol=lf
|
||||
*.bat text eol=crlf
|
||||
*.cmd text eol=crlf
|
||||
*.coffee text
|
||||
*.css text
|
||||
*.htm text diff=html
|
||||
*.html text diff=html
|
||||
*.inc text
|
||||
*.ini text
|
||||
*.js text
|
||||
*.json text
|
||||
*.jsx text
|
||||
*.less text
|
||||
*.ls text
|
||||
*.map text -diff
|
||||
*.od text
|
||||
*.onlydata text
|
||||
*.php text diff=php
|
||||
*.pl text
|
||||
*.ps1 text eol=crlf
|
||||
*.py text diff=python
|
||||
*.rb text diff=ruby
|
||||
*.sass text
|
||||
*.scm text
|
||||
*.scss text diff=css
|
||||
*.sh text eol=lf
|
||||
*.sql text
|
||||
*.styl text
|
||||
*.tag text
|
||||
*.ts text
|
||||
*.tsx text
|
||||
*.xml text
|
||||
*.xhtml text diff=html
|
||||
|
||||
# Docker
|
||||
Dockerfile text
|
||||
|
||||
# Documentation
|
||||
*.ipynb text
|
||||
*.markdown text
|
||||
*.md text
|
||||
*.mdwn text
|
||||
*.mdown text
|
||||
*.mkd text
|
||||
*.mkdn text
|
||||
*.mdtxt text
|
||||
*.mdtext text
|
||||
*.txt text
|
||||
AUTHORS text
|
||||
CHANGELOG text
|
||||
CHANGES text
|
||||
CONTRIBUTING text
|
||||
COPYING text
|
||||
copyright text
|
||||
*COPYRIGHT* text
|
||||
INSTALL text
|
||||
license text
|
||||
LICENSE text
|
||||
NEWS text
|
||||
readme text
|
||||
*README* text
|
||||
TODO text
|
||||
|
||||
# Templates
|
||||
*.dot text
|
||||
*.ejs text
|
||||
*.haml text
|
||||
*.handlebars text
|
||||
*.hbs text
|
||||
*.hbt text
|
||||
*.jade text
|
||||
*.latte text
|
||||
*.mustache text
|
||||
*.njk text
|
||||
*.phtml text
|
||||
*.tmpl text
|
||||
*.tpl text
|
||||
*.twig text
|
||||
*.vue text
|
||||
|
||||
# Configs
|
||||
*.cnf text
|
||||
*.conf text
|
||||
*.config text
|
||||
.editorconfig text
|
||||
.env text
|
||||
.gitattributes text
|
||||
.gitconfig text
|
||||
.htaccess text
|
||||
*.lock text -diff
|
||||
package-lock.json text -diff
|
||||
*.toml text
|
||||
*.yaml text
|
||||
*.yml text
|
||||
browserslist text
|
||||
Makefile text
|
||||
makefile text
|
||||
|
||||
# Heroku
|
||||
Procfile text
|
||||
|
||||
# Graphics
|
||||
*.ai binary
|
||||
*.bmp binary
|
||||
*.eps binary
|
||||
*.gif binary
|
||||
*.gifv binary
|
||||
*.ico binary
|
||||
*.jng binary
|
||||
*.jp2 binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.jpx binary
|
||||
*.jxr binary
|
||||
*.pdf binary
|
||||
*.png binary
|
||||
*.psb binary
|
||||
*.psd binary
|
||||
# SVG treated as an asset (binary) by default.
|
||||
*.svg text
|
||||
# If you want to treat it as binary,
|
||||
# use the following line instead.
|
||||
# *.svg binary
|
||||
*.svgz binary
|
||||
*.tif binary
|
||||
*.tiff binary
|
||||
*.wbmp binary
|
||||
*.webp binary
|
||||
|
||||
# Audio
|
||||
*.kar binary
|
||||
*.m4a binary
|
||||
*.mid binary
|
||||
*.midi binary
|
||||
*.mp3 binary
|
||||
*.ogg binary
|
||||
*.ra binary
|
||||
|
||||
# Video
|
||||
*.3gpp binary
|
||||
*.3gp binary
|
||||
*.as binary
|
||||
*.asf binary
|
||||
*.asx binary
|
||||
*.fla binary
|
||||
*.flv binary
|
||||
*.m4v binary
|
||||
*.mng binary
|
||||
*.mov binary
|
||||
*.mp4 binary
|
||||
*.mpeg binary
|
||||
*.mpg binary
|
||||
*.ogv binary
|
||||
*.swc binary
|
||||
*.swf binary
|
||||
*.webm binary
|
||||
|
||||
# Archives
|
||||
*.7z binary
|
||||
*.gz binary
|
||||
*.jar binary
|
||||
*.rar binary
|
||||
*.tar binary
|
||||
*.zip binary
|
||||
|
||||
# Fonts
|
||||
*.ttf binary
|
||||
*.eot binary
|
||||
*.otf binary
|
||||
*.woff binary
|
||||
*.woff2 binary
|
||||
|
||||
# Executables
|
||||
*.exe binary
|
||||
*.pyc binary
|
||||
|
||||
# RC files (like .babelrc or .eslintrc)
|
||||
*.*rc text
|
||||
|
||||
# Ignore files (like .npmignore or .gitignore)
|
||||
*.*ignore text
|
3
.github/FUNDING.yml
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: timlrx
|
37
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@ -0,0 +1,37 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: bug
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**System Info (if dev / build issue):**
|
||||
- OS: [e.g. iOS]
|
||||
- Node version (please ensure you are using 14+)
|
||||
- Npm version
|
||||
|
||||
**Browser Info (if display / formatting issue):**
|
||||
- Device [e.g. Desktop, iPhone6]
|
||||
- Browser [e.g. chrome, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@ -0,0 +1,20 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
36
.gitignore
vendored
Normal file
@ -0,0 +1,36 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
public/sitemap.xml
|
||||
.vercel
|
||||
|
||||
# production
|
||||
/build
|
||||
*.xml
|
||||
# rss feed
|
||||
/public/feed.xml
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
|
||||
# debug
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# local env files
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
1
.husky/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
_
|
4
.husky/pre-commit
Executable file
@ -0,0 +1,4 @@
|
||||
#!/bin/sh
|
||||
. "$(dirname "$0")/_/husky.sh"
|
||||
|
||||
npx --no-install lint-staged
|
21
LICENSE
Normal file
@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2021 Timothy Lin
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
250
README.md
Normal file
@ -0,0 +1,250 @@
|
||||
![tailwind-nextjs-banner](/public/static/images/twitter-card.png)
|
||||
|
||||
# Tailwind Nextjs Starter Blog
|
||||
|
||||
[![GitHub Repo stars](https://img.shields.io/github/stars/timlrx/tailwind-nextjs-starter-blog?style=social)](https://GitHub.com/timlrx/tailwind-nextjs-starter-blog/stargazers/)
|
||||
[![GitHub forks](https://img.shields.io/github/forks/timlrx/tailwind-nextjs-starter-blog?style=social)](https://GitHub.com/timlrx/tailwind-nextjs-starter-blog/network/)
|
||||
[![Twitter URL](https://img.shields.io/twitter/url?style=social&url=https%3A%2F%2Ftwitter.com%2Ftimlrxx)](https://twitter.com/timlrxx)
|
||||
[![Sponsor](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub&link=https://github.com/sponsors/timlrx)](https://github.com/sponsors/timlrx)
|
||||
|
||||
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/git/external?repository-url=https://github.com/timlrx/tailwind-nextjs-starter-blog)
|
||||
|
||||
This is a [Next.js](https://nextjs.org/), [Tailwind CSS](https://tailwindcss.com/) blogging starter template. Probably the most feature-rich Next.js markdown blogging template out there. Comes out of the box configured with the latest technologies to make technical writing a breeze. Easily configurable and customizable. Perfect as a replacement to existing Jekyll and Hugo individual blogs.
|
||||
|
||||
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.
|
||||
- [the all JavaScript Blog](https://the-all-javascript-blog.vercel.app/) - a JavaScript enlightenment blog uses this
|
||||
- [KirillSo.com](https://www.kirillso.com/) - Personal blog & website.
|
||||
- [ghali.dev](https://ghali.dev) - Cyril's Blog
|
||||
- [DevBoy Blog](https://devboy.vercel.app/) - M.Reza's personal blog
|
||||
- [slightlysharpe.com](https://slightlysharpe.com) - [Tincre's](https://tincre.com) main company blog
|
||||
- [blog.b00st.com](https://blog.b00st.com) - [b00st.com's](https://b00st.com) main music promotion blog
|
||||
- [astrosaurus.me](https://astrosaurus.me/) - Ephraim Atta-Duncan's Personal Blog
|
||||
- [dhanrajsp.me](https://dhanrajsp.me/) - Dhanraj's personal site and blog.
|
||||
- [blog.r00ks.io](https://blog.r00ks.io/) - Austin Rooks's personal blog ([source code](https://github.com/Austionian/blog.r00ks)).
|
||||
- [honghong.me](https://honghong.me) - Tszhong's personal website ([source code](https://github.com/tszhong0411/home))
|
||||
- [alfoncode.com](https://alfoncode.com) - Alfonso García's personar website. Customized design ([source code](https://github.com/alfoncode/personal-web))
|
||||
- [marceloformentao.dev](https://marceloformentao.dev) - Marcelo Formentão personal website ([source code](https://github.com/marceloavf/marceloformentao.dev)).
|
||||
- [abiraja.com](https://www.abiraja.com/) - with a [runnable JS code snippet component!](https://www.abiraja.com/blog/querying-solana-blockchain)
|
||||
- [einargudni.com](https://www.einargudni.com) - with a customized theme, command pallette and more ([source code](https://github.com/einargudnig/einargudni.com))
|
||||
- [bpiggin.com](https://www.bpiggin.com) - Ben Piggin's personal blog
|
||||
- [maqib.cn](https://maqib.cn) - A blog of Chinese front-end developers 狂奔小马的博客 ([源码](https://github.com/maqi1520/nextjs-tailwind-blog))
|
||||
- [ambilena.com](https://ambilena.com/) - Electronic Music Blog & imprint for upcoming musicians.
|
||||
- [kittan.ru](https://www.kittan.ru/) - Kittanb's personal blog about linux ([source code](https://github.com/kittanb/blog))
|
||||
- [nchristopher.me](https://nchristopher.me) - Nicholas Christopher's personal website and blog ([source code](https://github.com/nchristopher/blog))
|
||||
- [dalelarroder.com](https://dalelarroder.com) - Dale Larroder's personal website and blog ([source code](https://github.com/dlarroder/dalelarroder))
|
||||
- [devahoy.com](https://devahoy.com) - Chai's personal blog (Thai language)
|
||||
- [0xchai.io](https://0xchai.io) - Chai's personal blog
|
||||
- [techipedia](https://techipedia.vercel.app) - Simple blogging progressive web app with custom installation button and top progress bar
|
||||
- [reubence.com](https://reubence.com) - Reuben Rapose's Digital Garden
|
||||
- [axolo.co/blog](https://axolo.co/blog) - Engineering management news & axolo.co updates (with image preview for article in the home page)
|
||||
- [musing.vercel.app](https://musing.vercel.app/) - Parth Desai's personal blog ([source code](https://github.com/pycoder2000/blog))
|
||||
- [onyourmental.com](https://www.onyourmental.com/) - [Curtis Warcup's](https://github.com/Cwarcup) website for the On Your Mental Podcast ([source code](https://github.com/Cwarcup/on-your-mental))
|
||||
- [cwarcup.com](https://www.cwarcup.com/) - Curtis Warcup's personal website and blog ([source code](https://github.com/Cwarcup/personal-blog).
|
||||
|
||||
Using the template? Feel free to create a PR and add your blog to this list.
|
||||
|
||||
## Motivation
|
||||
|
||||
I wanted to port my existing blog to Nextjs and Tailwind CSS but there was no easy out of the box template to use so I decided to create one. Design is adapted from [Tailwindlabs blog](https://github.com/tailwindlabs/blog.tailwindcss.com).
|
||||
|
||||
I wanted it to be nearly as feature-rich as popular blogging templates like [beautiful-jekyll](https://github.com/daattali/beautiful-jekyll) and [Hugo Academic](https://github.com/wowchemy/wowchemy-hugo-modules) but with the best of React's ecosystem and current web development's best practices.
|
||||
|
||||
## Features
|
||||
|
||||
- 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, 45kB first load JS, uses Preact in production build
|
||||
- Mobile-friendly view
|
||||
- Light and dark theme
|
||||
- Self-hosted font with [Fontsource](https://fontsource.org/)
|
||||
- Supports [plausible](https://plausible.io/), [simple analytics](https://simpleanalytics.com/) and google analytics
|
||||
- [MDX - write JSX in markdown documents!](https://mdxjs.com/)
|
||||
- Server-side syntax highlighting with line numbers and line highlighting via [rehype-prism-plus](https://github.com/timlrx/rehype-prism-plus)
|
||||
- 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, klaviyo, revue, and emailoctopus
|
||||
- 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](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. Try installing the starter using the new [Pliny project CLI](https://github.com/timlrx/pliny):
|
||||
|
||||
```bash
|
||||
npm i -g @pliny/cli
|
||||
pliny new --template=starter-blog my-blog
|
||||
```
|
||||
|
||||
It supports the updated version of the blog with Contentlayer, optional choice of TS/JS and different package managers as well as more modularized components which will be the basis of the template going forward.
|
||||
|
||||
Alternatively to stick with the current version, TypeScript and Contentlayer:
|
||||
|
||||
```bash
|
||||
npx degit 'timlrx/tailwind-nextjs-starter-blog#contentlayer'
|
||||
```
|
||||
|
||||
or JS (official support)
|
||||
|
||||
```bash
|
||||
npx degit https://github.com/timlrx/tailwind-nextjs-starter-blog.git
|
||||
```
|
||||
|
||||
2. Personalize `siteMetadata.js` (site related information)
|
||||
3. Modify the content security policy in `next.config.js` if you want to use
|
||||
any analytics provider or a commenting solution other than giscus.
|
||||
4. Personalize `authors/default.md` (main author)
|
||||
5. Modify `projectsData.js`
|
||||
6. Modify `headerNavLinks.js` to customize navigation links
|
||||
7. Add blog posts
|
||||
8. Deploy on Vercel
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
or
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `pages/index.js`. The page auto-updates as you edit the file.
|
||||
|
||||
## Extend / Customize
|
||||
|
||||
`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 on the projects page.
|
||||
|
||||
`data/headerNavLinks.js` - navigation links.
|
||||
|
||||
`data/logo.svg` - replace with your own logo.
|
||||
|
||||
`data/blog` - replace with your own blog posts.
|
||||
|
||||
`public/static` - store assets such as images and favicons.
|
||||
|
||||
`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.
|
||||
|
||||
`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.
|
||||
|
||||
`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
|
||||
|
||||
### Frontmatter
|
||||
|
||||
Frontmatter follows [Hugo's standards](https://gohugo.io/content-management/front-matter/).
|
||||
|
||||
Currently 7 fields are supported.
|
||||
|
||||
```
|
||||
title (required)
|
||||
date (required)
|
||||
tags (required, can be empty array)
|
||||
lastmod (optional)
|
||||
draft (optional)
|
||||
summary (optional)
|
||||
images (optional, if none provided defaults to socialBanner in siteMetadata config)
|
||||
authors (optional list which should correspond to the file names in `data/authors`. Uses `default` if none is specified)
|
||||
layout (optional list which should correspond to the file names in `data/layouts`)
|
||||
canonicalUrl (optional, canonical url for the post for SEO)
|
||||
```
|
||||
|
||||
Here's an example of a post's frontmatter:
|
||||
|
||||
```
|
||||
---
|
||||
title: 'Introducing Tailwind Nexjs Starter Blog'
|
||||
date: '2021-01-12'
|
||||
lastmod: '2021-01-18'
|
||||
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.'
|
||||
images: ['/static/images/canada/mountains.jpg', '/static/images/canada/toronto.jpg']
|
||||
authors: ['default', 'sparrowhawk']
|
||||
layout: PostLayout
|
||||
canonicalUrl: https://tailwind-nextjs-starter-blog.vercel.app/blog/introducing-tailwind-nextjs-starter-blog
|
||||
---
|
||||
```
|
||||
|
||||
### Compose
|
||||
|
||||
Run `node ./scripts/compose.js` to bootstrap a new post.
|
||||
|
||||
Follow the interactive prompt to generate a post with pre-filled front matter.
|
||||
|
||||
## Deploy
|
||||
|
||||
**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 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.timlrx.com)
|
56
components/Card.js
Normal file
@ -0,0 +1,56 @@
|
||||
import Image from './Image'
|
||||
import Link from './Link'
|
||||
|
||||
const Card = ({ title, description, imgSrc, href }) => (
|
||||
<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 md:h-36 lg:h-48"
|
||||
width={544}
|
||||
height={306}
|
||||
/>
|
||||
))}
|
||||
<div className="p-6">
|
||||
<h2 className="mb-3 text-2xl font-bold leading-8 tracking-tight">
|
||||
{href ? (
|
||||
<Link href={href} aria-label={`Link to ${title}`}>
|
||||
{title}
|
||||
</Link>
|
||||
) : (
|
||||
title
|
||||
)}
|
||||
</h2>
|
||||
<p className="prose mb-3 max-w-none text-gray-500 dark:text-gray-400">{description}</p>
|
||||
{href && (
|
||||
<Link
|
||||
href={href}
|
||||
className="text-base font-medium leading-6 text-primary-500 hover:text-primary-600 dark:hover:text-primary-400"
|
||||
aria-label={`Link to ${title}`}
|
||||
>
|
||||
Learn more →
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
export default Card
|
23
components/ClientReload.js
Normal file
@ -0,0 +1,23 @@
|
||||
import { useEffect } from 'react'
|
||||
import Router from 'next/router'
|
||||
|
||||
/**
|
||||
* Client-side complement to next-remote-watch
|
||||
* Re-triggers getStaticProps when watched mdx files change
|
||||
*
|
||||
*/
|
||||
export const ClientReload = () => {
|
||||
// Exclude socket.io from prod bundle
|
||||
useEffect(() => {
|
||||
import('socket.io-client').then((module) => {
|
||||
const socket = module.io()
|
||||
socket.on('reload', (data) => {
|
||||
Router.replace(Router.asPath, undefined, {
|
||||
scroll: false,
|
||||
})
|
||||
})
|
||||
})
|
||||
}, [])
|
||||
|
||||
return null
|
||||
}
|
32
components/Footer.js
Normal file
@ -0,0 +1,32 @@
|
||||
import Link from './Link'
|
||||
import siteMetadata from '@/data/siteMetadata'
|
||||
import SocialIcon from '@/components/social-icons'
|
||||
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer>
|
||||
<div className="mt-16 flex flex-col items-center">
|
||||
<div className="mb-3 flex space-x-4">
|
||||
<SocialIcon kind="mail" href={`mailto:${siteMetadata.email}`} size="6" />
|
||||
<SocialIcon kind="github" href={siteMetadata.github} size="6" />
|
||||
<SocialIcon kind="facebook" href={siteMetadata.facebook} size="6" />
|
||||
<SocialIcon kind="youtube" href={siteMetadata.youtube} size="6" />
|
||||
<SocialIcon kind="linkedin" href={siteMetadata.linkedin} size="6" />
|
||||
<SocialIcon kind="twitter" href={siteMetadata.twitter} size="6" />
|
||||
</div>
|
||||
<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>
|
||||
<div>{` • `}</div>
|
||||
<Link href="/">{siteMetadata.title}</Link>
|
||||
</div>
|
||||
<div className="mb-8 text-sm text-gray-500 dark:text-gray-400">
|
||||
<Link href="https://github.com/timlrx/tailwind-nextjs-starter-blog">
|
||||
Tailwind Nextjs Theme
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
6
components/Image.js
Normal file
@ -0,0 +1,6 @@
|
||||
import NextImage from 'next/image'
|
||||
|
||||
// eslint-disable-next-line jsx-a11y/alt-text
|
||||
const Image = ({ ...rest }) => <NextImage {...rest} />
|
||||
|
||||
export default Image
|
54
components/LayoutWrapper.js
Normal file
@ -0,0 +1,54 @@
|
||||
import siteMetadata from '@/data/siteMetadata'
|
||||
import headerNavLinks from '@/data/headerNavLinks'
|
||||
import Logo from '@/data/logo.svg'
|
||||
import Link from './Link'
|
||||
import SectionContainer from './SectionContainer'
|
||||
import Footer from './Footer'
|
||||
import MobileNav from './MobileNav'
|
||||
import ThemeSwitch from './ThemeSwitch'
|
||||
|
||||
const LayoutWrapper = ({ children }) => {
|
||||
return (
|
||||
<SectionContainer>
|
||||
<div className="flex h-screen flex-col justify-between">
|
||||
<header className="flex items-center justify-between py-10">
|
||||
<div>
|
||||
<Link href="/" aria-label={siteMetadata.headerTitle}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="mr-3">
|
||||
<Logo />
|
||||
</div>
|
||||
{typeof siteMetadata.headerTitle === 'string' ? (
|
||||
<div className="hidden h-6 text-2xl font-semibold sm:block">
|
||||
{siteMetadata.headerTitle}
|
||||
</div>
|
||||
) : (
|
||||
siteMetadata.headerTitle
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex items-center text-base leading-5">
|
||||
<div className="hidden sm:block">
|
||||
{headerNavLinks.map((link) => (
|
||||
<Link
|
||||
key={link.title}
|
||||
href={link.href}
|
||||
className="p-1 font-medium text-gray-900 dark:text-gray-100 sm:p-4"
|
||||
>
|
||||
{link.title}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<ThemeSwitch />
|
||||
<MobileNav />
|
||||
</div>
|
||||
</header>
|
||||
<main className="mb-auto">{children}</main>
|
||||
<Footer />
|
||||
</div>
|
||||
</SectionContainer>
|
||||
)
|
||||
}
|
||||
|
||||
export default LayoutWrapper
|
23
components/Link.js
Normal file
@ -0,0 +1,23 @@
|
||||
/* eslint-disable jsx-a11y/anchor-has-content */
|
||||
import Link from 'next/link'
|
||||
|
||||
const CustomLink = ({ href, ...rest }) => {
|
||||
const isInternalLink = href && href.startsWith('/')
|
||||
const isAnchorLink = href && href.startsWith('#')
|
||||
|
||||
if (isInternalLink) {
|
||||
return (
|
||||
<Link href={href}>
|
||||
<a {...rest} />
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
if (isAnchorLink) {
|
||||
return <a href={href} {...rest} />
|
||||
}
|
||||
|
||||
return <a target="_blank" rel="noopener noreferrer" href={href} {...rest} />
|
||||
}
|
||||
|
||||
export default CustomLink
|
26
components/MDXComponents.js
Normal file
@ -0,0 +1,26 @@
|
||||
/* eslint-disable react/display-name */
|
||||
import { useMemo } from 'react'
|
||||
import { getMDXComponent } from 'mdx-bundler/client'
|
||||
import Image from './Image'
|
||||
import CustomLink from './Link'
|
||||
import TOCInline from './TOCInline'
|
||||
import Pre from './Pre'
|
||||
import { BlogNewsletterForm } from './NewsletterForm'
|
||||
|
||||
export const MDXComponents = {
|
||||
Image,
|
||||
TOCInline,
|
||||
a: CustomLink,
|
||||
pre: Pre,
|
||||
BlogNewsletterForm: BlogNewsletterForm,
|
||||
wrapper: ({ components, layout, ...rest }) => {
|
||||
const Layout = require(`../layouts/${layout}`).default
|
||||
return <Layout {...rest} />
|
||||
},
|
||||
}
|
||||
|
||||
export const MDXLayoutRenderer = ({ layout, mdxSource, ...rest }) => {
|
||||
const MDXLayout = useMemo(() => getMDXComponent(mdxSource), [mdxSource])
|
||||
|
||||
return <MDXLayout layout={layout} components={MDXComponents} {...rest} />
|
||||
}
|
85
components/MobileNav.js
Normal file
@ -0,0 +1,85 @@
|
||||
import { useState } from 'react'
|
||||
import Link from './Link'
|
||||
import headerNavLinks from '@/data/headerNavLinks'
|
||||
|
||||
const MobileNav = () => {
|
||||
const [navShow, setNavShow] = useState(false)
|
||||
|
||||
const onToggleNav = () => {
|
||||
setNavShow((status) => {
|
||||
if (status) {
|
||||
document.body.style.overflow = 'auto'
|
||||
} else {
|
||||
// Prevent scrolling
|
||||
document.body.style.overflow = 'hidden'
|
||||
}
|
||||
return !status
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="sm:hidden">
|
||||
<button
|
||||
type="button"
|
||||
className="ml-1 mr-1 h-8 w-8 rounded py-1"
|
||||
aria-label="Toggle Menu"
|
||||
onClick={onToggleNav}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
className="text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M3 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 15a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<div
|
||||
className={`fixed top-0 left-0 z-10 h-full w-full transform bg-gray-200 opacity-95 duration-300 ease-in-out dark:bg-gray-800 ${
|
||||
navShow ? 'translate-x-0' : 'translate-x-full'
|
||||
}`}
|
||||
>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
className="mr-5 mt-11 h-8 w-8 rounded"
|
||||
aria-label="Toggle Menu"
|
||||
onClick={onToggleNav}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
className="text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<nav className="fixed mt-8 h-full">
|
||||
{headerNavLinks.map((link) => (
|
||||
<div key={link.title} className="px-12 py-4">
|
||||
<Link
|
||||
href={link.href}
|
||||
className="text-2xl font-bold tracking-widest text-gray-900 dark:text-gray-100"
|
||||
onClick={onToggleNav}
|
||||
>
|
||||
{link.title}
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default MobileNav
|
84
components/NewsletterForm.js
Normal file
@ -0,0 +1,84 @@
|
||||
import { useRef, useState } from 'react'
|
||||
|
||||
import siteMetadata from '@/data/siteMetadata'
|
||||
|
||||
const NewsletterForm = ({ title = 'Subscribe to the newsletter' }) => {
|
||||
const inputEl = useRef(null)
|
||||
const [error, setError] = useState(false)
|
||||
const [message, setMessage] = useState('')
|
||||
const [subscribed, setSubscribed] = useState(false)
|
||||
|
||||
const subscribe = async (e) => {
|
||||
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>
|
||||
)
|
7
components/PageTitle.js
Normal file
@ -0,0 +1,7 @@
|
||||
export default function PageTitle({ children }) {
|
||||
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}
|
||||
</h1>
|
||||
)
|
||||
}
|
36
components/Pagination.js
Normal file
@ -0,0 +1,36 @@
|
||||
import Link from '@/components/Link'
|
||||
|
||||
export default function Pagination({ totalPages, currentPage }) {
|
||||
const prevPage = parseInt(currentPage) - 1 > 0
|
||||
const nextPage = parseInt(currentPage) + 1 <= parseInt(totalPages)
|
||||
|
||||
return (
|
||||
<div className="space-y-2 pt-6 pb-8 md:space-y-5">
|
||||
<nav className="flex justify-between">
|
||||
{!prevPage && (
|
||||
<button rel="previous" className="cursor-auto disabled:opacity-50" disabled={!prevPage}>
|
||||
Previous
|
||||
</button>
|
||||
)}
|
||||
{prevPage && (
|
||||
<Link href={currentPage - 1 === 1 ? `/blog/` : `/blog/page/${currentPage - 1}`}>
|
||||
<button rel="previous">Previous</button>
|
||||
</Link>
|
||||
)}
|
||||
<span>
|
||||
{currentPage} of {totalPages}
|
||||
</span>
|
||||
{!nextPage && (
|
||||
<button rel="next" className="cursor-auto disabled:opacity-50" disabled={!nextPage}>
|
||||
Next
|
||||
</button>
|
||||
)}
|
||||
{nextPage && (
|
||||
<Link href={`/blog/page/${currentPage + 1}`}>
|
||||
<button rel="next">Next</button>
|
||||
</Link>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
)
|
||||
}
|
71
components/Pre.js
Normal file
@ -0,0 +1,71 @@
|
||||
import { useState, useRef } from 'react'
|
||||
|
||||
const Pre = (props) => {
|
||||
const textInput = useRef(null)
|
||||
const [hovered, setHovered] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const onEnter = () => {
|
||||
setHovered(true)
|
||||
}
|
||||
const onExit = () => {
|
||||
setHovered(false)
|
||||
setCopied(false)
|
||||
}
|
||||
const onCopy = () => {
|
||||
setCopied(true)
|
||||
navigator.clipboard.writeText(textInput.current.textContent)
|
||||
setTimeout(() => {
|
||||
setCopied(false)
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={textInput} onMouseEnter={onEnter} onMouseLeave={onExit} className="relative">
|
||||
{hovered && (
|
||||
<button
|
||||
aria-label="Copy code"
|
||||
type="button"
|
||||
className={`absolute right-2 top-2 h-8 w-8 rounded border-2 bg-gray-700 p-1 dark:bg-gray-800 ${
|
||||
copied
|
||||
? 'border-green-400 focus:border-green-400 focus:outline-none'
|
||||
: 'border-gray-300'
|
||||
}`}
|
||||
onClick={onCopy}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
className={copied ? 'text-green-400' : 'text-gray-300'}
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<pre>{props.children}</pre>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Pre
|
163
components/SEO.js
Normal file
@ -0,0 +1,163 @@
|
||||
import Head from 'next/head'
|
||||
import { useRouter } from 'next/router'
|
||||
import siteMetadata from '@/data/siteMetadata'
|
||||
|
||||
const CommonSEO = ({ title, description, ogType, ogImage, twImage, canonicalUrl }) => {
|
||||
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} />
|
||||
{ogImage.constructor.name === 'Array' ? (
|
||||
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} />
|
||||
<link
|
||||
rel="canonical"
|
||||
href={canonicalUrl ? canonicalUrl : `${siteMetadata.siteUrl}${router.asPath}`}
|
||||
/>
|
||||
</Head>
|
||||
)
|
||||
}
|
||||
|
||||
export const PageSEO = ({ title, description }) => {
|
||||
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 }) => {
|
||||
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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const BlogSEO = ({
|
||||
authorDetails,
|
||||
title,
|
||||
summary,
|
||||
date,
|
||||
lastmod,
|
||||
url,
|
||||
images = [],
|
||||
canonicalUrl,
|
||||
}) => {
|
||||
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: img.includes('http') ? img : 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}
|
||||
canonicalUrl={canonicalUrl}
|
||||
/>
|
||||
<Head>
|
||||
{date && <meta property="article:published_time" content={publishedAt} />}
|
||||
{lastmod && <meta property="article:modified_time" content={modifiedAt} />}
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: JSON.stringify(structuredData, null, 2),
|
||||
}}
|
||||
/>
|
||||
</Head>
|
||||
</>
|
||||
)
|
||||
}
|
61
components/ScrollTopAndComment.js
Normal file
@ -0,0 +1,61 @@
|
||||
import siteMetadata from '@/data/siteMetadata'
|
||||
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 })
|
||||
}
|
||||
const handleScrollToComment = () => {
|
||||
document.getElementById('comment').scrollIntoView()
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={`fixed right-8 bottom-8 hidden flex-col gap-3 ${show ? 'md:flex' : 'md:hidden'}`}
|
||||
>
|
||||
{siteMetadata.comment.provider && (
|
||||
<button
|
||||
aria-label="Scroll To Comment"
|
||||
type="button"
|
||||
onClick={handleScrollToComment}
|
||||
className="rounded-full bg-gray-200 p-2 text-gray-500 transition-all hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-400 dark:hover:bg-gray-600"
|
||||
>
|
||||
<svg className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M18 10c0 3.866-3.582 7-8 7a8.841 8.841 0 01-4.083-.98L2 17l1.338-3.123C2.493 12.767 2 11.434 2 10c0-3.866 3.582-7 8-7s8 3.134 8 7zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
aria-label="Scroll To 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
|
3
components/SectionContainer.js
Normal file
@ -0,0 +1,3 @@
|
||||
export default function SectionContainer({ children }) {
|
||||
return <div className="mx-auto max-w-3xl px-4 sm:px-6 xl:max-w-5xl xl:px-0">{children}</div>
|
||||
}
|
64
components/TOCInline.js
Normal file
@ -0,0 +1,64 @@
|
||||
/**
|
||||
* @typedef TocHeading
|
||||
* @prop {string} value
|
||||
* @prop {number} depth
|
||||
* @prop {string} url
|
||||
*/
|
||||
|
||||
/**
|
||||
* Generates an inline table of contents
|
||||
* Exclude titles matching this string (new RegExp('^(' + string + ')$', 'i')).
|
||||
* If an array is passed the array gets joined with a pipe (new RegExp('^(' + array.join('|') + ')$', 'i')).
|
||||
*
|
||||
* @param {{
|
||||
* toc: TocHeading[],
|
||||
* indentDepth?: number,
|
||||
* fromHeading?: number,
|
||||
* toHeading?: number,
|
||||
* asDisclosure?: boolean,
|
||||
* exclude?: string|string[]
|
||||
* }} props
|
||||
*
|
||||
*/
|
||||
const TOCInline = ({
|
||||
toc,
|
||||
indentDepth = 3,
|
||||
fromHeading = 1,
|
||||
toHeading = 6,
|
||||
asDisclosure = false,
|
||||
exclude = '',
|
||||
}) => {
|
||||
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
|
14
components/Tag.js
Normal file
@ -0,0 +1,14 @@
|
||||
import Link from 'next/link'
|
||||
import kebabCase from '@/lib/utils/kebabCase'
|
||||
|
||||
const Tag = ({ text }) => {
|
||||
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">
|
||||
{text.split(' ').join('-')}
|
||||
</a>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export default Tag
|
38
components/ThemeSwitch.js
Normal file
@ -0,0 +1,38 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTheme } from 'next-themes'
|
||||
|
||||
const ThemeSwitch = () => {
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const { theme, setTheme, resolvedTheme } = useTheme()
|
||||
|
||||
// When mounted on client, now we can show the UI
|
||||
useEffect(() => setMounted(true), [])
|
||||
|
||||
return (
|
||||
<button
|
||||
aria-label="Toggle Dark Mode"
|
||||
type="button"
|
||||
className="ml-1 mr-1 h-8 w-8 rounded p-1 sm:ml-4"
|
||||
onClick={() => setTheme(theme === 'dark' || resolvedTheme === 'dark' ? 'light' : 'dark')}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
className="text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
{mounted && (theme === 'dark' || resolvedTheme === 'dark') ? (
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
) : (
|
||||
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z" />
|
||||
)}
|
||||
</svg>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default ThemeSwitch
|
36
components/analytics/GoogleAnalytics.js
Normal 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,
|
||||
})
|
||||
}
|
27
components/analytics/Plausible.js
Normal 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)
|
||||
}
|
18
components/analytics/Posthog.js
Normal file
@ -0,0 +1,18 @@
|
||||
import Script from 'next/script'
|
||||
|
||||
import siteMetadata from '@/data/siteMetadata'
|
||||
|
||||
const PosthogScript = () => {
|
||||
return (
|
||||
<>
|
||||
<Script strategy="lazyOnload" id="posthog-script">
|
||||
{`
|
||||
!function(t,e){var o,n,p,r;e.__SV||(window.posthog=e,e._i=[],e.init=function(i,s,a){function g(t,e){var o=e.split(".");2==o.length&&(t=t[o[0]],e=o[1]),t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}}(p=t.createElement("script")).type="text/javascript",p.async=!0,p.src=s.api_host+"/static/array.js",(r=t.getElementsByTagName("script")[0]).parentNode.insertBefore(p,r);var u=e;for(void 0!==a?u=e[a]=[]:a="posthog",u.people=u.people||[],u.toString=function(t){var e="posthog";return"posthog"!==a&&(e+="."+a),t||(e+=" (stub)"),e},u.people.toString=function(){return u.toString(1)+".people (stub)"},o="capture identify alias people.set people.set_once set_config register register_once unregister opt_out_capturing has_opted_out_capturing opt_in_capturing reset isFeatureEnabled onFeatureFlags".split(" "),n=0;n<o.length;n++)g(u,o[n]);e._i.push([i,s,a])},e.__SV=1)}(document,window.posthog||[]);
|
||||
posthog.init('${siteMetadata.analytics.posthogAnalyticsId}',{api_host:'https://app.posthog.com'})
|
||||
`}
|
||||
</Script>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default PosthogScript
|
25
components/analytics/SimpleAnalytics.js
Normal 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
|
18
components/analytics/Umami.js
Normal 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
|
22
components/analytics/index.js
Normal file
@ -0,0 +1,22 @@
|
||||
import GA from './GoogleAnalytics'
|
||||
import Plausible from './Plausible'
|
||||
import SimpleAnalytics from './SimpleAnalytics'
|
||||
import Umami from './Umami'
|
||||
import Posthog from './Posthog'
|
||||
import siteMetadata from '@/data/siteMetadata'
|
||||
|
||||
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 />}
|
||||
{isProduction && siteMetadata.analytics.posthogAnalyticsId && <Posthog />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Analytics
|
37
components/comments/Disqus.js
Normal file
@ -0,0 +1,37 @@
|
||||
import React, { useState } from 'react'
|
||||
|
||||
import siteMetadata from '@/data/siteMetadata'
|
||||
|
||||
const Disqus = ({ frontMatter }) => {
|
||||
const [enableLoadComments, setEnabledLoadComments] = useState(true)
|
||||
|
||||
const COMMENTS_ID = 'disqus_thread'
|
||||
|
||||
function LoadComments() {
|
||||
setEnabledLoadComments(false)
|
||||
|
||||
window.disqus_config = function () {
|
||||
this.page.url = window.location.href
|
||||
this.page.identifier = frontMatter.slug
|
||||
}
|
||||
if (window.DISQUS === undefined) {
|
||||
const script = document.createElement('script')
|
||||
script.src = 'https://' + siteMetadata.comment.disqusConfig.shortname + '.disqus.com/embed.js'
|
||||
script.setAttribute('data-timestamp', +new Date())
|
||||
script.setAttribute('crossorigin', 'anonymous')
|
||||
script.async = true
|
||||
document.body.appendChild(script)
|
||||
} else {
|
||||
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
|
72
components/comments/Giscus.js
Normal file
@ -0,0 +1,72 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { useTheme } from 'next-themes'
|
||||
|
||||
import siteMetadata from '@/data/siteMetadata'
|
||||
|
||||
const Giscus = () => {
|
||||
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 {
|
||||
repo,
|
||||
repositoryId,
|
||||
category,
|
||||
categoryId,
|
||||
mapping,
|
||||
reactions,
|
||||
metadata,
|
||||
inputPosition,
|
||||
lang,
|
||||
} = siteMetadata?.comment?.giscusConfig
|
||||
|
||||
const script = document.createElement('script')
|
||||
script.src = 'https://giscus.app/client.js'
|
||||
script.setAttribute('data-repo', repo)
|
||||
script.setAttribute('data-repo-id', repositoryId)
|
||||
script.setAttribute('data-category', category)
|
||||
script.setAttribute('data-category-id', categoryId)
|
||||
script.setAttribute('data-mapping', mapping)
|
||||
script.setAttribute('data-reactions-enabled', reactions)
|
||||
script.setAttribute('data-emit-metadata', metadata)
|
||||
script.setAttribute('data-input-position', inputPosition)
|
||||
script.setAttribute('data-lang', lang)
|
||||
script.setAttribute('data-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])
|
||||
|
||||
// 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
|
52
components/comments/Utterances.js
Normal file
@ -0,0 +1,52 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { useTheme } from 'next-themes'
|
||||
|
||||
import siteMetadata from '@/data/siteMetadata'
|
||||
|
||||
const Utterances = () => {
|
||||
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', siteMetadata.comment.utterancesConfig.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])
|
||||
|
||||
// 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
|
39
components/comments/index.js
Normal file
@ -0,0 +1,39 @@
|
||||
import siteMetadata from '@/data/siteMetadata'
|
||||
import dynamic from 'next/dynamic'
|
||||
|
||||
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 }) => {
|
||||
const comment = siteMetadata?.comment
|
||||
if (!comment || Object.keys(comment).length === 0) return <></>
|
||||
return (
|
||||
<div id="comment">
|
||||
{siteMetadata.comment && siteMetadata.comment.provider === 'giscus' && <GiscusComponent />}
|
||||
{siteMetadata.comment && siteMetadata.comment.provider === 'utterances' && (
|
||||
<UtterancesComponent />
|
||||
)}
|
||||
{siteMetadata.comment && siteMetadata.comment.provider === 'disqus' && (
|
||||
<DisqusComponent frontMatter={frontMatter} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Comments
|
1
components/social-icons/facebook.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>Facebook icon</title><path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/></svg>
|
After Width: | Height: | Size: 403 B |
1
components/social-icons/github.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>GitHub icon</title><path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/></svg>
|
After Width: | Height: | Size: 827 B |
40
components/social-icons/index.js
Normal file
@ -0,0 +1,40 @@
|
||||
import Mail from './mail.svg'
|
||||
import Github from './github.svg'
|
||||
import Facebook from './facebook.svg'
|
||||
import Youtube from './youtube.svg'
|
||||
import Linkedin from './linkedin.svg'
|
||||
import Twitter from './twitter.svg'
|
||||
|
||||
// Icons taken from: https://simpleicons.org/
|
||||
|
||||
const components = {
|
||||
mail: Mail,
|
||||
github: Github,
|
||||
facebook: Facebook,
|
||||
youtube: Youtube,
|
||||
linkedin: Linkedin,
|
||||
twitter: Twitter,
|
||||
}
|
||||
|
||||
const SocialIcon = ({ kind, href, size = 8 }) => {
|
||||
if (!href || (kind === 'mail' && !/^mailto:\w+([.-]?\w+)@\w+([.-]?\w+)(.\w{2,3})+$/.test(href)))
|
||||
return null
|
||||
|
||||
const SocialSvg = components[kind]
|
||||
|
||||
return (
|
||||
<a
|
||||
className="text-sm text-gray-500 transition hover:text-gray-600"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href={href}
|
||||
>
|
||||
<span className="sr-only">{kind}</span>
|
||||
<SocialSvg
|
||||
className={`fill-current text-gray-700 hover:text-blue-500 dark:text-gray-200 dark:hover:text-blue-400 h-${size} w-${size}`}
|
||||
/>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
export default SocialIcon
|
1
components/social-icons/linkedin.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>LinkedIn icon</title><path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/></svg>
|
After Width: | Height: | Size: 615 B |
4
components/social-icons/mail.svg
Normal file
@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
|
||||
<path d="M2.003 5.884L10 9.882l7.997-3.998A2 2 0 0016 4H4a2 2 0 00-1.997 1.884z" />
|
||||
<path d="M18 8.118l-8 4-8-4V14a2 2 0 002 2h12a2 2 0 002-2V8.118z" />
|
||||
</svg>
|
After Width: | Height: | Size: 224 B |
1
components/social-icons/twitter.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>Twitter icon</title><path d="M23.953 4.57a10 10 0 01-2.825.775 4.958 4.958 0 002.163-2.723c-.951.555-2.005.959-3.127 1.184a4.92 4.92 0 00-8.384 4.482C7.69 8.095 4.067 6.13 1.64 3.162a4.822 4.822 0 00-.666 2.475c0 1.71.87 3.213 2.188 4.096a4.904 4.904 0 01-2.228-.616v.06a4.923 4.923 0 003.946 4.827 4.996 4.996 0 01-2.212.085 4.936 4.936 0 004.604 3.417 9.867 9.867 0 01-6.102 2.105c-.39 0-.779-.023-1.17-.067a13.995 13.995 0 007.557 2.209c9.053 0 13.998-7.496 13.998-13.985 0-.21 0-.42-.015-.63A9.935 9.935 0 0024 4.59z"/></svg>
|
After Width: | Height: | Size: 607 B |
1
components/social-icons/youtube.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>YouTube icon</title><path d="M23.499 6.203a3.008 3.008 0 00-2.089-2.089c-1.87-.501-9.4-.501-9.4-.501s-7.509-.01-9.399.501a3.008 3.008 0 00-2.088 2.09A31.258 31.26 0 000 12.01a31.258 31.26 0 00.523 5.785 3.008 3.008 0 002.088 2.089c1.869.502 9.4.502 9.4.502s7.508 0 9.399-.502a3.008 3.008 0 002.089-2.09 31.258 31.26 0 00.5-5.784 31.258 31.26 0 00-.5-5.808zm-13.891 9.4V8.407l6.266 3.604z"/></svg>
|
After Width: | Height: | Size: 474 B |
140
css/prism.css
Normal 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;
|
||||
}
|
25
css/tailwind.css
Normal file
@ -0,0 +1,25 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
.task-list-item::before {
|
||||
@apply hidden;
|
||||
}
|
||||
|
||||
.task-list-item {
|
||||
@apply list-none;
|
||||
}
|
||||
|
||||
.footnotes {
|
||||
@apply pt-8 mt-12 border-t border-gray-200 dark:border-gray-700;
|
||||
}
|
||||
|
||||
.csl-entry {
|
||||
@apply my-5;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
16
data/authors/default.md
Normal file
@ -0,0 +1,16 @@
|
||||
---
|
||||
name: Tails Azimuth
|
||||
avatar: /static/images/avatar.png
|
||||
occupation: Professor of Atmospheric Science
|
||||
company: Stanford University
|
||||
email: address@yoursite.com
|
||||
twitter: https://twitter.com/Twitter
|
||||
linkedin: https://www.linkedin.com
|
||||
github: https://github.com
|
||||
---
|
||||
|
||||
Tails Azimuth is a professor of atmospheric sciences at the Stanford AI Lab. His research interests includes complexity modelling of tailwinds, headwinds and crosswinds.
|
||||
|
||||
He leads the clean energy group which develops 3D air pollution-climate models, writes differential equation solvers, and manufactures titanium plated air ballons. In his free time he bakes raspberry pi.
|
||||
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed neque elit, tristique placerat feugiat ac, facilisis vitae arcu. Proin eget egestas augue. Praesent ut sem nec arcu pellentesque aliquet. Duis dapibus diam vel metus tempus vulputate.
|
12
data/authors/sparrowhawk.md
Normal file
@ -0,0 +1,12 @@
|
||||
---
|
||||
name: Sparrow Hawk
|
||||
avatar: /static/images/sparrowhawk-avatar.jpg
|
||||
occupation: Wizard of Earthsea
|
||||
company: Earthsea
|
||||
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, 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.
|
38
data/blog/code-sample.md
Normal file
@ -0,0 +1,38 @@
|
||||
---
|
||||
title: Sample .md file
|
||||
date: '2016-03-08'
|
||||
tags: ['markdown', 'code', 'features']
|
||||
draft: false
|
||||
summary: Example of a markdown file with code blocks and syntax highlighting
|
||||
---
|
||||
|
||||
A sample post with markdown.
|
||||
|
||||
## Inline Highlighting
|
||||
|
||||
Sample of inline highlighting `sum = parseInt(num1) + parseInt(num2)`
|
||||
|
||||
## Code Blocks
|
||||
|
||||
Some Javascript code
|
||||
|
||||
```javascript
|
||||
var num1, num2, sum
|
||||
num1 = prompt('Enter first number')
|
||||
num2 = prompt('Enter second number')
|
||||
sum = parseInt(num1) + parseInt(num2) // "+" means "add"
|
||||
alert('Sum = ' + sum) // "+" means combine into a string
|
||||
```
|
||||
|
||||
Some Python code 🐍
|
||||
|
||||
```python
|
||||
def fib():
|
||||
a, b = 0, 1
|
||||
while True: # First iteration:
|
||||
yield a # yield 0 to start with and then
|
||||
a, b = b, a + b # a will now be 1, and b will also be 1, (0 + 1)
|
||||
|
||||
for index, fibonacci_number in zip(range(10), fib()):
|
||||
print('{i:3}: {f:3}'.format(i=index, f=fibonacci_number))
|
||||
```
|
141
data/blog/deriving-ols-estimator.mdx
Normal file
@ -0,0 +1,141 @@
|
||||
---
|
||||
title: Deriving the OLS Estimator
|
||||
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.'
|
||||
---
|
||||
|
||||
# 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 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 are denoted by `$$`.
|
||||
|
||||
If you intend to use the `$` sign instead of math, you can escape it (`\$`), or specify the HTML entity (`$`) [^2]
|
||||
|
||||
Inline or manually enumerated footnotes are also supported. Click on the links above to see them in action.
|
||||
|
||||
[^2]: \$10 and $20.
|
||||
|
||||
# Deriving the OLS Estimator
|
||||
|
||||
Using matrix notation, let $n$ denote the number of observations and $k$ denote the number of regressors.
|
||||
|
||||
The vector of outcome variables $\mathbf{Y}$ is a $n \times 1$ matrix,
|
||||
|
||||
```tex
|
||||
\mathbf{Y} = \left[\begin{array}
|
||||
{c}
|
||||
y_1 \\
|
||||
. \\
|
||||
. \\
|
||||
. \\
|
||||
y_n
|
||||
\end{array}\right]
|
||||
```
|
||||
|
||||
$$
|
||||
\mathbf{Y} = \left[\begin{array}
|
||||
{c}
|
||||
y_1 \\
|
||||
. \\
|
||||
. \\
|
||||
. \\
|
||||
y_n
|
||||
\end{array}\right]
|
||||
$$
|
||||
|
||||
The matrix of regressors $\mathbf{X}$ is a $n \times k$ matrix (or each row is a $k \times 1$ vector),
|
||||
|
||||
```latex
|
||||
\mathbf{X} = \left[\begin{array}
|
||||
{ccccc}
|
||||
x_{11} & . & . & . & x_{1k} \\
|
||||
. & . & . & . & . \\
|
||||
. & . & . & . & . \\
|
||||
. & . & . & . & . \\
|
||||
x_{n1} & . & . & . & x_{nn}
|
||||
\end{array}\right] =
|
||||
\left[\begin{array}
|
||||
{c}
|
||||
\mathbf{x}'_1 \\
|
||||
. \\
|
||||
. \\
|
||||
. \\
|
||||
\mathbf{x}'_n
|
||||
\end{array}\right]
|
||||
```
|
||||
|
||||
$$
|
||||
\mathbf{X} = \left[\begin{array}
|
||||
{ccccc}
|
||||
x_{11} & . & . & . & x_{1k} \\
|
||||
. & . & . & . & . \\
|
||||
. & . & . & . & . \\
|
||||
. & . & . & . & . \\
|
||||
x_{n1} & . & . & . & x_{nn}
|
||||
\end{array}\right] =
|
||||
\left[\begin{array}
|
||||
{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.
|
||||
|
||||
## Least Squares
|
||||
|
||||
**Start**:
|
||||
$$y_i = \mathbf{x}'_i \beta + u_i$$
|
||||
|
||||
**Assumptions**:
|
||||
|
||||
1. Linearity (given above)
|
||||
2. $E(\mathbf{U}|\mathbf{X}) = 0$ (conditional independence)
|
||||
3. rank($\mathbf{X}$) = $k$ (no multi-collinearity i.e. full rank)
|
||||
4. $Var(\mathbf{U}|\mathbf{X}) = \sigma^2 I_n$ (Homoskedascity)
|
||||
|
||||
**Aim**:
|
||||
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)
|
||||
$$
|
||||
|
||||
**Solution**:
|
||||
Hints: $Q$ is a $1 \times 1$ scalar, by symmetry $\frac{\partial b'Ab}{\partial b} = 2Ab$.
|
||||
|
||||
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
|
||||
\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
|
||||
\end{aligned}
|
||||
$$
|
198
data/blog/github-markdown-guide.mdx
Normal file
@ -0,0 +1,198 @@
|
||||
---
|
||||
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.'
|
||||
---
|
||||
|
||||
# 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.
|
||||
|
||||
The following markdown cheatsheet is adapted from: https://guides.github.com/features/mastering-markdown/
|
||||
|
||||
# What is Markdown?
|
||||
|
||||
Markdown is a way to style text on the web. You control the display of the document; formatting words as bold or italic, adding images, and creating lists are just a few of the things we can do with Markdown. Mostly, Markdown is just regular text with a few non-alphabetic characters thrown in, like `#` or `*`.
|
||||
|
||||
# Syntax guide
|
||||
|
||||
Here’s an overview of Markdown syntax that you can use anywhere on GitHub.com or in your own text files.
|
||||
|
||||
## Headers
|
||||
|
||||
```
|
||||
# This is a h1 tag
|
||||
|
||||
## This is a h2 tag
|
||||
|
||||
#### This is a h4 tag
|
||||
```
|
||||
|
||||
# This is a h1 tag
|
||||
|
||||
## This is a h2 tag
|
||||
|
||||
#### This is a h4 tag
|
||||
|
||||
## Emphasis
|
||||
|
||||
```
|
||||
_This text will be italic_
|
||||
|
||||
**This text will be bold**
|
||||
|
||||
_You **can** combine them_
|
||||
```
|
||||
|
||||
_This text will be italic_
|
||||
|
||||
**This text will be bold**
|
||||
|
||||
_You **can** combine them_
|
||||
|
||||
## Lists
|
||||
|
||||
### Unordered
|
||||
|
||||
```
|
||||
- Item 1
|
||||
- Item 2
|
||||
- Item 2a
|
||||
- Item 2b
|
||||
```
|
||||
|
||||
- Item 1
|
||||
- Item 2
|
||||
- Item 2a
|
||||
- Item 2b
|
||||
|
||||
### Ordered
|
||||
|
||||
```
|
||||
1. Item 1
|
||||
1. Item 2
|
||||
1. Item 3
|
||||
1. Item 3a
|
||||
1. Item 3b
|
||||
```
|
||||
|
||||
1. Item 1
|
||||
1. Item 2
|
||||
1. Item 3
|
||||
1. Item 3a
|
||||
1. Item 3b
|
||||
|
||||
## Images
|
||||
|
||||
```
|
||||
![GitHub Logo](https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png)
|
||||
Format: ![Alt Text](url)
|
||||
```
|
||||
|
||||
![GitHub Logo](https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png)
|
||||
|
||||
## Links
|
||||
|
||||
```
|
||||
http://github.com - automatic!
|
||||
[GitHub](http://github.com)
|
||||
```
|
||||
|
||||
http://github.com - automatic!
|
||||
[GitHub](http://github.com)
|
||||
|
||||
## Blockquotes
|
||||
|
||||
```
|
||||
As Kanye West said:
|
||||
|
||||
> We're living the future so
|
||||
> the present is our past.
|
||||
```
|
||||
|
||||
As Kanye West said:
|
||||
|
||||
> We're living the future so
|
||||
> the present is our past.
|
||||
|
||||
## Inline code
|
||||
|
||||
```
|
||||
I think you should use an
|
||||
`<addr>` element here instead.
|
||||
```
|
||||
|
||||
I think you should use an
|
||||
`<addr>` element here instead.
|
||||
|
||||
## Syntax highlighting
|
||||
|
||||
Here’s an example of how you can use syntax highlighting with [GitHub Flavored Markdown](https://help.github.com/articles/basic-writing-and-formatting-syntax/):
|
||||
|
||||
````
|
||||
```js:fancyAlert.js
|
||||
function fancyAlert(arg) {
|
||||
if (arg) {
|
||||
$.facebox({ div: '#foo' })
|
||||
}
|
||||
}
|
||||
```
|
||||
````
|
||||
|
||||
And here's how it looks - nicely colored with styled code titles!
|
||||
|
||||
```js:fancyAlert.js
|
||||
function fancyAlert(arg) {
|
||||
if (arg) {
|
||||
$.facebox({ div: '#foo' })
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 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
|
||||
|
||||
```
|
||||
- [x] list syntax required (any unordered or ordered list supported)
|
||||
- [x] this is a complete item
|
||||
- [ ] this is an incomplete item
|
||||
```
|
||||
|
||||
- [x] list syntax required (any unordered or ordered list supported)
|
||||
- [x] this is a complete item
|
||||
- [ ] this is an incomplete item
|
||||
|
||||
## Tables
|
||||
|
||||
You can create tables by assembling a list of words and dividing them with hyphens `-` (for the first row), and then separating each column with a pipe `|`:
|
||||
|
||||
```
|
||||
| First Header | Second Header |
|
||||
| --------------------------- | ---------------------------- |
|
||||
| Content from cell 1 | Content from cell 2 |
|
||||
| Content in the first column | Content in the second column |
|
||||
```
|
||||
|
||||
| First Header | Second Header |
|
||||
| --------------------------- | ---------------------------- |
|
||||
| Content from cell 1 | Content from cell 2 |
|
||||
| Content in the first column | Content in the second column |
|
||||
|
||||
## Strikethrough
|
||||
|
||||
Any word wrapped with two tildes (like `~~this~~`) will appear ~~crossed out~~.
|
76
data/blog/guide-to-using-images-in-nextjs.mdx
Normal file
@ -0,0 +1,76 @@
|
||||
---
|
||||
title: Images in Next.js
|
||||
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.'
|
||||
authors: ['sparrowhawk']
|
||||
---
|
||||
|
||||
# Introduction
|
||||
|
||||
The tailwind starter blog has out of the box support for [Next.js's built-in image component](https://nextjs.org/docs/api-reference/next/image) and automatically swaps out default image tags in markdown or mdx documents to use the Image component provided.
|
||||
|
||||
# Usage
|
||||
|
||||
To use in a new page route / javascript file, simply import the image component and call it e.g.
|
||||
|
||||
```js
|
||||
import Image from 'next/image'
|
||||
|
||||
function Home() {
|
||||
return (
|
||||
<>
|
||||
<h1>My Homepage</h1>
|
||||
<Image src="/me.png" alt="Picture of the author" width={500} height={500} />
|
||||
<p>Welcome to my homepage!</p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Home
|
||||
```
|
||||
|
||||
For a markdown file, the default image tag can be used and the default `img` tag gets replaced by the `Image` component in the build process.
|
||||
|
||||
Assuming we have a file called `ocean.jpg` in `data/img/ocean.jpg`, the following line of code would generate the optimized image.
|
||||
|
||||
```
|
||||
![ocean](/static/images/ocean.jpg)
|
||||
```
|
||||
|
||||
Alternatively, since we are using mdx, we can just use the image component directly! Note, that you would have to provide a fixed width and height. The `img` tag method parses the dimension automatically.
|
||||
|
||||
```js
|
||||
<Image alt="ocean" src="/static/images/ocean.jpg" width={256} height={128} />
|
||||
```
|
||||
|
||||
_Note_: If you try to save the image, it is in webp format, if your browser supports it!
|
||||
|
||||
![ocean](/static/images/ocean.jpeg)
|
||||
|
||||
<p>
|
||||
Photo by [YUCAR
|
||||
FotoGrafik](https://unsplash.com/@yucar?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText)
|
||||
on
|
||||
[Unsplash](https://unsplash.com/s/photos/sea?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText)
|
||||
</p>
|
||||
|
||||
# Benefits
|
||||
|
||||
- Smaller image size with Webp (~30% smaller than jpeg)
|
||||
- Responsive images - the correct image size is served based on the user's viewport
|
||||
- Lazy loading - images load as they are scrolled to the viewport
|
||||
- Avoids [Cumulative Layout Shift](https://web.dev/cls/)
|
||||
- Optimization on demand instead of build-time - no increase in build time!
|
||||
|
||||
# Limitations
|
||||
|
||||
- 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.
|
||||
|
||||
Alternatively, one could wait for image optimization at build time to be supported. A different library, [next-optimized-images](https://github.com/cyrilwanner/next-optimized-images) does that, although it requires transforming the images through webpack which is not done here.
|
||||
|
||||
- Images from external links are not passed through `next/image`
|
||||
- All images have to be stored in the `public` folder e.g `/static/images/ocean.jpeg`
|
214
data/blog/introducing-tailwind-nextjs-starter-blog.mdx
Normal file
@ -0,0 +1,214 @@
|
||||
---
|
||||
title: 'Introducing Tailwind Nextjs Starter Blog'
|
||||
date: '2021-01-12'
|
||||
lastmod: '2021-02-01'
|
||||
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.'
|
||||
images: ['/static/images/canada/mountains.jpg', '/static/images/canada/toronto.jpg']
|
||||
authors: ['default', 'sparrowhawk']
|
||||
---
|
||||
|
||||
![tailwind-nextjs-banner](/static/images/twitter-card.png)
|
||||
|
||||
# Tailwind Nextjs Starter Blog
|
||||
|
||||
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/git/external?repository-url=https://github.com/timlrx/tailwind-nextjs-starter-blog)
|
||||
|
||||
This is a [Next.js](https://nextjs.org/), [Tailwind CSS](https://tailwindcss.com/) blogging starter template. Probably the most feature-rich Next.js markdown blogging template out there. Comes out of the box configured with the latest technologies to make technical writing a breeze. Easily configurable and customizable. Perfect as a replacement to existing Jekyll and Hugo individual blogs.
|
||||
|
||||
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? Feel free to create a PR and add your blog to this list.
|
||||
|
||||
## Motivation
|
||||
|
||||
I wanted to port my existing blog to Nextjs and Tailwind CSS but there was no easy out of the box template to use so I decided to create one. Design is adapted from [Tailwindlabs blog](https://github.com/tailwindlabs/blog.tailwindcss.com).
|
||||
|
||||
I wanted it to be nearly as feature-rich as popular blogging templates like [beautiful-jekyll](https://github.com/daattali/beautiful-jekyll) and [Hugo Academic](https://github.com/wowchemy/wowchemy-hugo-modules) but with the best of React's ecosystem and current web development's best practices.
|
||||
|
||||
## Features
|
||||
|
||||
- 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, 45kB first load JS, uses Preact in production build
|
||||
- Mobile-friendly view
|
||||
- Light and dark theme
|
||||
- Self-hosted font with [Fontsource](https://fontsource.org/)
|
||||
- Supports [plausible](https://plausible.io/), [simple analytics](https://simpleanalytics.com/) and google analytics
|
||||
- [MDX - write JSX in markdown documents!](https://mdxjs.com/)
|
||||
- Server-side syntax highlighting with line numbers and line highlighting via [rehype-prism-plus](https://github.com/timlrx/rehype-prism-plus)
|
||||
- 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, klaviyo, revue, and emailoctopus
|
||||
- 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](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. Try installing the starter using the new [Pliny project CLI](https://github.com/timlrx/pliny):
|
||||
|
||||
```bash
|
||||
npm i -g @pliny/cli
|
||||
pliny new --template=starter-blog my-blog
|
||||
```
|
||||
|
||||
It supports the updated version of the blog with Contentlayer, optional choice of TS/JS and different package managers as well as more modularized components which will be the basis of the template going forward.
|
||||
|
||||
Alternatively to stick with the current version, TypeScript and Contentlayer:
|
||||
|
||||
```bash
|
||||
npx degit 'timlrx/tailwind-nextjs-starter-blog#contentlayer'
|
||||
```
|
||||
|
||||
or JS (official support)
|
||||
|
||||
```bash
|
||||
npx degit https://github.com/timlrx/tailwind-nextjs-starter-blog.git
|
||||
```
|
||||
|
||||
2. Personalize `siteMetadata.js` (site related information)
|
||||
3. Modify the content security policy in `next.config.js` if you want to use
|
||||
any analytics provider or a commenting solution other than giscus.
|
||||
4. Personalize `authors/default.md` (main author)
|
||||
5. Modify `projectsData.js`
|
||||
6. Modify `headerNavLinks.js` to customize navigation links
|
||||
7. Add blog posts
|
||||
8. Deploy on Vercel
|
||||
|
||||
## Development
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm start
|
||||
# or
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `pages/index.js`. The page auto-updates as you edit the file.
|
||||
|
||||
## Extend / Customize
|
||||
|
||||
`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 on the projects page.
|
||||
|
||||
`data/headerNavLinks.js` - navigation links.
|
||||
|
||||
`data/logo.svg` - replace with your own logo.
|
||||
|
||||
`data/blog` - replace with your own blog posts.
|
||||
|
||||
`public/static` - store assets such as images and favicons.
|
||||
|
||||
`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.
|
||||
|
||||
`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.
|
||||
|
||||
`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
|
||||
|
||||
### Frontmatter
|
||||
|
||||
Frontmatter follows [Hugo's standards](https://gohugo.io/content-management/front-matter/).
|
||||
|
||||
Currently 7 fields are supported.
|
||||
|
||||
```
|
||||
title (required)
|
||||
date (required)
|
||||
tags (required, can be empty array)
|
||||
lastmod (optional)
|
||||
draft (optional)
|
||||
summary (optional)
|
||||
images (optional, if none provided defaults to socialBanner in siteMetadata config)
|
||||
authors (optional list which should correspond to the file names in `data/authors`. Uses `default` if none is specified)
|
||||
layout (optional list which should correspond to the file names in `data/layouts`)
|
||||
canonicalUrl (optional, canonical url for the post for SEO)
|
||||
```
|
||||
|
||||
Here's an example of a post's frontmatter:
|
||||
|
||||
```
|
||||
---
|
||||
title: 'Introducing Tailwind Nexjs Starter Blog'
|
||||
date: '2021-01-12'
|
||||
lastmod: '2021-01-18'
|
||||
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.'
|
||||
images: ['/static/images/canada/mountains.jpg', '/static/images/canada/toronto.jpg']
|
||||
authors: ['default', 'sparrowhawk']
|
||||
layout: PostLayout
|
||||
canonicalUrl: https://tailwind-nextjs-starter-blog.vercel.app/blog/introducing-tailwind-nextjs-starter-blog
|
||||
---
|
||||
```
|
||||
|
||||
### Compose
|
||||
|
||||
Run `node ./scripts/compose.js` to bootstrap a new post.
|
||||
|
||||
Follow the interactive prompt to generate a post with pre-filled front matter.
|
||||
|
||||
## Deploy
|
||||
|
||||
**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 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)
|
10
data/blog/my-fancy-title.md
Normal file
@ -0,0 +1,10 @@
|
||||
---
|
||||
title: My fancy title
|
||||
date: '2021-01-31'
|
||||
tags: ['hello']
|
||||
draft: true
|
||||
summary:
|
||||
images: []
|
||||
---
|
||||
|
||||
Draft post which should not display
|
@ -0,0 +1,30 @@
|
||||
---
|
||||
title: Introducing Multi-part Posts with Nested Routing
|
||||
date: '2021-05-02'
|
||||
tags: ['multi-author', 'next-js', 'feature']
|
||||
draft: false
|
||||
summary: 'The blog template supports posts in nested sub-folders. This can be used to group posts of similar content e.g. a multi-part course. This post is itself an example of a nested route!'
|
||||
---
|
||||
|
||||
# Nested Routes
|
||||
|
||||
The blog template supports posts in nested sub-folders. This helps in organisation and can be used to group posts of similar content e.g. a multi-part series. This post is itself an example of a nested route! It's located in the `/data/blog/nested-route` folder.
|
||||
|
||||
## How
|
||||
|
||||
Simplify create multiple folders inside the main `/data/blog` folder and add your `.md`/`.mdx` files to them. You can even create something like `/data/blog/nested-route/deeply-nested-route/my-post.md`
|
||||
|
||||
We use Next.js catch all routes to handle the routing and path creations.
|
||||
|
||||
## Use Cases
|
||||
|
||||
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))
|
||||
|
||||
## Note
|
||||
|
||||
- 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.
|
451
data/blog/new-features-in-v1.mdx
Normal file
@ -0,0 +1,451 @@
|
||||
---
|
||||
title: 'New features in v1'
|
||||
date: 2021-08-07T15:32:14Z
|
||||
lastmod: '2021-02-01'
|
||||
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
|
||||
canonicalUrl: https://tailwind-nextjs-starter-blog.vercel.app/blog/new-features-in-v1/
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
A post on the new features introduced in v1.0. New features:
|
||||
|
||||
<TOCInline toc={props.toc} exclude="Overview" toHeading={2} />
|
||||
|
||||
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.
|
||||
|
||||
## Theme colors
|
||||
|
||||
You can easily modify the theme color by changing the primary attribute in the tailwind config file:
|
||||
|
||||
```js:tailwind.config.js
|
||||
theme: {
|
||||
colors: {
|
||||
primary: colors.teal,
|
||||
gray: colors.neutral,
|
||||
...
|
||||
}
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
The primary color attribute should be assigned an object with keys from 50, 100, 200 ... 900 and the corresponding color code values.
|
||||
|
||||
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.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 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 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:
|
||||
|
||||
```jsx
|
||||
import PageTitle from './PageTitle.js'
|
||||
;<PageTitle> Using JSX components in MDX </PageTitle>
|
||||
```
|
||||
|
||||
import PageTitle from './PageTitle.js'
|
||||
|
||||
<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 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 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.
|
||||
|
||||
You can configure the template to take in other fields - see `PostLayout` component for an example.
|
||||
|
||||
Here's an example layout which you can further customise:
|
||||
|
||||
```jsx
|
||||
export default function ExampleLayout({ frontMatter, children }) {
|
||||
const { date, title } = frontMatter
|
||||
|
||||
return (
|
||||
<SectionContainer>
|
||||
<div>{date}</div>
|
||||
<h1>{title}</h1>
|
||||
<div>{children}</div>
|
||||
</SectionContainer>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Configuring a blog post frontmatter
|
||||
|
||||
Use the `layout` frontmatter field to specify the template you want to map the markdown post to. Here's how the frontmatter of this post looks like:
|
||||
|
||||
```
|
||||
---
|
||||
title: 'New features in v1'
|
||||
date: '2021-05-26 '
|
||||
tags: ['next-js', 'tailwind', 'guide']
|
||||
draft: false
|
||||
summary: 'Introducing the new layout features - you can map mdx blog content to layout components by configuring the frontmatter field'
|
||||
layout: PostSimple
|
||||
---
|
||||
```
|
||||
|
||||
You can configure the default layout in the respective page section by modifying the `DEFAULT_LAYOUT` variable.
|
||||
The `DEFAULT_LAYOUT` for blog posts page is set to `PostLayout`.
|
||||
|
||||
### Extend
|
||||
|
||||
`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} />
|
||||
},
|
||||
}
|
||||
|
||||
export const MDXLayoutRenderer = ({ layout, mdxSource, ...rest }) => {
|
||||
const MDXLayout = useMemo(() => getMDXComponent(mdxSource), [mdxSource])
|
||||
|
||||
return <MDXLayout layout={layout} components={MDXComponents} {...rest} />
|
||||
}
|
||||
```
|
||||
|
||||
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.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 look like:
|
||||
|
||||
```md:default.md
|
||||
---
|
||||
name: Tails Azimuth
|
||||
avatar: /static/images/avatar.png
|
||||
occupation: Professor of Atmospheric Science
|
||||
company: Stanford University
|
||||
email: address@yoursite.com
|
||||
twitter: https://twitter.com/Twitter
|
||||
linkedin: https://www.linkedin.com
|
||||
github: https://github.com
|
||||
---
|
||||
|
||||
A long description of yourself...
|
||||
```
|
||||
|
||||
You can use this information in multiple places across the template. For example in the about section of the page, we grab the default author information with this line of code:
|
||||
|
||||
```js
|
||||
const authorDetails = await getFileBySlug('authors', ['default'])
|
||||
```
|
||||
|
||||
This is rendered in the `AuthorLayout` template.
|
||||
|
||||
### Multiple authors in blog 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`
|
||||
|
||||
```yaml
|
||||
title: 'My first post'
|
||||
date: '2021-01-12'
|
||||
draft: false
|
||||
summary: 'My first post'
|
||||
authors: ['default', 'sparrowhawk']
|
||||
```
|
||||
|
||||
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.
|
||||
The component is passed to `MDXComponents` and modifies all `<pre>` blocks.
|
||||
|
||||
## Line highlighting and line numbers
|
||||
|
||||
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:
|
||||
|
||||
````
|
||||
```js {1, 3-4} showLineNumbers
|
||||
var num1, num2, sum
|
||||
num1 = prompt('Enter first number')
|
||||
num2 = prompt('Enter second number')
|
||||
sum = parseInt(num1) + parseInt(num2) // "+" means "add"
|
||||
alert('Sum = ' + sum) // "+" means combine into a string
|
||||
```
|
||||
````
|
||||
|
||||
will appear as:
|
||||
|
||||
```js {1,3-4} showLineNumbers
|
||||
var num1, num2, sum
|
||||
num1 = prompt('Enter first number')
|
||||
num2 = prompt('Enter second number')
|
||||
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 `prism.css` file:
|
||||
|
||||
```css
|
||||
.code-highlight {
|
||||
@apply float-left min-w-full;
|
||||
}
|
||||
|
||||
.code-line {
|
||||
@apply -mx-4 block border-l-4 border-opacity-0 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);
|
||||
}
|
||||
```
|
||||
|
||||
## 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.
|
||||
|
||||
## Self-hosted font (v1.5.0)
|
||||
|
||||
Google font has been replaced with self-hosted font from [Fontsource](https://fontsource.org/). This gives the following [advantages](https://fontsource.org/docs/introduction):
|
||||
|
||||
> Self-hosting brings significant performance gains as loading fonts from hosted services, such as Google Fonts, lead to an extra (render blocking) network request. To provide perspective, for simple websites it has been seen to double visual load times.
|
||||
>
|
||||
> Fonts remain version locked. Google often pushes updates to their fonts without notice, which may interfere with your live production projects. Manage your fonts like any other NPM dependency.
|
||||
>
|
||||
> Commit to privacy. Google does track the usage of their fonts and for those who are extremely privacy concerned, self-hosting is an alternative.
|
||||
|
||||
This leads to a smaller font bundle and a 0.1s faster load time ([webpagetest comparison](https://www.webpagetest.org/video/compare.php?tests=220201_AiDcFH_f68a69b758454dd52d8e67493fdef7da,220201_BiDcMC_bf2d53f14483814ba61e794311dfa771)).
|
||||
|
||||
To change the default Inter font:
|
||||
|
||||
1. Install the preferred [font](https://fontsource.org/fonts) - `npm install -save @fontsource/<font-name>`
|
||||
2. Update the import at `pages/_app.js`- `import '@fontsource/<font-name>.css'`
|
||||
3. Update the `fontfamily` property in the tailwind css config file
|
||||
|
||||
## Upgrade guide
|
||||
|
||||
There are significant portions of the code that has been changed from v0 to v1 including support for layouts and a new mdx engine.
|
||||
|
||||
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 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 over to the new template.
|
||||
|
||||
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
|
||||
git pull template v1 --allow-unrelated-histories
|
||||
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`, to avoid some build issues with Vercel. If you are migrating you should add a redirect to `next.config.js` like so:
|
||||
|
||||
```js
|
||||
async redirects() {
|
||||
return [
|
||||
{
|
||||
source: '/:path/index.xml',
|
||||
destination: '/:path/feed.xml',
|
||||
permanent: true,
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
82
data/blog/pictures-of-canada.mdx
Normal file
@ -0,0 +1,82 @@
|
||||
---
|
||||
title: O Canada
|
||||
date: '2017-07-15'
|
||||
tags: ['holiday', 'canada', 'images']
|
||||
draft: false
|
||||
summary: The scenic lands of Canada featuring maple leaves, snow-capped mountains, turquoise lakes and Toronto. Take in the sights in this photo gallery exhibition and see how easy it is to replicate with some MDX magic and tailwind classes.
|
||||
---
|
||||
|
||||
# O Canada
|
||||
|
||||
The scenic lands of Canada featuring maple leaves, snow-capped mountains, turquoise lakes and Toronto. Take in the sights in this photo gallery exhibition and see how easy it is to replicate with some MDX magic and tailwind classes.
|
||||
|
||||
Features images served using `next/image` component. The locally stored images are located in a folder with the following path: `/static/images/canada/[filename].jpg`
|
||||
|
||||
Since we are using mdx, we can create a simple responsive flexbox grid to display our images with a few tailwind css classes.
|
||||
|
||||
---
|
||||
|
||||
# Gallery
|
||||
|
||||
<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">
|
||||
![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">
|
||||
![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">
|
||||
![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">
|
||||
![Toronto](/static/images/canada/toronto.jpg)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
# Implementation
|
||||
|
||||
```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">
|
||||
![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">
|
||||
![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">
|
||||
![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">
|
||||
![Toronto](/static/images/canada/toronto.jpg)
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
With MDX v2, one can interleave markdown in jsx as shown in the example code.
|
||||
|
||||
### Photo Credits
|
||||
|
||||
<div>
|
||||
Maple photo by [Guillaume
|
||||
Jaillet](https://unsplash.com/@i_am_g?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText)
|
||||
on
|
||||
[Unsplash](https://unsplash.com/s/photos/canada?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText)
|
||||
</div>
|
||||
<div>
|
||||
Mountains photo by [John
|
||||
Lee](https://unsplash.com/@john_artifexfilms?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText)
|
||||
on
|
||||
[Unsplash](https://unsplash.com/s/photos/canada?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText)
|
||||
</div>
|
||||
<div>
|
||||
Lake photo by [Tj
|
||||
Holowaychuk](https://unsplash.com/@tjholowaychuk?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText)
|
||||
on
|
||||
[Unsplash](https://unsplash.com/s/photos/canada?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText)
|
||||
</div>
|
||||
<div>
|
||||
Toronto photo by [Matthew
|
||||
Henry](https://unsplash.com/@matthewhenry?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText)
|
||||
on
|
||||
[Unsplash](https://unsplash.com/s/photos/canada?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText)
|
||||
</div>
|
238
data/blog/the-time-machine.mdx
Normal file
@ -0,0 +1,238 @@
|
||||
---
|
||||
title: 'The Time Machine'
|
||||
date: '2018-08-15'
|
||||
tags: ['writings', 'book', 'reflection']
|
||||
draft: false
|
||||
summary: 'The Time Traveller (for so it will be convenient to speak of him) was
|
||||
expounding a recondite matter to us. His pale grey eyes shone and
|
||||
twinkled, and his usually pale face was flushed and animated...'
|
||||
---
|
||||
|
||||
# The Time Machine by H. G. Wells
|
||||
|
||||
_Title_: The Time Machine
|
||||
|
||||
_Author_: H. G. Wells
|
||||
|
||||
_Subject_: Science Fiction
|
||||
|
||||
_Language_: English
|
||||
|
||||
_Source_: [Project Gutenberg](https://www.gutenberg.org/ebooks/35)
|
||||
|
||||
## Introduction
|
||||
|
||||
The Time Traveller (for so it will be convenient to speak of him) was
|
||||
expounding a recondite matter to us. His pale grey eyes shone and
|
||||
twinkled, and his usually pale face was flushed and animated. The fire
|
||||
burnt brightly, and the soft radiance of the incandescent lights in the
|
||||
lilies of silver caught the bubbles that flashed and passed in our
|
||||
glasses. Our chairs, being his patents, embraced and caressed us rather
|
||||
than submitted to be sat upon, and there was that luxurious
|
||||
after-dinner atmosphere, when thought runs gracefully free of the
|
||||
trammels of precision. And he put it to us in this way—marking the
|
||||
points with a lean forefinger—as we sat and lazily admired his
|
||||
earnestness over this new paradox (as we thought it) and his fecundity.
|
||||
|
||||
“You must follow me carefully. I shall have to controvert one or two
|
||||
ideas that are almost universally accepted. The geometry, for instance,
|
||||
they taught you at school is founded on a misconception.”
|
||||
|
||||
“Is not that rather a large thing to expect us to begin upon?” said
|
||||
Filby, an argumentative person with red hair.
|
||||
|
||||
“I do not mean to ask you to accept anything without reasonable ground
|
||||
for it. You will soon admit as much as I need from you. You know of
|
||||
course that a mathematical line, a line of thickness _nil_, has no real
|
||||
existence. They taught you that? Neither has a mathematical plane.
|
||||
These things are mere abstractions.”
|
||||
|
||||
“That is all right,” said the Psychologist.
|
||||
|
||||
“Nor, having only length, breadth, and thickness, can a cube have a
|
||||
real existence.”
|
||||
|
||||
“There I object,” said Filby. “Of course a solid body may exist. All
|
||||
real things—”
|
||||
|
||||
“So most people think. But wait a moment. Can an _instantaneous_ cube
|
||||
exist?”
|
||||
|
||||
“Don’t follow you,” said Filby.
|
||||
|
||||
“Can a cube that does not last for any time at all, have a real
|
||||
existence?”
|
||||
|
||||
Filby became pensive. “Clearly,” the Time Traveller proceeded, “any
|
||||
real body must have extension in _four_ directions: it must have
|
||||
Length, Breadth, Thickness, and—Duration. But through a natural
|
||||
infirmity of the flesh, which I will explain to you in a moment, we
|
||||
incline to overlook this fact. There are really four dimensions, three
|
||||
which we call the three planes of Space, and a fourth, Time. There is,
|
||||
however, a tendency to draw an unreal distinction between the former
|
||||
three dimensions and the latter, because it happens that our
|
||||
consciousness moves intermittently in one direction along the latter
|
||||
from the beginning to the end of our lives.”
|
||||
|
||||
“That,” said a very young man, making spasmodic efforts to relight his
|
||||
cigar over the lamp; “that . . . very clear indeed.”
|
||||
|
||||
“Now, it is very remarkable that this is so extensively overlooked,”
|
||||
continued the Time Traveller, with a slight accession of cheerfulness.
|
||||
“Really this is what is meant by the Fourth Dimension, though some
|
||||
people who talk about the Fourth Dimension do not know they mean it. It
|
||||
is only another way of looking at Time. _There is no difference between
|
||||
Time and any of the three dimensions of Space except that our
|
||||
consciousness moves along it_. But some foolish people have got hold of
|
||||
the wrong side of that idea. You have all heard what they have to say
|
||||
about this Fourth Dimension?”
|
||||
|
||||
“_I_ have not,” said the Provincial Mayor.
|
||||
|
||||
“It is simply this. That Space, as our mathematicians have it, is
|
||||
spoken of as having three dimensions, which one may call Length,
|
||||
Breadth, and Thickness, and is always definable by reference to three
|
||||
planes, each at right angles to the others. But some philosophical
|
||||
people have been asking why _three_ dimensions particularly—why not
|
||||
another direction at right angles to the other three?—and have even
|
||||
tried to construct a Four-Dimensional geometry. Professor Simon Newcomb
|
||||
was expounding this to the New York Mathematical Society only a month
|
||||
or so ago. You know how on a flat surface, which has only two
|
||||
dimensions, we can represent a figure of a three-dimensional solid, and
|
||||
similarly they think that by models of three dimensions they could
|
||||
represent one of four—if they could master the perspective of the
|
||||
thing. See?”
|
||||
|
||||
“I think so,” murmured the Provincial Mayor; and, knitting his brows,
|
||||
he lapsed into an introspective state, his lips moving as one who
|
||||
repeats mystic words. “Yes, I think I see it now,” he said after some
|
||||
time, brightening in a quite transitory manner.
|
||||
|
||||
“Well, I do not mind telling you I have been at work upon this geometry
|
||||
of Four Dimensions for some time. Some of my results are curious. For
|
||||
instance, here is a portrait of a man at eight years old, another at
|
||||
fifteen, another at seventeen, another at twenty-three, and so on. All
|
||||
these are evidently sections, as it were, Three-Dimensional
|
||||
representations of his Four-Dimensioned being, which is a fixed and
|
||||
unalterable thing.
|
||||
|
||||
“Scientific people,” proceeded the Time Traveller, after the pause
|
||||
required for the proper assimilation of this, “know very well that Time
|
||||
is only a kind of Space. Here is a popular scientific diagram, a
|
||||
weather record. This line I trace with my finger shows the movement of
|
||||
the barometer. Yesterday it was so high, yesterday night it fell, then
|
||||
this morning it rose again, and so gently upward to here. Surely the
|
||||
mercury did not trace this line in any of the dimensions of Space
|
||||
generally recognised? But certainly it traced such a line, and that
|
||||
line, therefore, we must conclude, was along the Time-Dimension.”
|
||||
|
||||
“But,” said the Medical Man, staring hard at a coal in the fire, “if
|
||||
Time is really only a fourth dimension of Space, why is it, and why has
|
||||
it always been, regarded as something different? And why cannot we move
|
||||
in Time as we move about in the other dimensions of Space?”
|
||||
|
||||
The Time Traveller smiled. “Are you so sure we can move freely in
|
||||
Space? Right and left we can go, backward and forward freely enough,
|
||||
and men always have done so. I admit we move freely in two dimensions.
|
||||
But how about up and down? Gravitation limits us there.”
|
||||
|
||||
“Not exactly,” said the Medical Man. “There are balloons.”
|
||||
|
||||
“But before the balloons, save for spasmodic jumping and the
|
||||
inequalities of the surface, man had no freedom of vertical movement.”
|
||||
|
||||
“Still they could move a little up and down,” said the Medical Man.
|
||||
|
||||
“Easier, far easier down than up.”
|
||||
|
||||
“And you cannot move at all in Time, you cannot get away from the
|
||||
present moment.”
|
||||
|
||||
“My dear sir, that is just where you are wrong. That is just where the
|
||||
whole world has gone wrong. We are always getting away from the present
|
||||
moment. Our mental existences, which are immaterial and have no
|
||||
dimensions, are passing along the Time-Dimension with a uniform
|
||||
velocity from the cradle to the grave. Just as we should travel _down_
|
||||
if we began our existence fifty miles above the earth’s surface.”
|
||||
|
||||
“But the great difficulty is this,” interrupted the Psychologist. ’You
|
||||
_can_ move about in all directions of Space, but you cannot move about
|
||||
in Time.”
|
||||
|
||||
“That is the germ of my great discovery. But you are wrong to say that
|
||||
we cannot move about in Time. For instance, if I am recalling an
|
||||
incident very vividly I go back to the instant of its occurrence: I
|
||||
become absent-minded, as you say. I jump back for a moment. Of course
|
||||
we have no means of staying back for any length of Time, any more than
|
||||
a savage or an animal has of staying six feet above the ground. But a
|
||||
civilised man is better off than the savage in this respect. He can go
|
||||
up against gravitation in a balloon, and why should he not hope that
|
||||
ultimately he may be able to stop or accelerate his drift along the
|
||||
Time-Dimension, or even turn about and travel the other way?”
|
||||
|
||||
“Oh, _this_,” began Filby, “is all—”
|
||||
|
||||
“Why not?” said the Time Traveller.
|
||||
|
||||
“It’s against reason,” said Filby.
|
||||
|
||||
“What reason?” said the Time Traveller.
|
||||
|
||||
“You can show black is white by argument,” said Filby, “but you will
|
||||
never convince me.”
|
||||
|
||||
“Possibly not,” said the Time Traveller. “But now you begin to see the
|
||||
object of my investigations into the geometry of Four Dimensions. Long
|
||||
ago I had a vague inkling of a machine—”
|
||||
|
||||
“To travel through Time!” exclaimed the Very Young Man.
|
||||
|
||||
“That shall travel indifferently in any direction of Space and Time, as
|
||||
the driver determines.”
|
||||
|
||||
Filby contented himself with laughter.
|
||||
|
||||
“But I have experimental verification,” said the Time Traveller.
|
||||
|
||||
“It would be remarkably convenient for the historian,” the Psychologist
|
||||
suggested. “One might travel back and verify the accepted account of
|
||||
the Battle of Hastings, for instance!”
|
||||
|
||||
“Don’t you think you would attract attention?” said the Medical Man.
|
||||
“Our ancestors had no great tolerance for anachronisms.”
|
||||
|
||||
“One might get one’s Greek from the very lips of Homer and Plato,” the
|
||||
Very Young Man thought.
|
||||
|
||||
“In which case they would certainly plough you for the Little-go. The
|
||||
German scholars have improved Greek so much.”
|
||||
|
||||
“Then there is the future,” said the Very Young Man. “Just think! One
|
||||
might invest all one’s money, leave it to accumulate at interest, and
|
||||
hurry on ahead!”
|
||||
|
||||
“To discover a society,” said I, “erected on a strictly communistic
|
||||
basis.”
|
||||
|
||||
“Of all the wild extravagant theories!” began the Psychologist.
|
||||
|
||||
“Yes, so it seemed to me, and so I never talked of it until—”
|
||||
|
||||
“Experimental verification!” cried I. “You are going to verify _that_?”
|
||||
|
||||
“The experiment!” cried Filby, who was getting brain-weary.
|
||||
|
||||
“Let’s see your experiment anyhow,” said the Psychologist, “though it’s
|
||||
all humbug, you know.”
|
||||
|
||||
The Time Traveller smiled round at us. Then, still smiling faintly, and
|
||||
with his hands deep in his trousers pockets, he walked slowly out of
|
||||
the room, and we heard his slippers shuffling down the long passage to
|
||||
his laboratory.
|
||||
|
||||
The Psychologist looked at us. “I wonder what he’s got?”
|
||||
|
||||
“Some sleight-of-hand trick or other,” said the Medical Man, and Filby
|
||||
tried to tell us about a conjuror he had seen at Burslem, but before he
|
||||
had finished his preface the Time Traveller came back, and Filby’s
|
||||
anecdote collapsed.
|
8
data/headerNavLinks.js
Normal file
@ -0,0 +1,8 @@
|
||||
const headerNavLinks = [
|
||||
{ href: '/blog', title: 'Blog' },
|
||||
{ href: '/tags', title: 'Tags' },
|
||||
{ href: '/projects', title: 'Projects' },
|
||||
{ href: '/about', title: 'About' },
|
||||
]
|
||||
|
||||
export default headerNavLinks
|
3
data/logo.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" preserveAspectRatio="xMidYMid meet" viewBox="344.5639097744361 330.27819548872174 111.73684210526318 91.21804511278197" width="53.87" height="43.61"><defs><path d="M453.3 331.28L453.3 359.85L388.64 418.5L388.64 388.42L453.3 331.28Z" id="aFZf6T5ED"></path><linearGradient id="gradientb2ThqnP5Op" gradientUnits="userSpaceOnUse" x1="420.97" y1="331.28" x2="420.97" y2="418.5"><stop style="stop-color: #06b6d4;stop-opacity: 1" offset="0%"></stop><stop style="stop-color: #67e8f9;stop-opacity: 1" offset="100%"></stop></linearGradient><path d="M410.23 331.28L410.23 359.85L345.56 418.5L345.56 388.42L410.23 331.28Z" id="a9fehgwfM"></path><linearGradient id="gradientk1wNV9Ostb" gradientUnits="userSpaceOnUse" x1="377.89" y1="331.28" x2="377.89" y2="418.5"><stop style="stop-color: #06b6d4;stop-opacity: 1" offset="0%"></stop><stop style="stop-color: #67e8f9;stop-opacity: 1" offset="100%"></stop></linearGradient></defs><g><g><use xlink:href="#aFZf6T5ED" opacity="1" fill="url(#gradientb2ThqnP5Op)"></use></g><g><use xlink:href="#a9fehgwfM" opacity="1" fill="url(#gradientk1wNV9Ostb)"></use></g></g></svg>
|
After Width: | Height: | Size: 1.3 KiB |
20
data/projectsData.js
Normal file
@ -0,0 +1,20 @@
|
||||
const projectsData = [
|
||||
{
|
||||
title: 'A Search Engine',
|
||||
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',
|
||||
href: 'https://www.google.com',
|
||||
},
|
||||
{
|
||||
title: 'The Time Machine',
|
||||
description: `Imagine being able to travel back in time or to the future. Simple turn the knob
|
||||
to the desired date and press "Go". No more worrying about lost keys or
|
||||
forgotten headphones with this simple yet affordable solution.`,
|
||||
imgSrc: '/static/images/time-machine.jpg',
|
||||
href: '/blog/the-time-machine',
|
||||
},
|
||||
]
|
||||
|
||||
export default projectsData
|
33
data/references-data.bib
Normal 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}
|
||||
}
|
85
data/siteMetadata.js
Normal file
@ -0,0 +1,85 @@
|
||||
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: {
|
||||
// If you want to use an analytics provider you have to add it to the
|
||||
// content security policy in the `next.config.js` file.
|
||||
// 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
|
||||
posthogAnalyticsId: '', // posthog.init e.g. phc_5yXvArzvRdqtZIsHkEm3Fkkhm3d0bEYUXCaFISzqPSQ
|
||||
},
|
||||
newsletter: {
|
||||
// supports mailchimp, buttondown, convertkit, klaviyo, revue, emailoctopus
|
||||
// Please add your .env file and modify it according to your selection
|
||||
provider: 'buttondown',
|
||||
},
|
||||
comment: {
|
||||
// If you want to use a commenting system other than giscus you have to add it to the
|
||||
// content security policy in the `next.config.js` file.
|
||||
// Select a provider and use the environment variables associated to it
|
||||
// https://vercel.com/docs/environment-variables
|
||||
provider: 'giscus', // supported providers: giscus, utterances, disqus
|
||||
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',
|
||||
// Place the comment box above the comments. options: bottom, top
|
||||
inputPosition: 'bottom',
|
||||
// Choose the language giscus will be displayed in. options: en, es, zh-CN, zh-TW, ko, ja etc
|
||||
lang: 'en',
|
||||
// theme when dark mode
|
||||
darkTheme: 'transparent_dark',
|
||||
// If the theme option above is set to 'custom`
|
||||
// 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
|
12
jsconfig.json
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/components/*": ["components/*"],
|
||||
"@/data/*": ["data/*"],
|
||||
"@/layouts/*": ["layouts/*"],
|
||||
"@/lib/*": ["lib/*"],
|
||||
"@/css/*": ["css/*"]
|
||||
}
|
||||
}
|
||||
}
|
41
layouts/AuthorLayout.js
Normal file
@ -0,0 +1,41 @@
|
||||
import SocialIcon from '@/components/social-icons'
|
||||
import Image from '@/components/Image'
|
||||
import { PageSEO } from '@/components/SEO'
|
||||
|
||||
export default function AuthorLayout({ children, frontMatter }) {
|
||||
const { name, avatar, occupation, company, email, twitter, linkedin, github } = frontMatter
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageSEO title={`About - ${name}`} description={`About me - ${name}`} />
|
||||
<div className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<div className="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">
|
||||
<Image
|
||||
src={avatar}
|
||||
alt="avatar"
|
||||
width="192px"
|
||||
height="192px"
|
||||
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 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="prose max-w-none pt-8 pb-8 dark:prose-dark xl:col-span-2">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
91
layouts/ListLayout.js
Normal file
@ -0,0 +1,91 @@
|
||||
import Link from '@/components/Link'
|
||||
import Tag from '@/components/Tag'
|
||||
import siteMetadata from '@/data/siteMetadata'
|
||||
import { useState } from 'react'
|
||||
import Pagination from '@/components/Pagination'
|
||||
import formatDate from '@/lib/utils/formatDate'
|
||||
|
||||
export default function ListLayout({ posts, title, initialDisplayPosts = [], pagination }) {
|
||||
const [searchValue, setSearchValue] = useState('')
|
||||
const filteredBlogPosts = posts.filter((frontMatter) => {
|
||||
const searchContent = frontMatter.title + frontMatter.summary + frontMatter.tags.join(' ')
|
||||
return searchContent.toLowerCase().includes(searchValue.toLowerCase())
|
||||
})
|
||||
|
||||
// If initialDisplayPosts exist, display it if no searchValue is specified
|
||||
const displayPosts =
|
||||
initialDisplayPosts.length > 0 && !searchValue ? initialDisplayPosts : filteredBlogPosts
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<div className="space-y-2 pt-6 pb-8 md:space-y-5">
|
||||
<h1 className="text-3xl font-extrabold leading-9 tracking-tight text-gray-900 dark:text-gray-100 sm:text-4xl sm:leading-10 md:text-6xl md:leading-14">
|
||||
{title}
|
||||
</h1>
|
||||
<div className="relative max-w-lg">
|
||||
<input
|
||||
aria-label="Search articles"
|
||||
type="text"
|
||||
onChange={(e) => setSearchValue(e.target.value)}
|
||||
placeholder="Search articles"
|
||||
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 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"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<ul>
|
||||
{!filteredBlogPosts.length && 'No posts found.'}
|
||||
{displayPosts.map((frontMatter) => {
|
||||
const { slug, date, title, summary, tags } = frontMatter
|
||||
return (
|
||||
<li key={slug} className="py-4">
|
||||
<article className="space-y-2 xl:grid xl:grid-cols-4 xl:items-baseline xl:space-y-0">
|
||||
<dl>
|
||||
<dt className="sr-only">Published on</dt>
|
||||
<dd className="text-base font-medium leading-6 text-gray-500 dark:text-gray-400">
|
||||
<time dateTime={date}>{formatDate(date)}</time>
|
||||
</dd>
|
||||
</dl>
|
||||
<div className="space-y-3 xl:col-span-3">
|
||||
<div>
|
||||
<h3 className="text-2xl font-bold leading-8 tracking-tight">
|
||||
<Link href={`/blog/${slug}`} className="text-gray-900 dark:text-gray-100">
|
||||
{title}
|
||||
</Link>
|
||||
</h3>
|
||||
<div className="flex flex-wrap">
|
||||
{tags.map((tag) => (
|
||||
<Tag key={tag} text={tag} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="prose max-w-none text-gray-500 dark:text-gray-400">
|
||||
{summary}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
{pagination && pagination.totalPages > 1 && !searchValue && (
|
||||
<Pagination currentPage={pagination.currentPage} totalPages={pagination.totalPages} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
152
layouts/PostLayout.js
Normal file
@ -0,0 +1,152 @@
|
||||
import Link from '@/components/Link'
|
||||
import PageTitle from '@/components/PageTitle'
|
||||
import SectionContainer from '@/components/SectionContainer'
|
||||
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'
|
||||
|
||||
const editUrl = (fileName) => `${siteMetadata.siteRepo}/blob/master/data/blog/${fileName}`
|
||||
const discussUrl = (slug) =>
|
||||
`https://mobile.twitter.com/search?q=${encodeURIComponent(
|
||||
`${siteMetadata.siteUrl}/blog/${slug}`
|
||||
)}`
|
||||
|
||||
const postDateTemplate = { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }
|
||||
|
||||
export default function PostLayout({ frontMatter, authorDetails, next, prev, children }) {
|
||||
const { slug, fileName, date, title, images, tags } = frontMatter
|
||||
|
||||
return (
|
||||
<SectionContainer>
|
||||
<BlogSEO
|
||||
url={`${siteMetadata.siteUrl}/blog/${slug}`}
|
||||
authorDetails={authorDetails}
|
||||
{...frontMatter}
|
||||
/>
|
||||
<ScrollTopAndComment />
|
||||
<article>
|
||||
<div className="xl:divide-y xl:divide-gray-200 xl:dark:divide-gray-700">
|
||||
<header className="pt-6 xl:pb-6">
|
||||
<div className="space-y-1 text-center">
|
||||
<dl className="space-y-10">
|
||||
<div>
|
||||
<dt className="sr-only">Published on</dt>
|
||||
<dd className="text-base font-medium leading-6 text-gray-500 dark:text-gray-400">
|
||||
<time dateTime={date}>
|
||||
{new Date(date).toLocaleDateString(siteMetadata.locale, postDateTemplate)}
|
||||
</time>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div>
|
||||
<PageTitle>{title}</PageTitle>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div
|
||||
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: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 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 && (
|
||||
<Image
|
||||
src={author.avatar}
|
||||
width="38px"
|
||||
height="38px"
|
||||
alt="avatar"
|
||||
className="h-10 w-10 rounded-full"
|
||||
/>
|
||||
)}
|
||||
<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>
|
||||
<dd>
|
||||
{author.twitter && (
|
||||
<Link
|
||||
href={author.twitter}
|
||||
className="text-primary-500 hover:text-primary-600 dark:hover:text-primary-400"
|
||||
>
|
||||
{author.twitter.replace('https://twitter.com/', '@')}
|
||||
</Link>
|
||||
)}
|
||||
</dd>
|
||||
</dl>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</dd>
|
||||
</dl>
|
||||
<div className="divide-y divide-gray-200 dark:divide-gray-700 xl:col-span-3 xl:row-span-2 xl:pb-0">
|
||||
<div className="prose max-w-none pt-10 pb-8 dark:prose-dark">{children}</div>
|
||||
<div className="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>
|
||||
</div>
|
||||
<Comments frontMatter={frontMatter} />
|
||||
</div>
|
||||
<footer>
|
||||
<div className="divide-gray-200 text-sm font-medium leading-5 dark:divide-gray-700 xl:col-start-1 xl:row-start-2 xl:divide-y">
|
||||
{tags && (
|
||||
<div className="py-4 xl:py-8">
|
||||
<h2 className="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Tags
|
||||
</h2>
|
||||
<div className="flex flex-wrap">
|
||||
{tags.map((tag) => (
|
||||
<Tag key={tag} text={tag} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{(next || prev) && (
|
||||
<div className="flex justify-between py-4 xl:block xl:space-y-8 xl:py-8">
|
||||
{prev && (
|
||||
<div>
|
||||
<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">
|
||||
<Link href={`/blog/${prev.slug}`}>{prev.title}</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{next && (
|
||||
<div>
|
||||
<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">
|
||||
<Link href={`/blog/${next.slug}`}>{next.title}</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="pt-4 xl:pt-8">
|
||||
<Link
|
||||
href="/blog"
|
||||
className="text-primary-500 hover:text-primary-600 dark:hover:text-primary-400"
|
||||
>
|
||||
← Back to the blog
|
||||
</Link>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</SectionContainer>
|
||||
)
|
||||
}
|
71
layouts/PostSimple.js
Normal file
@ -0,0 +1,71 @@
|
||||
import Link from '@/components/Link'
|
||||
import PageTitle from '@/components/PageTitle'
|
||||
import SectionContainer from '@/components/SectionContainer'
|
||||
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'
|
||||
|
||||
export default function PostLayout({ frontMatter, authorDetails, next, prev, children }) {
|
||||
const { date, title } = frontMatter
|
||||
|
||||
return (
|
||||
<SectionContainer>
|
||||
<BlogSEO url={`${siteMetadata.siteUrl}/blog/${frontMatter.slug}`} {...frontMatter} />
|
||||
<ScrollTopAndComment />
|
||||
<article>
|
||||
<div>
|
||||
<header>
|
||||
<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>
|
||||
<dd className="text-base font-medium leading-6 text-gray-500 dark:text-gray-400">
|
||||
<time dateTime={date}>{formatDate(date)}</time>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div>
|
||||
<PageTitle>{title}</PageTitle>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div
|
||||
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: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 && (
|
||||
<div className="pt-4 xl:pt-8">
|
||||
<Link
|
||||
href={`/blog/${prev.slug}`}
|
||||
className="text-primary-500 hover:text-primary-600 dark:hover:text-primary-400"
|
||||
>
|
||||
← {prev.title}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
{next && (
|
||||
<div className="pt-4 xl:pt-8">
|
||||
<Link
|
||||
href={`/blog/${next.slug}`}
|
||||
className="text-primary-500 hover:text-primary-600 dark:hover:text-primary-400"
|
||||
>
|
||||
{next.title} →
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</SectionContainer>
|
||||
)
|
||||
}
|
32
lib/generate-rss.js
Normal file
@ -0,0 +1,32 @@
|
||||
import { escape } from '@/lib/utils/htmlEscaper'
|
||||
|
||||
import siteMetadata from '@/data/siteMetadata'
|
||||
|
||||
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>
|
||||
`
|
||||
export default generateRss
|
136
lib/mdx.js
Normal file
@ -0,0 +1,136 @@
|
||||
import { bundleMDX } from 'mdx-bundler'
|
||||
import fs from 'fs'
|
||||
import matter from 'gray-matter'
|
||||
import path from 'path'
|
||||
import readingTime from 'reading-time'
|
||||
import { visit } from 'unist-util-visit'
|
||||
import getAllFilesRecursively from './utils/files'
|
||||
// Remark packages
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkFootnotes from 'remark-footnotes'
|
||||
import remarkMath from 'remark-math'
|
||||
import remarkExtractFrontmatter from './remark-extract-frontmatter'
|
||||
import remarkCodeTitles from './remark-code-title'
|
||||
import remarkTocHeadings from './remark-toc-headings'
|
||||
import remarkImgToJsx from './remark-img-to-jsx'
|
||||
// Rehype packages
|
||||
import rehypeSlug from 'rehype-slug'
|
||||
import rehypeAutolinkHeadings from 'rehype-autolink-headings'
|
||||
import rehypeKatex from 'rehype-katex'
|
||||
import rehypeCitation from 'rehype-citation'
|
||||
import rehypePrismPlus from 'rehype-prism-plus'
|
||||
import rehypePresetMinify from 'rehype-preset-minify'
|
||||
|
||||
const root = process.cwd()
|
||||
|
||||
export function getFiles(type) {
|
||||
const prefixPaths = path.join(root, 'data', type)
|
||||
const files = getAllFilesRecursively(prefixPaths)
|
||||
// Only want to return blog/path and ignore root, replace is needed to work on Windows
|
||||
return files.map((file) => file.slice(prefixPaths.length + 1).replace(/\\/g, '/'))
|
||||
}
|
||||
|
||||
export function formatSlug(slug) {
|
||||
return slug.replace(/\.(mdx|md)/, '')
|
||||
}
|
||||
|
||||
export function dateSortDesc(a, b) {
|
||||
if (a > b) return -1
|
||||
if (a < b) return 1
|
||||
return 0
|
||||
}
|
||||
|
||||
export async function getFileBySlug(type, slug) {
|
||||
const mdxPath = path.join(root, 'data', type, `${slug}.mdx`)
|
||||
const mdPath = path.join(root, 'data', type, `${slug}.md`)
|
||||
const source = fs.existsSync(mdxPath)
|
||||
? fs.readFileSync(mdxPath, 'utf8')
|
||||
: fs.readFileSync(mdPath, 'utf8')
|
||||
|
||||
// https://github.com/kentcdodds/mdx-bundler#nextjs-esbuild-enoent
|
||||
if (process.platform === 'win32') {
|
||||
process.env.ESBUILD_BINARY_PATH = path.join(root, 'node_modules', 'esbuild', 'esbuild.exe')
|
||||
} else {
|
||||
process.env.ESBUILD_BINARY_PATH = path.join(root, 'node_modules', 'esbuild', 'bin', 'esbuild')
|
||||
}
|
||||
|
||||
let toc = []
|
||||
|
||||
const { code, frontmatter } = await bundleMDX({
|
||||
source,
|
||||
// mdx imports can be automatically source from the components directory
|
||||
cwd: path.join(root, 'components'),
|
||||
xdmOptions(options, frontmatter) {
|
||||
// this is the recommended way to add custom remark/rehype plugins:
|
||||
// The syntax might look weird, but it protects you in case we add/remove
|
||||
// plugins in the future.
|
||||
options.remarkPlugins = [
|
||||
...(options.remarkPlugins ?? []),
|
||||
remarkExtractFrontmatter,
|
||||
[remarkTocHeadings, { exportRef: toc }],
|
||||
remarkGfm,
|
||||
remarkCodeTitles,
|
||||
[remarkFootnotes, { inlineNotes: true }],
|
||||
remarkMath,
|
||||
remarkImgToJsx,
|
||||
]
|
||||
options.rehypePlugins = [
|
||||
...(options.rehypePlugins ?? []),
|
||||
rehypeSlug,
|
||||
rehypeAutolinkHeadings,
|
||||
rehypeKatex,
|
||||
[rehypeCitation, { path: path.join(root, 'data') }],
|
||||
[rehypePrismPlus, { ignoreMissing: true }],
|
||||
rehypePresetMinify,
|
||||
]
|
||||
return options
|
||||
},
|
||||
esbuildOptions: (options) => {
|
||||
options.loader = {
|
||||
...options.loader,
|
||||
'.js': 'jsx',
|
||||
}
|
||||
return options
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
mdxSource: code,
|
||||
toc,
|
||||
frontMatter: {
|
||||
readingTime: readingTime(code),
|
||||
slug: slug || null,
|
||||
fileName: fs.existsSync(mdxPath) ? `${slug}.mdx` : `${slug}.md`,
|
||||
...frontmatter,
|
||||
date: frontmatter.date ? new Date(frontmatter.date).toISOString() : null,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAllFilesFrontMatter(folder) {
|
||||
const prefixPaths = path.join(root, 'data', folder)
|
||||
|
||||
const files = getAllFilesRecursively(prefixPaths)
|
||||
|
||||
const allFrontMatter = []
|
||||
|
||||
files.forEach((file) => {
|
||||
// Replace is needed to work on Windows
|
||||
const fileName = file.slice(prefixPaths.length + 1).replace(/\\/g, '/')
|
||||
// Remove Unexpected File
|
||||
if (path.extname(fileName) !== '.md' && path.extname(fileName) !== '.mdx') {
|
||||
return
|
||||
}
|
||||
const source = fs.readFileSync(file, 'utf8')
|
||||
const { data: frontmatter } = matter(source)
|
||||
if (frontmatter.draft !== true) {
|
||||
allFrontMatter.push({
|
||||
...frontmatter,
|
||||
slug: formatSlug(fileName),
|
||||
date: frontmatter.date ? new Date(frontmatter.date).toISOString() : null,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return allFrontMatter.sort((a, b) => dateSortDesc(a.date, b.date))
|
||||
}
|
32
lib/remark-code-title.js
Normal file
@ -0,0 +1,32 @@
|
||||
import { visit } from 'unist-util-visit'
|
||||
|
||||
export default function remarkCodeTitles() {
|
||||
return (tree) =>
|
||||
visit(tree, 'code', (node, index, parent) => {
|
||||
const nodeLang = node.lang || ''
|
||||
let language = ''
|
||||
let title = ''
|
||||
|
||||
if (nodeLang.includes(':')) {
|
||||
language = nodeLang.slice(0, nodeLang.search(':'))
|
||||
title = nodeLang.slice(nodeLang.search(':') + 1, nodeLang.length)
|
||||
}
|
||||
|
||||
if (!title) {
|
||||
return
|
||||
}
|
||||
|
||||
const className = 'remark-code-title'
|
||||
|
||||
const titleNode = {
|
||||
type: 'mdxJsxFlowElement',
|
||||
name: 'div',
|
||||
attributes: [{ type: 'mdxJsxAttribute', name: 'className', value: className }],
|
||||
children: [{ type: 'text', value: title }],
|
||||
data: { _xdmExplicitJsx: true },
|
||||
}
|
||||
|
||||
parent.children.splice(index, 0, titleNode)
|
||||
node.lang = language
|
||||
})
|
||||
}
|
10
lib/remark-extract-frontmatter.js
Normal file
@ -0,0 +1,10 @@
|
||||
import { visit } from 'unist-util-visit'
|
||||
import { load } from 'js-yaml'
|
||||
|
||||
export default function extractFrontmatter() {
|
||||
return (tree, file) => {
|
||||
visit(tree, 'yaml', (node, index, parent) => {
|
||||
file.data.frontmatter = load(node.value)
|
||||
})
|
||||
}
|
||||
}
|
35
lib/remark-img-to-jsx.js
Normal file
@ -0,0 +1,35 @@
|
||||
import { visit } from 'unist-util-visit'
|
||||
import sizeOf from 'image-size'
|
||||
import fs from 'fs'
|
||||
|
||||
export default function remarkImgToJsx() {
|
||||
return (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]
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
15
lib/remark-toc-headings.js
Normal file
@ -0,0 +1,15 @@
|
||||
import { visit } from 'unist-util-visit'
|
||||
import { slug } from 'github-slugger'
|
||||
import { toString } from 'mdast-util-to-string'
|
||||
|
||||
export default function remarkTocHeadings(options) {
|
||||
return (tree) =>
|
||||
visit(tree, 'heading', (node, index, parent) => {
|
||||
const textContent = toString(node)
|
||||
options.exportRef.push({
|
||||
value: textContent,
|
||||
url: '#' + slug(textContent),
|
||||
depth: node.depth,
|
||||
})
|
||||
})
|
||||
}
|
30
lib/tags.js
Normal file
@ -0,0 +1,30 @@
|
||||
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
|
||||
}
|
23
lib/utils/files.js
Normal file
@ -0,0 +1,23 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
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) => {
|
||||
return fs.statSync(fullPath).isFile() ? fullPath : getAllFilesRecursively(fullPath)
|
||||
}
|
||||
|
||||
const pathJoinPrefix = (prefix) => (extraPath) => path.join(prefix, extraPath)
|
||||
|
||||
const getAllFilesRecursively = (folder) =>
|
||||
pipe(fs.readdirSync, map(pipe(pathJoinPrefix(folder), walkDir)), flattenArray)(folder)
|
||||
|
||||
export default getAllFilesRecursively
|
14
lib/utils/formatDate.js
Normal file
@ -0,0 +1,14 @@
|
||||
import siteMetadata from '@/data/siteMetadata'
|
||||
|
||||
const formatDate = (date) => {
|
||||
const options = {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
}
|
||||
const now = new Date(date).toLocaleDateString(siteMetadata.locale, options)
|
||||
|
||||
return now
|
||||
}
|
||||
|
||||
export default formatDate
|
23
lib/utils/htmlEscaper.js
Normal file
@ -0,0 +1,23 @@
|
||||
const { replace } = ''
|
||||
|
||||
// escape
|
||||
const es = /&(?:amp|#38|lt|#60|gt|#62|apos|#39|quot|#34);/g
|
||||
const ca = /[&<>'"]/g
|
||||
|
||||
const esca = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
"'": ''',
|
||||
'"': '"',
|
||||
}
|
||||
const pe = (m) => 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) => replace.call(es, ca, pe)
|
5
lib/utils/kebabCase.js
Normal file
@ -0,0 +1,5 @@
|
||||
import { slug } from 'github-slugger'
|
||||
|
||||
const kebabCase = (str) => slug(str)
|
||||
|
||||
export default kebabCase
|
87
next.config.js
Normal file
@ -0,0 +1,87 @@
|
||||
const withBundleAnalyzer = require('@next/bundle-analyzer')({
|
||||
enabled: process.env.ANALYZE === 'true',
|
||||
})
|
||||
|
||||
// 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';
|
||||
img-src * blob: data:;
|
||||
media-src 'none';
|
||||
connect-src *;
|
||||
font-src 'self';
|
||||
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, ''),
|
||||
},
|
||||
// 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',
|
||||
},
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Feature-Policy
|
||||
{
|
||||
key: 'Permissions-Policy',
|
||||
value: 'camera=(), microphone=(), geolocation=()',
|
||||
},
|
||||
]
|
||||
|
||||
module.exports = withBundleAnalyzer({
|
||||
reactStrictMode: true,
|
||||
pageExtensions: ['js', 'jsx', 'md', 'mdx'],
|
||||
eslint: {
|
||||
dirs: ['pages', 'components', 'lib', 'layouts', 'scripts'],
|
||||
},
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: '/(.*)',
|
||||
headers: securityHeaders,
|
||||
},
|
||||
]
|
||||
},
|
||||
webpack: (config, { dev, isServer }) => {
|
||||
config.module.rules.push({
|
||||
test: /\.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
|
||||
},
|
||||
})
|
BIN
package-lock.json
generated
Normal file
73
package.json
Normal file
@ -0,0 +1,73 @@
|
||||
{
|
||||
"name": "tailwind-nextjs-starter-blog",
|
||||
"version": "1.5.6",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "cross-env SOCKET=true node ./scripts/next-remote-watch.js ./data",
|
||||
"dev": "next dev",
|
||||
"build": "next build && node ./scripts/generate-sitemap",
|
||||
"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": {
|
||||
"@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",
|
||||
"esbuild": "^0.13.13",
|
||||
"github-slugger": "^1.3.0",
|
||||
"gray-matter": "^4.0.2",
|
||||
"image-size": "1.0.0",
|
||||
"mdx-bundler": "^8.0.0",
|
||||
"next": "12.1.4",
|
||||
"next-themes": "^0.0.14",
|
||||
"postcss": "^8.4.5",
|
||||
"preact": "^10.6.2",
|
||||
"react": "17.0.2",
|
||||
"react-dom": "17.0.2",
|
||||
"reading-time": "1.3.0",
|
||||
"rehype-autolink-headings": "^6.1.0",
|
||||
"rehype-citation": "^0.4.0",
|
||||
"rehype-katex": "^6.0.2",
|
||||
"rehype-preset-minify": "6.0.0",
|
||||
"rehype-prism-plus": "^1.1.3",
|
||||
"rehype-slug": "^5.0.0",
|
||||
"remark-footnotes": "^4.0.1",
|
||||
"remark-gfm": "^3.0.1",
|
||||
"remark-math": "^5.1.1",
|
||||
"sharp": "^0.28.3",
|
||||
"tailwindcss": "^3.0.23",
|
||||
"unist-util-visit": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@next/bundle-analyzer": "12.1.4",
|
||||
"@svgr/webpack": "^6.1.2",
|
||||
"cross-env": "^7.0.3",
|
||||
"dedent": "^0.7.0",
|
||||
"eslint": "^7.29.0",
|
||||
"eslint-config-next": "12.1.4",
|
||||
"eslint-config-prettier": "^8.3.0",
|
||||
"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.5.1",
|
||||
"prettier-plugin-tailwindcss": "^0.1.4",
|
||||
"socket.io": "^4.4.0",
|
||||
"socket.io-client": "^4.4.0"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.+(js|jsx|ts|tsx)": [
|
||||
"eslint --fix"
|
||||
],
|
||||
"*.+(js|jsx|ts|tsx|json|css|md|mdx)": [
|
||||
"prettier --write"
|
||||
]
|
||||
}
|
||||
}
|
31
pages/404.js
Normal file
@ -0,0 +1,31 @@
|
||||
import Link from '@/components/Link'
|
||||
import { PageSEO } from '@/components/SEO'
|
||||
import siteMetadata from '@/data/siteMetadata'
|
||||
|
||||
export default function FourZeroFour() {
|
||||
return (
|
||||
<>
|
||||
<PageSEO title={`Page Not Found - ${siteMetadata.title}`} />
|
||||
<div className="flex flex-col items-start justify-start md:mt-24 md:flex-row md:items-center md:justify-center md:space-x-6">
|
||||
<div className="space-x-2 pt-6 pb-8 md:space-y-5">
|
||||
<h1 className="text-6xl font-extrabold leading-9 tracking-tight text-gray-900 dark:text-gray-100 md:border-r-2 md:px-6 md:text-8xl md:leading-14">
|
||||
404
|
||||
</h1>
|
||||
</div>
|
||||
<div className="max-w-md">
|
||||
<p className="mb-4 text-xl font-bold leading-normal md:text-2xl">
|
||||
Sorry we couldn't find this page.
|
||||
</p>
|
||||
<p className="mb-8">
|
||||
But dont worry, you can find plenty of other things on our homepage.
|
||||
</p>
|
||||
<Link href="/">
|
||||
<button className="focus:shadow-outline-blue inline rounded-lg border border-transparent bg-blue-600 px-4 py-2 text-sm font-medium leading-5 text-white shadow transition-colors duration-150 hover:bg-blue-700 focus:outline-none dark:hover:bg-blue-500">
|
||||
Back to homepage
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
31
pages/_app.js
Normal file
@ -0,0 +1,31 @@
|
||||
import '@/css/tailwind.css'
|
||||
import '@/css/prism.css'
|
||||
import 'katex/dist/katex.css'
|
||||
|
||||
import '@fontsource/inter/variable-full.css'
|
||||
|
||||
import { ThemeProvider } from 'next-themes'
|
||||
import Head from 'next/head'
|
||||
|
||||
import siteMetadata from '@/data/siteMetadata'
|
||||
import Analytics from '@/components/analytics'
|
||||
import LayoutWrapper from '@/components/LayoutWrapper'
|
||||
import { ClientReload } from '@/components/ClientReload'
|
||||
|
||||
const isDevelopment = process.env.NODE_ENV === 'development'
|
||||
const isSocket = process.env.SOCKET
|
||||
|
||||
export default function App({ Component, pageProps }) {
|
||||
return (
|
||||
<ThemeProvider attribute="class" defaultTheme={siteMetadata.theme}>
|
||||
<Head>
|
||||
<meta content="width=device-width, initial-scale=1" name="viewport" />
|
||||
</Head>
|
||||
{isDevelopment && isSocket && <ClientReload />}
|
||||
<Analytics />
|
||||
<LayoutWrapper>
|
||||
<Component {...pageProps} />
|
||||
</LayoutWrapper>
|
||||
</ThemeProvider>
|
||||
)
|
||||
}
|
36
pages/_document.js
Normal file
@ -0,0 +1,36 @@
|
||||
import Document, { Html, Head, Main, NextScript } from 'next/document'
|
||||
class MyDocument extends Document {
|
||||
render() {
|
||||
return (
|
||||
<Html lang="en" className="scroll-smooth">
|
||||
<Head>
|
||||
<link rel="apple-touch-icon" sizes="76x76" href="/static/favicons/apple-touch-icon.png" />
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="32x32"
|
||||
href="/static/favicons/favicon-32x32.png"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="16x16"
|
||||
href="/static/favicons/favicon-16x16.png"
|
||||
/>
|
||||
<link rel="manifest" href="/static/favicons/site.webmanifest" />
|
||||
<link rel="mask-icon" href="/static/favicons/safari-pinned-tab.svg" color="#5bbad5" />
|
||||
<meta name="msapplication-TileColor" content="#000000" />
|
||||
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#fff" />
|
||||
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#000" />
|
||||
<link rel="alternate" type="application/rss+xml" href="/feed.xml" />
|
||||
</Head>
|
||||
<body className="bg-white text-black antialiased dark:bg-gray-900 dark:text-white">
|
||||
<Main />
|
||||
<NextScript />
|
||||
</body>
|
||||
</Html>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default MyDocument
|
21
pages/about.js
Normal file
@ -0,0 +1,21 @@
|
||||
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}
|
||||
/>
|
||||
)
|
||||
}
|
30
pages/api/buttondown.js
Normal file
@ -0,0 +1,30 @@
|
||||
// eslint-disable-next-line import/no-anonymous-default-export
|
||||
export default async (req, res) => {
|
||||
const { email } = req.body
|
||||
if (!email) {
|
||||
return res.status(400).json({ error: 'Email is required' })
|
||||
}
|
||||
|
||||
try {
|
||||
const API_KEY = process.env.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() })
|
||||
}
|
||||
}
|
35
pages/api/convertkit.js
Normal file
@ -0,0 +1,35 @@
|
||||
/* eslint-disable import/no-anonymous-default-export */
|
||||
export default async (req, res) => {
|
||||
const { email } = req.body
|
||||
|
||||
if (!email) {
|
||||
return res.status(400).json({ error: 'Email is required' })
|
||||
}
|
||||
|
||||
try {
|
||||
const 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() })
|
||||
}
|
||||
}
|
33
pages/api/emailoctopus.js
Normal file
@ -0,0 +1,33 @@
|
||||
// eslint-disable-next-line import/no-anonymous-default-export
|
||||
export default async (req, res) => {
|
||||
const { email } = req.body
|
||||
if (!email) {
|
||||
return res.status(400).json({ error: 'Email is required' })
|
||||
}
|
||||
|
||||
try {
|
||||
const API_URL = process.env.EMAILOCTOPUS_API_URL
|
||||
const API_KEY = process.env.EMAILOCTOPUS_API_KEY
|
||||
const LIST_ID = process.env.EMAILOCTOPUS_LIST_ID
|
||||
|
||||
const data = { email_address: email, api_key: API_KEY }
|
||||
|
||||
const API_ROUTE = `${API_URL}lists/${LIST_ID}/contacts`
|
||||
|
||||
const response = await fetch(API_ROUTE, {
|
||||
body: JSON.stringify(data),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
method: 'POST',
|
||||
})
|
||||
|
||||
if (response.status >= 400) {
|
||||
return res.status(500).json({ error: `There was an error subscribing to the list.` })
|
||||
}
|
||||
|
||||
return res.status(201).json({ error: '' })
|
||||
} catch (error) {
|
||||
return res.status(500).json({ error: error.message || error.toString() })
|
||||
}
|
||||
}
|
35
pages/api/klaviyo.js
Normal file
@ -0,0 +1,35 @@
|
||||
/* eslint-disable import/no-anonymous-default-export */
|
||||
export default async (req, res) => {
|
||||
const { email } = req.body
|
||||
if (!email) {
|
||||
return res.status(400).json({ error: 'Email is required' })
|
||||
}
|
||||
|
||||
try {
|
||||
const API_KEY = process.env.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() })
|
||||
}
|
||||
}
|
25
pages/api/mailchimp.js
Normal file
@ -0,0 +1,25 @@
|
||||
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, res) => {
|
||||
const { email } = req.body
|
||||
|
||||
if (!email) {
|
||||
return res.status(400).json({ error: 'Email is required' })
|
||||
}
|
||||
|
||||
try {
|
||||
const test = 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() })
|
||||
}
|
||||
}
|
30
pages/api/revue.js
Normal file
@ -0,0 +1,30 @@
|
||||
// eslint-disable-next-line import/no-anonymous-default-export
|
||||
export default async (req, res) => {
|
||||
const { email } = req.body
|
||||
|
||||
if (!email) {
|
||||
return res.status(400).json({ error: 'Email is required' })
|
||||
}
|
||||
|
||||
try {
|
||||
const API_KEY = process.env.REVUE_API_KEY
|
||||
const revueRoute = `${process.env.REVUE_API_URL}subscribers`
|
||||
|
||||
const response = await fetch(revueRoute, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Token ${API_KEY}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ email, double_opt_in: false }),
|
||||
})
|
||||
|
||||
if (response.status >= 400) {
|
||||
return res.status(500).json({ error: `There was an error subscribing to the list.` })
|
||||
}
|
||||
|
||||
return res.status(201).json({ error: '' })
|
||||
} catch (error) {
|
||||
return res.status(500).json({ error: error.message || error.toString() })
|
||||
}
|
||||
}
|
31
pages/blog.js
Normal file
@ -0,0 +1,31 @@
|
||||
import { getAllFilesFrontMatter } from '@/lib/mdx'
|
||||
import siteMetadata from '@/data/siteMetadata'
|
||||
import ListLayout from '@/layouts/ListLayout'
|
||||
import { PageSEO } from '@/components/SEO'
|
||||
|
||||
export const POSTS_PER_PAGE = 5
|
||||
|
||||
export async function getStaticProps() {
|
||||
const posts = await getAllFilesFrontMatter('blog')
|
||||
const initialDisplayPosts = posts.slice(0, POSTS_PER_PAGE)
|
||||
const pagination = {
|
||||
currentPage: 1,
|
||||
totalPages: Math.ceil(posts.length / POSTS_PER_PAGE),
|
||||
}
|
||||
|
||||
return { props: { initialDisplayPosts, posts, pagination } }
|
||||
}
|
||||
|
||||
export default function Blog({ posts, initialDisplayPosts, pagination }) {
|
||||
return (
|
||||
<>
|
||||
<PageSEO title={`Blog - ${siteMetadata.author}`} description={siteMetadata.description} />
|
||||
<ListLayout
|
||||
posts={posts}
|
||||
initialDisplayPosts={initialDisplayPosts}
|
||||
pagination={pagination}
|
||||
title="All Posts"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
70
pages/blog/[...slug].js
Normal file
@ -0,0 +1,70 @@
|
||||
import fs from 'fs'
|
||||
import PageTitle from '@/components/PageTitle'
|
||||
import generateRss from '@/lib/generate-rss'
|
||||
import { MDXLayoutRenderer } from '@/components/MDXComponents'
|
||||
import { formatSlug, getAllFilesFrontMatter, getFileBySlug, getFiles } from '@/lib/mdx'
|
||||
|
||||
const DEFAULT_LAYOUT = 'PostLayout'
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const posts = getFiles('blog')
|
||||
return {
|
||||
paths: posts.map((p) => ({
|
||||
params: {
|
||||
slug: formatSlug(p).split('/'),
|
||||
},
|
||||
})),
|
||||
fallback: false,
|
||||
}
|
||||
}
|
||||
|
||||
export async function getStaticProps({ params }) {
|
||||
const allPosts = await getAllFilesFrontMatter('blog')
|
||||
const postIndex = allPosts.findIndex((post) => formatSlug(post.slug) === params.slug.join('/'))
|
||||
const prev = allPosts[postIndex + 1] || null
|
||||
const next = allPosts[postIndex - 1] || null
|
||||
const post = await getFileBySlug('blog', params.slug.join('/'))
|
||||
const authorList = post.frontMatter.authors || ['default']
|
||||
const authorPromise = authorList.map(async (author) => {
|
||||
const authorResults = await getFileBySlug('authors', [author])
|
||||
return authorResults.frontMatter
|
||||
})
|
||||
const authorDetails = await Promise.all(authorPromise)
|
||||
|
||||
// rss
|
||||
if (allPosts.length > 0) {
|
||||
const rss = generateRss(allPosts)
|
||||
fs.writeFileSync('./public/feed.xml', rss)
|
||||
}
|
||||
|
||||
return { props: { post, authorDetails, prev, next } }
|
||||
}
|
||||
|
||||
export default function Blog({ post, authorDetails, prev, next }) {
|
||||
const { mdxSource, toc, frontMatter } = post
|
||||
|
||||
return (
|
||||
<>
|
||||
{frontMatter.draft !== true ? (
|
||||
<MDXLayoutRenderer
|
||||
layout={frontMatter.layout || DEFAULT_LAYOUT}
|
||||
toc={toc}
|
||||
mdxSource={mdxSource}
|
||||
frontMatter={frontMatter}
|
||||
authorDetails={authorDetails}
|
||||
prev={prev}
|
||||
next={next}
|
||||
/>
|
||||
) : (
|
||||
<div className="mt-24 text-center">
|
||||
<PageTitle>
|
||||
Under Construction{' '}
|
||||
<span role="img" aria-label="roadwork sign">
|
||||
🚧
|
||||
</span>
|
||||
</PageTitle>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
56
pages/blog/page/[page].js
Normal file
@ -0,0 +1,56 @@
|
||||
import { PageSEO } from '@/components/SEO'
|
||||
import siteMetadata from '@/data/siteMetadata'
|
||||
import { getAllFilesFrontMatter } from '@/lib/mdx'
|
||||
import ListLayout from '@/layouts/ListLayout'
|
||||
import { POSTS_PER_PAGE } from '../../blog'
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const totalPosts = await getAllFilesFrontMatter('blog')
|
||||
const totalPages = Math.ceil(totalPosts.length / POSTS_PER_PAGE)
|
||||
const paths = Array.from({ length: totalPages }, (_, i) => ({
|
||||
params: { page: (i + 1).toString() },
|
||||
}))
|
||||
|
||||
return {
|
||||
paths,
|
||||
fallback: false,
|
||||
}
|
||||
}
|
||||
|
||||
export async function getStaticProps(context) {
|
||||
const {
|
||||
params: { page },
|
||||
} = context
|
||||
const posts = await getAllFilesFrontMatter('blog')
|
||||
const pageNumber = parseInt(page)
|
||||
const initialDisplayPosts = posts.slice(
|
||||
POSTS_PER_PAGE * (pageNumber - 1),
|
||||
POSTS_PER_PAGE * pageNumber
|
||||
)
|
||||
const pagination = {
|
||||
currentPage: pageNumber,
|
||||
totalPages: Math.ceil(posts.length / POSTS_PER_PAGE),
|
||||
}
|
||||
|
||||
return {
|
||||
props: {
|
||||
posts,
|
||||
initialDisplayPosts,
|
||||
pagination,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default function PostPage({ posts, initialDisplayPosts, pagination }) {
|
||||
return (
|
||||
<>
|
||||
<PageSEO title={siteMetadata.title} description={siteMetadata.description} />
|
||||
<ListLayout
|
||||
posts={posts}
|
||||
initialDisplayPosts={initialDisplayPosts}
|
||||
pagination={pagination}
|
||||
title="All Posts"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
101
pages/index.js
Normal file
@ -0,0 +1,101 @@
|
||||
import Link from '@/components/Link'
|
||||
import { PageSEO } from '@/components/SEO'
|
||||
import Tag from '@/components/Tag'
|
||||
import siteMetadata from '@/data/siteMetadata'
|
||||
import { getAllFilesFrontMatter } from '@/lib/mdx'
|
||||
import formatDate from '@/lib/utils/formatDate'
|
||||
|
||||
import NewsletterForm from '@/components/NewsletterForm'
|
||||
|
||||
const MAX_DISPLAY = 5
|
||||
|
||||
export async function getStaticProps() {
|
||||
const posts = await getAllFilesFrontMatter('blog')
|
||||
|
||||
return { props: { posts } }
|
||||
}
|
||||
|
||||
export default function Home({ posts }) {
|
||||
return (
|
||||
<>
|
||||
<PageSEO title={siteMetadata.title} description={siteMetadata.description} />
|
||||
<div className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<div className="space-y-2 pt-6 pb-8 md:space-y-5">
|
||||
<h1 className="text-3xl font-extrabold leading-9 tracking-tight text-gray-900 dark:text-gray-100 sm:text-4xl sm:leading-10 md:text-6xl md:leading-14">
|
||||
Latest
|
||||
</h1>
|
||||
<p className="text-lg leading-7 text-gray-500 dark:text-gray-400">
|
||||
{siteMetadata.description}
|
||||
</p>
|
||||
</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
|
||||
return (
|
||||
<li key={slug} className="py-12">
|
||||
<article>
|
||||
<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">
|
||||
<time dateTime={date}>{formatDate(date)}</time>
|
||||
</dd>
|
||||
</dl>
|
||||
<div className="space-y-5 xl:col-span-3">
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold leading-8 tracking-tight">
|
||||
<Link
|
||||
href={`/blog/${slug}`}
|
||||
className="text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
{title}
|
||||
</Link>
|
||||
</h2>
|
||||
<div className="flex flex-wrap">
|
||||
{tags.map((tag) => (
|
||||
<Tag key={tag} text={tag} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="prose max-w-none text-gray-500 dark:text-gray-400">
|
||||
{summary}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-base font-medium leading-6">
|
||||
<Link
|
||||
href={`/blog/${slug}`}
|
||||
className="text-primary-500 hover:text-primary-600 dark:hover:text-primary-400"
|
||||
aria-label={`Read "${title}"`}
|
||||
>
|
||||
Read more →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
{posts.length > MAX_DISPLAY && (
|
||||
<div className="flex justify-end text-base font-medium leading-6">
|
||||
<Link
|
||||
href="/blog"
|
||||
className="text-primary-500 hover:text-primary-600 dark:hover:text-primary-400"
|
||||
aria-label="all posts"
|
||||
>
|
||||
All Posts →
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
{siteMetadata.newsletter.provider !== '' && (
|
||||
<div className="flex items-center justify-center pt-4">
|
||||
<NewsletterForm />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
35
pages/projects.js
Normal file
@ -0,0 +1,35 @@
|
||||
import siteMetadata from '@/data/siteMetadata'
|
||||
import projectsData from '@/data/projectsData'
|
||||
import Card from '@/components/Card'
|
||||
import { PageSEO } from '@/components/SEO'
|
||||
|
||||
export default function Projects() {
|
||||
return (
|
||||
<>
|
||||
<PageSEO title={`Projects - ${siteMetadata.author}`} description={siteMetadata.description} />
|
||||
<div className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<div className="space-y-2 pt-6 pb-8 md:space-y-5">
|
||||
<h1 className="text-3xl font-extrabold leading-9 tracking-tight text-gray-900 dark:text-gray-100 sm:text-4xl sm:leading-10 md:text-6xl md:leading-14">
|
||||
Projects
|
||||
</h1>
|
||||
<p className="text-lg leading-7 text-gray-500 dark:text-gray-400">
|
||||
Showcase your projects with a hero image (16 x 9)
|
||||
</p>
|
||||
</div>
|
||||
<div className="container py-12">
|
||||
<div className="-m-4 flex flex-wrap">
|
||||
{projectsData.map((d) => (
|
||||
<Card
|
||||
key={d.title}
|
||||
title={d.title}
|
||||
description={d.description}
|
||||
imgSrc={d.imgSrc}
|
||||
href={d.href}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|