Compare commits

..

No commits in common. "master" and "696fe48d53a68b1de4b6a64f57246f30884e3f99" have entirely different histories.

184 changed files with 31155 additions and 26471 deletions

View File

@ -1,14 +0,0 @@
# [Choice] Node.js version (use -bullseye variants on local arm64/Apple Silicon): 18, 16, 14, 18-bullseye, 16-bullseye, 14-bullseye, 18-buster, 16-buster, 14-buster
ARG VARIANT=16-bullseye
FROM mcr.microsoft.com/vscode/devcontainers/typescript-node:0-${VARIANT}
# [Optional] Uncomment this section to install additional OS packages.
# RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
# && apt-get -y install --no-install-recommends <your-package-list-here>
# [Optional] Uncomment if you want to install an additional version of node using nvm
# ARG EXTRA_NODE_VERSION=10
# 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
RUN su node -c "npm install -g pnpm"

View File

@ -1,17 +0,0 @@
# [Choice] Node.js version (use -bullseye variants on local arm64/Apple Silicon): 18, 16, 14, 18-bullseye, 16-bullseye, 14-bullseye, 18-buster, 16-buster, 14-buster
ARG VARIANT=16-bullseye
FROM mcr.microsoft.com/vscode/devcontainers/javascript-node:0-${VARIANT}
# Install tslint, typescript. eslint is installed by javascript image
ARG NODE_MODULES="tslint-to-eslint-config typescript"
COPY library-scripts/meta.env /usr/local/etc/vscode-dev-containers
RUN su node -c "umask 0002 && npm install -g ${NODE_MODULES}" \
&& npm cache clean --force > /dev/null 2>&1
# [Optional] Uncomment this section to install additional OS packages.
# RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
# && apt-get -y install --no-install-recommends <your-package-list-here>
# [Optional] Uncomment if you want to install an additional version of node using nvm
# ARG EXTRA_NODE_VERSION=10
# RUN su node -c "source /usr/local/share/nvm/nvm.sh && nvm install ${EXTRA_NODE_VERSION}"

View File

@ -1,58 +0,0 @@
// For format details, see https://aka.ms/devcontainer.json. For config options, see the README at:
// https://github.com/microsoft/vscode-dev-containers/tree/v0.245.2/containers/typescript-node
{
"name": "Node.js & TypeScript",
"build": {
"dockerfile": "Dockerfile",
// Update 'VARIANT' to pick a Node version: 18, 16, 14.
// Append -bullseye or -buster to pin to an OS version.
// Use -bullseye variants on local on arm64/Apple Silicon.
"args": {
"VARIANT": "18-bullseye"
}
},
// Configure tool-specific properties.
"customizations": {
// Configure properties specific to VS Code.
"vscode": {
// Add the IDs of extensions you want installed when the container is created.
"extensions": [
"dbaeumer.vscode-eslint",
"formulahendry.auto-rename-tag",
"aaron-bond.better-comments",
"bierner.color-info",
"ldez.ignore-files",
"gooooloo.smartquote",
"wmaurer.change-case",
"streetsidesoftware.code-spell-checker",
"naumovs.color-highlight",
"EditorConfig.EditorConfig",
"mhutchie.git-graph",
"donjayamanne.githistory",
"ecmel.vscode-html-css",
"yzhang.markdown-all-in-one",
"christian-kohler.path-intellisense",
"esbenp.prettier-vscode",
"shardulm94.trailing-spaces",
"lihui.vs-color-picker",
"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.
"forwardPorts": [3000],
// Use 'postCreateCommand' to run commands after the container is created.
// "postCreateCommand": "yarn install",
// Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
"remoteUser": "node",
"features": {
"git": "os-provided",
"git-lfs": "latest",
"ghcr.io/devcontainers/features/docker-in-docker:2": {}
},
"mounts": [],
"postAttachCommand": "pnpm install && npm run dev"
}

View File

@ -36,5 +36,3 @@ yarn-error.log*
.env.production.local .env.production.local
secrets.txt secrets.txt
.pnpm-store

141
.drone.yml Normal file
View File

@ -0,0 +1,141 @@
---
kind: pipeline
name: base
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/gatsby-blog
cache_from:
- docker-registry.ivanli.cc/ivan/gatsby-blog:${DRONE_BRANCH}${DRONE_TAG}-amd64
dockerfile: Dockerfile
build_args:
- BUILDKIT_INLINE_CACHE=1
target: base
tags:
- '${DRONE_COMMIT_SHA:0:8}-amd64'
- '${DRONE_BRANCH}${DRONE_TAG}-amd64'
---
kind: pipeline
name: linux-amd64
type: docker
depends_on:
- base
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/gatsby-blog
dockerfile: Dockerfile
target: release
cache_from:
- docker-registry.ivanli.cc/ivan/gatsby-blog:${DRONE_COMMIT_SHA:0:8}-amd64
tags:
- '${DRONE_COMMIT_SHA:0:8}'
- '${DRONE_BRANCH}${DRONE_TAG}'
- name: notify
image: appleboy/drone-telegram
failure: ignore
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
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}}

1
.env
View File

@ -1 +0,0 @@
NEXT_PUBLIC_COMMENTO_URL=https://comment.ivanli.cc/js/commento.js

View File

@ -1,4 +1,3 @@
# 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=
@ -11,16 +10,20 @@ 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,2 +1 @@
node_modules node_modules
.eslintrc.js

View File

@ -1,42 +1,17 @@
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,
}, },
plugins: ['@typescript-eslint'], extends: ['eslint:recommended', 'plugin:prettier/recommended', 'next', 'next/core-web-vitals'],
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,
'@typescript-eslint/no-unused-vars': 0, '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 eol=lf to force LF endings. ## Modified * text=auto to * text=auto eol=lf to force LF endings.
## GITATTRIBUTES FOR WEB PROJECTS ## GITATTRIBUTES FOR WEB PROJECTS
# #

3
.github/FUNDING.yml vendored Normal file
View File

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

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

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

View File

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

View File

@ -1,33 +0,0 @@
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/

13
.gitignore vendored
View File

@ -4,10 +4,6 @@
/node_modules /node_modules
/.pnp /.pnp
.pnp.js .pnp.js
/.yarn/*
!/.yarn/releases
!/.yarn/plugins
!/.yarn/sdks
# testing # testing
/coverage /coverage
@ -21,13 +17,9 @@ 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
@ -43,9 +35,4 @@ yarn-error.log*
.env.test.local .env.test.local
.env.production.local .env.production.local
# Contentlayer
.contentlayer
secrets.txt secrets.txt
.pnpm-store

16
.vscode/launch.json vendored
View File

@ -1,16 +0,0 @@
{
// 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
}
]
}

35
.vscode/settings.json vendored
View File

@ -1,35 +0,0 @@
{
"typescript.tsdk": "node_modules/typescript/lib",
"typescript.enablePromptUseWorkspaceTsdk": true,
"cSpell.words": [
"alpn",
"appleboy",
"blackhole",
"BUTTONDOWN",
"Commento",
"CONVERTKIT",
"Cusdis",
"Discuz",
"Disqus",
"dokodemo",
"EMAILOCTOPUS",
"fullchain",
"Giscus",
"Hackintosh",
"KLAVIYO",
"Kutt",
"lastmod",
"Logseq",
"MAILCHIMP",
"Miniflux",
"nextjs",
"Nuxt",
"outbounds",
"rprx",
"unist",
"vfile",
"VLESS",
"vmess",
"xtls"
]
}

File diff suppressed because one or more lines are too long

View File

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

View File

@ -1,35 +1,13 @@
FROM node:16-alpine as base FROM node:16-alpine as base
RUN npm i --location=global pnpm@7
FROM base as deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY pnpm-lock.yaml package.json ./
RUN pnpm i
FROM deps as build
WORKDIR /app WORKDIR /app
COPY package-lock.json package.json ./
RUN npm ci --no-audit
COPY . . COPY . .
COPY --from=deps /app ./
RUN pnpm build
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=pre-release /app ./ COPY --from=base /app ./
RUN npm run build &&\
npm prune --omit dev
EXPOSE 80 EXPOSE 80
CMD npm run serve -- -p 80 CMD npm run serve -- --port 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'

138
README.md
View File

@ -9,9 +9,7 @@
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/git/external?repository-url=https://github.com/timlrx/tailwind-nextjs-starter-blog) [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/git/external?repository-url=https://github.com/timlrx/tailwind-nextjs-starter-blog)
This is a [Next.js](https://nextjs.org/), [Tailwind CSS](https://tailwindcss.com/) blogging starter template. Version 2 is based on Next App directory with [React Server Component](https://nextjs.org/docs/getting-started/react-essentials#server-components) and uses [Contentlayer](https://www.contentlayer.dev/) to manage markdown content. 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.
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.
@ -19,52 +17,48 @@ 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 ## Examples
- [Demo Blog](https://tailwind-nextjs-starter-blog.vercel.app/) - this repo - [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 - [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! - [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) - [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 - [David Levai's digital garden](https://davidlevai.com/) - customized design and added email subscriptions
- [Thinh's Corner](https://thinhcorner.com/) - [customized layout](https://github.com/Th1nhNg0/th1nhng0.vercel.app/blob/5e73a420828d82f01e7147512a2c3273c4ec19f8/layouts/PostLayout.js) with sticky side table of contents
- [Leo's Blog](https://leohuynh.dev) - Tuan Anh Huynh's personal site. Add Snippets Page, Author Profile Card, Image Lightbox, ... - [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. - [thvu.dev](https://thvu.dev) - Added `mdx-embed`, view count, reading minutes and more.
- [fiqrychoerudin.dev](https://www.fiqrychoerudin.dev/) - simple portfolio.
- [irvin.dev](https://www.irvin.dev/) - Irvin Lin's personal site. Added YouTube embedding. - [irvin.dev](https://www.irvin.dev/) - Irvin Lin's personal site. Added YouTube embedding.
- [the all JavaScript Blog](https://the-all-javascript-blog.vercel.app/) - a JavaScript enlightenment blog uses this
- [KirillSo.com](https://www.kirillso.com/) - Personal blog & website. - [KirillSo.com](https://www.kirillso.com/) - Personal blog & website.
- [ghali.dev](https://ghali.dev) - Cyril's Blog
- [DevBoy Blog](https://devboy.vercel.app/) - M.Reza's personal blog
- [slightlysharpe.com](https://slightlysharpe.com) - [Tincre's](https://tincre.com) main company blog - [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 - [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 - [astrosaurus.me](https://astrosaurus.me/) - Ephraim Atta-Duncan's Personal Blog
- [dhanrajsp.me](https://dhanrajsp.me/) - Dhanraj's personal site and 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)). - [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)) - [honghong.me](https://honghong.me) - Tszhong's personal website ([source code](https://github.com/tszhong0411/home))
- [alfoncode.com](https://alfoncode.com) - Alfonso García's personar website. Customized design ([source code](https://github.com/alfoncode/personal-web))
- [marceloformentao.dev](https://marceloformentao.dev) - Marcelo Formentão personal website ([source code](https://github.com/marceloavf/marceloformentao.dev)). - [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) - [abiraja.com](https://www.abiraja.com/) - with a [runnable JS code snippet component!](https://www.abiraja.com/blog/querying-solana-blockchain)
- [einargudni.com](https://www.einargudni.com) - with a customized theme, command pallette and more ([source code](https://github.com/einargudnig/einargudni.com))
- [bpiggin.com](https://www.bpiggin.com) - Ben Piggin's personal blog - [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)) - [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. - [ambilena.com](https://ambilena.com/) - Electronic Music Blog & imprint for upcoming musicians.
- [kittan.ru](https://www.kittan.ru/) - Kittanb's personal blog about linux ([source code](https://github.com/kittanb/blog))
- [nchristopher.me](https://nchristopher.me) - Nicholas Christopher's personal website and blog ([source code](https://github.com/nchristopher/blog))
- [dalelarroder.com](https://dalelarroder.com) - Dale Larroder's personal website and blog ([source code](https://github.com/dlarroder/dalelarroder)) - [dalelarroder.com](https://dalelarroder.com) - Dale Larroder's personal website and blog ([source code](https://github.com/dlarroder/dalelarroder))
- [devahoy.com](https://devahoy.com) - Chai's personal blog (Thai language)
- [0xchai.io](https://0xchai.io) - Chai's personal blog - [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 - [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 - [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) - [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)) - [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)) - [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)). - [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)) Using the template? Feel free to create a PR and add your blog to this list.
- [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 ## Motivation
@ -74,29 +68,26 @@ I wanted it to be nearly as feature-rich as popular blogging templates like [bea
## 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
- [MDX - write JSX in markdown documents!](https://mdxjs.com/) - Near perfect lighthouse score - [Lighthouse report](https://www.webpagetest.org/result/210111_DiC1_08f3670c3430bf4a9b76fc3b927716c5/)
- Near perfect lighthouse score - [Lighthouse report](https://www.webpagetest.org/result/230805_BiDcBQ_4H7) - Lightweight, 45kB first load JS, uses Preact in production build
- Lightweight, 85kB first load JS
- Mobile-friendly view - Mobile-friendly view
- Light and dark theme - Light and dark theme
- Font optimization with [next/font](https://nextjs.org/docs/app/api-reference/components/font) - Self-hosted font with [Fontsource](https://fontsource.org/)
- Integration with [pliny](https://github.com/timlrx/pliny) that provides: - Supports [plausible](https://plausible.io/), [simple analytics](https://simpleanalytics.com/) and google analytics
- Multiple analytics options including [Umami](https://umami.is/), [Plausible](https://plausible.io/), [Simple Analytics](https://simpleanalytics.com/), Posthog and Google Analytics - [MDX - write JSX in markdown documents!](https://mdxjs.com/)
- 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
- 3 different blog layouts - Blog templates
- 2 different blog listing layouts - TOC component
- 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!
@ -112,15 +103,30 @@ I wanted it to be nearly as feature-rich as popular blogging templates like [bea
## Quick Start Guide ## Quick Start Guide
1. Clone the repo 1. Try installing the starter using the new [Pliny project CLI](https://github.com/timlrx/pliny):
```bash ```bash
npx degit 'timlrx/tailwind-nextjs-starter-blog' npm i -g @pliny/cli
pliny new --template=starter-blog my-blog
```
It supports the updated version of the blog with Contentlayer, optional choice of TS/JS and different package managers as well as more modularized components which will be the basis of the template going forward.
Alternatively to stick with the current version, TypeScript and Contentlayer:
```bash
npx degit 'timlrx/tailwind-nextjs-starter-blog#contentlayer'
```
or JS (official support)
```bash
npx degit https://github.com/timlrx/tailwind-nextjs-starter-blog.git
``` ```
2. Personalize `siteMetadata.js` (site related information) 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
other analytics provider or a commenting solution other than giscus. any 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
@ -130,7 +136,7 @@ npx degit 'timlrx/tailwind-nextjs-starter-blog'
## Installation ## Installation
```bash ```bash
yarn npm install
``` ```
## Development ## Development
@ -138,12 +144,18 @@ yarn
First, run the development server: First, run the development server:
```bash ```bash
yarn dev npm start
```
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.
Edit the layout in `app` or content in `data`. With live reloading, the pages auto-updates as you edit them. You can start editing the page by modifying `pages/index.js`. The page auto-updates as you edit the file.
## Extend / Customize ## Extend / Customize
@ -161,41 +173,36 @@ Edit the layout in `app` or content in `data`. With live reloading, the pages au
`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` - tailwind configuration and stylesheet which can be modified to change the overall look and feel of the site. `tailwind.config.js` and `css/tailwind.css` - contain the tailwind stylesheet which can be modified to change the overall look and feel of the site.
`css/prism.css` - controls the styles associated with the code blocks. Feel free to customize it and use your preferred prismjs theme e.g. [prism themes](https://github.com/PrismJS/prism-themes). `css/prism.css` - controls the styles associated with the code blocks. Feel free to customize it and use your preferred prismjs theme e.g. [prism themes](https://github.com/PrismJS/prism-themes).
`contentlayer.config.ts` - configuration for Contentlayer, including definition of content sources and MDX plugins used. See [Contentlayer documentation](https://www.contentlayer.dev/docs/getting-started) for more information. `components/social-icons` - to add other icons, simply copy an svg file from [Simple Icons](https://simpleicons.org/) and map them in `index.js`. Other icons use [heroicons](https://heroicons.com/).
`components/MDXComponents.js` - pass your own JSX code or React component by specifying it over here. You can then 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). `components/MDXComponents.js` - pass your own JSX code or React component by specifying it over here. You can then call them directly in the `.mdx` or `.md` file. By default, a custom link and image component is passed.
`layouts` - main templates used in pages: `layouts` - main templates used in pages.
- There are currently 3 post layouts available: `PostLayout`, `PostSimple` and `PostBanner`. `PostLayout` is the default 2 column layout with meta and author information. `PostSimple` is a simplified version of `PostLayout`, while `PostBanner` features a banner image. `pages` - pages to route to. Read the [Next.js documentation](https://nextjs.org/docs) for more information.
- 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/).
Please refer to `contentlayer.config.ts` for an up to date list of supported fields. The following fields are supported: Currently 7 fields are supported.
``` ```
title (required) title (required)
date (required) date (required)
tags (optional) tags (required, can be empty array)
lastmod (optional) lastmod (optional)
draft (optional) draft (optional)
summary (optional) summary (optional)
images (optional) images (optional, if none provided defaults to socialBanner in siteMetadata config)
authors (optional list which should correspond to the file names in `data/authors`. Uses `default` if none is specified) 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)
@ -218,24 +225,21 @@ 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 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. The easiest way to deploy the template is to use the [Vercel Platform](https://vercel.com) from the creators of Next.js. Check out the [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
**Netlify** **Netlify / GitHub Pages / Firebase etc.**
[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. 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.
See [Next.js on Netlify](https://docs.netlify.com/integrations/frameworks/next-js/overview/#next-js-runtime) for suggested configuration values and more details. The API routes used in the newsletter component cannot be used in a static site export. You will need to use a form API endpoint provider and substitute the route in the newsletter component accordingly. Other hosting platforms such as Netlify also offer alternative solutions - please refer to their docs for more information.
**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
@ -243,4 +247,4 @@ Using the template? Support this effort by giving a star on GitHub, sharing your
## Licence ## Licence
[MIT](https://github.com/timlrx/tailwind-nextjs-starter-blog/blob/main/LICENSE) © [Timothy Lin](https://www.timlrx.com) [MIT](https://github.com/timlrx/tailwind-nextjs-starter-blog/blob/master/LICENSE) © [Timothy Lin](https://www.timlrx.com)

View File

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

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

View File

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

View File

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

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

View File

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

View File

@ -1,124 +0,0 @@
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,26 +0,0 @@
import Link from '@/components/Link';
export default function NotFound() {
return (
<div className="flex flex-col items-start justify-start md:mt-24 md:flex-row md:items-center md:justify-center md:space-x-6">
<div className="space-x-2 pb-8 pt-6 md:space-y-5">
<h1 className="text-6xl font-extrabold leading-9 tracking-tight text-gray-900 dark:text-gray-100 md:border-r-2 md:px-6 md:text-8xl md:leading-14">
404
</h1>
</div>
<div className="max-w-md">
<p className="mb-4 text-xl font-bold leading-normal md:text-2xl">
Sorry we couldn't find this page.
</p>
<p className="mb-8">
But dont worry, you can find plenty of other things on our homepage.
</p>
<Link
href="/"
className="focus:shadow-outline-blue inline rounded-lg border border-transparent bg-blue-600 px-4 py-2 text-sm font-medium leading-5 text-white shadow transition-colors duration-150 hover:bg-blue-700 focus:outline-none dark:hover:bg-blue-500">
Back to homepage
</Link>
</div>
</div>
);
}

View File

@ -1,9 +0,0 @@
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,13 +0,0 @@
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,
};
}

View File

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

View File

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

View File

@ -1,50 +0,0 @@
{
"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
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,22 +1,18 @@
import Link from './Link'; import Link from './Link'
import siteMetadata from '@/data/siteMetadata'; import siteMetadata from '@/data/siteMetadata'
import SocialIcon from '@/components/social-icons'; import SocialIcon from '@/components/social-icons'
export default function Footer() { export default function Footer() {
return ( return (
<footer> <footer>
<div className="mt-16 flex flex-col items-center"> <div className="mt-16 flex flex-col items-center">
<div className="mb-3 flex space-x-4"> <div className="mb-3 flex space-x-4">
<SocialIcon <SocialIcon kind="mail" href={`mailto:${siteMetadata.email}`} size="6" />
kind="mail" <SocialIcon kind="github" href={siteMetadata.github} size="6" />
href={`mailto:${siteMetadata.email}`} <SocialIcon kind="facebook" href={siteMetadata.facebook} size="6" />
size={6} <SocialIcon kind="youtube" href={siteMetadata.youtube} size="6" />
/> <SocialIcon kind="linkedin" href={siteMetadata.linkedin} size="6" />
<SocialIcon kind="github" href={siteMetadata.github} size={6} /> <SocialIcon kind="twitter" href={siteMetadata.twitter} size="6" />
<SocialIcon kind="facebook" href={siteMetadata.facebook} size={6} />
<SocialIcon kind="youtube" href={siteMetadata.youtube} size={6} />
<SocialIcon kind="linkedin" href={siteMetadata.linkedin} size={6} />
<SocialIcon kind="twitter" href={siteMetadata.twitter} size={6} />
</div> </div>
<div className="mb-2 flex space-x-2 text-sm text-gray-500 dark:text-gray-400"> <div className="mb-2 flex space-x-2 text-sm text-gray-500 dark:text-gray-400">
<div>{siteMetadata.author}</div> <div>{siteMetadata.author}</div>
@ -25,11 +21,6 @@ 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 href="https://github.com/timlrx/tailwind-nextjs-starter-blog"> <Link href="https://github.com/timlrx/tailwind-nextjs-starter-blog">
Tailwind Nextjs Theme Tailwind Nextjs Theme
@ -37,5 +28,5 @@ export default function Footer() {
</div> </div>
</div> </div>
</footer> </footer>
); )
} }

View File

@ -1,47 +0,0 @@
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;

6
components/Image.js Normal file
View File

@ -0,0 +1,6 @@
import NextImage from 'next/image'
// eslint-disable-next-line jsx-a11y/alt-text
const Image = ({ ...rest }) => <NextImage {...rest} />
export default Image

View File

@ -1,8 +0,0 @@
const Image = ({
...rest
}: React.DetailedHTMLProps<
React.ImgHTMLAttributes<HTMLImageElement>,
HTMLImageElement
>) => <img {...rest} />;
export default Image;

View File

@ -0,0 +1,54 @@
import siteMetadata from '@/data/siteMetadata'
import headerNavLinks from '@/data/headerNavLinks'
import Logo from '@/data/logo.svg'
import Link from './Link'
import SectionContainer from './SectionContainer'
import Footer from './Footer'
import MobileNav from './MobileNav'
import ThemeSwitch from './ThemeSwitch'
const LayoutWrapper = ({ children }) => {
return (
<SectionContainer>
<div className="flex h-screen flex-col justify-between">
<header className="flex items-center justify-between py-10">
<div>
<Link href="/" aria-label={siteMetadata.headerTitle}>
<div className="flex items-center justify-between">
<div className="mr-3">
<Logo />
</div>
{typeof siteMetadata.headerTitle === 'string' ? (
<div className="hidden h-6 text-2xl font-semibold sm:block">
{siteMetadata.headerTitle}
</div>
) : (
siteMetadata.headerTitle
)}
</div>
</Link>
</div>
<div className="flex items-center text-base leading-5">
<div className="hidden sm:block">
{headerNavLinks.map((link) => (
<Link
key={link.title}
href={link.href}
className="p-1 font-medium text-gray-900 dark:text-gray-100 sm:p-4"
>
{link.title}
</Link>
))}
</div>
<ThemeSwitch />
<MobileNav />
</div>
</header>
<main className="mb-auto">{children}</main>
<Footer />
</div>
</SectionContainer>
)
}
export default LayoutWrapper

View File

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

23
components/Link.js Normal file
View File

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

View File

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

View File

@ -0,0 +1,26 @@
/* eslint-disable react/display-name */
import { useMemo } from 'react'
import { getMDXComponent } from 'mdx-bundler/client'
import Image from './Image'
import CustomLink from './Link'
import TOCInline from './TOCInline'
import Pre from './Pre'
import { BlogNewsletterForm } from './NewsletterForm'
export const MDXComponents = {
Image,
TOCInline,
a: CustomLink,
pre: Pre,
BlogNewsletterForm: BlogNewsletterForm,
wrapper: ({ components, layout, ...rest }) => {
const Layout = require(`../layouts/${layout}`).default
return <Layout {...rest} />
},
}
export const MDXLayoutRenderer = ({ layout, mdxSource, ...rest }) => {
const MDXLayout = useMemo(() => getMDXComponent(mdxSource), [mdxSource])
return <MDXLayout layout={layout} components={MDXComponents} {...rest} />
}

View File

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

View File

@ -1,35 +1,36 @@
'use client'; import { useState } from 'react'
import Link from './Link'
import { useState } from 'react'; import headerNavLinks from '@/data/headerNavLinks'
import Link from './Link';
import headerNavLinks from '@/data/headerNavLinks';
const MobileNav = () => { const MobileNav = () => {
const [navShow, setNavShow] = useState(false); const [navShow, setNavShow] = useState(false)
const onToggleNav = () => { const onToggleNav = () => {
setNavShow((status) => { setNavShow((status) => {
if (status) { if (status) {
document.body.style.overflow = 'auto'; document.body.style.overflow = 'auto'
} else { } else {
// Prevent scrolling // Prevent scrolling
document.body.style.overflow = 'hidden'; document.body.style.overflow = 'hidden'
} }
return !status; return !status
}); })
}; }
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 h-8 w-8"> className="text-gray-900 dark:text-gray-100"
>
<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="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"
@ -38,19 +39,23 @@ const MobileNav = () => {
</svg> </svg>
</button> </button>
<div <div
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 ${ className={`fixed top-0 left-0 z-10 h-full w-full transform bg-gray-200 opacity-95 duration-300 ease-in-out dark:bg-gray-800 ${
navShow ? 'translate-x-0' : 'translate-x-full' navShow ? 'translate-x-0' : 'translate-x-full'
}`}> }`}
>
<div className="flex justify-end"> <div className="flex justify-end">
<button <button
className="mr-8 mt-11 h-8 w-8" type="button"
className="mr-5 mt-11 h-8 w-8 rounded"
aria-label="Toggle Menu" aria-label="Toggle Menu"
onClick={onToggleNav}> onClick={onToggleNav}
>
<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"
>
<path <path
fillRule="evenodd" 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" 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"
@ -65,15 +70,16 @@ const MobileNav = () => {
<Link <Link
href={link.href} href={link.href}
className="text-2xl font-bold tracking-widest text-gray-900 dark:text-gray-100" className="text-2xl font-bold tracking-widest text-gray-900 dark:text-gray-100"
onClick={onToggleNav}> onClick={onToggleNav}
>
{link.title} {link.title}
</Link> </Link>
</div> </div>
))} ))}
</nav> </nav>
</div> </div>
</> </div>
); )
}; }
export default MobileNav; export default MobileNav

View File

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

View File

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

36
components/Pagination.js Normal file
View File

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

71
components/Pre.js Normal file
View File

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

163
components/SEO.js Normal file
View File

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

View File

@ -1,37 +1,36 @@
'use client'; import siteMetadata from '@/data/siteMetadata'
import { useEffect, useState } from 'react'
import siteMetadata from '@/data/siteMetadata';
import { useEffect, useState } from 'react';
const ScrollTopAndComment = () => { const ScrollTopAndComment = () => {
const [show, setShow] = useState(false); const [show, setShow] = useState(false)
useEffect(() => { useEffect(() => {
const handleWindowScroll = () => { const handleWindowScroll = () => {
if (window.scrollY > 50) setShow(true); if (window.scrollY > 50) setShow(true)
else setShow(false); else setShow(false)
}; }
window.addEventListener('scroll', handleWindowScroll); window.addEventListener('scroll', handleWindowScroll)
return () => window.removeEventListener('scroll', handleWindowScroll); return () => window.removeEventListener('scroll', handleWindowScroll)
}, []); }, [])
const handleScrollTop = () => { const handleScrollTop = () => {
window.scrollTo({ top: 0 }); window.scrollTo({ top: 0 })
}; }
const handleScrollToComment = () => { const handleScrollToComment = () => {
document.getElementById('comment')?.scrollIntoView(); document.getElementById('comment').scrollIntoView()
}; }
return ( return (
<div <div
className={`fixed bottom-8 right-8 hidden flex-col gap-3 ${ className={`fixed right-8 bottom-8 hidden flex-col gap-3 ${show ? 'md:flex' : 'md:hidden'}`}
show ? 'md:flex' : 'md:hidden' >
}`}> {siteMetadata.comment.provider && (
{siteMetadata.comments?.provider && (
<button <button
aria-label="Scroll To Comment" aria-label="Scroll To Comment"
type="button"
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"
@ -43,8 +42,10 @@ const ScrollTopAndComment = () => {
)} )}
<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">
<path <path
fillRule="evenodd" fillRule="evenodd"
@ -54,7 +55,7 @@ const ScrollTopAndComment = () => {
</svg> </svg>
</button> </button>
</div> </div>
); )
}; }
export default ScrollTopAndComment; export default ScrollTopAndComment

View File

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

@ -0,0 +1,3 @@
export default function SectionContainer({ children }) {
return <div className="mx-auto max-w-3xl px-4 sm:px-6 xl:max-w-5xl xl:px-0">{children}</div>
}

View File

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

64
components/TOCInline.js Normal file
View File

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

14
components/Tag.js Normal file
View File

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

View File

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

View File

@ -1,29 +1,27 @@
'use client'; import { useEffect, useState } from 'react'
import { useTheme } from 'next-themes'
import { useEffect, useState } from 'react';
import { useTheme } from 'next-themes';
const ThemeSwitch = () => { const ThemeSwitch = () => {
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false)
const { theme, setTheme } = useTheme(); const { theme, setTheme, resolvedTheme } = 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"
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}> type="button"
className="ml-1 mr-1 h-8 w-8 rounded p-1 sm:ml-4"
onClick={() => setTheme(theme === 'dark' || resolvedTheme === 'dark' ? 'light' : 'dark')}
>
<svg <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 h-6 w-6"> className="text-gray-900 dark:text-gray-100"
{mounted && theme === 'dark' ? ( >
{mounted && (theme === 'dark' || resolvedTheme === '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"
@ -34,7 +32,7 @@ const ThemeSwitch = () => {
)} )}
</svg> </svg>
</button> </button>
); )
}; }
export default ThemeSwitch; export default ThemeSwitch

View File

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

View File

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

View File

@ -0,0 +1,18 @@
import Script from 'next/script'
import siteMetadata from '@/data/siteMetadata'
const PosthogScript = () => {
return (
<>
<Script strategy="lazyOnload" id="posthog-script">
{`
!function(t,e){var o,n,p,r;e.__SV||(window.posthog=e,e._i=[],e.init=function(i,s,a){function g(t,e){var o=e.split(".");2==o.length&&(t=t[o[0]],e=o[1]),t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}}(p=t.createElement("script")).type="text/javascript",p.async=!0,p.src=s.api_host+"/static/array.js",(r=t.getElementsByTagName("script")[0]).parentNode.insertBefore(p,r);var u=e;for(void 0!==a?u=e[a]=[]:a="posthog",u.people=u.people||[],u.toString=function(t){var e="posthog";return"posthog"!==a&&(e+="."+a),t||(e+=" (stub)"),e},u.people.toString=function(){return u.toString(1)+".people (stub)"},o="capture identify alias people.set people.set_once set_config register register_once unregister opt_out_capturing has_opted_out_capturing opt_in_capturing reset isFeatureEnabled onFeatureFlags".split(" "),n=0;n<o.length;n++)g(u,o[n]);e._i.push([i,s,a])},e.__SV=1)}(document,window.posthog||[]);
posthog.init('${siteMetadata.analytics.posthogAnalyticsId}',{api_host:'https://app.posthog.com'})
`}
</Script>
</>
)
}
export default PosthogScript

View File

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

View File

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

View File

@ -0,0 +1,22 @@
import GA from './GoogleAnalytics'
import Plausible from './Plausible'
import SimpleAnalytics from './SimpleAnalytics'
import Umami from './Umami'
import Posthog from './Posthog'
import siteMetadata from '@/data/siteMetadata'
const isProduction = process.env.NODE_ENV === 'production'
const Analytics = () => {
return (
<>
{isProduction && siteMetadata.analytics.plausibleDataDomain && <Plausible />}
{isProduction && siteMetadata.analytics.simpleAnalytics && <SimpleAnalytics />}
{isProduction && siteMetadata.analytics.umamiWebsiteId && <Umami />}
{isProduction && siteMetadata.analytics.googleAnalyticsId && <GA />}
{isProduction && siteMetadata.analytics.posthogAnalyticsId && <Posthog />}
</>
)
}
export default Analytics

View File

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

View File

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

View File

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

View File

@ -0,0 +1,39 @@
import siteMetadata from '@/data/siteMetadata'
import dynamic from 'next/dynamic'
const UtterancesComponent = dynamic(
() => {
return import('@/components/comments/Utterances')
},
{ ssr: false }
)
const GiscusComponent = dynamic(
() => {
return import('@/components/comments/Giscus')
},
{ ssr: false }
)
const DisqusComponent = dynamic(
() => {
return import('@/components/comments/Disqus')
},
{ ssr: false }
)
const Comments = ({ frontMatter }) => {
const comment = siteMetadata?.comment
if (!comment || Object.keys(comment).length === 0) return <></>
return (
<div id="comment">
{siteMetadata.comment && siteMetadata.comment.provider === 'giscus' && <GiscusComponent />}
{siteMetadata.comment && siteMetadata.comment.provider === 'utterances' && (
<UtterancesComponent />
)}
{siteMetadata.comment && siteMetadata.comment.provider === 'disqus' && (
<DisqusComponent frontMatter={frontMatter} />
)}
</div>
)
}
export default Comments

View File

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

After

Width:  |  Height:  |  Size: 403 B

View File

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

After

Width:  |  Height:  |  Size: 827 B

View File

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

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

View File

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

View File

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

After

Width:  |  Height:  |  Size: 615 B

View File

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

After

Width:  |  Height:  |  Size: 224 B

View File

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

After

Width:  |  Height:  |  Size: 607 B

View File

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

After

Width:  |  Height:  |  Size: 474 B

View File

@ -1,157 +0,0 @@
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 dark:bg-gray-800 px-5 py-3 font-mono text-sm font-bold text-gray-200; @apply rounded-t bg-gray-700 px-5 py-3 font-mono text-sm font-bold text-gray-200;
} }
.remark-code-title + div > pre { .remark-code-title + div > pre {
@ -32,7 +32,7 @@
} }
.highlight-line { .highlight-line {
@apply -mx-4 border-l-4 border-primary-500 bg-gray-700 bg-opacity-50; @apply -mx-4 border-l-4 border-primary-500 bg-gray-700 bg-opacity-50;
} }
.line-number::before { .line-number::before {
@ -138,7 +138,3 @@
.token.table { .token.table {
display: inline; display: inline;
} }
.token.table {
display: inline;
}

View File

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

View File

@ -1,6 +1,6 @@
--- ---
name: Ivan Li name: Ivan Li
avatar: https://minio.ivanli.cc/ivan-public/uPic/2023/Urpetm.png avatar: https://pan.ivanli.cc/api/v3/file/source/1234/%E5%A4%B4%E5%83%8F.png?sign=xIgy54DyFRYupxjZJbK02HmpKX8C53YR-O0I18Rxm70%3D%3A0
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,12 @@
---
name: Sparrow Hawk
avatar: /static/images/sparrowhawk-avatar.jpg
occupation: Wizard of Earthsea
company: Earthsea
twitter: https://twitter.com/sparrowhawk
linkedin: https://www.linkedin.com/sparrowhawk
---
At birth, Ged was given the child-name Duny by his mother. He was born on the island of Gont, as a son of a bronzesmith. His mother died before he reached the age of one. As a small boy, Ged had overheard the village witch, his maternal aunt, using various words of power to call goats. Ged later used the words without an understanding of their meanings, to surprising effect.
The witch knew that using words of power effectively without understanding them required innate power, so she endeavored to teach him what little she knew. After learning more from her, he was able to call animals to him. Particularly, he was seen in the company of wild sparrowhawks so often that his "use name" became Sparrowhawk.

View File

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

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

@ -1,221 +0,0 @@
---
title: 在 PVE 中运行 Arch Linux
date: '2022-02-18'
lastmod: '2022-09-17'
tags: ['Arch Linux', 'Linux', 'PVE']
draft: false
summary: Arch Linux 的好,懂的都懂。这次在 PVE 中的 LCX 虚拟化了几个 Arch Linux 环境,用于跑一些服务和开发环境。本文主要分享了 Arch Linux 的配置步骤,其他方式入教的同志也可参考本文配置。
---
Arch Linux 准入门槛确实有点高,在 PVE 中,使用 LCX 容器运行 Arch Linux似乎是一个不错的选择难度比物理机安装低就是资料也少了许多……不过问题不大毕竟最蛋疼的部分我们可以忽略掉了。前几个月还想着直接在树莓派上安装 Arch Linux操作一波太难了时间有限就没继续搞了(没有备用设备,折腾完怕是要旷工了),最后还是再次给树每派安装了 Manjaro。
## 起步
### 0. 创建 LCX 容器
打开 Proxmox VE选择你的宿主机然后在界面右上角点击“创建 CT”。
然后你就看着搞咯,创建这个没有像 OpenWRT 那样讲究。
值得注意的一点是,记得先在 PVE 中通过 `pveam` 更新并下载 Arch Linux 的模板 _([Proxmox Container Toolkit](https://pve.proxmox.com/pve-docs/chapter-pct.html#pct_container_images))_。
进入容器后,我们将以 `root` 用户登录。
### 1. 配置系统
位置(Location)
先编辑 `/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
locale-gen
```
语言:
然后创建文件 `/etc/locale.conf`,内容如下:
```ini
LANG=en_US.UTF-8
```
命令:
```zsh
echo 'LANG=en_US.UTF-8' > /etc/locale.conf
```
时区
查看当前时区:
```zsh
date +"%Z %z"
```
![image.png](https://notes.ivanli.cc/assets/image_1651218347929_0.png)
如果在中国大陆,那么执行以下命令:
```bash
ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime
# 验证
date
# outputs:
# Sat Jan 15 23:26:18 CST 2022
```
### 2. 配置 pacman
我们知道 pacman 是 Arch Linux 自带的包管理器,系统到手,得先装点软件,毕竟 Arch Linux 比较简约。
首先配置 pacman 的源的镜像:
```bash
nano /etc/pacman.d/mirrorlist
```
选择你喜欢并且方便连接的镜像,然后删除该行的“#”取消注释。可以选择一个或多个,在前面的优先级高。
开启并行下载,在 `/etc/pacman.conf` 中取消 `ParallelDownloads` 前的注释,值为并行下载数:
```bash
sed -i "s/#ParallelDownloads = 5/ParallelDownloads = 5/" /etc/pacman.conf
```
接下来我们更新已安装的软件,我们的哲学就是时刻保持最新。
```bash
pacman -Syu
```
**一般来说,执行上面的命令后,会拉取索引数据库,之后会优先更新 `archlinuxx-keyring`。如果不是这样的话,应当手动执行下面的代码:**
初始化并刷新 pacman 的 keys。这个 key 是 pacman 的每个用户都拥有的,包括开发者和使用者。所以执行下面两条命令:
```bash
pacman-key --init
pacman-key --populate
pacman-key --refresh-keys
pacman -Sy archlinux-keyring
pacman -Syu
```
没执行上面步骤的,要手动一个个确认软件包开发者的签名……很蛋疼。如果遇到各种错误的话,可以执行下面几条命令后,再执行上面的命令:
```bash
pacman -Sc
pacman-mirrors -f0
rm -fr /etc/pacman.d/gnupg
```
_参考[Cant Upgrade because of keyring - Technical Issues and Assistance / Package update process - Manjaro Linux Forum](https://archived.forum.manjaro.org/t/cant-upgrade-because-of-keyring/106893/10)_
### 3. 创建用户
首先,安装 `sudo`
```bash
pacman -S sudo
```
让我们给自己分配一个具有 sudo 权限的账户
```zsh
useradd -m ivan
passwd ivan
usermod -aG wheel ivan
```
_参考[Create a Sudo User on Arch Linux - Vultr.com](https://www.vultr.com/docs/create-a-sudo-user-on-arch-linux?__cf_chl_captcha_tk__=zPG_V_axFV3IH5lhY2j_1ChaaZgIcdPe_eYDPUOSouY-1642259505-0-gaNycGzNCZE)_
如果 `visudo` 找不到编辑器,那么可以执行:
```zsh
EDITOR=vim visudo
```
接下来使用刚刚创建的用户登录吧!
### 4. 使用 SSH 远程登录
先安装 OpenSSH
```bash
sudo pacman -S openssh
```
然后启用并启动:
```bash
sudo systemctl enable sshd
sudo systemctl start sshd
```
接下来就可以在其他机子上以刚刚的用户通过 ssh 访问了。
### 5. 安装 Yay
安装 AUR 上的软件,怎么少得了 [[yay]] 呢?安装 Yay 需要切换到非 root 账户。
```bash
sudo pacman -S git
sudo pacman -S --needed base-devel
# 上面的命令有选装的项目,简单起见,全都装上
git clone https://aur.archlinux.org/yay.git
cd yay
makepkg -si
```
### 6. Zsh
安装 Zsh
```shell
yay -Sy zsh-git
```
安装 Zinit 和我常用的插件
```shell
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
zinit ice depth=1; zinit light romkatv/powerlevel10k' >> ~/.zshrc
```
然后进入到 `zsh` 中,执行一次 `source ~/.zshrc`
```shell
zsh
source ~/.zshrc
```
设置 Zsh 为默认的 shell 程序:
```bash
# 列出所有已安装的 shell 程序
chsh -l
# 从上面的结果中找到 zsh 的完整路径
# 我的是 /bin/zsh
chsh -s /bin/zsh
```
### 7. Docker
安装 Docker 也很简单:
```bash
yay -S docker
# 启动
sudo systemctl start docker
# 启用
sudo systemctl enable docker
# 安装 Compose
yay -S docker-compose
# 添加当前用户到 docker 组
sudo usermod -aG docker $USER
# log in to a new group
newgrp docker
```

View File

@ -1,271 +0,0 @@
---
title: 使用 Xray、acme.sh、Docker Compose 搭建内网穿透服务
date: '2022-06-11'
tags: ['xray', 'acme', 'acme.sh', 'docker', 'docker compose', '内网穿透']
draft: false
summary: 为了能在外直接访问家中网络,我组建了三套方案,一是 [[Xray]],二是 [[ZeroTier]],三是 [[NPS]]。今天,我准备在我上个月购入的服务器上再部署一套 Xray 服务,提高可用性。本次准备完全仰仗 Docker 容器,让我未来迁移服务更加省事。
---
## 简介
为了能在外直接访问家中网络,我组建了三套方案,一是 [Xray](/tags/xray),二是 [ZeroTier](/tags/zerotier),三是 [NPS](/tags/nps)。今天,我准备在我上个月购入的服务器上再部署一套 Xray 服务,提高可用性。本次准备完全仰仗 Docker 容器,让我未来迁移服务更加省事。
## 目标与方案
个人自用,成本得控制到零(bushi),安全性还是得做得好些,所以选用 Xray 来承载功能,使用免费的 TLS CA 来签发证书。由于免费的证书一般有效期比较短 (常见的是 90 天),所以还需要实现自动续签。
Let's Encrypt 和 acme.sh 是不错的组合。不过听说 Let's Encrypt 被收购了,不知道是否有安全风险,未来需要再确认下。由于财力并不雄厚,考虑到未来可能服务会”流离失所“,用容器方案比较好迁移。
## 技术栈
- Xray
一款支持加密传输、内网穿透的网络工具。由 GoLang 编写,支持很多平台。
_官方站点[Project X](https://xtls.github.io/)_
- acme.sh
用于签发 TLS 证书。顾名思义,支持 ACME 协议签发、自动续签证书的脚本。
_官方站点[acmesh-official/acme.sh](https://github.com/acmesh-official/acme.sh)_
- Caddy
用于反向代理部署在家里的 Web 服务。它是现代的反向代理服务。
_官方站点[Caddy 2](https://caddyserver.com/v2)_
- Docker Compose
众所周知?
## 搭建步骤
### Docker Compose
首先需要拥有并运行 Docker 和 Docker Compose。
创建一个用于存放配置文件目录,并进入该目录。
创建 Compose 配置文件:
```bash
touch docker-compose.yml
vim docker-compose.yml
```
文件内容:
```yaml
version: '3.9'
networks:
caddy:
name: caddy
xray:
name: xray
volumes:
caddy-data:
name: caddy-data
caddy-config:
name: caddy-config
acme-sh-data:
name: acme-sh-data
services:
caddy:
image: caddy:2
container_name: caddy
restart: always
ports:
- 80:80
- 443:443
networks:
- caddy
volumes:
- $PWD/caddy/Caddyfile:/etc/caddy/Caddyfile
- $PWD/site:/srv
- caddy-data:/data
- caddy-config:/config
xray:
image: teddysun/xray
container_name: xray
restart: always
networks:
- xray
- caddy
ports:
- 3332-3334:3332-3334
volumes:
- ./xray:/etc/xray
- acme-sh-data:/certs
command: "xray -c=/etc/xray/config.yml"
acme.sh:
image: neilpang/acme.sh
container_name: acme.sh
# restart: always
volumes:
- acme-sh-data:/acme.sh
env_file: acme.env
command: "daemon"
```
### 签发证书
使用 DNS Challenge 来签发证书,所以需要 DNS 服务商的 API 来实现自动化签发流程。
以阿里云举例:
1. 创建 RAM 子账户,并只允许访问 API
2. 复制 key 和 secret
3. 为 RAM 子账户授权 DNS 解析的管理权限。
在当前目录创建 `acme.env` 文件:
```zsh
touch acme.env
vim acme.env
```
文件内容:
```zsh
Ali_Key="sdfsdfsdfljlbjkljlkjsdfoiwje"
Ali_Secret="jlsdflanljkljlfdsaklkjflsa"
```
启动 compose 服务:
```zsh
docker-compose up -d
```
前面我们启动了刚刚创建的 compose 服务,现在,我们使用 `acme.sh` 容器运行以下命令签发证书:
```zsh
docker exec acme.sh acme.sh --log --issue --dns dns_ali --server letsencrypt -d ivanli.cc -d "*.ivanli.cc"
```
签发成功后你将会在输出末尾看到如下内容:
![签发成功时,程序输出图示](https://notes.ivanli.cc/assets/image_1654257070519_0.png)
注意,签发通配符证书时,需要一次性将所有通配的子域都写在同一条命令上,使用 `-d` 参数追加。
### 配置 Xray
因为前面挂载了 `acme.sh` 的数据卷,所以默认的证书位于 `/certs/ivanli.cc/` 目录下。证书要使用 `fullchain` 的,避免证书链不完整,导致客户端连接验证失败。
创建 Xray 配置文件:
```zsh
mkdir ./xray
vim ./xray/config.yml
```
内容如下:
```yml
inbounds:
# listening for host-name.home
- tag: host-name.home.in
listen: 0.0.0.0
port: 3332
protocol: vless
settings:
clients:
- id: <uuid> # 你的 UUID
flow: xtls-rprx-direct
decryption: none
streamSettings:
network: tcp
security: xtls
xtlsSettings:
serverName: ivanli.cc
alpn:
- http/1.1
certificates:
- certificateFile: /certs/ivanli.cc/fullchain.cer
keyFile: /certs/ivanli.cc/ivanli.cc.key
# reverse ssh to host-name.home
- tag: ssh.host-name.home.in
listen: 0.0.0.0
port: 3334
protocol: dokodemo-door
settings:
network: tcp
address: 127.0.0.1
port: 22
# reverse http to 101.home
- tag: http.host-name.home.in
listen: 0.0.0.0
port: 3333
protocol: dokodemo-door
settings:
network: tcp
address: 127.0.0.1
port: 80
outbounds:
- protocol: freedom
tag: direct
- tag: blocked
protocol: blackhole
reverse:
portals:
- tag: host-name.home.portal
domain: host-name.home.reverse
routing:
- type: field
inboundTag:
- ssh.host-name.home.in
- http.host-name.home.in
outboundTag: host-name.home.portal
- type: field
domain:
- full:host-name.home.reverse
outboundTag: host-name.home.portal
```
配置说明
- `3332` 端口用于客户端连接服务端;
- `3333` 端口用于 HTTP 穿透,映射了 `server:3333 <--> client:80` 端口;
- `3334` 端口用于 SSH 穿透。
- 如果需要连接更多的内网主机和端口,可以继续依葫芦画瓢地加。
### 配置 Caddy
为了让我们的 Web 站点能够公开到互联网,并且增强可控性,没有直接公开 Xray 的端口,而是使用 Caddy 反向代理 Xray 的穿透的本地端口。
创建 Caddy 配置文件:
```zsh
mkdir ./caddy
vim ./caddy/Caddyfile
```
内容如下:
```caddyfile
{
servers {
protocol {
allow_h2c
}
}
admin off
}
any-service.ivanli.cc, another-service.ivanli.cc {
reverse_proxy http://localhost:3333
}
```
端口 `3333` 是 Xray Server 映射家里 HTTP 服务的端口,所以我们这里反向代理服务器上的 3333 端口就好了。
因为 Caddy 会自动从 CA 签发证书,所以这里不需要我们手动配置证书。
配置完成后,重启服务就好
```zsh
docker-compose restart
```
现在,你拥有一个安全的内网穿透服务了~
用户通过 HTTPS 协议访问服务器,服务器通过 TLS 加密连接与内网主机通讯。
TODO 自动重启

View File

@ -1,282 +0,0 @@
---
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
```

38
data/blog/code-sample.md Normal file
View File

@ -0,0 +1,38 @@
---
title: Sample .md file
date: '2016-03-08'
tags: ['markdown', 'code', 'features']
draft: false
summary: Example of a markdown file with code blocks and syntax highlighting
---
A sample post with markdown.
## Inline Highlighting
Sample of inline highlighting `sum = parseInt(num1) + parseInt(num2)`
## Code Blocks
Some Javascript code
```javascript
var num1, num2, sum
num1 = prompt('Enter first number')
num2 = prompt('Enter second number')
sum = parseInt(num1) + parseInt(num2) // "+" means "add"
alert('Sum = ' + sum) // "+" means combine into a string
```
Some Python code 🐍
```python
def fib():
a, b = 0, 1
while True: # First iteration:
yield a # yield 0 to start with and then
a, b = b, a + b # a will now be 1, and b will also be 1, (0 + 1)
for index, fibonacci_number in zip(range(10), fib()):
print('{i:3}: {f:3}'.format(i=index, f=fibonacci_number))
```

View File

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

@ -0,0 +1,141 @@
---
title: Deriving the OLS Estimator
date: '2020-12-21'
tags: ['next js', 'math', 'ols']
draft: false
summary: 'How to derive the OLS Estimator with matrix notation and a tour of math typesetting using markdown with the help of KaTeX.'
---
# Introduction
Parsing and display of math equations is included in this blog template. Parsing of math is enabled by `remark-math` and `rehype-katex`.
KaTeX and its associated font is included in `_document.js` so feel free to use it on any page.
^[For the full list of supported TeX functions, check out the [KaTeX documentation](https://katex.org/docs/supported.html)]
Inline math symbols can be included by enclosing the term between the `$` symbol.
Math code blocks are denoted by `$$`.
If you intend to use the `$` sign instead of math, you can escape it (`\$`), or specify the HTML entity (`&dollar;`) [^2]
Inline or manually enumerated footnotes are also supported. Click on the links above to see them in action.
[^2]: \$10 and &dollar;20.
# Deriving the OLS Estimator
Using matrix notation, let $n$ denote the number of observations and $k$ denote the number of regressors.
The vector of outcome variables $\mathbf{Y}$ is a $n \times 1$ matrix,
```tex
\mathbf{Y} = \left[\begin{array}
{c}
y_1 \\
. \\
. \\
. \\
y_n
\end{array}\right]
```
$$
\mathbf{Y} = \left[\begin{array}
{c}
y_1 \\
. \\
. \\
. \\
y_n
\end{array}\right]
$$
The matrix of regressors $\mathbf{X}$ is a $n \times k$ matrix (or each row is a $k \times 1$ vector),
```latex
\mathbf{X} = \left[\begin{array}
{ccccc}
x_{11} & . & . & . & x_{1k} \\
. & . & . & . & . \\
. & . & . & . & . \\
. & . & . & . & . \\
x_{n1} & . & . & . & x_{nn}
\end{array}\right] =
\left[\begin{array}
{c}
\mathbf{x}'_1 \\
. \\
. \\
. \\
\mathbf{x}'_n
\end{array}\right]
```
$$
\mathbf{X} = \left[\begin{array}
{ccccc}
x_{11} & . & . & . & x_{1k} \\
. & . & . & . & . \\
. & . & . & . & . \\
. & . & . & . & . \\
x_{n1} & . & . & . & x_{nn}
\end{array}\right] =
\left[\begin{array}
{c}
\mathbf{x}'_1 \\
. \\
. \\
. \\
\mathbf{x}'_n
\end{array}\right]
$$
The vector of error terms $\mathbf{U}$ is also a $n \times 1$ matrix.
At times it might be easier to use vector notation. For consistency, I will use the bold small x to denote a vector and capital letters to denote a matrix. Single observations are denoted by the subscript.
## Least Squares
**Start**:
$$y_i = \mathbf{x}'_i \beta + u_i$$
**Assumptions**:
1. Linearity (given above)
2. $E(\mathbf{U}|\mathbf{X}) = 0$ (conditional independence)
3. rank($\mathbf{X}$) = $k$ (no multi-collinearity i.e. full rank)
4. $Var(\mathbf{U}|\mathbf{X}) = \sigma^2 I_n$ (Homoskedascity)
**Aim**:
Find $\beta$ that minimises the sum of squared errors:
$$
Q = \sum_{i=1}^{n}{u_i^2} = \sum_{i=1}^{n}{(y_i - \mathbf{x}'_i\beta)^2} = (Y-X\beta)'(Y-X\beta)
$$
**Solution**:
Hints: $Q$ is a $1 \times 1$ scalar, by symmetry $\frac{\partial b'Ab}{\partial b} = 2Ab$.
Take matrix derivative w.r.t $\beta$:
```tex
\begin{aligned}
\min Q & = \min_{\beta} \mathbf{Y}'\mathbf{Y} - 2\beta'\mathbf{X}'\mathbf{Y} +
\beta'\mathbf{X}'\mathbf{X}\beta \\
& = \min_{\beta} - 2\beta'\mathbf{X}'\mathbf{Y} + \beta'\mathbf{X}'\mathbf{X}\beta \\
\text{[FOC]}~~~0 & = - 2\mathbf{X}'\mathbf{Y} + 2\mathbf{X}'\mathbf{X}\hat{\beta} \\
\hat{\beta} & = (\mathbf{X}'\mathbf{X})^{-1}\mathbf{X}'\mathbf{Y} \\
& = (\sum^{n} \mathbf{x}_i \mathbf{x}'_i)^{-1} \sum^{n} \mathbf{x}_i y_i
\end{aligned}
```
$$
\begin{aligned}
\min Q & = \min_{\beta} \mathbf{Y}'\mathbf{Y} - 2\beta'\mathbf{X}'\mathbf{Y} +
\beta'\mathbf{X}'\mathbf{X}\beta \\
& = \min_{\beta} - 2\beta'\mathbf{X}'\mathbf{Y} + \beta'\mathbf{X}'\mathbf{X}\beta \\
\text{[FOC]}~~~0 & = - 2\mathbf{X}'\mathbf{Y} + 2\mathbf{X}'\mathbf{X}\hat{\beta} \\
\hat{\beta} & = (\mathbf{X}'\mathbf{X})^{-1}\mathbf{X}'\mathbf{Y} \\
& = (\sum^{n} \mathbf{x}_i \mathbf{x}'_i)^{-1} \sum^{n} \mathbf{x}_i y_i
\end{aligned}
$$

View File

@ -1,19 +0,0 @@
---
title: 你好,我的朋友!
date: '2022-07-19'
tags: ['Blog', 'Markdown', 'Next.js', 'Tailwind CSS']
draft: false
summary: 这是本站当前版本的第一篇文章。2015 - 2022希望是最后一次重开博客。
---
2015 年开始,我开发了自己的第一个博客站,当时还是使用 PHP 5 + MySQL + HTML + CSS 3 开发的。当时非常地执着,不想用 JavaScript希望用纯 CSS 实现所有功能。当然,这所有功能也很朴素。不过最后好像还是加上了一些 JavaScript 代码。当年使用 PHP主要是因为更早之前我一直在运营和维护 Discuz! 论坛,混“机油”圈。当时认识了很多网友呢。
后来,博客慢慢迭代,之后的后端都是使用 Node.js 了,前端从 Vue (Nuxt.js) -> Angular -> React (Next.js)。现在,我发现文章存数据库里是非常不明智的选择,因为我每次重写博客,都不想迁移文章……是的,就是不想……以至于后来,我开始考虑从文件生成静态站点。毕竟我们现在拥有 Markdown 这么棒的格式。
本来 22 年初的时候就准备放弃 React 版本的博客了,为什么要放弃呢?因为去年一年一直在研究微服务,全部自己实现确实费时费力,博客迟迟没有完全完成,所以当时那版博客也没有在主域名上部署。后来我就一直在使用 Logseq 做笔记,写文章。不过我发现这家伙确实不方便完整地将内容无损地转换为静态站点……这就是现在这版本博客上线的原因了。这么想想我的主域名吃灰了一年有余了……
不知道会不会坚持经常更新博客,因为我还是比较喜欢记录一些碎片内容,以备将来回顾。而博文主要还是为了做一些分享。做分享,动力还是有些不足,而且学海无涯,要学的东西还是真的多。精力有限,估计短期是无法预见能有经常分享的机会了。
总之,先上线再说。后面会陆续添加评论、评分和通知功能。会尽量使用开源、自部署的程序。自从看上了容器化和容器编排技术,我已经爱上了用开源项目自部署一堆服务,配合使用。
_日积月累厚积薄发。_

View File

@ -0,0 +1,198 @@
---
title: 'Markdown Guide'
date: '2019-10-11'
tags: ['github', 'guide']
draft: false
summary: 'Markdown cheatsheet for all your blogging needs - headers, lists, images, tables and more! An illustrated guide based on GitHub Flavored Markdown.'
---
# Introduction
Markdown and Mdx parsing is supported via `unified`, and other remark and rehype packages. `next-mdx-remote` allows us to parse `.mdx` and `.md` files in a more flexible manner without touching webpack.
GitHub flavored markdown is used. `mdx-prism` provides syntax highlighting capabilities for code blocks. Here's a demo of how everything looks.
The following markdown cheatsheet is adapted from: https://guides.github.com/features/mastering-markdown/
# What is Markdown?
Markdown is a way to style text on the web. You control the display of the document; formatting words as bold or italic, adding images, and creating lists are just a few of the things we can do with Markdown. Mostly, Markdown is just regular text with a few non-alphabetic characters thrown in, like `#` or `*`.
# Syntax guide
Heres an overview of Markdown syntax that you can use anywhere on GitHub.com or in your own text files.
## Headers
```
# This is a h1 tag
## This is a h2 tag
#### This is a h4 tag
```
# This is a h1 tag
## This is a h2 tag
#### This is a h4 tag
## Emphasis
```
_This text will be italic_
**This text will be bold**
_You **can** combine them_
```
_This text will be italic_
**This text will be bold**
_You **can** combine them_
## Lists
### Unordered
```
- Item 1
- Item 2
- Item 2a
- Item 2b
```
- Item 1
- Item 2
- Item 2a
- Item 2b
### Ordered
```
1. Item 1
1. Item 2
1. Item 3
1. Item 3a
1. Item 3b
```
1. Item 1
1. Item 2
1. Item 3
1. Item 3a
1. Item 3b
## Images
```
![GitHub Logo](https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png)
Format: ![Alt Text](url)
```
![GitHub Logo](https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png)
## Links
```
http://github.com - automatic!
[GitHub](http://github.com)
```
http://github.com - automatic!
[GitHub](http://github.com)
## Blockquotes
```
As Kanye West said:
> We're living the future so
> the present is our past.
```
As Kanye West said:
> We're living the future so
> the present is our past.
## Inline code
```
I think you should use an
`<addr>` element here instead.
```
I think you should use an
`<addr>` element here instead.
## Syntax highlighting
Heres an example of how you can use syntax highlighting with [GitHub Flavored Markdown](https://help.github.com/articles/basic-writing-and-formatting-syntax/):
````
```js:fancyAlert.js
function fancyAlert(arg) {
if (arg) {
$.facebox({ div: '#foo' })
}
}
```
````
And here's how it looks - nicely colored with styled code titles!
```js:fancyAlert.js
function fancyAlert(arg) {
if (arg) {
$.facebox({ div: '#foo' })
}
}
```
## Footnotes
```
Here is a simple footnote[^1]. With some additional text after it.
[^1]: My reference.
```
Here is a simple footnote[^1]. With some additional text after it.
[^1]: My reference.
## Task Lists
```
- [x] list syntax required (any unordered or ordered list supported)
- [x] this is a complete item
- [ ] this is an incomplete item
```
- [x] list syntax required (any unordered or ordered list supported)
- [x] this is a complete item
- [ ] this is an incomplete item
## Tables
You can create tables by assembling a list of words and dividing them with hyphens `-` (for the first row), and then separating each column with a pipe `|`:
```
| First Header | Second Header |
| --------------------------- | ---------------------------- |
| Content from cell 1 | Content from cell 2 |
| Content in the first column | Content in the second column |
```
| First Header | Second Header |
| --------------------------- | ---------------------------- |
| Content from cell 1 | Content from cell 2 |
| Content in the first column | Content in the second column |
## Strikethrough
Any word wrapped with two tildes (like `~~this~~`) will appear ~~crossed out~~.

View File

@ -0,0 +1,76 @@
---
title: Images in Next.js
date: '2020-11-11'
tags: ['next js', 'guide']
draft: false
summary: 'In this article we introduce adding images in the tailwind starter blog and the benefits and limitations of the next/image component.'
authors: ['sparrowhawk']
---
# Introduction
The tailwind starter blog has out of the box support for [Next.js's built-in image component](https://nextjs.org/docs/api-reference/next/image) and automatically swaps out default image tags in markdown or mdx documents to use the Image component provided.
# Usage
To use in a new page route / javascript file, simply import the image component and call it e.g.
```js
import Image from 'next/image'
function Home() {
return (
<>
<h1>My Homepage</h1>
<Image src="/me.png" alt="Picture of the author" width={500} height={500} />
<p>Welcome to my homepage!</p>
</>
)
}
export default Home
```
For a markdown file, the default image tag can be used and the default `img` tag gets replaced by the `Image` component in the build process.
Assuming we have a file called `ocean.jpg` in `data/img/ocean.jpg`, the following line of code would generate the optimized image.
```
![ocean](/static/images/ocean.jpg)
```
Alternatively, since we are using mdx, we can just use the image component directly! Note, that you would have to provide a fixed width and height. The `img` tag method parses the dimension automatically.
```js
<Image alt="ocean" src="/static/images/ocean.jpg" width={256} height={128} />
```
_Note_: If you try to save the image, it is in webp format, if your browser supports it!
![ocean](/static/images/ocean.jpeg)
<p>
Photo by [YUCAR
FotoGrafik](https://unsplash.com/@yucar?utm_source=unsplash&amp;utm_medium=referral&amp;utm_content=creditCopyText)
on
[Unsplash](https://unsplash.com/s/photos/sea?utm_source=unsplash&amp;utm_medium=referral&amp;utm_content=creditCopyText)
</p>
# Benefits
- Smaller image size with Webp (~30% smaller than jpeg)
- Responsive images - the correct image size is served based on the user's viewport
- Lazy loading - images load as they are scrolled to the viewport
- Avoids [Cumulative Layout Shift](https://web.dev/cls/)
- Optimization on demand instead of build-time - no increase in build time!
# Limitations
- Due to the reliance on `next/image`, unless you are using an external image CDN like Cloudinary or Imgix, it is practically required to use Vercel for hosting. This is because the component acts like a serverless function that calls a highly optimized image CDN.
If you do not want to be tied to Vercel, you can remove `imgToJsx` in `remarkPlugins` in `lib/mdx.js`. This would avoid substituting the default `img` tag.
Alternatively, one could wait for image optimization at build time to be supported. A different library, [next-optimized-images](https://github.com/cyrilwanner/next-optimized-images) does that, although it requires transforming the images through webpack which is not done here.
- Images from external links are not passed through `next/image`
- All images have to be stored in the `public` folder e.g `/static/images/ocean.jpeg`

View File

@ -1,156 +0,0 @@
---
title: 使用 Verdaccio 自建 Node 存储库
date: '2022-09-23'
tags: ['Verdaccio', 'Self-Hosted', 'Docker', 'Caddy', 'registry', 'Node.js']
draft: false
summary: 作为靠着 JavaScript 生态吃饭的 Web 开发者,自建一个 Node regsitry 是很有必要的,我这次继续选择 Verdaccio 来搭建存储库。这次使用 Docker Compose 部署 Verdaccio并将 Caddy 用于反向代理该服务。
images: ['https://minio.ivanli.cc/ivan-public/uPic/2023/3Dqijk.png']
---
## 为何自建存储库?
平常开发项目时,会抽出一些可复用的逻辑封装成 package。如果别人用得上就可以发布到公开存储库中比如 NPM但有些没必要的或者是不可公开的就需要有私有库了。自建私有库是一个比较好的方案毕竟至少人手一个服务器不是四舍五入就是不要钱白送呐
再说了,要是遇到了上游 package 有缺陷,无论是自己提了 PR还是上游已有修复代码如果想要方便地使用并且与他人共享已修复的 package使用自建的存储库也是很方便的。
更重要的一点是Verdaccio 能作为任何仓库的代理。这样我们可以将本地的远程存储库设为 Verdaccio然后 Verdaccio 上游设为 `https://registry.npmjs.org` (这也是缺省值)就能得到一个带有缓存的反向代理了很适合国内的网络环境bushi)。
接着,就能解锁另一个功能了,假设我们修复了 `axios` 的一个缺陷,我们可以继续使用 `axios` 作为包名发布到 `Verdaccio` 中,这样再拉到的依赖就是我们修复的版本了。当然,版本号应当保持不变。之后上游合并了你的代码后,官方发包后版本号会增加。本地项目更新依赖后就能获取到官方更新的版本了,从而实现了”无感“的效果。
## 如何自建存储库
已有环境:
- Docker, Docker Compose
- Caddy (in Docker)
- 网络:`caddy`
新增:
- Verdaccio
接下来使用 Docker Compose 部署 Verdaccio并将其加入到 `caddy` 网络中,之后配置 Caddy使其反向代理 Verdaccio。
### 使用 Docker Compose 部署
创建文件 `docker-compose.yml`
```yml {9,16-17} showLineNumbers
version: '3'
networks:
caddy:
external: true # 目前我的 caddy 在其他 compose 中
services:
verdaccio:
image: verdaccio/verdaccio:5.x-next
container_name: verdaccio
restart: unless-stopped
networks:
- caddy
expose:
- 4873
# environment:
# VERDACCIO_PUBLIC_URL: "https://node-registry.ivanli.cc"
volumes:
- ./verdaccio:/verdaccio/conf
- verdaccio-storage-data:/verdaccio/storage
- verdaccio-plugins-data:/verdaccio/plugins
volumes:
verdaccio-storage-data:
verdaccio-plugins-data:
```
上面第 9 行可以看到我现在2022 年 09 月 22 日)是使用不是正式版本,因为当前的正式版有个缺陷,就是无法正确读取到反向代理提供的 `X-Forwarded-Proto`,这有可能导致访问问题。如果使用正式版本,需要加上第 17 行的环境变量。
**不要启动 compose**,因为你还没有配置文件。当然启动了也没关系,无伤大雅。
### 创建 Verdaccio 配置文件
因为前面将配置文件目录 `verdaccio/conf` 设为了 `verdaccio`,所以:
创建配置文件 `verdaccio/config.yaml`
```zsh {1,2} showLineNumbers
storage: /verdaccio/storage
plugins: /verdaccio/plugins
auth:
htpasswd:
file: ./htpasswd
algorithm: bcrypt
rounds: 10
uplinks:
npmjs:
url: https://registry.npmjs.org/
packages:
"@*/*":
access: $all
publish: $authenticated
proxy: npmjs
"**":
proxy: npmjs
publish: $authenticated
access: $all
log: { type: stdout, format: pretty, level: http }
web:
enable: true
title: "Ivan's Node Package Registry"
logo: logo.png
scope:
```
第一、二行对应 compose 文件的第 19、20 行。
### htpasswd
使用 [.htpasswd](https://en.wikipedia.org/wiki/.htpasswd) 配置账号密码。
因为我们使用了 `bcrypt` 算法保存密码,所以可以借助 [Bcrypt-Generator.com](https://bcrypt-generator.com/) 生成保存的密码。
创建文件:`verdaccio/htpasswd`:
```htpasswd
admin:$2a$12$9xxxxxxxxxxxxxxlO.slh2k2
```
### 配置 Caddy
```Caddyfile
http://node-registry.ivanli.cc, https://node-registry.ivanli.cc {
encode zstd gzip
reverse_proxy verdaccio:4873 {
// trusted_proxies 172.0.0.0/8 192.168.31.0/24
}
}
```
结合[官方文档关于反向代理的说明](https://verdaccio.org/docs/reverse-proxy/)Caddy 默认会传递 `Host` 和 `X-Forwarded-Proto` 字段。所以不需要像 Nginx 和 Apache 一样配置那么多东西。
第四行可选,因为我是多重代理,这个 Caddy 下游还有反向代理服务,所以需要使用 `trusted_proxies` 指令。_([参考](https://caddyserver.com/docs/caddyfile/directives/reverse_proxy#defaults))_
### 启动
大工告成,启动 compose
```bash
docker compose up -d
```
助你成功!
## 使用
### 配置默认远程仓库地址
[Using a private registry | Verdaccio](https://verdaccio.org/docs/cli-registry/)
### 发布与撤销发布
```bash
npm publish --registry="https://node-registry.ivanli.cc"
npm unpublish -f --registry="https://node-registry.ivanli.cc"
```

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