feat: 更新博客框架到 v2。 #3

Merged
Ivan merged 1 commits from v2 into master 2023-08-16 23:29:23 +08:00
143 changed files with 2798 additions and 12297 deletions

View File

@ -1,243 +0,0 @@
---
kind: pipeline
name: deps
type: docker
steps:
- name: install
image: plugins/docker
settings:
registry: docker-registry.ivanli.cc
username:
from_secret: ivan-docker-username
password:
from_secret: ivan-docker-password
repo: docker-registry.ivanli.cc/ivan/tailwind-nextjs-blog-deps
cache_from:
- docker-registry.ivanli.cc/ivan/tailwind-nextjs-blog-deps:${DRONE_BRANCH}${DRONE_TAG}-amd64
dockerfile: Dockerfile
target: deps
tags:
- '${DRONE_COMMIT_SHA:0:8}-amd64'
- '${DRONE_BRANCH}${DRONE_TAG}-amd64'
- name: notify
image: appleboy/drone-telegram
when:
status:
- failure
failure: ignore
detach: true
environment:
PLUGIN_TOKEN:
from_secret: drone-telegram-bot-token
PLUGIN_TO:
from_secret: telegram-notify-to
settings:
format: markdown
message: >
{{#success build.status}}
✅ Install Deps #{{build.number}} of `{{repo.name}}` succeeded.
📝 Commit by {{commit.author}} on `{{commit.branch}}`:
```
{{commit.message}}
```
🌐 {{ build.link }}
{{else}}
❌ Install Deps #{{build.number}} of `{{repo.name}}` failed.
📝 Commit by {{commit.author}} on `{{commit.branch}}`:
```
{{commit.message}}
```
🌐 {{ build.link }}
{{/success}}
---
kind: pipeline
name: linux-amd64
type: docker
depends_on:
- deps
steps:
- name: build&publish
image: plugins/docker
settings:
registry: docker-registry.ivanli.cc
username:
from_secret: ivan-docker-username
password:
from_secret: ivan-docker-password
repo: docker-registry.ivanli.cc/ivan/tailwind-nextjs-blog
dockerfile: Dockerfile
target: release
cache_from:
- docker-registry.ivanli.cc/ivan/tailwind-nextjs-blog-deps:${DRONE_BRANCH}${DRONE_TAG}-amd64
tags:
- '${DRONE_COMMIT_SHA:0:8}'
- '${DRONE_BRANCH}${DRONE_TAG}'
- name: notify
image: appleboy/drone-telegram
failure: ignore
detach: true
when:
status:
- success
- failure
environment:
PLUGIN_TOKEN:
from_secret: drone-telegram-bot-token
PLUGIN_TO:
from_secret: telegram-notify-to
settings:
format: markdown
message: >
{{#success build.status}}
✅ Build #{{build.number}} of `{{repo.name}}` succeeded.
📝 Commit by {{commit.author}} on `{{commit.branch}}`:
```
{{commit.message}}
```
🌐 {{ build.link }}
{{else}}
❌ Build #{{build.number}} of `{{repo.name}}` failed.
📝 Commit by {{commit.author}} on `{{commit.branch}}`:
```
{{commit.message}}
```
🌐 {{ build.link }}
{{/success}}
trigger:
branch:
- master
- main
- develop
---
kind: pipeline
type: docker
name: deploy
clone:
disable: false
depends_on:
- linux-amd64
steps:
- name: watchtower-online
image: plugins/webhook
settings:
token_value:
from_secret: watchtower-webhook-token
token_type: Bearer
urls: https://watchtower.ivanli.cc/v1/update
content_type: application/json
template: |
{
"owner": "{{ repo.owner }}",
"repo": "{{ repo.name }}",
"status": "{{ build.status }}",
}
- name: notify
image: appleboy/drone-telegram
when:
status:
- success
- failure
failure: ignore
detach: true
environment:
PLUGIN_TOKEN:
from_secret: drone-telegram-bot-token
PLUGIN_TO:
from_secret: telegram-notify-to
settings:
format: markdown
message: >
{{#success build.status}}
✅ Deploy #{{build.number}} of `{{repo.name}}` succeeded.
📝 Commit by {{commit.author}} on `{{commit.branch}}`:
```
{{commit.message}}
```
🌐 {{ build.link }}
{{else}}
❌ Deploy #{{build.number}} of `{{repo.name}}` failed.
📝 Commit by {{commit.author}} on `{{commit.branch}}`:
```
{{commit.message}}
```
🌐 {{ build.link }}
{{/success}}
---
kind: pipeline
type: docker
name: deploy-to-zzidc
clone:
disable: false
depends_on:
- linux-amd64
trigger:
branch:
- master
- develop
steps:
- name: upload
image: docker:dind
volumes:
- name: dockersock
path: /var/run/docker.sock
commands:
- docker pull docker-registry.ivanli.cc/ivan/tailwind-nextjs-blog-deps:${DRONE_BRANCH}${DRONE_TAG}-amd64
- docker build --pull=true --target upload -t docker-registry.ivanli.cc/ivan/tailwind-nextjs-blog-upload:${DRONE_COMMIT_SHA:0:8} --cache-from docker-registry.ivanli.cc/ivan/tailwind-nextjs-blog-deps:${DRONE_BRANCH}${DRONE_TAG}-amd64 .
- docker run --rm -t -e FTP_ACCOUNT=$${FTP_ACCOUNT} -e FTP_PASSWORD=$${FTP_PASSWORD} -e FTP_HOST=$${FTP_HOST} docker-registry.ivanli.cc/ivan/tailwind-nextjs-blog-upload:${DRONE_COMMIT_SHA:0:8}
environment:
DOCKER_BUILDKIT: "1"
FTP_ACCOUNT:
from_secret: zzidc_ftp_account
FTP_PASSWORD:
from_secret: zzidc_ftp_password
FTP_HOST:
from_secret: zzidc_ftp_host
- name: notify
image: appleboy/drone-telegram
when:
status:
- success
- failure
failure: ignore
detach: true
environment:
PLUGIN_TOKEN:
from_secret: drone-telegram-bot-token
PLUGIN_TO:
from_secret: telegram-notify-to
settings:
format: markdown
message: >
{{#success build.status}}
✅ Deploy #{{build.number}} of `{{repo.name}}` to ZZIDC succeeded.
📝 Commit by {{commit.author}} on `{{commit.branch}}`:
```
{{commit.message}}
```
🌐 {{ build.link }}
{{else}}
❌ Deploy #{{build.number}} of `{{repo.name}}` to ZZIDC failed.
📝 Commit by {{commit.author}} on `{{commit.branch}}`:
```
{{commit.message}}
```
🌐 {{ build.link }}
{{/success}}
volumes:
- name: dockersock
host:
path: /var/run/docker.sock

View File

@ -1,12 +0,0 @@
# EditorConfig is awesome: https://EditorConfig.org
# top-most EditorConfig file
root = true
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = false
insert_final_newline = false

View File

@ -1,30 +1,26 @@
# 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=
NEXT_PUBLIC_COMMENTO_URL=
MAILCHIMP_API_KEY=
MAILCHIMP_API_SERVER=
MAILCHIMP_AUDIENCE_ID=
BUTTONDOWN_API_URL=https://api.buttondown.email/v1/
BUTTONDOWN_API_KEY=
CONVERTKIT_API_URL=https://api.convertkit.com/v3/
CONVERTKIT_API_KEY=
// curl https://api.convertkit.com/v3/forms?api_key=<your_public_api_key> to get your form ID
# curl https://api.convertkit.com/v3/forms?api_key=<your_public_api_key> to get your form ID
CONVERTKIT_FORM_ID=
KLAVIYO_API_KEY=
KLAVIYO_LIST_ID=
REVUE_API_URL=https://www.getrevue.co/api/v2/
REVUE_API_KEY=
EMAILOCTOPUS_API_URL=https://emailoctopus.com/api/1.6/
EMAILOCTOPUS_API_KEY=
EMAILOCTOPUS_LIST_ID=
EMAILOCTOPUS_LIST_ID=

View File

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

View File

@ -1,17 +1,42 @@
module.exports = {
root: true,
parser: '@typescript-eslint/parser',
env: {
browser: true,
amd: true,
node: true,
es6: true,
},
extends: ['eslint:recommended', 'plugin:prettier/recommended', 'next', 'next/core-web-vitals'],
plugins: ['@typescript-eslint'],
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/eslint-recommended',
'plugin:@typescript-eslint/recommended',
'plugin:jsx-a11y/recommended',
'plugin:prettier/recommended',
'next',
'next/core-web-vitals',
],
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,
'no-unused-vars': 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',
},
}

2
.gitattributes vendored
View File

@ -1,5 +1,5 @@
## Source: https://github.com/alexkaratarakis/gitattributes
## Modified * text=auto to * text=auto eol=lf to force LF endings.
## Modified * text=auto to * text=auto eol=lf eol=lf to force LF endings.
## GITATTRIBUTES FOR WEB PROJECTS
#

11
.gitignore vendored
View File

@ -4,6 +4,10 @@
/node_modules
/.pnp
.pnp.js
/.yarn/*
!/.yarn/releases
!/.yarn/plugins
!/.yarn/sdks
# testing
/coverage
@ -17,9 +21,13 @@ public/sitemap.xml
# production
/build
*.xml
# rss feed
/public/feed.xml
# search
/public/search.json
# misc
.DS_Store
@ -35,6 +43,9 @@ yarn-error.log*
.env.test.local
.env.production.local
# Contentlayer
.contentlayer
secrets.txt
.pnpm-store

View File

@ -1,4 +1,6 @@
{
"typescript.tsdk": "node_modules/typescript/lib",
"typescript.enablePromptUseWorkspaceTsdk": true,
"cSpell.words": [
"alpn",
"appleboy",

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

192
README.md
View File

@ -1,16 +1,136 @@
![tailwind-nextjs-banner](/public/static/images/twitter-card.png)
# Ivan Li's Blog
# Tailwind Nextjs Starter Blog
[![Build Status](https://ci.ivanli.cc/api/badges/Ivan/tailwind-nextjs-blog/status.svg)](https://ci.ivanli.cc/Ivan/tailwind-nextjs-blog)
[![Website Status](https://uptime.sg.ivanli.cc/api/badge/18/uptime/720?label=30&labelSuffix=d)](https://ivanli.cc)
[![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)
Ivan Li's Blog, base [timlrx/tailwind-nextjs-starter-blog](https://github.com/timlrx/tailwind-nextjs-starter-blog)
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/git/external?repository-url=https://github.com/timlrx/tailwind-nextjs-starter-blog)
This is a [Next.js](https://nextjs.org/), [Tailwind CSS](https://tailwindcss.com/) blogging starter template. 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!
## 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
- [ben.codes blog](https://ben.codes) - Benoit's personal blog about software development ([source code](https://github.com/bendotcodes/bendotcodes))
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 contributers 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.
- [dalelarroder.com](https://dalelarroder.com) - Dale Larroder's personal website and blog ([source code](https://github.com/dlarroder/dalelarroder))
- [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.
## 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, and Emailoctopus
- 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)
- 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.js`
6. Modify `headerNavLinks.js` to customize navigation links
7. Add blog posts
8. Deploy on Vercel
## Installation
```bash
pnpm install
yarn
```
## Development
@ -18,18 +138,12 @@ pnpm install
First, run the development server:
```bash
pnpm start
```
or
```bash
pnpm run dev
yarn 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.
Edit the layout in `app` or content in `data`. With live reloading, the pages auto-updates as you edit them.
## Extend / Customize
@ -47,36 +161,41 @@ You can start editing the page by modifying `pages/index.js`. The page auto-upda
`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.
`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).
`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/).
`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 call them directly in the `.mdx` or `.md` file. By default, a custom link and image component is passed.
`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.
`layouts` - main templates used in pages:
`pages` - pages to route to. Read the [Next.js documentation](https://nextjs.org/docs) for more information.
- 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/).
Currently 7 fields are supported.
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 (required, can be empty array)
tags (optional)
lastmod (optional)
draft (optional)
summary (optional)
images (optional, if none provided defaults to socialBanner in siteMetadata config)
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)
@ -99,12 +218,29 @@ canonicalUrl: https://tailwind-nextjs-starter-blog.vercel.app/blog/introducing-t
---
```
### 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
Drone CI.
**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.**
1. Add `output: 'export'` in `next.config.js`. See [static exports documentation](https://nextjs.org/docs/app/building-your-application/deploying/static-exports#configuration) for more information.
2. Comment out `headers()` from `next.config.js`.
3. Change `components/Image.tsx` to use a standard `<img>` tag instead of `next/image`. Alternatively, 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.
4. 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.
5. Run `yarn build`. The generated static content is in the `out` folder.
6. Deploy the `out` folder to your hosting service of choice or run `npx serve out` to view the website locally.
## 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)

View File

@ -1,45 +1,27 @@
import Link from '@/components/Link';
import { PageSEO } from '@/components/SEO';
import Tag from '@/components/Tag';
import siteMetadata from '@/data/siteMetadata';
import { getAllFilesFrontMatter } from '@/lib/mdx';
import formatDate from '@/lib/utils/formatDate';
import { GetStaticProps, InferGetStaticPropsType } from 'next';
import { PostFrontMatter } from 'types/PostFrontMatter';
import NewsletterForm from '@/components/NewsletterForm';
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;
const MAX_DISPLAY = 5
export const getStaticProps: GetStaticProps<{
posts: PostFrontMatter[];
}> = async () => {
const posts = await getAllFilesFrontMatter('blog');
return { props: { posts } };
};
export default function Home({
posts,
}: InferGetStaticPropsType<typeof getStaticProps>) {
export default function Home({ posts }) {
return (
<>
<PageSEO
title={siteMetadata.title}
description={siteMetadata.description}
/>
<div className="divide-y divide-gray-200 dark:divide-gray-700">
<div className="space-y-2 pt-6 pb-8 md:space-y-5">
<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 && '没有找到文章。 😭'}
{posts.slice(0, MAX_DISPLAY).map((frontMatter) => {
const { slug, date, title, summary, tags } = frontMatter;
{!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>
@ -47,7 +29,7 @@ export default function Home({
<dl>
<dt className="sr-only">Published on</dt>
<dd className="text-base font-medium leading-6 text-gray-500 dark:text-gray-400">
<time dateTime={date}>{formatDate(date)}</time>
<time dateTime={date}>{formatDate(date, siteMetadata.locale)}</time>
</dd>
</dl>
<div className="space-y-5 xl:col-span-3">
@ -56,7 +38,8 @@ export default function Home({
<h2 className="text-2xl font-bold leading-8 tracking-tight">
<Link
href={`/blog/${slug}`}
className="text-gray-900 dark:text-gray-100">
className="text-gray-900 dark:text-gray-100"
>
{title}
</Link>
</h2>
@ -74,7 +57,8 @@ export default function Home({
<Link
href={`/blog/${slug}`}
className="text-primary-500 hover:text-primary-600 dark:hover:text-primary-400"
aria-label={`Read "${title}"`}>
aria-label={`Read "${title}"`}
>
Read more &rarr;
</Link>
</div>
@ -82,7 +66,7 @@ export default function Home({
</div>
</article>
</li>
);
)
})}
</ul>
</div>
@ -91,16 +75,17 @@ export default function Home({
<Link
href="/blog"
className="text-primary-500 hover:text-primary-600 dark:hover:text-primary-400"
aria-label="all posts">
aria-label="All posts"
>
All Posts &rarr;
</Link>
</div>
)}
{siteMetadata.newsletter.provider !== '' && (
{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 }

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

@ -0,0 +1,130 @@
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 } 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'
const isProduction = process.env.NODE_ENV === 'production'
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 () => {
const paths = allBlogs.map((p) => ({ slug: p.slug.split('/') }))
return paths
}
export default async function Page({ params }: { params: { slug: string[] } }) {
const slug = decodeURI(params.slug.join('/'))
const sortedPosts = sortPosts(allBlogs) as Blog[]
const postIndex = sortedPosts.findIndex((p) => p.slug === slug)
const prev = coreContent(sortedPosts[postIndex + 1])
const next = coreContent(sortedPosts[postIndex - 1])
const post = sortedPosts.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 (
<>
{isProduction && post && 'draft' in post && post.draft === true ? (
<div className="mt-24 text-center">
<PageTitle>
Under Construction{' '}
<span role="img" aria-label="roadwork sign">
🚧
</span>
</PageTitle>
</div>
) : (
<>
<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"
/>
)
}

16
app/head.tsx Normal file
View File

@ -0,0 +1,16 @@
export default function Head() {
return (
<>
<link rel="apple-touch-icon" sizes="76x76" href="/static/favicons/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicons/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/static/favicons/favicon-16x16.png" />
<link rel="manifest" href="/static/favicons/site.webmanifest" />
<link rel="mask-icon" href="/static/favicons/safari-pinned-tab.svg" color="#5bbad5" />
<meta name="msapplication-TileColor" content="#000000" />
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#fff" />
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#000" />
<link rel="alternate" type="application/rss+xml" href="/feed.xml" />
<meta content="width=device-width, initial-scale=1" name="viewport" />
</>
)
}

92
app/layout.tsx Normal file
View File

@ -0,0 +1,92 @@
import 'css/tailwind.css'
import 'pliny/search/algolia.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 }) {
return (
<html
lang={siteMetadata.language}
className={`${space_grotesk.variable} scroll-smooth`}
suppressHydrationWarning
>
<link rel="apple-touch-icon" sizes="76x76" href="/static/favicons/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicons/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/static/favicons/favicon-16x16.png" />
<link rel="manifest" href="/static/favicons/site.webmanifest" />
<link rel="mask-icon" href="/static/favicons/safari-pinned-tab.svg" color="#5bbad5" />
<meta name="msapplication-TileColor" content="#000000" />
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#fff" />
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#000" />
<link rel="alternate" type="application/rss+xml" href="/feed.xml" />
<body className="bg-white text-black antialiased dark:bg-gray-950 dark:text-white">
<ThemeProviders>
<Analytics analyticsConfig={siteMetadata.analytics as AnalyticsConfig} />
<SectionContainer>
<div className="flex h-screen flex-col justify-between font-sans">
<SearchProvider searchConfig={siteMetadata.search as SearchConfig}>
<Header />
<main className="mb-auto">{children}</main>
</SearchProvider>
<Footer />
</div>
</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} />
}

View File

@ -1,22 +1,19 @@
import siteMetadata from '@/data/siteMetadata';
import projectsData from '@/data/projectsData';
import Card from '@/components/Card';
import { PageSEO } from '@/components/SEO';
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 (
<>
<PageSEO
title={`Projects - ${siteMetadata.author}`}
description={siteMetadata.description}
/>
<div className="divide-y divide-gray-200 dark:divide-gray-700">
<div className="space-y-2 pt-6 pb-8 md:space-y-5">
<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">
@ -34,5 +31,5 @@ export default function Projects() {
</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,
}
}

31
app/seo.tsx Normal file
View File

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

18
app/sitemap.ts Normal file
View File

@ -0,0 +1,18 @@
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.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 @@
{"arch-linux":3,"环境搭建":3,"vps":3,"linux":1,"pve":2,"xray":2,"acme":1,"acmesh":1,"docker":3,"docker-compose":1,"内网穿透":1,"github-actions":1,"cicd":1,"de":1,"debian":1,"blog":1,"markdown":1,"nextjs":1,"tailwind-css":1,"verdaccio":1,"self-hosted":3,"caddy":2,"registry":1,"nodejs":1,"zerotier":1,"tailscale":1,"sd-wan":1,"nat":1,"frp":1,"react":1,"baas":1,"appwrite":1,"nhost":1,"supabase":1,"sni":1,"tls":1,"reverse-proxy":1,"反向代理":1,"vless":1}

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

@ -0,0 +1,43 @@
import { slug } from 'github-slugger'
import { allCoreContent } 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'
export async function generateMetadata({ params }: { params: { tag: string } }): Promise<Metadata> {
const tag = 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: tag,
}))
return paths
}
export default function TagPage({ params }: { params: { tag: string } }) {
const { tag } = params
// Capitalize first letter and convert space to dash
const title = tag[0].toUpperCase() + tag.split(' ').join('-').slice(1)
const filteredPosts = allCoreContent(
allBlogs.filter(
(post) => post.draft !== true && post.tags && post.tags.map((t) => slug(t)).includes(tag)
)
)
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>
)
}

View File

@ -1,12 +1,13 @@
import Image from './Image';
import Link from './Link';
import Image from './Image'
import Link from './Link'
const Card = ({ title, description, imgSrc, href }) => (
<div className="md p-4 md:w-1/2" style={{ maxWidth: '544px' }}>
<div className="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`}>
} 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}`}>
@ -37,20 +38,19 @@ const Card = ({ title, description, imgSrc, href }) => (
title
)}
</h2>
<p className="prose mb-3 max-w-none text-gray-500 dark:text-gray-400">
{description}
</p>
<p className="prose mb-3 max-w-none text-gray-500 dark:text-gray-400">{description}</p>
{href && (
<Link
href={href}
className="text-base font-medium leading-6 text-primary-500 hover:text-primary-600 dark:hover:text-primary-400"
aria-label={`Link to ${title}`}>
aria-label={`Link to ${title}`}
>
Learn more &rarr;
</Link>
)}
</div>
</div>
</div>
);
)
export default Card;
export default Card

View File

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

17
components/Comments.tsx Normal file
View File

@ -0,0 +1,17 @@
'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)
return (
<>
{!loadComments && <button onClick={() => setLoadComments(true)}>Load Comments</button>}
{siteMetadata.comments && loadComments && (
<CommentsComponent commentsConfig={siteMetadata.comments} slug={slug} />
)}
</>
)
}

View File

@ -1,17 +1,13 @@
import Link from './Link';
import siteMetadata from '@/data/siteMetadata';
import SocialIcon from '@/components/social-icons';
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="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} />
@ -31,13 +27,11 @@ export default function Footer() {
</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"
rel="nofollow">
<Link href="https://github.com/timlrx/tailwind-nextjs-starter-blog">
Tailwind Nextjs Theme
</Link>
</div>
</div>
</footer>
);
)
}

48
components/Header.tsx Normal file
View File

@ -0,0 +1,48 @@
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 = () => {
return (
<header className="flex items-center justify-between py-10">
<div>
<Link href="/" aria-label={siteMetadata.headerTitle}>
<div className="flex items-center justify-between">
<div className="mr-3">
<Logo />
</div>
{typeof siteMetadata.headerTitle === 'string' ? (
<div className="hidden h-6 text-2xl font-semibold sm:block">
{siteMetadata.headerTitle}
</div>
) : (
siteMetadata.headerTitle
)}
</div>
</Link>
</div>
<div className="flex items-center leading-5 space-x-4 sm:space-x-6">
{headerNavLinks
.filter((link) => link.href !== '/')
.map((link) => (
<Link
key={link.title}
href={link.href}
className="hidden sm:block font-medium text-gray-900 dark:text-gray-100"
>
{link.title}
</Link>
))}
<SearchButton />
<ThemeSwitch />
<MobileNav />
</div>
</header>
)
}
export default Header

View File

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

View File

@ -1,58 +1,27 @@
import siteMetadata from '@/data/siteMetadata';
import headerNavLinks from '@/data/headerNavLinks';
import Logo from '@/data/logo.svg';
import Link from './Link';
import SectionContainer from './SectionContainer';
import Footer from './Footer';
import MobileNav from './MobileNav';
import ThemeSwitch from './ThemeSwitch';
import { ReactNode } from 'react';
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;
children: ReactNode
}
const inter = Inter({
subsets: ['latin'],
})
const LayoutWrapper = ({ children }: Props) => {
return (
<SectionContainer>
<div className="flex h-screen flex-col justify-between">
<header className="flex items-center justify-between py-10">
<div>
<Link href="/" aria-label={siteMetadata.headerTitle}>
<div className="flex items-center justify-between">
<div className="mr-3">
<Logo />
</div>
{typeof siteMetadata.headerTitle === 'string' ? (
<div className="hidden h-6 text-2xl font-semibold sm:block">
{siteMetadata.headerTitle}
</div>
) : (
siteMetadata.headerTitle
)}
</div>
</Link>
</div>
<div className="flex items-center text-base leading-5">
<div className="hidden sm:block">
{headerNavLinks.map((link) => (
<Link
key={link.title}
href={link.href}
className="p-1 font-medium text-gray-900 dark:text-gray-100 sm:p-4">
{link.title}
</Link>
))}
</div>
<ThemeSwitch />
<MobileNav />
</div>
</header>
<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;
export default LayoutWrapper

View File

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

View File

@ -1,39 +1,14 @@
/* eslint-disable react/display-name */
import React, { useMemo } from 'react';
import { ComponentMap, getMDXComponent } from 'mdx-bundler/client';
import Image from './Image';
import CustomLink from './Link';
import TOCInline from './TOCInline';
import Pre from './Pre';
import { BlogNewsletterForm } from './NewsletterForm';
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'
const Wrapper: React.ComponentType<{ layout: string }> = ({
layout,
...rest
}) => {
const Layout = require(`../layouts/${layout}`).default;
return <Layout {...rest} />;
};
export const MDXComponents: ComponentMap = {
export const components: MDXComponents = {
Image,
//@ts-ignore
TOCInline,
a: CustomLink,
pre: Pre,
wrapper: Wrapper,
//@ts-ignore
BlogNewsletterForm,
};
interface Props {
layout: string;
mdxSource: string;
[key: string]: unknown;
}
export const MDXLayoutRenderer = ({ layout, mdxSource, ...rest }: Props) => {
const MDXLayout = useMemo(() => getMDXComponent(mdxSource), [mdxSource]);
return <MDXLayout layout={layout} components={MDXComponents} {...rest} />;
};

View File

@ -1,73 +1,77 @@
import { useState } from 'react';
import Link from './Link';
import headerNavLinks from '@/data/headerNavLinks';
'use client'
import { useState } from 'react'
import Link from './Link'
import headerNavLinks from '@/data/headerNavLinks'
const MobileNav = () => {
const [navShow, setNavShow] = useState(false);
const [navShow, setNavShow] = useState(false)
const onToggleNav = () => {
setNavShow((status) => {
if (status) {
document.body.style.overflow = 'auto';
document.body.style.overflow = 'auto'
} else {
// Prevent scrolling
document.body.style.overflow = 'hidden';
document.body.style.overflow = 'hidden'
}
return !status;
});
};
return !status
})
}
return (
<div className="sm:hidden">
<button
type="button"
className="ml-1 mr-1 h-8 w-8 rounded py-1"
aria-label="Toggle Menu"
onClick={onToggleNav}>
<>
<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="text-gray-900 dark:text-gray-100">
{navShow ? (
<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"
/>
) : (
<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"
/>
)}
className="text-gray-900 dark:text-gray-100 h-8 w-8"
>
<path
fillRule="evenodd"
d="M3 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 15a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z"
clipRule="evenodd"
/>
</svg>
</button>
<div
className={`fixed top-24 right-0 z-10 h-full w-full transform bg-gray-200 opacity-95 duration-300 ease-in-out dark:bg-gray-800 ${
className={`fixed left-0 top-0 z-10 h-full w-full transform opacity-95 dark:opacity-[0.98] bg-white duration-300 ease-in-out dark:bg-gray-950 ${
navShow ? 'translate-x-0' : 'translate-x-full'
}`}>
<button
type="button"
aria-label="toggle modal"
className="fixed h-full w-full cursor-auto focus:outline-none"
onClick={onToggleNav}></button>
}`}
>
<div className="flex justify-end">
<button className="mr-8 mt-11 h-8 w-8" aria-label="Toggle Menu" onClick={onToggleNav}>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
className="text-gray-900 dark:text-gray-100"
>
<path
fillRule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
</button>
</div>
<nav className="fixed mt-8 h-full">
{headerNavLinks.map((link) => (
<div key={link.title} className="px-12 py-4">
<Link
href={link.href}
className="text-2xl font-bold tracking-widest text-gray-900 dark:text-gray-100"
onClick={onToggleNav}>
onClick={onToggleNav}
>
{link.title}
</Link>
</div>
))}
</nav>
</div>
</div>
);
};
</>
)
}
export default MobileNav;
export default MobileNav

View File

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

View File

@ -1,7 +1,7 @@
import { ReactNode } from 'react';
import { ReactNode } from 'react'
interface Props {
children: ReactNode;
children: ReactNode
}
export default function PageTitle({ children }: Props) {
@ -9,5 +9,5 @@ export default function PageTitle({ children }: Props) {
<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

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

View File

@ -1,77 +0,0 @@
import { useState, useRef, ReactNode } from 'react';
interface Props {
children: ReactNode;
}
const Pre = ({ children }: Props) => {
const textInput = useRef(null);
const [hovered, setHovered] = useState(false);
const [copied, setCopied] = useState(false);
const onEnter = () => {
setHovered(true);
};
const onExit = () => {
setHovered(false);
setCopied(false);
};
const onCopy = () => {
setCopied(true);
navigator.clipboard.writeText(textInput.current.textContent);
setTimeout(() => {
setCopied(false);
}, 2000);
};
return (
<div
ref={textInput}
onMouseEnter={onEnter}
onMouseLeave={onExit}
className="relative">
{hovered && (
<button
aria-label="Copy code"
type="button"
className={`absolute right-2 top-2 h-8 w-8 rounded border-2 bg-gray-700 p-1 dark:bg-gray-800 ${
copied
? 'border-green-400 focus:border-green-400 focus:outline-none'
: 'border-gray-300'
}`}
onClick={onCopy}>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
stroke="currentColor"
fill="none"
className={copied ? 'text-green-400' : 'text-gray-300'}>
{copied ? (
<>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"
/>
</>
) : (
<>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
/>
</>
)}
</svg>
</button>
)}
<pre>{children}</pre>
</div>
);
};
export default Pre;

View File

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

View File

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

View File

@ -0,0 +1,34 @@
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="text-gray-900 dark:text-gray-100 h-6 w-6"
>
<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

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

View File

@ -1,74 +0,0 @@
import { Toc } from 'types/Toc';
interface TOCInlineProps {
toc: Toc;
indentDepth?: number;
fromHeading?: number;
toHeading?: number;
asDisclosure?: boolean;
exclude?: string | string[];
}
/**
* Generates an inline table of contents
* Exclude titles matching this string (new RegExp('^(' + string + ')$', 'i')).
* If an array is passed the array gets joined with a pipe (new RegExp('^(' + array.join('|') + ')$', 'i')).
*
* @param {TOCInlineProps} {
* toc,
* indentDepth = 3,
* fromHeading = 1,
* toHeading = 6,
* asDisclosure = false,
* exclude = '',
* }
*
*/
const TOCInline = ({
toc,
indentDepth = 3,
fromHeading = 1,
toHeading = 6,
asDisclosure = false,
exclude = '',
}: TOCInlineProps) => {
const re = Array.isArray(exclude)
? new RegExp('^(' + exclude.join('|') + ')$', 'i')
: new RegExp('^(' + exclude + ')$', 'i');
const filteredToc = toc.filter(
(heading) =>
heading.depth >= fromHeading &&
heading.depth <= toHeading &&
!re.test(heading.value)
);
const tocList = (
<ul>
{filteredToc.map((heading) => (
<li
key={heading.value}
className={`${heading.depth >= indentDepth && 'ml-6'}`}>
<a href={heading.url}>{heading.value}</a>
</li>
))}
</ul>
);
return (
<>
{asDisclosure ? (
<details open>
<summary className="ml-6 pt-2 pb-2 text-xl font-bold">
Table of Contents
</summary>
<div className="ml-6">{tocList}</div>
</details>
) : (
tocList
)}
</>
);
};
export default TOCInline;

View File

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

View File

@ -1,29 +1,31 @@
import { useEffect, useState } from 'react';
import { useTheme } from 'next-themes';
'use client'
import { useEffect, useState } from 'react'
import { useTheme } from 'next-themes'
const ThemeSwitch = () => {
const [mounted, setMounted] = useState(false);
const { theme, setTheme, resolvedTheme } = useTheme();
const [mounted, setMounted] = useState(false)
const { theme, setTheme } = useTheme()
// When mounted on client, now we can show the UI
useEffect(() => setMounted(true), []);
useEffect(() => setMounted(true), [])
if (!mounted) {
return null
}
return (
<button
aria-label="Toggle Dark Mode"
type="button"
className="ml-1 mr-1 h-8 w-8 rounded p-1 sm:ml-4"
onClick={() =>
setTheme(
theme === 'dark' || resolvedTheme === 'dark' ? 'light' : 'dark'
)
}>
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
className="text-gray-900 dark:text-gray-100">
{mounted && (theme === 'dark' || resolvedTheme === 'dark') ? (
className="text-gray-900 dark:text-gray-100 h-6 w-6"
>
{mounted && theme === 'dark' ? (
<path
fillRule="evenodd"
d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z"
@ -34,7 +36,7 @@ const ThemeSwitch = () => {
)}
</svg>
</button>
);
};
)
}
export default ThemeSwitch;
export default ThemeSwitch

View File

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

View File

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

View File

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

View File

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

View File

@ -1,32 +0,0 @@
import GA from './GoogleAnalytics';
import Plausible from './Plausible';
import SimpleAnalytics from './SimpleAnalytics';
import Umami from './Umami';
import siteMetadata from '@/data/siteMetadata';
declare global {
interface Window {
gtag?: (...args: any[]) => void;
plausible?: (...args: any[]) => void;
sa_event?: (...args: any[]) => void;
}
}
const isProduction = process.env.NODE_ENV === 'production';
const Analytics = () => {
return (
<>
{isProduction && siteMetadata.analytics.plausibleDataDomain && (
<Plausible />
)}
{isProduction && siteMetadata.analytics.simpleAnalytics && (
<SimpleAnalytics />
)}
{isProduction && siteMetadata.analytics.umamiWebsiteId && <Umami />}
{isProduction && siteMetadata.analytics.googleAnalyticsId && <GA />}
</>
);
};
export default Analytics;

View File

@ -1,33 +0,0 @@
import React, { useMemo, useState } from 'react';
import siteMetadata from '@/data/siteMetadata';
import { PostFrontMatter } from 'types/PostFrontMatter';
import { useTheme } from 'next-themes';
import ReactCommento from './commento/ReactCommento';
interface Props {
frontMatter: PostFrontMatter;
}
const Commento = ({ frontMatter }: Props) => {
const { resolvedTheme } = useTheme();
const commentsTheme = useMemo(() => {
switch (resolvedTheme) {
case 'light':
case 'dark':
return resolvedTheme;
default:
return 'auto';
}
}, [resolvedTheme]);
return (
<div className="my-2">
<ReactCommento
url={siteMetadata.comment.commentoConfig.url}
pageId={frontMatter.slug}
/>
</div>
);
};
export default Commento;

View File

@ -1,51 +0,0 @@
import React, { useState } from 'react';
import siteMetadata from '@/data/siteMetadata';
import { PostFrontMatter } from 'types/PostFrontMatter';
interface Props {
frontMatter: PostFrontMatter;
}
const Disqus = ({ frontMatter }: Props) => {
const [enableLoadComments, setEnabledLoadComments] = useState(true);
const COMMENTS_ID = 'disqus_thread';
function LoadComments() {
setEnabledLoadComments(false);
// @ts-ignore
window.disqus_config = function () {
this.page.url = window.location.href;
this.page.identifier = frontMatter.slug;
};
// @ts-ignore
if (window.DISQUS === undefined) {
const script = document.createElement('script');
script.src =
'https://' +
siteMetadata.comment.disqusConfig.shortname +
'.disqus.com/embed.js';
// @ts-ignore
script.setAttribute('data-timestamp', +new Date());
script.setAttribute('crossorigin', 'anonymous');
script.async = true;
document.body.appendChild(script);
} else {
// @ts-ignore
window.DISQUS.reset({ reload: true });
}
}
return (
<div className="pt-6 pb-6 text-center text-gray-700 dark:text-gray-300">
{enableLoadComments && (
<button onClick={LoadComments}>Load Comments</button>
)}
<div className="disqus-frame" id={COMMENTS_ID} />
</div>
);
};
export default Disqus;

View File

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

View File

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

View File

@ -1,93 +0,0 @@
import { createRef } from 'preact';
import React, { useLayoutEffect, useMemo, useRef } from 'react';
interface DataAttributes {
[key: string]: string | boolean | undefined;
}
const insertScript = (
src: string,
id: string,
dataAttributes: DataAttributes,
onload = () => {}
) => {
const script = window.document.createElement('script');
script.async = true;
script.src = src;
script.id = id;
if (document.getElementById(id)) {
return;
}
script.addEventListener('load', onload, { capture: true, once: true });
Object.entries(dataAttributes).forEach(([key, value]) => {
if (value === undefined) {
return;
}
script.setAttribute(`data-${key}`, value.toString());
});
document.body.appendChild(script);
return () => {
script.remove();
};
};
const ReactCommento = ({
url,
cssOverride,
autoInit,
noFonts,
hideDeleted,
pageId,
}: {
url: string;
cssOverride?: string;
autoInit?: boolean;
noFonts?: boolean;
hideDeleted?: boolean;
pageId?: string;
}) => {
const containerId = useMemo(
() => `commento-${Math.random().toString().slice(2, 8)}`,
[]
);
const container = createRef<HTMLDivElement>();
useLayoutEffect(() => {
if (!window) {
return;
}
window['commento'] = container.current;
const removeScript = insertScript(
url,
`${containerId}-script`,
{
'css-override': cssOverride,
'auto-init': autoInit,
'no-fonts': noFonts,
'hide-deleted': hideDeleted,
'page-id': pageId,
'id-root': containerId,
},
() => {
removeScript();
}
);
}, [
autoInit,
cssOverride,
hideDeleted,
noFonts,
pageId,
url,
containerId,
container,
]);
return <div ref={container} id={containerId} />;
};
export default ReactCommento;

View File

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

View File

@ -1 +0,0 @@
<svg role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>Facebook icon</title><path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/></svg>

Before

Width:  |  Height:  |  Size: 403 B

View File

@ -1 +0,0 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>GitHub icon</title><path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/></svg>

Before

Width:  |  Height:  |  Size: 827 B

View File

@ -0,0 +1,61 @@
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}>
<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}>
<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}>
<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}>
<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}>
<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 Youtube(svgProps: SVGProps<SVGSVGElement>) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" {...svgProps}>
<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}>
<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>
)
}

View File

@ -1,11 +1,4 @@
import Mail from './mail.svg';
import Github from './github.svg';
import Facebook from './facebook.svg';
import Youtube from './youtube.svg';
import Linkedin from './linkedin.svg';
import Twitter from './twitter.svg';
// Icons taken from: https://simpleicons.org/
import { Mail, Github, Facebook, Youtube, Linkedin, Twitter, Mastodon } from './icons'
const components = {
mail: Mail,
@ -14,30 +7,34 @@ const components = {
youtube: Youtube,
linkedin: Linkedin,
twitter: Twitter,
};
mastodon: Mastodon,
}
const SocialIcon = ({ kind, href, size = 8 }) => {
if (
!href ||
(kind === 'mail' &&
!/^mailto:\w+([.-]?\w+)@\w+([.-]?\w+)(.\w{2,3})+$/.test(href))
)
return null;
type SocialIconProps = {
kind: keyof typeof components
href: string | undefined
size?: number
}
const SocialSvg = components[kind];
const SocialIcon = ({ kind, href, size = 8 }: SocialIconProps) => {
if (!href || (kind === 'mail' && !/^mailto:\w+([.-]?\w+)@\w+([.-]?\w+)(.\w{2,3})+$/.test(href)))
return null
const SocialSvg = components[kind]
return (
<a
className="text-sm text-gray-500 transition hover:text-gray-600"
target="_blank"
rel="noopener noreferrer"
href={href}>
href={href}
>
<span className="sr-only">{kind}</span>
<SocialSvg
className={`fill-current text-gray-700 hover:text-blue-500 dark:text-gray-200 dark:hover:text-blue-400 h-${size} w-${size}`}
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;
export default SocialIcon

View File

@ -1 +0,0 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>LinkedIn icon</title><path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/></svg>

Before

Width:  |  Height:  |  Size: 615 B

View File

@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<path d="M2.003 5.884L10 9.882l7.997-3.998A2 2 0 0016 4H4a2 2 0 00-1.997 1.884z" />
<path d="M18 8.118l-8 4-8-4V14a2 2 0 002 2h12a2 2 0 002-2V8.118z" />
</svg>

Before

Width:  |  Height:  |  Size: 224 B

View File

@ -1 +0,0 @@
<svg role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>Twitter icon</title><path d="M23.953 4.57a10 10 0 01-2.825.775 4.958 4.958 0 002.163-2.723c-.951.555-2.005.959-3.127 1.184a4.92 4.92 0 00-8.384 4.482C7.69 8.095 4.067 6.13 1.64 3.162a4.822 4.822 0 00-.666 2.475c0 1.71.87 3.213 2.188 4.096a4.904 4.904 0 01-2.228-.616v.06a4.923 4.923 0 003.946 4.827 4.996 4.996 0 01-2.212.085 4.936 4.936 0 004.604 3.417 9.867 9.867 0 01-6.102 2.105c-.39 0-.779-.023-1.17-.067a13.995 13.995 0 007.557 2.209c9.053 0 13.998-7.496 13.998-13.985 0-.21 0-.42-.015-.63A9.935 9.935 0 0024 4.59z"/></svg>

Before

Width:  |  Height:  |  Size: 607 B

View File

@ -1 +0,0 @@
<svg role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>YouTube icon</title><path d="M23.499 6.203a3.008 3.008 0 00-2.089-2.089c-1.87-.501-9.4-.501-9.4-.501s-7.509-.01-9.399.501a3.008 3.008 0 00-2.088 2.09A31.258 31.26 0 000 12.01a31.258 31.26 0 00.523 5.785 3.008 3.008 0 002.088 2.089c1.869.502 9.4.502 9.4.502s7.508 0 9.399-.502a3.008 3.008 0 002.089-2.09 31.258 31.26 0 00.5-5.784 31.258 31.26 0 00-.5-5.808zm-13.891 9.4V8.407l6.266 3.604z"/></svg>

Before

Width:  |  Height:  |  Size: 474 B

157
contentlayer.config.ts Normal file
View File

@ -0,0 +1,157 @@
import { defineDocumentType, ComputedFields, makeSource } from 'contentlayer/source-files'
import { writeFileSync } from 'fs'
import readingTime from 'reading-time'
import GithubSlugger from 'github-slugger'
import path from 'path'
// Remark packages
import remarkGfm from 'remark-gfm'
import remarkMath from 'remark-math'
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 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: 'string', 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 && file.draft !== true) {
file.tags.forEach((tag) => {
const formattedTag = GithubSlugger.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/${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: 'list', of: { type: 'string' } },
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}`,
author: doc.authors,
}),
},
},
}))
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,
],
rehypePlugins: [
rehypeSlug,
rehypeAutolinkHeadings,
rehypeKatex,
[rehypeCitation, { path: path.join(root, 'data') }],
[rehypePrismPlus, { defaultLanguage: 'js', ignoreMissing: true }],
rehypePresetMinify,
],
},
onSuccess: async (importData) => {
const { allBlogs } = await importData()
createTagCount(allBlogs)
createSearchIndex(allBlogs)
},
})

View File

@ -7,7 +7,7 @@
/* 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;
@apply rounded-t bg-gray-700 dark:bg-gray-800 px-5 py-3 font-mono text-sm font-bold text-gray-200;
}
.remark-code-title + div > pre {
@ -138,3 +138,7 @@
.token.table {
display: inline;
}
.token.table {
display: inline;
}

View File

@ -11,7 +11,11 @@
}
.footnotes {
@apply pt-8 mt-12 border-t border-gray-200 dark:border-gray-700;
@apply mt-12 border-t border-gray-200 pt-8 dark:border-gray-700;
}
.data-footnote-backref {
@apply no-underline;
}
.csl-entry {
@ -21,5 +25,7 @@
/* 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;
transition:
background-color 600000s 0s,
color 600000s 0s;
}

View File

@ -1,7 +1,7 @@
---
title: 在 PVE 宿主机上使用桌面环境
date: '2022-10-28'
tags: ['PVE', 'PVE', 'DE', '环境搭建', 'Debian']
tags: ['PVE', 'DE', '环境搭建', 'Debian']
draft: false
summary: 虽然 PVE 宿主机不应该安装乱七八糟的东西,但是我穷,为了物尽其用,为了在主力电脑翻车时有一个立即可用的备用环境,所以还是安装了基础的桌面环境。现在的 Linux 桌面环境越来越好了,我选择安装 KDE Plasma 作为桌面环境,并且默认关闭,按需启用。
images: ['https://minio.ivanli.cc/ivan-public/uPic/2023/qldEtP.png']

View File

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

View File

@ -1,47 +1,36 @@
import SocialIcon from '@/components/social-icons';
import Image from '@/components/Image';
import { PageSEO } from '@/components/SEO';
import { ReactNode } from 'react';
import { AuthorFrontMatter } from 'types/AuthorFrontMatter';
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;
frontMatter: AuthorFrontMatter;
children: ReactNode
content: Omit<Authors, '_id' | '_raw' | 'body'>
}
export default function AuthorLayout({ children, frontMatter }: Props) {
const {
name,
avatar,
occupation,
company,
email,
twitter,
linkedin,
github,
} = frontMatter;
export default function AuthorLayout({ children, content }: Props) {
const { name, avatar, occupation, company, email, twitter, linkedin, github } = content
return (
<>
<PageSEO title={`About - ${name}`} description={`About me - ${name}`} />
<div className="divide-y divide-gray-200 dark:divide-gray-700">
<div className="space-y-2 pt-6 pb-8 md:space-y-5">
<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 pt-8">
<Image
src={avatar}
alt="avatar"
width={192}
height={192}
className="h-48 w-48 rounded-full"
/>
<h3 className="pt-4 pb-2 text-2xl font-bold leading-8 tracking-tight">
{name}
</h3>
<div className="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">
@ -51,11 +40,11 @@ export default function AuthorLayout({ children, frontMatter }: Props) {
<SocialIcon kind="twitter" href={twitter} />
</div>
</div>
<div className="prose max-w-none pt-8 pb-8 dark:prose-dark xl:col-span-2">
<div className="prose max-w-none pb-8 pt-8 dark:prose-invert xl:col-span-2">
{children}
</div>
</div>
</div>
</>
);
)
}

View File

@ -1,14 +1,63 @@
import Link from '@/components/Link';
import Tag from '@/components/Tag';
import { ComponentProps, useState } from 'react';
import Pagination from '@/components/Pagination';
import formatDate from '@/lib/utils/formatDate';
import { PostFrontMatter } from 'types/PostFrontMatter';
interface Props {
posts: PostFrontMatter[];
title: string;
initialDisplayPosts?: PostFrontMatter[];
pagination?: ComponentProps<typeof Pagination>;
'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({
@ -16,41 +65,42 @@ export default function ListLayout({
title,
initialDisplayPosts = [],
pagination,
}: Props) {
const [searchValue, setSearchValue] = useState('');
const filteredBlogPosts = posts.filter((frontMatter) => {
const searchContent =
frontMatter.title + frontMatter.summary + frontMatter.tags.join(' ');
return searchContent.toLowerCase().includes(searchValue.toLowerCase());
});
}: 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;
initialDisplayPosts.length > 0 && !searchValue ? initialDisplayPosts : filteredBlogPosts
return (
<>
<div className="divide-y divide-gray-200 dark:divide-gray-700">
<div className="space-y-2 pt-6 pb-8 md:space-y-5">
<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">
<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>
<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">
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
@ -62,30 +112,26 @@ export default function ListLayout({
</div>
<ul>
{!filteredBlogPosts.length && 'No posts found.'}
{displayPosts.map((frontMatter) => {
const { slug, date, title, summary, tags } = frontMatter;
{displayPosts.map((post) => {
const { path, date, title, summary, tags } = post
return (
<li key={slug} className="py-4">
<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)}</time>
<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={`/blog/${slug}`}
className="text-gray-900 dark:text-gray-100">
<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} />
))}
{tags?.map((tag) => <Tag key={tag} text={tag} />)}
</div>
</div>
<div className="prose max-w-none text-gray-500 dark:text-gray-400">
@ -94,16 +140,13 @@ export default function ListLayout({
</div>
</article>
</li>
);
)
})}
</ul>
</div>
{pagination && pagination.totalPages > 1 && !searchValue && (
<Pagination
currentPage={pagination.currentPage}
totalPages={pagination.totalPages}
/>
<Pagination currentPage={pagination.currentPage} totalPages={pagination.totalPages} />
)}
</>
);
)
}

View File

@ -0,0 +1,163 @@
/* 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="sm:hidden 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>
<div className="flex sm:space-x-24">
<div className="hidden max-h-screen h-full sm:flex flex-wrap bg-gray-50 dark:bg-gray-900/70 shadow-md pt-5 dark:shadow-gray-800/40 rounded min-w-[280px] max-w-[280px]">
<div className="py-4 px-6">
{pathname.startsWith('/blog') ? (
<h3 className="text-primary-500 font-bold uppercase">All Posts</h3>
) : (
<Link
href={`/blog`}
className="font-bold uppercase text-gray-700 dark:text-gray-300 hover:text-primary-500 dark:hover:text-primary-500"
>
All Posts
</Link>
)}
<ul>
{sortedTags.map((t) => {
return (
<li key={t} className="my-3">
{pathname.split('/tags/')[1] === slug(t) ? (
<h3 className="inline py-2 px-3 uppercase text-sm font-bold text-primary-500">
{`${t} (${tagCounts[t]})`}
</h3>
) : (
<Link
href={`/tags/${slug(t)}`}
className="py-2 px-3 uppercase text-sm font-medium text-gray-500 dark:text-gray-300 hover:text-primary-500 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="space-y-2 flex flex-col 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">
<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="aspect-[2/1] w-full relative">
<Image src={displayImage} alt={title} fill className="object-cover" />
</div>
</Bleed>
</div>
<div className="pt-10 relative">
<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>
)
}

View File

@ -1,27 +1,23 @@
import Link from '@/components/Link';
import PageTitle from '@/components/PageTitle';
import SectionContainer from '@/components/SectionContainer';
import { BlogSEO } from '@/components/SEO';
import Image from '@/components/Image';
import Tag from '@/components/Tag';
import siteMetadata from '@/data/siteMetadata';
import Comments from '@/components/comments';
import ScrollTopAndComment from '@/components/ScrollTopAndComment';
import { ReactNode, useMemo } from 'react';
import { PostFrontMatter } from 'types/PostFrontMatter';
import { AuthorFrontMatter } from 'types/AuthorFrontMatter';
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}/raw/branch/master/data/${path}`
const editUrl = (fileName) =>
`${siteMetadata.siteRepo}/raw/master/data/blog/${fileName}`;
const discussUrl = (slug) =>
`https://mobile.twitter.com/search?q=${encodeURIComponent(
`${siteMetadata.siteUrl}/blog/${slug}`
)}`;
const Copyright = () => (
<a
rel="license"
href="http://creativecommons.org/licenses/by-sa/4.0/"
className="inline-flex self-center">
className="inline-flex self-center"
>
<Image
className="border-0"
alt="知识共享许可协议"
@ -30,73 +26,40 @@ const Copyright = () => (
src="https://i.creativecommons.org/l/by-sa/4.0/80x15.png"
/>
</a>
);
)
const postDateTemplate: Intl.DateTimeFormatOptions = {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
};
interface Props {
frontMatter: PostFrontMatter;
authorDetails: AuthorFrontMatter[];
next?: { slug: string; title: string };
prev?: { slug: string; title: string };
children: ReactNode;
}
export default function PostLayout({
frontMatter,
authorDetails,
next,
prev,
children,
}: Props) {
const { slug, fileName, date, title, images, tags } = frontMatter;
interface LayoutProps {
content: CoreContent<Blog>
authorDetails: CoreContent<Authors>[]
next?: { path: string; title: string }
prev?: { path: string; title: string }
children: ReactNode
}
const headerStyles = useMemo(
() =>
images?.[0]
? {
backgroundImage: `url(${images[0]})`,
}
: {},
[images]
);
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>
<BlogSEO
url={`${siteMetadata.siteUrl}/blog/${slug}`}
authorDetails={authorDetails}
{...frontMatter}
/>
<ScrollTopAndComment />
<article>
<div className="xl:divide-y xl:divide-gray-200 xl:dark:divide-gray-700">
<header className="relative h-48 pt-6 xl:pb-6">
{images?.[0] && (
<Image
alt="background"
layout="fill"
objectFit="cover"
src={images[0]}
style={headerStyles}
className="blur-xs -z-10 opacity-50 bg-blend-soft-light"
/>
)}
<div className="space-y-5 text-center">
<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
)}
{new Date(date).toLocaleDateString(siteMetadata.locale, postDateTemplate)}
</time>
</dd>
</div>
@ -106,17 +69,13 @@ export default function PostLayout({
</div>
</div>
</header>
<div
className="divide-y divide-gray-200 pb-8 dark:divide-gray-700 xl:grid xl:grid-cols-4 xl:gap-x-6 xl:divide-y-0"
style={{ gridTemplateRows: 'auto 1fr' }}>
<dl className="pt-6 pb-10 xl:border-b xl:border-gray-200 xl:pt-11 xl:dark:border-gray-700">
<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 justify-center space-x-8 sm:space-x-12 xl:block xl:space-x-0 xl:space-y-8">
<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}>
<li className="flex items-center space-x-2" key={author.name}>
{author.avatar && (
<Image
src={author.avatar}
@ -128,19 +87,15 @@ export default function PostLayout({
)}
<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>
<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/',
'@'
)}
className="text-primary-500 hover:text-primary-600 dark:hover:text-primary-400"
>
{author.twitter.replace('https://twitter.com/', '@')}
</Link>
)}
</dd>
@ -151,14 +106,19 @@ export default function PostLayout({
</dd>
</dl>
<div className="divide-y divide-gray-200 dark:divide-gray-700 xl:col-span-3 xl:row-span-2 xl:pb-0">
<div className="prose max-w-none pt-10 pb-8 dark:prose-dark">
{children}
</div>
<div className="flex items-center gap-4 pt-6 pb-6 text-sm text-gray-700 dark:text-gray-300">
<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 flex items-center gap-4 ">
<Copyright />
<Link href={editUrl(fileName)}>{'View source'}</Link>
<Link href={editUrl(filePath)}>{'View source'}</Link>
</div>
<Comments frontMatter={frontMatter} />
{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">
@ -176,23 +136,23 @@ export default function PostLayout({
)}
{(next || prev) && (
<div className="flex justify-between py-4 xl:block xl:space-y-8 xl:py-8">
{prev && (
{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={`/blog/${prev.slug}`}>{prev.title}</Link>
<Link href={`/${prev.path}`}>{prev.title}</Link>
</div>
</div>
)}
{next && (
{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={`/blog/${next.slug}`}>{next.title}</Link>
<Link href={`/${next.path}`}>{next.title}</Link>
</div>
</div>
)}
@ -201,8 +161,10 @@ export default function PostLayout({
</div>
<div className="pt-4 xl:pt-8">
<Link
href="/blog"
className="text-primary-500 hover:text-primary-600 dark:hover:text-primary-400">
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>
@ -211,5 +173,5 @@ export default function PostLayout({
</div>
</article>
</SectionContainer>
);
)
}

View File

@ -1,32 +1,26 @@
import Link from '@/components/Link';
import PageTitle from '@/components/PageTitle';
import SectionContainer from '@/components/SectionContainer';
import { BlogSEO } from '@/components/SEO';
import siteMetadata from '@/data/siteMetadata';
import formatDate from '@/lib/utils/formatDate';
import Comments from '@/components/comments';
import ScrollTopAndComment from '@/components/ScrollTopAndComment';
import { ReactNode } from 'react';
import { PostFrontMatter } from 'types/PostFrontMatter';
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 Props {
frontMatter: PostFrontMatter;
children: ReactNode;
next?: { slug: string; title: string };
prev?: { slug: string; title: string };
interface LayoutProps {
content: CoreContent<Blog>
children: ReactNode
next?: { path: string; title: string }
prev?: { path: string; title: string }
}
export default function PostLayout({
frontMatter,
next,
prev,
children,
}: Props) {
const { slug, date, title } = frontMatter;
export default function PostLayout({ content, next, prev, children }: LayoutProps) {
const { path, slug, date, title } = content
return (
<SectionContainer>
<BlogSEO url={`${siteMetadata.siteUrl}/blog/${slug}`} {...frontMatter} />
<ScrollTopAndComment />
<article>
<div>
@ -36,7 +30,7 @@ export default function PostLayout({
<div>
<dt className="sr-only">Published on</dt>
<dd className="text-base font-medium leading-6 text-gray-500 dark:text-gray-400">
<time dateTime={date}>{formatDate(date)}</time>
<time dateTime={date}>{formatDate(date, siteMetadata.locale)}</time>
</dd>
</div>
</dl>
@ -45,31 +39,35 @@ export default function PostLayout({
</div>
</div>
</header>
<div
className="divide-y divide-gray-200 pb-8 dark:divide-gray-700 xl:divide-y-0 "
style={{ gridTemplateRows: 'auto 1fr' }}>
<div className="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 pt-10 pb-8 dark:prose-dark">
{children}
</div>
<div className="prose max-w-none pb-8 pt-10 dark:prose-invert">{children}</div>
</div>
<Comments frontMatter={frontMatter} />
{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 && prev.path && (
<div className="pt-4 xl:pt-8">
<Link
href={`/blog/${prev.slug}`}
className="text-primary-500 hover:text-primary-600 dark:hover:text-primary-400">
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 && next.path && (
<div className="pt-4 xl:pt-8">
<Link
href={`/blog/${next.slug}`}
className="text-primary-500 hover:text-primary-600 dark:hover:text-primary-400">
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>
@ -80,5 +78,5 @@ export default function PostLayout({
</div>
</article>
</SectionContainer>
);
)
}

View File

@ -1,37 +0,0 @@
import { escape } from '@/lib/utils/htmlEscaper';
import siteMetadata from '@/data/siteMetadata';
import { PostFrontMatter } from 'types/PostFrontMatter';
const generateRssItem = (post: PostFrontMatter) => `
<item>
<guid>${siteMetadata.siteUrl}/blog/${post.slug}</guid>
<title>${escape(post.title)}</title>
<link>${siteMetadata.siteUrl}/blog/${post.slug}</link>
${post.summary && `<description>${escape(post.summary)}</description>`}
<pubDate>${new Date(post.date).toUTCString()}</pubDate>
<author>${siteMetadata.email} (${siteMetadata.author})</author>
${post.tags && post.tags.map((t) => `<category>${t}</category>`).join('')}
</item>
`;
const generateRss = (posts: PostFrontMatter[], page = 'feed.xml') => `
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>${escape(siteMetadata.title)}</title>
<link>${siteMetadata.siteUrl}/blog</link>
<description>${escape(siteMetadata.description)}</description>
<language>${siteMetadata.language}</language>
<managingEditor>${siteMetadata.email} (${
siteMetadata.author
})</managingEditor>
<webMaster>${siteMetadata.email} (${siteMetadata.author})</webMaster>
<lastBuildDate>${new Date(posts[0].date).toUTCString()}</lastBuildDate>
<atom:link href="${
siteMetadata.siteUrl
}/${page}" rel="self" type="application/rss+xml"/>
${posts.map(generateRssItem).join('')}
</channel>
</rss>
`;
export default generateRss;

View File

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

View File

@ -1,39 +0,0 @@
import { Parent } from 'unist';
import { visit } from 'unist-util-visit';
export default function remarkCodeTitles() {
return (tree: Parent & { lang?: string }) =>
visit(
tree,
'code',
(node: Parent & { lang?: string }, index, parent: Parent) => {
const nodeLang = node.lang || '';
let language = '';
let title = '';
if (nodeLang.includes(':')) {
language = nodeLang.slice(0, nodeLang.search(':'));
title = nodeLang.slice(nodeLang.search(':') + 1, nodeLang.length);
}
if (!title) {
return;
}
const className = 'remark-code-title';
const titleNode = {
type: 'mdxJsxFlowElement',
name: 'div',
attributes: [
{ type: 'mdxJsxAttribute', name: 'className', value: className },
],
children: [{ type: 'text', value: title }],
data: { _xdmExplicitJsx: true },
};
parent.children.splice(index, 0, titleNode);
node.lang = language;
}
);
}

View File

@ -1,13 +0,0 @@
import { VFile } from 'vfile';
import { visit } from 'unist-util-visit';
import { load } from 'js-yaml';
import { Parent } from 'unist';
export default function extractFrontmatter() {
return (tree: Parent, file: VFile) => {
visit(tree, 'yaml', (node: Parent) => {
//@ts-ignore
file.data.frontmatter = load(node.value);
});
};
}

View File

@ -1,55 +0,0 @@
import { Literal, Parent, Node } from 'unist';
import { visit } from 'unist-util-visit';
import sizeOf from 'image-size';
import fs from 'fs';
type ImageNode = Parent & {
url: string;
alt: string;
name: string;
attributes: (Literal & { name: string })[];
};
export default function remarkImgToJsx() {
return (tree: Node) => {
visit(
tree,
// only visit p tags that contain an img element
(node: Parent): node is Parent =>
node.type === 'paragraph' &&
node.children.some((n) => n.type === 'image'),
(node: Parent) => {
const imageNode = node.children.find(
(n) => n.type === 'image'
) as ImageNode;
// only local files
if (fs.existsSync(`${process.cwd()}/public${imageNode.url}`)) {
const dimensions = sizeOf(`${process.cwd()}/public${imageNode.url}`);
// Convert original node to next/image
(imageNode.type = 'mdxJsxFlowElement'),
(imageNode.name = 'Image'),
(imageNode.attributes = [
{ type: 'mdxJsxAttribute', name: 'alt', value: imageNode.alt },
{ type: 'mdxJsxAttribute', name: 'src', value: imageNode.url },
{
type: 'mdxJsxAttribute',
name: 'width',
value: dimensions.width,
},
{
type: 'mdxJsxAttribute',
name: 'height',
value: dimensions.height,
},
]);
// Change node type from p to div to avoid nesting error
node.type = 'div';
node.children = [imageNode];
}
}
);
};
}

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