First iteration of blog
Some checks failed
GitHub Pages / deploy (push) Has been cancelled
GitHub Pages / build (push) Has been cancelled

This commit is contained in:
Jonathan Branan 2024-10-14 00:18:50 -05:00
parent 336b7b579a
commit 081f5b1f67
112 changed files with 6638 additions and 124 deletions

View File

@ -0,0 +1,28 @@
{
"image": "mcr.microsoft.com/devcontainers/javascript-node:20",
"hostRequirements": {
"memory": "8gb"
},
"waitFor": "onCreateCommand",
"updateContentCommand": "yarn",
"postCreateCommand": "",
"postAttachCommand": "yarn dev",
"customizations": {
"codespaces": {
"openFiles": ["data/blog/code-sample.mdx"]
},
"vscode": {
"settings": {
"editor.formatOnSave": true
},
"extensions": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode"]
}
},
"portsAttributes": {
"3000": {
"label": "Nextjs server",
"onAutoForward": "openPreview"
}
},
"forwardPorts": [3000]
}

33
.env.example Normal file
View File

@ -0,0 +1,33 @@
# visit https://giscus.app to get your Giscus ids
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_KEY=
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_KEY=
# Create EmailOctopus API key at https://emailoctopus.com/api-documentation
EMAILOCTOPUS_API_KEY=
# List ID can be found in the URL as a UUID after clicking a list on https://emailoctopus.com/lists
# or the settings page of your list https://emailoctopus.com/lists/{UUID}/settings
EMAILOCTOPUS_LIST_ID=
# Create Beehive API key at https://developers.beehiiv.com/docs/v2/bktd9a7mxo67n-create-an-api-key
BEEHIVE_API_KEY=
BEEHIVE_PUBLICATION_ID=

2
.eslintignore Normal file
View File

@ -0,0 +1,2 @@
node_modules
.eslintrc.js

42
.eslintrc.js Normal file
View File

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

202
.gitattributes vendored Normal file
View File

@ -0,0 +1,202 @@
## Source: https://github.com/alexkaratarakis/gitattributes
## Modified * text=auto to * text=auto eol=lf 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
View File

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

39
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@ -0,0 +1,39 @@
---
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 18+)
- Npm version
**Browser Info (if display / formatting issue):**
- Device [e.g. Desktop, iPhone6]
- Browser [e.g. chrome, safari]
- Version [e.g. 22]
**Additional context**
Add any other context about the problem here.

View File

@ -0,0 +1,19 @@
---
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.

55
.github/workflows/pages.yml vendored Normal file
View File

@ -0,0 +1,55 @@
# Inspired from https://github.com/actions/starter-workflows/blob/main/pages/nextjs.yml
name: GitHub Pages
on:
push:
branches: main
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: 'pages'
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: yarn
- id: configurepages
uses: actions/configure-pages@v5
- name: Restore cache
uses: actions/cache@v4
with:
path: .next/cache
key: ${{ runner.os }}-nextjs-${{ hashFiles('**/yarn.lock') }}-${{ hashFiles('**.[jt]s', '**.[jt]sx') }}
restore-keys: |
${{ runner.os }}-nextjs-${{ hashFiles('**/yarn.lock') }}-
- run: yarn
- run: yarn build
env:
EXPORT: 1
UNOPTIMIZED: 1
BASE_PATH: ${{ steps.configurepages.outputs.base_path }}
- uses: actions/upload-pages-artifact@v3
with:
path: ./out
# Deployment job
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
needs: build
steps:
- id: deployment
uses: actions/deploy-pages@v4

161
.gitignore vendored
View File

@ -1,132 +1,47 @@
# ---> Node # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# Logs
logs # dependencies
/node_modules
/.pnp
.pnp.js
/.yarn/*
!/.yarn/releases
!/.yarn/plugins
!/.yarn/sdks
# testing
/coverage
# next.js
/.next/
/out/
public/sitemap.xml
.vercel
# production
/build
*.xml
# rss feed
/public/feed.xml
# search
/public/search.json
# misc
.DS_Store
# debug
*.log *.log
npm-debug.log* npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html) # local env files
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json .env.local
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local .env.development.local
.env.test.local .env.test.local
.env.production.local .env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
# Contentlayer
.contentlayer

1
.husky/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
_

1
.husky/pre-commit Executable file
View File

@ -0,0 +1 @@
npx --no-install lint-staged

4
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,4 @@
{
"typescript.tsdk": "node_modules/typescript/lib",
"typescript.enablePromptUseWorkspaceTsdk": true
}

874
.yarn/releases/yarn-3.6.1.cjs vendored Executable file

File diff suppressed because one or more lines are too long

3
.yarnrc.yml Normal file
View File

@ -0,0 +1,3 @@
nodeLinker: node-modules
yarnPath: .yarn/releases/yarn-3.6.1.cjs

21
LICENSE Normal file
View 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.

330
README.md
View File

@ -1,2 +1,330 @@
# jonb.io ![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. Version 2 is based on Next App directory with [React Server Component](https://nextjs.org/docs/getting-started/react-essentials#server-components) and uses [Contentlayer](https://www.contentlayer.dev/) to manage markdown content.
Probably the most feature-rich Next.js markdown blogging template out there. 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!
## Variations
**Note**: These are community contributed forks using different frameworks or with significant changes to the codebase - not officially supported.
Astro alternative - [Tailwind Astro Template](https://github.com/wanoo21/tailwind-astro-starting-blog).
Remix-run alternative - [Tailwind Remix-run Starter Blog Template](https://github.com/SangeetAgarwal/tailwind-remix-run-mdxjs-typescript-starter-blog).
Internationalization support - [Template with i18n](https://tailwind-nextjs-starter-blog-i18n.vercel.app/) and [source code](https://github.com/PxlSyl/tailwind-nextjs-starter-blog-i18n/tree/main).
## Examples V2
- [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
- [Karhdo's Blog](https://karhdo.dev) - Karhdo's Blog - Karhdo's Coding Adventure ([source code](https://github.com/Karhdo/karhdo.dev))
- [ben.codes blog](https://ben.codes) - Benoit's personal blog about software development ([source code](https://github.com/bendotcodes/bendotcodes))
- [tsix blog](https://tsix.top) - A front-end engineer is used to record some knowledge points in work and study _中文_
- [SOTO's Blog](https://www.atksoto.com/) - A more personalized personal website upgraded from V1 ([source code](https://github.com/acsoto/soto-blog-nextjs))
- [Prabhu's Blog](https://prabhukirankonda.vercel.app) - Prabhu's Personal website with blog ([source code](https://github.com/prabhukiran8790/prabhukirankonda))
- [Rabby Hasan's Blog](https://blog.rabbyhasan.com.bd/) - Rabby Hasan's personal blog about full stack development with cloud ([source code](https://github.com/rabbyalone/myblog))
- [enscribe.dev](https://enscribe.dev) - enscribe's personal blog; cybersecurity shenanigans, frontend webdev, etc. ([source code](https://github.com/jktrn/enscribe.dev))
- [dalelarroder.com](https://dalelarroder.com) - Dale Larroder's personal website upgraded from V1 ([source code](https://github.com/dlarroder/dalelarroder))
- [thetalhatahir.com](https://www.thetalhatahir.com) - Talha Tahir's personal blog. Added article thumbnails, linkedIn card, Beautiful hero content, technology emoticons.
- [homing.so](https://homing.so) - Homing's personal blog about the stuff he's learning ([source code](https://github.com/hominsu/blog))
- [zS1m's Blog](https://contrails.space) - zS1m's personal blog for recording and sharing daily learning technical content ([source code](https://github.com/zS1m/nextjs-contrails))
- [dariuszwozniak.net](https://dariuszwozniak.net/) - Software development blog
- [Terminals.run](https://terminals.run) - Blog site for some thoughts and records for life and technology.
- [francisaguilar.co blog](https://francisaguilar.co) - Francis Aguilar's personal blog that talks about tech, fitness, and personal development.
- [Min71 Dev Blog](https://min71.dev) - Personal blog about Blockchain, Development and etc. ([source code](https://github.com/mingi3442/blog))
- [Bryce Yu's Blog](https://earayu.github.io/) - Bryce Yu's personal Blog about distributed system, database, and web development. ([source code](https://github.com/earayu/earayu.github.io))
- [Remote Startup Senpai Anime Series Website](https://remote-startup-senpai.com) - Landing page for the anime series Remote Startup Senpai.
- [Secret Base](https://www.jachsu.com/) - Jac Hsu's personal Blog.talks about tech, thought, and life in general.
- [Zsebinformatikus](https://www.zsebinformatikus.hu/) - The information superhighway guide blog.
- [Anton Morgunov's Blog](https://blog.ischemist.com/) - talking about science without oversimplifications or why theoretical and computational chemistry is cool.
- [Hans Blog](https://www.hansking.cn/) - Hans' personal blog, front-end technology, gallery and travel diary 中文. ([source code](https://github.com/hansking98/hans-nextjs-blog))
- [CuB3y0nd's Portfolio](https://www.cubeyond.net/) - CuB3y0nds cyber security study notes「中文」
- [London Tech Talk](https://london-tech-talk.com/) - A podcast exploring technology trends and expatriate living experiences. - 日本語
- [CRUD Flow Blog](http://blog.ndamulelo.co.za/) - A technical blog about AI, Cloud Engineering, Data Science and Personal development
- [Trillium's Blog](https://trilliumsmith.com/) - Modified to render resume pdf on `/resume` page. ([source code](https://github.com/trillium/trilliumsmith.com))
- [Frank's Tech Blog](https://frank-tech-blog.vercel.app/) - Frank's personal blog about software development and technology. ([source code](https://github.com/frank-mendez/frank-blog))
- [Wujie's Blog: 旅行者计划](https://www.wujieli.com/) - Wujie's personal digital garden ([source code](https://github.com/wujieli0207/wujie-blog-next))
- [Xiaodong's Blog](https://blog.linxiaodong.com) - Xiaodong's personal blog about front-end technology, and life. 「中文」([source code](https://github.com/buxuku/buxuku.github.io))
- [Azurtelier.com](https://www.azurtelier.com/) - Amos's personal website for tech, music, AI illustrations, etc. [English/中文] ([Source code](https://github.com/AmosChenZixuan/Azurtelier.com))
- [JoshHaines.com](https://www.JoshHaines.com/) - Personal website for Josh Haines. ([source code](https://github.com/jdhaines/joshhaines))
- - [Jigu's Blog](https://animeirl.top) - Jigu's personal blog about tech, crypto, golang, and life. 「中文」
- [andrewsam.xyz](https://www.andrewsam.xyz/) - Andrew's Personal website using ShadCN, Prisma, MongoDB, Auth.js, Resume Page, Custom Experience timeline and technologies components. ([source code](https://github.com/andrew-sameh/andrewsam.xyz))
- [Rulli Damara Putra's Portfolio](https://www.damaraputra.my.id/) - Rully's personal blog and portfolio.
- [blog.taoluyuan.com 套路猿](https://blog.taoluyuan.com) - A personal tech blog that supports multi-theme switching. 「中英」
- [LyricsDecode.com](https://lyricsdecode.com) - A song lyrics website offering original lyrics, Romanisation, and English translations with customisable viewing options.
- [bmacharia.com](https://bmacharia.com/) - Benson Macharia's technical blog about Cybersecurity and IT Risk Management.
- [armujahid.me](https://armujahid.me/) - Abdul Rauf's personal blog about tech and random stuff.
Using the template? Feel free to create a PR and add your blog to this list.
## Examples V1
[v1-blogs-showcase.webm](https://github.com/timlrx/tailwind-nextjs-starter-blog/assets/28362229/2124c81f-b99d-4431-839c-347e01a2616c)
Thanks to the community of users and contributors to the template! We are no longer accepting new blog listings over here. If you have updated from version 1 to version 2, feel free to remove your blog from this list and add it to the one above.
- [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
- [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.
- [irvin.dev](https://www.irvin.dev/) - Irvin Lin's personal site. Added YouTube embedding.
- [KirillSo.com](https://www.kirillso.com/) - Personal blog & website.
- [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))
- [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)
- [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.
- [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)).
- [ondiek-elijah.me](https://www.ondiek-elijah.me/) - Ondiek Elijah's website and blog ([source code](https://github.com/Dev-Elie/ondiek-elijah)).
- [jmalvarez.dev](https://www.jmalvarez.dev/) - José Miguel Álvarez's personal blog ([source code](https://github.com/josemiguel-alvarez/nextjs-blog))
- [justingosses.com](https://justingosses.com/) - Justin Gosses's personal website and blog ([source code](https://github.com/JustinGOSSES/justingosses-website))
- [https://bitoflearning-9a57.fly.dev/](https://bitoflearning-9a57.fly.dev/) - Sangeet Agarwal's personal blog, replatformed to [remix](https://remix.run/remix) using the [indie stack](https://github.com/remix-run/indie-stack) ([source code](https://github.com/SangeetAgarwal/bitoflearning))
- [raphaelchelly.com](https://www.raphaelchelly.com/) - Raphaël Chelly's personal website and blog ([source code](https://github.com/raphaelchelly/raph_www))
- [kaveh.page](https://kaveh.page) - Kaveh Tehrani's personal blog. Added tags directory, profile card, time-to-read on posts directory, etc.
- [drakerossman.com](https://drakerossman.com/) - Drake Rossman's blog about NixOS, Rust, Software Architecture and Engineering Management, as well as general musings.
- [meamenu.com](https://www.meamenu.com) - Landing page and product blog starting from this template. It also uses [framer-motion](https://www.framer.com/motion) for animations, custom layout templates, [waline](https://waline.js.org/en/) for blog comments and [primereact](https://primereact.org/) forms [Ita]
- [giovanni.orciuolo.it](https://giovanni.orciuolo.it) - Giovanni Orciuolo's personal website, blog and everything nerd.
## 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
- Next.js with Typescript
- [Contentlayer](https://www.contentlayer.dev/) to manage content logic
- Easy styling customization with [Tailwind 3.0](https://tailwindcss.com/blog/tailwindcss-v3) and primary color attribute
- [MDX - write JSX in markdown documents!](https://mdxjs.com/)
- Near perfect lighthouse score - [Lighthouse report](https://www.webpagetest.org/result/230805_BiDcBQ_4H7)
- Lightweight, 85kB first load JS
- Mobile-friendly view
- Light and dark theme
- Font optimization with [next/font](https://nextjs.org/docs/app/api-reference/components/font)
- Integration with [pliny](https://github.com/timlrx/pliny) that provides:
- Multiple analytics options including [Umami](https://umami.is/), [Plausible](https://plausible.io/), [Simple Analytics](https://simpleanalytics.com/), Posthog and Google Analytics
- Comments via [Giscus](https://github.com/laymonage/giscus), [Utterances](https://github.com/utterance/utterances) or Disqus
- Newsletter API and component with support for Mailchimp, Buttondown, Convertkit, Klaviyo, Revue, Emailoctopus and Beehiiv
- Command palette search with [Kbar](https://github.com/timc1/kbar) or Algolia
- 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)
- [Github alerts](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax#alerts) via [remark-github-blockquote-alert](https://github.com/jaywcjlove/remark-github-blockquote-alert)
- Automatic image optimization via [next/image](https://nextjs.org/docs/basic-features/image-optimization)
- Support for tags - each unique tag will be its own page
- Support for multiple authors
- 3 different blog layouts
- 2 different blog listing layouts
- Support for nested routing of blog posts
- 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. Clone the repo
```bash
npx degit 'timlrx/tailwind-nextjs-starter-blog'
```
2. Personalize `siteMetadata.js` (site related information)
3. Modify the content security policy in `next.config.js` if you want to use
other analytics provider or a commenting solution other than giscus.
4. Personalize `authors/default.md` (main author)
5. Modify `projectsData.ts`
6. Modify `headerNavLinks.ts` to customize navigation links
7. Add blog posts
8. Deploy on Vercel
## Installation
```bash
yarn
```
Please note, that if you are using Windows, you may need to run:
```bash
$env:PWD = $(Get-Location).Path
```
## Development
First, run the development server:
```bash
yarn dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
Edit the layout in `app` or content in `data`. With live reloading, the pages auto-updates as you edit them.
## 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` - tailwind configuration and 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).
`contentlayer.config.ts` - configuration for Contentlayer, including definition of content sources and MDX plugins used. See [Contentlayer documentation](https://www.contentlayer.dev/docs/getting-started) for more information.
`components/MDXComponents.js` - pass your own JSX code or React component by specifying it over here. You can then use them directly in the `.mdx` or `.md` file. By default, a custom link, `next/image` component, table of contents component and Newsletter form are passed down. Note that the components should be default exported to avoid [existing issues with Next.js](https://github.com/vercel/next.js/issues/51593).
`layouts` - main templates used in pages:
- There are currently 3 post layouts available: `PostLayout`, `PostSimple` and `PostBanner`. `PostLayout` is the default 2 column layout with meta and author information. `PostSimple` is a simplified version of `PostLayout`, while `PostBanner` features a banner image.
- There are 2 blog listing layouts: `ListLayout`, the layout used in version 1 of the template with a search bar and `ListLayoutWithTags`, currently used in version 2, which omits the search bar but includes a sidebar with information on the tags.
`app` - pages to route to. Read the [Next.js documentation](https://nextjs.org/docs/app) 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
Content is modelled using [Contentlayer](https://www.contentlayer.dev/), which allows you to define your own content schema and use it to generate typed content objects. See [Contentlayer documentation](https://www.contentlayer.dev/docs/getting-started) for more information.
### Frontmatter
Frontmatter follows [Hugo's standards](https://gohugo.io/content-management/front-matter/).
Please refer to `contentlayer.config.ts` for an up to date list of supported fields. The following fields are supported:
```
title (required)
date (required)
tags (optional)
lastmod (optional)
draft (optional)
summary (optional)
images (optional)
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
---
```
## Deploy
### GitHub Pages
A [`pages.yml`](.github/workflows/pages.yml) workflow is already provided. Simply select "GitHub Actions" in: `Settings > Pages > Build and deployment > Source`.
### Vercel
The easiest way to deploy the template is to deploy on [Vercel](https://vercel.com). Check out the [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
### Netlify
[Netlify](https://www.netlify.com/)s Next.js runtime configures enables key Next.js functionality on your website without the need for additional configurations. Netlify generates serverless functions that will handle Next.js functionalities such as server-side rendered (SSR) pages, incremental static regeneration (ISR), `next/images`, etc.
See [Next.js on Netlify](https://docs.netlify.com/integrations/frameworks/next-js/overview/#next-js-runtime) for suggested configuration values and more details.
### Static hosting services (GitHub Pages / S3 / Firebase etc.)
Run:
```sh
$ EXPORT=1 UNOPTIMIZED=1 yarn build
```
Then, deploy the generated `out` folder or run `npx serve out` it locally.
> [!IMPORTANT]
> If deploying with a URL base path, like https://example.org/myblog you need an extra `BASE_PATH` shell-var to the build command:
>
> ```sh
> $ EXPORT=1 UNOPTIMIZED=1 BASE_PATH=/myblog yarn build
> ```
>
> => In your code, `${process.env.BASE_PATH || ''}/robots.txt` will print `"/myblog/robots.txt"` in the `out` build (or only `/robots.txt` if `yarn dev`, ie: on localhost:3000)
> [!TIP]
> Alternatively to `UNOPTIMIZED=1`, to continue using `next/image`, you can use an alternative image optimization provider such as Imgix, Cloudinary or Akamai. See [image optimization documentation](https://nextjs.org/docs/app/building-your-application/deploying/static-exports#image-optimization) for more details.
Consider removing the following features that cannot be used in a static build:
1. Comment out `headers()` from `next.config.js`.
2. Remove `api` folder and components which call the server-side function such as the Newsletter component. Not technically required and the site will build successfully, but the APIs cannot be used as they are server-side functions.
## Frequently Asked Questions
- [How can I add a custom MDX component?](/faq/custom-mdx-component.md)
- [How can I customize the `kbar` search?](/faq/customize-kbar-search.md)
## 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/main/LICENSE) © [Timothy Lin](https://www.timlrx.com)

91
app/Main.tsx Normal file
View File

@ -0,0 +1,91 @@
import Link from '@/components/Link'
import Tag from '@/components/Tag'
import siteMetadata from '@/data/siteMetadata'
import { formatDate } from 'pliny/utils/formatDate'
import NewsletterForm from 'pliny/ui/NewsletterForm'
const MAX_DISPLAY = 5
export default function Home({ posts }) {
return (
<>
<div className="divide-y divide-gray-200 dark:divide-gray-700">
<div className="space-y-2 pb-8 pt-6 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((post) => {
const { slug, date, title, summary, tags } = post
return (
<li key={slug} className="py-12">
<article>
<div className="space-y-2 xl:grid xl:grid-cols-4 xl: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, siteMetadata.locale)}</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 more: "${title}"`}
>
Read more &rarr;
</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 &rarr;
</Link>
</div>
)}
{siteMetadata.newsletter?.provider && (
<div className="flex items-center justify-center pt-4">
<NewsletterForm />
</div>
)}
</>
)
}

20
app/about/page.tsx Normal file
View File

@ -0,0 +1,20 @@
import { Authors, allAuthors } from 'contentlayer/generated'
import { MDXLayoutRenderer } from 'pliny/mdx-components'
import AuthorLayout from '@/layouts/AuthorLayout'
import { coreContent } from 'pliny/utils/contentlayer'
import { genPageMetadata } from 'app/seo'
export const metadata = genPageMetadata({ title: 'About' })
export default function Page() {
const author = allAuthors.find((p) => p.slug === 'default') as Authors
const mainContent = coreContent(author)
return (
<>
<AuthorLayout content={mainContent}>
<MDXLayoutRenderer code={author.body.code} />
</AuthorLayout>
</>
)
}

View File

@ -0,0 +1,9 @@
import { NewsletterAPI } from 'pliny/newsletter'
import siteMetadata from '@/data/siteMetadata'
const handler = NewsletterAPI({
// @ts-ignore
provider: siteMetadata.newsletter.provider,
})
export { handler as GET, handler as POST }

120
app/blog/[...slug]/page.tsx Normal file
View File

@ -0,0 +1,120 @@
import 'css/prism.css'
import 'katex/dist/katex.css'
import PageTitle from '@/components/PageTitle'
import { components } from '@/components/MDXComponents'
import { MDXLayoutRenderer } from 'pliny/mdx-components'
import { sortPosts, coreContent, allCoreContent } from 'pliny/utils/contentlayer'
import { allBlogs, allAuthors } from 'contentlayer/generated'
import type { Authors, Blog } from 'contentlayer/generated'
import PostSimple from '@/layouts/PostSimple'
import PostLayout from '@/layouts/PostLayout'
import PostBanner from '@/layouts/PostBanner'
import { Metadata } from 'next'
import siteMetadata from '@/data/siteMetadata'
import { notFound } from 'next/navigation'
const defaultLayout = 'PostLayout'
const layouts = {
PostSimple,
PostLayout,
PostBanner,
}
export async function generateMetadata({
params,
}: {
params: { slug: string[] }
}): Promise<Metadata | undefined> {
const slug = decodeURI(params.slug.join('/'))
const post = allBlogs.find((p) => p.slug === slug)
const authorList = post?.authors || ['default']
const authorDetails = authorList.map((author) => {
const authorResults = allAuthors.find((p) => p.slug === author)
return coreContent(authorResults as Authors)
})
if (!post) {
return
}
const publishedAt = new Date(post.date).toISOString()
const modifiedAt = new Date(post.lastmod || post.date).toISOString()
const authors = authorDetails.map((author) => author.name)
let imageList = [siteMetadata.socialBanner]
if (post.images) {
imageList = typeof post.images === 'string' ? [post.images] : post.images
}
const ogImages = imageList.map((img) => {
return {
url: img.includes('http') ? img : siteMetadata.siteUrl + img,
}
})
return {
title: post.title,
description: post.summary,
openGraph: {
title: post.title,
description: post.summary,
siteName: siteMetadata.title,
locale: 'en_US',
type: 'article',
publishedTime: publishedAt,
modifiedTime: modifiedAt,
url: './',
images: ogImages,
authors: authors.length > 0 ? authors : [siteMetadata.author],
},
twitter: {
card: 'summary_large_image',
title: post.title,
description: post.summary,
images: imageList,
},
}
}
export const generateStaticParams = async () => {
return allBlogs.map((p) => ({ slug: p.slug.split('/').map((name) => decodeURI(name)) }))
}
export default async function Page({ params }: { params: { slug: string[] } }) {
const slug = decodeURI(params.slug.join('/'))
// Filter out drafts in production
const sortedCoreContents = allCoreContent(sortPosts(allBlogs))
const postIndex = sortedCoreContents.findIndex((p) => p.slug === slug)
if (postIndex === -1) {
return notFound()
}
const prev = sortedCoreContents[postIndex + 1]
const next = sortedCoreContents[postIndex - 1]
const post = allBlogs.find((p) => p.slug === slug) as Blog
const authorList = post?.authors || ['default']
const authorDetails = authorList.map((author) => {
const authorResults = allAuthors.find((p) => p.slug === author)
return coreContent(authorResults as Authors)
})
const mainContent = coreContent(post)
const jsonLd = post.structuredData
jsonLd['author'] = authorDetails.map((author) => {
return {
'@type': 'Person',
name: author.name,
}
})
const Layout = layouts[post.layout || defaultLayout]
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<Layout content={mainContent} authorDetails={authorDetails} next={next} prev={prev}>
<MDXLayoutRenderer code={post.body.code} components={components} toc={post.toc} />
</Layout>
</>
)
}

30
app/blog/page.tsx Normal file
View File

@ -0,0 +1,30 @@
import ListLayout from '@/layouts/ListLayoutWithTags'
import { allCoreContent, sortPosts } from 'pliny/utils/contentlayer'
import { allBlogs } from 'contentlayer/generated'
import { genPageMetadata } from 'app/seo'
const POSTS_PER_PAGE = 5
export const metadata = genPageMetadata({ title: 'Blog' })
export default function BlogPage() {
const posts = allCoreContent(sortPosts(allBlogs))
const pageNumber = 1
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 (
<ListLayout
posts={posts}
initialDisplayPosts={initialDisplayPosts}
pagination={pagination}
title="All Posts"
/>
)
}

View File

@ -0,0 +1,34 @@
import ListLayout from '@/layouts/ListLayoutWithTags'
import { allCoreContent, sortPosts } from 'pliny/utils/contentlayer'
import { allBlogs } from 'contentlayer/generated'
const POSTS_PER_PAGE = 5
export const generateStaticParams = async () => {
const totalPages = Math.ceil(allBlogs.length / POSTS_PER_PAGE)
const paths = Array.from({ length: totalPages }, (_, i) => ({ page: (i + 1).toString() }))
return paths
}
export default function Page({ params }: { params: { page: string } }) {
const posts = allCoreContent(sortPosts(allBlogs))
const pageNumber = parseInt(params.page as string)
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 (
<ListLayout
posts={posts}
initialDisplayPosts={initialDisplayPosts}
pagination={pagination}
title="All Posts"
/>
)
}

111
app/layout.tsx Normal file
View File

@ -0,0 +1,111 @@
import 'css/tailwind.css'
import 'pliny/search/algolia.css'
import 'remark-github-blockquote-alert/alert.css'
import { Space_Grotesk } from 'next/font/google'
import { Analytics, AnalyticsConfig } from 'pliny/analytics'
import { SearchProvider, SearchConfig } from 'pliny/search'
import Header from '@/components/Header'
import SectionContainer from '@/components/SectionContainer'
import Footer from '@/components/Footer'
import siteMetadata from '@/data/siteMetadata'
import { ThemeProviders } from './theme-providers'
import { Metadata } from 'next'
const space_grotesk = Space_Grotesk({
subsets: ['latin'],
display: 'swap',
variable: '--font-space-grotesk',
})
export const metadata: Metadata = {
metadataBase: new URL(siteMetadata.siteUrl),
title: {
default: siteMetadata.title,
template: `%s | ${siteMetadata.title}`,
},
description: siteMetadata.description,
openGraph: {
title: siteMetadata.title,
description: siteMetadata.description,
url: './',
siteName: siteMetadata.title,
images: [siteMetadata.socialBanner],
locale: 'en_US',
type: 'website',
},
alternates: {
canonical: './',
types: {
'application/rss+xml': `${siteMetadata.siteUrl}/feed.xml`,
},
},
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
'max-video-preview': -1,
'max-image-preview': 'large',
'max-snippet': -1,
},
},
twitter: {
title: siteMetadata.title,
card: 'summary_large_image',
images: [siteMetadata.socialBanner],
},
}
export default function RootLayout({ children }: { children: React.ReactNode }) {
const basePath = process.env.BASE_PATH || ''
return (
<html
lang={siteMetadata.language}
className={`${space_grotesk.variable} scroll-smooth`}
suppressHydrationWarning
>
<link
rel="apple-touch-icon"
sizes="76x76"
href={`${basePath}/static/favicons/apple-touch-icon.png`}
/>
<link
rel="icon"
type="image/png"
sizes="32x32"
href={`${basePath}/static/favicons/favicon-32x32.png`}
/>
<link
rel="icon"
type="image/png"
sizes="16x16"
href={`${basePath}/static/favicons/favicon-16x16.png`}
/>
<link rel="manifest" href={`${basePath}/static/favicons/site.webmanifest`} />
<link
rel="mask-icon"
href={`${basePath}/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={`${basePath}/feed.xml`} />
<body className="bg-white pl-[calc(100vw-100%)] text-black antialiased dark:bg-gray-950 dark:text-white">
<ThemeProviders>
<Analytics analyticsConfig={siteMetadata.analytics as AnalyticsConfig} />
<SectionContainer>
<SearchProvider searchConfig={siteMetadata.search as SearchConfig}>
<Header />
<main className="mb-auto">{children}</main>
</SearchProvider>
<Footer />
</SectionContainer>
</ThemeProviders>
</body>
</html>
)
}

25
app/not-found.tsx Normal file
View File

@ -0,0 +1,25 @@
import Link from '@/components/Link'
export default function NotFound() {
return (
<div className="flex flex-col items-start justify-start md:mt-24 md:flex-row md:items-center md:justify-center md:space-x-6">
<div className="space-x-2 pb-8 pt-6 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="/"
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
</Link>
</div>
</div>
)
}

9
app/page.tsx Normal file
View File

@ -0,0 +1,9 @@
import { sortPosts, allCoreContent } from 'pliny/utils/contentlayer'
import { allBlogs } from 'contentlayer/generated'
import Main from './Main'
export default async function Page() {
const sortedPosts = sortPosts(allBlogs)
const posts = allCoreContent(sortedPosts)
return <Main posts={posts} />
}

35
app/projects/page.tsx Normal file
View File

@ -0,0 +1,35 @@
import projectsData from '@/data/projectsData'
import Card from '@/components/Card'
import { genPageMetadata } from 'app/seo'
export const metadata = genPageMetadata({ title: 'Projects' })
export default function Projects() {
return (
<>
<div className="divide-y divide-gray-200 dark:divide-gray-700">
<div className="space-y-2 pb-8 pt-6 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>
</>
)
}

13
app/robots.ts Normal file
View File

@ -0,0 +1,13 @@
import { MetadataRoute } from 'next'
import siteMetadata from '@/data/siteMetadata'
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: '*',
allow: '/',
},
sitemap: `${siteMetadata.siteUrl}/sitemap.xml`,
host: siteMetadata.siteUrl,
}
}

32
app/seo.tsx Normal file
View File

@ -0,0 +1,32 @@
import { Metadata } from 'next'
import siteMetadata from '@/data/siteMetadata'
interface PageSEOProps {
title: string
description?: string
image?: string
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[key: string]: any
}
export function genPageMetadata({ title, description, image, ...rest }: PageSEOProps): Metadata {
return {
title,
description: description || siteMetadata.description,
openGraph: {
title: `${title} | ${siteMetadata.title}`,
description: description || siteMetadata.description,
url: './',
siteName: siteMetadata.title,
images: image ? [image] : [siteMetadata.socialBanner],
locale: 'en_US',
type: 'website',
},
twitter: {
title: `${title} | ${siteMetadata.title}`,
card: 'summary_large_image',
images: image ? [image] : [siteMetadata.socialBanner],
},
...rest,
}
}

21
app/sitemap.ts Normal file
View File

@ -0,0 +1,21 @@
import { MetadataRoute } from 'next'
import { allBlogs } from 'contentlayer/generated'
import siteMetadata from '@/data/siteMetadata'
export default function sitemap(): MetadataRoute.Sitemap {
const siteUrl = siteMetadata.siteUrl
const blogRoutes = allBlogs
.filter((post) => !post.draft)
.map((post) => ({
url: `${siteUrl}/${post.path}`,
lastModified: post.lastmod || post.date,
}))
const routes = ['', 'blog', 'projects', 'tags'].map((route) => ({
url: `${siteUrl}/${route}`,
lastModified: new Date().toISOString().split('T')[0],
}))
return [...routes, ...blogRoutes]
}

1
app/tag-data.json Normal file
View File

@ -0,0 +1 @@
{"next-js":6,"tailwind":3,"guide":5,"feature":2,"multi-author":1,"hello":1,"math":1,"ols":1,"github":1,"writings":1,"book":1,"reflection":1,"holiday":1,"canada":1,"images":1,"markdown":1,"code":1,"features":1}

45
app/tags/[tag]/page.tsx Normal file
View File

@ -0,0 +1,45 @@
import { slug } from 'github-slugger'
import { allCoreContent, sortPosts } from 'pliny/utils/contentlayer'
import siteMetadata from '@/data/siteMetadata'
import ListLayout from '@/layouts/ListLayoutWithTags'
import { allBlogs } from 'contentlayer/generated'
import tagData from 'app/tag-data.json'
import { genPageMetadata } from 'app/seo'
import { Metadata } from 'next'
import { notFound } from 'next/navigation'
export async function generateMetadata({ params }: { params: { tag: string } }): Promise<Metadata> {
const tag = decodeURI(params.tag)
return genPageMetadata({
title: tag,
description: `${siteMetadata.title} ${tag} tagged content`,
alternates: {
canonical: './',
types: {
'application/rss+xml': `${siteMetadata.siteUrl}/tags/${tag}/feed.xml`,
},
},
})
}
export const generateStaticParams = async () => {
const tagCounts = tagData as Record<string, number>
const tagKeys = Object.keys(tagCounts)
const paths = tagKeys.map((tag) => ({
tag: encodeURI(tag),
}))
return paths
}
export default function TagPage({ params }: { params: { tag: string } }) {
const tag = decodeURI(params.tag)
// Capitalize first letter and convert space to dash
const title = tag[0].toUpperCase() + tag.split(' ').join('-').slice(1)
const filteredPosts = allCoreContent(
sortPosts(allBlogs.filter((post) => post.tags && post.tags.map((t) => slug(t)).includes(tag)))
)
if (filteredPosts.length === 0) {
return notFound()
}
return <ListLayout posts={filteredPosts} title={title} />
}

41
app/tags/page.tsx Normal file
View File

@ -0,0 +1,41 @@
import Link from '@/components/Link'
import Tag from '@/components/Tag'
import { slug } from 'github-slugger'
import tagData from 'app/tag-data.json'
import { genPageMetadata } from 'app/seo'
export const metadata = genPageMetadata({ title: 'Tags', description: 'Things I blog about' })
export default async function Page() {
const tagCounts = tagData as Record<string, number>
const tagKeys = Object.keys(tagCounts)
const sortedTags = tagKeys.sort((a, b) => tagCounts[b] - tagCounts[a])
return (
<>
<div className="flex flex-col items-start justify-start divide-y divide-gray-200 dark:divide-gray-700 md:mt-24 md:flex-row md:items-center md:justify-center md:space-x-6 md:divide-y-0">
<div className="space-x-2 pb-8 pt-6 md:space-y-5">
<h1 className="text-3xl font-extrabold leading-9 tracking-tight text-gray-900 dark:text-gray-100 sm:text-4xl sm:leading-10 md:border-r-2 md:px-6 md:text-6xl md:leading-14">
Tags
</h1>
</div>
<div className="flex max-w-lg flex-wrap">
{tagKeys.length === 0 && 'No tags found.'}
{sortedTags.map((t) => {
return (
<div key={t} className="mb-2 mr-5 mt-2">
<Tag text={t} />
<Link
href={`/tags/${slug(t)}`}
className="-ml-2 text-sm font-semibold uppercase text-gray-600 dark:text-gray-300"
aria-label={`View posts tagged ${t}`}
>
{` (${tagCounts[t]})`}
</Link>
</div>
)
})}
</div>
</div>
</>
)
}

12
app/theme-providers.tsx Normal file
View File

@ -0,0 +1,12 @@
'use client'
import { ThemeProvider } from 'next-themes'
import siteMetadata from '@/data/siteMetadata'
export function ThemeProviders({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider attribute="class" defaultTheme={siteMetadata.theme} enableSystem>
{children}
</ThemeProvider>
)
}

56
components/Card.tsx Normal file
View File

@ -0,0 +1,56 @@
import Image from './Image'
import Link from './Link'
const Card = ({ title, description, imgSrc, href }) => (
<div className="md max-w-[544px] p-4 md:w-1/2">
<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 &rarr;
</Link>
)}
</div>
</div>
</div>
)
export default Card

22
components/Comments.tsx Normal file
View File

@ -0,0 +1,22 @@
'use client'
import { Comments as CommentsComponent } from 'pliny/comments'
import { useState } from 'react'
import siteMetadata from '@/data/siteMetadata'
export default function Comments({ slug }: { slug: string }) {
const [loadComments, setLoadComments] = useState(false)
if (!siteMetadata.comments?.provider) {
return null
}
return (
<>
{loadComments ? (
<CommentsComponent commentsConfig={siteMetadata.comments} slug={slug} />
) : (
<button onClick={() => setLoadComments(true)}>Load Comments</button>
)}
</>
)
}

35
components/Footer.tsx Normal file
View File

@ -0,0 +1,35 @@
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} />
<SocialIcon kind="x" href={siteMetadata.x} size={6} />
<SocialIcon kind="instagram" href={siteMetadata.instagram} size={6} />
<SocialIcon kind="threads" href={siteMetadata.threads} 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>
)
}

53
components/Header.tsx Normal file
View File

@ -0,0 +1,53 @@
import siteMetadata from '@/data/siteMetadata'
import headerNavLinks from '@/data/headerNavLinks'
import Logo from '@/data/logo.svg'
import Link from './Link'
import MobileNav from './MobileNav'
import ThemeSwitch from './ThemeSwitch'
import SearchButton from './SearchButton'
const Header = () => {
let headerClass = 'flex items-center w-full bg-white dark:bg-gray-950 justify-between py-10'
if (siteMetadata.stickyNav) {
headerClass += ' sticky top-0 z-50'
}
return (
<header className={headerClass}>
<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 className="flex items-center space-x-4 leading-5 sm:space-x-6">
<div className="no-scrollbar hidden max-w-40 items-center space-x-4 overflow-x-auto sm:flex sm:space-x-6 md:max-w-72 lg:max-w-96">
{headerNavLinks
.filter((link) => link.href !== '/')
.map((link) => (
<Link
key={link.title}
href={link.href}
className="block font-medium text-gray-900 hover:text-primary-500 dark:text-gray-100 dark:hover:text-primary-400"
>
{link.title}
</Link>
))}
</div>
<SearchButton />
<ThemeSwitch />
<MobileNav />
</div>
</header>
)
}
export default Header

9
components/Image.tsx Normal file
View File

@ -0,0 +1,9 @@
import NextImage, { ImageProps } from 'next/image'
const basePath = process.env.BASE_PATH
const Image = ({ src, ...rest }: ImageProps) => (
<NextImage src={`${basePath || ''}${src}`} {...rest} />
)
export default Image

View File

@ -0,0 +1,27 @@
import { Inter } from 'next/font/google'
import SectionContainer from './SectionContainer'
import Footer from './Footer'
import { ReactNode } from 'react'
import Header from './Header'
interface Props {
children: ReactNode
}
const inter = Inter({
subsets: ['latin'],
})
const LayoutWrapper = ({ children }: Props) => {
return (
<SectionContainer>
<div className={`${inter.className} flex h-screen flex-col justify-between font-sans`}>
<Header />
<main className="mb-auto">{children}</main>
<Footer />
</div>
</SectionContainer>
)
}
export default LayoutWrapper

23
components/Link.tsx Normal file
View File

@ -0,0 +1,23 @@
/* eslint-disable jsx-a11y/anchor-has-content */
import Link from 'next/link'
import type { LinkProps } from 'next/link'
import { AnchorHTMLAttributes } from 'react'
const CustomLink = ({ href, ...rest }: LinkProps & AnchorHTMLAttributes<HTMLAnchorElement>) => {
const isInternalLink = href && href.startsWith('/')
const isAnchorLink = href && href.startsWith('#')
if (isInternalLink) {
return <Link className="break-words" href={href} {...rest} />
}
if (isAnchorLink) {
return <a className="break-words" href={href} {...rest} />
}
return (
<a className="break-words" target="_blank" rel="noopener noreferrer" href={href} {...rest} />
)
}
export default CustomLink

View File

@ -0,0 +1,16 @@
import TOCInline from 'pliny/ui/TOCInline'
import Pre from 'pliny/ui/Pre'
import BlogNewsletterForm from 'pliny/ui/BlogNewsletterForm'
import type { MDXComponents } from 'mdx/types'
import Image from './Image'
import CustomLink from './Link'
import TableWrapper from './TableWrapper'
export const components: MDXComponents = {
Image,
TOCInline,
a: CustomLink,
pre: Pre,
table: TableWrapper,
BlogNewsletterForm,
}

108
components/MobileNav.tsx Normal file
View File

@ -0,0 +1,108 @@
'use client'
import { Dialog, Transition } from '@headlessui/react'
import { disableBodyScroll, enableBodyScroll, clearAllBodyScrollLocks } from 'body-scroll-lock'
import { Fragment, useState, useEffect, useRef } from 'react'
import Link from './Link'
import headerNavLinks from '@/data/headerNavLinks'
const MobileNav = () => {
const [navShow, setNavShow] = useState(false)
const navRef = useRef(null)
const onToggleNav = () => {
setNavShow((status) => {
if (status) {
enableBodyScroll(navRef.current)
} else {
// Prevent scrolling
disableBodyScroll(navRef.current)
}
return !status
})
}
useEffect(() => {
return clearAllBodyScrollLocks
})
return (
<>
<button aria-label="Toggle Menu" onClick={onToggleNav} className="sm:hidden">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
className="h-8 w-8 text-gray-900 hover:text-primary-500 dark:text-gray-100 dark:hover:text-primary-400"
>
<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>
<Transition appear show={navShow} as={Fragment} unmount={false}>
<Dialog as="div" onClose={onToggleNav} unmount={false}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
unmount={false}
>
<div className="fixed inset-0 z-60 bg-black/25" />
</Transition.Child>
<Transition.Child
as={Fragment}
enter="transition ease-in-out duration-300 transform"
enterFrom="translate-x-full opacity-0"
enterTo="translate-x-0 opacity-95"
leave="transition ease-in duration-200 transform"
leaveFrom="translate-x-0 opacity-95"
leaveTo="translate-x-full opacity-0"
unmount={false}
>
<Dialog.Panel className="fixed left-0 top-0 z-70 h-full w-full bg-white opacity-95 duration-300 dark:bg-gray-950 dark:opacity-[0.98]">
<nav
ref={navRef}
className="mt-8 flex h-full basis-0 flex-col items-start overflow-y-auto pl-12 pt-2 text-left"
>
{headerNavLinks.map((link) => (
<Link
key={link.title}
href={link.href}
className="mb-4 py-2 pr-4 text-2xl font-bold tracking-widest text-gray-900 outline outline-0 hover:text-primary-500 dark:text-gray-100 dark:hover:text-primary-400"
onClick={onToggleNav}
>
{link.title}
</Link>
))}
</nav>
<button
className="fixed right-4 top-7 z-80 h-16 w-16 p-4 text-gray-900 hover:text-primary-500 dark:text-gray-100 dark:hover:text-primary-400"
aria-label="Toggle Menu"
onClick={onToggleNav}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<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>
</Dialog.Panel>
</Transition.Child>
</Dialog>
</Transition>
</>
)
}
export default MobileNav

13
components/PageTitle.tsx Normal file
View File

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

View File

@ -0,0 +1,61 @@
'use client'
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 bottom-8 right-8 hidden flex-col gap-3 ${show ? 'md:flex' : 'md:hidden'}`}
>
{siteMetadata.comments?.provider && (
<button
aria-label="Scroll To Comment"
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"
onClick={handleScrollTop}
className="rounded-full bg-gray-200 p-2 text-gray-500 transition-all hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-400 dark:hover:bg-gray-600"
>
<svg className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path
fillRule="evenodd"
d="M3.293 9.707a1 1 0 010-1.414l6-6a1 1 0 011.414 0l6 6a1 1 0 01-1.414 1.414L11 5.414V17a1 1 0 11-2 0V5.414L4.707 9.707a1 1 0 01-1.414 0z"
clipRule="evenodd"
/>
</svg>
</button>
</div>
)
}
export default ScrollTopAndComment

View File

@ -0,0 +1,35 @@
import { AlgoliaButton } from 'pliny/search/AlgoliaButton'
import { KBarButton } from 'pliny/search/KBarButton'
import siteMetadata from '@/data/siteMetadata'
const SearchButton = () => {
if (
siteMetadata.search &&
(siteMetadata.search.provider === 'algolia' || siteMetadata.search.provider === 'kbar')
) {
const SearchButtonWrapper =
siteMetadata.search.provider === 'algolia' ? AlgoliaButton : KBarButton
return (
<SearchButtonWrapper aria-label="Search">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="h-6 w-6 text-gray-900 hover:text-primary-500 dark:text-gray-100
dark:hover:text-primary-400"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
/>
</svg>
</SearchButtonWrapper>
)
}
}
export default SearchButton

View File

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

View File

@ -0,0 +1,9 @@
const TableWrapper = ({ children }) => {
return (
<div className="w-full overflow-x-auto">
<table>{children}</table>
</div>
)
}
export default TableWrapper

18
components/Tag.tsx Normal file
View File

@ -0,0 +1,18 @@
import Link from 'next/link'
import { slug } from 'github-slugger'
interface Props {
text: string
}
const Tag = ({ text }: Props) => {
return (
<Link
href={`/tags/${slug(text)}`}
className="mr-3 text-sm font-medium uppercase text-primary-500 hover:text-primary-600 dark:hover:text-primary-400"
>
{text.split(' ').join('-')}
</Link>
)
}
export default Tag

133
components/ThemeSwitch.tsx Normal file
View File

@ -0,0 +1,133 @@
'use client'
import { Fragment, useEffect, useState } from 'react'
import { useTheme } from 'next-themes'
import { Menu, RadioGroup, Transition } from '@headlessui/react'
const Sun = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
className="group:hover:text-gray-100 h-6 w-6"
>
<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"
/>
</svg>
)
const Moon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
className="group:hover:text-gray-100 h-6 w-6"
>
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z" />
</svg>
)
const Monitor = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="group:hover:text-gray-100 h-6 w-6"
>
<rect x="3" y="3" width="14" height="10" rx="2" ry="2"></rect>
<line x1="7" y1="17" x2="13" y2="17"></line>
<line x1="10" y1="13" x2="10" y2="17"></line>
</svg>
)
const Blank = () => <svg className="h-6 w-6" />
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 (
<div className="mr-5 flex items-center">
<Menu as="div" className="relative inline-block text-left">
<div className="flex items-center justify-center hover:text-primary-500 dark:hover:text-primary-400">
<Menu.Button aria-label="Theme switcher">
{mounted ? resolvedTheme === 'dark' ? <Moon /> : <Sun /> : <Blank />}
</Menu.Button>
</div>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items className="absolute right-0 z-50 mt-2 w-32 origin-top-right divide-y divide-gray-100 rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:bg-gray-800">
<RadioGroup value={theme} onChange={setTheme}>
<div className="p-1">
<RadioGroup.Option value="light">
<Menu.Item>
{({ active }) => (
<button
className={`${
active ? 'bg-primary-600 text-white' : ''
} group flex w-full items-center rounded-md px-2 py-2 text-sm`}
>
<div className="mr-2">
<Sun />
</div>
Light
</button>
)}
</Menu.Item>
</RadioGroup.Option>
<RadioGroup.Option value="dark">
<Menu.Item>
{({ active }) => (
<button
className={`${
active ? 'bg-primary-600 text-white' : ''
} group flex w-full items-center rounded-md px-2 py-2 text-sm`}
>
<div className="mr-2">
<Moon />
</div>
Dark
</button>
)}
</Menu.Item>
</RadioGroup.Option>
<RadioGroup.Option value="system">
<Menu.Item>
{({ active }) => (
<button
className={`${
active ? 'bg-primary-600 text-white' : ''
} group flex w-full items-center rounded-md px-2 py-2 text-sm`}
>
<div className="mr-2">
<Monitor />
</div>
System
</button>
)}
</Menu.Item>
</RadioGroup.Option>
</div>
</RadioGroup>
</Menu.Items>
</Transition>
</Menu>
</div>
)
}
export default ThemeSwitch

View File

@ -0,0 +1,95 @@
import { SVGProps } from 'react'
// Icons taken from: https://simpleicons.org/
// To add a new icon, add a new function here and add it to components in social-icons/index.tsx
export function Facebook(svgProps: SVGProps<SVGSVGElement>) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" {...svgProps}>
<title>Facebook</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"></path>
</svg>
)
}
export function Github(svgProps: SVGProps<SVGSVGElement>) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" {...svgProps}>
<title>GitHub</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"></path>
</svg>
)
}
export function Linkedin(svgProps: SVGProps<SVGSVGElement>) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" {...svgProps}>
<title>Linkedin</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.433a2.062 2.062 0 01-2.063-2.065 2.064 2.064 0 112.063 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"></path>
</svg>
)
}
export function Mail(svgProps: SVGProps<SVGSVGElement>) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" {...svgProps}>
<title>Mail</title>
<path d="M2.003 5.884L10 9.882l7.997-3.998A2 2 0 0016 4H4a2 2 0 00-1.997 1.884z"></path>
<path d="M18 8.118l-8 4-8-4V14a2 2 0 002 2h12a2 2 0 002-2V8.118z"></path>
</svg>
)
}
export function Twitter(svgProps: SVGProps<SVGSVGElement>) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" {...svgProps}>
<title>Twitter</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"></path>
</svg>
)
}
export function X(svgProps: SVGProps<SVGSVGElement>) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" {...svgProps}>
<title>X</title>
<path d="M18.901 1.153h3.68l-8.04 9.19L24 22.846h-7.406l-5.8-7.584-6.638 7.584H.474l8.6-9.83L0 1.154h7.594l5.243 6.932ZM17.61 20.644h2.039L6.486 3.24H4.298Z" />
</svg>
)
}
export function Youtube(svgProps: SVGProps<SVGSVGElement>) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" {...svgProps}>
<title>Youtube</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"></path>
</svg>
)
}
export function Mastodon(svgProps: SVGProps<SVGSVGElement>) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" {...svgProps}>
<title>Mastodon</title>
<path d="M23.268 5.313c-.35-2.578-2.617-4.61-5.304-5.004C17.51.242 15.792 0 11.813 0h-.03c-3.98 0-4.835.242-5.288.309C3.882.692 1.496 2.518.917 5.127.64 6.412.61 7.837.661 9.143c.074 1.874.088 3.745.26 5.611.118 1.24.325 2.47.62 3.68.55 2.237 2.777 4.098 4.96 4.857 2.336.792 4.849.923 7.256.38.265-.061.527-.132.786-.213.585-.184 1.27-.39 1.774-.753a.057.057 0 0 0 .023-.043v-1.809a.052.052 0 0 0-.02-.041.053.053 0 0 0-.046-.01 20.282 20.282 0 0 1-4.709.545c-2.73 0-3.463-1.284-3.674-1.818a5.593 5.593 0 0 1-.319-1.433.053.053 0 0 1 .066-.054c1.517.363 3.072.546 4.632.546.376 0 .75 0 1.125-.01 1.57-.044 3.224-.124 4.768-.422.038-.008.077-.015.11-.024 2.435-.464 4.753-1.92 4.989-5.604.008-.145.03-1.52.03-1.67.002-.512.167-3.63-.024-5.545zm-3.748 9.195h-2.561V8.29c0-1.309-.55-1.976-1.67-1.976-1.23 0-1.846.79-1.846 2.35v3.403h-2.546V8.663c0-1.56-.617-2.35-1.848-2.35-1.112 0-1.668.668-1.67 1.977v6.218H4.822V8.102c0-1.31.337-2.35 1.011-3.12.696-.77 1.608-1.164 2.74-1.164 1.311 0 2.302.5 2.962 1.498l.638 1.06.638-1.06c.66-.999 1.65-1.498 2.96-1.498 1.13 0 2.043.395 2.74 1.164.675.77 1.012 1.81 1.012 3.12z" />
</svg>
)
}
export function Threads(svgProps: SVGProps<SVGSVGElement>) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" {...svgProps}>
<title>Threads</title>
<path d="M12.186 24h-.007c-3.581-.024-6.334-1.205-8.184-3.509C2.35 18.44 1.5 15.586 1.472 12.01v-.017c.03-3.579.879-6.43 2.525-8.482C5.845 1.205 8.6.024 12.18 0h.014c2.746.02 5.043.725 6.826 2.098 1.677 1.29 2.858 3.13 3.509 5.467l-2.04.569c-1.104-3.96-3.898-5.984-8.304-6.015-2.91.022-5.11.936-6.54 2.717C4.307 6.504 3.616 8.914 3.589 12c.027 3.086.718 5.496 2.057 7.164 1.43 1.783 3.631 2.698 6.54 2.717 2.623-.02 4.358-.631 5.8-2.045 1.647-1.613 1.618-3.593 1.09-4.798-.31-.71-.873-1.3-1.634-1.75-.192 1.352-.622 2.446-1.284 3.272-.886 1.102-2.14 1.704-3.73 1.79-1.202.065-2.361-.218-3.259-.801-1.063-.689-1.685-1.74-1.752-2.964-.065-1.19.408-2.285 1.33-3.082.88-.76 2.119-1.207 3.583-1.291a13.853 13.853 0 0 1 3.02.142c-.126-.742-.375-1.332-.75-1.757-.513-.586-1.308-.883-2.359-.89h-.029c-.844 0-1.992.232-2.721 1.32L7.734 7.847c.98-1.454 2.568-2.256 4.478-2.256h.044c3.194.02 5.097 1.975 5.287 5.388.108.046.216.094.321.142 1.49.7 2.58 1.761 3.154 3.07.797 1.82.871 4.79-1.548 7.158-1.85 1.81-4.094 2.628-7.277 2.65Zm1.003-11.69c-.242 0-.487.007-.739.021-1.836.103-2.98.946-2.916 2.143.067 1.256 1.452 1.839 2.784 1.767 1.224-.065 2.818-.543 3.086-3.71a10.5 10.5 0 0 0-2.215-.221z" />
</svg>
)
}
export function Instagram(svgProps: SVGProps<SVGSVGElement>) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" {...svgProps}>
<title>Instagram</title>
<path d="M12 0C8.74 0 8.333.015 7.053.072 5.775.132 4.905.333 4.14.63c-.789.306-1.459.717-2.126 1.384S.935 3.35.63 4.14C.333 4.905.131 5.775.072 7.053.012 8.333 0 8.74 0 12s.015 3.667.072 4.947c.06 1.277.261 2.148.558 2.913.306.788.717 1.459 1.384 2.126.667.666 1.336 1.079 2.126 1.384.766.296 1.636.499 2.913.558C8.333 23.988 8.74 24 12 24s3.667-.015 4.947-.072c1.277-.06 2.148-.262 2.913-.558.788-.306 1.459-.718 2.126-1.384.666-.667 1.079-1.335 1.384-2.126.296-.765.499-1.636.558-2.913.06-1.28.072-1.687.072-4.947s-.015-3.667-.072-4.947c-.06-1.277-.262-2.149-.558-2.913-.306-.789-.718-1.459-1.384-2.126C21.319 1.347 20.651.935 19.86.63c-.765-.297-1.636-.499-2.913-.558C15.667.012 15.26 0 12 0zm0 2.16c3.203 0 3.585.016 4.85.071 1.17.055 1.805.249 2.227.415.562.217.96.477 1.382.896.419.42.679.819.896 1.381.164.422.36 1.057.413 2.227.057 1.266.07 1.646.07 4.85s-.015 3.585-.074 4.85c-.061 1.17-.256 1.805-.421 2.227-.224.562-.479.96-.899 1.382-.419.419-.824.679-1.38.896-.42.164-1.065.36-2.235.413-1.274.057-1.649.07-4.859.07-3.211 0-3.586-.015-4.859-.074-1.171-.061-1.816-.256-2.236-.421-.569-.224-.96-.479-1.379-.899-.421-.419-.69-.824-.9-1.38-.165-.42-.359-1.065-.42-2.235-.045-1.26-.061-1.649-.061-4.844 0-3.196.016-3.586.061-4.861.061-1.17.255-1.814.42-2.234.21-.57.479-.96.9-1.381.419-.419.81-.689 1.379-.898.42-.166 1.051-.361 2.221-.421 1.275-.045 1.65-.06 4.859-.06l.045.03zm0 3.678c-3.405 0-6.162 2.76-6.162 6.162 0 3.405 2.76 6.162 6.162 6.162 3.405 0 6.162-2.76 6.162-6.162 0-3.405-2.76-6.162-6.162-6.162zM12 16c-2.21 0-4-1.79-4-4s1.79-4 4-4 4 1.79 4 4-1.79 4-4 4zm7.846-10.405c0 .795-.646 1.44-1.44 1.44-.795 0-1.44-.646-1.44-1.44 0-.794.646-1.439 1.44-1.439.793-.001 1.44.645 1.44 1.439z" />
</svg>
)
}

View File

@ -0,0 +1,57 @@
import {
Mail,
Github,
Facebook,
Youtube,
Linkedin,
Twitter,
X,
Mastodon,
Threads,
Instagram,
} from './icons'
const components = {
mail: Mail,
github: Github,
facebook: Facebook,
youtube: Youtube,
linkedin: Linkedin,
twitter: Twitter,
x: X,
mastodon: Mastodon,
threads: Threads,
instagram: Instagram,
}
type SocialIconProps = {
kind: keyof typeof components
href: string | undefined
size?: number
}
const SocialIcon = ({ kind, href, size = 8 }: SocialIconProps) => {
if (
!href ||
(kind === 'mail' && !/^mailto:[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.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-primary-500 dark:text-gray-200 dark:hover:text-primary-400 h-${size} w-${size}`}
/>
</a>
)
}
export default SocialIcon

182
contentlayer.config.ts Normal file
View File

@ -0,0 +1,182 @@
import { defineDocumentType, ComputedFields, makeSource } from 'contentlayer2/source-files'
import { writeFileSync } from 'fs'
import readingTime from 'reading-time'
import { slug } from 'github-slugger'
import path from 'path'
import { fromHtmlIsomorphic } from 'hast-util-from-html-isomorphic'
// Remark packages
import remarkGfm from 'remark-gfm'
import remarkMath from 'remark-math'
import { remarkAlert } from 'remark-github-blockquote-alert'
import {
remarkExtractFrontmatter,
remarkCodeTitles,
remarkImgToJsx,
extractTocHeadings,
} from 'pliny/mdx-plugins/index.js'
// 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'
import siteMetadata from './data/siteMetadata'
import { allCoreContent, sortPosts } from 'pliny/utils/contentlayer.js'
const root = process.cwd()
const isProduction = process.env.NODE_ENV === 'production'
// heroicon mini link
const icon = fromHtmlIsomorphic(
`
<span class="content-header-link">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="w-5 h-5 linkicon">
<path d="M12.232 4.232a2.5 2.5 0 0 1 3.536 3.536l-1.225 1.224a.75.75 0 0 0 1.061 1.06l1.224-1.224a4 4 0 0 0-5.656-5.656l-3 3a4 4 0 0 0 .225 5.865.75.75 0 0 0 .977-1.138 2.5 2.5 0 0 1-.142-3.667l3-3Z" />
<path d="M11.603 7.963a.75.75 0 0 0-.977 1.138 2.5 2.5 0 0 1 .142 3.667l-3 3a2.5 2.5 0 0 1-3.536-3.536l1.225-1.224a.75.75 0 0 0-1.061-1.06l-1.224 1.224a4 4 0 1 0 5.656 5.656l3-3a4 4 0 0 0-.225-5.865Z" />
</svg>
</span>
`,
{ fragment: true }
)
const computedFields: ComputedFields = {
readingTime: { type: 'json', resolve: (doc) => readingTime(doc.body.raw) },
slug: {
type: 'string',
resolve: (doc) => doc._raw.flattenedPath.replace(/^.+?(\/)/, ''),
},
path: {
type: 'string',
resolve: (doc) => doc._raw.flattenedPath,
},
filePath: {
type: 'string',
resolve: (doc) => doc._raw.sourceFilePath,
},
toc: { type: 'json', resolve: (doc) => extractTocHeadings(doc.body.raw) },
}
/**
* Count the occurrences of all tags across blog posts and write to json file
*/
function createTagCount(allBlogs) {
const tagCount: Record<string, number> = {}
allBlogs.forEach((file) => {
if (file.tags && (!isProduction || file.draft !== true)) {
file.tags.forEach((tag) => {
const formattedTag = slug(tag)
if (formattedTag in tagCount) {
tagCount[formattedTag] += 1
} else {
tagCount[formattedTag] = 1
}
})
}
})
writeFileSync('./app/tag-data.json', JSON.stringify(tagCount))
}
function createSearchIndex(allBlogs) {
if (
siteMetadata?.search?.provider === 'kbar' &&
siteMetadata.search.kbarConfig.searchDocumentsPath
) {
writeFileSync(
`public/${path.basename(siteMetadata.search.kbarConfig.searchDocumentsPath)}`,
JSON.stringify(allCoreContent(sortPosts(allBlogs)))
)
console.log('Local search index generated...')
}
}
export const Blog = defineDocumentType(() => ({
name: 'Blog',
filePathPattern: 'blog/**/*.mdx',
contentType: 'mdx',
fields: {
title: { type: 'string', required: true },
date: { type: 'date', required: true },
tags: { type: 'list', of: { type: 'string' }, default: [] },
lastmod: { type: 'date' },
draft: { type: 'boolean' },
summary: { type: 'string' },
images: { type: 'json' },
authors: { type: 'list', of: { type: 'string' } },
layout: { type: 'string' },
bibliography: { type: 'string' },
canonicalUrl: { type: 'string' },
},
computedFields: {
...computedFields,
structuredData: {
type: 'json',
resolve: (doc) => ({
'@context': 'https://schema.org',
'@type': 'BlogPosting',
headline: doc.title,
datePublished: doc.date,
dateModified: doc.lastmod || doc.date,
description: doc.summary,
image: doc.images ? doc.images[0] : siteMetadata.socialBanner,
url: `${siteMetadata.siteUrl}/${doc._raw.flattenedPath}`,
}),
},
},
}))
export const Authors = defineDocumentType(() => ({
name: 'Authors',
filePathPattern: 'authors/**/*.mdx',
contentType: 'mdx',
fields: {
name: { type: 'string', required: true },
avatar: { type: 'string' },
occupation: { type: 'string' },
company: { type: 'string' },
email: { type: 'string' },
twitter: { type: 'string' },
linkedin: { type: 'string' },
github: { type: 'string' },
layout: { type: 'string' },
},
computedFields,
}))
export default makeSource({
contentDirPath: 'data',
documentTypes: [Blog, Authors],
mdx: {
cwd: process.cwd(),
remarkPlugins: [
remarkExtractFrontmatter,
remarkGfm,
remarkCodeTitles,
remarkMath,
remarkImgToJsx,
remarkAlert,
],
rehypePlugins: [
rehypeSlug,
[
rehypeAutolinkHeadings,
{
behavior: 'prepend',
headingProperties: {
className: ['content-header'],
},
content: icon,
},
],
rehypeKatex,
[rehypeCitation, { path: path.join(root, 'data') }],
[rehypePrismPlus, { defaultLanguage: 'js', ignoreMissing: true }],
rehypePresetMinify,
],
},
onSuccess: async (importData) => {
const { allBlogs } = await importData()
createTagCount(allBlogs)
createSearchIndex(allBlogs)
},
})

144
css/prism.css Normal file
View File

@ -0,0 +1,144 @@
/**
* 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 dark:bg-gray-800;
}
.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 -ml-2 mr-4 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;
}
.token.table {
display: inline;
}

60
css/tailwind.css Normal file
View File

@ -0,0 +1,60 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
.task-list-item::before {
@apply hidden;
}
.task-list-item {
@apply list-none;
}
.footnotes {
@apply mt-12 border-t border-gray-200 pt-8 dark:border-gray-700;
}
.data-footnote-backref {
@apply no-underline;
}
.csl-entry {
@apply my-5;
}
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
/* 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;
}
.katex-display {
overflow: auto hidden;
}
.content-header-link {
opacity: 0;
margin-left: -24px;
padding-right: 4px;
}
.content-header:hover .content-header-link,
.content-header-link:hover {
opacity: 1;
}
.linkicon {
display: inline-block;
vertical-align: middle;
}

16
data/authors/default.mdx Normal file
View 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.

View 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.mdx Normal file
View 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))
```

View File

@ -0,0 +1,143 @@
---
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.
[^footnote]
[^footnote]: 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 (`&dollar;`) [^2]
Inline or manually enumerated footnotes are also supported. Click on the links above to see them in action.
[^2]: \$10 and &dollar;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}
$$

View 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
Heres 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
Heres 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~~.

View File

@ -0,0 +1,72 @@
---
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 `static/images/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)
Photo by [YUCAR FotoGrafik](https://unsplash.com/@yucar?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText)
on [Unsplash](https://unsplash.com/s/photos/sea?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText)
# Benefits
- 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`

View File

@ -0,0 +1,197 @@
---
title: 'Introducing Tailwind Nextjs Starter Blog'
date: '2021-01-12'
lastmod: '2024-08-16'
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)
> [!CAUTION]
> This is the README of version 1 of the template. It is kept for historical reasons, but is no longer supported. It also serves as a nice example of [Github Alert](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax#alerts). For the actual documentation, please refer to the [Github repository](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, Emailoctopus and Beehiiv
- 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. JS (official support) - `npx degit https://github.com/timlrx/tailwind-nextjs-starter-blog.git` or TS (community support) - `npx degit timlrx/tailwind-nextjs-starter-blog#typescript`
2. Personalize `siteMetadata.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.ts`
6. Modify `headerNavLinks.ts` 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 10 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/main/LICENSE) © [Timothy Lin](https://www.timrlx.com)

View File

@ -0,0 +1,10 @@
---
title: My fancy title
date: '2021-01-31'
tags: ['hello']
draft: true
summary: draft post
images: []
---
Draft post which should not display

View File

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

View File

@ -0,0 +1,449 @@
---
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
---
## 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! [^1]
[^1]: 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
// Or import PageTitle from './components/PageTitle.js' if you are using js
import PageTitle from './components/PageTitle.tsx'
;<PageTitle> Using JSX components in MDX </PageTitle>
```
The default configuration resolves all components relative to the `components` directory.
**Note**:
Components which require external image loaders 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 -ml-2 mr-4 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,
}
]
}
```

View File

@ -0,0 +1,82 @@
---
title: O Canada
date: '2017-07-15'
tags: ['holiday', 'canada', 'images']
draft: false
layout: PostBanner
images: ['/static/images/canada/mountains.jpg']
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.
---
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="-mx-2 flex flex-wrap overflow-hidden xl:-mx-2">
<div className="my-1 w-full overflow-hidden px-2 xl:my-1 xl:w-1/2 xl:px-2">
![Maple](/static/images/canada/maple.jpg)
</div>
<div className="my-1 w-full overflow-hidden px-2 xl:my-1 xl:w-1/2 xl:px-2">
![Lake](/static/images/canada/lake.jpg)
</div>
<div className="my-1 w-full overflow-hidden px-2 xl:my-1 xl:w-1/2 xl:px-2">
![Mountains](/static/images/canada/mountains.jpg)
</div>
<div className="my-1 w-full overflow-hidden px-2 xl:my-1 xl:w-1/2 xl:px-2">
![Toronto](/static/images/canada/toronto.jpg)
</div>
</div>
# Implementation
```js
<div className="-mx-2 flex flex-wrap overflow-hidden xl:-mx-2">
<div className="my-1 w-full overflow-hidden px-2 xl:my-1 xl:w-1/2 xl:px-2">
![Maple](/static/images/canada/maple.jpg)
</div>
<div className="my-1 w-full overflow-hidden px-2 xl:my-1 xl:w-1/2 xl:px-2">
![Lake](/static/images/canada/lake.jpg)
</div>
<div className="my-1 w-full overflow-hidden px-2 xl:my-1 xl:w-1/2 xl:px-2">
![Mountains](/static/images/canada/mountains.jpg)
</div>
<div className="my-1 w-full overflow-hidden px-2 xl:my-1 xl:w-1/2 xl:px-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&amp;utm_medium=referral&amp;utm_content=creditCopyText)
on
[Unsplash](https://unsplash.com/s/photos/canada?utm_source=unsplash&amp;utm_medium=referral&amp;utm_content=creditCopyText)
</div>
<div>
Mountains photo by [John
Lee](https://unsplash.com/@john_artifexfilms?utm_source=unsplash&amp;utm_medium=referral&amp;utm_content=creditCopyText)
on
[Unsplash](https://unsplash.com/s/photos/canada?utm_source=unsplash&amp;utm_medium=referral&amp;utm_content=creditCopyText)
</div>
<div>
Lake photo by [Tj
Holowaychuk](https://unsplash.com/@tjholowaychuk?utm_source=unsplash&amp;utm_medium=referral&amp;utm_content=creditCopyText)
on
[Unsplash](https://unsplash.com/s/photos/canada?utm_source=unsplash&amp;utm_medium=referral&amp;utm_content=creditCopyText)
</div>
<div>
Toronto photo by [Matthew
Henry](https://unsplash.com/@matthewhenry?utm_source=unsplash&amp;utm_medium=referral&amp;utm_content=creditCopyText)
on
[Unsplash](https://unsplash.com/s/photos/canada?utm_source=unsplash&amp;utm_medium=referral&amp;utm_content=creditCopyText)
</div>

View File

@ -0,0 +1,190 @@
---
title: 'Release of Tailwind Nextjs Starter Blog v2.0'
date: '2023-08-05'
lastmod: '2023-08-05'
tags: ['next-js', 'tailwind', 'guide', 'feature']
draft: false
summary: 'Release of Tailwind Nextjs Starter Blog template v2.0, refactored with Nextjs App directory and React Server Components setup.Discover the new features and how to migrate from V1.'
images: ['/static/images/twitter-card.png']
---
## Introduction
Welcome to the release of Tailwind Nextjs Starter Blog template v2.0. This release is a major refactor of the codebase to support Nextjs App directory and React Server Components. Read on to discover the new features and how to migrate from V1.
<TOCInline toc={props.toc} exclude="Introduction" />
## V1 to V2
![Github Traffic](/static/images/github-traffic.png)
The template was first released in January 2021 and has since been used by thousands of users. It is featured on [Next.js Templates](https://vercel.com/templates/next.js/tailwind-css-starter-blog), [Tailwind Awesome](https://www.tailwindawesome.com/resources/tailwind-nextjs-starter-blog) among other listing sites. It attracts 200+ unique visitors daily notching 1500-2000 page views, with 1.3k forks and many other clones.
Many thanks to the community of users and contributors for making this template a success! I created a small video montage of the blogs (while cleaning up the list in the readme) to showcase the diversity of the blogs created using the template and to celebrate the milestone:
<video controls>
<source
src="https://github-production-user-asset-6210df.s3.amazonaws.com/28362229/258559849-2124c81f-b99d-4431-839c-347e01a2616c.webm"
type="video/webm"
/>
</video>
Version 2 builds on the success of the previous version and introduces many new features and improvements. The codebase has been refactored to support Next.js App directory and React Server Components. Markdown / MDX is now processed using Contentlayer, a type-safe content SDK that validates and transforms your content into type-safe JSON data. It integrates with Pliny, a new library that provides out of the box Next.js components to enhance your static site with analytics, comments and newsletter subscription. A new command palette (⌘-k) search component is also added to the template.
Let's dive into the new features and improvements in V2.
## Next.js App Directory and React Server Components
Now that [Next.js App router](https://nextjs.org/docs/app) is finally stable and is mostly feature compatible with Page Router, the codebase has been migrated to new setup. This allows for a hybrid rendering approach, with the use of React Server Components generated on the server side for faster page loads and smaller bundle sizes, while retaining the ability to sprinkle in client side React components for interactivity.[^1]
With addition powers comes a [new paradigm](https://nextjs.org/docs/getting-started/react-essentials) to learn. I have migrated the codebase to make use of the new features as much as possible. This includes changes in the folder structure, splitting components into server vs client components, leveraging server side data fetching and using the recommended [Metadata](https://nextjs.org/docs/app/building-your-application/optimizing/metadata) API for SEO discoverability.
While this simplifies the codebase to some extent, it makes migration from the old codebase more difficult. If you are looking to migrate, I recommend starting from a fresh template and copying over your customizations and existing content. See the [migration recommendations](#migration-recommendations) section for more details.
## Typescript First
The codebase has been migrated to Typescript. While the previous version of the template was available in both Javascript and Typescript, I decided to reduce the maintenance burden and focus on Typescript. This also allows for better type checking and code completion in IDEs.
Typescript is also a perfect match with our new type-safe markdown processor - Contentlayer.
## Contentlayer
[Contentlayer](https://www.contentlayer.dev/) is a content SDK that validates and transforms your content into type-safe JSON data that you can easily import into your application. It makes working with local markdown or MDX files a breeze. This replaces `MDX-bundler` and our own markdown processing workflow.
First, a content source is defined, specifying the name of the document type, the source where it is located along with the frontmatter fields and any additional computed fields that should be generated as part of the process.
```ts:contentlayer.config.ts
export const Blog = defineDocumentType(() => ({
name: 'Blog',
filePathPattern: 'blog/**/*.mdx',
contentType: 'mdx',
fields: {
title: { type: 'string', required: true },
date: { type: 'date', required: true },
tags: { type: 'list', of: { type: 'string' }, default: [] },
...
},
computedFields: {
readingTime: { type: 'json', resolve: (doc) => readingTime(doc.body.raw) },
slug: {
type: 'string',
resolve: (doc) => doc._raw.flattenedPath.replace(/^.+?(\/)/, ''),
}
...
},
}))
```
Contentlayer then processes the MDX files with our desired markdown remark or rehype plugins, validates the schema, generate type definitions and output json files that can be easily imported in our pages. Hot reloading comes out of the box, so edits to the markdown files will be reflected in the browser immediately!
## Pliny
A large reason for the popularity of the template was its customizability and integration with other services from analytics providers to commenting solutions. However, this means that a lot of boilerplate code has to be co-located within the template even if the user does not use the feature. Updates and bug fixes had to be copied manually to the user's codebase.
To solve this, I have abstracted the logic to a separate repository - [Pliny](https://github.com/timlrx/pliny). Pliny provides out of the box Next.js components to enhance static sites:
- Analytics
- Google Analytics
- Plausible Analytics
- Simple Analytics
- Umami Analytics
- Posthog
- Comments
- Disqus
- Giscus
- Utterances
- Newsletter (uses Next 13 API Routes)
- Buttondown
- Convertkit
- Email Octopus
- Klaviyo
- Mailchimp
- Revue
- Command palette search with tailwind style sheet
- Algolia
- Kbar (local search)
- UI utility components
- Bleed
- Newsletter / Blog Newsletter
- Pre / Code block
- Table of Contents
Choose your preferred service by modifying `siteMetadata.js` and changing the appropriate fields. For example to change from Umami Analytics to Plausible, we can change the following fields:
```diff-js:siteMetadata.js
analytics: {
- umamiAnalytics: {
- // We use an env variable for this site to avoid other users cloning our analytics ID
- umamiWebsiteId: process.env.NEXT_UMAMI_ID, // e.g. 123e4567-e89b-12d3-a456-426614174000
- },
+ plausibleAnalytics: {
+ plausibleDataDomain: '', // e.g. tailwind-nextjs-starter-blog.vercel.app
+ },
},
```
Changes in the configuration file gets propagated to the components automatically. No modification to the template is required.
Under the hood, Pliny exports high level components such as `<Analytics analyticsConfig={analyticsConfig}/>` and `<Comments commentsConfig={commentsConfig}/>` which takes in a configuration object and renders the appropriate component. Since the layouts are defined on the server side, Next.js is able to use the configuration object to determine which component to render and send only the required component bundle to the client.
## New Search Component
What's a blog in 2023 without a command palette search bar?
One of the most highly requested features have been added 🎉! The search component supports 2 search providers - Algolia and Kbar local search.
### Algolia
[Algolia Docsearch](https://docsearch.algolia.com/) is popular free service used across many documentation websites. It automatically scrapes the website that has is submitted for indexing and makes the search result available via a beautiful dialog modal. The pliny component is greatly inspired by the Docusaurus implementation and comes with a stylesheet that is compatible with the Tailwind CSS theme.
### Kbar
[Kbar](https://github.com/timc1/kbar) is a fast, portable, and extensible cmd+k interface. The pliny implementation uses kbar to create a local search dialog box. The component loads a JSON file, default `search.json`, that was created in the contentlayer build process. Try pressing ⌘-k or ctrl-k to see the search bar in action!
## Styling and Layout Updates
### Theming
`tailwind.config.js` has been updated to use tailwind typography defaults where possible and to use the built-in support for dark mode via the `prose-invert` class. This replaces the previous `prose-dark` class and configuration.
The primary theme color is updated from `teal` to `pink` and the primary gray theme from `neutral` to `gray`.
Inter is now replaced with Space Grotesk as the default font.
### New Layouts
Layout components available in the `layouts` directory, provide a simple way to customize the look and feel of the blog.[^2]
The downside of building a popular template is that you start seeing multiple similar sites everywhere 😆. While users are encouraged to customized the layouts to their liking, having more layout options that are easily switchable promotes diversity and perhaps can be a good starting point for further customizations.
In v2, I added a new post layout - `PostBanner`. It features a large banner image and a centered content container. Check out "[Pictures of Canada](/blog/pictures-of-canada)" blog post which has been updated to use the new layout.
The default blog listing layout has also been updated to include a side bar with blog tags. The search bar in the previous layout has been replace with the new command palette search. To switch back to the old layout, simply change the pages that use the `ListLayoutWithTags` component back to the original `ListLayout`.
## Migration Recommendations
Due to the large changes in directory structure, setup and tooling, I recommend starting from a fresh template and copying existing content, followed by incrementally migrating changes over to the new template.
Styling changes should be relatively minor and can be copied over from the old `tailwind.config.js` to the new one. If copying over, you might need to add back the `prose-dark` class to components that opt into tailwind typography styling. Do modify the font import in the root layout component to use the desired font of choice.
Changes to the MDX processing pipeline and schema can be easily ported to the new Contentlayer setup. If there are changes to the frontmatter fields, you can modify the document type in `contentlayer.config.ts` to include the new fields. Custom plugins can be added to the `remarkPlugins` and `rehypePlugins` properties in the `makeSource` export of `contentlayer.config.ts`.
Markdown layouts are no longer sourced automatically from the `layouts` directory. Instead, they have to be specified in the `layouts` object defined in `blog/[...slug]/page.tsx`.[^3]
To port over larger components or pages, I recommend first specificing it as a client component by using the `"use client"` directive. Once it renders correctly, you can split the interactive components (parts that rely on `use` hooks) as a client component and keep the remaining code as a server component. Consult the comprehensive Next.js [migration guide](https://nextjs.org/docs/app/building-your-application/upgrading/app-router-migration#migrating-from-pages-to-app) for more details.
## Conclusion
I hope you enjoy the new features and improvements in V2. If you have any feedback or suggestions, feel free to open an issue or reach out to me on [Twitter](https://twitter.com/timlrx).
## 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/main/LICENSE) © [Timothy Lin](https://www.timrlx.com)
[^1]: The previous version injects Preact into the production build. However, this is no longer possible as it does not support React Server Components. While overall bundle size has increased to about 85kB, most of the content can be pre-rendered on the server side, resulting in a low first contentful paint and time to interactive. Using React throughtout also leads to more consistent behavior with external libraries and components.
[^2]: This is different from Next.js App Directory layouts and are best thought of as reusable React containers.
[^3]: This takes advantage of Server Components by making it simple to specify the layout of choice in the markdown file and match against the `layouts` object which is then used to render the appropriate layout component.

View 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?”
“Dont 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 earths 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.
“Its 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!”
“Dont you think you would attract attention?” said the Medical Man.
“Our ancestors had no great tolerance for anachronisms.”
“One might get ones 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 ones 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.
“Lets see your experiment anyhow,” said the Psychologist, “though its
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 hes 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 Filbys
anecdote collapsed.

9
data/headerNavLinks.ts Normal file
View File

@ -0,0 +1,9 @@
const headerNavLinks = [
{ href: '/', title: 'Home' },
{ 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
View 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

27
data/projectsData.ts Normal file
View File

@ -0,0 +1,27 @@
interface Project {
title: string
description: string
href?: string
imgSrc?: string
}
const projectsData: Project[] = [
{
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
View File

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

101
data/siteMetadata.js Normal file
View File

@ -0,0 +1,101 @@
/** @type {import("pliny/config").PlinyConfig } */
const siteMetadata = {
title: 'Jonathan\'s Blog and Likes',
author: 'Jonathan Branan',
headerTitle: 'JonB.IO',
description: 'Thought\'s from someone with an internet connection.',
language: 'en-us',
theme: 'system', // system, dark or light
siteUrl: 'https://jonb.io',
siteRepo: 'https://git.jonb.io/jblu/jonb.io.git',
siteLogo: `${process.env.BASE_PATH || ''}/static/images/logo.png`,
socialBanner: `${process.env.BASE_PATH || ''}/static/images/twitter-card.png`,
// mastodon: 'https://mastodon.social/@mastodonuser',
email: 'jonbranan@gmail.com',
github: 'https://github.com',
// x: 'https://twitter.com/x',
// twitter: 'https://twitter.com/Twitter',
// facebook: 'https://facebook.com',
// youtube: 'https://youtube.com',
linkedin: 'https://www.linkedin.com',
// threads: 'https://www.threads.net',
// instagram: 'https://www.instagram.com',
locale: 'en-US',
// set to true if you want a navbar fixed to the top
stickyNav: false,
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, Simple Analytics, Umami, Posthog or Google Analytics.
umamiAnalytics: {
// We use an env variable for this site to avoid other users cloning our analytics ID
umamiWebsiteId: process.env.NEXT_UMAMI_ID, // e.g. 123e4567-e89b-12d3-a456-426614174000
// You may also need to overwrite the script if you're storing data in the US - ex:
// src: 'https://us.umami.is/script.js'
// Remember to add 'us.umami.is' in `next.config.js` as a permitted domain for the CSP
},
// plausibleAnalytics: {
// plausibleDataDomain: '', // e.g. tailwind-nextjs-starter-blog.vercel.app
// If you are hosting your own Plausible.
// src: '', // e.g. https://plausible.my-domain.com/js/script.js
// },
// simpleAnalytics: {},
// posthogAnalytics: {
// posthogProjectApiKey: '', // e.g. 123e4567-e89b-12d3-a456-426614174000
// },
// googleAnalytics: {
// googleAnalyticsId: '', // e.g. G-XXXXXXX
// },
},
newsletter: {
// supports mailchimp, buttondown, convertkit, klaviyo, revue, emailoctopus, beehive
// Please add your .env file and modify it according to your selection
provider: 'buttondown',
},
comments: {
// If you want to use an analytics provider 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',
// 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: '',
// This corresponds to the `data-lang="en"` in giscus's configurations
lang: 'en',
},
},
search: {
provider: 'kbar', // kbar or algolia
kbarConfig: {
searchDocumentsPath: `${process.env.BASE_PATH || ''}/search.json`, // path to load documents to search
},
// provider: 'algolia',
// algoliaConfig: {
// // The application ID provided by Algolia
// appId: 'R2IYF7ETH7',
// // Public API key: it is safe to commit it
// apiKey: '599cec31baffa4868cae4e79f180729b',
// indexName: 'docsearch',
// },
},
}
module.exports = siteMetadata

View File

@ -0,0 +1,61 @@
# How can I add a custom MDX component?
Here's an example on how to create a donut chart from Chart.js (assuming you already have the dependencies installed) and use it in MDX posts. First, create a new `DonutChart.tsx` component in `components`:
```tsx
'use client'
import { Doughnut } from 'react-chartjs-2'
import { Chart as ChartJS, ArcElement, Tooltip, Legend } from 'chart.js'
ChartJS.register(ArcElement, Tooltip, Legend)
const DonutChart = ({ data }) => {
return <Doughnut data={data} />
}
export default Doughnut
```
Since the underlying `Doughnut` component uses React hooks, we add the `'use client'` directive to specify that it is a client side component. Also, there is an existing issue which prevents named components from being used, so we need to export the component as the default export.
Next, add the component to `MDXComponents.tsx`:
```diff
...
+ import DonutChart from './DonutChart'
export const components: MDXComponents = {
Image,
TOCInline,
a: CustomLink,
pre: Pre,
+ DonutChart,
BlogNewsletterForm,
}
```
You can now use the component in `.mdx` files:
```mdx
## Example Donut Chart
export const data = {
labels: ['Red', 'Blue', 'Yellow'],
datasets: [
{
label: '# of Votes',
data: [12, 19, 3],
backgroundColor: [
'rgba(255, 99, 132, 0.2)',
'rgba(54, 162, 235, 0.2)',
'rgba(255, 206, 86, 0.2)',
],
borderColor: ['rgba(255, 99, 132, 1)', 'rgba(54, 162, 235, 1)', 'rgba(255, 206, 86, 1)'],
borderWidth: 1,
},
],
}
<DonutChart data={data} />
```

View File

@ -0,0 +1,91 @@
# How can I customize the `kbar` search?
Add a `SearchProvider` component such as the one shown below and use it in place of the default `SearchProvider` component in `app/layout.tsx`.
`defaultActions` are the initial list of actions.
`onSearchDocumentsLoad` is a callback function that is called when the documents specified by `searchDocumentsPath` are loaded. Set `searchDocumentsPath` to `false` to disable the dynamically loaded search feature.
```tsx
'use client'
import { KBarSearchProvider } from 'pliny/search/KBar'
import { useRouter } from 'next/navigation'
import { CoreContent } from 'pliny/utils/contentlayer'
import { Blog } from 'contentlayer/generated'
export const SearchProvider = ({ children }) => {
const router = useRouter()
return (
<KBarSearchProvider
kbarConfig={{
searchDocumentsPath: 'search.json',
defaultActions: [
{
id: 'homepage',
name: 'Homepage',
keywords: '',
shortcut: ['h', 'h'],
section: 'Home',
perform: () => router.push('/'),
},
{
id: 'projects',
name: 'Projects',
keywords: '',
shortcut: ['p'],
section: 'Home',
perform: () => router.push('/projects'),
},
],
onSearchDocumentsLoad(json) {
return json.map((post: CoreContent<Blog>) => ({
id: post.path,
name: post.title,
keywords: post?.summary || '',
section: 'Blog',
subtitle: post.tags.join(', '),
perform: () => router.push('/' + post.path),
}))
},
}}
>
{children}
</KBarSearchProvider>
)
}
```
You can even choose to do a full text search over the entire generated blog content though this would come at the expense of a larger search index file by modifying the `createSearchIndex` function in `contentlayer.config.ts` to:
```tsx
function createSearchIndex(allBlogs) {
if (
siteMetadata?.search?.provider === 'kbar' &&
siteMetadata.search.kbarConfig.searchDocumentsPath
) {
writeFileSync(
`public/${siteMetadata.search.kbarConfig.searchDocumentsPath}`,
JSON.stringify((sortPosts(allBlogs)))
)
console.log('Local search index generated...')
}
}
```
Note the change from `JSON.stringify(allCoreContent(sortPosts(allBlogs)))` to `JSON.stringify((sortPosts(allBlogs)))`.
Next, in the modified `SearchProvider`, dump the raw content to the `keywords` field in the `onSearchDocumentsLoad` prop:
```tsx
onSearchDocumentsLoad(json) {
return json.map((post: Blog) => ({
id: post.path,
name: post.title,
keywords: post.body.raw,
section: 'Blog',
subtitle: post.tags.join(', '),
perform: () => router.push('/' + post.path),
}))
}
```

12
jsconfig.json Normal file
View File

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

50
layouts/AuthorLayout.tsx Normal file
View File

@ -0,0 +1,50 @@
import { ReactNode } from 'react'
import type { Authors } from 'contentlayer/generated'
import SocialIcon from '@/components/social-icons'
import Image from '@/components/Image'
interface Props {
children: ReactNode
content: Omit<Authors, '_id' | '_raw' | 'body'>
}
export default function AuthorLayout({ children, content }: Props) {
const { name, avatar, occupation, company, email, twitter, linkedin, github } = content
return (
<>
<div className="divide-y divide-gray-200 dark:divide-gray-700">
<div className="space-y-2 pb-8 pt-6 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 space-x-2 pt-8">
{avatar && (
<Image
src={avatar}
alt="avatar"
width={192}
height={192}
className="h-48 w-48 rounded-full"
/>
)}
<h3 className="pb-2 pt-4 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="x" href={twitter} />
</div>
</div>
<div className="prose max-w-none pb-8 pt-8 dark:prose-invert xl:col-span-2">
{children}
</div>
</div>
</div>
</>
)
}

152
layouts/ListLayout.tsx Normal file
View File

@ -0,0 +1,152 @@
'use client'
import { useState } from 'react'
import { usePathname } from 'next/navigation'
import { formatDate } from 'pliny/utils/formatDate'
import { CoreContent } from 'pliny/utils/contentlayer'
import type { Blog } from 'contentlayer/generated'
import Link from '@/components/Link'
import Tag from '@/components/Tag'
import siteMetadata from '@/data/siteMetadata'
interface PaginationProps {
totalPages: number
currentPage: number
}
interface ListLayoutProps {
posts: CoreContent<Blog>[]
title: string
initialDisplayPosts?: CoreContent<Blog>[]
pagination?: PaginationProps
}
function Pagination({ totalPages, currentPage }: PaginationProps) {
const pathname = usePathname()
const basePath = pathname.split('/')[1]
const prevPage = currentPage - 1 > 0
const nextPage = currentPage + 1 <= totalPages
return (
<div className="space-y-2 pb-8 pt-6 md:space-y-5">
<nav className="flex justify-between">
{!prevPage && (
<button className="cursor-auto disabled:opacity-50" disabled={!prevPage}>
Previous
</button>
)}
{prevPage && (
<Link
href={currentPage - 1 === 1 ? `/${basePath}/` : `/${basePath}/page/${currentPage - 1}`}
rel="prev"
>
Previous
</Link>
)}
<span>
{currentPage} of {totalPages}
</span>
{!nextPage && (
<button className="cursor-auto disabled:opacity-50" disabled={!nextPage}>
Next
</button>
)}
{nextPage && (
<Link href={`/${basePath}/page/${currentPage + 1}`} rel="next">
Next
</Link>
)}
</nav>
</div>
)
}
export default function ListLayout({
posts,
title,
initialDisplayPosts = [],
pagination,
}: ListLayoutProps) {
const [searchValue, setSearchValue] = useState('')
const filteredBlogPosts = posts.filter((post) => {
const searchContent = post.title + post.summary + post.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 pb-8 pt-6 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">
<label>
<span className="sr-only">Search articles</span>
<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"
/>
</label>
<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((post) => {
const { path, date, title, summary, tags } = post
return (
<li key={path} 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, siteMetadata.locale)}</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={`/${path}`} 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} />
)}
</>
)
}

View File

@ -0,0 +1,165 @@
/* eslint-disable jsx-a11y/anchor-is-valid */
'use client'
import { usePathname } from 'next/navigation'
import { slug } from 'github-slugger'
import { formatDate } from 'pliny/utils/formatDate'
import { CoreContent } from 'pliny/utils/contentlayer'
import type { Blog } from 'contentlayer/generated'
import Link from '@/components/Link'
import Tag from '@/components/Tag'
import siteMetadata from '@/data/siteMetadata'
import tagData from 'app/tag-data.json'
interface PaginationProps {
totalPages: number
currentPage: number
}
interface ListLayoutProps {
posts: CoreContent<Blog>[]
title: string
initialDisplayPosts?: CoreContent<Blog>[]
pagination?: PaginationProps
}
function Pagination({ totalPages, currentPage }: PaginationProps) {
const pathname = usePathname()
const basePath = pathname.split('/')[1]
const prevPage = currentPage - 1 > 0
const nextPage = currentPage + 1 <= totalPages
return (
<div className="space-y-2 pb-8 pt-6 md:space-y-5">
<nav className="flex justify-between">
{!prevPage && (
<button className="cursor-auto disabled:opacity-50" disabled={!prevPage}>
Previous
</button>
)}
{prevPage && (
<Link
href={currentPage - 1 === 1 ? `/${basePath}/` : `/${basePath}/page/${currentPage - 1}`}
rel="prev"
>
Previous
</Link>
)}
<span>
{currentPage} of {totalPages}
</span>
{!nextPage && (
<button className="cursor-auto disabled:opacity-50" disabled={!nextPage}>
Next
</button>
)}
{nextPage && (
<Link href={`/${basePath}/page/${currentPage + 1}`} rel="next">
Next
</Link>
)}
</nav>
</div>
)
}
export default function ListLayoutWithTags({
posts,
title,
initialDisplayPosts = [],
pagination,
}: ListLayoutProps) {
const pathname = usePathname()
const tagCounts = tagData as Record<string, number>
const tagKeys = Object.keys(tagCounts)
const sortedTags = tagKeys.sort((a, b) => tagCounts[b] - tagCounts[a])
const displayPosts = initialDisplayPosts.length > 0 ? initialDisplayPosts : posts
return (
<>
<div>
<div className="pb-6 pt-6">
<h1 className="text-3xl font-extrabold leading-9 tracking-tight text-gray-900 dark:text-gray-100 sm:hidden sm:text-4xl sm:leading-10 md:text-6xl md:leading-14">
{title}
</h1>
</div>
<div className="flex sm:space-x-24">
<div className="hidden h-full max-h-screen min-w-[280px] max-w-[280px] flex-wrap overflow-auto rounded bg-gray-50 pt-5 shadow-md dark:bg-gray-900/70 dark:shadow-gray-800/40 sm:flex">
<div className="px-6 py-4">
{pathname.startsWith('/blog') ? (
<h3 className="font-bold uppercase text-primary-500">All Posts</h3>
) : (
<Link
href={`/blog`}
className="font-bold uppercase text-gray-700 hover:text-primary-500 dark:text-gray-300 dark:hover:text-primary-500"
>
All Posts
</Link>
)}
<ul>
{sortedTags.map((t) => {
return (
<li key={t} className="my-3">
{decodeURI(pathname.split('/tags/')[1]) === slug(t) ? (
<h3 className="inline px-3 py-2 text-sm font-bold uppercase text-primary-500">
{`${t} (${tagCounts[t]})`}
</h3>
) : (
<Link
href={`/tags/${slug(t)}`}
className="px-3 py-2 text-sm font-medium uppercase text-gray-500 hover:text-primary-500 dark:text-gray-300 dark:hover:text-primary-500"
aria-label={`View posts tagged ${t}`}
>
{`${t} (${tagCounts[t]})`}
</Link>
)}
</li>
)
})}
</ul>
</div>
</div>
<div>
<ul>
{displayPosts.map((post) => {
const { path, date, title, summary, tags } = post
return (
<li key={path} className="py-5">
<article className="flex flex-col space-y-2 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} suppressHydrationWarning>
{formatDate(date, siteMetadata.locale)}
</time>
</dd>
</dl>
<div className="space-y-3">
<div>
<h2 className="text-2xl font-bold leading-8 tracking-tight">
<Link href={`/${path}`} 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>
</article>
</li>
)
})}
</ul>
{pagination && pagination.totalPages > 1 && (
<Pagination currentPage={pagination.currentPage} totalPages={pagination.totalPages} />
)}
</div>
</div>
</div>
</>
)
}

78
layouts/PostBanner.tsx Normal file
View File

@ -0,0 +1,78 @@
import { ReactNode } from 'react'
import Image from '@/components/Image'
import Bleed from 'pliny/ui/Bleed'
import { CoreContent } from 'pliny/utils/contentlayer'
import type { Blog } from 'contentlayer/generated'
import Comments from '@/components/Comments'
import Link from '@/components/Link'
import PageTitle from '@/components/PageTitle'
import SectionContainer from '@/components/SectionContainer'
import siteMetadata from '@/data/siteMetadata'
import ScrollTopAndComment from '@/components/ScrollTopAndComment'
interface LayoutProps {
content: CoreContent<Blog>
children: ReactNode
next?: { path: string; title: string }
prev?: { path: string; title: string }
}
export default function PostMinimal({ content, next, prev, children }: LayoutProps) {
const { slug, title, images } = content
const displayImage =
images && images.length > 0 ? images[0] : 'https://picsum.photos/seed/picsum/800/400'
return (
<SectionContainer>
<ScrollTopAndComment />
<article>
<div>
<div className="space-y-1 pb-10 text-center dark:border-gray-700">
<div className="w-full">
<Bleed>
<div className="relative aspect-[2/1] w-full">
<Image src={displayImage} alt={title} fill className="object-cover" />
</div>
</Bleed>
</div>
<div className="relative pt-10">
<PageTitle>{title}</PageTitle>
</div>
</div>
<div className="prose max-w-none py-4 dark:prose-invert">{children}</div>
{siteMetadata.comments && (
<div className="pb-6 pt-6 text-center text-gray-700 dark:text-gray-300" id="comment">
<Comments slug={slug} />
</div>
)}
<footer>
<div className="flex flex-col text-sm font-medium sm:flex-row sm:justify-between sm:text-base">
{prev && prev.path && (
<div className="pt-4 xl:pt-8">
<Link
href={`/${prev.path}`}
className="text-primary-500 hover:text-primary-600 dark:hover:text-primary-400"
aria-label={`Previous post: ${prev.title}`}
>
&larr; {prev.title}
</Link>
</div>
)}
{next && next.path && (
<div className="pt-4 xl:pt-8">
<Link
href={`/${next.path}`}
className="text-primary-500 hover:text-primary-600 dark:hover:text-primary-400"
aria-label={`Next post: ${next.title}`}
>
{next.title} &rarr;
</Link>
</div>
)}
</div>
</footer>
</div>
</article>
</SectionContainer>
)
}

168
layouts/PostLayout.tsx Normal file
View File

@ -0,0 +1,168 @@
import { ReactNode } from 'react'
import { CoreContent } from 'pliny/utils/contentlayer'
import type { Blog, Authors } from 'contentlayer/generated'
import Comments from '@/components/Comments'
import Link from '@/components/Link'
import PageTitle from '@/components/PageTitle'
import SectionContainer from '@/components/SectionContainer'
import Image from '@/components/Image'
import Tag from '@/components/Tag'
import siteMetadata from '@/data/siteMetadata'
import ScrollTopAndComment from '@/components/ScrollTopAndComment'
const editUrl = (path) => `${siteMetadata.siteRepo}/blob/main/data/${path}`
const discussUrl = (path) =>
`https://mobile.twitter.com/search?q=${encodeURIComponent(`${siteMetadata.siteUrl}/${path}`)}`
const postDateTemplate: Intl.DateTimeFormatOptions = {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
}
interface LayoutProps {
content: CoreContent<Blog>
authorDetails: CoreContent<Authors>[]
next?: { path: string; title: string }
prev?: { path: string; title: string }
children: ReactNode
}
export default function PostLayout({ content, authorDetails, next, prev, children }: LayoutProps) {
const { filePath, path, slug, date, title, tags } = content
const basePath = path.split('/')[0]
return (
<SectionContainer>
<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="grid-rows-[auto_1fr] 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">
<dl className="pb-10 pt-6 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 flex-wrap justify-center gap-4 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={38}
height={38}
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/', '@')
.replace('https://x.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 pb-8 pt-10 dark:prose-invert">{children}</div>
<div className="pb-6 pt-6 text-sm text-gray-700 dark:text-gray-300">
<Link href={discussUrl(path)} rel="nofollow">
Discuss on Twitter
</Link>
{``}
<Link href={editUrl(filePath)}>View on GitHub</Link>
</div>
{siteMetadata.comments && (
<div
className="pb-6 pt-6 text-center text-gray-700 dark:text-gray-300"
id="comment"
>
<Comments slug={slug} />
</div>
)}
</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 && prev.path && (
<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={`/${prev.path}`}>{prev.title}</Link>
</div>
</div>
)}
{next && next.path && (
<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={`/${next.path}`}>{next.title}</Link>
</div>
</div>
)}
</div>
)}
</div>
<div className="pt-4 xl:pt-8">
<Link
href={`/${basePath}`}
className="text-primary-500 hover:text-primary-600 dark:hover:text-primary-400"
aria-label="Back to the blog"
>
&larr; Back to the blog
</Link>
</div>
</footer>
</div>
</div>
</article>
</SectionContainer>
)
}

82
layouts/PostSimple.tsx Normal file
View File

@ -0,0 +1,82 @@
import { ReactNode } from 'react'
import { formatDate } from 'pliny/utils/formatDate'
import { CoreContent } from 'pliny/utils/contentlayer'
import type { Blog } from 'contentlayer/generated'
import Comments from '@/components/Comments'
import Link from '@/components/Link'
import PageTitle from '@/components/PageTitle'
import SectionContainer from '@/components/SectionContainer'
import siteMetadata from '@/data/siteMetadata'
import ScrollTopAndComment from '@/components/ScrollTopAndComment'
interface LayoutProps {
content: CoreContent<Blog>
children: ReactNode
next?: { path: string; title: string }
prev?: { path: string; title: string }
}
export default function PostLayout({ content, next, prev, children }: LayoutProps) {
const { path, slug, date, title } = content
return (
<SectionContainer>
<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, siteMetadata.locale)}</time>
</dd>
</div>
</dl>
<div>
<PageTitle>{title}</PageTitle>
</div>
</div>
</header>
<div className="grid-rows-[auto_1fr] divide-y divide-gray-200 pb-8 dark:divide-gray-700 xl:divide-y-0">
<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 pb-8 pt-10 dark:prose-invert">{children}</div>
</div>
{siteMetadata.comments && (
<div className="pb-6 pt-6 text-center text-gray-700 dark:text-gray-300" id="comment">
<Comments slug={slug} />
</div>
)}
<footer>
<div className="flex flex-col text-sm font-medium sm:flex-row sm:justify-between sm:text-base">
{prev && prev.path && (
<div className="pt-4 xl:pt-8">
<Link
href={`/${prev.path}`}
className="text-primary-500 hover:text-primary-600 dark:hover:text-primary-400"
aria-label={`Previous post: ${prev.title}`}
>
&larr; {prev.title}
</Link>
</div>
)}
{next && next.path && (
<div className="pt-4 xl:pt-8">
<Link
href={`/${next.path}`}
className="text-primary-500 hover:text-primary-600 dark:hover:text-primary-400"
aria-label={`Next post: ${next.title}`}
>
{next.title} &rarr;
</Link>
</div>
)}
</div>
</footer>
</div>
</div>
</article>
</SectionContainer>
)
}

5
next-env.d.ts vendored Normal file
View File

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

100
next.config.js Normal file
View File

@ -0,0 +1,100 @@
const { withContentlayer } = require('next-contentlayer2')
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 analytics.umami.is;
style-src 'self' 'unsafe-inline';
img-src * blob: data:;
media-src *.s3.amazonaws.com;
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=()',
},
]
const output = process.env.EXPORT ? 'export' : undefined
const basePath = process.env.BASE_PATH || undefined
const unoptimized = process.env.UNOPTIMIZED ? true : undefined
/**
* @type {import('next/dist/next-server/server/config').NextConfig}
**/
module.exports = () => {
const plugins = [withContentlayer, withBundleAnalyzer]
return plugins.reduce((acc, next) => next(acc), {
output,
basePath,
reactStrictMode: true,
pageExtensions: ['ts', 'tsx', 'js', 'jsx', 'md', 'mdx'],
eslint: {
dirs: ['app', 'components', 'layouts', 'scripts'],
},
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'picsum.photos',
},
],
unoptimized,
},
async headers() {
return [
{
source: '/(.*)',
headers: securityHeaders,
},
]
},
webpack: (config, options) => {
config.module.rules.push({
test: /\.svg$/,
use: ['@svgr/webpack'],
})
return config
},
})
}

74
package.json Normal file
View File

@ -0,0 +1,74 @@
{
"name": "tailwind-nextjs-starter-blog",
"version": "2.2.0",
"private": true,
"scripts": {
"start": "next dev",
"dev": "cross-env INIT_CWD=$PWD next dev",
"build": "cross-env INIT_CWD=$PWD next build && cross-env NODE_OPTIONS='--experimental-json-modules' node ./scripts/postbuild.mjs",
"serve": "next start",
"analyze": "cross-env ANALYZE=true next build",
"lint": "next lint --fix --dir pages --dir app --dir components --dir lib --dir layouts --dir scripts",
"prepare": "husky"
},
"dependencies": {
"@headlessui/react": "1.7.19",
"@next/bundle-analyzer": "14.2.3",
"@tailwindcss/forms": "^0.5.7",
"@tailwindcss/typography": "^0.5.12",
"autoprefixer": "^10.4.13",
"body-scroll-lock": "^4.0.0-beta.0",
"contentlayer2": "0.5.1",
"esbuild": "0.20.2",
"github-slugger": "^2.0.0",
"gray-matter": "^4.0.2",
"hast-util-from-html-isomorphic": "^2.0.0",
"image-size": "1.0.0",
"next": "14.2.3",
"next-contentlayer2": "0.5.1",
"next-themes": "^0.3.0",
"pliny": "0.2.1",
"postcss": "^8.4.24",
"react": "18.3.1",
"react-dom": "18.3.1",
"reading-time": "1.5.0",
"rehype-autolink-headings": "^7.1.0",
"rehype-citation": "^2.0.0",
"rehype-katex": "^7.0.0",
"rehype-preset-minify": "7.0.0",
"rehype-prism-plus": "^2.0.0",
"rehype-slug": "^6.0.0",
"remark": "^15.0.0",
"remark-gfm": "^4.0.0",
"remark-github-blockquote-alert": "^1.2.1",
"remark-math": "^6.0.0",
"tailwindcss": "^3.4.3",
"unist-util-visit": "^5.0.0"
},
"devDependencies": {
"@svgr/webpack": "^8.0.1",
"@types/mdx": "^2.0.12",
"@types/react": "^18.2.73",
"@typescript-eslint/eslint-plugin": "^6.1.0",
"@typescript-eslint/parser": "^6.1.0",
"cross-env": "^7.0.3",
"eslint": "^8.45.0",
"eslint-config-next": "14.2.3",
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-prettier": "^5.0.0",
"husky": "^9.0.0",
"lint-staged": "^13.0.0",
"prettier": "^3.0.0",
"prettier-plugin-tailwindcss": "^0.5.11",
"typescript": "^5.1.3"
},
"lint-staged": {
"*.+(js|jsx|ts|tsx)": [
"eslint --fix"
],
"*.+(js|jsx|ts|tsx|json|css|md|mdx)": [
"prettier --write"
]
},
"packageManager": "yarn@3.6.1"
}

6
postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

10
prettier.config.js Normal file
View File

@ -0,0 +1,10 @@
module.exports = {
semi: false,
singleQuote: true,
printWidth: 100,
tabWidth: 2,
useTabs: false,
trailingComma: 'es5',
bracketSpacing: true,
plugins: ['prettier-plugin-tailwindcss'],
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

View File

@ -0,0 +1,21 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="107.000000pt" height="107.000000pt" viewBox="0 0 107.000000 107.000000"
preserveAspectRatio="xMidYMid meet">
<metadata>
Created by potrace 1.11, written by Peter Selinger 2001-2013
</metadata>
<g transform="translate(0.000000,107.000000) scale(0.100000,-0.100000)"
fill="#000000" stroke="none">
<path d="M609 942 c-17 -18 -95 -88 -289 -258 -117 -102 -177 -155 -204 -179
-6 -5 -31 -28 -56 -50 l-45 -39 -1 -144 -1 -143 21 18 c11 10 25 23 31 28 7 6
46 41 88 79 86 77 86 77 216 196 52 47 103 93 115 103 11 10 50 45 86 77 l65
59 -1 131 c0 71 -3 132 -5 135 -3 2 -12 -3 -20 -13z"/>
<path d="M929 857 c-64 -56 -145 -128 -180 -159 -35 -31 -75 -66 -89 -78 -55
-47 -68 -59 -150 -131 l-85 -76 2 -129 c1 -71 2 -135 2 -143 1 -11 5 -10 19 5
10 10 54 51 98 89 43 39 92 83 109 99 26 24 63 58 189 171 12 11 47 43 77 70
30 28 70 63 89 79 l35 29 1 138 c1 77 1 139 0 139 0 -1 -53 -47 -117 -103z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,14 @@
{
"name": "",
"short_name": "",
"icons": [
{
"src": "/android-chrome-96x96.png",
"sizes": "96x96",
"type": "image/png"
}
],
"theme_color": "#000000",
"background_color": "#000000",
"display": "standalone"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 689 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

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