Compare commits

...

33 Commits

Author SHA1 Message Date
104fac9196 blog: 2023 年,我的 Mac OS 环境初始化。
All checks were successful
🚀 Build and deploy by ftp / 🎉 Deploy (push) Successful in 5m30s
2023-11-05 04:03:39 +00:00
b03230f3a6 blog: 2023 年,我的 Mac OS 环境初始化。
All checks were successful
🚀 Build and deploy by ftp / 🎉 Deploy (push) Successful in 5m18s
2023-11-05 03:37:06 +00:00
1f2a742467 blog: 更新内容:2023 年,再组一台黑苹果 ITX 主机。
All checks were successful
🚀 Build and deploy by ftp / 🎉 Deploy (push) Successful in 4m29s
2023-10-30 05:17:47 +00:00
5836fc2a02 blog: 2023 年,再组一台黑苹果 ITX 主机。
All checks were successful
🚀 Build and deploy by ftp / 🎉 Deploy (push) Successful in 5m50s
2023-10-19 17:26:46 +00:00
44174c5f36 blog: 使用导航网格实现寻路
All checks were successful
🚀 Build and deploy by ftp / 🎉 Deploy (push) Successful in 11m37s
2023-09-14 14:05:57 +00:00
f3a25f7a46 build(ci): 更换 FTP action.
All checks were successful
🚀 Build and deploy by ftp / 🎉 Deploy (push) Successful in 5m45s
2023-09-10 13:45:05 +00:00
7a9fe3fb2f build(ci): 修复构建问题。
Some checks failed
🚀 Build and deploy by ftp / 🎉 Deploy (push) Failing after 3m41s
2023-09-10 12:16:17 +00:00
ec61c5bb9f build(ci): 修复构建问题。
Some checks failed
🚀 Build and deploy by ftp / 🎉 Deploy (push) Failing after 3m19s
2023-09-10 11:58:12 +00:00
dc0969d175 build(ci): 修复构建问题。
Some checks failed
🚀 Build and deploy by ftp / 🎉 Deploy (push) Failing after 8m24s
2023-09-10 11:29:01 +00:00
87f9e54318 build(ci): 修复构建问题。
Some checks failed
🚀 Build and deploy by ftp / 🎉 Deploy (push) Failing after 5m58s
2023-09-10 09:56:05 +00:00
87d7f43afb build(ci): 修复构建问题。
Some checks failed
🚀 Build and deploy by ftp / 🎉 Deploy (push) Failing after 3m55s
2023-09-10 09:32:33 +00:00
423f908b83 build(ci): 改用 yarn 构建。
Some checks failed
🚀 Build and deploy by ftp / 🎉 Deploy (push) Failing after 3m48s
2023-09-10 09:15:41 +00:00
3932a2b612 style: 迁移到 v2.
Some checks failed
🚀 Build and deploy by ftp / 🎉 Deploy (push) Failing after 8m21s
2023-08-16 23:57:24 +08:00
de1da22508 feat: 更新博客框架到 v2。 (#3)
Some checks failed
🚀 Build and deploy by ftp / 🎉 Deploy (push) Failing after 10m33s
Co-authored-by: Ivan Li <ivanli2048@gmail.com>
Reviewed-on: #3
2023-08-16 23:29:22 +08:00
02ab7d11b2 blog: 隐藏 2022 总结。
All checks were successful
🚀 Build and deploy by ftp / 🎉 Deploy (push) Successful in 9m23s
2023-08-01 08:44:06 +00:00
0adaed6c97 blog: 使用 Github Action 为其他项目构建 Docker Image. 更新语法。
All checks were successful
🚀 Build and deploy by ftp / 🎉 Deploy (push) Successful in 2m27s
2023-07-09 16:55:41 +00:00
ff13b8b2b2 build: ftp path.
All checks were successful
🚀 Build and deploy by ftp / 🎉 Deploy (push) Successful in 3m40s
2023-07-09 15:04:42 +00:00
2b59af89cc build: ftp path.
Some checks failed
🚀 Build and deploy by ftp / 🎉 Deploy (push) Failing after 2m55s
2023-07-09 15:00:53 +00:00
348d18a348 build(ci): use node 18 and pnpm 8.
Some checks failed
🚀 Build and deploy by ftp / 🎉 Deploy (push) Failing after 2m23s
2023-07-09 14:43:15 +00:00
8dc5ffd39f chore: clean up.
Some checks failed
🚀 Build and deploy by ftp / 🎉 Deploy (push) Failing after 1m39s
2023-07-09 14:19:05 +00:00
00b8565dba fix: pnpm lock.
Some checks failed
🚀 Build and deploy by ftp / 🎉 Deploy (push) Failing after 1m21s
2023-07-09 14:05:53 +00:00
1e7cb5c942 build(ci): cache key hash.
Some checks failed
🚀 Build and deploy by ftp / 🎉 Deploy (push) Failing after 43s
2023-07-09 09:28:04 +00:00
dbb35eb462 build(ci): cache.
Some checks failed
🚀 Build and deploy by ftp / 🎉 Deploy (push) Failing after 34s
2023-07-09 08:54:13 +00:00
a0f5822bb8 build(ci): cache.
Some checks failed
🚀 Build and deploy by ftp / 🎉 Deploy (push) Failing after 27s
2023-07-09 08:52:20 +00:00
90a6a3d9d9 chore: update next.js and remove image optimize.
Some checks failed
🚀 Build and deploy by ftp / 🎉 Deploy (push) Failing after 17s
2023-07-09 08:43:43 +00:00
4f06f56754 build(ci): cache pnpm store. 2023-07-09 06:24:36 +00:00
1536ffa319 chore(deps): update deps.
Some checks failed
🚀 Build and deploy by ftp / 🎉 Deploy (push) Failing after 44s
2023-07-09 06:19:21 +00:00
24aadfa329 chore: replace image url of blogs. 2023-07-09 06:04:45 +00:00
bd4a211c6c fix(ci): typo.
Some checks failed
🚀 Build and deploy by ftp / 🎉 Deploy (push) Failing after 1m9s
2023-07-09 05:43:12 +00:00
e263be3fe9 chore: 细节调整。
Some checks failed
🚀 Build and deploy by ftp / 🎉 Deploy (push) Failing after 1s
2023-07-09 05:33:17 +00:00
fede1341b0 build(ci): ftp deploy by gitea actions.
Some checks failed
continuous-integration/drone/push Build is failing
2023-07-09 05:29:05 +00:00
e2af844823 blog: 使用 Github Action 为其他项目构建 Docker Image. typo
Some checks failed
continuous-integration/drone/push Build is failing
2023-07-08 21:34:08 +00:00
e470ac241e blog: 使用 Github Action 为其他项目构建 Docker Image
Some checks failed
continuous-integration/drone/push Build is failing
2023-07-08 21:27:43 +00:00
155 changed files with 8658 additions and 8108 deletions

View File

@ -8,7 +8,7 @@
// Append -bullseye or -buster to pin to an OS version.
// Use -bullseye variants on local on arm64/Apple Silicon.
"args": {
"VARIANT": "16-bullseye"
"VARIANT": "18-bullseye"
}
},
// Configure tool-specific properties.
@ -35,7 +35,10 @@
"esbenp.prettier-vscode",
"shardulm94.trailing-spaces",
"lihui.vs-color-picker",
"bradlc.vscode-tailwindcss"
"bradlc.vscode-tailwindcss",
"github.vscode-github-actions",
"unifiedjs.vscode-mdx",
"Codeium.codeium"
]
}
},
@ -51,5 +54,5 @@
"ghcr.io/devcontainers/features/docker-in-docker:2": {}
},
"mounts": [],
"postStartCommand": "pnpm install && npm run dev"
"postAttachCommand": "pnpm install && npm run dev"
}

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,32 +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_CUSDIS_APPID=
NEXT_PUBLIC_CUSDIS_HOST=
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
#

3
.github/FUNDING.yml vendored
View File

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

View File

@ -1,37 +0,0 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: bug
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**System Info (if dev / build issue):**
- OS: [e.g. iOS]
- Node version (please ensure you are using 14+)
- Npm version
**Browser Info (if display / formatting issue):**
- Device [e.g. Desktop, iPhone6]
- Browser [e.g. chrome, safari]
- Version [e.g. 22]
**Additional context**
Add any other context about the problem here.

View File

@ -1,20 +0,0 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: ''
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.

View File

@ -0,0 +1,33 @@
name: 🚀 Build and deploy by ftp
on:
push:
branches:
- master
- main
jobs:
ftp-build-and-deploy:
name: 🎉 Deploy
runs-on: ubuntu-latest
steps:
- name: 🚚 Get latest code
uses: https://github.com/actions/checkout@v3
- uses: https://github.com/actions/setup-node@v3
with:
node-version: 18
- name: Install dependencies
run: yarn install
- run: yarn build
- name: 📂 Sync files
uses: https://github.com/airvzxf/ftp-deployment-action@latest
with:
server: ${{ secrets.ftp_server }}
user: ${{ secrets.ftp_username }}
password: ${{ secrets.ftp_password }}
remote_dir: ./WEB/
local_dir: ./out/

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,6 +0,0 @@
module.exports = {
singleQuote: true,
trailingCommas: 'all',
bracketSpacing: true,
bracketSameLine: true,
};

16
.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,16 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "chrome",
"request": "launch",
"name": "Launch Chrome against localhost",
"url": "http://localhost:3000/",
"runtimeArgs": ["--disable-web-security", "--enable-precise-memory-info"],
"userDataDir": true
}
]
}

View File

@ -1,4 +1,6 @@
{
"typescript.tsdk": "node_modules/typescript/lib",
"typescript.enablePromptUseWorkspaceTsdk": true,
"cSpell.words": [
"alpn",
"appleboy",
@ -13,6 +15,7 @@
"EMAILOCTOPUS",
"fullchain",
"Giscus",
"Hackintosh",
"KLAVIYO",
"Kutt",
"lastmod",

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 { formatDate } from 'pliny/utils/formatDate';
import NewsletterForm from 'pliny/ui/NewsletterForm';
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,9 @@ 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">
@ -91,12 +75,12 @@ 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 };

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

@ -0,0 +1,138 @@
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,36 @@
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"
/>
);
}

42
app/head.tsx Normal file
View File

@ -0,0 +1,42 @@
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" />
</>
);
}

124
app/layout.tsx Normal file
View File

@ -0,0 +1,124 @@
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>
);
}

View File

@ -1,9 +1,9 @@
import Link from '@/components/Link';
export default function FourZeroFour() {
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 pt-6 pb-8 md:space-y-5">
<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>
@ -15,10 +15,10 @@ export default function FourZeroFour() {
<p className="mb-8">
But dont worry, you can find plenty of other things on our homepage.
</p>
<Link href="/">
<button className="focus:shadow-outline-blue inline rounded-lg border border-transparent bg-blue-600 px-4 py-2 text-sm font-medium leading-5 text-white shadow transition-colors duration-150 hover:bg-blue-700 focus:outline-none dark:hover:bg-blue-500">
Back to homepage
</button>
<Link
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 { 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">

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,
};
}

36
app/seo.tsx Normal file
View File

@ -0,0 +1,36 @@
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];
}

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

@ -0,0 +1,50 @@
{
"itx": 1,
"主机": 1,
"hackintosh": 1,
"硬件": 1,
"threejs": 1,
"navigation-mesh": 1,
"3d": 1,
"game": 1,
"path-finding": 1,
"github-actions": 1,
"cicd": 1,
"docker": 3,
"react": 1,
"baas": 1,
"self-hosted": 3,
"appwrite": 1,
"nhost": 1,
"supabase": 1,
"pve": 2,
"de": 1,
"环境搭建": 3,
"debian": 1,
"arch-linux": 3,
"vps": 3,
"zerotier": 1,
"tailscale": 1,
"sd-wan": 1,
"nat": 1,
"frp": 1,
"verdaccio": 1,
"caddy": 2,
"registry": 1,
"nodejs": 1,
"sni": 1,
"tls": 1,
"reverse-proxy": 1,
"反向代理": 1,
"xray": 2,
"vless": 1,
"blog": 1,
"markdown": 1,
"nextjs": 1,
"tailwind-css": 1,
"acme": 1,
"acmesh": 1,
"docker-compose": 1,
"内网穿透": 1,
"linux": 1
}

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

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

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

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

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

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

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

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

19
components/Comments.tsx Normal file
View File

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

@ -31,9 +31,7 @@ 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>

47
components/Header.tsx Normal file
View File

@ -0,0 +1,47 @@
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,8 @@
import NextImage, { ImageProps } from 'next/image';
const Image = ({ ...rest }: ImageProps) => <NextImage {...rest} />;
const Image = ({
...rest
}: React.DetailedHTMLProps<
React.ImgHTMLAttributes<HTMLImageElement>,
HTMLImageElement
>) => <img {...rest} />;
export default Image;

View File

@ -1,53 +1,23 @@
import siteMetadata from '@/data/siteMetadata';
import headerNavLinks from '@/data/headerNavLinks';
import Logo from '@/data/logo.svg';
import Link from './Link';
import { Inter } from 'next/font/google';
import SectionContainer from './SectionContainer';
import Footer from './Footer';
import MobileNav from './MobileNav';
import ThemeSwitch from './ThemeSwitch';
import { ReactNode } from 'react';
import Header from './Header';
interface Props {
children: ReactNode;
}
const inter = Inter({
subsets: ['latin'],
});
const LayoutWrapper = ({ children }: Props) => {
return (
<SectionContainer>
<div className="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>

View File

@ -1,23 +1,17 @@
/* eslint-disable jsx-a11y/anchor-has-content */
import Link from 'next/link';
import { AnchorHTMLAttributes, DetailedHTMLProps } from 'react';
import type { LinkProps } from 'next/link';
import { AnchorHTMLAttributes } from 'react';
const CustomLink = ({
href,
...rest
}: DetailedHTMLProps<
AnchorHTMLAttributes<HTMLAnchorElement>,
HTMLAnchorElement
>) => {
}: 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) {

View File

@ -1,39 +1,14 @@
/* eslint-disable react/display-name */
import React, { useMemo } from 'react';
import { ComponentMap, getMDXComponent } from 'mdx-bundler/client';
import TOCInline from 'pliny/ui/TOCInline';
import Pre from 'pliny/ui/Pre';
import BlogNewsletterForm from 'pliny/ui/BlogNewsletterForm';
import type { MDXComponents } from 'mdx/types';
import Image from './Image';
import CustomLink from './Link';
import TOCInline from './TOCInline';
import Pre from './Pre';
import { BlogNewsletterForm } from './NewsletterForm';
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,3 +1,5 @@
'use client';
import { useState } from 'react';
import Link from './Link';
import headerNavLinks from '@/data/headerNavLinks';
@ -18,41 +20,45 @@ const MobileNav = () => {
};
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}>
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">
@ -66,7 +72,7 @@ const MobileNav = () => {
))}
</nav>
</div>
</div>
</>
);
};

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,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,3 +1,6 @@
'use client';
import siteMetadata from '@/data/siteMetadata';
import { useEffect, useState } from 'react';
const ScrollTopAndComment = () => {
@ -14,32 +17,32 @@ const ScrollTopAndComment = () => {
}, []);
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 ${
className={`fixed bottom-8 right-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>
{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">
<svg className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">

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

@ -6,8 +6,8 @@ interface Props {
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">
<section className="mx-auto max-w-3xl px-4 sm:px-6 xl:max-w-5xl xl:px-0">
{children}
</div>
</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,16 +1,15 @@
import Link from 'next/link';
import kebabCase from '@/lib/utils/kebabCase';
import { slug } from 'github-slugger';
interface Props {
text: string;
}
const Tag = ({ text }: Props) => {
return (
<Link href={`/tags/${kebabCase(text)}`}>
<a className="mr-3 text-sm font-medium uppercase text-primary-500 hover:text-primary-600 dark:hover:text-primary-400">
{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>
);
};

View File

@ -1,29 +1,29 @@
'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 { theme, setTheme } = useTheme();
// When mounted on client, now we can show the UI
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"

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,41 +0,0 @@
import React, { useMemo, useState } from 'react';
import siteMetadata from '@/data/siteMetadata';
import { PostFrontMatter } from 'types/PostFrontMatter';
import { ReactCusdis } from 'react-cusdis';
import { useTheme } from 'next-themes';
interface Props {
frontMatter: PostFrontMatter;
}
const Cusdis = ({ 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">
<ReactCusdis
key={commentsTheme}
lang={siteMetadata.language?.toLocaleLowerCase()}
attrs={{
appId: siteMetadata.comment.cusdisConfig.appId,
host: siteMetadata.comment.cusdisConfig.host,
pageId: frontMatter.slug,
pageUrl: window.location.href,
pageTitle: frontMatter.title,
theme: commentsTheme,
}}
/>
</div>
);
};
export default Cusdis;

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,80 +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 CusdisComponent = dynamic(
() => {
return import('@/components/comments/Cusdis');
},
{ 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 === 'cusdis' && (
<CusdisComponent 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,12 @@
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,9 +15,16 @@ const components = {
youtube: Youtube,
linkedin: Linkedin,
twitter: Twitter,
mastodon: Mastodon,
};
const SocialIcon = ({ kind, href, size = 8 }) => {
type SocialIconProps = {
kind: keyof typeof components;
href: string | undefined;
size?: number;
};
const SocialIcon = ({ kind, href, size = 8 }: SocialIconProps) => {
if (
!href ||
(kind === 'mail' &&
@ -34,7 +42,7 @@ const SocialIcon = ({ kind, href, size = 8 }) => {
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>
);

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,6 +1,6 @@
---
name: Ivan Li
avatar: https://pan.ivanli.cc/api/v3/file/source/1234/头像.png?sign=xIgy54DyFRYupxjZJbK02HmpKX8C53YR-O0I18Rxm70%3D%3A0
avatar: https://minio.ivanli.cc/ivan-public/uPic/2023/Urpetm.png
occupation: Web Full Stack Developer
email: master@ivanli.cc
github: https://github.com/IvanLi-CN

View File

@ -0,0 +1,189 @@
---
title: 2023 年,再组一台黑苹果 ITX 主机
date: '2023-10-20'
tags: ['ITX', '主机', 'Hackintosh', '硬件']
draft: false
summary: 这是第二次组黑苹果台式机了,上一次是第一次,所以保守地选择了 i5-10400 + B460M + 6600XT 的组合。因为最近感受到 CPU 性能有一些吃紧,并且 ITX 的遗憾又开始涌上心头,最后看到 V 站老哥出 10700K + Z490I。顺势入手了。但是夜长梦多挑选了半天的配件最后发现应该买 12600K 比较合适。只能友好地鸽掉了 TAT
---
这是第二次组黑苹果台式机了,上一次是第一次,所以保守地选择了 i5-10400 + B460M + 6600XT 的组合。因为最近感受到 CPU 性能有一些吃紧,并且 ITX 的遗憾又开始涌上心头,最后看到 V 站老哥出 10700K + Z490I。顺势入手了。但是夜长梦多挑选了半天的配件最后发现应该买 12600K 比较合适。只能友好地鸽掉了 TAT
## 机箱
由于一开始定位是小体积 ITX 主机,加上我依然想沿用 6600XT所以可选的目标就两种一种是直插式的机箱以 K66 为代表的那些;另一种是 A4 结构的机箱。
### 直插式
![傻瓜超人 K66 青春版](https://s3.ivanli.cc/ivan-public/manual/2023-build-another-hackintosh-itx-workstation/c09ff5fd-f068-42d6-a259-6e80b0f856a7.png ' =269x198')我看了好多款,小体积下只能放 240mm 的显卡,我的显卡刚好是 240mm所以非常的极限。有些机箱前置的 IO 可能会和显卡冲突,也有的机箱设计上就很难放入 240mm 的显卡,所以作罢。
![](https://s3.ivanli.cc/ivan-public/manual/2023-build-another-hackintosh-itx-workstation/917695f5-9592-4350-b3b4-088cae5d756d.png ' =269x284')
### A4 结构
A4 结构的机箱,虽说成本会高出一条 PCIE 延长线,但是能放得下 300mm 的显卡。追求小,也是要成本的。不过体积上也会比直插的再小上一点点。大概代表就是蜂鸟 i100 了,同类产品还有闪鳞 S300。。
![蜂鸟 i100](https://s3.ivanli.cc/ivan-public/manual/2023-build-another-hackintosh-itx-workstation/3cc7cc07-5b16-4f43-8ce4-b895b79e01d5.png ' =269x214')
两天前,我选择了最便宜也是比较轻的蜂鸟 i100机箱隔天到发现显卡厚了点装不下。后来发现同样 128mm 的机箱宽度i100 的显卡限宽是 43mm我的 6600XT 是 45mm的i100 pro 不知道从什么地方挤了 5mm 给显卡,实现了 48mm 的显卡限宽。当时没留意这个差别,买了 i100所以只能加钱换货换成 i100 pro 了。
昨天,我终于找到了官方旗舰店有货的 6800一看尺寸i100 pro 也放不下,差一点……然后看了眼闪鳞,完了,闪鳞 S300 才是我要的机箱,真正的 Mesh 侧板8.1 L 的体积,下方能放两个薄扇出风。更重要的是价格和 i100 pro 相当。选配 PCIE 4.0 的线,价格比 i100 pro 便宜呀!
## 板 U 选择
因为机箱已经确定了,所以也只能用下压式散热器了。我选择的这家店可配超频三-降龙v53解热功耗是 150W再考虑我是来提升性能的所以可选的 CPU 就三款了。这三款 CPU 分别是 Intel Core i7-10700K、i7-12600K、i7-12700。为啥没有 i7-10700因为如果选 10700我买个 CPU 换就好了,实在没必要为 ITX 组一台新的机子……
### i5-12600K 对比 i7-10700K
看[参数对比](https://www.cpu-monkey.com/en/compare_cpu-intel_core_i5_12600k-vs-intel_core_i7_10700k),能注意到 12600K 单核频率是有 0.1 GHz睿频 0.2 GHz的降低但是实际单核表现是高 20% 左右的。虽然黑苹果用不了核显,但是我是装双系统,所以 Windows 下还能有加成的,不用想都知道 **[Intel UHD Graphics 770 ](https://www.cpu-monkey.com/en/igpu-intel_uhd_graphics_770)**的性能比 630 好不少。
前面说到,我是在 7.5 L 的 A4 机箱用下压散热器, 12600K 的 PL2 是 150W而 10700K 是 229W@56s所以在散热压力上i5-12600K 默频应该没什么问题。也因为是在这小体积机箱里,所以我也不想着超频了。
根据我在网络上冲浪几小时后获得的信息, 12600K 性能比 10700K 高一些。所以如果是购买全新的板 U上 12600K 是不错的选择。而在我的情况,我本来是准备收二手的 10700K + Z490I 主板,但是因为小体积机箱散热问题,以及绝不可能超频,所以 2000 收这套配置(折算 CPU 1350 元左右)不台划算,毕竟目前 12600K 散片是 1500 元出头。150 元换来性能加成 + 店保三年 + 更低 LP2 是很值的。
### B660 还是 Z690
首先要说明一点,因为 B660 与 B670、Z690 与 Z790 都支持 12 代和 13 代 CPU所以我直接都叫 B660 和 Z690 了,后面选具体型号也不拘泥芯片组代数。
不是土豪的话,超频就选 Z690否则选 B660这个毋庸置疑了。但因为超频会更热更热小机箱顶不住所以短期内不会考虑超频。加算上价格能差大几百块所以我只考虑 B660、B760 芯片组的主板。
那不超频的情况下,选带 K 的 12600K 是否合理呢?不知道 Intel 出于什么考虑划分的定位12600K 是 12 代 i5 唯一一个有能效核的 CPU这多加的四个能效核能带来更好的多核表现至于功耗因为在黑苹果下目前能用得上能效核已经够我高兴很久了还没去查功耗表现但在 Windows 下,应该是能低负载待机功耗的。再对比不带 K 的 12600高了 0.1GHz 的主频和睿频,多了四个能效核,但只贵 250 元(我都没找到有卖的)。在这情况下,选择 12600K + B660 其实很合适。
没考虑 H610 主要是怕太丐了带不动……主观臆测,没证实过,嘿嘿。
### 精粤 B760I 还是铭瑄B760ITX
![7块B760i ITX主板大横比用魔法打败魔法体验不输白果Windows+MacOS双系统性价比方案推荐_哔哩哔哩_bilibili](https://s3.ivanli.cc/ivan-public/manual/2023-build-another-hackintosh-itx-workstation/1c50b3b5-f4d8-4442-99a6-4f0503179744.png)
从[7块B760i ITX主板大横比](https://www.bilibili.com/video/BV15M4y117My/)的总结可以大概感受一下。既然在这个价位选,这两款本该丰俭由人。但是精粤目前睡眠问题好像还是比较迷惑,似乎还没有得到解决,尤其是 Snow Dream 版本。为了避免无法睡眠或者睡死导致电费增加、光污染之类的问题。我选了铭瑄。目前某宝 829 元拿下精粤B760ITX Snow Dream 588 元。加 241 元换个稳定,加个 20G 的后置 USB C 口,带个无线网卡,少个千兆有线。我感觉是划得来。可惜的一点是铭瑄的主板不是白色的,与我的白色主题不太搭。不过装完机后应该不怎么可见,无伤大雅。
### i5-12600K 对比 i7-12700
现在,选择范围就是 12600K 与 12700 了。为何还要纠结 12700 呢?因为我不超频,那高一档的 12700 似乎也是个不错的选择。
看[对比](https://www.cpu-monkey.com/en/compare_cpu-intel_core_i5_12600k-vs-intel_core_i7_12700)12700 多两个性能核,性能核具有更低的主频、一样的睿频,核显睿频稍高 0.5GHz。性能表现会比默频 12600K 好一些。PL2 也来到 180W 了。
~~但是考虑到散热问题,估计不太可能能发挥多出来的这部分性能,我想没必要加三百元为了多两个性能核,虽然我很想要这两个性能核,因为我经常用 FFMPGE 转码 1 分钟左右的视频,这时 CPU 都是吃满的。~~
隔天,商家说 12600K 没货了,估计是这个价格的没了,市场价会再高一点,然后商家说 12700 主频低,低负载的情况下功耗表现会比 12600K 好。这话说得我差点就信了CPU 空闲的时候频率可是能比主频低得多得多呀!
但是因为这个几十块的涨价,让我又开始想 12700 了,毕竟两个性能核呀!思来想去,加了三百五去别家买了 12700希望在这极限散热的情况下能有良好的日常使用表现。
综上,我决定入手 12700 + 铭瑄 B670I2327 元。比收12700K + Z690I 贵六百五,但全新有保修,性能绝对更好,发热更低。算是合理。黑苹果同样比较完美,还成。
## 其他
电源计划选个 650W 的白色全模组电源,目前看上了 TT 家的金牌 SFX 电源。京东自营 659 元,应该还可以。
机箱上摆两个风扇买俩可以直接拼接的风扇65 元。
固态暂时用我手上有的 PCIE 3.0 的 M.2 固态。
内存直接从旧机子拔DDR4 32G x2正好装满主板只支持 64G 内存。
## 装机小结
最终装机单:
| 项目 | 型号 | 备注 |
| ------------- | ----------------------------------------------------------------------------------- | -------------------------------------- |
| CPU | **[Intel Core i5-12700](https://www.cpu-monkey.com/zh-cn/cpu-intel_core_i7_12700)** | |
| 主板 | [MS-终结者 B760ITX D4 WIFI](https://www.maxsun.com.cn/2023/0206/5881.html) | |
| 显卡 | AMD Radeon RX 6800XT | |
| 硬盘 | Crucial 英睿达 P5 1TB 3D NAND NVMe\n京东京造 J.ZAO QL SERIES 1TB SSD保修换新 | |
| 电源 | TT 钢影SFX 650W | |
| CPU 散热 | 超频三 降龙 V53 绚彩版 | |
| 机箱 | 闪鳞 S300 + PCIE 4.0 延长线 | |
| ~~8015 风扇~~ | | 最后没装。除了烤机,电源下方不怎么积热 |
| 12015 风扇 | | 装机箱下主板下方对应风扇位出风 |
| 12025 风扇 | x2 | 摆机箱上面,抽风 |
## 性能调教
### CPU
#### CPU-Z
通过 CPU-Z 的跑分来确定性能,加上五分钟左右的 CPU 单烤来验证功耗与性能发挥的稳定性。
| 微码 | AC | 电压偏移 | PWM | 单核 | 多核 | 烤机 | 链接 |
| ---- | ---- | -------- | --- | ---- | ---- | ------------------------------- | ---------------------------------------------------------- |
| 默认 | 默认 | 默认 | 1.7 | 739 | 8673 | | [https://valid.x86.fr/wh5k3k](https://valid.x86.fr/wh5k3k) |
| 104 | 90 | -100 | 1.7 | 747 | 7123 | | [https://valid.x86.fr/3i8s1u](https://valid.x86.fr/3i8s1u) |
| 104 | 90 | 0 | 1.7 | 742 | 8750 | | [https://valid.x86.fr/qsleu7](https://valid.x86.fr/qsleu7) |
| 104 | 90 | | 1.1 | 767 | 8834 | 4.2G 死机 | [https://valid.x86.fr/602lbe](https://valid.x86.fr/602lbe) |
| 104 | 100 | -50 | 1.1 | 777 | 8949 | 4G | [https://valid.x86.fr/mrdyb0](https://valid.x86.fr/mrdyb0) |
| 104 | 95 | -100 | 1.1 | 775 | 8940 | 4.3G | [https://valid.x86.fr/373lpt](https://valid.x86.fr/373lpt) |
| 104 | 95 | -150 | 1.1 | 750 | 8897 | 4.3G 容易掉到 4.2G | [https://valid.x86.fr/tbvs6a](https://valid.x86.fr/tbvs6a) |
| 104 | 80 | -100 | 1.1 | 776 | 8928 | 4.3G 容易掉到 4.2G | [https://valid.x86.fr/knssqd](https://valid.x86.fr/knssqd) |
| 104 | 80 | -100 | 1.1 | 780 | 8862 | 4.3G | [https://valid.x86.fr/e75pzj](https://valid.x86.fr/e75pzj) |
| 104 | 70 | -150 | 1.1 | 778 | 8914 | 4.4G (一分钟内 4.5G) | [https://valid.x86.fr/cyzsa5](https://valid.x86.fr/cyzsa5) |
| 104 | 60 | -200 | 1.1 | 777 | 8827 | 4.4G (一分钟内 4.5G) | [https://valid.x86.fr/pmz7wq](https://valid.x86.fr/pmz7wq) |
| 104 | 60 | -150 | 1.1 | 778 | 8801 | 4.4G (一分钟半内 4.5G,不稳定) | [https://valid.x86.fr/lmlcrs](https://valid.x86.fr/lmlcrs) |
| 104 | 70 | -200 | 1.1 | 777 | 8931 | 4.4G(波动有点大) | [https://valid.x86.fr/lw675p](https://valid.x86.fr/lw675p) |
| 104 | 65 | -200 | 1.1 | 777 | 8892 | 4.4G(波动有点大) | [https://valid.x86.fr/bqy58g](https://valid.x86.fr/bqy58g) |
| 104 | 70 | -250 | 1.1 | 778 | 8910 | 4.4G(波动有点大) | [https://valid.x86.fr/ua63zf](https://valid.x86.fr/ua63zf) |
从测试结果上来看,电压越低,一开始的功耗也越低,温度上升就越慢,所以全核心跑满 4.5G 的时间也从十几秒来到一分半。但是最后都会因为我选用的 CPU 下压散热器只有 160W 的解热功耗,所以撞上 100 摄氏度的温度墙而降频。
#### Cinebench 2024
从 CPU-Z 的成绩来看,挑选了两个表现比较好的参数来进行多核跑分。
| AC | 电压 | CPU 多线程 |
| --- | ---- | ---------- |
| 70 | -200 | 1105pts |
| 70 | -150 | 1112pts |
#### 双拷测试
最后选定 AC 7070mΩ、电压偏移 -150mV 来进行双烤 10 分钟稳定性测试。测试通过🥰
### GPU
目前我使用从之前主机上拆下来的 6600XT能稳定发挥散热表现也正常。准备过两天去某宝买 6800XT 默认矿卡,祝我好运。希望散热表现也能稳定,毕竟 A4 结构,显卡散热应该挺好的。
默认矿卡到手。盒盖跑分。
- Cinebench 202410531pts
![](https://s3.ivanli.cc/ivan-public/manual/2023-build-another-hackintosh-itx-workstation/5d8f16d0-be54-4655-a2b3-e4a80b287701.png)
- 3D Mark Time Spy: [18 091](https://www.3dmark.com/3dm/101278696)\n ![](https://s3.ivanli.cc/ivan-public/manual/2023-build-another-hackintosh-itx-workstation/e7969870-e4f8-46ba-b9fb-35530aeeba3c.png)
- 3D Mark Time Spy Extreme: [8727](https://www.3dmark.com/3dm/101277666)
- ![](https://s3.ivanli.cc/ivan-public/manual/2023-build-another-hackintosh-itx-workstation/e296ac7c-1d3f-4eae-be0e-21d04e5af121.png)
- 3D Mark Speed Way 压力测试:[99.3%](https://www.3dmark.com/3dm/101279293)
![](https://s3.ivanli.cc/ivan-public/manual/2023-build-another-hackintosh-itx-workstation/5749102a-8471-4ea2-b332-362a8c8469a2.png ' =806x594')
看起来显卡还成,分数比平均值差一点,不过还好,作为二手默认矿卡表现应该能说得过去。
### 调教小结
我在机箱主板底部的风扇位安装了 12015 风扇出风,在机箱上方摆了两个积木风扇抽风,形成侧进-上下出的风道。目前散热瓶颈在 CPU 的下压散热CPU 撞 100 摄氏度温度墙,长时间烤机功耗在 160W\~170W 之间徘徊CPU 频率在 4.4 GHz 左右,损失 0.1 GHz。显卡没有遇到什么问题。
## 黑苹果
本来想自己搞 OpenCore 的,奈何时间不够,最后朋友介绍了 B 站大佬[乌龙蜜桃来](https://space.bilibili.com/244390800?spm_id_from=333.337.0.0)帮我弄好了 EFI。大佬做完后分享的 EFI
[hackintosh-club/MAXSUN-TERMINATOR-B760ITX-D4-WIFI-OpenCore: MS-B760i Hackintosh OpenCore macos 12 Monertey & 13 Ventura & 14 Sonoma](https://github.com/hackintosh-club/MAXSUN-TERMINATOR-B760ITX-D4-WIFI-OpenCore)
我试过了, Ventura 和 Sonoma 都是能稳定运行的睡眠、WIFI、蓝牙都正常随航和隔空投送因为是 Intel 的无线网卡,所以不支持。
![](https://s3.ivanli.cc/ivan-public/manual/2023-build-another-hackintosh-itx-workstation/23da98ef-8b5f-42b5-b515-f4d6fced10f5.png)
双系统 Blender Benchmark 得分
![](https://s3.ivanli.cc/ivan-public/manual/2023-build-another-hackintosh-itx-workstation/image.png)
前两组是 Windows 的成绩,后两组是 Mac OS 的成绩。似乎都比 Blender 上记录的成绩好一些。至少这次测试能看出来Mac OS 上显卡性能是有损失,但是 CPU 居然能跑得分更高。
## 使用体验
日常使用,没发现 CPU 因为散热问题降频。显卡一直稳定发挥,这二手显卡不知道矿没矿过,但是还算稳定。散热方面表现不错,基本上是因为我在机箱上面放了两把 12025 反向风扇,热量都能很好地被抽出来,小机箱现在不再是小闷罐。机箱底部的风扇,感觉有点聊胜于无。烤机的情况下,底部还是有一点积热的。或许和我只装了主板下的出风扇、没装电源下的出风扇有点关系,但是下部出风确实表现不理想,即使我把机箱放在通透的架子上。
现在我搞了两块 0.8mm 孔径的防尘网贴在机箱左右两侧,散热表现依然还可以,感觉我放上面的两个风扇真棒。
最后,放一张主机工作照吧。
![](https://s3.ivanli.cc/ivan-public/manual/2023-build-another-hackintosh-itx-workstation/8aacafcc-c69c-4a1d-adf7-68f86e2edd3e.png)

View File

@ -4,10 +4,7 @@ date: '2022-10-17'
tags: ['Arch Linux', '环境搭建', 'VPS']
draft: false
summary: 有了上次快速安装步骤后,接下来就是使用这个环境了。要使用环境,首先需要做一些初始化操作。我的步骤适合我,但不一定适合你,只是记录和参考。
images:
[
'https://pan.ivanli.cc/api/v3/file/source/2238/archlinux-logo-light.png?sign=bWxqFFy3RUDT5UsWb4UD5byt-_L4h79wede3runRKFc%3D%3A0',
]
images: ['https://minio.ivanli.cc/ivan-public/uPic/2023/RTr3IU.png']
---
## Docker

View File

@ -0,0 +1,282 @@
---
title: 使用 Github Actions 为其他项目构建 Docker Image
date: '2023-07-09'
tags: ['Github Actions', 'CI/CD', 'Docker']
draft: false
summary: 使用 Github Actions为自己喜爱的 Github 开源项目,快速、独立、自动化地构建 Docker 镜像,并推送到 ghcr (GitHub Container Registry)。
images: ['https://minio.ivanli.cc/ivan-public/uPic/2023/DZhPx7.jpg']
---
## 背景
这两天搞起了自动追番,使用了 [AutoBangumi](https://github.com/EstrellaXD/Auto_Bangumi/tree/main) 及其部署文档使用的方案组了个套件。感觉还不错,使用 Plex 播放,也让我的 iPad 终于能好好当个播放器了。
其中就包括一个 qBittorrent 程序,自带的 Web UI 很工具化,不是很漂亮,也不是很好用。所以我就找了第三方的,正好它没有单独的 Docker image所以我就做个自动化构建吧。
本来想在自建的 Gitea Actions 运行的,不过想了想,这个丢 Github 上跑比较合适,反正源头都在 Github 上。之前也有注意到做同样事情的库,但是一直想不起来是什么项目,就没找到……那作业没得抄,只能自己写了。
对了,我选择的 qBittorrent Web UI 是 [VueTorrent](https://github.com/WDaan/VueTorrent)。
## 方案
方案很简单,将目标项目作为 Git Submodule 放在我们的构建 Repo 中。然后分为两步:
1. 使用定时任务,每天检查最新发布的版本。将最新版本拉到项目中并提交。
2. 使用 push 触发器,将新版本构建成 Docker image 并推送到 ghcr (GitHub Container Registry)。
项目地址在这:[IvanLi-CN/vue-torrent-docker: Automatically build VueTorrent Docker images](https://github.com/IvanLi-CN/vue-torrent-docker)
## 实施
下面就是流水帐了。实施这个方案的话,我是先做第二步。因为我当时需要一个 Docker image 来替换原始方案。在文章里,为了流程顺畅,就按上帝视角,用正常的顺序来编写吧。毕竟不是教程,就不循序渐进了。
### 定时检查上游更新
Github Actions 支持使用 Cron 来创建一个定时任务。
所以触发 Action 的问题轻松解决。
```yaml
on:
schedule:
- cron: '0 2 * * *'
```
那么第二步就是获取上游最新的发布版本了。
上游使用 “Github Releases” 发布版本:
![Github Releases for VueTorrent](https://minio.ivanli.cc/ivan-public/uPic/2023/bHrczD.png)
所以这里使用了 [git-get-release-action](https://github.com/marketplace/actions/git-get-release-action),获取最新的版本号和 commitish hash。
其中版本号就是 tag name而 commit-ish hash 就是平常使用的 commit hash 了。
```yaml
- name: git-get-release-action
id: git-get-release
uses: cardinalby/git-get-release-action@1.2.4
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
repo: WDaan/VueTorrent
latest: true
```
这里提供了几个参数:
- `GITHUB_TOKEN` 是自动注入的,我们将这个变量作为环境变量提供给 git-get-release-action 就行,缺少这个变量,这个步骤会报错。
- 我们通过 `repo` 提供了上游的仓库名称 “WDaan/VueTorrent”这个仓库在 Github 上,所以可以直接这么简写。
- 因为只需要获取最新的发布版本,所以提供了 `latest` 为 `true`。
这一步骤会有两个关键的输出:
- `tag_name` 是我们想要的最新版本的版本号,因为他的 git tag 写的就是版本号,例如现在的 `v1.6.0`。
- `current_commitish` 就顾名思义了,接下来就是要用他作为当前 commit 的唯一标记。
我们有了最新版本的 commit-ish 值,那就要有上次构建 Docker image 时用的 commit-ish 值。
```yaml
- name: Get current commitish
id: get-current-commitish
run: |
cd vue-torrent
echo "Current commitish: $(git rev-parse HEAD)"
echo "{current_commitish}=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
```
这里直接进入子模块的目录里,使用 `git rev-parse HEAD` 命令获取当前 repo 使用的上游的 commit-ish。
因为我们每次构建镜像前,都会先更新子模块的 commit 位置,所以这里就是上次发布镜像时的 commit-ish 了。
再因为我们一直是单调地往按时间往更新的构建,所以不需要比较 commit 的新旧,只要不一样,就是要构建新的镜像。
所以我们这一步,就直接比较最新的 commit 和现在的 commit 是否一直:
```yaml
- name: Compare versions
id: compare
run: |
echo "Current version: ${{ steps.git-get-release.outputs.tag_name }}"
echo "{should_update}=${{ steps.git-get-release.outputs.target_commitish != steps.get-current-commitish.outputs.current_commitish }}" >> $GITHUB_OUTPUT
```
下面就是重要的三个步骤了,一更新子模块,二提交更新,三打标签。
#### 更新子模块
```yaml
- name: Update
if: steps.compare.outputs.should_update == 'true'
working-directory: ./vue-torrent
run: |
git fetch --depth=1 origin ${{ steps.git-get-release.outputs.tag_name }}
git checkout -b ${{ steps.git-get-release.outputs.tag_name }} ${{ steps.git-get-release.outputs.target_commitish }}
git reset --hard HEAD
```
_这里使用了 `working-directory` 更改执行目录到子模块中,用 `cd` 进去应该也一样。_
因为之前检出存储库时是只检出最新的那个 commit所以需使用 `git fetch` 将我们需要的那个 commit 拉到运行环境中,否则会报形如 `fatal: Could not parse object '6ab00a179b9509ef162a14862fb828c78144caff'.` 的错误。
之后使用 `git checkout -b` 将目标的 commit 拉到新的分支上。
最后,将当前的位置设到 HEAD即目标 commit。
后两步应该是可以直接改成 `git reset --hard ${{ steps.git-get-release.outputs.target_commitish }}`,不过我没试过,仅供参考。
#### 提交更新
```yaml
- name: Commit changes
if: steps.compare.outputs.should_update == 'true'
run: |
git diff
git config user.name "GitHub Actionss"
git config user.email "bot@noreply.github.com"
git add .
git commit -m "Update to ${{ steps.git-get-release.outputs.tag_name }}"
git push origin ${{ github.ref_name }}
```
这就没什么好说的了,需要注意的一点就是权限问题。因为我们是 push 到当前的 repo 上,所以可以直接使用自动注入的 `GITHUB_TOKEN`,不过需要在 repo 的设置页面更改下权限:
![Github Actions Permissions Setting](https://minio.ivanli.cc/ivan-public/uPic/2023/QPvf6R.png)
选择 “Read and write permissions",这样就能写入当前的 repo。
#### 打标签
```yaml
- name: Tag
if: steps.compare.outputs.should_update == 'true'
run: |
git tag ${{ steps.git-get-release.outputs.tag_name }}
git push origin ${{ steps.git-get-release.outputs.tag_name }}
```
目的就是后面构建镜像时,能方便地从这里取到版本号。
### 构建镜像
这个就比较简单了,代码在这:
[Action](https://github.com/IvanLi-CN/vue-torrent-docker/blob/7439b89cb87bb4627d87f0445e8c1fc39ed89f78/.github/workflows/auto-build.yaml)
[Dockerfile](https://github.com/IvanLi-CN/vue-torrent-docker/blob/7439b89cb87bb4627d87f0445e8c1fc39ed89f78/.github/workflows/auto-build.yaml)
因为直接做成子模块了,流程就简单很多了,只要检出代码时将子模块一并检出,之后直接构建 Docker 镜像就行。
我也不知道有没有人用,只构建了 x86 的自用。
## 最后
分享一下我现在用的追番的 Docker Compose 吧:
`docker-compose.yaml`
```yaml
version: '3.2'
services:
caddy:
container_name: caddy
ports:
- ${QB_PORT}:80
networks:
- auto_bangumi
restart: unless-stopped
volumes:
- ./caddy:/etc/caddy
image: caddy:2
vuetorrent:
container_name: vuetorrent
expose:
- 3000
networks:
- auto_bangumi
restart: unless-stopped
image: ghcr.io/ivanli-cn/vue-torrent:main
qbittorrent:
container_name: qBittorrent
environment:
- TZ=Asia/Shanghai
- TemPath=/downloads
- SavePath=/downloads
- PGID=${GID}
- PUID=${UID}
- WEBUI_PORT=8080
volumes:
- qb_config:/config
- ${DOWNLOAD_PATH}:/downloads
ports:
- 6881:6881
- 6881:6881/udp
networks:
- auto_bangumi
restart: unless-stopped
image: superng6/qbittorrent:latest
auto_bangumi:
container_name: AutoBangumi
environment:
- TZ=Asia/Shanghai
- PGID=${GID}
- PUID=${UID}
- AB_DOWNLOADER_HOST=qbittorrent:${QB_PORT}
networks:
- auto_bangumi
volumes:
- ./auto_bangumi/config:/app/config
- ./auto_bangumi/data:/app/data
ports:
- 7892:7892
dns:
- 8.8.8.8
- 223.5.5.5
restart: unless-stopped
image: estrellaxd/auto_bangumi:latest
depends_on:
- qbittorrent
plex:
container_name: Plex
environment:
- TZ=Asia/Shanghai
- PUID=${UID}
- PGID=${GID}
- VERSION=docker
- PLEX_CLAIM=${PLEX_CLAIM}
networks:
- auto_bangumi
ports:
- 32400:32400
volumes:
- plex_config:/config
- ${DOWNLOAD_PATH}/Bangumi:/tv
restart: unless-stopped
image: lscr.io/linuxserver/plex:latest
networks:
auto_bangumi:
volumes:
qb_config:
external: false
plex_config:
external: false
```
`caddy/Caddyfile`
```Caddyfile
:80 {
reverse_proxy /api/* qbittorrent:8080
reverse_proxy /* vuetorrent:3000
}
```
`.env`
```bash
QB_PORT=8080
DOWNLOAD_PATH=/home/ivan/downloads
UID=1000
GID=1000
PLEX_CLAIM=claim-DwbcewEB7j3pmNotG_eT
```

View File

@ -1,13 +1,10 @@
---
title: 在 PVE 宿主机上使用桌面环境
date: '2022-10-28'
tags: ['PVE', 'PVE', 'DE', '环境搭建', 'Debian']
tags: ['PVE', 'DE', '环境搭建', 'Debian']
draft: false
summary: 虽然 PVE 宿主机不应该安装乱七八糟的东西,但是我穷,为了物尽其用,为了在主力电脑翻车时有一个立即可用的备用环境,所以还是安装了基础的桌面环境。现在的 Linux 桌面环境越来越好了,我选择安装 KDE Plasma 作为桌面环境,并且默认关闭,按需启用。
images:
[
'https://pan.ivanli.cc/api/v3/file/source/2243/1200px-Kde_dragons.png?sign=yGZL9jYeVt53Ve43ddhHt_0EzVV2cW_WbxHc0dEcwWY%3D%3A0',
]
images: ['https://minio.ivanli.cc/ivan-public/uPic/2023/qldEtP.png']
---
## 前言
@ -160,7 +157,7 @@ ibus-setup
在打开的 GUI 中添加中文输入法,找到 Rime 并添加输入法:
![rime](https://pan.ivanli.cc/api/v3/file/source/2241/Screen%20Capture_select-area_20221028225457.png?sign=XVrl7rPk4Gd7QRFBCCDGruB2L7V1bvxDpK9-v9pC0Nc%3D%3A0)
![rime](https://minio.ivanli.cc/ivan-public/uPic/2023/E4SWeR.png)
现在,新打开的软件应该能使用输入法了。像 Chrome 这类,关闭后还需要手动杀死进程后再打开才能使用。最简单的方法就是重启电脑啦~

View File

@ -4,10 +4,7 @@ date: '2022-09-23'
tags: ['Verdaccio', 'Self-Hosted', 'Docker', 'Caddy', 'registry', 'Node.js']
draft: false
summary: 作为靠着 JavaScript 生态吃饭的 Web 开发者,自建一个 Node regsitry 是很有必要的,我这次继续选择 Verdaccio 来搭建存储库。这次使用 Docker Compose 部署 Verdaccio并将 Caddy 用于反向代理该服务。
images:
[
'https://pan.ivanli.cc/api/v3/file/source/2233/verdaccio.png?sign=qpoeADXzbhHk2MY5CehgTftUJ67pnUj-Ylko9D5jscU%3D%3A0',
]
images: ['https://minio.ivanli.cc/ivan-public/uPic/2023/3Dqijk.png']
---
## 为何自建存储库?

View File

@ -0,0 +1,168 @@
---
title: 2023 年,我的 Mac OS 环境初始化
date: '2023-04-13'
tags: ['Mac OS', '环境搭建', 'Hackintosh', 'Develop', '装机']
draft: false
summary: 记录和分享我的 Mac OS 必备的基本软件。
---
## 初步环境
跑完系统的向导,正式进入系统后,先装 brew
```bash
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
```
参考:[macOS或 Linux缺失的软件包的管理器 — Homebrew](https://brew.sh/index_zh-cn)
### 安装第一批软件
我需要立即能够使用:
- Warp
- Chrome
- Syncthing
- Logseq现在我抛弃它了只用来在本地查阅以前记录的内容
所以:
```bash
brew install --casks warp google-chrome syncthing logseq
```
恢复输入法——小鹤音型
因为使用 Rime 实现的,所以先安装 Squirrel
```bash
brew install --cask squirrel
```
安装完毕后,到系统设置里选择启用鼠须管。
## 开发环境
```bash
brew install --cask visual-studio-code
brew install cmake git-lfs
```
### Node JS 环境
```bash
brew install nvm
cat >> ~/.zshrc<<EOF
#nvm start
export NVM_DIR="$HOME/.nvm"
[ -s "/usr/local/opt/nvm/nvm.sh" ] && \. "/usr/local/opt/nvm/nvm.sh" # This loads nvm
[ -s "/usr/local/opt/nvm/etc/bash_completion.d/nvm" ] && \. "/usr/local/opt/nvm/etc/bash_completion.d/nvm" # This loads nvm bash_completion
# nvm end
EOF
source ~/.zshrc
nvm install --lts
npm i -g pnpm && pnpm setup
source /Users/ivan/.zshrc
```
### 数据库运维
```bash
brew tap mongodb/brew
brew install mongodb-database-tools mongosh
```
### Rust
```bash
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
```
### 消息队列
```bash
brew install --casks mqttx
```
### 设计软件
```bash
brew install --casks blender
```
### 硬件开发
```bash
brew install minicom
cargo install cargo-espmonitor
```
### 网络相关
```bash
brew install --cask wireshark
```
## 日常环境
### 软件
```bash
brew install --cask \
telegram-desktop \
iina \
monitorcontrol \
logitech-options \
microsoft-remote-desktop \
keycastr
brew install croc
```
### 终端
#### ZSH 插件
```bash
sh -c "$(curl -fsSL https://git.io/zinit-install)"
echo 'zinit load zsh-users/zsh-syntax-highlighting
zinit load zsh-users/zsh-autosuggestions
zinit load ael-code/zsh-colored-man-pages
zinit load agkozak/zsh-z
export NVM_AUTO_USE=true
zinit load lukechilds/zsh-nvm
zinit ice depth=1; zinit light romkatv/powerlevel10k' >> ~/.zshrc
```
#### ZSH-Z 配置
避免优先匹配到子目录,在 `.zshrc` 中添加如下行:
```bash
cat >> ~/.zshrc <<EOF
# zsh-z
ZSHZ_UNCOMMON=1
ZSHZ_TRAILING_SLASH=1
EOF
```
#### History 配置
配置历史记录,在 `.zshrc` 中添加如下行:
```bash
cat >> ~/.zshrc <<EOF
# History
HISTFILE=~/.zsh_history
HISTSIZE=10000
SAVEHIST=1000
setopt INC_APPEND_HISTORY_TIME
EOF
```

View File

@ -4,10 +4,7 @@ date: '2022-10-17'
tags: ['Arch Linux', '环境搭建', 'VPS']
draft: false
summary: 又到了新装 Arch Linux 的日子了。这次又是温故而知新的机会,把之前写的笔记稍微整理了一下,在这里记录下教徒搭窝的备忘录。
images:
[
'https://pan.ivanli.cc/api/v3/file/source/2238/archlinux-logo-light.png?sign=bWxqFFy3RUDT5UsWb4UD5byt-_L4h79wede3runRKFc%3D%3A0',
]
images: ['https://minio.ivanli.cc/ivan-public/uPic/2023/RTr3IU.png']
---
## 起势

View File

@ -39,7 +39,7 @@ useEffect(() => {
### 在 React 18 的测试代码
![React 18 Stricter Strict Mode.png](https://pan.ivanli.cc/api/v3/file/source/2753/React%2018%20Stricter%20Strict%20Mode.png?sign=ARQ8AVTh-NEaeJRypJlVokuUVhocPeaK8n7GRSDwqNw%3D%3A0)
![React 18 Stricter Strict Mode.png](https://minio.ivanli.cc/ivan-public/uPic/2023/TWVx7v.png)
代码:[Code Sandbox](https://codesandbox.io/p/sandbox/clever-cache-pm1oct?file=%2Fsrc%2FApp.tsx&selection=%5B%7B%22endColumn%22%3A20%2C%22endLineNumber%22%3A33%2C%22startColumn%22%3A20%2C%22startLineNumber%22%3A33%7D%5D)
@ -51,7 +51,7 @@ useEffect(() => {
2. `useEffect(() => /* */, [])`执行一此后,以新的严格模式的规则,调用了 `destructor` 后,进行了二次调用。
在第 2 点中,两次 useEffect 都是使用同一个值,是因为严格模式的二次调用按钩子分别执行两次,所以 useMemo 两次的调用都完毕后,得到的值再被 useEffect 执行两次。我调整了一下代码,将测试代码复制了一份在后面,可以看到 “useMemo” 和 “useMemo 2” 先执行了一次,又再执行了一次,然后再到 “useEffect“ 和 “useEffect 2"
![加倍快乐](https://pan.ivanli.cc/api/v3/file/source/2754/React%2018%20Stricter%20Strict%20Mode%202.png?sign=iYz9KP9uMuccRCesjqoRPKejEoUOj4FZfnBPt8kCXnQ%3D%3A0)
![加倍快乐](https://minio.ivanli.cc/ivan-public/uPic/2023/OwlDG6.png)
## 结论

View File

@ -2,7 +2,7 @@
title: 再见 2022你好 2023
date: '2022-12-31'
tags: ['总结']
draft: false
draft: true
summary: 2022 年就要结束了,写个工作与学习的总结,记录下我的 2022 年。
---

View File

@ -0,0 +1,84 @@
---
title: 使用导航网格实现寻路
date: '2023-09-12'
tags: ['Three.js', 'Navigation Mesh', '3D', 'Game', 'Path Finding']
draft: false
summary: 本文结合 three-pathfinding 项目的源代码,简要分析如何使用导航网格实现寻路功能。 three-pathfinding 是一个 Three.js 导航网格寻路库。
images: ['https://s3.ivanli.cc/ivan-public/uPic/2023/mZ9HNo.jpeg']
---
本文结合 [donmccurdy/three-pathfinding](https://github.com/donmccurdy/three-pathfinding) 项目的源代码,简要分析如何使用导航网格实现寻路功能。 three-pathfinding 是一个基于 [PatrolJS](https://github.com/nickjanssen/PatrolJS) 实现的 Three.js 导航网格寻路库。
在 Three.js 中使用导航网格实现寻路功能,主要用到以下三个部分:
- 导航网格Navmesh, Navigation Mesh寻路用的地图
- A\*A Star搜索算法用于寻路
- 漏斗算法Funnel Algorithm: 用于在二维平面找到绕过障碍物的最短路径
## 导航网格
导航网格由若干个可供角色行走的、相邻的凸多边形组成,在我们的用例中,是三角形。导航网格的作用是寻路算法提供所需的顶点数据的,本身并没有任何算法。导航网格可以通过 [UPBGE](https://tl.ivanli.cc/m/28) 生成,生成后就是一个 Mesh 物体,所以顶点数据可以直接通过 GLTF 等格式分发。
## A Star 搜索
网络上有很多文章介绍这个算法:
- [A Star Algorithm 总结与实现 | Cheng Wei's Blog](https://shiori.ivanli.cc/bookmark/47/archive/)
本文结合实际应用再简要地说明下。
首先,我们除了会给算法传入三角形的定点数据,还会传了起点和终点,这里分为三个情况:
#### 起点和终点顶点都在同一个三角形之中
不需要执行 A Star 搜索算法,直接将两点用直线连接就是目标路径。
#### 起点或终点不在任意一个的三角形之中
要么通过其他算法将原始的起点和终点在三角形中找到最接近的点,要么放弃这次寻路。因为这是不可处理的意外情况。
#### 起点和终点在不同的三角形之中
这样就能正式执行 A Star 搜索算法了。
### 数据准备
[Builder.\_buildNavigationMesh()](https://github.com/donmccurdy/three-pathfinding/blob/538b57b65e78b37fe033ff845a09659ebed426dd/src/Builder.js#L150) 方法将从 NavMesh 提取所有三角形的顶点,并通过三角形的三个顶点的 x 分量作为 ID找到了每个三角形相邻的三角形数组。
[Build_buildPolygonGroups()](https://github.com/donmccurdy/three-pathfinding/blob/538b57b65e78b37fe033ff845a09659ebed426dd/src/Builder.js#L103) 方法将从上一个方法中返回三角形及其相邻的三角形作为一个参数,返回了若干组三角形,每组内的三角形都能相互联通,也就是在这组三角形上的任意两点都能找到连接的路径。
最后每个三角形会被结构化成 Node长这样
```javascript
{
id: number, // ID
neighbours: nb[], // 相邻的三角形的 id 数组Node.id 数组)
vertexIds: vertexIds[], // 顶点 ID使用三个顶点的 X 分量组成
centroid: Vector3, // 重心
portals: [number, number][] // 与其他三角形公用的边的顶点的索引数组,
// 一般情况下是两个,即一个边与另一个三角形相邻,但是也不排除会三个边都相邻
// 如果三边都相邻,则是 [number, number, number]
}
```
### 开始使用 A Star 搜索路径
A Star 算法怎么跑的本文就不赘述了。
执行 [Pathfinding.findPath()](https://github.com/donmccurdy/three-pathfinding/blob/abc331195143d7ea1242debed4b52500bda8b7fe/src/Pathfinding.js#L106) 时,需要传入 zoneID 和 groupID。通过前面对数据准备的分析我们知道同组的三角形是相通的只要保证起点和终点都在这组三角形的面上正常情况下 findPath 就能求出路径了。
A Star 算法本质上是在若干个点之间求出一组点,连接这些点就是导航路径。这里使用三角形的重心作为这个点。
通过 A Star 算法搜索出路径后, 会获得一组有序的 Node。
## “拉绳”
A Star 算法寻得的路径是比较粗粒度的路径,接下来使用漏斗算法来拉出一条最短路径。
[Channel.js](https://github.com/donmccurdy/three-pathfinding/blob/364fdc5e6c41c6f3835d881edd00565c45ab0401/src/Channel.js) 里便是使用漏斗算法来获取最短路径。值得注意的一点是,这个算法适合平面,并不适合有高度落差联通的导航网格。
漏斗算法参考这篇文章:[图解NavMesh寻路中的漏斗算法 - PointerSMQ - 博客园](https://shiori.ivanli.cc/bookmark/48/archive/)
![](https://s3.ivanli.cc/ivan-public/uPic/2023/JJjp9f.png ' =666x428')
漏斗算法能够让最终路径在绕过障碍物的同时,保证路径最短。从上面的文章中,可以总结一个核心逻辑,每次生成的路径如果超过左边界或右边界,就会增加一个节点,并从此处构造新的漏斗。直到到达终点。

View File

@ -8,10 +8,8 @@ const siteMetadata = {
theme: 'system', // system, dark or light
siteUrl: 'https://ivanli.cc/',
siteRepo: 'https://git.ivanli.cc/ivan/tailwind-nextjs-blog',
siteLogo:
'https://pan.ivanli.cc/api/v3/file/source/1234/%E5%A4%B4%E5%83%8F.png?sign=xIgy54DyFRYupxjZJbK02HmpKX8C53YR-O0I18Rxm70%3D%3A0',
image:
'https://pan.ivanli.cc/api/v3/file/source/1234/%E5%A4%B4%E5%83%8F.png?sign=xIgy54DyFRYupxjZJbK02HmpKX8C53YR-O0I18Rxm70%3D%3A0',
siteLogo: 'https://minio.ivanli.cc/ivan-public/uPic/2023/Urpetm.png',
image: 'https://minio.ivanli.cc/ivan-public/uPic/2023/Urpetm.png',
socialBanner: '/static/images/twitter-card.png',
email: 'master@ivanli.cc',
github: 'https://github.com/IvanLi-CN',
@ -23,12 +21,21 @@ const siteMetadata = {
analytics: {
// If you want to use an analytics provider you have to add it to the
// content security policy in the `next.config.js` file.
// supports plausible, simpleAnalytics, umami or googleAnalytics
plausibleDataDomain: '', // e.g. tailwind-nextjs-starter-blog.vercel.app
simpleAnalytics: false, // true or false
umamiWebsiteId: '', // e.g. 123e4567-e89b-12d3-a456-426614174000
googleAnalyticsId: '', // e.g. UA-000000-2 or G-XXXXXXX
posthogAnalyticsId: '', // posthog.init e.g. phc_5yXvArzvRdqtZIsHkEm3Fkkhm3d0bEYUXCaFISzqPSQ
// supports Plausible, Simple Analytics, Umami, Posthog or Google Analytics.
umamiAnalytics: {
// We use an env variable for this site to avoid other users cloning our analytics ID
umamiWebsiteId: process.env.NEXT_UMAMI_ID, // e.g. 123e4567-e89b-12d3-a456-426614174000
},
// plausibleAnalytics: {
// plausibleDataDomain: '', // e.g. tailwind-nextjs-starter-blog.vercel.app
// },
// simpleAnalytics: {},
// posthogAnalytics: {
// posthogProjectApiKey: '', // e.g. 123e4567-e89b-12d3-a456-426614174000
// },
// googleAnalytics: {
// googleAnalyticsId: '', // e.g. G-XXXXXXX
// },
},
newsletter: {
// supports mailchimp, buttondown, convertkit, klaviyo, revue, emailoctopus
@ -78,14 +85,24 @@ const siteMetadata = {
// https://help.disqus.com/en/articles/1717111-what-s-a-shortname
shortname: process.env.NEXT_PUBLIC_DISQUS_SHORTNAME,
},
cusdisConfig: {
appId: process.env.NEXT_PUBLIC_CUSDIS_APPID,
host: process.env.NEXT_PUBLIC_CUSDIS_HOST,
},
commentoConfig: {
url: process.env.NEXT_PUBLIC_COMMENTO_URL,
},
},
}
search: {
provider: 'kbar', // kbar or algolia
kbarConfig: {
searchDocumentsPath: 'search.json', // path to load documents to search
},
// provider: 'algolia',
// algoliaConfig: {
// // The application ID provided by Algolia
// appId: 'R2IYF7ETH7',
// // Public API key: it is safe to commit it
// apiKey: '599cec31baffa4868cae4e79f180729b',
// indexName: 'docsearch',
// },
},
};
module.exports = siteMetadata
module.exports = siteMetadata;

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"]
}
}
}

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