Compare commits
No commits in common. "master" and "696fe48d53a68b1de4b6a64f57246f30884e3f99" have entirely different histories.
master
...
696fe48d53
@ -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"
|
|
@ -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}"
|
|
@ -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"
|
|
||||||
}
|
|
@ -36,5 +36,3 @@ yarn-error.log*
|
|||||||
.env.production.local
|
.env.production.local
|
||||||
|
|
||||||
secrets.txt
|
secrets.txt
|
||||||
|
|
||||||
.pnpm-store
|
|
141
.drone.yml
Normal file
141
.drone.yml
Normal 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,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=
|
@ -1,2 +1 @@
|
|||||||
node_modules
|
node_modules
|
||||||
.eslintrc.js
|
|
29
.eslintrc.js
29
.eslintrc.js
@ -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
2
.gitattributes
vendored
@ -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
3
.github/FUNDING.yml
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
# These are supported funding model platforms
|
||||||
|
|
||||||
|
github: timlrx
|
37
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
37
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal 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.
|
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal 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.
|
33
.github/workflows/build-and-deploy-by-ftp.yaml
vendored
33
.github/workflows/build-and-deploy-by-ftp.yaml
vendored
@ -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
13
.gitignore
vendored
@ -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
16
.vscode/launch.json
vendored
@ -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
35
.vscode/settings.json
vendored
@ -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"
|
|
||||||
]
|
|
||||||
}
|
|
874
.yarn/releases/yarn-3.6.1.cjs
vendored
874
.yarn/releases/yarn-3.6.1.cjs
vendored
File diff suppressed because one or more lines are too long
@ -1,3 +0,0 @@
|
|||||||
nodeLinker: node-modules
|
|
||||||
|
|
||||||
yarnPath: .yarn/releases/yarn-3.6.1.cjs
|
|
34
Dockerfile
34
Dockerfile
@ -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
138
README.md
@ -9,9 +9,7 @@
|
|||||||
|
|
||||||
[](https://vercel.com/new/git/external?repository-url=https://github.com/timlrx/tailwind-nextjs-starter-blog)
|
[](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)
|
||||||
|
@ -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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
@ -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 };
|
|
@ -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>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
@ -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"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
@ -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"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
42
app/head.tsx
42
app/head.tsx
@ -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" />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
124
app/layout.tsx
124
app/layout.tsx
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
@ -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} />;
|
|
||||||
}
|
|
@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
36
app/seo.tsx
36
app/seo.tsx
@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
@ -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];
|
|
||||||
}
|
|
@ -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
|
|
||||||
}
|
|
@ -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} />;
|
|
||||||
}
|
|
@ -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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
@ -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 →
|
Learn more →
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
|
|
||||||
export default Card;
|
export default Card
|
23
components/ClientReload.js
Normal file
23
components/ClientReload.js
Normal 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
|
||||||
|
}
|
@ -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} />
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
@ -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>
|
||||||
);
|
)
|
||||||
}
|
}
|
@ -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
6
components/Image.js
Normal 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
|
@ -1,8 +0,0 @@
|
|||||||
const Image = ({
|
|
||||||
...rest
|
|
||||||
}: React.DetailedHTMLProps<
|
|
||||||
React.ImgHTMLAttributes<HTMLImageElement>,
|
|
||||||
HTMLImageElement
|
|
||||||
>) => <img {...rest} />;
|
|
||||||
|
|
||||||
export default Image;
|
|
54
components/LayoutWrapper.js
Normal file
54
components/LayoutWrapper.js
Normal 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
|
@ -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
23
components/Link.js
Normal 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
|
@ -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;
|
|
26
components/MDXComponents.js
Normal file
26
components/MDXComponents.js
Normal 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} />
|
||||||
|
}
|
@ -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,
|
|
||||||
};
|
|
@ -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
|
84
components/NewsletterForm.js
Normal file
84
components/NewsletterForm.js
Normal 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>
|
||||||
|
)
|
@ -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
36
components/Pagination.js
Normal 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
71
components/Pre.js
Normal 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
163
components/SEO.js
Normal 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
@ -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
|
@ -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;
|
|
3
components/SectionContainer.js
Normal file
3
components/SectionContainer.js
Normal 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>
|
||||||
|
}
|
@ -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
64
components/TOCInline.js
Normal 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
14
components/Tag.js
Normal 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
|
@ -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;
|
|
@ -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
|
36
components/analytics/GoogleAnalytics.js
Normal file
36
components/analytics/GoogleAnalytics.js
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
27
components/analytics/Plausible.js
Normal file
27
components/analytics/Plausible.js
Normal 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)
|
||||||
|
}
|
18
components/analytics/Posthog.js
Normal file
18
components/analytics/Posthog.js
Normal 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
|
25
components/analytics/SimpleAnalytics.js
Normal file
25
components/analytics/SimpleAnalytics.js
Normal 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
|
18
components/analytics/Umami.js
Normal file
18
components/analytics/Umami.js
Normal 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
|
22
components/analytics/index.js
Normal file
22
components/analytics/index.js
Normal 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
|
37
components/comments/Disqus.js
Normal file
37
components/comments/Disqus.js
Normal 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
|
72
components/comments/Giscus.js
Normal file
72
components/comments/Giscus.js
Normal 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
|
52
components/comments/Utterances.js
Normal file
52
components/comments/Utterances.js
Normal 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
|
39
components/comments/index.js
Normal file
39
components/comments/index.js
Normal 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
|
1
components/social-icons/facebook.svg
Normal file
1
components/social-icons/facebook.svg
Normal 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 |
1
components/social-icons/github.svg
Normal file
1
components/social-icons/github.svg
Normal 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 |
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
40
components/social-icons/index.js
Normal file
40
components/social-icons/index.js
Normal 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
|
@ -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;
|
|
1
components/social-icons/linkedin.svg
Normal file
1
components/social-icons/linkedin.svg
Normal 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 |
4
components/social-icons/mail.svg
Normal file
4
components/social-icons/mail.svg
Normal 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 |
1
components/social-icons/twitter.svg
Normal file
1
components/social-icons/twitter.svg
Normal 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 |
1
components/social-icons/youtube.svg
Normal file
1
components/social-icons/youtube.svg
Normal 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 |
@ -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)
|
|
||||||
},
|
|
||||||
})
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
@ -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;
|
|
||||||
}
|
}
|
||||||
|
@ -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
|
12
data/authors/sparrowhawk.md
Normal file
12
data/authors/sparrowhawk.md
Normal 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.
|
@ -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 结构的机箱。
|
|
||||||
|
|
||||||
### 直插式
|
|
||||||
|
|
||||||
我看了好多款,小体积下只能放 240mm 的显卡,我的显卡刚好是 240mm,所以非常的极限。有些机箱前置的 IO 可能会和显卡冲突,也有的机箱设计上就很难放入 240mm 的显卡,所以作罢。
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
### A4 结构
|
|
||||||
|
|
||||||
A4 结构的机箱,虽说成本会高出一条 PCIE 延长线,但是能放得下 300mm 的显卡。追求小,也是要成本的。不过体积上也会比直插的再小上一点点。大概代表就是蜂鸟 i100 了,同类产品还有闪鳞 S300。。
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
两天前,我选择了最便宜也是比较轻的蜂鸟 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主板大横比](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 + 铭瑄 B670I,2327 元。比收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 70(70mΩ)、电压偏移 -150mV 来进行双烤 10 分钟稳定性测试。测试通过🥰
|
|
||||||
|
|
||||||
### GPU
|
|
||||||
|
|
||||||
目前我使用从之前主机上拆下来的 6600XT,能稳定发挥,散热表现也正常。准备过两天去某宝买 6800XT 默认矿卡,祝我好运。希望散热表现也能稳定,毕竟 A4 结构,显卡散热应该挺好的。
|
|
||||||
|
|
||||||
默认矿卡到手。盒盖跑分。
|
|
||||||
|
|
||||||
- Cinebench 2024:10531pts
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
- 3D Mark Time Spy: [18 091](https://www.3dmark.com/3dm/101278696)\n 
|
|
||||||
|
|
||||||
- 3D Mark Time Spy Extreme: [8727](https://www.3dmark.com/3dm/101277666)
|
|
||||||
- 
|
|
||||||
- 3D Mark Speed Way 压力测试:[99.3%](https://www.3dmark.com/3dm/101279293)
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
看起来显卡还成,分数比平均值差一点,不过还好,作为二手默认矿卡表现应该能说得过去。
|
|
||||||
|
|
||||||
### 调教小结
|
|
||||||
|
|
||||||
我在机箱主板底部的风扇位安装了 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 的无线网卡,所以不支持。
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
双系统 Blender Benchmark 得分
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
前两组是 Windows 的成绩,后两组是 Mac OS 的成绩。似乎都比 Blender 上记录的成绩好一些。至少这次测试能看出来,Mac OS 上显卡性能是有损失,但是 CPU 居然能跑得分更高。
|
|
||||||
|
|
||||||
## 使用体验
|
|
||||||
|
|
||||||
日常使用,没发现 CPU 因为散热问题降频。显卡一直稳定发挥,这二手显卡不知道矿没矿过,但是还算稳定。散热方面表现不错,基本上是因为我在机箱上面放了两把 12025 反向风扇,热量都能很好地被抽出来,小机箱现在不再是小闷罐。机箱底部的风扇,感觉有点聊胜于无。烤机的情况下,底部还是有一点积热的。或许和我只装了主板下的出风扇、没装电源下的出风扇有点关系,但是下部出风确实表现不理想,即使我把机箱放在通透的架子上。
|
|
||||||
|
|
||||||
现在我搞了两块 0.8mm 孔径的防尘网贴在机箱左右两侧,散热表现依然还可以,感觉我放上面的两个风扇真棒。
|
|
||||||
|
|
||||||
最后,放一张主机工作照吧。
|
|
||||||
|
|
||||||

|
|
@ -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
|
|
||||||
```
|
|
@ -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"
|
|
||||||
```
|
|
||||||
|
|
||||||

|
|
||||||
如果在中国大陆,那么执行以下命令:
|
|
||||||
|
|
||||||
```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
|
|
||||||
```
|
|
@ -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"
|
|
||||||
```
|
|
||||||
|
|
||||||
签发成功后你将会在输出末尾看到如下内容:
|
|
||||||

|
|
||||||
|
|
||||||
注意,签发通配符证书时,需要一次性将所有通配的子域都写在同一条命令上,使用 `-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 自动重启
|
|
@ -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” 发布版本:
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
所以这里使用了 [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 的设置页面更改下权限:
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
选择 “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
38
data/blog/code-sample.md
Normal 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))
|
||||||
|
```
|
@ -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 并添加输入法:
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
现在,新打开的软件应该能使用输入法了。像 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
|
|
||||||
```
|
|
141
data/blog/deriving-ols-estimator.mdx
Normal file
141
data/blog/deriving-ols-estimator.mdx
Normal 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 (`$`) [^2]
|
||||||
|
|
||||||
|
Inline or manually enumerated footnotes are also supported. Click on the links above to see them in action.
|
||||||
|
|
||||||
|
[^2]: \$10 and $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}
|
||||||
|
$$
|
@ -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 做笔记,写文章。不过我发现这家伙确实不方便完整地将内容无损地转换为静态站点……这就是现在这版本博客上线的原因了。这么想想我的主域名吃灰了一年有余了……
|
|
||||||
|
|
||||||
不知道会不会坚持经常更新博客,因为我还是比较喜欢记录一些碎片内容,以备将来回顾。而博文主要还是为了做一些分享。做分享,动力还是有些不足,而且学海无涯,要学的东西还是真的多。精力有限,估计短期是无法预见能有经常分享的机会了。
|
|
||||||
|
|
||||||
总之,先上线再说。后面会陆续添加评论、评分和通知功能。会尽量使用开源、自部署的程序。自从看上了容器化和容器编排技术,我已经爱上了用开源项目自部署一堆服务,配合使用。
|
|
||||||
|
|
||||||
_日积月累,厚积薄发。_
|
|
198
data/blog/github-markdown-guide.mdx
Normal file
198
data/blog/github-markdown-guide.mdx
Normal 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
|
||||||
|
|
||||||
|
Here’s 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
|
||||||
|
|
||||||
|
```
|
||||||
|

|
||||||
|
Format: 
|
||||||
|
```
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
Here’s 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~~.
|
76
data/blog/guide-to-using-images-in-nextjs.mdx
Normal file
76
data/blog/guide-to-using-images-in-nextjs.mdx
Normal 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.
|
||||||
|
|
||||||
|
```
|
||||||
|

|
||||||
|
```
|
||||||
|
|
||||||
|
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!
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
<p>
|
||||||
|
Photo by [YUCAR
|
||||||
|
FotoGrafik](https://unsplash.com/@yucar?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText)
|
||||||
|
on
|
||||||
|
[Unsplash](https://unsplash.com/s/photos/sea?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText)
|
||||||
|
</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`
|
@ -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
Loading…
x
Reference in New Issue
Block a user