Compare commits

..

69 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
a8e6ee073f feat: 添加 ICP 备案号。
All checks were successful
continuous-integration/drone/push Build is passing
2023-03-30 09:02:43 +00:00
14719936fc fix: 头像图片编码问题.
All checks were successful
continuous-integration/drone/push Build is passing
2023-03-01 20:47:17 +08:00
26350e033b build: 支持构建并上传网页到景安虚机。xN
Some checks reported errors
continuous-integration/drone/push Build encountered an error
2023-03-01 20:00:36 +08:00
91d3acc358 build: 支持构建并上传网页到景安虚机。xN
Some checks failed
continuous-integration/drone/push Build is failing
2023-03-01 19:56:50 +08:00
e41238fb60 build: 支持构建并上传网页到景安虚机。xN
Some checks reported errors
continuous-integration/drone/push Build was killed
2023-03-01 00:01:23 +08:00
404c7cba87 build: 支持构建并上传网页到景安虚机。xN
Some checks reported errors
continuous-integration/drone/push Build encountered an error
2023-03-01 00:00:12 +08:00
930953fc1a build: 支持构建并上传网页到景安虚机。xN
Some checks failed
continuous-integration/drone/push Build is failing
2023-02-28 22:40:59 +08:00
7ad3729ae0 build: 支持构建并上传网页到景安虚机。xN
Some checks failed
continuous-integration/drone/push Build is failing
2023-02-28 22:31:51 +08:00
9a3297e1c7 build: 支持构建并上传网页到景安虚机。xN
Some checks failed
continuous-integration/drone/push Build is failing
2023-02-28 22:25:59 +08:00
abb37dbcac build: 支持构建并上传网页到景安虚机。xN
Some checks failed
continuous-integration/drone/push Build is failing
2023-02-28 21:56:12 +08:00
0549b3c385 build: 支持构建并上传网页到景安虚机。xN
Some checks reported errors
continuous-integration/drone/push Build was killed
2023-02-28 21:49:51 +08:00
c2dca0e57b build: 支持构建并上传网页到景安虚机。x8
Some checks failed
continuous-integration/drone/push Build is failing
2023-02-27 21:28:05 +08:00
1b05eae89b build: 支持构建并上传网页到景安虚机。x7
Some checks reported errors
continuous-integration/drone/push Build encountered an error
2023-02-27 13:26:01 +00:00
c85737fa3f build: 支持构建并上传网页到景安虚机。x6.
Some checks reported errors
continuous-integration/drone/push Build encountered an error
2023-02-27 21:22:56 +08:00
364b85cdc6 build: 支持构建并上传网页到景安虚机。x5.
Some checks failed
continuous-integration/drone/push Build is failing
2023-02-27 09:29:35 +08:00
6e0b88bd1e build: 支持构建并上传网页到景安虚机。x4.
Some checks failed
continuous-integration/drone/push Build is failing
2023-02-27 09:24:18 +08:00
d137dfac70 build: 支持构建并上传网页到景安虚机。x3.
Some checks failed
continuous-integration/drone/push Build is failing
2023-02-27 00:07:48 +08:00
864085ea4a build: 支持构建并上传网页到景安虚机。x2
Some checks failed
continuous-integration/drone/push Build is failing
2023-02-26 13:53:59 +00:00
16d5d1f32e build: 支持构建并上传网页到景安虚机。
Some checks reported errors
continuous-integration/drone/push Build encountered an error
2023-02-26 13:17:16 +00:00
2f526f713c blog: React 18,新的严格模式.
All checks were successful
continuous-integration/drone/push Build is passing
2023-02-07 15:50:03 +00:00
0b3bdfc36a blog: 简单调整。
Some checks failed
continuous-integration/drone/push Build is failing
2023-01-23 12:28:18 +00:00
b2c2b0eb98 blog: 再见 2022,你好 2023
Some checks failed
continuous-integration/drone/push Build is failing
2023-01-01 05:31:59 +00:00
97904d66b4 blog: 再见 2022,你好 2023
All checks were successful
continuous-integration/drone/push Build is passing
2022-12-31 16:04:16 +00:00
61efce68b5 blog: 更新《安装并配置 Arch Linux》
Some checks failed
continuous-integration/drone/push Build is failing
2022-11-27 16:14:02 +00:00
de5081da69 blog: 完善。
All checks were successful
continuous-integration/drone/push Build is passing
2022-10-30 13:16:44 +00:00
8e0f1e4ff1 blog: 完善内容。
All checks were successful
continuous-integration/drone/push Build is passing
2022-10-29 05:57:41 +00:00
a8a1fe1e0d blog: 完善样式。
All checks were successful
continuous-integration/drone/push Build is passing
2022-10-29 02:30:36 +00:00
8d9020da3a build(dev): 开发容器迁移到 pnpm. 2022-10-29 02:15:14 +00:00
4cc2f920a1 blog: 完善内容。
All checks were successful
continuous-integration/drone/push Build is passing
2022-10-28 15:45:48 +00:00
7a2d689a4f blog: 完善内容。
All checks were successful
continuous-integration/drone/push Build is passing
2022-10-28 15:40:21 +00:00
7550421a74 blog: add image.
All checks were successful
continuous-integration/drone/push Build is passing
2022-10-28 15:13:23 +00:00
6557412554 blog: 在 PVE 宿主机上使用桌面环境
All checks were successful
continuous-integration/drone/push Build is passing
2022-10-28 15:06:06 +00:00
49ad571864 blog: 优化文章内的 CLI 命令。
All checks were successful
continuous-integration/drone/push Build is passing
2022-10-20 21:25:45 +08:00
1bde01a6a9 blog/arch-linux-quick-setup
All checks were successful
continuous-integration/drone/push Build is passing
2022-10-20 20:31:58 +08:00
5bee02b567 blog: 补充遗漏的内容。
All checks were successful
continuous-integration/drone/push Build is passing
2022-10-19 23:57:51 +08:00
35b92490d8 build: remove .ssh mount.
All checks were successful
continuous-integration/drone/push Build is passing
2022-10-19 20:53:59 +08:00
157 changed files with 21166 additions and 7973 deletions

View File

@ -11,4 +11,4 @@ FROM mcr.microsoft.com/vscode/devcontainers/typescript-node:0-${VARIANT}
# RUN su node -c "source /usr/local/share/nvm/nvm.sh && nvm install ${EXTRA_NODE_VERSION}" # RUN su node -c "source /usr/local/share/nvm/nvm.sh && nvm install ${EXTRA_NODE_VERSION}"
# [Optional] Uncomment if you want to install more global node packages # [Optional] Uncomment if you want to install more global node packages
# RUN su node -c "npm install -g pnpm" RUN su node -c "npm install -g pnpm"

View File

@ -8,10 +8,9 @@
// Append -bullseye or -buster to pin to an OS version. // Append -bullseye or -buster to pin to an OS version.
// Use -bullseye variants on local on arm64/Apple Silicon. // Use -bullseye variants on local on arm64/Apple Silicon.
"args": { "args": {
"VARIANT": "16-bullseye" "VARIANT": "18-bullseye"
} }
}, },
// Configure tool-specific properties. // Configure tool-specific properties.
"customizations": { "customizations": {
// Configure properties specific to VS Code. // Configure properties specific to VS Code.
@ -36,25 +35,24 @@
"esbenp.prettier-vscode", "esbenp.prettier-vscode",
"shardulm94.trailing-spaces", "shardulm94.trailing-spaces",
"lihui.vs-color-picker", "lihui.vs-color-picker",
"bradlc.vscode-tailwindcss" "bradlc.vscode-tailwindcss",
"github.vscode-github-actions",
"unifiedjs.vscode-mdx",
"Codeium.codeium"
] ]
} }
}, },
// Use 'forwardPorts' to make a list of ports inside the container available locally. // Use 'forwardPorts' to make a list of ports inside the container available locally.
"forwardPorts": [3000], "forwardPorts": [3000],
// Use 'postCreateCommand' to run commands after the container is created. // Use 'postCreateCommand' to run commands after the container is created.
// "postCreateCommand": "yarn install", // "postCreateCommand": "yarn install",
// Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. // Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
"remoteUser": "node", "remoteUser": "node",
"features": { "features": {
"git": "os-provided", "git": "os-provided",
"git-lfs": "latest" "git-lfs": "latest",
"ghcr.io/devcontainers/features/docker-in-docker:2": {}
}, },
"mounts": [ "mounts": [],
"source=${localEnv:HOME}${localEnv:USERPROFILE}/.ssh,target=/home/node/.ssh,type=bind,consistency=cached" "postAttachCommand": "pnpm install && npm run dev"
],
"postStartCommand": "npm ci && npm run dev"
} }

View File

@ -1,172 +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: true
depends_on:
- linux-amd64
steps:
- name: deploy
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}}

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_REPO=
NEXT_PUBLIC_GISCUS_REPOSITORY_ID= NEXT_PUBLIC_GISCUS_REPOSITORY_ID=
NEXT_PUBLIC_GISCUS_CATEGORY= NEXT_PUBLIC_GISCUS_CATEGORY=
NEXT_PUBLIC_GISCUS_CATEGORY_ID= NEXT_PUBLIC_GISCUS_CATEGORY_ID=
NEXT_PUBLIC_UTTERANCES_REPO= NEXT_PUBLIC_UTTERANCES_REPO=
NEXT_PUBLIC_DISQUS_SHORTNAME= NEXT_PUBLIC_DISQUS_SHORTNAME=
NEXT_PUBLIC_CUSDIS_APPID=
NEXT_PUBLIC_CUSDIS_HOST=
NEXT_PUBLIC_COMMENTO_URL=
MAILCHIMP_API_KEY= MAILCHIMP_API_KEY=
MAILCHIMP_API_SERVER= MAILCHIMP_API_SERVER=
MAILCHIMP_AUDIENCE_ID= MAILCHIMP_AUDIENCE_ID=
BUTTONDOWN_API_URL=https://api.buttondown.email/v1/
BUTTONDOWN_API_KEY= BUTTONDOWN_API_KEY=
CONVERTKIT_API_URL=https://api.convertkit.com/v3/
CONVERTKIT_API_KEY= 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= CONVERTKIT_FORM_ID=
KLAVIYO_API_KEY= KLAVIYO_API_KEY=
KLAVIYO_LIST_ID= KLAVIYO_LIST_ID=
REVUE_API_URL=https://www.getrevue.co/api/v2/
REVUE_API_KEY= REVUE_API_KEY=
EMAILOCTOPUS_API_URL=https://emailoctopus.com/api/1.6/
EMAILOCTOPUS_API_KEY= EMAILOCTOPUS_API_KEY=
EMAILOCTOPUS_LIST_ID= EMAILOCTOPUS_LIST_ID=

View File

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

View File

@ -1,17 +1,42 @@
module.exports = { module.exports = {
root: true, root: true,
parser: '@typescript-eslint/parser',
env: { env: {
browser: true, browser: true,
amd: true, amd: true,
node: true, node: true,
es6: 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: { rules: {
'prettier/prettier': 'error', 'prettier/prettier': 'error',
'react/react-in-jsx-scope': 'off', 'react/react-in-jsx-scope': 'off',
'jsx-a11y/anchor-is-valid': [
'error',
{
components: ['Link'],
specialLink: ['hrefLeft', 'hrefRight'],
aspects: ['invalidHref', 'preferButton'],
},
],
'react/prop-types': 0, 'react/prop-types': 0,
'no-unused-vars': 0, '@typescript-eslint/no-unused-vars': 0,
'react/no-unescaped-entities': 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 ## 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 ## 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 /node_modules
/.pnp /.pnp
.pnp.js .pnp.js
/.yarn/*
!/.yarn/releases
!/.yarn/plugins
!/.yarn/sdks
# testing # testing
/coverage /coverage
@ -17,9 +21,13 @@ public/sitemap.xml
# production # production
/build /build
*.xml *.xml
# rss feed # rss feed
/public/feed.xml /public/feed.xml
# search
/public/search.json
# misc # misc
.DS_Store .DS_Store
@ -35,6 +43,9 @@ yarn-error.log*
.env.test.local .env.test.local
.env.production.local .env.production.local
# Contentlayer
.contentlayer
secrets.txt secrets.txt
.pnpm-store .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": [ "cSpell.words": [
"alpn", "alpn",
"appleboy", "appleboy",
@ -7,15 +9,21 @@
"Commento", "Commento",
"CONVERTKIT", "CONVERTKIT",
"Cusdis", "Cusdis",
"Discuz",
"Disqus", "Disqus",
"dokodemo", "dokodemo",
"EMAILOCTOPUS", "EMAILOCTOPUS",
"fullchain", "fullchain",
"Giscus", "Giscus",
"Hackintosh",
"KLAVIYO", "KLAVIYO",
"Kutt",
"lastmod", "lastmod",
"Logseq",
"MAILCHIMP", "MAILCHIMP",
"Miniflux",
"nextjs", "nextjs",
"Nuxt",
"outbounds", "outbounds",
"rprx", "rprx",
"unist", "unist",

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

View File

@ -12,11 +12,24 @@ FROM deps as build
WORKDIR /app WORKDIR /app
COPY . . COPY . .
COPY --from=deps /app ./ COPY --from=deps /app ./
RUN pnpm build &&\ RUN pnpm build
pnpm prune --prod --config.ignore-scripts=true
FROM build as pre-release
WORKDIR /app
RUN pnpm prune --prod --config.ignore-scripts=true
FROM node:16-alpine as release FROM node:16-alpine as release
WORKDIR /app WORKDIR /app
COPY --from=build /app ./ COPY --from=pre-release /app ./
EXPOSE 80 EXPOSE 80
CMD npm run serve -- -p 80 CMD npm run serve -- -p 80
FROM build as export
WORKDIR /app
RUN npm run export
FROM alpine:latest as upload
RUN apk add lftp
WORKDIR /app
COPY --from=export /app/out ./
CMD lftp -u "${FTP_ACCOUNT},${FTP_PASSWORD}" "${FTP_HOST}" -e 'set ftp:ssl-allow off && set use-feat no && mirror -c -R --use-pget-n=10 . ./WEB && exit'

171
README.md
View File

@ -1,11 +1,17 @@
![tailwind-nextjs-banner](/public/static/images/twitter-card.png) ![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) [![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/)
[![Website Status](https://uptime.sg.ivanli.cc/api/badge/18/uptime/720?label=30&labelSuffix=d)](https://ivanli.cc) [![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)
This is a [Next.js](https://nextjs.org/), [Tailwind CSS](https://tailwindcss.com/) blogging starter template. Probably the most feature-rich Next.js markdown blogging template out there. Comes out of the box configured with the latest technologies to make technical writing a breeze. Easily configurable and customizable. Perfect as a replacement to existing Jekyll and Hugo individual blogs. [![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. Check out the documentation below to get started.
@ -13,28 +19,84 @@ Facing issues? Check the [FAQ page](https://github.com/timlrx/tailwind-nextjs-st
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! 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 ## 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 - Easy styling customization with [Tailwind 3.0](https://tailwindcss.com/blog/tailwindcss-v3) and primary color attribute
- Near perfect lighthouse score - [Lighthouse report](https://www.webpagetest.org/result/210111_DiC1_08f3670c3430bf4a9b76fc3b927716c5/) - [MDX - write JSX in markdown documents!](https://mdxjs.com/)
- Lightweight, 45kB first load JS, uses Preact in production build - Near perfect lighthouse score - [Lighthouse report](https://www.webpagetest.org/result/230805_BiDcBQ_4H7)
- Lightweight, 85kB first load JS
- Mobile-friendly view - Mobile-friendly view
- Light and dark theme - Light and dark theme
- Self-hosted font with [Fontsource](https://fontsource.org/) - Font optimization with [next/font](https://nextjs.org/docs/app/api-reference/components/font)
- Supports [plausible](https://plausible.io/), [simple analytics](https://simpleanalytics.com/) and google analytics - Integration with [pliny](https://github.com/timlrx/pliny) that provides:
- [MDX - write JSX in markdown documents!](https://mdxjs.com/) - 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) - 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/) - Math display supported via [KaTeX](https://katex.org/)
- Citation and bibliography support via [rehype-citation](https://github.com/timlrx/rehype-citation) - 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) - Automatic image optimization via [next/image](https://nextjs.org/docs/basic-features/image-optimization)
- Flexible data retrieval with [mdx-bundler](https://github.com/kentcdodds/mdx-bundler)
- Support for tags - each unique tag will be its own page - Support for tags - each unique tag will be its own page
- Support for multiple authors - Support for multiple authors
- Blog templates - 3 different blog layouts
- TOC component - 2 different blog listing layouts
- Support for nested routing of blog posts - Support for nested routing of blog posts
- Newsletter component with support for mailchimp, buttondown, convertkit, klaviyo, revue, and emailoctopus
- Supports [giscus](https://github.com/laymonage/giscus), [utterances](https://github.com/utterance/utterances) or disqus
- Projects page - Projects page
- Preconfigured security headers - Preconfigured security headers
- SEO friendly with RSS feed, sitemaps and more! - SEO friendly with RSS feed, sitemaps and more!
@ -50,30 +112,15 @@ Feature request? Check the past discussions to see if it has been brought up pre
## Quick Start Guide ## Quick Start Guide
1. Try installing the starter using the new [Pliny project CLI](https://github.com/timlrx/pliny): 1. Clone the repo
```bash ```bash
npm i -g @pliny/cli npx degit 'timlrx/tailwind-nextjs-starter-blog'
pliny new --template=starter-blog my-blog
```
It supports the updated version of the blog with Contentlayer, optional choice of TS/JS and different package managers as well as more modularized components which will be the basis of the template going forward.
Alternatively to stick with the current version, TypeScript and Contentlayer:
```bash
npx degit 'timlrx/tailwind-nextjs-starter-blog#contentlayer'
```
or JS (official support)
```bash
npx degit https://github.com/timlrx/tailwind-nextjs-starter-blog.git
``` ```
2. Personalize `siteMetadata.js` (site related information) 2. Personalize `siteMetadata.js` (site related information)
3. Modify the content security policy in `next.config.js` if you want to use 3. Modify the content security policy in `next.config.js` if you want to use
any analytics provider or a commenting solution other than giscus. other analytics provider or a commenting solution other than giscus.
4. Personalize `authors/default.md` (main author) 4. Personalize `authors/default.md` (main author)
5. Modify `projectsData.js` 5. Modify `projectsData.js`
6. Modify `headerNavLinks.js` to customize navigation links 6. Modify `headerNavLinks.js` to customize navigation links
@ -83,7 +130,7 @@ npx degit https://github.com/timlrx/tailwind-nextjs-starter-blog.git
## Installation ## Installation
```bash ```bash
npm install yarn
``` ```
## Development ## Development
@ -91,18 +138,12 @@ npm install
First, run the development server: First, run the development server:
```bash ```bash
npm start yarn dev
```
or
```bash
npm run dev
``` ```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 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 ## Extend / Customize
@ -120,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. `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). `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. `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 ## 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
Frontmatter follows [Hugo's standards](https://gohugo.io/content-management/front-matter/). 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) title (required)
date (required) date (required)
tags (required, can be empty array) tags (optional)
lastmod (optional) lastmod (optional)
draft (optional) draft (optional)
summary (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) 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`) layout (optional list which should correspond to the file names in `data/layouts`)
canonicalUrl (optional, canonical url for the post for SEO) canonicalUrl (optional, canonical url for the post for SEO)
@ -172,26 +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 ## Deploy
**Vercel** **Vercel**
The easiest way to deploy the template is to use the [Vercel Platform](https://vercel.com) from the creators of Next.js. Check out the [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 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 / GitHub Pages / Firebase etc.** **Netlify**
As the template uses `next/image` for image optimization, additional configurations have to be made to deploy on other popular static hosting websites like [Netlify](https://www.netlify.com/) or [GitHub Pages](https://pages.github.com/). An alternative image optimization provider such as Imgix, Cloudinary or Akamai has to be used. Alternatively, replace the `next/image` component with a standard `<img>` tag. See [`next/image` documentation](https://nextjs.org/docs/basic-features/image-optimization) for more details. [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.
The API routes used in the newsletter component cannot be used in a static site export. You will need to use a form API endpoint provider and substitute the route in the newsletter component accordingly. Other hosting platforms such as Netlify also offer alternative solutions - please refer to their docs for more information. 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 ## 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). 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).
## License ## Licence
[MIT](https://github.com/timlrx/tailwind-nextjs-starter-blog/blob/master/LICENSE) © [Timothy Lin](https://www.timlrx.com) [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 Link from '@/components/Link';
import { PageSEO } from '@/components/SEO';
import Tag from '@/components/Tag'; import Tag from '@/components/Tag';
import siteMetadata from '@/data/siteMetadata'; import siteMetadata from '@/data/siteMetadata';
import { getAllFilesFrontMatter } from '@/lib/mdx'; import { formatDate } from 'pliny/utils/formatDate';
import formatDate from '@/lib/utils/formatDate'; import NewsletterForm from 'pliny/ui/NewsletterForm';
import { GetStaticProps, InferGetStaticPropsType } from 'next';
import { PostFrontMatter } from 'types/PostFrontMatter';
import NewsletterForm from '@/components/NewsletterForm';
const MAX_DISPLAY = 5; const MAX_DISPLAY = 5;
export const getStaticProps: GetStaticProps<{ export default function Home({ posts }) {
posts: PostFrontMatter[];
}> = async () => {
const posts = await getAllFilesFrontMatter('blog');
return { props: { posts } };
};
export default function Home({
posts,
}: InferGetStaticPropsType<typeof getStaticProps>) {
return ( return (
<> <>
<PageSEO
title={siteMetadata.title}
description={siteMetadata.description}
/>
<div className="divide-y divide-gray-200 dark:divide-gray-700"> <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"> <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> </h1>
<p className="text-lg leading-7 text-gray-500 dark:text-gray-400"> <p className="text-lg leading-7 text-gray-500 dark:text-gray-400">
{siteMetadata.description} {siteMetadata.description}
</p> </p>
</div> </div>
<ul className="divide-y divide-gray-200 dark:divide-gray-700"> <ul className="divide-y divide-gray-200 dark:divide-gray-700">
{!posts.length && '没有找到文章。 😭'} {!posts.length && 'No posts found.'}
{posts.slice(0, MAX_DISPLAY).map((frontMatter) => { {posts.slice(0, MAX_DISPLAY).map((post) => {
const { slug, date, title, summary, tags } = frontMatter; const { slug, date, title, summary, tags } = post;
return ( return (
<li key={slug} className="py-12"> <li key={slug} className="py-12">
<article> <article>
@ -47,7 +29,9 @@ export default function Home({
<dl> <dl>
<dt className="sr-only">Published on</dt> <dt className="sr-only">Published on</dt>
<dd className="text-base font-medium leading-6 text-gray-500 dark:text-gray-400"> <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> </dd>
</dl> </dl>
<div className="space-y-5 xl:col-span-3"> <div className="space-y-5 xl:col-span-3">
@ -91,12 +75,12 @@ export default function Home({
<Link <Link
href="/blog" href="/blog"
className="text-primary-500 hover:text-primary-600 dark:hover:text-primary-400" className="text-primary-500 hover:text-primary-600 dark:hover:text-primary-400"
aria-label="all posts"> aria-label="All posts">
All Posts &rarr; All Posts &rarr;
</Link> </Link>
</div> </div>
)} )}
{siteMetadata.newsletter.provider !== '' && ( {siteMetadata.newsletter?.provider && (
<div className="flex items-center justify-center pt-4"> <div className="flex items-center justify-center pt-4">
<NewsletterForm /> <NewsletterForm />
</div> </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'; import Link from '@/components/Link';
export default function FourZeroFour() { export default function NotFound() {
return ( 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="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"> <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 404
</h1> </h1>
@ -15,10 +15,10 @@ export default function FourZeroFour() {
<p className="mb-8"> <p className="mb-8">
But dont worry, you can find plenty of other things on our homepage. But dont worry, you can find plenty of other things on our homepage.
</p> </p>
<Link href="/"> <Link
<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"> href="/"
Back to homepage 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">
</button> Back to homepage
</Link> </Link>
</div> </div>
</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 projectsData from '@/data/projectsData';
import Card from '@/components/Card'; 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() { export default function Projects() {
return ( return (
<> <>
<PageSEO
title={`Projects - ${siteMetadata.author}`}
description={siteMetadata.description}
/>
<div className="divide-y divide-gray-200 dark:divide-gray-700"> <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"> <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 Projects
</h1> </h1>
<p className="text-lg leading-7 text-gray-500 dark:text-gray-400"> <p className="text-lg leading-7 text-gray-500 dark:text-gray-400">
Showcase your projects with a hero image (16 x 9)
</p> </p>
</div> </div>
<div className="container py-12"> <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'; import Link from './Link';
const Card = ({ title, description, imgSrc, href }) => ( 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 <div
className={`${ className={`${
imgSrc && 'h-full' 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

@ -25,10 +25,13 @@ export default function Footer() {
<div>{``}</div> <div>{``}</div>
<Link href="/">{siteMetadata.title}</Link> <Link href="/">{siteMetadata.title}</Link>
</div> </div>
<div className="mb-2 text-sm text-gray-500 dark:text-gray-400">
<Link href="https://beian.miit.gov.cn" rel="nofollow">
ICP备2023000043号
</Link>
</div>
<div className="mb-8 text-sm text-gray-500 dark:text-gray-400"> <div className="mb-8 text-sm text-gray-500 dark:text-gray-400">
<Link <Link href="https://github.com/timlrx/tailwind-nextjs-starter-blog">
href="https://github.com/timlrx/tailwind-nextjs-starter-blog"
rel="nofollow">
Tailwind Nextjs Theme Tailwind Nextjs Theme
</Link> </Link>
</div> </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
const Image = ({ ...rest }: ImageProps) => <NextImage {...rest} />; }: React.DetailedHTMLProps<
React.ImgHTMLAttributes<HTMLImageElement>,
HTMLImageElement
>) => <img {...rest} />;
export default Image; export default Image;

View File

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

View File

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

View File

@ -1,39 +1,14 @@
/* eslint-disable react/display-name */ import TOCInline from 'pliny/ui/TOCInline';
import React, { useMemo } from 'react'; import Pre from 'pliny/ui/Pre';
import { ComponentMap, getMDXComponent } from 'mdx-bundler/client'; import BlogNewsletterForm from 'pliny/ui/BlogNewsletterForm';
import type { MDXComponents } from 'mdx/types';
import Image from './Image'; import Image from './Image';
import CustomLink from './Link'; import CustomLink from './Link';
import TOCInline from './TOCInline';
import Pre from './Pre';
import { BlogNewsletterForm } from './NewsletterForm';
const Wrapper: React.ComponentType<{ layout: string }> = ({ export const components: MDXComponents = {
layout,
...rest
}) => {
const Layout = require(`../layouts/${layout}`).default;
return <Layout {...rest} />;
};
export const MDXComponents: ComponentMap = {
Image, Image,
//@ts-ignore
TOCInline, TOCInline,
a: CustomLink, a: CustomLink,
pre: Pre, pre: Pre,
wrapper: Wrapper,
//@ts-ignore
BlogNewsletterForm, 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 { useState } from 'react';
import Link from './Link'; import Link from './Link';
import headerNavLinks from '@/data/headerNavLinks'; import headerNavLinks from '@/data/headerNavLinks';
@ -18,41 +20,45 @@ const MobileNav = () => {
}; };
return ( return (
<div className="sm:hidden"> <>
<button <button
type="button"
className="ml-1 mr-1 h-8 w-8 rounded py-1"
aria-label="Toggle Menu" aria-label="Toggle Menu"
onClick={onToggleNav}> onClick={onToggleNav}
className="sm:hidden">
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20" viewBox="0 0 20 20"
fill="currentColor" fill="currentColor"
className="text-gray-900 dark:text-gray-100"> className="text-gray-900 dark:text-gray-100 h-8 w-8">
{navShow ? ( <path
<path fillRule="evenodd"
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"
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"
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"
/>
)}
</svg> </svg>
</button> </button>
<div <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' navShow ? 'translate-x-0' : 'translate-x-full'
}`}> }`}>
<button <div className="flex justify-end">
type="button" <button
aria-label="toggle modal" className="mr-8 mt-11 h-8 w-8"
className="fixed h-full w-full cursor-auto focus:outline-none" aria-label="Toggle Menu"
onClick={onToggleNav}></button> 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"> <nav className="fixed mt-8 h-full">
{headerNavLinks.map((link) => ( {headerNavLinks.map((link) => (
<div key={link.title} className="px-12 py-4"> <div key={link.title} className="px-12 py-4">
@ -66,7 +72,7 @@ const MobileNav = () => {
))} ))}
</nav> </nav>
</div> </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'; import { useEffect, useState } from 'react';
const ScrollTopAndComment = () => { const ScrollTopAndComment = () => {
@ -14,32 +17,32 @@ const ScrollTopAndComment = () => {
}, []); }, []);
const handleScrollTop = () => { const handleScrollTop = () => {
window.scrollTo({ top: 0, behavior: 'smooth' }); window.scrollTo({ top: 0 });
}; };
const handleScrollToComment = () => { const handleScrollToComment = () => {
document.getElementById('comment').scrollIntoView(); document.getElementById('comment')?.scrollIntoView();
}; };
return ( return (
<div <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' show ? 'md:flex' : 'md:hidden'
}`}> }`}>
<button {siteMetadata.comments?.provider && (
aria-label="Scroll To Comment" <button
type="button" aria-label="Scroll To Comment"
onClick={handleScrollToComment} 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"> 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"> <svg className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path <path
fillRule="evenodd" 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" 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" clipRule="evenodd"
/> />
</svg> </svg>
</button> </button>
)}
<button <button
aria-label="Scroll To Top" aria-label="Scroll To Top"
type="button"
onClick={handleScrollTop} onClick={handleScrollTop}
className="rounded-full bg-gray-200 p-2 text-gray-500 transition-all hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-400 dark:hover:bg-gray-600"> className="rounded-full bg-gray-200 p-2 text-gray-500 transition-all hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-400 dark:hover:bg-gray-600">
<svg className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"> <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) { export default function SectionContainer({ children }: Props) {
return ( 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} {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 Link from 'next/link';
import kebabCase from '@/lib/utils/kebabCase'; import { slug } from 'github-slugger';
interface Props { interface Props {
text: string; text: string;
} }
const Tag = ({ text }: Props) => { const Tag = ({ text }: Props) => {
return ( return (
<Link href={`/tags/${kebabCase(text)}`}> <Link
<a className="mr-3 text-sm font-medium uppercase text-primary-500 hover:text-primary-600 dark:hover:text-primary-400"> href={`/tags/${slug(text)}`}
{text.split(' ').join('-')} className="mr-3 text-sm font-medium uppercase text-primary-500 hover:text-primary-600 dark:hover:text-primary-400">
</a> {text.split(' ').join('-')}
</Link> </Link>
); );
}; };

View File

@ -1,29 +1,29 @@
'use client';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useTheme } from 'next-themes'; import { useTheme } from 'next-themes';
const ThemeSwitch = () => { const ThemeSwitch = () => {
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
const { theme, setTheme, resolvedTheme } = useTheme(); const { theme, setTheme } = useTheme();
// When mounted on client, now we can show the UI // When mounted on client, now we can show the UI
useEffect(() => setMounted(true), []); useEffect(() => setMounted(true), []);
if (!mounted) {
return null;
}
return ( return (
<button <button
aria-label="Toggle Dark Mode" aria-label="Toggle Dark Mode"
type="button" onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>
className="ml-1 mr-1 h-8 w-8 rounded p-1 sm:ml-4"
onClick={() =>
setTheme(
theme === 'dark' || resolvedTheme === 'dark' ? 'light' : 'dark'
)
}>
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20" viewBox="0 0 20 20"
fill="currentColor" fill="currentColor"
className="text-gray-900 dark:text-gray-100"> className="text-gray-900 dark:text-gray-100 h-6 w-6">
{mounted && (theme === 'dark' || resolvedTheme === 'dark') ? ( {mounted && theme === 'dark' ? (
<path <path
fillRule="evenodd" 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" 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 {
import Github from './github.svg'; Mail,
import Facebook from './facebook.svg'; Github,
import Youtube from './youtube.svg'; Facebook,
import Linkedin from './linkedin.svg'; Youtube,
import Twitter from './twitter.svg'; Linkedin,
Twitter,
// Icons taken from: https://simpleicons.org/ Mastodon,
} from './icons';
const components = { const components = {
mail: Mail, mail: Mail,
@ -14,9 +15,16 @@ const components = {
youtube: Youtube, youtube: Youtube,
linkedin: Linkedin, linkedin: Linkedin,
twitter: Twitter, 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 ( if (
!href || !href ||
(kind === 'mail' && (kind === 'mail' &&
@ -34,7 +42,7 @@ const SocialIcon = ({ kind, href, size = 8 }) => {
href={href}> href={href}>
<span className="sr-only">{kind}</span> <span className="sr-only">{kind}</span>
<SocialSvg <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> </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 */ /* Code title styles */
.remark-code-title { .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 { .remark-code-title + div > pre {
@ -138,3 +138,7 @@
.token.table { .token.table {
display: inline; display: inline;
} }
.token.table {
display: inline;
}

View File

@ -11,7 +11,11 @@
} }
.footnotes { .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 { .csl-entry {
@ -21,5 +25,7 @@
/* https://stackoverflow.com/questions/61083813/how-to-avoid-internal-autofill-selected-style-to-be-applied */ /* https://stackoverflow.com/questions/61083813/how-to-avoid-internal-autofill-selected-style-to-be-applied */
input:-webkit-autofill, input:-webkit-autofill,
input:-webkit-autofill:focus { 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 name: Ivan Li
avatar: https://pan.ivanli.cc/api/v3/file/source/1234/%E5%A4%B4%E5%83%8F.png?sign=xIgy54DyFRYupxjZJbK02HmpKX8C53YR-O0I18Rxm70%3D%3A0 avatar: https://minio.ivanli.cc/ivan-public/uPic/2023/Urpetm.png
occupation: Web Full Stack Developer occupation: Web Full Stack Developer
email: master@ivanli.cc email: master@ivanli.cc
github: https://github.com/IvanLi-CN 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

@ -0,0 +1,16 @@
---
title: 搭建日常使用的 Arch Linux
date: '2022-10-17'
tags: ['Arch Linux', '环境搭建', 'VPS']
draft: false
summary: 有了上次快速安装步骤后,接下来就是使用这个环境了。要使用环境,首先需要做一些初始化操作。我的步骤适合我,但不一定适合你,只是记录和参考。
images: ['https://minio.ivanli.cc/ivan-public/uPic/2023/RTr3IU.png']
---
## Docker
登录私有仓库,以便拉取镜像。
```zsh
docker login -u="ivan+hk_nat" docker-registry.ivanli.cc
```

View File

@ -22,6 +22,11 @@ Arch Linux 准入门槛确实有点高,在 PVE 中,使用 LCX 容器运行 A
位置(Location) 位置(Location)
先编辑 `/etc/locale.gen`,取消 `en_US.UTF-8 UTF-8` 的注释。 先编辑 `/etc/locale.gen`,取消 `en_US.UTF-8 UTF-8` 的注释。
```bash
sed -i "s/#en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/" /etc/locale.gen
```
然后执行: 然后执行:
```bash ```bash
@ -69,10 +74,17 @@ nano /etc/pacman.d/mirrorlist
``` ```
选择你喜欢并且方便连接的镜像,然后删除该行的“#”取消注释。可以选择一个或多个,在前面的优先级高。 选择你喜欢并且方便连接的镜像,然后删除该行的“#”取消注释。可以选择一个或多个,在前面的优先级高。
开启并行下载,在 `/etc/pacman.conf` 中取消 `ParallelDownloads` 前的注释,值为并行下载数:
```bash
sed -i "s/#ParallelDownloads = 5/ParallelDownloads = 5/" /etc/pacman.conf
```
接下来我们更新已安装的软件,我们的哲学就是时刻保持最新。 接下来我们更新已安装的软件,我们的哲学就是时刻保持最新。
```bash ```bash
pacman -Syu pacman -Syu
``` ```
**一般来说,执行上面的命令后,会拉取索引数据库,之后会优先更新 `archlinuxx-keyring`。如果不是这样的话,应当手动执行下面的代码:** **一般来说,执行上面的命令后,会拉取索引数据库,之后会优先更新 `archlinuxx-keyring`。如果不是这样的话,应当手动执行下面的代码:**
@ -98,6 +110,12 @@ _参考[Cant Upgrade because of keyring - Technical Issues and Assistance / P
### 3. 创建用户 ### 3. 创建用户
首先,安装 `sudo`
```bash
pacman -S sudo
```
让我们给自己分配一个具有 sudo 权限的账户 让我们给自己分配一个具有 sudo 权限的账户
```zsh ```zsh
@ -113,12 +131,6 @@ _参考[Create a Sudo User on Arch Linux - Vultr.com](https://www.vultr.com/d
EDITOR=vim visudo EDITOR=vim visudo
``` ```
安装 `sudo`
```bash
pacman -S sudo
```
接下来使用刚刚创建的用户登录吧! 接下来使用刚刚创建的用户登录吧!
### 4. 使用 SSH 远程登录 ### 4. 使用 SSH 远程登录

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

@ -0,0 +1,178 @@
---
title: 在 PVE 宿主机上使用桌面环境
date: '2022-10-28'
tags: ['PVE', 'DE', '环境搭建', 'Debian']
draft: false
summary: 虽然 PVE 宿主机不应该安装乱七八糟的东西,但是我穷,为了物尽其用,为了在主力电脑翻车时有一个立即可用的备用环境,所以还是安装了基础的桌面环境。现在的 Linux 桌面环境越来越好了,我选择安装 KDE Plasma 作为桌面环境,并且默认关闭,按需启用。
images: ['https://minio.ivanli.cc/ivan-public/uPic/2023/qldEtP.png']
---
## 前言
过几天就是双十一了,或许是我去购物网站上看了下显卡价格吧,我的显卡当晚就闹情绪,不工作还引发宕机。虽然拔了显卡,我还能用核显开机,但是我懒呀,所以我花了一个晚上在 PVE 宿主机上搭了一个临时环境,用于日常娱乐(看番、听歌)和一般工作(敲代码)。还别说,我在一开始装 PVE 时,就预先装上了桌面环境,这就是预判呀!
现在 Linux 桌面环境已经非常好了,相比 17 年左右的体验,又上了一个新的台阶。不过,作为临时应急环境,倒也不会去装那些没啥用的国产软件,本着够用就好的原则,主要是以 Web App > Web > Linux Client 的顺序挑选软件。一般来说,我用到的也不多:
- **浏览器Google Chrome**。主要是好用,能同步,还能远程桌面。
## 准备
首先应该拥有自己的账户,否则你将会发现自己无法登录桌面环境。因为桌面环境默认在登录时没有 `root` 用户选项。
### 创建账户:
```bash
useradd -m ivan
passwd ivan
usermod -aG wheel ivan
```
给刚刚创建的账户分配一个具有 sudo 权限的账户
```bash
EDITOR=vim visudo
```
找到 `%wheel ALL=(ALL: ALL) ALL` 这行,取消这行的注释。
现在,你自己的账号具有 sudo 权限了。
### 生成 SSH 密钥
2022 年,应该生成 `ed25519` 算法的密钥:
```bash
ssh-keygen -t ed25519
```
## 启用和禁用桌面环境
**使用 `root` 账户执行下面的命令!**
查看当前的默认目标:
```bash
systemctl get-default
```
临时禁用图形界面:
```bash
init 3
```
临时启用图形界面:
```bash
init 5
```
永久禁用图形界面:重启生效:
```bash
systemctl set-default multi-user.target
```
永久启用图形界面,重启生效:
```bash
systemctl set-default graphical.target
```
## Google Chrome Browser
安装方式就是直接[官网下载](https://www.google.com/chrome/)。下载完成后双击打开安装。
或者通过命令行安装:
```bash
wget https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb
sudo apt install ./google-chrome-stable_current_amd64.deb
```
安装过程中可能会出错,可以使用命令进行安装,然后根据提示修复问题。修复过程中可能会重启电脑。具体情况我没留意,下次遇到的话再补充,嘿嘿。
## VS Code
同样从官网下载安装:[Download Visual Studio Code - Mac, Linux, Windows](https://code.visualstudio.com/download)
### 同步问题
参考:[Visual Studio Code 中的设置同步](https://code.visualstudio.com/docs/editor/settings-sync#_linux)
我用的是 KDE Plasma似乎[再等等](https://github.com/microsoft/vscode/issues/104319#issuecomment-1250089491)就能直接正常使用了,所以我先忍受同步问题吧。
## 中文输入法
我使用 iBus + Rime + 小鹤音形.
执行以下命令安装 iBus + Rime
```bash
sudo apt install ibus ibus-rime
```
接下来配置小鹤音形方案。
访问[小鹤的网盘](http://flypy.ysepan.com/)下载小鹤音形的挂接文件,小狼毫、鼠须管的都可以。
下载完成后解压出来,把压缩文件里的 `rime` 目录复制到 `/home/ivan/.config/ibus/rime`
```bash
# 如果你没有 unzip通过下面命令安装
# sudo apt install unzip
cd ~/Downloads
unzip '小鹤音形“鼠须管”for macOS.zip'
cd '小鹤音形Rime平台鼠须管for macOS'
cp -r ./rime ~/.config/ibus/rime
```
创建 `~/.config/ibus/rime/default.custom.yaml` 文件,并设为以下内容:
```yaml
patch:
schema_list:
- { schema: flypy }
- { schema: luna_pinyin }
```
参考:[分享我的输入法配置 Rime 小狼豪 + 小鹤音形) - 炒饭之道](https://itx.ink/2018/11/21/SHARE_MY_RIME/)
配置 iBus 环境变量:
```bash
cat >> ~/.zshrc <<EOF
# ibus
export GTK_IM_MODULE=ibus
export XMODIFIERS=@im=ibus
export QT_IM_MODULE=ibus
EOF
```
启动 ibus
```bash
ibus-setup
```
在打开的 GUI 中添加中文输入法,找到 Rime 并添加输入法:
![rime](https://minio.ivanli.cc/ivan-public/uPic/2023/E4SWeR.png)
现在,新打开的软件应该能使用输入法了。像 Chrome 这类,关闭后还需要手动杀死进程后再打开才能使用。最简单的方法就是重启电脑啦~
## 快捷键
我习惯使用 Mac OS 系统的快捷键,所以 [Kinto](https://github.com/rbreaves/kinto) 是我的不二之选。key
安装:
```bash
/bin/bash -c "$(wget -qO- https://raw.githubusercontent.com/rbreaves/kinto/HEAD/install/linux.sh || curl -fsSL https://raw.githubusercontent.com/rbreaves/kinto/HEAD/install/linux.sh)"
```
卸载:
```bash
/bin/bash <( wget -qO- https://raw.githubusercontent.com/rbreaves/kinto/HEAD/install/linux.sh || curl -fsSL https://raw.githubusercontent.com/rbreaves/kinto/HEAD/install/linux.sh ) -r
```

View File

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

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'] tags: ['Arch Linux', '环境搭建', 'VPS']
draft: false draft: false
summary: 又到了新装 Arch Linux 的日子了。这次又是温故而知新的机会,把之前写的笔记稍微整理了一下,在这里记录下教徒搭窝的备忘录。 summary: 又到了新装 Arch Linux 的日子了。这次又是温故而知新的机会,把之前写的笔记稍微整理了一下,在这里记录下教徒搭窝的备忘录。
images: images: ['https://minio.ivanli.cc/ivan-public/uPic/2023/RTr3IU.png']
[
'https://pan.ivanli.cc/api/v3/file/source/2238/archlinux-logo-light.png?sign=bWxqFFy3RUDT5UsWb4UD5byt-_L4h79wede3runRKFc%3D%3A0',
]
--- ---
## 起势 ## 起势
@ -31,7 +28,15 @@ chmod +x vps2arch
./vps2arch ./vps2arch
``` ```
等待几分钟就完成了。如果是中国大陆境内的机子,建议全局代理或使用自定义的系统镜像源。可以从[这个网站](https://archlinux.org/mirrorlist/?country=HK&protocol=https&use_mirror_status=on)获取镜像地址。地址上有查询参数,可以根据自己需要修改。 等待几分钟就完成了。如果是中国大陆境内的机子,建议全局代理或使用自定义的系统镜像源。可以从下面的网站获取镜像地址。地址上有查询参数,可以根据自己需要修改。
> [https://archlinux.org/mirrorlist/?country=HK&protocol=https&use_mirror_status=on](https://archlinux.org/mirrorlist/?country=HK&protocol=https&use_mirror_status=on)
推荐使用 `https://hkg.mirror.rackspace.com/archlinux/`
```bash
./vps2arch -m 'https://hkg.mirror.rackspace.com/archlinux/'
```
如果系统装不上,可以在 IDC 面板上重装其他系统后再试,推荐使用 Debian。 如果系统装不上,可以在 IDC 面板上重装其他系统后再试,推荐使用 Debian。
@ -57,7 +62,7 @@ ssh-keygen -R '[20.20.20.20]:20000'
设置主机名: 设置主机名:
```bash ```bash
sudo hostnamectl set-hostname arch.example.com hostnamectl set-hostname arch.example.com
``` ```
启用 pacman 并行下载: 启用 pacman 并行下载:
@ -145,9 +150,12 @@ zinit ice depth=1; zinit light romkatv/powerlevel10k' >> ~/.zshrc
避免优先匹配到子目录,在 `.zshrc` 中添加如下行: 避免优先匹配到子目录,在 `.zshrc` 中添加如下行:
```zsh ```zsh
cat >> ~/.zshrc <<EOF
# zsh-z # zsh-z
ZSHZ_UNCOMMON=1 ZSHZ_UNCOMMON=1
ZSHZ_TRAILING_SLASH=1 ZSHZ_TRAILING_SLASH=1
EOF
``` ```
#### History #### History
@ -155,15 +163,37 @@ ZSHZ_TRAILING_SLASH=1
配置历史记录,在 `.zshrc` 中添加如下行: 配置历史记录,在 `.zshrc` 中添加如下行:
```zsh ```zsh
cat >> ~/.zshrc <<EOF
# History
HISTFILE=~/.zsh_history HISTFILE=~/.zsh_history
HISTSIZE=10000 HISTSIZE=10000
SAVEHIST=1000 SAVEHIST=1000
setopt INC_APPEND_HISTORY_TIME setopt INC_APPEND_HISTORY_TIME
EOF
``` ```
详细配置参考:[Better zsh history | SoberKoder](https://www.soberkoder.com/better-zsh-history/) 详细配置参考:[Better zsh history | SoberKoder](https://www.soberkoder.com/better-zsh-history/)
文档:[zsh: 16 Options](https://zsh.sourceforge.io/Doc/Release/Options.html) 文档:[zsh: 16 Options](https://zsh.sourceforge.io/Doc/Release/Options.html)
然后进入到 `zsh` 中,执行一次 `source ~/.zshrc`
```shell
zsh
source ~/.zshrc
```
设置 Zsh 为默认的 shell 程序:
```bash
# 列出所有已安装的 shell 程序
chsh -l
# 从上面的结果中找到 zsh 的完整路径
# 我的是 /bin/zsh
chsh -s /bin/zsh
```
### Docker ### Docker
安装 Docker 和 Docker Compose 也很简单: 安装 Docker 和 Docker Compose 也很简单:

View File

@ -1,8 +1,19 @@
--- ---
title: 利用一台小鸡实现网络自由 title: 利用一台小鸡实现网络自由
date: '2022-10-06' date: '2022-10-06'
tags: ['SNI', 'TLS', 'Reverse Proxy', '反向代理', '正向代理', ‘内网穿透', 'Caddy', 'Xray', 'Vless'] tags:
draft: false [
'SNI',
'TLS',
'Reverse Proxy',
'反向代理',
'正向代理',
‘内网穿透',
'Caddy',
'Xray',
'Vless',
]
draft: true
summary: SNI Proxy 进行 TLS 分流Caddy 对网站和 Xray 进行反向代理Xray 实现正向、反向代理(内网穿透)。 summary: SNI Proxy 进行 TLS 分流Caddy 对网站和 Xray 进行反向代理Xray 实现正向、反向代理(内网穿透)。
--- ---

View File

@ -0,0 +1,74 @@
---
title: React 18新的严格模式
date: '2023-02-06'
tags: ['React']
draft: false
summary: 好好的 useMemo、useEffect 居然执行了两次,我明明传入了依赖,为什么会执行两次呢?原来是 React 18 的破坏性改动!
---
之前在开发模式时,一直记得 `useMemo` 在严格模式下不会二次执行, `useEffect` 在有传入 `deps` 时也不会二次执行。而今天我在排下面这段代码时,发现一个要命的事情!
```tsx
const camera = useMemo(/* .. */, []);
const renderer = props; // from parent
const controls = useMemo(
() => new OrbitControls(camera, renderer.domElement),
[camera, renderer],
);
useEffect(() => {
// 同一个 controls 进入两次
return () => {
controls.dispose(); // 执行一次controls 被释放
};
}, [controls]);
```
`controls` 生成了两个,第一个似乎没用到了,第二个是后续要正常使用的对象,并且进入了 `useEffect` 中,而且进去了两次!
这导致两个严重的问题就是,第一个 `controls` 没有被销毁,第二个 `controls` 被销毁了!
第一个没销毁是内存泄漏,第二个被销毁了导致 `controls` 对象不可用了!
我一度以为是我对 useEffect 特性的记忆出现了偏差,后来我在官方文档翻了半天没啥收获,指到我看见了更新说明里的 [Stricter Strict Mode](https://github.com/facebook/react/blob/main/CHANGELOG.md#1820-june-14-2022:~:text=Stricter%20Strict%20Mode)。
## 新的严格模式
从更新日志可以看到,新的严格模式会自动卸载并再次重新挂载每个组件,这就解释了为什么 `useMemo` 和 `useEffect` 即使在有传入 `deps` 也会多执行一次。
这个特性是破坏性的,会影响之前版本的程序逻辑,所以 React 在[更新说明](https://github.com/facebook/react/blob/main/CHANGELOG.md#1820-june-14-2022:~:text=If%20this%20breaks%20your%20app%2C%20consider%20removing%20Strict%20Mode%20until%20you%20can%20fix%20the%20components%20to%20be%20resilient%20to%20remounting%20with%20existing%20state.)也建议如果旧的应用因为这个出现兼容性问题,建议先关掉 `strictMode`。
### 在 React 18 的测试代码
![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)
红色是第一次渲染,绿色是第二次渲染。输出日志里淡些的是 React 18 在二次调用时输出的 log 的默认效果,在 React 17 中是被默认隐藏的。蓝色指向的是最终应用在界面上呈现的结果。
我在 V 站上也提出了我的[疑问](https://v2ex.com/t/913595),根据大佬们的回复,我总结了一下:
1. `useMemo(() => /* */, [])` 执行一此后,以新的严格模式的规则,进行了二次调用,第一次的值作废。
2. `useEffect(() => /* */, [])`执行一此后,以新的严格模式的规则,调用了 `destructor` 后,进行了二次调用。
在第 2 点中,两次 useEffect 都是使用同一个值,是因为严格模式的二次调用按钩子分别执行两次,所以 useMemo 两次的调用都完毕后,得到的值再被 useEffect 执行两次。我调整了一下代码,将测试代码复制了一份在后面,可以看到 “useMemo” 和 “useMemo 2” 先执行了一次,又再执行了一次,然后再到 “useEffect“ 和 “useEffect 2"
![加倍快乐](https://minio.ivanli.cc/ivan-public/uPic/2023/OwlDG6.png)
## 结论
`useEffect` 应该独自管理副作用,要做到自己创建,自己销毁。
```tsx
const camera = useMemo(/* .. */, []);
const renderer = props; // from parent
const [controls, setControls] = useState<OrbitControls | null>(null);
useEffect(() => {
const controls = new OrbitControls(camera, renderer.domElement) // 自己的锅
setControls(controls);
return () => {
controls.dispose(); // 自己背
};
}, [camera, renderer]);
```
今天深究了一下这个问题,解决方案其实我也知道,但是之前的写法突然以我不理解的方式失效了,还是要较个劲,万一是 React 不规范呢?(狗头

View File

@ -0,0 +1,35 @@
---
title: 自部署的 BaaS 服务对比
date: '2023-01-24'
tags: ['BaaS', 'Self-Hosted', 'appwrite', 'nhost', 'supabase']
draft: false
summary: supabase、nhost、appwrite 之间的对比,关注自部署方向。
---
BaaS后端即服务。
最近关注 BaaS 自部署主要还是因为自己有一些简单的服务端开发需求,可能就一两个函数的事。
如果专门去写一个后端,显得有些铺张了。而且创建一个项目是个麻烦事,我也不愿意从旧项目复制可能已经过时代码进来,所以就想到 FaaS。
但是 FaaS 我搜罗了一圈,适合个人自部署的 FaaS 平台少之又少,最后找了个 Serverless 的函数项目 [Trusted CGI](https://trusted-cgi.reddec.net/)[Repo](https://github.com/reddec/trusted-cgi)Go Lang
这个挺适合个人使用,但是无状态意味着我还得自己搞状态存储,还是不太符合我的需求,所以就看上了 BaaS。
## BaaS 简要介绍
目前 BaaS 大概是用户管理、授权、认证,加上数据库设计和存储、对象存储,再加上各类的 event hook 和 push再加上 Serverless Functions 构成的。这个组件构成非常适合原型、Demo 以及轻量的应用开发。
当然,如果后续有性能瓶颈,至少垂直扩展和 functions 层水平扩展是没有任何问题。至于持久层的水平扩展,也能像传统方案处理。
BaaS 最佳的应用场景就是各类 Apps 的服务端了。包括 Web Apps 在内,主要业务由 Apps 端处理的话BaaS 就是绝佳的生产力工具。而且服务端的业务逻辑基本上都是写在 Serverless Functions 里,所以根本不需要考虑升级会暂停服务,因为它是无状态的,更新时是能做到零秒重载的。
更权威的定义可以看[这里](https://www.cloudflare.com/zh-cn/learning/serverless/glossary/backend-as-a-service-baas/)。
## 对比
适合生产的环境自然也就是各大平台自有的云 BaaS 平台了,在他们各自的云平台上,你可以享受到完整、轻松的开发体验。但是这是对于企业和专业用途的个人用户,而玩票性质的我就暂时用不上了,所以我的目标就是开源的、可自部署的 BaaS 服务。
我淘了好久,找到了三个不错的开源项目,分别是 supabase <img alt="Packagist Stars" class="inline" src="https://img.shields.io/packagist/stars/appwrite/appwrite?style=social"/>、appwrite <img alt="Packagist Stars" class="inline" src="https://img.shields.io/packagist/stars/nhost/nhost?style=social"/>、nhost <img alt="Packagist Stars" class="inline" src="https://img.shields.io/packagist/stars/appwrite/appwrite?style=social"/>。他们仨在 Github 上的 Stars 目前是从多到少的。
接下来就以自部署的角度来对比下他们三个之间的差异。
TL; DR, 如果小项目多,推荐使用 appwrite如果现阶段需要表的关联建议使用 nhostsupabase 不适合自部署,他没有可以自部署的 serverless functions。
### Supabase
Github 上的星星老多了,可以说是目前最火的 BaaS 项目了。他对标的是  Firebase Firebase 的开源版本自居。

View File

@ -0,0 +1,119 @@
---
title: 再见 2022你好 2023
date: '2022-12-31'
tags: ['总结']
draft: true
summary: 2022 年就要结束了,写个工作与学习的总结,记录下我的 2022 年。
---
二零二二,疫情不出意外地还在我们身边,而我们也早已经习惯了疫情。
## 电子电路与嵌入式开发
我一直对电子电路有着兴趣,而今年三月,我突然觉得我行了。购买了仪器和工具设备,开始自学电子电路知识。
软件开发一直和数字电路有着密不可分的联系,草草地学习完模拟电路和数字电路后,更加肯定软件和硬件不能独立,而是相辅相成的。
而我的第一个目标是构建一个锂电池 UPS为的是让充当服务器的 J4125 工控机能从容应对突然的断电。
项目我拆分了好几部分,一个阶段,一个阶段地完成,向着目标努力。
印象比较深的还是第一个部分的实现。第一个部分是实现一个理想二极管。
所谓理想二极管就是电流单向流动,并且(几乎)没有普通二极管的压降。
还记得我开开心心地在各大电子电路论坛爬楼找思路时,找到了个方案,在电路模拟软件搭建好电路后,运行了模拟,结果挺有效的。
我就赶紧画电路板打样了。等待样板寄来的包裹的这几天,我思来想去这电路好像不应该有效啊,换了个模拟软件一跑,坏了,不对。
接着就在洞洞板上做实验,果然,效果不对,压降那叫一个大。
最后这样改方案,画了第三块板后,终于实现了这一电路。果然实践才是检验真理的唯一途径,三个月从零搞出这么个东西,其实没啥,但是过程确实有趣。
就这样,边学边试,软件和硬件都验证完了,过程充满松香味。
不过可惜的是,半年后的八月,我换了 AMD 迷你主机UPS 设计供电已经不满足新主机的要求,项目搁置了。
电路的理论论证和各部分的功能验证板已经实现了,外壳也已经设计好了,但是还是没继续往下走,因为当时工作上发生了一些变动,就没余力折腾了。
之后又开始折腾屏幕氛围灯,挺有趣的,不过还没做完,目前一周也就花几小时在边学边弄。
我能想着开始折腾电子电路,还是因为国产单片机的崛起。
乐鑫公司的 ESP32 系列单片机真的是物美价廉,白嫖嘉立创的 PCB 也非常的香。
学习成本就低了很多,想想去年我入手的树莓派 4B。当时也有考虑折腾物联网但是一个三四百也只适合当个上位机干活了。
而现在用着 ESP32 模组和开发板,烧了也不心疼,嘿嘿。
嵌入式开发是一个陌生的领域C 语言是一个上古的高级语言,而天天写着现代高级语言的我,一时之间回不到大一时写 C 语言的感觉,
所以用了一段 C 语言做嵌入式开发后,我看上了 C++。或许是 C++ 没看上我,编译器报错我是一个也看不懂,所以我想起了 Rust。
Rust 比着“耶”向我招手,我大意了,就进了这个跟大的坑。
Rust 语言确实费脑子,因为开发过程中将会被编译器一直教育,非常地严谨。还记得我被引用变量的生命周期教育得死去活来,被结构体没有实现 `Copy`、 `Send`、`Sync` 折磨得痛不欲生。
其实本来没这么复杂的,可是我却拿着嵌入式开发和 Rust 一起学,难度不能说陡增吧,只能说是经常看不到明天的太阳。
不过我还是磨出来了 UPS 的程序,还行,能用。之后开发屏幕氛围灯就滚回去用 C 语言写了,还别说,嵌入式开发我也学出了点感觉,写起来可亲切了。
因为示例多、论坛上的开发者主要也是用 C 写,资料很充足,也没有那群还在 0.0.x 版本的 Rust 库,感觉代码好写多了……
但是我贼心不死,拿着 Rust 转眼就配着 Tauri 开始开发氛围灯的上位机程序。
不知是没有了 rust-embedded 系的折磨,还是我懂得了 Rust 的脾气,开发得比较顺利,很舒服。
嵌入式的世界非常的美妙,将虚幻的软件借着硬件能更真实地让我们触碰到。
看好 IoT这是极客们的方向也是科技改变生活的方向。
## 元宇宙
年中因为业务调整,我的工作和元宇宙搭上了边。年中出去嗨皮了一周后,回来就开始学习 Unity 3D。
不得不说,国内疫情防控挺好的,走了好几个城市,回来也没阳,哈哈。
跑题了不得不说Unity 3D 作为入门 3D 游戏开发确实挺合适的,虽然我们当时本想着用 Unity 开发元宇宙项目。
正当我在庆幸我还没把 C Sharp 忘光光时,又被要求换成了现在正在使用的 Three.js 作为引擎,也就回到了 Web 领域。
接下来的半年,我和我的同事们便开始踩坑之旅。
因为是全新的领域,我和我的同事经历了 Unity 3D、Lingo 3D、r3f 这三个阶段,踩了许多的坑。
不知为什么,他们似乎对游戏开发好像并不觉得是全新的世界,极其低估了所需的知识储备。
现在回头看,每次的技术选型其实都不合理。
Lingo 3D 作为刚出现的框架,并没有经过市场的检验,也并没有基于该框架商业项目,使用这个框架和二开这个框架并没有什么区别。反对无力,作罢。
当我向 Lingo 3D 提了一个 PR 后,同事就抛弃了 Lingo 3D转向 r3f + BVH 碰撞检测。
而接下来,继续遇到了大量的性能问题。
从项目开发的第一周起,我就有一个 3D 场景渲染性能优化的任务挂着。
不过滑稽的是我的开发任务基本上在 WebRTC 相关部分和后端,而前端游戏场景渲染这部分并不是我开发的……
虽然处于尴尬的位置,优化是没处优化了,但是问题还是能另起项目去发现和验证。
我断断续续地折腾这事,现在回头看看,其实得出的结论挺正确的,但是当时没人懂也没人信,我也是半验证半猜测,没想到正确率还行。
- 游戏开发确实很考验建模师的素质,调优后的模型性能直接翻倍;
- 模型拆分、复用、LOD 是真的有明显的性能提升;
- 内存瓶颈是存在的,降低内存占用量能够让程序更稳定
虽然我对元宇宙没啥兴趣,不过可视化这方向是很有价值的。
看好元宇宙的风口,也看好可视化的前景。
## 自建服务
八月换了新的迷你主机作为服务器,依然使用移动平台的 CPU性能和功耗还可以。
比之前的机子性能好太多了,当然,满载时也学会芜湖起飞了。
我也多部署了几个服务。
### RSS 阅读器
现在信息茧房问题挺严重的,所以使用 Miniflux 自建了 RSS 阅读器。目前搜寻了一些个人博客、小众资讯站作为资讯源,感觉挺好的,没有乱七八糟的内容,很舒适。
### 标签打印机和短网址服务
另外,买了一个标签打印机,配合短网址服务 Kutt给我买的一堆电子元件做分类打标签。标签上带了个二维码里面存了描述元件信息文件的超链接地址。
### 日志分析服务
最后,因为部署了太多服务,自建服务又容易挂,所以跑了 Grafana + loki 用作日志分析服务。再配合在云服务器上部署的 Kuma 服务健康监控服务,目前服务状况了然于胸。
我还购入了国内的 NAT VPS做内网穿透和 SD-WAN比使用境外服务器快太多啦还稳定。
公司网络不太稳定,直接组网性能经常断线;
使用 VPN 拨入 NAT VPS 后,再由 NAT VPS 与家里组网,就没在掉过线。
现在从公网访问我的自建服务是由从境外服务器反向代理的,延迟比较大。
目前域名重新备案,希望能套上国内 CDN这样再由 NAT VPS 反向代理应该能改善国内访问速度。
## 开发容器与远程开发
那么,我为什么要组网呢,更大原因还是我想进行远程开发。
我在公司使用的是毕业时买的 MacBook Pro 2018 款,性能其实还不错,但是跑测试用例还是有些慢。
而且我今年也拥抱了 Dev Container 的开发方式,开发容器能很好地解决开发环境搭建问题。
再配合上数据库迁移脚本和数据生成工具,能极大地解决前后端开发时出现的数据污染问题。
可谓是 2022 年我最正确的选择了,哈哈哈哈。
这个选择是有代价的,那就是每开发一个项目都是有自己的持久化、缓存和应用组件,比较消耗硬件资源。
这不就换了个迷你主机嘛AMD yes!
因为在家里,所以就搞起了远程开发,家里网络质量也比较好,拉依赖什么的速度和稳定性比在公司高出了不少,这也极大地改善了我的开发体验。
要说这远程开发有啥不好的,那就是怕家里突然断网断电,那就有可能痛失劳动成果了。
开发容器是个不错的东西,今年看很多开源项目都用上了开发容器,也有很多开源项目还没用上。
但很明显,这是未来。我今年在开源社区也算是小小地冒了个泡,无论项目是否有开发容器的配置,我都会拉完项目后在开发容器运行,不用担心弄乱我的电脑,也不用担心环境冲突。
而且最重要的,是不用那么担心去年的项目今年怎么也跑不起来。这感觉,经历过的都懂。
最后,洁癖万岁~
比较可惜VS Code 的 Web 版本还不能支持开发容器,要是支持了,在 iPad 上快乐生产是多么值得的一件事呐。
## 未来可期
以上是我在 2022 年的经历,有些是我的计划,有些是命运的安排。贴近底层、满足兴趣,让我接触了嵌入式开发,意外的变动让我接触了 3D 游戏开发;心心念念的 Rust 终于安排上了,为了修自建的服务而改起了好几个 Go 语言项目。回顾过往,我已经摸了好多好多门编程语言了,也换了好多口味的编程风格,也大概摸清了自己向往的方向和风格。希望能够继续用着 TypeScript 和 Rust快乐地写着后端玩着前端搞着偏后端的全栈开发在 Arch Linux 上维护着服务。我可见不得 Java 和 PHP希望依旧再也不见。对还有 Python我和你不熟可别过来。

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

@ -0,0 +1,7 @@
---
title: 使用 code-server 自部署 Web 版 VS Code
date: '2022-12-24'
tags: ['Develop', 'VS Code', 'Self Hosted']
draft: true
summary: 心心念念的在浏览器中写代码iPad 终于有希望回归生产力了。
---

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