Compare commits
90 Commits
696fe48d53
...
develop
Author | SHA1 | Date | |
---|---|---|---|
a8e6ee073f | |||
14719936fc | |||
26350e033b | |||
91d3acc358 | |||
e41238fb60 | |||
404c7cba87 | |||
930953fc1a | |||
7ad3729ae0 | |||
9a3297e1c7 | |||
abb37dbcac | |||
0549b3c385 | |||
c2dca0e57b | |||
1b05eae89b | |||
c85737fa3f | |||
364b85cdc6 | |||
6e0b88bd1e | |||
d137dfac70 | |||
864085ea4a | |||
16d5d1f32e | |||
2f526f713c | |||
0b3bdfc36a | |||
b2c2b0eb98 | |||
97904d66b4 | |||
61efce68b5 | |||
de5081da69 | |||
8e0f1e4ff1 | |||
a8a1fe1e0d | |||
8d9020da3a | |||
4cc2f920a1 | |||
7a2d689a4f | |||
7550421a74 | |||
6557412554 | |||
49ad571864 | |||
1bde01a6a9 | |||
5bee02b567 | |||
35b92490d8 | |||
fd187a1370 | |||
c081d55a32 | |||
10f64a9ba4 | |||
6f94a476c5 | |||
f9b127f2bc | |||
4d5fc7ef60 | |||
7f3474f8b5 | |||
7591d486f5 | |||
bcdc4ce087 | |||
0e44afcbea | |||
ea037d04e2 | |||
92590e849d | |||
0340f28993 | |||
05faf000cb | |||
c897b46f5c | |||
9cef9fe8d8 | |||
adb7b2cf3f | |||
8d2406e3d5 | |||
66ea9d7df7 | |||
b87470d051 | |||
83440b09da | |||
541c9e6e8f | |||
727046805b | |||
2ed23c4327 | |||
85fa2ae57f | |||
4738a03fb9 | |||
e64ece00ab | |||
8f70c41086 | |||
2ee5810930 | |||
f1bbe539a7 | |||
079ae47a30 | |||
ca5eb7cd5e | |||
eee38148ee | |||
31a4cb3bd1 | |||
ae952694d7 | |||
2bd6937564 | |||
d9fbbc19e6 | |||
1dfd5e5271 | |||
11b9017a07 | |||
d7c65cf444 | |||
45af8732c0 | |||
8c277bfd4d | |||
0f86455590 | |||
dae62eb4db | |||
c9b6220699 | |||
9427837151 | |||
168af0e9bc | |||
97c637f050 | |||
0c74f2f7a6 | |||
7cc4c54ef6 | |||
e8ea16d917 | |||
26e61bfd9b | |||
42fc2d9fac | |||
d5aac3e833 |
14
.devcontainer/Dockerfile
Normal file
14
.devcontainer/Dockerfile
Normal file
@ -0,0 +1,14 @@
|
||||
# [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"
|
17
.devcontainer/base.Dockerfile
Normal file
17
.devcontainer/base.Dockerfile
Normal file
@ -0,0 +1,17 @@
|
||||
# [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}"
|
55
.devcontainer/devcontainer.json
Normal file
55
.devcontainer/devcontainer.json
Normal file
@ -0,0 +1,55 @@
|
||||
// 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": "16-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"
|
||||
]
|
||||
}
|
||||
},
|
||||
// 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": [],
|
||||
"postStartCommand": "pnpm install && npm run dev"
|
||||
}
|
@ -35,4 +35,6 @@ yarn-error.log*
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
secrets.txt
|
||||
secrets.txt
|
||||
|
||||
.pnpm-store
|
124
.drone.yml
124
.drone.yml
@ -1,6 +1,6 @@
|
||||
---
|
||||
kind: pipeline
|
||||
name: base
|
||||
name: deps
|
||||
type: docker
|
||||
|
||||
steps:
|
||||
@ -12,23 +12,52 @@ steps:
|
||||
from_secret: ivan-docker-username
|
||||
password:
|
||||
from_secret: ivan-docker-password
|
||||
repo: docker-registry.ivanli.cc/ivan/gatsby-blog
|
||||
repo: docker-registry.ivanli.cc/ivan/tailwind-nextjs-blog-deps
|
||||
cache_from:
|
||||
- docker-registry.ivanli.cc/ivan/gatsby-blog:${DRONE_BRANCH}${DRONE_TAG}-amd64
|
||||
- docker-registry.ivanli.cc/ivan/tailwind-nextjs-blog-deps:${DRONE_BRANCH}${DRONE_TAG}-amd64
|
||||
dockerfile: Dockerfile
|
||||
build_args:
|
||||
- BUILDKIT_INLINE_CACHE=1
|
||||
target: base
|
||||
target: deps
|
||||
tags:
|
||||
- '${DRONE_COMMIT_SHA:0:8}-amd64'
|
||||
- '${DRONE_BRANCH}${DRONE_TAG}-amd64'
|
||||
|
||||
- name: notify
|
||||
image: appleboy/drone-telegram
|
||||
when:
|
||||
status:
|
||||
- failure
|
||||
failure: ignore
|
||||
detach: true
|
||||
environment:
|
||||
PLUGIN_TOKEN:
|
||||
from_secret: drone-telegram-bot-token
|
||||
PLUGIN_TO:
|
||||
from_secret: telegram-notify-to
|
||||
settings:
|
||||
format: markdown
|
||||
message: >
|
||||
{{#success build.status}}
|
||||
✅ Install Deps #{{build.number}} of `{{repo.name}}` succeeded.
|
||||
📝 Commit by {{commit.author}} on `{{commit.branch}}`:
|
||||
```
|
||||
{{commit.message}}
|
||||
```
|
||||
🌐 {{ build.link }}
|
||||
{{else}}
|
||||
❌ Install Deps #{{build.number}} of `{{repo.name}}` failed.
|
||||
📝 Commit by {{commit.author}} on `{{commit.branch}}`:
|
||||
```
|
||||
{{commit.message}}
|
||||
```
|
||||
🌐 {{ build.link }}
|
||||
{{/success}}
|
||||
|
||||
---
|
||||
kind: pipeline
|
||||
name: linux-amd64
|
||||
type: docker
|
||||
depends_on:
|
||||
- base
|
||||
- deps
|
||||
|
||||
steps:
|
||||
- name: build&publish
|
||||
@ -39,17 +68,18 @@ steps:
|
||||
from_secret: ivan-docker-username
|
||||
password:
|
||||
from_secret: ivan-docker-password
|
||||
repo: docker-registry.ivanli.cc/ivan/gatsby-blog
|
||||
repo: docker-registry.ivanli.cc/ivan/tailwind-nextjs-blog
|
||||
dockerfile: Dockerfile
|
||||
target: release
|
||||
cache_from:
|
||||
- docker-registry.ivanli.cc/ivan/gatsby-blog:${DRONE_COMMIT_SHA:0:8}-amd64
|
||||
- docker-registry.ivanli.cc/ivan/tailwind-nextjs-blog-deps:${DRONE_BRANCH}${DRONE_TAG}-amd64
|
||||
tags:
|
||||
- '${DRONE_COMMIT_SHA:0:8}'
|
||||
- '${DRONE_BRANCH}${DRONE_TAG}'
|
||||
- name: notify
|
||||
image: appleboy/drone-telegram
|
||||
failure: ignore
|
||||
detach: true
|
||||
when:
|
||||
status:
|
||||
- success
|
||||
@ -89,12 +119,12 @@ kind: pipeline
|
||||
type: docker
|
||||
name: deploy
|
||||
clone:
|
||||
disable: true
|
||||
disable: false
|
||||
depends_on:
|
||||
- linux-amd64
|
||||
|
||||
steps:
|
||||
- name: deploy
|
||||
- name: watchtower-online
|
||||
image: plugins/webhook
|
||||
settings:
|
||||
token_value:
|
||||
@ -116,6 +146,7 @@ steps:
|
||||
- success
|
||||
- failure
|
||||
failure: ignore
|
||||
detach: true
|
||||
environment:
|
||||
PLUGIN_TOKEN:
|
||||
from_secret: drone-telegram-bot-token
|
||||
@ -139,3 +170,74 @@ steps:
|
||||
```
|
||||
🌐 {{ build.link }}
|
||||
{{/success}}
|
||||
|
||||
|
||||
---
|
||||
kind: pipeline
|
||||
type: docker
|
||||
name: deploy-to-zzidc
|
||||
clone:
|
||||
disable: false
|
||||
depends_on:
|
||||
- linux-amd64
|
||||
|
||||
trigger:
|
||||
branch:
|
||||
- master
|
||||
- develop
|
||||
|
||||
steps:
|
||||
- name: upload
|
||||
image: docker:dind
|
||||
volumes:
|
||||
- name: dockersock
|
||||
path: /var/run/docker.sock
|
||||
commands:
|
||||
- docker pull docker-registry.ivanli.cc/ivan/tailwind-nextjs-blog-deps:${DRONE_BRANCH}${DRONE_TAG}-amd64
|
||||
- docker build --pull=true --target upload -t docker-registry.ivanli.cc/ivan/tailwind-nextjs-blog-upload:${DRONE_COMMIT_SHA:0:8} --cache-from docker-registry.ivanli.cc/ivan/tailwind-nextjs-blog-deps:${DRONE_BRANCH}${DRONE_TAG}-amd64 .
|
||||
- docker run --rm -t -e FTP_ACCOUNT=$${FTP_ACCOUNT} -e FTP_PASSWORD=$${FTP_PASSWORD} -e FTP_HOST=$${FTP_HOST} docker-registry.ivanli.cc/ivan/tailwind-nextjs-blog-upload:${DRONE_COMMIT_SHA:0:8}
|
||||
environment:
|
||||
DOCKER_BUILDKIT: "1"
|
||||
FTP_ACCOUNT:
|
||||
from_secret: zzidc_ftp_account
|
||||
FTP_PASSWORD:
|
||||
from_secret: zzidc_ftp_password
|
||||
FTP_HOST:
|
||||
from_secret: zzidc_ftp_host
|
||||
|
||||
- name: notify
|
||||
image: appleboy/drone-telegram
|
||||
when:
|
||||
status:
|
||||
- success
|
||||
- failure
|
||||
failure: ignore
|
||||
detach: true
|
||||
environment:
|
||||
PLUGIN_TOKEN:
|
||||
from_secret: drone-telegram-bot-token
|
||||
PLUGIN_TO:
|
||||
from_secret: telegram-notify-to
|
||||
settings:
|
||||
format: markdown
|
||||
message: >
|
||||
{{#success build.status}}
|
||||
✅ Deploy #{{build.number}} of `{{repo.name}}` to ZZIDC succeeded.
|
||||
📝 Commit by {{commit.author}} on `{{commit.branch}}`:
|
||||
```
|
||||
{{commit.message}}
|
||||
```
|
||||
🌐 {{ build.link }}
|
||||
{{else}}
|
||||
❌ Deploy #{{build.number}} of `{{repo.name}}` to ZZIDC failed.
|
||||
📝 Commit by {{commit.author}} on `{{commit.branch}}`:
|
||||
```
|
||||
{{commit.message}}
|
||||
```
|
||||
🌐 {{ build.link }}
|
||||
{{/success}}
|
||||
|
||||
volumes:
|
||||
- name: dockersock
|
||||
host:
|
||||
path: /var/run/docker.sock
|
12
.editorconfig
Normal file
12
.editorconfig
Normal file
@ -0,0 +1,12 @@
|
||||
# EditorConfig is awesome: https://EditorConfig.org
|
||||
|
||||
# top-most EditorConfig file
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = false
|
||||
insert_final_newline = false
|
1
.env
Normal file
1
.env
Normal file
@ -0,0 +1 @@
|
||||
NEXT_PUBLIC_COMMENTO_URL=https://comment.ivanli.cc/js/commento.js
|
@ -4,6 +4,9 @@ NEXT_PUBLIC_GISCUS_CATEGORY=
|
||||
NEXT_PUBLIC_GISCUS_CATEGORY_ID=
|
||||
NEXT_PUBLIC_UTTERANCES_REPO=
|
||||
NEXT_PUBLIC_DISQUS_SHORTNAME=
|
||||
NEXT_PUBLIC_CUSDIS_APPID=
|
||||
NEXT_PUBLIC_CUSDIS_HOST=
|
||||
NEXT_PUBLIC_COMMENTO_URL=
|
||||
|
||||
|
||||
MAILCHIMP_API_KEY=
|
||||
@ -15,8 +18,8 @@ BUTTONDOWN_API_KEY=
|
||||
|
||||
CONVERTKIT_API_URL=https://api.convertkit.com/v3/
|
||||
CONVERTKIT_API_KEY=
|
||||
// curl https://api.convertkit.com/v3/forms?api_key=<your_public_api_key> to get your form ID
|
||||
CONVERTKIT_FORM_ID=
|
||||
// curl https://api.convertkit.com/v3/forms?api_key=<your_public_api_key> to get your form ID
|
||||
CONVERTKIT_FORM_ID=
|
||||
|
||||
KLAVIYO_API_KEY=
|
||||
KLAVIYO_LIST_ID=
|
||||
|
4
.gitignore
vendored
4
.gitignore
vendored
@ -35,4 +35,6 @@ yarn-error.log*
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
secrets.txt
|
||||
secrets.txt
|
||||
|
||||
.pnpm-store
|
6
.prettierrc.js
Normal file
6
.prettierrc.js
Normal file
@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
singleQuote: true,
|
||||
trailingCommas: 'all',
|
||||
bracketSpacing: true,
|
||||
bracketSameLine: true,
|
||||
};
|
32
.vscode/settings.json
vendored
Normal file
32
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,32 @@
|
||||
{
|
||||
"cSpell.words": [
|
||||
"alpn",
|
||||
"appleboy",
|
||||
"blackhole",
|
||||
"BUTTONDOWN",
|
||||
"Commento",
|
||||
"CONVERTKIT",
|
||||
"Cusdis",
|
||||
"Discuz",
|
||||
"Disqus",
|
||||
"dokodemo",
|
||||
"EMAILOCTOPUS",
|
||||
"fullchain",
|
||||
"Giscus",
|
||||
"KLAVIYO",
|
||||
"Kutt",
|
||||
"lastmod",
|
||||
"Logseq",
|
||||
"MAILCHIMP",
|
||||
"Miniflux",
|
||||
"nextjs",
|
||||
"Nuxt",
|
||||
"outbounds",
|
||||
"rprx",
|
||||
"unist",
|
||||
"vfile",
|
||||
"VLESS",
|
||||
"vmess",
|
||||
"xtls"
|
||||
]
|
||||
}
|
34
Dockerfile
34
Dockerfile
@ -1,13 +1,35 @@
|
||||
|
||||
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
|
||||
COPY package-lock.json package.json ./
|
||||
RUN npm ci --no-audit
|
||||
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
|
||||
WORKDIR /app
|
||||
COPY --from=base /app ./
|
||||
RUN npm run build &&\
|
||||
npm prune --omit dev
|
||||
COPY --from=pre-release /app ./
|
||||
EXPOSE 80
|
||||
CMD npm run serve -- --port 80
|
||||
CMD npm run serve -- -p 80
|
||||
|
||||
FROM build as export
|
||||
WORKDIR /app
|
||||
RUN npm run export
|
||||
|
||||
FROM alpine:latest as upload
|
||||
RUN apk add lftp
|
||||
WORKDIR /app
|
||||
COPY --from=export /app/out ./
|
||||
CMD lftp -u "${FTP_ACCOUNT},${FTP_PASSWORD}" "${FTP_HOST}" -e 'set ftp:ssl-allow off && set use-feat no && mirror -c -R --use-pget-n=10 . ./WEB && exit'
|
156
README.md
156
README.md
@ -1,142 +1,16 @@
|
||||

|
||||
|
||||
# Tailwind Nextjs Starter Blog
|
||||
# Ivan Li's Blog
|
||||
|
||||
[](https://GitHub.com/timlrx/tailwind-nextjs-starter-blog/stargazers/)
|
||||
[](https://GitHub.com/timlrx/tailwind-nextjs-starter-blog/network/)
|
||||
[](https://twitter.com/timlrxx)
|
||||
[](https://github.com/sponsors/timlrx)
|
||||
[](https://ci.ivanli.cc/Ivan/tailwind-nextjs-blog)
|
||||
[](https://ivanli.cc)
|
||||
|
||||
[](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. 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.
|
||||
|
||||
Check out the documentation below to get started.
|
||||
|
||||
Facing issues? Check the [FAQ page](https://github.com/timlrx/tailwind-nextjs-starter-blog/wiki) and do a search on past issues. Feel free to open a new issue if none has been posted previously.
|
||||
|
||||
Feature request? Check the past discussions to see if it has been brought up previously. Otherwise, feel free to start a new discussion thread. All ideas are welcomed!
|
||||
|
||||
## Examples
|
||||
|
||||
- [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
|
||||
- [Aloisdg's cookbook](https://tambouille.vercel.app/) - with pictures and recipes!
|
||||
- [GautierArcin's demo with next translate](https://tailwind-nextjs-starter-blog-seven.vercel.app/) - includes translation of mdx posts, [source code](https://github.com/GautierArcin/tailwind-nextjs-starter-blog/tree/demo/next-translate)
|
||||
- [David Levai's digital garden](https://davidlevai.com/) - customized design and added email subscriptions
|
||||
- [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, ...
|
||||
- [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.
|
||||
- [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.
|
||||
- [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
|
||||
- [blog.b00st.com](https://blog.b00st.com) - [b00st.com's](https://b00st.com) main music promotion blog
|
||||
- [astrosaurus.me](https://astrosaurus.me/) - Ephraim Atta-Duncan's Personal Blog
|
||||
- [dhanrajsp.me](https://dhanrajsp.me/) - Dhanraj's personal site and blog.
|
||||
- [blog.r00ks.io](https://blog.r00ks.io/) - Austin Rooks's personal blog ([source code](https://github.com/Austionian/blog.r00ks)).
|
||||
- [honghong.me](https://honghong.me) - Tszhong's personal website ([source code](https://github.com/tszhong0411/home))
|
||||
- [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)).
|
||||
- [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
|
||||
- [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.
|
||||
- [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))
|
||||
- [devahoy.com](https://devahoy.com) - Chai's personal blog (Thai language)
|
||||
- [0xchai.io](https://0xchai.io) - Chai's personal blog
|
||||
- [techipedia](https://techipedia.vercel.app) - Simple blogging progressive web app with custom installation button and top progress bar
|
||||
- [reubence.com](https://reubence.com) - Reuben Rapose's Digital Garden
|
||||
- [axolo.co/blog](https://axolo.co/blog) - Engineering management news & axolo.co updates (with image preview for article in the home page)
|
||||
- [musing.vercel.app](https://musing.vercel.app/) - Parth Desai's personal blog ([source code](https://github.com/pycoder2000/blog))
|
||||
- [onyourmental.com](https://www.onyourmental.com/) - [Curtis Warcup's](https://github.com/Cwarcup) website for the On Your Mental Podcast ([source code](https://github.com/Cwarcup/on-your-mental))
|
||||
- [cwarcup.com](https://www.cwarcup.com/) - Curtis Warcup's personal website and blog ([source code](https://github.com/Cwarcup/personal-blog).
|
||||
|
||||
Using the template? Feel free to create a PR and add your blog to this list.
|
||||
|
||||
## Motivation
|
||||
|
||||
I wanted to port my existing blog to Nextjs and Tailwind CSS but there was no easy out of the box template to use so I decided to create one. Design is adapted from [Tailwindlabs blog](https://github.com/tailwindlabs/blog.tailwindcss.com).
|
||||
|
||||
I wanted it to be nearly as feature-rich as popular blogging templates like [beautiful-jekyll](https://github.com/daattali/beautiful-jekyll) and [Hugo Academic](https://github.com/wowchemy/wowchemy-hugo-modules) but with the best of React's ecosystem and current web development's best practices.
|
||||
|
||||
## Features
|
||||
|
||||
- Easy styling customization with [Tailwind 3.0](https://tailwindcss.com/blog/tailwindcss-v3) and primary color attribute
|
||||
- Near perfect lighthouse score - [Lighthouse report](https://www.webpagetest.org/result/210111_DiC1_08f3670c3430bf4a9b76fc3b927716c5/)
|
||||
- Lightweight, 45kB first load JS, uses Preact in production build
|
||||
- Mobile-friendly view
|
||||
- Light and dark theme
|
||||
- Self-hosted font with [Fontsource](https://fontsource.org/)
|
||||
- Supports [plausible](https://plausible.io/), [simple analytics](https://simpleanalytics.com/) and google analytics
|
||||
- [MDX - write JSX in markdown documents!](https://mdxjs.com/)
|
||||
- Server-side syntax highlighting with line numbers and line highlighting via [rehype-prism-plus](https://github.com/timlrx/rehype-prism-plus)
|
||||
- Math display supported via [KaTeX](https://katex.org/)
|
||||
- Citation and bibliography support via [rehype-citation](https://github.com/timlrx/rehype-citation)
|
||||
- Automatic image optimization via [next/image](https://nextjs.org/docs/basic-features/image-optimization)
|
||||
- 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 multiple authors
|
||||
- Blog templates
|
||||
- TOC component
|
||||
- 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
|
||||
- Preconfigured security headers
|
||||
- SEO friendly with RSS feed, sitemaps and more!
|
||||
|
||||
## Sample posts
|
||||
|
||||
- [A markdown guide](https://tailwind-nextjs-starter-blog.vercel.app/blog/github-markdown-guide)
|
||||
- [Learn more about images in Next.js](https://tailwind-nextjs-starter-blog.vercel.app/blog/guide-to-using-images-in-nextjs)
|
||||
- [A tour of math typesetting](https://tailwind-nextjs-starter-blog.vercel.app/blog/deriving-ols-estimator)
|
||||
- [Simple MDX image grid](https://tailwind-nextjs-starter-blog.vercel.app/blog/pictures-of-canada)
|
||||
- [Example of long prose](https://tailwind-nextjs-starter-blog.vercel.app/blog/the-time-machine)
|
||||
- [Example of Nested Route Post](https://tailwind-nextjs-starter-blog.vercel.app/blog/nested-route/introducing-multi-part-posts-with-nested-routing)
|
||||
|
||||
## Quick Start Guide
|
||||
|
||||
1. Try installing the starter using the new [Pliny project CLI](https://github.com/timlrx/pliny):
|
||||
|
||||
```bash
|
||||
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)
|
||||
3. Modify the content security policy in `next.config.js` if you want to use
|
||||
any analytics provider or a commenting solution other than giscus.
|
||||
4. Personalize `authors/default.md` (main author)
|
||||
5. Modify `projectsData.js`
|
||||
6. Modify `headerNavLinks.js` to customize navigation links
|
||||
7. Add blog posts
|
||||
8. Deploy on Vercel
|
||||
Ivan Li's Blog, base [timlrx/tailwind-nextjs-starter-blog](https://github.com/timlrx/tailwind-nextjs-starter-blog)
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install
|
||||
pnpm install
|
||||
```
|
||||
|
||||
## Development
|
||||
@ -144,13 +18,13 @@ npm install
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm start
|
||||
pnpm start
|
||||
```
|
||||
|
||||
or
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
pnpm run dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
@ -233,18 +107,4 @@ Follow the interactive prompt to generate a post with pre-filled front matter.
|
||||
|
||||
## Deploy
|
||||
|
||||
**Vercel**
|
||||
The easiest way to deploy the template is to use the [Vercel Platform](https://vercel.com) from the creators of Next.js. Check out the [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
|
||||
|
||||
**Netlify / GitHub Pages / Firebase 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.
|
||||
|
||||
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.
|
||||
|
||||
## Support
|
||||
|
||||
Using the template? Support this effort by giving a star on GitHub, sharing your own blog and giving a shoutout on Twitter or becoming a project [sponsor](https://github.com/sponsors/timlrx).
|
||||
|
||||
## Licence
|
||||
|
||||
[MIT](https://github.com/timlrx/tailwind-nextjs-starter-blog/blob/master/LICENSE) © [Timothy Lin](https://www.timlrx.com)
|
||||
Drone CI.
|
||||
|
@ -1,13 +1,12 @@
|
||||
import Image from './Image'
|
||||
import Link from './Link'
|
||||
import Image from './Image';
|
||||
import Link from './Link';
|
||||
|
||||
const Card = ({ title, description, imgSrc, href }) => (
|
||||
<div className="md p-4 md:w-1/2" style={{ maxWidth: '544px' }}>
|
||||
<div
|
||||
className={`${
|
||||
imgSrc && 'h-full'
|
||||
} overflow-hidden rounded-md border-2 border-gray-200 border-opacity-60 dark:border-gray-700`}
|
||||
>
|
||||
} overflow-hidden rounded-md border-2 border-gray-200 border-opacity-60 dark:border-gray-700`}>
|
||||
{imgSrc &&
|
||||
(href ? (
|
||||
<Link href={href} aria-label={`Link to ${title}`}>
|
||||
@ -38,19 +37,20 @@ const Card = ({ title, description, imgSrc, href }) => (
|
||||
title
|
||||
)}
|
||||
</h2>
|
||||
<p className="prose mb-3 max-w-none text-gray-500 dark:text-gray-400">{description}</p>
|
||||
<p className="prose mb-3 max-w-none text-gray-500 dark:text-gray-400">
|
||||
{description}
|
||||
</p>
|
||||
{href && (
|
||||
<Link
|
||||
href={href}
|
||||
className="text-base font-medium leading-6 text-primary-500 hover:text-primary-600 dark:hover:text-primary-400"
|
||||
aria-label={`Link to ${title}`}
|
||||
>
|
||||
aria-label={`Link to ${title}`}>
|
||||
Learn more →
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
|
||||
export default Card
|
||||
export default Card;
|
@ -1,5 +1,5 @@
|
||||
import { useEffect } from 'react'
|
||||
import Router from 'next/router'
|
||||
import { useEffect } from 'react';
|
||||
import Router from 'next/router';
|
||||
|
||||
/**
|
||||
* Client-side complement to next-remote-watch
|
||||
@ -10,14 +10,14 @@ export const ClientReload = () => {
|
||||
// Exclude socket.io from prod bundle
|
||||
useEffect(() => {
|
||||
import('socket.io-client').then((module) => {
|
||||
const socket = module.io()
|
||||
socket.on('reload', (data) => {
|
||||
const socket = module.io();
|
||||
socket.on('reload', () => {
|
||||
Router.replace(Router.asPath, undefined, {
|
||||
scroll: false,
|
||||
})
|
||||
})
|
||||
})
|
||||
}, [])
|
||||
});
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
return null
|
||||
}
|
||||
return null;
|
||||
};
|
@ -1,18 +1,22 @@
|
||||
import Link from './Link'
|
||||
import siteMetadata from '@/data/siteMetadata'
|
||||
import SocialIcon from '@/components/social-icons'
|
||||
import Link from './Link';
|
||||
import siteMetadata from '@/data/siteMetadata';
|
||||
import SocialIcon from '@/components/social-icons';
|
||||
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer>
|
||||
<div className="mt-16 flex flex-col items-center">
|
||||
<div className="mb-3 flex space-x-4">
|
||||
<SocialIcon kind="mail" href={`mailto:${siteMetadata.email}`} size="6" />
|
||||
<SocialIcon kind="github" href={siteMetadata.github} 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" />
|
||||
<SocialIcon
|
||||
kind="mail"
|
||||
href={`mailto:${siteMetadata.email}`}
|
||||
size={6}
|
||||
/>
|
||||
<SocialIcon kind="github" href={siteMetadata.github} size={6} />
|
||||
<SocialIcon kind="facebook" href={siteMetadata.facebook} size={6} />
|
||||
<SocialIcon kind="youtube" href={siteMetadata.youtube} size={6} />
|
||||
<SocialIcon kind="linkedin" href={siteMetadata.linkedin} size={6} />
|
||||
<SocialIcon kind="twitter" href={siteMetadata.twitter} size={6} />
|
||||
</div>
|
||||
<div className="mb-2 flex space-x-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
<div>{siteMetadata.author}</div>
|
||||
@ -21,12 +25,19 @@ export default function Footer() {
|
||||
<div>{` • `}</div>
|
||||
<Link href="/">{siteMetadata.title}</Link>
|
||||
</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">
|
||||
<Link href="https://github.com/timlrx/tailwind-nextjs-starter-blog">
|
||||
<Link
|
||||
href="https://github.com/timlrx/tailwind-nextjs-starter-blog"
|
||||
rel="nofollow">
|
||||
Tailwind Nextjs Theme
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
);
|
||||
}
|
@ -1,6 +0,0 @@
|
||||
import NextImage from 'next/image'
|
||||
|
||||
// eslint-disable-next-line jsx-a11y/alt-text
|
||||
const Image = ({ ...rest }) => <NextImage {...rest} />
|
||||
|
||||
export default Image
|
5
components/Image.tsx
Normal file
5
components/Image.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import NextImage, { ImageProps } from 'next/image';
|
||||
|
||||
const Image = ({ ...rest }: ImageProps) => <NextImage {...rest} />;
|
||||
|
||||
export default Image;
|
@ -1,13 +1,18 @@
|
||||
import siteMetadata from '@/data/siteMetadata'
|
||||
import headerNavLinks from '@/data/headerNavLinks'
|
||||
import Logo from '@/data/logo.svg'
|
||||
import Link from './Link'
|
||||
import SectionContainer from './SectionContainer'
|
||||
import Footer from './Footer'
|
||||
import MobileNav from './MobileNav'
|
||||
import ThemeSwitch from './ThemeSwitch'
|
||||
import siteMetadata from '@/data/siteMetadata';
|
||||
import headerNavLinks from '@/data/headerNavLinks';
|
||||
import Logo from '@/data/logo.svg';
|
||||
import Link from './Link';
|
||||
import SectionContainer from './SectionContainer';
|
||||
import Footer from './Footer';
|
||||
import MobileNav from './MobileNav';
|
||||
import ThemeSwitch from './ThemeSwitch';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
const LayoutWrapper = ({ children }) => {
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const LayoutWrapper = ({ children }: Props) => {
|
||||
return (
|
||||
<SectionContainer>
|
||||
<div className="flex h-screen flex-col justify-between">
|
||||
@ -34,8 +39,7 @@ const LayoutWrapper = ({ children }) => {
|
||||
<Link
|
||||
key={link.title}
|
||||
href={link.href}
|
||||
className="p-1 font-medium text-gray-900 dark:text-gray-100 sm:p-4"
|
||||
>
|
||||
className="p-1 font-medium text-gray-900 dark:text-gray-100 sm:p-4">
|
||||
{link.title}
|
||||
</Link>
|
||||
))}
|
||||
@ -48,7 +52,7 @@ const LayoutWrapper = ({ children }) => {
|
||||
<Footer />
|
||||
</div>
|
||||
</SectionContainer>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default LayoutWrapper
|
||||
export default LayoutWrapper;
|
@ -1,23 +0,0 @@
|
||||
/* 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
|
30
components/Link.tsx
Normal file
30
components/Link.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
/* eslint-disable jsx-a11y/anchor-has-content */
|
||||
import Link from 'next/link';
|
||||
import { AnchorHTMLAttributes, DetailedHTMLProps } from 'react';
|
||||
|
||||
const CustomLink = ({
|
||||
href,
|
||||
...rest
|
||||
}: DetailedHTMLProps<
|
||||
AnchorHTMLAttributes<HTMLAnchorElement>,
|
||||
HTMLAnchorElement
|
||||
>) => {
|
||||
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,26 +0,0 @@
|
||||
/* 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} />
|
||||
}
|
39
components/MDXComponents.tsx
Normal file
39
components/MDXComponents.tsx
Normal file
@ -0,0 +1,39 @@
|
||||
/* eslint-disable react/display-name */
|
||||
import React, { useMemo } from 'react';
|
||||
import { ComponentMap, getMDXComponent } from 'mdx-bundler/client';
|
||||
import Image from './Image';
|
||||
import CustomLink from './Link';
|
||||
import TOCInline from './TOCInline';
|
||||
import Pre from './Pre';
|
||||
import { BlogNewsletterForm } from './NewsletterForm';
|
||||
|
||||
const Wrapper: React.ComponentType<{ layout: string }> = ({
|
||||
layout,
|
||||
...rest
|
||||
}) => {
|
||||
const Layout = require(`../layouts/${layout}`).default;
|
||||
return <Layout {...rest} />;
|
||||
};
|
||||
|
||||
export const MDXComponents: ComponentMap = {
|
||||
Image,
|
||||
//@ts-ignore
|
||||
TOCInline,
|
||||
a: CustomLink,
|
||||
pre: Pre,
|
||||
wrapper: Wrapper,
|
||||
//@ts-ignore
|
||||
BlogNewsletterForm,
|
||||
};
|
||||
|
||||
interface Props {
|
||||
layout: string;
|
||||
mdxSource: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export const MDXLayoutRenderer = ({ layout, mdxSource, ...rest }: Props) => {
|
||||
const MDXLayout = useMemo(() => getMDXComponent(mdxSource), [mdxSource]);
|
||||
|
||||
return <MDXLayout layout={layout} components={MDXComponents} {...rest} />;
|
||||
};
|
@ -1,85 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import Link from './Link'
|
||||
import headerNavLinks from '@/data/headerNavLinks'
|
||||
|
||||
const MobileNav = () => {
|
||||
const [navShow, setNavShow] = useState(false)
|
||||
|
||||
const onToggleNav = () => {
|
||||
setNavShow((status) => {
|
||||
if (status) {
|
||||
document.body.style.overflow = 'auto'
|
||||
} else {
|
||||
// Prevent scrolling
|
||||
document.body.style.overflow = 'hidden'
|
||||
}
|
||||
return !status
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="sm:hidden">
|
||||
<button
|
||||
type="button"
|
||||
className="ml-1 mr-1 h-8 w-8 rounded py-1"
|
||||
aria-label="Toggle Menu"
|
||||
onClick={onToggleNav}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
className="text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M3 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 15a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<div
|
||||
className={`fixed top-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'
|
||||
}`}
|
||||
>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
className="mr-5 mt-11 h-8 w-8 rounded"
|
||||
aria-label="Toggle Menu"
|
||||
onClick={onToggleNav}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
className="text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<nav className="fixed mt-8 h-full">
|
||||
{headerNavLinks.map((link) => (
|
||||
<div key={link.title} className="px-12 py-4">
|
||||
<Link
|
||||
href={link.href}
|
||||
className="text-2xl font-bold tracking-widest text-gray-900 dark:text-gray-100"
|
||||
onClick={onToggleNav}
|
||||
>
|
||||
{link.title}
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default MobileNav
|
73
components/MobileNav.tsx
Normal file
73
components/MobileNav.tsx
Normal file
@ -0,0 +1,73 @@
|
||||
import { useState } from 'react';
|
||||
import Link from './Link';
|
||||
import headerNavLinks from '@/data/headerNavLinks';
|
||||
|
||||
const MobileNav = () => {
|
||||
const [navShow, setNavShow] = useState(false);
|
||||
|
||||
const onToggleNav = () => {
|
||||
setNavShow((status) => {
|
||||
if (status) {
|
||||
document.body.style.overflow = 'auto';
|
||||
} else {
|
||||
// Prevent scrolling
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
return !status;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="sm:hidden">
|
||||
<button
|
||||
type="button"
|
||||
className="ml-1 mr-1 h-8 w-8 rounded py-1"
|
||||
aria-label="Toggle Menu"
|
||||
onClick={onToggleNav}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
className="text-gray-900 dark:text-gray-100">
|
||||
{navShow ? (
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
) : (
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M3 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 15a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
</button>
|
||||
<div
|
||||
className={`fixed top-24 right-0 z-10 h-full w-full transform bg-gray-200 opacity-95 duration-300 ease-in-out dark:bg-gray-800 ${
|
||||
navShow ? 'translate-x-0' : 'translate-x-full'
|
||||
}`}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="toggle modal"
|
||||
className="fixed h-full w-full cursor-auto focus:outline-none"
|
||||
onClick={onToggleNav}></button>
|
||||
<nav className="fixed mt-8 h-full">
|
||||
{headerNavLinks.map((link) => (
|
||||
<div key={link.title} className="px-12 py-4">
|
||||
<Link
|
||||
href={link.href}
|
||||
className="text-2xl font-bold tracking-widest text-gray-900 dark:text-gray-100"
|
||||
onClick={onToggleNav}>
|
||||
{link.title}
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MobileNav;
|
@ -1,15 +1,15 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import React, { useRef, useState } from 'react';
|
||||
|
||||
import siteMetadata from '@/data/siteMetadata'
|
||||
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 inputEl = useRef<HTMLInputElement>(null);
|
||||
const [error, setError] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
const [subscribed, setSubscribed] = useState(false);
|
||||
|
||||
const subscribe = async (e) => {
|
||||
e.preventDefault()
|
||||
const subscribe = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
const res = await fetch(`/api/${siteMetadata.newsletter.provider}`, {
|
||||
body: JSON.stringify({
|
||||
@ -19,24 +19,28 @@ const NewsletterForm = ({ title = 'Subscribe to the newsletter' }) => {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
method: 'POST',
|
||||
})
|
||||
});
|
||||
|
||||
const { error } = await res.json()
|
||||
const { error } = await res.json();
|
||||
if (error) {
|
||||
setError(true)
|
||||
setMessage('Your e-mail address is invalid or you are already subscribed!')
|
||||
return
|
||||
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.')
|
||||
}
|
||||
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>
|
||||
<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">
|
||||
@ -47,7 +51,9 @@ const NewsletterForm = ({ title = 'Subscribe to the newsletter' }) => {
|
||||
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'}
|
||||
placeholder={
|
||||
subscribed ? "You're subscribed ! 🎉" : 'Enter your email'
|
||||
}
|
||||
ref={inputEl}
|
||||
required
|
||||
type="email"
|
||||
@ -57,23 +63,26 @@ const NewsletterForm = ({ title = 'Subscribe to the newsletter' }) => {
|
||||
<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'
|
||||
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}
|
||||
>
|
||||
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 className="w-72 pt-2 text-sm text-red-500 dark:text-red-400 sm:w-96">
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default NewsletterForm
|
||||
export default NewsletterForm;
|
||||
|
||||
export const BlogNewsletterForm = ({ title }) => (
|
||||
<div className="flex items-center justify-center">
|
||||
@ -81,4 +90,4 @@ export const BlogNewsletterForm = ({ title }) => (
|
||||
<NewsletterForm title={title} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
@ -1,7 +1,13 @@
|
||||
export default function PageTitle({ children }) {
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export default function PageTitle({ children }: Props) {
|
||||
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">
|
||||
{children}
|
||||
</h1>
|
||||
)
|
||||
);
|
||||
}
|
@ -1,36 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
48
components/Pagination.tsx
Normal file
48
components/Pagination.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
import Link from '@/components/Link';
|
||||
|
||||
interface Props {
|
||||
totalPages: number;
|
||||
currentPage: number;
|
||||
}
|
||||
|
||||
export default function Pagination({ totalPages, currentPage }: Props) {
|
||||
const prevPage = currentPage - 1 > 0;
|
||||
const nextPage = currentPage + 1 <= totalPages;
|
||||
|
||||
return (
|
||||
<div className="space-y-2 pt-6 pb-8 md:space-y-5">
|
||||
<nav className="flex justify-between">
|
||||
{!prevPage && (
|
||||
<button
|
||||
className="cursor-auto disabled:opacity-50"
|
||||
disabled={!prevPage}>
|
||||
Previous
|
||||
</button>
|
||||
)}
|
||||
{prevPage && (
|
||||
<Link
|
||||
href={
|
||||
currentPage - 1 === 1 ? `/blog/` : `/blog/page/${currentPage - 1}`
|
||||
}>
|
||||
<button>Previous</button>
|
||||
</Link>
|
||||
)}
|
||||
<span>
|
||||
{currentPage} of {totalPages}
|
||||
</span>
|
||||
{!nextPage && (
|
||||
<button
|
||||
className="cursor-auto disabled:opacity-50"
|
||||
disabled={!nextPage}>
|
||||
Next
|
||||
</button>
|
||||
)}
|
||||
{nextPage && (
|
||||
<Link href={`/blog/page/${currentPage + 1}`}>
|
||||
<button>Next</button>
|
||||
</Link>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,27 +1,35 @@
|
||||
import { useState, useRef } from 'react'
|
||||
import { useState, useRef, ReactNode } from 'react';
|
||||
|
||||
const Pre = (props) => {
|
||||
const textInput = useRef(null)
|
||||
const [hovered, setHovered] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const Pre = ({ children }: Props) => {
|
||||
const textInput = useRef(null);
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const onEnter = () => {
|
||||
setHovered(true)
|
||||
}
|
||||
setHovered(true);
|
||||
};
|
||||
const onExit = () => {
|
||||
setHovered(false)
|
||||
setCopied(false)
|
||||
}
|
||||
setHovered(false);
|
||||
setCopied(false);
|
||||
};
|
||||
const onCopy = () => {
|
||||
setCopied(true)
|
||||
navigator.clipboard.writeText(textInput.current.textContent)
|
||||
setCopied(true);
|
||||
navigator.clipboard.writeText(textInput.current.textContent);
|
||||
setTimeout(() => {
|
||||
setCopied(false)
|
||||
}, 2000)
|
||||
}
|
||||
setCopied(false);
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={textInput} onMouseEnter={onEnter} onMouseLeave={onExit} className="relative">
|
||||
<div
|
||||
ref={textInput}
|
||||
onMouseEnter={onEnter}
|
||||
onMouseLeave={onExit}
|
||||
className="relative">
|
||||
{hovered && (
|
||||
<button
|
||||
aria-label="Copy code"
|
||||
@ -31,15 +39,13 @@ const Pre = (props) => {
|
||||
? 'border-green-400 focus:border-green-400 focus:outline-none'
|
||||
: 'border-gray-300'
|
||||
}`}
|
||||
onClick={onCopy}
|
||||
>
|
||||
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'}
|
||||
>
|
||||
className={copied ? 'text-green-400' : 'text-gray-300'}>
|
||||
{copied ? (
|
||||
<>
|
||||
<path
|
||||
@ -63,9 +69,9 @@ const Pre = (props) => {
|
||||
</button>
|
||||
)}
|
||||
|
||||
<pre>{props.children}</pre>
|
||||
<pre>{children}</pre>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default Pre
|
||||
export default Pre;
|
@ -1,21 +1,49 @@
|
||||
import Head from 'next/head'
|
||||
import { useRouter } from 'next/router'
|
||||
import siteMetadata from '@/data/siteMetadata'
|
||||
import Head from 'next/head';
|
||||
import { useRouter } from 'next/router';
|
||||
import siteMetadata from '@/data/siteMetadata';
|
||||
import { AuthorFrontMatter } from 'types/AuthorFrontMatter';
|
||||
import { PostFrontMatter } from 'types/PostFrontMatter';
|
||||
|
||||
const CommonSEO = ({ title, description, ogType, ogImage, twImage, canonicalUrl }) => {
|
||||
const router = useRouter()
|
||||
interface CommonSEOProps {
|
||||
title: string;
|
||||
description: string;
|
||||
ogType: string;
|
||||
ogImage:
|
||||
| string
|
||||
| {
|
||||
'@type': string;
|
||||
url: string;
|
||||
}[];
|
||||
twImage: string;
|
||||
canonicalUrl?: string;
|
||||
}
|
||||
|
||||
const CommonSEO = ({
|
||||
title,
|
||||
description,
|
||||
ogType,
|
||||
ogImage,
|
||||
twImage,
|
||||
canonicalUrl,
|
||||
}: CommonSEOProps) => {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<Head>
|
||||
<title>{title}</title>
|
||||
<meta name="robots" content="follow, index" />
|
||||
<meta name="description" content={description} />
|
||||
<meta property="og:url" content={`${siteMetadata.siteUrl}${router.asPath}`} />
|
||||
<meta
|
||||
property="og: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} />)
|
||||
{Array.isArray(ogImage) ? (
|
||||
ogImage.map(({ url }) => (
|
||||
<meta property="og:image" content={url} key={url} />
|
||||
))
|
||||
) : (
|
||||
<meta property="og:image" content={ogImage} key={ogImage} />
|
||||
)}
|
||||
@ -26,15 +54,24 @@ const CommonSEO = ({ title, description, ogType, ogImage, twImage, canonicalUrl
|
||||
<meta name="twitter:image" content={twImage} />
|
||||
<link
|
||||
rel="canonical"
|
||||
href={canonicalUrl ? canonicalUrl : `${siteMetadata.siteUrl}${router.asPath}`}
|
||||
href={
|
||||
canonicalUrl
|
||||
? canonicalUrl
|
||||
: `${siteMetadata.siteUrl}${router.asPath}`
|
||||
}
|
||||
/>
|
||||
</Head>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
interface PageSEOProps {
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export const PageSEO = ({ title, description }) => {
|
||||
const ogImageUrl = siteMetadata.siteUrl + siteMetadata.socialBanner
|
||||
const twImageUrl = siteMetadata.siteUrl + siteMetadata.socialBanner
|
||||
export const PageSEO = ({ title, description }: PageSEOProps) => {
|
||||
const ogImageUrl = siteMetadata.siteUrl + siteMetadata.socialBanner;
|
||||
const twImageUrl = siteMetadata.siteUrl + siteMetadata.socialBanner;
|
||||
return (
|
||||
<CommonSEO
|
||||
title={title}
|
||||
@ -43,13 +80,13 @@ export const PageSEO = ({ title, description }) => {
|
||||
ogImage={ogImageUrl}
|
||||
twImage={twImageUrl}
|
||||
/>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export const TagSEO = ({ title, description }) => {
|
||||
const ogImageUrl = siteMetadata.siteUrl + siteMetadata.socialBanner
|
||||
const twImageUrl = siteMetadata.siteUrl + siteMetadata.socialBanner
|
||||
const router = useRouter()
|
||||
export const TagSEO = ({ title, description }: PageSEOProps) => {
|
||||
const ogImageUrl = siteMetadata.siteUrl + siteMetadata.socialBanner;
|
||||
const twImageUrl = siteMetadata.siteUrl + siteMetadata.socialBanner;
|
||||
const router = useRouter();
|
||||
return (
|
||||
<>
|
||||
<CommonSEO
|
||||
@ -68,7 +105,12 @@ export const TagSEO = ({ title, description }) => {
|
||||
/>
|
||||
</Head>
|
||||
</>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
interface BlogSeoProps extends PostFrontMatter {
|
||||
authorDetails?: AuthorFrontMatter[];
|
||||
url: string;
|
||||
}
|
||||
|
||||
export const BlogSEO = ({
|
||||
@ -80,37 +122,36 @@ export const BlogSEO = ({
|
||||
url,
|
||||
images = [],
|
||||
canonicalUrl,
|
||||
}) => {
|
||||
const router = useRouter()
|
||||
const publishedAt = new Date(date).toISOString()
|
||||
const modifiedAt = new Date(lastmod || date).toISOString()
|
||||
let imagesArr =
|
||||
}: BlogSeoProps) => {
|
||||
const publishedAt = new Date(date).toISOString();
|
||||
const modifiedAt = new Date(lastmod || date).toISOString();
|
||||
const imagesArr =
|
||||
images.length === 0
|
||||
? [siteMetadata.socialBanner]
|
||||
: typeof images === 'string'
|
||||
? [images]
|
||||
: images
|
||||
: images;
|
||||
|
||||
const featuredImages = imagesArr.map((img) => {
|
||||
return {
|
||||
'@type': 'ImageObject',
|
||||
url: img.includes('http') ? img : siteMetadata.siteUrl + img,
|
||||
}
|
||||
})
|
||||
url: `${siteMetadata.siteUrl}${img}`,
|
||||
};
|
||||
});
|
||||
|
||||
let authorList
|
||||
let authorList;
|
||||
if (authorDetails) {
|
||||
authorList = authorDetails.map((author) => {
|
||||
return {
|
||||
'@type': 'Person',
|
||||
name: author.name,
|
||||
}
|
||||
})
|
||||
};
|
||||
});
|
||||
} else {
|
||||
authorList = {
|
||||
'@type': 'Person',
|
||||
name: siteMetadata.author,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const structuredData = {
|
||||
@ -134,9 +175,9 @@ export const BlogSEO = ({
|
||||
},
|
||||
},
|
||||
description: summary,
|
||||
}
|
||||
};
|
||||
|
||||
const twImageUrl = featuredImages[0].url
|
||||
const twImageUrl = featuredImages[0].url;
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -149,8 +190,12 @@ export const BlogSEO = ({
|
||||
canonicalUrl={canonicalUrl}
|
||||
/>
|
||||
<Head>
|
||||
{date && <meta property="article:published_time" content={publishedAt} />}
|
||||
{lastmod && <meta property="article:modified_time" content={modifiedAt} />}
|
||||
{date && (
|
||||
<meta property="article:published_time" content={publishedAt} />
|
||||
)}
|
||||
{lastmod && (
|
||||
<meta property="article:modified_time" content={modifiedAt} />
|
||||
)}
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{
|
||||
@ -159,5 +204,5 @@ export const BlogSEO = ({
|
||||
/>
|
||||
</Head>
|
||||
</>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
@ -1,61 +0,0 @@
|
||||
import siteMetadata from '@/data/siteMetadata'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
const ScrollTopAndComment = () => {
|
||||
const [show, setShow] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const handleWindowScroll = () => {
|
||||
if (window.scrollY > 50) setShow(true)
|
||||
else setShow(false)
|
||||
}
|
||||
|
||||
window.addEventListener('scroll', handleWindowScroll)
|
||||
return () => window.removeEventListener('scroll', handleWindowScroll)
|
||||
}, [])
|
||||
|
||||
const handleScrollTop = () => {
|
||||
window.scrollTo({ top: 0 })
|
||||
}
|
||||
const handleScrollToComment = () => {
|
||||
document.getElementById('comment').scrollIntoView()
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={`fixed right-8 bottom-8 hidden flex-col gap-3 ${show ? 'md:flex' : 'md:hidden'}`}
|
||||
>
|
||||
{siteMetadata.comment.provider && (
|
||||
<button
|
||||
aria-label="Scroll To Comment"
|
||||
type="button"
|
||||
onClick={handleScrollToComment}
|
||||
className="rounded-full bg-gray-200 p-2 text-gray-500 transition-all hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-400 dark:hover:bg-gray-600"
|
||||
>
|
||||
<svg className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M18 10c0 3.866-3.582 7-8 7a8.841 8.841 0 01-4.083-.98L2 17l1.338-3.123C2.493 12.767 2 11.434 2 10c0-3.866 3.582-7 8-7s8 3.134 8 7zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
aria-label="Scroll To Top"
|
||||
type="button"
|
||||
onClick={handleScrollTop}
|
||||
className="rounded-full bg-gray-200 p-2 text-gray-500 transition-all hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-400 dark:hover:bg-gray-600"
|
||||
>
|
||||
<svg className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M3.293 9.707a1 1 0 010-1.414l6-6a1 1 0 011.414 0l6 6a1 1 0 01-1.414 1.414L11 5.414V17a1 1 0 11-2 0V5.414L4.707 9.707a1 1 0 01-1.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ScrollTopAndComment
|
57
components/ScrollTopAndComment.tsx
Normal file
57
components/ScrollTopAndComment.tsx
Normal file
@ -0,0 +1,57 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
const ScrollTopAndComment = () => {
|
||||
const [show, setShow] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleWindowScroll = () => {
|
||||
if (window.scrollY > 50) setShow(true);
|
||||
else setShow(false);
|
||||
};
|
||||
|
||||
window.addEventListener('scroll', handleWindowScroll);
|
||||
return () => window.removeEventListener('scroll', handleWindowScroll);
|
||||
}, []);
|
||||
|
||||
const handleScrollTop = () => {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
const handleScrollToComment = () => {
|
||||
document.getElementById('comment').scrollIntoView();
|
||||
};
|
||||
return (
|
||||
<div
|
||||
className={`fixed right-8 bottom-8 hidden flex-col gap-3 ${
|
||||
show ? 'md:flex' : 'md:hidden'
|
||||
}`}>
|
||||
<button
|
||||
aria-label="Scroll To Comment"
|
||||
type="button"
|
||||
onClick={handleScrollToComment}
|
||||
className="rounded-full bg-gray-200 p-2 text-gray-500 transition-all hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-400 dark:hover:bg-gray-600">
|
||||
<svg className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M18 10c0 3.866-3.582 7-8 7a8.841 8.841 0 01-4.083-.98L2 17l1.338-3.123C2.493 12.767 2 11.434 2 10c0-3.866 3.582-7 8-7s8 3.134 8 7zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
aria-label="Scroll To Top"
|
||||
type="button"
|
||||
onClick={handleScrollTop}
|
||||
className="rounded-full bg-gray-200 p-2 text-gray-500 transition-all hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-400 dark:hover:bg-gray-600">
|
||||
<svg className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M3.293 9.707a1 1 0 010-1.414l6-6a1 1 0 011.414 0l6 6a1 1 0 01-1.414 1.414L11 5.414V17a1 1 0 11-2 0V5.414L4.707 9.707a1 1 0 01-1.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ScrollTopAndComment;
|
@ -1,3 +0,0 @@
|
||||
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>
|
||||
}
|
13
components/SectionContainer.tsx
Normal file
13
components/SectionContainer.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export default function SectionContainer({ children }: Props) {
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-4 sm:px-6 xl:max-w-5xl xl:px-0">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,23 +1,27 @@
|
||||
/**
|
||||
* @typedef TocHeading
|
||||
* @prop {string} value
|
||||
* @prop {number} depth
|
||||
* @prop {string} url
|
||||
*/
|
||||
import { Toc } from 'types/Toc';
|
||||
|
||||
interface TOCInlineProps {
|
||||
toc: Toc;
|
||||
indentDepth?: number;
|
||||
fromHeading?: number;
|
||||
toHeading?: number;
|
||||
asDisclosure?: boolean;
|
||||
exclude?: string | string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates an inline table of contents
|
||||
* Exclude titles matching this string (new RegExp('^(' + string + ')$', 'i')).
|
||||
* If an array is passed the array gets joined with a pipe (new RegExp('^(' + array.join('|') + ')$', 'i')).
|
||||
*
|
||||
* @param {{
|
||||
* toc: TocHeading[],
|
||||
* indentDepth?: number,
|
||||
* fromHeading?: number,
|
||||
* toHeading?: number,
|
||||
* asDisclosure?: boolean,
|
||||
* exclude?: string|string[]
|
||||
* }} props
|
||||
* @param {TOCInlineProps} {
|
||||
* toc,
|
||||
* indentDepth = 3,
|
||||
* fromHeading = 1,
|
||||
* toHeading = 6,
|
||||
* asDisclosure = false,
|
||||
* exclude = '',
|
||||
* }
|
||||
*
|
||||
*/
|
||||
const TOCInline = ({
|
||||
@ -27,38 +31,44 @@ const TOCInline = ({
|
||||
toHeading = 6,
|
||||
asDisclosure = false,
|
||||
exclude = '',
|
||||
}) => {
|
||||
}: TOCInlineProps) => {
|
||||
const re = Array.isArray(exclude)
|
||||
? new RegExp('^(' + exclude.join('|') + ')$', 'i')
|
||||
: new RegExp('^(' + exclude + ')$', 'i')
|
||||
: new RegExp('^(' + exclude + ')$', 'i');
|
||||
|
||||
const filteredToc = toc.filter(
|
||||
(heading) =>
|
||||
heading.depth >= fromHeading && heading.depth <= toHeading && !re.test(heading.value)
|
||||
)
|
||||
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'}`}>
|
||||
<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>
|
||||
<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
|
||||
export default TOCInline;
|
@ -1,14 +1,18 @@
|
||||
import Link from 'next/link'
|
||||
import kebabCase from '@/lib/utils/kebabCase'
|
||||
import Link from 'next/link';
|
||||
import kebabCase from '@/lib/utils/kebabCase';
|
||||
|
||||
const Tag = ({ text }) => {
|
||||
interface Props {
|
||||
text: string;
|
||||
}
|
||||
|
||||
const Tag = ({ text }: Props) => {
|
||||
return (
|
||||
<Link href={`/tags/${kebabCase(text)}`}>
|
||||
<a className="mr-3 text-sm font-medium uppercase text-primary-500 hover:text-primary-600 dark:hover:text-primary-400">
|
||||
{text.split(' ').join('-')}
|
||||
</a>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default Tag
|
||||
export default Tag;
|
@ -1,26 +1,28 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTheme } from 'next-themes'
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTheme } from 'next-themes';
|
||||
|
||||
const ThemeSwitch = () => {
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const { theme, setTheme, resolvedTheme } = useTheme()
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const { theme, setTheme, resolvedTheme } = useTheme();
|
||||
|
||||
// When mounted on client, now we can show the UI
|
||||
useEffect(() => setMounted(true), [])
|
||||
useEffect(() => setMounted(true), []);
|
||||
|
||||
return (
|
||||
<button
|
||||
aria-label="Toggle Dark Mode"
|
||||
type="button"
|
||||
className="ml-1 mr-1 h-8 w-8 rounded p-1 sm:ml-4"
|
||||
onClick={() => setTheme(theme === 'dark' || resolvedTheme === 'dark' ? 'light' : 'dark')}
|
||||
>
|
||||
onClick={() =>
|
||||
setTheme(
|
||||
theme === 'dark' || resolvedTheme === 'dark' ? 'light' : 'dark'
|
||||
)
|
||||
}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
className="text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
className="text-gray-900 dark:text-gray-100">
|
||||
{mounted && (theme === 'dark' || resolvedTheme === 'dark') ? (
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
@ -32,7 +34,7 @@ const ThemeSwitch = () => {
|
||||
)}
|
||||
</svg>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default ThemeSwitch
|
||||
export default ThemeSwitch;
|
@ -1,6 +1,6 @@
|
||||
import Script from 'next/script'
|
||||
import Script from 'next/script';
|
||||
|
||||
import siteMetadata from '@/data/siteMetadata'
|
||||
import siteMetadata from '@/data/siteMetadata';
|
||||
|
||||
const GAScript = () => {
|
||||
return (
|
||||
@ -21,10 +21,10 @@ const GAScript = () => {
|
||||
`}
|
||||
</Script>
|
||||
</>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default GAScript
|
||||
export default GAScript;
|
||||
|
||||
// https://developers.google.com/analytics/devguides/collection/gtagjs/events
|
||||
export const logEvent = (action, category, label, value) => {
|
||||
@ -32,5 +32,5 @@ export const logEvent = (action, category, label, value) => {
|
||||
event_category: category,
|
||||
event_label: label,
|
||||
value: value,
|
||||
})
|
||||
}
|
||||
});
|
||||
};
|
@ -1,6 +1,6 @@
|
||||
import Script from 'next/script'
|
||||
import Script from 'next/script';
|
||||
|
||||
import siteMetadata from '@/data/siteMetadata'
|
||||
import siteMetadata from '@/data/siteMetadata';
|
||||
|
||||
const PlausibleScript = () => {
|
||||
return (
|
||||
@ -16,12 +16,12 @@ const PlausibleScript = () => {
|
||||
`}
|
||||
</Script>
|
||||
</>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default PlausibleScript
|
||||
export default PlausibleScript;
|
||||
|
||||
// https://plausible.io/docs/custom-event-goals
|
||||
export const logEvent = (eventName, ...rest) => {
|
||||
return window.plausible?.(eventName, ...rest)
|
||||
}
|
||||
return window.plausible?.(eventName, ...rest);
|
||||
};
|
@ -1,18 +0,0 @@
|
||||
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
|
@ -1,4 +1,4 @@
|
||||
import Script from 'next/script'
|
||||
import Script from 'next/script';
|
||||
|
||||
const SimpleAnalyticsScript = () => {
|
||||
return (
|
||||
@ -8,18 +8,21 @@ const SimpleAnalyticsScript = () => {
|
||||
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" />
|
||||
<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)
|
||||
return window.sa_event?.(eventName, callback);
|
||||
} else {
|
||||
return window.sa_event?.(eventName)
|
||||
return window.sa_event?.(eventName);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default SimpleAnalyticsScript
|
||||
export default SimpleAnalyticsScript;
|
@ -1,6 +1,6 @@
|
||||
import Script from 'next/script'
|
||||
import Script from 'next/script';
|
||||
|
||||
import siteMetadata from '@/data/siteMetadata'
|
||||
import siteMetadata from '@/data/siteMetadata';
|
||||
|
||||
const UmamiScript = () => {
|
||||
return (
|
||||
@ -12,7 +12,7 @@ const UmamiScript = () => {
|
||||
src="https://umami.example.com/umami.js" // Replace with your umami instance
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default UmamiScript
|
||||
export default UmamiScript;
|
@ -1,22 +0,0 @@
|
||||
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
|
32
components/analytics/index.tsx
Normal file
32
components/analytics/index.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
import GA from './GoogleAnalytics';
|
||||
import Plausible from './Plausible';
|
||||
import SimpleAnalytics from './SimpleAnalytics';
|
||||
import Umami from './Umami';
|
||||
import siteMetadata from '@/data/siteMetadata';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
gtag?: (...args: any[]) => void;
|
||||
plausible?: (...args: any[]) => void;
|
||||
sa_event?: (...args: any[]) => void;
|
||||
}
|
||||
}
|
||||
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
|
||||
const Analytics = () => {
|
||||
return (
|
||||
<>
|
||||
{isProduction && siteMetadata.analytics.plausibleDataDomain && (
|
||||
<Plausible />
|
||||
)}
|
||||
{isProduction && siteMetadata.analytics.simpleAnalytics && (
|
||||
<SimpleAnalytics />
|
||||
)}
|
||||
{isProduction && siteMetadata.analytics.umamiWebsiteId && <Umami />}
|
||||
{isProduction && siteMetadata.analytics.googleAnalyticsId && <GA />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Analytics;
|
33
components/comments/Commento.tsx
Normal file
33
components/comments/Commento.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
|
||||
import siteMetadata from '@/data/siteMetadata';
|
||||
import { PostFrontMatter } from 'types/PostFrontMatter';
|
||||
import { useTheme } from 'next-themes';
|
||||
import ReactCommento from './commento/ReactCommento';
|
||||
|
||||
interface Props {
|
||||
frontMatter: PostFrontMatter;
|
||||
}
|
||||
|
||||
const Commento = ({ frontMatter }: Props) => {
|
||||
const { resolvedTheme } = useTheme();
|
||||
const commentsTheme = useMemo(() => {
|
||||
switch (resolvedTheme) {
|
||||
case 'light':
|
||||
case 'dark':
|
||||
return resolvedTheme;
|
||||
default:
|
||||
return 'auto';
|
||||
}
|
||||
}, [resolvedTheme]);
|
||||
return (
|
||||
<div className="my-2">
|
||||
<ReactCommento
|
||||
url={siteMetadata.comment.commentoConfig.url}
|
||||
pageId={frontMatter.slug}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Commento;
|
41
components/comments/Cusdis.tsx
Normal file
41
components/comments/Cusdis.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
|
||||
import siteMetadata from '@/data/siteMetadata';
|
||||
import { PostFrontMatter } from 'types/PostFrontMatter';
|
||||
import { ReactCusdis } from 'react-cusdis';
|
||||
import { useTheme } from 'next-themes';
|
||||
|
||||
interface Props {
|
||||
frontMatter: PostFrontMatter;
|
||||
}
|
||||
|
||||
const Cusdis = ({ frontMatter }: Props) => {
|
||||
const { resolvedTheme } = useTheme();
|
||||
const commentsTheme = useMemo(() => {
|
||||
switch (resolvedTheme) {
|
||||
case 'light':
|
||||
case 'dark':
|
||||
return resolvedTheme;
|
||||
default:
|
||||
return 'auto';
|
||||
}
|
||||
}, [resolvedTheme]);
|
||||
return (
|
||||
<div className="my-2">
|
||||
<ReactCusdis
|
||||
key={commentsTheme}
|
||||
lang={siteMetadata.language?.toLocaleLowerCase()}
|
||||
attrs={{
|
||||
appId: siteMetadata.comment.cusdisConfig.appId,
|
||||
host: siteMetadata.comment.cusdisConfig.host,
|
||||
pageId: frontMatter.slug,
|
||||
pageUrl: window.location.href,
|
||||
pageTitle: frontMatter.title,
|
||||
theme: commentsTheme,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Cusdis;
|
@ -1,37 +0,0 @@
|
||||
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
|
51
components/comments/Disqus.tsx
Normal file
51
components/comments/Disqus.tsx
Normal file
@ -0,0 +1,51 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import siteMetadata from '@/data/siteMetadata';
|
||||
import { PostFrontMatter } from 'types/PostFrontMatter';
|
||||
|
||||
interface Props {
|
||||
frontMatter: PostFrontMatter;
|
||||
}
|
||||
|
||||
const Disqus = ({ frontMatter }: Props) => {
|
||||
const [enableLoadComments, setEnabledLoadComments] = useState(true);
|
||||
|
||||
const COMMENTS_ID = 'disqus_thread';
|
||||
|
||||
function LoadComments() {
|
||||
setEnabledLoadComments(false);
|
||||
|
||||
// @ts-ignore
|
||||
window.disqus_config = function () {
|
||||
this.page.url = window.location.href;
|
||||
this.page.identifier = frontMatter.slug;
|
||||
};
|
||||
// @ts-ignore
|
||||
if (window.DISQUS === undefined) {
|
||||
const script = document.createElement('script');
|
||||
script.src =
|
||||
'https://' +
|
||||
siteMetadata.comment.disqusConfig.shortname +
|
||||
'.disqus.com/embed.js';
|
||||
// @ts-ignore
|
||||
script.setAttribute('data-timestamp', +new Date());
|
||||
script.setAttribute('crossorigin', 'anonymous');
|
||||
script.async = true;
|
||||
document.body.appendChild(script);
|
||||
} else {
|
||||
// @ts-ignore
|
||||
window.DISQUS.reset({ reload: true });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pt-6 pb-6 text-center text-gray-700 dark:text-gray-300">
|
||||
{enableLoadComments && (
|
||||
<button onClick={LoadComments}>Load Comments</button>
|
||||
)}
|
||||
<div className="disqus-frame" id={COMMENTS_ID} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Disqus;
|
@ -1,72 +0,0 @@
|
||||
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
|
78
components/comments/Giscus.tsx
Normal file
78
components/comments/Giscus.tsx
Normal file
@ -0,0 +1,78 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useTheme } from 'next-themes';
|
||||
|
||||
import siteMetadata from '@/data/siteMetadata';
|
||||
|
||||
interface Props {
|
||||
mapping: string;
|
||||
}
|
||||
|
||||
const Giscus = ({ mapping }: Props) => {
|
||||
const [enableLoadComments, setEnabledLoadComments] = useState(true);
|
||||
const { theme, resolvedTheme } = useTheme();
|
||||
const commentsTheme =
|
||||
siteMetadata.comment.giscusConfig.themeURL === ''
|
||||
? theme === 'dark' || resolvedTheme === 'dark'
|
||||
? siteMetadata.comment.giscusConfig.darkTheme
|
||||
: siteMetadata.comment.giscusConfig.theme
|
||||
: siteMetadata.comment.giscusConfig.themeURL;
|
||||
|
||||
const COMMENTS_ID = 'comments-container';
|
||||
|
||||
const LoadComments = useCallback(() => {
|
||||
setEnabledLoadComments(false);
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://giscus.app/client.js';
|
||||
script.setAttribute('data-repo', siteMetadata.comment.giscusConfig.repo);
|
||||
script.setAttribute(
|
||||
'data-repo-id',
|
||||
siteMetadata.comment.giscusConfig.repositoryId
|
||||
);
|
||||
script.setAttribute(
|
||||
'data-category',
|
||||
siteMetadata.comment.giscusConfig.category
|
||||
);
|
||||
script.setAttribute(
|
||||
'data-category-id',
|
||||
siteMetadata.comment.giscusConfig.categoryId
|
||||
);
|
||||
script.setAttribute('data-mapping', mapping);
|
||||
script.setAttribute(
|
||||
'data-reactions-enabled',
|
||||
siteMetadata.comment.giscusConfig.reactions
|
||||
);
|
||||
script.setAttribute(
|
||||
'data-emit-metadata',
|
||||
siteMetadata.comment.giscusConfig.metadata
|
||||
);
|
||||
script.setAttribute('data-theme', commentsTheme);
|
||||
script.setAttribute('crossorigin', 'anonymous');
|
||||
script.async = true;
|
||||
|
||||
const comments = document.getElementById(COMMENTS_ID);
|
||||
if (comments) comments.appendChild(script);
|
||||
|
||||
return () => {
|
||||
const comments = document.getElementById(COMMENTS_ID);
|
||||
if (comments) comments.innerHTML = '';
|
||||
};
|
||||
}, [commentsTheme, mapping]);
|
||||
|
||||
// Reload on theme change
|
||||
useEffect(() => {
|
||||
const iframe = document.querySelector('iframe.giscus-frame');
|
||||
if (!iframe) return;
|
||||
LoadComments();
|
||||
}, [LoadComments]);
|
||||
|
||||
return (
|
||||
<div className="pt-6 pb-6 text-center text-gray-700 dark:text-gray-300">
|
||||
{enableLoadComments && (
|
||||
<button onClick={LoadComments}>Load Comments</button>
|
||||
)}
|
||||
<div className="giscus" id={COMMENTS_ID} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Giscus;
|
@ -1,52 +0,0 @@
|
||||
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
|
58
components/comments/Utterances.tsx
Normal file
58
components/comments/Utterances.tsx
Normal file
@ -0,0 +1,58 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useTheme } from 'next-themes';
|
||||
|
||||
import siteMetadata from '@/data/siteMetadata';
|
||||
|
||||
interface Props {
|
||||
issueTerm: string;
|
||||
}
|
||||
|
||||
const Utterances = ({ issueTerm }: Props) => {
|
||||
const [enableLoadComments, setEnabledLoadComments] = useState(true);
|
||||
const { theme, resolvedTheme } = useTheme();
|
||||
const commentsTheme =
|
||||
theme === 'dark' || resolvedTheme === 'dark'
|
||||
? siteMetadata.comment.utterancesConfig.darkTheme
|
||||
: siteMetadata.comment.utterancesConfig.theme;
|
||||
|
||||
const COMMENTS_ID = 'comments-container';
|
||||
|
||||
const LoadComments = useCallback(() => {
|
||||
setEnabledLoadComments(false);
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://utteranc.es/client.js';
|
||||
script.setAttribute('repo', siteMetadata.comment.utterancesConfig.repo);
|
||||
script.setAttribute('issue-term', issueTerm);
|
||||
script.setAttribute('label', siteMetadata.comment.utterancesConfig.label);
|
||||
script.setAttribute('theme', commentsTheme);
|
||||
script.setAttribute('crossorigin', 'anonymous');
|
||||
script.async = true;
|
||||
|
||||
const comments = document.getElementById(COMMENTS_ID);
|
||||
if (comments) comments.appendChild(script);
|
||||
|
||||
return () => {
|
||||
const comments = document.getElementById(COMMENTS_ID);
|
||||
if (comments) comments.innerHTML = '';
|
||||
};
|
||||
}, [commentsTheme, issueTerm]);
|
||||
|
||||
// Reload on theme change
|
||||
useEffect(() => {
|
||||
const iframe = document.querySelector('iframe.utterances-frame');
|
||||
if (!iframe) return;
|
||||
LoadComments();
|
||||
}, [LoadComments]);
|
||||
|
||||
// Added `relative` to fix a weird bug with `utterances-frame` position
|
||||
return (
|
||||
<div className="pt-6 pb-6 text-center text-gray-700 dark:text-gray-300">
|
||||
{enableLoadComments && (
|
||||
<button onClick={LoadComments}>Load Comments</button>
|
||||
)}
|
||||
<div className="utterances-frame relative" id={COMMENTS_ID} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Utterances;
|
93
components/comments/commento/ReactCommento.tsx
Normal file
93
components/comments/commento/ReactCommento.tsx
Normal file
@ -0,0 +1,93 @@
|
||||
import { createRef } from 'preact';
|
||||
import React, { useLayoutEffect, useMemo, useRef } from 'react';
|
||||
|
||||
interface DataAttributes {
|
||||
[key: string]: string | boolean | undefined;
|
||||
}
|
||||
|
||||
const insertScript = (
|
||||
src: string,
|
||||
id: string,
|
||||
dataAttributes: DataAttributes,
|
||||
onload = () => {}
|
||||
) => {
|
||||
const script = window.document.createElement('script');
|
||||
script.async = true;
|
||||
script.src = src;
|
||||
script.id = id;
|
||||
if (document.getElementById(id)) {
|
||||
return;
|
||||
}
|
||||
script.addEventListener('load', onload, { capture: true, once: true });
|
||||
|
||||
Object.entries(dataAttributes).forEach(([key, value]) => {
|
||||
if (value === undefined) {
|
||||
return;
|
||||
}
|
||||
script.setAttribute(`data-${key}`, value.toString());
|
||||
});
|
||||
|
||||
document.body.appendChild(script);
|
||||
|
||||
return () => {
|
||||
script.remove();
|
||||
};
|
||||
};
|
||||
|
||||
const ReactCommento = ({
|
||||
url,
|
||||
cssOverride,
|
||||
autoInit,
|
||||
noFonts,
|
||||
hideDeleted,
|
||||
pageId,
|
||||
}: {
|
||||
url: string;
|
||||
cssOverride?: string;
|
||||
autoInit?: boolean;
|
||||
noFonts?: boolean;
|
||||
hideDeleted?: boolean;
|
||||
pageId?: string;
|
||||
}) => {
|
||||
const containerId = useMemo(
|
||||
() => `commento-${Math.random().toString().slice(2, 8)}`,
|
||||
[]
|
||||
);
|
||||
const container = createRef<HTMLDivElement>();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!window) {
|
||||
return;
|
||||
}
|
||||
|
||||
window['commento'] = container.current;
|
||||
|
||||
const removeScript = insertScript(
|
||||
url,
|
||||
`${containerId}-script`,
|
||||
{
|
||||
'css-override': cssOverride,
|
||||
'auto-init': autoInit,
|
||||
'no-fonts': noFonts,
|
||||
'hide-deleted': hideDeleted,
|
||||
'page-id': pageId,
|
||||
'id-root': containerId,
|
||||
},
|
||||
() => {
|
||||
removeScript();
|
||||
}
|
||||
);
|
||||
}, [
|
||||
autoInit,
|
||||
cssOverride,
|
||||
hideDeleted,
|
||||
noFonts,
|
||||
pageId,
|
||||
url,
|
||||
containerId,
|
||||
container,
|
||||
]);
|
||||
|
||||
return <div ref={container} id={containerId} />;
|
||||
};
|
||||
export default ReactCommento;
|
@ -1,39 +0,0 @@
|
||||
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
|
80
components/comments/index.tsx
Normal file
80
components/comments/index.tsx
Normal file
@ -0,0 +1,80 @@
|
||||
import React from 'react';
|
||||
|
||||
import siteMetadata from '@/data/siteMetadata';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { PostFrontMatter } from 'types/PostFrontMatter';
|
||||
|
||||
interface Props {
|
||||
frontMatter: PostFrontMatter;
|
||||
}
|
||||
|
||||
const UtterancesComponent = dynamic(
|
||||
() => {
|
||||
return import('@/components/comments/Utterances');
|
||||
},
|
||||
{ ssr: false }
|
||||
);
|
||||
const GiscusComponent = dynamic(
|
||||
() => {
|
||||
return import('@/components/comments/Giscus');
|
||||
},
|
||||
{ ssr: false }
|
||||
);
|
||||
const DisqusComponent = dynamic(
|
||||
() => {
|
||||
return import('@/components/comments/Disqus');
|
||||
},
|
||||
{ ssr: false }
|
||||
);
|
||||
const CusdisComponent = dynamic(
|
||||
() => {
|
||||
return import('@/components/comments/Cusdis');
|
||||
},
|
||||
{ ssr: false }
|
||||
);
|
||||
const CommentoComponent = dynamic(
|
||||
() => {
|
||||
return import('@/components/comments/Commento');
|
||||
},
|
||||
{ ssr: false }
|
||||
);
|
||||
|
||||
const Comments = ({ frontMatter }: Props) => {
|
||||
let term;
|
||||
switch (
|
||||
siteMetadata.comment.giscusConfig.mapping ||
|
||||
siteMetadata.comment.utterancesConfig.issueTerm
|
||||
) {
|
||||
case 'pathname':
|
||||
term = frontMatter.slug;
|
||||
break;
|
||||
case 'url':
|
||||
term = window.location.href;
|
||||
break;
|
||||
case 'title':
|
||||
term = frontMatter.title;
|
||||
break;
|
||||
}
|
||||
return (
|
||||
<div id="comment">
|
||||
{siteMetadata.comment && siteMetadata.comment.provider === 'giscus' && (
|
||||
<GiscusComponent mapping={term} />
|
||||
)}
|
||||
{siteMetadata.comment &&
|
||||
siteMetadata.comment.provider === 'utterances' && (
|
||||
<UtterancesComponent issueTerm={term} />
|
||||
)}
|
||||
{siteMetadata.comment && siteMetadata.comment.provider === 'disqus' && (
|
||||
<DisqusComponent frontMatter={frontMatter} />
|
||||
)}
|
||||
{siteMetadata.comment && siteMetadata.comment.provider === 'cusdis' && (
|
||||
<CusdisComponent frontMatter={frontMatter} />
|
||||
)}
|
||||
{siteMetadata.comment && siteMetadata.comment.provider === 'commento' && (
|
||||
<CommentoComponent frontMatter={frontMatter} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Comments;
|
@ -1,9 +1,9 @@
|
||||
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'
|
||||
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/
|
||||
|
||||
@ -14,27 +14,30 @@ const components = {
|
||||
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
|
||||
if (
|
||||
!href ||
|
||||
(kind === 'mail' &&
|
||||
!/^mailto:\w+([.-]?\w+)@\w+([.-]?\w+)(.\w{2,3})+$/.test(href))
|
||||
)
|
||||
return null;
|
||||
|
||||
const SocialSvg = components[kind]
|
||||
const SocialSvg = components[kind];
|
||||
|
||||
return (
|
||||
<a
|
||||
className="text-sm text-gray-500 transition hover:text-gray-600"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href={href}
|
||||
>
|
||||
href={href}>
|
||||
<span className="sr-only">{kind}</span>
|
||||
<SocialSvg
|
||||
className={`fill-current text-gray-700 hover:text-blue-500 dark:text-gray-200 dark:hover:text-blue-400 h-${size} w-${size}`}
|
||||
/>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default SocialIcon
|
||||
export default SocialIcon;
|
@ -32,7 +32,7 @@
|
||||
}
|
||||
|
||||
.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 {
|
||||
|
@ -1,6 +1,6 @@
|
||||
---
|
||||
name: Ivan Li
|
||||
avatar: https://pan.ivanli.cc/api/v3/file/source/1234/%E5%A4%B4%E5%83%8F.png?sign=xIgy54DyFRYupxjZJbK02HmpKX8C53YR-O0I18Rxm70%3D%3A0
|
||||
avatar: https://pan.ivanli.cc/api/v3/file/source/1234/头像.png?sign=xIgy54DyFRYupxjZJbK02HmpKX8C53YR-O0I18Rxm70%3D%3A0
|
||||
occupation: Web Full Stack Developer
|
||||
email: master@ivanli.cc
|
||||
github: https://github.com/IvanLi-CN
|
||||
|
@ -1,12 +0,0 @@
|
||||
---
|
||||
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.
|
19
data/blog/arch-linux-quick-setup.md
Normal file
19
data/blog/arch-linux-quick-setup.md
Normal file
@ -0,0 +1,19 @@
|
||||
---
|
||||
title: 搭建日常使用的 Arch Linux
|
||||
date: '2022-10-17'
|
||||
tags: ['Arch Linux', '环境搭建', 'VPS']
|
||||
draft: false
|
||||
summary: 有了上次快速安装步骤后,接下来就是使用这个环境了。要使用环境,首先需要做一些初始化操作。我的步骤适合我,但不一定适合你,只是记录和参考。
|
||||
images:
|
||||
[
|
||||
'https://pan.ivanli.cc/api/v3/file/source/2238/archlinux-logo-light.png?sign=bWxqFFy3RUDT5UsWb4UD5byt-_L4h79wede3runRKFc%3D%3A0',
|
||||
]
|
||||
---
|
||||
|
||||
## Docker
|
||||
|
||||
登录私有仓库,以便拉取镜像。
|
||||
|
||||
```zsh
|
||||
docker login -u="ivan+hk_nat" docker-registry.ivanli.cc
|
||||
```
|
221
data/blog/archlinux-on-promox-ve-lxc.md
Normal file
221
data/blog/archlinux-on-promox-ve-lxc.md
Normal file
@ -0,0 +1,221 @@
|
||||
---
|
||||
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
|
||||
```
|
271
data/blog/build-an-frp-using-xray-acme.sh-docker-compose.md
Normal file
271
data/blog/build-an-frp-using-xray-acme.sh-docker-compose.md
Normal file
@ -0,0 +1,271 @@
|
||||
---
|
||||
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,38 +0,0 @@
|
||||
---
|
||||
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))
|
||||
```
|
181
data/blog/debian-desktop-environment.md
Normal file
181
data/blog/debian-desktop-environment.md
Normal file
@ -0,0 +1,181 @@
|
||||
---
|
||||
title: 在 PVE 宿主机上使用桌面环境
|
||||
date: '2022-10-28'
|
||||
tags: ['PVE', 'PVE', 'DE', '环境搭建', 'Debian']
|
||||
draft: false
|
||||
summary: 虽然 PVE 宿主机不应该安装乱七八糟的东西,但是我穷,为了物尽其用,为了在主力电脑翻车时有一个立即可用的备用环境,所以还是安装了基础的桌面环境。现在的 Linux 桌面环境越来越好了,我选择安装 KDE Plasma 作为桌面环境,并且默认关闭,按需启用。
|
||||
images:
|
||||
[
|
||||
'https://pan.ivanli.cc/api/v3/file/source/2243/1200px-Kde_dragons.png?sign=yGZL9jYeVt53Ve43ddhHt_0EzVV2cW_WbxHc0dEcwWY%3D%3A0',
|
||||
]
|
||||
---
|
||||
|
||||
## 前言
|
||||
|
||||
过几天就是双十一了,或许是我去购物网站上看了下显卡价格吧,我的显卡当晚就闹情绪,不工作还引发宕机。虽然拔了显卡,我还能用核显开机,但是我懒呀,所以我花了一个晚上在 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
|
||||
```
|
@ -1,141 +0,0 @@
|
||||
---
|
||||
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}
|
||||
$$
|
19
data/blog/first-article.md
Normal file
19
data/blog/first-article.md
Normal file
@ -0,0 +1,19 @@
|
||||
---
|
||||
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 做笔记,写文章。不过我发现这家伙确实不方便完整地将内容无损地转换为静态站点……这就是现在这版本博客上线的原因了。这么想想我的主域名吃灰了一年有余了……
|
||||
|
||||
不知道会不会坚持经常更新博客,因为我还是比较喜欢记录一些碎片内容,以备将来回顾。而博文主要还是为了做一些分享。做分享,动力还是有些不足,而且学海无涯,要学的东西还是真的多。精力有限,估计短期是无法预见能有经常分享的机会了。
|
||||
|
||||
总之,先上线再说。后面会陆续添加评论、评分和通知功能。会尽量使用开源、自部署的程序。自从看上了容器化和容器编排技术,我已经爱上了用开源项目自部署一堆服务,配合使用。
|
||||
|
||||
_日积月累,厚积薄发。_
|
@ -1,198 +0,0 @@
|
||||
---
|
||||
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~~.
|
@ -1,76 +0,0 @@
|
||||
---
|
||||
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`
|
159
data/blog/hosting-verdaccio-with-docker-and-caddy.md
Normal file
159
data/blog/hosting-verdaccio-with-docker-and-caddy.md
Normal file
@ -0,0 +1,159 @@
|
||||
---
|
||||
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://pan.ivanli.cc/api/v3/file/source/2233/verdaccio.png?sign=qpoeADXzbhHk2MY5CehgTftUJ67pnUj-Ylko9D5jscU%3D%3A0',
|
||||
]
|
||||
---
|
||||
|
||||
## 为何自建存储库?
|
||||
|
||||
平常开发项目时,会抽出一些可复用的逻辑封装成 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"
|
||||
```
|
267
data/blog/install-and-setup-arch-linux.md
Normal file
267
data/blog/install-and-setup-arch-linux.md
Normal file
@ -0,0 +1,267 @@
|
||||
---
|
||||
title: 安装并配置 Arch Linux
|
||||
date: '2022-10-17'
|
||||
tags: ['Arch Linux', '环境搭建', 'VPS']
|
||||
draft: false
|
||||
summary: 又到了新装 Arch Linux 的日子了。这次又是温故而知新的机会,把之前写的笔记稍微整理了一下,在这里记录下教徒搭窝的备忘录。
|
||||
images:
|
||||
[
|
||||
'https://pan.ivanli.cc/api/v3/file/source/2238/archlinux-logo-light.png?sign=bWxqFFy3RUDT5UsWb4UD5byt-_L4h79wede3runRKFc%3D%3A0',
|
||||
]
|
||||
---
|
||||
|
||||
## 起势
|
||||
|
||||
首先,通过 SSH 以 `root` 用户连接服务器,然后修改 `root` 密码:
|
||||
|
||||
```bash
|
||||
passwd
|
||||
# 输入两次你的新密码
|
||||
```
|
||||
|
||||
## 重装系统
|
||||
|
||||
为了避免 IDC 提供的系统镜像有加料、后门、老旧等问题,拿到服务器后第一件事是重装系统。 Arch Linux 是我的第一选择。
|
||||
|
||||
借助 felixonmars 的 [vps2arch](https://github.com/felixonmars/vps2arch),我们可以将绝大多数的 Linux 系统转换成 Arch Linux 🎉。
|
||||
|
||||
```bash
|
||||
wget https://felixc.at/vps2arch
|
||||
chmod +x vps2arch
|
||||
./vps2arch
|
||||
```
|
||||
|
||||
等待几分钟就完成了。如果是中国大陆境内的机子,建议全局代理或使用自定义的系统镜像源。可以从下面的网站获取镜像地址。地址上有查询参数,可以根据自己需要修改。
|
||||
|
||||
> [https://archlinux.org/mirrorlist/?country=HK&protocol=https&use_mirror_status=on](https://archlinux.org/mirrorlist/?country=HK&protocol=https&use_mirror_status=on)
|
||||
|
||||
推荐使用 `https://hkg.mirror.rackspace.com/archlinux/`:
|
||||
|
||||
```bash
|
||||
./vps2arch -m 'https://hkg.mirror.rackspace.com/archlinux/'
|
||||
```
|
||||
|
||||
如果系统装不上,可以在 IDC 面板上重装其他系统后再试,推荐使用 Debian。
|
||||
|
||||
脚本执行完成后,按脚本提示执行下面的命令重启设备,密码将被保留:
|
||||
|
||||
```bash
|
||||
sync ; reboot -f
|
||||
```
|
||||
|
||||
重启时,SSH 会断开连接。因为新系统的 SSH 主机指纹会变化,所以需要忘记旧指纹:
|
||||
|
||||
```bash
|
||||
ssh-keygen -R <remote-host>
|
||||
|
||||
# example
|
||||
ssh-keygen -R '[20.20.20.20]:20000'
|
||||
```
|
||||
|
||||
之后重新连接 SSH。
|
||||
|
||||
## 基本配置
|
||||
|
||||
设置主机名:
|
||||
|
||||
```bash
|
||||
hostnamectl set-hostname arch.example.com
|
||||
```
|
||||
|
||||
启用 pacman 并行下载:
|
||||
|
||||
- 编辑 `/etc/pacman.conf`
|
||||
- 取消 `ParallelDownloads` 前的注释,值为并行下载数
|
||||
|
||||
## 常用环境安装
|
||||
|
||||
我的常用环境如下:
|
||||
|
||||
- 一个自己的账户
|
||||
- Git
|
||||
- Yay
|
||||
- Zsh
|
||||
- Docker
|
||||
- TailScale
|
||||
|
||||
### 创建账户
|
||||
|
||||
安装 `sudo`:
|
||||
|
||||
```bash
|
||||
pacman -Sy sudo
|
||||
```
|
||||
|
||||
创建账户:
|
||||
|
||||
```bash
|
||||
useradd -m ivan
|
||||
passwd ivan
|
||||
usermod -aG wheel ivan
|
||||
```
|
||||
|
||||
给刚刚创建的账户分配一个具有 sudo 权限的账户
|
||||
|
||||
```bash
|
||||
EDITOR=vim visudo
|
||||
```
|
||||
|
||||
找到 `%wheel ALL=(ALL: ALL) ALL` 这行,取消这行的注释。
|
||||
|
||||
现在,你自己的账号具有 sudo 权限了,接下来切换到自己的账户连接终端吧。
|
||||
|
||||
### Git
|
||||
|
||||
需要手动安装:
|
||||
|
||||
```bash
|
||||
sudo pacman -S git
|
||||
```
|
||||
|
||||
### Zsh
|
||||
|
||||
[Zsh](https://wiki.archlinux.org/title/zsh) 是一个不错的终端外壳(Shell)。
|
||||
|
||||
使用 `pacman` 安装:
|
||||
|
||||
```bash
|
||||
sudo pacman -S zsh
|
||||
```
|
||||
|
||||
如果你想执行交互式的初始化配置,可以输入下面命令进入 zsh 并开始初始化配置,否则不要执行下面的命令:
|
||||
|
||||
```bash
|
||||
zsh
|
||||
```
|
||||
|
||||
接下来安装我常用的插件:
|
||||
|
||||
```zsh
|
||||
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-z
|
||||
|
||||
一个快速跳转目录的插件。
|
||||
|
||||
避免优先匹配到子目录,在 `.zshrc` 中添加如下行:
|
||||
|
||||
```zsh
|
||||
cat >> ~/.zshrc <<EOF
|
||||
|
||||
# zsh-z
|
||||
ZSHZ_UNCOMMON=1
|
||||
ZSHZ_TRAILING_SLASH=1
|
||||
EOF
|
||||
```
|
||||
|
||||
#### History
|
||||
|
||||
配置历史记录,在 `.zshrc` 中添加如下行:
|
||||
|
||||
```zsh
|
||||
cat >> ~/.zshrc <<EOF
|
||||
|
||||
# History
|
||||
HISTFILE=~/.zsh_history
|
||||
HISTSIZE=10000
|
||||
SAVEHIST=1000
|
||||
setopt INC_APPEND_HISTORY_TIME
|
||||
EOF
|
||||
```
|
||||
|
||||
详细配置参考:[Better zsh history | SoberKoder](https://www.soberkoder.com/better-zsh-history/)
|
||||
文档:[zsh: 16 Options](https://zsh.sourceforge.io/Doc/Release/Options.html)
|
||||
|
||||
然后进入到 `zsh` 中,执行一次 `source ~/.zshrc`:
|
||||
|
||||
```shell
|
||||
zsh
|
||||
|
||||
source ~/.zshrc
|
||||
```
|
||||
|
||||
设置 Zsh 为默认的 shell 程序:
|
||||
|
||||
```bash
|
||||
# 列出所有已安装的 shell 程序
|
||||
chsh -l
|
||||
# 从上面的结果中找到 zsh 的完整路径
|
||||
# 我的是 /bin/zsh
|
||||
chsh -s /bin/zsh
|
||||
```
|
||||
|
||||
### Docker
|
||||
|
||||
安装 Docker 和 Docker Compose 也很简单:
|
||||
|
||||
```zsh
|
||||
sudo pacman -S docker docker-compose
|
||||
# 启动
|
||||
sudo systemctl start docker
|
||||
# 启用
|
||||
sudo systemctl enable docker
|
||||
# 添加当前用户到 docker 组
|
||||
sudo usermod -aG docker $USER
|
||||
# log in to a new group
|
||||
newgrp docker
|
||||
```
|
||||
|
||||
## 安全配置
|
||||
|
||||
### 禁用 SSH 密码登录
|
||||
|
||||
修改 `/etc/ssh/sshd_config`,找到 `PasswordAuthentication`,改为 `no`。
|
||||
然后重启:
|
||||
|
||||
```zsh
|
||||
sudo systemctl restart sshd
|
||||
```
|
||||
|
||||
### 使用 Fail2Ban
|
||||
|
||||
安装:
|
||||
|
||||
```zsh
|
||||
sudo pacman -S fail2ban
|
||||
```
|
||||
|
||||
复制配置文件:
|
||||
|
||||
```zsh
|
||||
sudo cp /etc/fail2ban/jail.{conf,local}
|
||||
```
|
||||
|
||||
编辑配置文件,找到 `[sshd]` 块,并添加 `enabled=true`,(**不是解除注释**):
|
||||
|
||||
```zsh
|
||||
sudo vim /etc/fail2ban/jail.local
|
||||
```
|
||||
|
||||
```text
|
||||
[sshd]
|
||||
enabled = true
|
||||
```
|
||||
|
||||
启动 fail2ban
|
||||
|
||||
```zsh
|
||||
sudo systemctl start fail2ban
|
||||
sudo systemctl enable fail2ban
|
||||
```
|
||||
|
||||
查看状态:
|
||||
|
||||
```zsh
|
||||
sudo fail2ban-client status
|
||||
```
|
||||
|
||||
## 写在最后
|
||||
|
||||
大功告成,现在又拥有了一个崭新的 Arch Linux 系统了。后面有机会的话,我得把这些配置脚本化,不然天天配也是有点蠢,哈哈。
|
@ -1,214 +0,0 @@
|
||||
---
|
||||
title: 'Introducing Tailwind Nextjs Starter Blog'
|
||||
date: '2021-01-12'
|
||||
lastmod: '2021-02-01'
|
||||
tags: ['next-js', 'tailwind', 'guide']
|
||||
draft: false
|
||||
summary: 'Looking for a performant, out of the box template, with all the best in web technology to support your blogging needs? Checkout the Tailwind Nextjs Starter Blog template.'
|
||||
images: ['/static/images/canada/mountains.jpg', '/static/images/canada/toronto.jpg']
|
||||
authors: ['default', 'sparrowhawk']
|
||||
---
|
||||
|
||||

|
||||
|
||||
# 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. 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.
|
||||
|
||||
Check out the documentation below to get started.
|
||||
|
||||
Facing issues? Check the [FAQ page](https://github.com/timlrx/tailwind-nextjs-starter-blog/wiki) and do a search on past issues. Feel free to open a new issue if none has been posted previously.
|
||||
|
||||
Feature request? Check the past discussions to see if it has been brought up previously. Otherwise, feel free to start a new discussion thread. All ideas are welcomed!
|
||||
|
||||
## Examples
|
||||
|
||||
- [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
|
||||
- [Aloisdg's cookbook](https://tambouille.vercel.app/) - with pictures and recipes!
|
||||
- [GautierArcin's demo with next translate](https://tailwind-nextjs-starter-blog-seven.vercel.app/) - includes translation of mdx posts, [source code](https://github.com/GautierArcin/tailwind-nextjs-starter-blog/tree/demo/next-translate)
|
||||
- [David Levai's digital garden](https://davidlevai.com/) - customized design and added email subscriptions
|
||||
- [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
|
||||
|
||||
Using the template? Feel free to create a PR and add your blog to this list.
|
||||
|
||||
## Motivation
|
||||
|
||||
I wanted to port my existing blog to Nextjs and Tailwind CSS but there was no easy out of the box template to use so I decided to create one. Design is adapted from [Tailwindlabs blog](https://github.com/tailwindlabs/blog.tailwindcss.com).
|
||||
|
||||
I wanted it to be nearly as feature-rich as popular blogging templates like [beautiful-jekyll](https://github.com/daattali/beautiful-jekyll) and [Hugo Academic](https://github.com/wowchemy/wowchemy-hugo-modules) but with the best of React's ecosystem and current web development's best practices.
|
||||
|
||||
## Features
|
||||
|
||||
- Easy styling customization with [Tailwind 3.0](https://tailwindcss.com/blog/tailwindcss-v3) and primary color attribute
|
||||
- Near perfect lighthouse score - [Lighthouse report](https://www.webpagetest.org/result/210111_DiC1_08f3670c3430bf4a9b76fc3b927716c5/)
|
||||
- Lightweight, 45kB first load JS, uses Preact in production build
|
||||
- Mobile-friendly view
|
||||
- Light and dark theme
|
||||
- Self-hosted font with [Fontsource](https://fontsource.org/)
|
||||
- Supports [plausible](https://plausible.io/), [simple analytics](https://simpleanalytics.com/) and google analytics
|
||||
- [MDX - write JSX in markdown documents!](https://mdxjs.com/)
|
||||
- Server-side syntax highlighting with line numbers and line highlighting via [rehype-prism-plus](https://github.com/timlrx/rehype-prism-plus)
|
||||
- Math display supported via [KaTeX](https://katex.org/)
|
||||
- Citation and bibliography support via [rehype-citation](https://github.com/timlrx/rehype-citation)
|
||||
- Automatic image optimization via [next/image](https://nextjs.org/docs/basic-features/image-optimization)
|
||||
- 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 multiple authors
|
||||
- Blog templates
|
||||
- TOC component
|
||||
- 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
|
||||
- Preconfigured security headers
|
||||
- SEO friendly with RSS feed, sitemaps and more!
|
||||
|
||||
## Sample posts
|
||||
|
||||
- [A markdown guide](https://tailwind-nextjs-starter-blog.vercel.app/blog/github-markdown-guide)
|
||||
- [Learn more about images in Next.js](https://tailwind-nextjs-starter-blog.vercel.app/blog/guide-to-using-images-in-nextjs)
|
||||
- [A tour of math typesetting](https://tailwind-nextjs-starter-blog.vercel.app/blog/deriving-ols-estimator)
|
||||
- [Simple MDX image grid](https://tailwind-nextjs-starter-blog.vercel.app/blog/pictures-of-canada)
|
||||
- [Example of long prose](https://tailwind-nextjs-starter-blog.vercel.app/blog/the-time-machine)
|
||||
- [Example of Nested Route Post](https://tailwind-nextjs-starter-blog.vercel.app/blog/nested-route/introducing-multi-part-posts-with-nested-routing)
|
||||
|
||||
## Quick Start Guide
|
||||
|
||||
1. Try installing the starter using the new [Pliny project CLI](https://github.com/timlrx/pliny):
|
||||
|
||||
```bash
|
||||
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)
|
||||
3. Modify the content security policy in `next.config.js` if you want to use
|
||||
any analytics provider or a commenting solution other than giscus.
|
||||
4. Personalize `authors/default.md` (main author)
|
||||
5. Modify `projectsData.js`
|
||||
6. Modify `headerNavLinks.js` to customize navigation links
|
||||
7. Add blog posts
|
||||
8. Deploy on Vercel
|
||||
|
||||
## Development
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm start
|
||||
# or
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `pages/index.js`. The page auto-updates as you edit the file.
|
||||
|
||||
## Extend / Customize
|
||||
|
||||
`data/siteMetadata.js` - contains most of the site related information which should be modified for a user's need.
|
||||
|
||||
`data/authors/default.md` - default author information (required). Additional authors can be added as files in `data/authors`.
|
||||
|
||||
`data/projectsData.js` - data used to generate styled card on the projects page.
|
||||
|
||||
`data/headerNavLinks.js` - navigation links.
|
||||
|
||||
`data/logo.svg` - replace with your own logo.
|
||||
|
||||
`data/blog` - replace with your own blog posts.
|
||||
|
||||
`public/static` - store assets such as images and favicons.
|
||||
|
||||
`tailwind.config.js` and `css/tailwind.css` - contain the tailwind stylesheet which can be modified to change the overall look and feel of the site.
|
||||
|
||||
`css/prism.css` - controls the styles associated with the code blocks. Feel free to customize it and use your preferred prismjs theme e.g. [prism themes](https://github.com/PrismJS/prism-themes).
|
||||
|
||||
`components/social-icons` - to add other icons, simply copy an svg file from [Simple Icons](https://simpleicons.org/) and map them in `index.js`. Other icons use [heroicons](https://heroicons.com/).
|
||||
|
||||
`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.
|
||||
|
||||
`pages` - pages to route to. Read the [Next.js documentation](https://nextjs.org/docs) for more information.
|
||||
|
||||
`next.config.js` - configuration related to Next.js. You need to adapt the Content Security Policy if you want to load scripts, images etc. from other domains.
|
||||
|
||||
## Post
|
||||
|
||||
### Frontmatter
|
||||
|
||||
Frontmatter follows [Hugo's standards](https://gohugo.io/content-management/front-matter/).
|
||||
|
||||
Currently 7 fields are supported.
|
||||
|
||||
```
|
||||
title (required)
|
||||
date (required)
|
||||
tags (required, can be empty array)
|
||||
lastmod (optional)
|
||||
draft (optional)
|
||||
summary (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)
|
||||
layout (optional list which should correspond to the file names in `data/layouts`)
|
||||
canonicalUrl (optional, canonical url for the post for SEO)
|
||||
```
|
||||
|
||||
Here's an example of a post's frontmatter:
|
||||
|
||||
```
|
||||
---
|
||||
title: 'Introducing Tailwind Nexjs Starter Blog'
|
||||
date: '2021-01-12'
|
||||
lastmod: '2021-01-18'
|
||||
tags: ['next-js', 'tailwind', 'guide']
|
||||
draft: false
|
||||
summary: 'Looking for a performant, out of the box template, with all the best in web technology to support your blogging needs? Checkout the Tailwind Nextjs Starter Blog template.'
|
||||
images: ['/static/images/canada/mountains.jpg', '/static/images/canada/toronto.jpg']
|
||||
authors: ['default', 'sparrowhawk']
|
||||
layout: PostLayout
|
||||
canonicalUrl: https://tailwind-nextjs-starter-blog.vercel.app/blog/introducing-tailwind-nextjs-starter-blog
|
||||
---
|
||||
```
|
||||
|
||||
### 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
|
||||
|
||||
**Vercel**
|
||||
The easiest way to deploy the template is to use the [Vercel Platform](https://vercel.com) from the creators of Next.js. Check out the [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
|
||||
|
||||
**Netlify / GitHub Pages / Firebase 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.
|
||||
|
||||
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.
|
||||
|
||||
## Support
|
||||
|
||||
Using the template? Support this effort by giving a star on GitHub, sharing your own blog and giving a shoutout on Twitter or be a project [sponsor](https://github.com/sponsors/timlrx).
|
||||
|
||||
## Licence
|
||||
|
||||
[MIT](https://github.com/timlrx/tailwind-nextjs-starter-blog/blob/master/LICENSE) © [Timothy Lin](https://www.timrlx.com)
|
@ -1,10 +0,0 @@
|
||||
---
|
||||
title: My fancy title
|
||||
date: '2021-01-31'
|
||||
tags: ['hello']
|
||||
draft: true
|
||||
summary:
|
||||
images: []
|
||||
---
|
||||
|
||||
Draft post which should not display
|
143
data/blog/nat-vps-as-sd-lan-relay.md
Normal file
143
data/blog/nat-vps-as-sd-lan-relay.md
Normal file
@ -0,0 +1,143 @@
|
||||
---
|
||||
title: 使用 NAT VPS 作为 SD-WAN 中继
|
||||
date: '2022-10-13'
|
||||
tags: ['ZeroTier', 'TailScale', ‘SD-WAN’, 'NAT', 'VPS', 'FRP', 'Self-Hosted']
|
||||
draft: false
|
||||
summary: ZeroTier 和 TailScale 是我目前正在同时使用的 SD-WAN 组网工具。这次购入了一台 NAT VPS,准备用它中继 ZeroTier 和 TailScale。
|
||||
---
|
||||
|
||||
## 诉苦
|
||||
|
||||
由于工作需要,我已经好几个月使用 SD-WAN 在公司和宿舍组建了 SD-WAN,可是国内网络环境实在是不友好,公司网络质量也差,我忍受了很久的随机掉线的问题,近两个月我改用手机热点避免了这个问题,但是只能在开发后端时使用,如果开发前端或者是对生产环境进行部署与验证时,访问线上环境需要几百兆流量,我吃不消哇,频繁切换 Wi-Fi 接入点也不是个令人愉快的事。但天无绝人之路,我兜兜转转购入了一台香港的 NAT VPS,规划用于中继 SD-WAN。
|
||||
|
||||
## 方案
|
||||
|
||||
## 步骤
|
||||
|
||||
### 重装系统
|
||||
|
||||
// TODO:
|
||||
|
||||
## ZeroTier 中继
|
||||
|
||||
### VPS 上安装步骤
|
||||
|
||||
#### 安装 ZeroTier 客户端
|
||||
|
||||
```bash
|
||||
yay -S zerotier-one
|
||||
```
|
||||
|
||||
#### 启动服务
|
||||
|
||||
```bash
|
||||
sudo systemctl enable zerotier-one
|
||||
sudo systemctl start zerotier-one
|
||||
```
|
||||
|
||||
#### 加入网络
|
||||
|
||||
打开官方 planet 面板:[ZeroTier Central](https://my.zerotier.com/),复制你的网络 ID,然后:
|
||||
|
||||
```bash
|
||||
sudo zerotier-cli join <your_network_id>
|
||||
```
|
||||
|
||||
回到面板,授权刚刚加入的节点。
|
||||
|
||||
#### 转换为 Moon
|
||||
|
||||
生成 moon 配置
|
||||
|
||||
```bash
|
||||
sudo zerotier-idtool initmoon identity.public | sudo tee -a moon.json
|
||||
```
|
||||
|
||||
输出示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "9axxxxxx12",
|
||||
"objtype": "world",
|
||||
"roots": [
|
||||
{
|
||||
"identity": "9axxxxxx12:0:258xxxxxx38b34a2fa88b46d290137a6ecb3a185dacdaee957c30e33f1977ca7e",
|
||||
"stableEndpoints": []
|
||||
}
|
||||
],
|
||||
"signingKey": "df18369fxxxxxx70036a356c",
|
||||
"signingKey_SECRET": "b1524155faa6f779b8xxxxxxf811f711fed",
|
||||
"updatesMustBeSignedBy": "df18369f3b54xxxxxx036a356c",
|
||||
"worldType": "moon"
|
||||
}
|
||||
```
|
||||
|
||||
向 `"stableEndpoints": []` 中添加 `"<server_ip>/<server_nat_port>"`,其中 `server_ip` 是你的公网地址,`server_nat_port` 是 NAT 后的外网端口。示例如下:
|
||||
|
||||
```json
|
||||
{
|
||||
//...
|
||||
"roots": [
|
||||
{
|
||||
"identity": "9axxxxxx12:0:258xxxxxx38b34a2fa88b46d290137a6ecb3a185dacdaee957c30e33f1977ca7e",
|
||||
"stableEndpoints": ["1.2.3.4/19993"]
|
||||
}
|
||||
]
|
||||
//...
|
||||
}
|
||||
```
|
||||
|
||||
生成 `.moon` 文件:
|
||||
|
||||
```bash
|
||||
sudo zerotier-idtool genmoon moon.json
|
||||
```
|
||||
|
||||
执行完成后,可以在当前目录中看到 `.moon` 文件:
|
||||
|
||||
```bash
|
||||
ls -l | grep *.moon
|
||||
```
|
||||
|
||||
将生成的 `.moon` 文件移动到 `./moons.d` 目录中:
|
||||
|
||||
```bash
|
||||
sudo mkdir moons.d
|
||||
sudo mv 000000xxxxxxxxxx.moon moons.d
|
||||
```
|
||||
|
||||
#### 重启 ZeroTier 服务
|
||||
|
||||
```bash
|
||||
sudo systemctl restart zerotier-one
|
||||
```
|
||||
|
||||
### 使用 ZeroTier 中继
|
||||
|
||||
前面生成的 "000000xxxxxxxx.moon" 文件,除去后缀 `.moon` 外就是 Moon ID,前面的零没啥用,把后面的内容放到下面的命令中,两个参数都是 Moon ID。
|
||||
|
||||
```bash
|
||||
sudo zerotier-cli orbit xxxxxxxxxx xxxxxxxxxx
|
||||
```
|
||||
|
||||
### 问题排查
|
||||
|
||||
#### 无法加入网络
|
||||
|
||||
```bash
|
||||
sudo zerotier-cli join xxxxxxxxxxxx
|
||||
# outputs:
|
||||
# zerotier-cli: missing port and zerotier-one.port not found in /var/lib/zerotier-one
|
||||
```
|
||||
|
||||
原因:没有[启动服务](#启动服务)。
|
||||
|
||||
#### 无法初始化 moon
|
||||
|
||||
```bash
|
||||
sudo zerotier-idtool initmoon identity.public | sudo tee -a moon.json
|
||||
# outputs:
|
||||
# identity.public is not a valid identity
|
||||
```
|
||||
|
||||
原因:没有[启动过服务](#启动服务)。第一次启动后 ZeroTier 会创建 `identity.public` 文件。
|
@ -1,30 +0,0 @@
|
||||
---
|
||||
title: Introducing Multi-part Posts with Nested Routing
|
||||
date: '2021-05-02'
|
||||
tags: ['multi-author', 'next-js', 'feature']
|
||||
draft: false
|
||||
summary: 'The blog template supports posts in nested sub-folders. This can be used to group posts of similar content e.g. a multi-part course. This post is itself an example of a nested route!'
|
||||
---
|
||||
|
||||
# Nested Routes
|
||||
|
||||
The blog template supports posts in nested sub-folders. This helps in organisation and can be used to group posts of similar content e.g. a multi-part series. This post is itself an example of a nested route! It's located in the `/data/blog/nested-route` folder.
|
||||
|
||||
## How
|
||||
|
||||
Simplify create multiple folders inside the main `/data/blog` folder and add your `.md`/`.mdx` files to them. You can even create something like `/data/blog/nested-route/deeply-nested-route/my-post.md`
|
||||
|
||||
We use Next.js catch all routes to handle the routing and path creations.
|
||||
|
||||
## Use Cases
|
||||
|
||||
Here are some reasons to use nested routes
|
||||
|
||||
- More logical content organisation (blogs will still be displayed based on the created date)
|
||||
- Multi-part posts
|
||||
- Different sub-routes for each author
|
||||
- Internationalization (though it would be recommended to use [Next.js built-in i8n routing](https://nextjs.org/docs/advanced-features/i18n-routing))
|
||||
|
||||
## Note
|
||||
|
||||
- The previous/next post links at bottom of the template are currently sorted by date. One could explore modifying the template to refer the reader to the previous/next post in the series, rather than by date.
|
251
data/blog/network-freedom-with-vps.md
Normal file
251
data/blog/network-freedom-with-vps.md
Normal file
@ -0,0 +1,251 @@
|
||||
---
|
||||
title: 利用一台小鸡实现网络自由
|
||||
date: '2022-10-06'
|
||||
tags:
|
||||
[
|
||||
'SNI',
|
||||
'TLS',
|
||||
'Reverse Proxy',
|
||||
'反向代理',
|
||||
'正向代理',
|
||||
‘内网穿透',
|
||||
'Caddy',
|
||||
'Xray',
|
||||
'Vless',
|
||||
]
|
||||
draft: true
|
||||
summary: SNI Proxy 进行 TLS 分流;Caddy 对网站和 Xray 进行反向代理;Xray 实现正向、反向代理(内网穿透)。
|
||||
---
|
||||
|
||||
## 前言
|
||||
|
||||
由于条件所限,我在住所使用了一台 AMD 小主机运行了 PVE,上面跑了许多服务,他们有一个共同点——具有 Web UI。另一方面,我的宽带使用共享公网 IP,使用 Full Cone NAT,这就导致我在外难以方便地访问内网服务。为了解决这个令人不爽的问题,我准备使用 VPS 主机作为中间人,用于反射相关流量。
|
||||
|
||||
比较悲剧的是,在撰写本文时,VPS 的 443 端口被干掉……不过还好,VPS 即将到期,如果不能恢复,就换别的了。
|
||||
|
||||
## 网络拓扑图
|
||||
|
||||
简单画一下,大概网络拓扑长下面这样:
|
||||
|
||||
```mermaid
|
||||
graph TB;
|
||||
subgraph CLIENT
|
||||
BROWSER(Browser)
|
||||
XRAY-C(Xray)
|
||||
GIT-C(Git)
|
||||
end
|
||||
|
||||
subgraph VPS
|
||||
SNI-PROXY(SNI Proxy)
|
||||
CADDY-S(Caddy)
|
||||
XRAY-S(Xray)
|
||||
end
|
||||
|
||||
subgraph HOME
|
||||
CADDY-H(Caddy)
|
||||
XRAY-H(Xray)
|
||||
GIT-S(Git)
|
||||
end
|
||||
|
||||
BROWSER--https://www.example.com:443-->SNI-PROXY--https://www.example.com:443-->CADDY-S
|
||||
BROWSER--https://home.example.com:443-->SNI-PROXY--https://home.example.com:443-->XRAY-H--https://home.example.com:443-->CADDY-H
|
||||
XRAY-C--vless://vless.example.com:443-->SNI-PROXY--vless://vless.example.com:443-->XRAY-S
|
||||
XRAY-C--vmess://vmess.example.com:443-->SNI-PROXY--vmess://vmess.example.com:443-->CADDY-S--vmess://vmess.example.com WITHOUT LTS-->XRAY-S
|
||||
BROWSER--http://www.example.com:80-->CADDY-S
|
||||
GIT-C--git://git.example.com:2222-->XRAY-S--git://git.example.com:2222-->XRAY-H--git://git.example.com:2222-->GIT-S
|
||||
```
|
||||
|
||||
从上图中可以看到,VPS 公网 443 端口由 SNI Proxy 监听,80 端口由 Caddy 监听,2222 端口作为 家庭 Git 服务器的透传端口,由 Xray 直接监听并反射(relay)给家庭 Git 服务。
|
||||
|
||||
SNI Proxy 通过对 TLS 的 SNI 对流量进行区分,将访问部署在家庭中的网站的流量直接转发给 Xray,再由 Xray 根据规则转发给家庭中的 Caddy 服务,最后由家庭中的 Caddy 服务将 HTTPS 流量转为 HTTP 流量,并转发给目标服务。对于 VLESS 流量,将转发给 Xray 对应端口处理;对于 vmess 和其他网站流量,转发给 Caddy 的 443 端口处理。
|
||||
|
||||
Caddy 接收到 443 端口的流量,将根据域名等规则处理并转发给对应服务,如果是 vmess 流量,将扒掉 TLS 层并交由 Xray 处理。Caddy 还接收 80 端口的流量,这部分流量基本上都转发给对应 HTTP 服务进行处理。
|
||||
|
||||
## 部署
|
||||
|
||||
### Docker Compose
|
||||
|
||||
```yml
|
||||
version: '3.9'
|
||||
|
||||
networks:
|
||||
caddy:
|
||||
xray:
|
||||
|
||||
volumes:
|
||||
caddy-data:
|
||||
caddy-config:
|
||||
acme-sh-data:
|
||||
|
||||
services:
|
||||
xray:
|
||||
image: teddysun/xray
|
||||
container_name: xray
|
||||
restart: always
|
||||
ports:
|
||||
- '2222:2222'
|
||||
networks:
|
||||
- xray
|
||||
volumes:
|
||||
- ./xray/config.yml:/etc/xray/config.yml
|
||||
- acme-sh-data:/certs
|
||||
command: 'xray -c=/etc/xray/config.yml'
|
||||
|
||||
caddy:
|
||||
image: caddy:2
|
||||
container_name: caddy
|
||||
restart: always
|
||||
ports:
|
||||
- '80:80'
|
||||
networks:
|
||||
- caddy
|
||||
- xray
|
||||
volumes:
|
||||
- $PWD/caddy/Caddyfile:/etc/caddy/Caddyfile
|
||||
- $PWD/site:/srv
|
||||
- caddy-data:/data
|
||||
- caddy-config:/config
|
||||
- acme-sh-data:/certs
|
||||
|
||||
sniproxy:
|
||||
image: tommylau/sniproxy
|
||||
container_name: sniproxy
|
||||
restart: always
|
||||
networks:
|
||||
- caddy
|
||||
- xray
|
||||
ports:
|
||||
- '443:443'
|
||||
volumes:
|
||||
- $PWD/sniproxy:/etc/sniproxy
|
||||
- /var/log/sniproxy:/var/log/sniproxy
|
||||
acme.sh:
|
||||
image: neilpang/acme.sh:dev
|
||||
container_name: acme.sh
|
||||
restart: always
|
||||
volumes:
|
||||
- acme-sh-data:/acme.sh
|
||||
env_file: acme.env
|
||||
command: 'daemon'
|
||||
```
|
||||
|
||||
### SNI Proxy 配置
|
||||
|
||||
文件位于 `sniproxy/sniproxy.conf
|
||||
|
||||
```conf
|
||||
user nobody
|
||||
group nogroup
|
||||
|
||||
# PID file
|
||||
pidfile /var/run/sniproxy.pid
|
||||
|
||||
error_log {
|
||||
# Log to the daemon syslog facility
|
||||
# syslog deamon
|
||||
|
||||
# Alternatively we could log to file
|
||||
filename /var/log/sniproxy/sniproxy.log
|
||||
|
||||
# Control the verbosity of the log
|
||||
priority notice
|
||||
}
|
||||
|
||||
listen 443 {
|
||||
proto tls
|
||||
table https_hosts
|
||||
|
||||
access_log {
|
||||
filename /var/log/sniproxy/https_access.log
|
||||
priority notice
|
||||
}
|
||||
}
|
||||
|
||||
table https_hosts {
|
||||
vmess.example.com caddy:443 # vmess
|
||||
vless.example.com xray:443 # VLESS
|
||||
.*\.example\.com xray:8443 # HTTPS tunnel
|
||||
www.example.com caddy:443 # VPS vhost
|
||||
}
|
||||
```
|
||||
|
||||
### Caddy 配置
|
||||
|
||||
文件位于 `caddy/Caddyfile`。
|
||||
|
||||
```caddyfile
|
||||
www.example.com {
|
||||
encode zstd gzip
|
||||
reverse_proxy some-host:80
|
||||
}
|
||||
|
||||
vmess.example.com {
|
||||
reverse_proxy h2c://xray:80 // vmess
|
||||
}
|
||||
```
|
||||
|
||||
### Xray 配置
|
||||
|
||||
文件位于 `xray/config.yml`
|
||||
|
||||
```yml
|
||||
---
|
||||
inbounds:
|
||||
- tag: vless-xtls.in
|
||||
listen: 0.0.0.0
|
||||
port: 443
|
||||
protocol: vless
|
||||
settings:
|
||||
clients:
|
||||
- id: <your-uuid>
|
||||
flow: xtls-rprx-direct
|
||||
decryption: none
|
||||
streamSettings:
|
||||
network: tcp
|
||||
security: xtls
|
||||
xtlsSettings:
|
||||
serverName: vless.example.com
|
||||
alpn:
|
||||
- h2
|
||||
- http/1.1
|
||||
certificates:
|
||||
- certificateFile: /certs/*.example.com/fullchain.cer
|
||||
keyFile: /certs/*.example.com/*.example.com.key
|
||||
falklbacks:
|
||||
- name: 'vmess.ivanli.cc'
|
||||
dist: 80
|
||||
xver: 1
|
||||
- dest: 'caddy:80'
|
||||
xver: 1
|
||||
- listen: 0.0.0.0
|
||||
port: 80
|
||||
protocol: vmess
|
||||
settings:
|
||||
clients:
|
||||
- id: <your-uuid>
|
||||
decryption: none
|
||||
streamSettings:
|
||||
network: h2
|
||||
security: none
|
||||
httpSettings:
|
||||
path: /
|
||||
host:
|
||||
- vmess.example.com
|
||||
# ... 其他配置 ...
|
||||
```
|
||||
|
||||
关于反向代理及泛域名证书相关配置,参考[使用 Xray、acme.sh、Docker Compose 搭建内网穿透服务](https://ivanli.cc/blog/build-an-frp-using-xray-acme.sh-docker-compose)。
|
||||
|
||||
### 启动服务
|
||||
|
||||
启动服务前,确保你的配置都完成了。之后在 `docker-compose.yml` 同级目录下执行下面的命令:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
如果有防火墙,注意放行端口。
|
||||
|
||||
## 方案浅析
|
||||
|
||||
上述方案主要解决了一个问题,443 端口的复用。使得所有**“HTTPS 流量”**都流向 443 端口,显得非常治愈。因为使用了境外服务器,为了避免被误杀,实现了站点伪装,这样在一定程度上避免被主动探测发现问题。可是在撰写本文前,翻车了,原因可能是客户端指纹特征导致的。因为除了穿透服务的客户端是完全可控的,能通过配置[开启 uTLS 来规避](https://t.ivanli.cc/UFImlX),但是在网关上的 Clash 客户端却没提供相关配置选项。大意了。
|
@ -1,451 +0,0 @@
|
||||
---
|
||||
title: 'New features in v1'
|
||||
date: 2021-08-07T15:32:14Z
|
||||
lastmod: '2021-02-01'
|
||||
tags: ['next-js', 'tailwind', 'guide']
|
||||
draft: false
|
||||
summary: 'An overview of the new features released in v1 - code block copy, multiple authors, frontmatter layout and more'
|
||||
layout: PostSimple
|
||||
bibliography: references-data.bib
|
||||
canonicalUrl: https://tailwind-nextjs-starter-blog.vercel.app/blog/new-features-in-v1/
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
A post on the new features introduced in v1.0. New features:
|
||||
|
||||
<TOCInline toc={props.toc} exclude="Overview" toHeading={2} />
|
||||
|
||||
First load JS decreased from 43kB to 39kB despite all the new features added!^[With the new changes in Nextjs 12, first load JS increase to 45kB.]
|
||||
|
||||
See [upgrade guide](#upgrade-guide) below if you are migrating from v0 version of the template.
|
||||
|
||||
## Theme colors
|
||||
|
||||
You can easily modify the theme color by changing the primary attribute in the tailwind config file:
|
||||
|
||||
```js:tailwind.config.js
|
||||
theme: {
|
||||
colors: {
|
||||
primary: colors.teal,
|
||||
gray: colors.neutral,
|
||||
...
|
||||
}
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
The primary color attribute should be assigned an object with keys from 50, 100, 200 ... 900 and the corresponding color code values.
|
||||
|
||||
Tailwind includes great default color palettes that can be used for theming your own website. Check out [customizing colors documentation page](https://tailwindcss.com/docs/customizing-colors) for the full range of options.
|
||||
|
||||
Migrating from v1? You can revert to the previous theme by setting `primary` to `colors.sky` (Tailwind 2.2.2 and above, otherwise `colors.lightBlue`) and changing gray to `colors.gray`.
|
||||
|
||||
From v1.1.2+, you can also customize the style of your code blocks easily by modifying the `css/prism.css` stylesheet. Token classnames are compatible with prismjs
|
||||
so you can copy and adapt token styles from a prismjs stylesheet e.g. [prism themes](https://github.com/PrismJS/prism-themes).
|
||||
|
||||
## Xdm MDX compiler
|
||||
|
||||
We switched the MDX bundler from [next-mdx-remote](https://github.com/hashicorp/next-mdx-remote) to [mdx-bundler](https://github.com/kentcdodds/mdx-bundler).
|
||||
This uses [xdm](https://github.com/wooorm/xdm) under the hood, the latest micromark 3 and remark, rehype libraries.
|
||||
|
||||
**Warning:** If you were using custom remark or rehype libraries, please upgrade to micromark 3 compatible ones. If you are upgrading, please delete `node_modules` and `package-lock.json` to avoid having past dependencies related issues.
|
||||
|
||||
[xdm](https://github.com/wooorm/xdm) contains multiple improvements over [@mdx-js/mdx](https://github.com/mdx-js/mdx), the compiler used internally by next-mdx-remote, but there might be some breaking behaviour changes.
|
||||
Please check your markdown output to verify.
|
||||
|
||||
Some new possibilities include loading components directly in the mdx file using the import syntax and including js code which could be compiled and bundled at the build step.
|
||||
|
||||
For example, the following jsx snippet can be used directly in an MDX file to render the page title component:
|
||||
|
||||
```jsx
|
||||
import PageTitle from './PageTitle.js'
|
||||
;<PageTitle> Using JSX components in MDX </PageTitle>
|
||||
```
|
||||
|
||||
import PageTitle from './PageTitle.js'
|
||||
|
||||
<PageTitle> Using JSX components in MDX </PageTitle>
|
||||
|
||||
The default configuration resolves all components relative to the `components` directory.
|
||||
|
||||
**Note**:
|
||||
Components which require external image loaders also require additional esbuild configuration.
|
||||
Components which are dependent on global application state on lifecycle like the Nextjs `Link` component would also not work with this setup as each mdx file is built independently.
|
||||
For such cases, it is better to use component substitution.
|
||||
|
||||
## Table of contents component
|
||||
|
||||
Inspired by [Docusaurus](https://docusaurus.io/docs/next/markdown-features/inline-toc) and Gatsby's [gatsby-remark-table-of-contents](https://www.gatsbyjs.com/plugins/gatsby-remark-table-of-contents/),
|
||||
the `toc` variable containing all the top level headings of the document is passed to the MDX file and can be styled accordingly.
|
||||
To make generating a table of contents (TOC) simple, you can use the existing `TOCInline` component.
|
||||
|
||||
For example, the TOC in this post was generated with the following code:
|
||||
|
||||
```jsx
|
||||
<TOCInline toc={props.toc} exclude="Overview" toHeading={2} />
|
||||
```
|
||||
|
||||
You can customise the headings that are displayed by configuring the `fromHeading` and `toHeading` props, or exclude particular headings
|
||||
by passing a string or a string array to the `exclude` prop. By default, all headings that are of depth 3 or smaller are indented. This can be configured by changing the `indentDepth` property.
|
||||
A `asDisclosure` prop can be used to render the TOC within an expandable disclosure element.
|
||||
|
||||
Here's the full TOC rendered in a disclosure element.
|
||||
|
||||
```jsx
|
||||
<TOCInline toc={props.toc} asDisclosure />
|
||||
```
|
||||
|
||||
<TOCInline toc={props.toc} asDisclosure />
|
||||
|
||||
## Layouts
|
||||
|
||||
You can map mdx blog content to layout components by configuring the frontmatter field. For example, this post is written with the new `PostSimple` layout!
|
||||
|
||||
### Adding new templates
|
||||
|
||||
layout templates are stored in the `./layouts` folder. You can add your React components that you want to map to markdown content in this folder.
|
||||
The component file name must match that specified in the markdown frontmatter `layout` field.
|
||||
|
||||
The only required field is `children` which contains the rendered MDX content, though you would probably want to pass in the frontMatter contents and render it in the template.
|
||||
|
||||
You can configure the template to take in other fields - see `PostLayout` component for an example.
|
||||
|
||||
Here's an example layout which you can further customise:
|
||||
|
||||
```jsx
|
||||
export default function ExampleLayout({ frontMatter, children }) {
|
||||
const { date, title } = frontMatter
|
||||
|
||||
return (
|
||||
<SectionContainer>
|
||||
<div>{date}</div>
|
||||
<h1>{title}</h1>
|
||||
<div>{children}</div>
|
||||
</SectionContainer>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Configuring a blog post frontmatter
|
||||
|
||||
Use the `layout` frontmatter field to specify the template you want to map the markdown post to. Here's how the frontmatter of this post looks like:
|
||||
|
||||
```
|
||||
---
|
||||
title: 'New features in v1'
|
||||
date: '2021-05-26 '
|
||||
tags: ['next-js', 'tailwind', 'guide']
|
||||
draft: false
|
||||
summary: 'Introducing the new layout features - you can map mdx blog content to layout components by configuring the frontmatter field'
|
||||
layout: PostSimple
|
||||
---
|
||||
```
|
||||
|
||||
You can configure the default layout in the respective page section by modifying the `DEFAULT_LAYOUT` variable.
|
||||
The `DEFAULT_LAYOUT` for blog posts page is set to `PostLayout`.
|
||||
|
||||
### Extend
|
||||
|
||||
`layout` is mapped to wrapper which wraps the entire MDX content.
|
||||
|
||||
```jsx
|
||||
export const MDXComponents = {
|
||||
Image,
|
||||
a: CustomLink,
|
||||
pre: Pre,
|
||||
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} />
|
||||
}
|
||||
```
|
||||
|
||||
Use the `MDXLayoutRenderer` component on a page where you want to accept a layout name to map to the desired layout.
|
||||
You need to pass the layout name from the layout folder (it has to be an exact match).
|
||||
|
||||
## Analytics
|
||||
|
||||
The template now supports [plausible](https://plausible.io/), [simple analytics](https://simpleanalytics.com/) and google analytics.
|
||||
Configure `siteMetadata.js` with the settings that correspond with the desired analytics provider.
|
||||
|
||||
```js
|
||||
analytics: {
|
||||
// supports plausible, simpleAnalytics or googleAnalytics
|
||||
plausibleDataDomain: '', // e.g. tailwind-nextjs-starter-blog.vercel.app
|
||||
simpleAnalytics: false, // true or false
|
||||
googleAnalyticsId: '', // e.g. UA-000000-2 or G-XXXXXXX
|
||||
},
|
||||
```
|
||||
|
||||
Custom events are also supported. You can import the `logEvent` function from `@components/analytics/[ANALYTICS-PROVIDER]` file and call it when
|
||||
triggering certain events of interest. _Note_: Additional configuration might be required depending on the analytics provider, please check their official
|
||||
documentation for more information.
|
||||
|
||||
## Blog comments system
|
||||
|
||||
We have also added support for [giscus](https://github.com/laymonage/giscus), [utterances](https://github.com/utterance/utterances) or disqus.
|
||||
To enable, simply configure `siteMetadata.js` comments property with the desired provider and settings as specified in the config file.
|
||||
|
||||
```js
|
||||
comment: {
|
||||
// Select a provider and use the environment variables associated to it
|
||||
// https://vercel.com/docs/environment-variables
|
||||
provider: 'giscus', // supported providers: giscus, utterances, disqus
|
||||
giscusConfig: {
|
||||
// Visit the link below, and follow the steps in the 'configuration' section
|
||||
// https://giscus.app/
|
||||
repo: process.env.NEXT_PUBLIC_GISCUS_REPO,
|
||||
repositoryId: process.env.NEXT_PUBLIC_GISCUS_REPOSITORY_ID,
|
||||
category: process.env.NEXT_PUBLIC_GISCUS_CATEGORY,
|
||||
categoryId: process.env.NEXT_PUBLIC_GISCUS_CATEGORY_ID,
|
||||
mapping: 'pathname', // supported options: pathname, url, title
|
||||
reactions: '1', // Emoji reactions: 1 = enable / 0 = disable
|
||||
// Send discussion metadata periodically to the parent window: 1 = enable / 0 = disable
|
||||
metadata: '0',
|
||||
// theme example: light, dark, dark_dimmed, dark_high_contrast
|
||||
// transparent_dark, preferred_color_scheme, custom
|
||||
theme: 'light',
|
||||
// theme when dark mode
|
||||
darkTheme: 'transparent_dark',
|
||||
// If the theme option above is set to 'custom`
|
||||
// please provide a link below to your custom theme css file.
|
||||
// example: https://giscus.app/themes/custom_example.css
|
||||
themeURL: '',
|
||||
},
|
||||
utterancesConfig: {
|
||||
// Visit the link below, and follow the steps in the 'configuration' section
|
||||
// https://utteranc.es/
|
||||
repo: process.env.NEXT_PUBLIC_UTTERANCES_REPO,
|
||||
issueTerm: '', // supported options: pathname, url, title
|
||||
label: '', // label (optional): Comment 💬
|
||||
// theme example: github-light, github-dark, preferred-color-scheme
|
||||
// github-dark-orange, icy-dark, dark-blue, photon-dark, boxy-light
|
||||
theme: '',
|
||||
// theme when dark mode
|
||||
darkTheme: '',
|
||||
},
|
||||
disqus: {
|
||||
// https://help.disqus.com/en/articles/1717111-what-s-a-shortname
|
||||
shortname: process.env.NEXT_PUBLIC_DISQUS_SHORTNAME,
|
||||
},
|
||||
},
|
||||
```
|
||||
|
||||
## Multiple authors
|
||||
|
||||
Information on authors is now split from `siteMetadata.js` and stored in its own `data/authors` folder as a markdown file. Minimally, you will need to have a `default.md` file with authorship information. You can create additional files as required and the file name will be used as the reference to the author.
|
||||
|
||||
Here's how an author markdown file might look like:
|
||||
|
||||
```md:default.md
|
||||
---
|
||||
name: Tails Azimuth
|
||||
avatar: /static/images/avatar.png
|
||||
occupation: Professor of Atmospheric Science
|
||||
company: Stanford University
|
||||
email: address@yoursite.com
|
||||
twitter: https://twitter.com/Twitter
|
||||
linkedin: https://www.linkedin.com
|
||||
github: https://github.com
|
||||
---
|
||||
|
||||
A long description of yourself...
|
||||
```
|
||||
|
||||
You can use this information in multiple places across the template. For example in the about section of the page, we grab the default author information with this line of code:
|
||||
|
||||
```js
|
||||
const authorDetails = await getFileBySlug('authors', ['default'])
|
||||
```
|
||||
|
||||
This is rendered in the `AuthorLayout` template.
|
||||
|
||||
### Multiple authors in blog post
|
||||
|
||||
The frontmatter of a blog post accepts an optional `authors` array field. If no field is specified, it is assumed that the default author is used. Simply pass in an array of authors to render multiple authors associated with a post.
|
||||
|
||||
For example, the following frontmatter will display the authors given by `data/authors/default.md` and `data/authors/sparrowhawk.md`
|
||||
|
||||
```yaml
|
||||
title: 'My first post'
|
||||
date: '2021-01-12'
|
||||
draft: false
|
||||
summary: 'My first post'
|
||||
authors: ['default', 'sparrowhawk']
|
||||
```
|
||||
|
||||
A demo of a multiple authors post is shown in [Introducing Tailwind Nextjs Starter Blog post](/blog/introducing-tailwind-nextjs-starter-blog).
|
||||
|
||||
## Copy button for code blocks
|
||||
|
||||
Hover over a code block and you will notice a GitHub-inspired copy button! You can modify `./components/Pre.js` to further customise it.
|
||||
The component is passed to `MDXComponents` and modifies all `<pre>` blocks.
|
||||
|
||||
## Line highlighting and line numbers
|
||||
|
||||
Line highlighting and line numbers are now supported out of the box thanks to the new [rehype-prism-plus plugin](https://github.com/timlrx/rehype-prism-plus)
|
||||
|
||||
The following javascript code block:
|
||||
|
||||
````
|
||||
```js {1, 3-4} showLineNumbers
|
||||
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
|
||||
```
|
||||
````
|
||||
|
||||
will appear as:
|
||||
|
||||
```js {1,3-4} showLineNumbers
|
||||
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
|
||||
```
|
||||
|
||||
To modify the styles, change the following class selectors in the `prism.css` file:
|
||||
|
||||
```css
|
||||
.code-highlight {
|
||||
@apply float-left min-w-full;
|
||||
}
|
||||
|
||||
.code-line {
|
||||
@apply -mx-4 block border-l-4 border-opacity-0 pl-4 pr-4;
|
||||
}
|
||||
|
||||
.code-line.inserted {
|
||||
@apply bg-green-500 bg-opacity-20;
|
||||
}
|
||||
|
||||
.code-line.deleted {
|
||||
@apply bg-red-500 bg-opacity-20;
|
||||
}
|
||||
|
||||
.highlight-line {
|
||||
@apply -mx-4 border-l-4 border-primary-500 bg-gray-700 bg-opacity-50;
|
||||
}
|
||||
|
||||
.line-number::before {
|
||||
@apply mr-4 -ml-2 inline-block w-4 text-right text-gray-400;
|
||||
content: attr(line);
|
||||
}
|
||||
```
|
||||
|
||||
## Newletter component (v1.1.3)
|
||||
|
||||
Introduced in v1.1.3, the newsletter component gives you an easy way to build an audience. It integrates with the following providers:
|
||||
|
||||
- [Mailchimp](https://mailchimp.com/)
|
||||
- [Buttondown](https://buttondown.email/)
|
||||
- [Convertkit](https://convertkit.com/)
|
||||
|
||||
To use it, specify the provider which you are using in the config file and add the necessary environment variables to the `.env` file.
|
||||
For more information on the required variables, check out `.env.sample.`
|
||||
|
||||
Two components are exported, a default `NewsletterForm` and a `BlogNewsletterForm` component, which is also passed in as an MDX component
|
||||
and can be used in a blog post:
|
||||
|
||||
```jsx
|
||||
<BlogNewsletterForm title="Like what you are reading?" />
|
||||
```
|
||||
|
||||
<BlogNewsletterForm title="Like what you are reading?" />
|
||||
|
||||
The component relies on nextjs's [API routes](https://nextjs.org/docs/api-routes/introduction) which requires a server-side instance of nextjs to be setup
|
||||
and is not compatible with a 100% static site export. Users should either self-host or use a compatible platform like Vercel or Netlify which supports this functionality.
|
||||
|
||||
A static site compatible alternative is to substitute the route in the newsletter component with a form API endpoint provider.
|
||||
|
||||
## Bibliography and Citations (v1.2.1)
|
||||
|
||||
`rehype-citation` plugin is added to the xdm processing pipeline in v1.2.1. This allows you to easily format citations and insert bibliography from an existing bibtex or CSL-json file.
|
||||
|
||||
For example, the following markdown code sample:
|
||||
|
||||
```md
|
||||
Standard citation [@Nash1950]
|
||||
In-text citations e.g. @Nash1951
|
||||
Multiple citations [see @Nash1950; @Nash1951, page 50]
|
||||
|
||||
**References:**
|
||||
|
||||
[^ref]
|
||||
```
|
||||
|
||||
is rendered to the following:
|
||||
|
||||
Standard citation [@Nash1950]
|
||||
In-text citations e.g. @Nash1951
|
||||
Multiple citations [see @Nash1950; @Nash1951, page 50]
|
||||
|
||||
**References:**
|
||||
|
||||
[^ref]
|
||||
|
||||
A bibliography will be inserted at the end of the document, but this can be overwritten by specifying a `[^Ref]` tag at the intended location.
|
||||
The plugin uses APA citation formation, but also supports the following CSLs, 'apa', 'vancouver', 'harvard1', 'chicago', 'mla', or a path to a user-specified CSL file.
|
||||
|
||||
See [rehype-citation readme](https://github.com/timlrx/rehype-citation) for more information on the configuration options.
|
||||
|
||||
## Self-hosted font (v1.5.0)
|
||||
|
||||
Google font has been replaced with self-hosted font from [Fontsource](https://fontsource.org/). This gives the following [advantages](https://fontsource.org/docs/introduction):
|
||||
|
||||
> Self-hosting brings significant performance gains as loading fonts from hosted services, such as Google Fonts, lead to an extra (render blocking) network request. To provide perspective, for simple websites it has been seen to double visual load times.
|
||||
>
|
||||
> Fonts remain version locked. Google often pushes updates to their fonts without notice, which may interfere with your live production projects. Manage your fonts like any other NPM dependency.
|
||||
>
|
||||
> Commit to privacy. Google does track the usage of their fonts and for those who are extremely privacy concerned, self-hosting is an alternative.
|
||||
|
||||
This leads to a smaller font bundle and a 0.1s faster load time ([webpagetest comparison](https://www.webpagetest.org/video/compare.php?tests=220201_AiDcFH_f68a69b758454dd52d8e67493fdef7da,220201_BiDcMC_bf2d53f14483814ba61e794311dfa771)).
|
||||
|
||||
To change the default Inter font:
|
||||
|
||||
1. Install the preferred [font](https://fontsource.org/fonts) - `npm install -save @fontsource/<font-name>`
|
||||
2. Update the import at `pages/_app.js`- `import '@fontsource/<font-name>.css'`
|
||||
3. Update the `fontfamily` property in the tailwind css config file
|
||||
|
||||
## Upgrade guide
|
||||
|
||||
There are significant portions of the code that has been changed from v0 to v1 including support for layouts and a new mdx engine.
|
||||
|
||||
There's also no real reason to change if the previous one serves your needs and it might be easier to copy
|
||||
the component changes you are interested in to your existing blog rather than migrating everything over.
|
||||
|
||||
Nonetheless, if you want to do so and have not changed much of the template, you could clone the new version and copy over the blog post over to the new template.
|
||||
|
||||
Another alternative would be to pull the latest template version with the following code:
|
||||
|
||||
```bash
|
||||
git remote add template git@github.com:timlrx/tailwind-nextjs-starter-blog.git
|
||||
git pull template v1 --allow-unrelated-histories
|
||||
rm -rf node_modules
|
||||
```
|
||||
|
||||
You can see an example of such a migration in this [commit](https://github.com/timlrx/timlrx.com/commit/bba1c185384fd6d5cdaac15abf802fdcff027286) for my personal blog.
|
||||
|
||||
v1 also uses `feed.xml` rather than `index.xml`, to avoid some build issues with Vercel. If you are migrating you should add a redirect to `next.config.js` like so:
|
||||
|
||||
```js
|
||||
async redirects() {
|
||||
return [
|
||||
{
|
||||
source: '/:path/index.xml',
|
||||
destination: '/:path/feed.xml',
|
||||
permanent: true,
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
@ -1,82 +0,0 @@
|
||||
---
|
||||
title: O Canada
|
||||
date: '2017-07-15'
|
||||
tags: ['holiday', 'canada', 'images']
|
||||
draft: false
|
||||
summary: The scenic lands of Canada featuring maple leaves, snow-capped mountains, turquoise lakes and Toronto. Take in the sights in this photo gallery exhibition and see how easy it is to replicate with some MDX magic and tailwind classes.
|
||||
---
|
||||
|
||||
# O Canada
|
||||
|
||||
The scenic lands of Canada featuring maple leaves, snow-capped mountains, turquoise lakes and Toronto. Take in the sights in this photo gallery exhibition and see how easy it is to replicate with some MDX magic and tailwind classes.
|
||||
|
||||
Features images served using `next/image` component. The locally stored images are located in a folder with the following path: `/static/images/canada/[filename].jpg`
|
||||
|
||||
Since we are using mdx, we can create a simple responsive flexbox grid to display our images with a few tailwind css classes.
|
||||
|
||||
---
|
||||
|
||||
# Gallery
|
||||
|
||||
<div className="flex flex-wrap -mx-2 overflow-hidden xl:-mx-2">
|
||||
<div className="my-1 px-2 w-full overflow-hidden xl:my-1 xl:px-2 xl:w-1/2">
|
||||

|
||||
</div>
|
||||
<div className="my-1 px-2 w-full overflow-hidden xl:my-1 xl:px-2 xl:w-1/2">
|
||||

|
||||
</div>
|
||||
<div className="my-1 px-2 w-full overflow-hidden xl:my-1 xl:px-2 xl:w-1/2">
|
||||

|
||||
</div>
|
||||
<div className="my-1 px-2 w-full overflow-hidden xl:my-1 xl:px-2 xl:w-1/2">
|
||||

|
||||
</div>
|
||||
</div>
|
||||
|
||||
# Implementation
|
||||
|
||||
```js
|
||||
<div className="flex flex-wrap -mx-2 overflow-hidden xl:-mx-2">
|
||||
<div className="my-1 px-2 w-full overflow-hidden xl:my-1 xl:px-2 xl:w-1/2">
|
||||

|
||||
</div>
|
||||
<div className="my-1 px-2 w-full overflow-hidden xl:my-1 xl:px-2 xl:w-1/2">
|
||||

|
||||
</div>
|
||||
<div className="my-1 px-2 w-full overflow-hidden xl:my-1 xl:px-2 xl:w-1/2">
|
||||

|
||||
</div>
|
||||
<div className="my-1 px-2 w-full overflow-hidden xl:my-1 xl:px-2 xl:w-1/2">
|
||||

|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
With MDX v2, one can interleave markdown in jsx as shown in the example code.
|
||||
|
||||
### Photo Credits
|
||||
|
||||
<div>
|
||||
Maple photo by [Guillaume
|
||||
Jaillet](https://unsplash.com/@i_am_g?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText)
|
||||
on
|
||||
[Unsplash](https://unsplash.com/s/photos/canada?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText)
|
||||
</div>
|
||||
<div>
|
||||
Mountains photo by [John
|
||||
Lee](https://unsplash.com/@john_artifexfilms?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText)
|
||||
on
|
||||
[Unsplash](https://unsplash.com/s/photos/canada?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText)
|
||||
</div>
|
||||
<div>
|
||||
Lake photo by [Tj
|
||||
Holowaychuk](https://unsplash.com/@tjholowaychuk?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText)
|
||||
on
|
||||
[Unsplash](https://unsplash.com/s/photos/canada?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText)
|
||||
</div>
|
||||
<div>
|
||||
Toronto photo by [Matthew
|
||||
Henry](https://unsplash.com/@matthewhenry?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText)
|
||||
on
|
||||
[Unsplash](https://unsplash.com/s/photos/canada?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText)
|
||||
</div>
|
74
data/blog/react-18-stricter-strict-mode.md
Normal file
74
data/blog/react-18-stricter-strict-mode.md
Normal file
@ -0,0 +1,74 @@
|
||||
---
|
||||
title: React 18,新的严格模式
|
||||
date: '2023-02-06'
|
||||
tags: ['React']
|
||||
draft: false
|
||||
summary: 好好的 useMemo、useEffect 居然执行了两次,我明明传入了依赖,为什么会执行两次呢?原来是 React 18 的破坏性改动!
|
||||
---
|
||||
|
||||
之前在开发模式时,一直记得 `useMemo` 在严格模式下不会二次执行, `useEffect` 在有传入 `deps` 时也不会二次执行。而今天我在排下面这段代码时,发现一个要命的事情!
|
||||
|
||||
```tsx
|
||||
const camera = useMemo(/* .. */, []);
|
||||
const renderer = props; // from parent
|
||||
|
||||
const controls = useMemo(
|
||||
() => new OrbitControls(camera, renderer.domElement),
|
||||
[camera, renderer],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// 同一个 controls 进入两次
|
||||
return () => {
|
||||
controls.dispose(); // 执行一次,controls 被释放
|
||||
};
|
||||
}, [controls]);
|
||||
```
|
||||
|
||||
`controls` 生成了两个,第一个似乎没用到了,第二个是后续要正常使用的对象,并且进入了 `useEffect` 中,而且进去了两次!
|
||||
这导致两个严重的问题就是,第一个 `controls` 没有被销毁,第二个 `controls` 被销毁了!
|
||||
第一个没销毁是内存泄漏,第二个被销毁了导致 `controls` 对象不可用了!
|
||||
|
||||
我一度以为是我对 useEffect 特性的记忆出现了偏差,后来我在官方文档翻了半天没啥收获,指到我看见了更新说明里的 [Stricter Strict Mode](https://github.com/facebook/react/blob/main/CHANGELOG.md#1820-june-14-2022:~:text=Stricter%20Strict%20Mode)。
|
||||
|
||||
## 新的严格模式
|
||||
|
||||
从更新日志可以看到,新的严格模式会自动卸载并再次重新挂载每个组件,这就解释了为什么 `useMemo` 和 `useEffect` 即使在有传入 `deps` 也会多执行一次。
|
||||
|
||||
这个特性是破坏性的,会影响之前版本的程序逻辑,所以 React 在[更新说明](https://github.com/facebook/react/blob/main/CHANGELOG.md#1820-june-14-2022:~:text=If%20this%20breaks%20your%20app%2C%20consider%20removing%20Strict%20Mode%20until%20you%20can%20fix%20the%20components%20to%20be%20resilient%20to%20remounting%20with%20existing%20state.)也建议如果旧的应用因为这个出现兼容性问题,建议先关掉 `strictMode`。
|
||||
|
||||
### 在 React 18 的测试代码
|
||||
|
||||

|
||||
|
||||
代码:[Code Sandbox](https://codesandbox.io/p/sandbox/clever-cache-pm1oct?file=%2Fsrc%2FApp.tsx&selection=%5B%7B%22endColumn%22%3A20%2C%22endLineNumber%22%3A33%2C%22startColumn%22%3A20%2C%22startLineNumber%22%3A33%7D%5D)
|
||||
|
||||
红色是第一次渲染,绿色是第二次渲染。输出日志里淡些的是 React 18 在二次调用时输出的 log 的默认效果,在 React 17 中是被默认隐藏的。蓝色指向的是最终应用在界面上呈现的结果。
|
||||
|
||||
我在 V 站上也提出了我的[疑问](https://v2ex.com/t/913595),根据大佬们的回复,我总结了一下:
|
||||
|
||||
1. `useMemo(() => /* */, [])` 执行一此后,以新的严格模式的规则,进行了二次调用,第一次的值作废。
|
||||
2. `useEffect(() => /* */, [])`执行一此后,以新的严格模式的规则,调用了 `destructor` 后,进行了二次调用。
|
||||
|
||||
在第 2 点中,两次 useEffect 都是使用同一个值,是因为严格模式的二次调用按钩子分别执行两次,所以 useMemo 两次的调用都完毕后,得到的值再被 useEffect 执行两次。我调整了一下代码,将测试代码复制了一份在后面,可以看到 “useMemo” 和 “useMemo 2” 先执行了一次,又再执行了一次,然后再到 “useEffect“ 和 “useEffect 2":
|
||||

|
||||
|
||||
## 结论
|
||||
|
||||
`useEffect` 应该独自管理副作用,要做到自己创建,自己销毁。
|
||||
|
||||
```tsx
|
||||
const camera = useMemo(/* .. */, []);
|
||||
const renderer = props; // from parent
|
||||
|
||||
const [controls, setControls] = useState<OrbitControls | null>(null);
|
||||
useEffect(() => {
|
||||
const controls = new OrbitControls(camera, renderer.domElement) // 自己的锅
|
||||
setControls(controls);
|
||||
return () => {
|
||||
controls.dispose(); // 自己背
|
||||
};
|
||||
}, [camera, renderer]);
|
||||
```
|
||||
|
||||
今天深究了一下这个问题,解决方案其实我也知道,但是之前的写法突然以我不理解的方式失效了,还是要较个劲,万一是 React 不规范呢?(狗头
|
35
data/blog/self-hosted-baas.md
Normal file
35
data/blog/self-hosted-baas.md
Normal file
@ -0,0 +1,35 @@
|
||||
---
|
||||
title: 自部署的 BaaS 服务对比
|
||||
date: '2023-01-24'
|
||||
tags: ['BaaS', 'Self-Hosted', 'appwrite', 'nhost', 'supabase']
|
||||
draft: false
|
||||
summary: supabase、nhost、appwrite 之间的对比,关注自部署方向。
|
||||
---
|
||||
|
||||
BaaS,后端即服务。
|
||||
最近关注 BaaS 自部署主要还是因为自己有一些简单的服务端开发需求,可能就一两个函数的事。
|
||||
如果专门去写一个后端,显得有些铺张了。而且创建一个项目是个麻烦事,我也不愿意从旧项目复制可能已经过时代码进来,所以就想到 FaaS。
|
||||
但是 FaaS 我搜罗了一圈,适合个人自部署的 FaaS 平台少之又少,最后找了个 Serverless 的函数项目 [Trusted CGI](https://trusted-cgi.reddec.net/)([Repo](https://github.com/reddec/trusted-cgi),Go Lang)。
|
||||
这个挺适合个人使用,但是无状态意味着我还得自己搞状态存储,还是不太符合我的需求,所以就看上了 BaaS。
|
||||
|
||||
## BaaS 简要介绍
|
||||
|
||||
目前 BaaS 大概是用户管理、授权、认证,加上数据库设计和存储、对象存储,再加上各类的 event hook 和 push,再加上 Serverless Functions 构成的。这个组件构成非常适合原型、Demo 以及轻量的应用开发。
|
||||
当然,如果后续有性能瓶颈,至少垂直扩展和 functions 层水平扩展是没有任何问题。至于持久层的水平扩展,也能像传统方案处理。
|
||||
|
||||
BaaS 最佳的应用场景就是各类 Apps 的服务端了。包括 Web Apps 在内,主要业务由 Apps 端处理的话,BaaS 就是绝佳的生产力工具。而且服务端的业务逻辑基本上都是写在 Serverless Functions 里,所以根本不需要考虑升级会暂停服务,因为它是无状态的,更新时是能做到零秒重载的。
|
||||
|
||||
更权威的定义可以看[这里](https://www.cloudflare.com/zh-cn/learning/serverless/glossary/backend-as-a-service-baas/)。
|
||||
|
||||
## 对比
|
||||
|
||||
适合生产的环境自然也就是各大平台自有的云 BaaS 平台了,在他们各自的云平台上,你可以享受到完整、轻松的开发体验。但是这是对于企业和专业用途的个人用户,而玩票性质的我就暂时用不上了,所以我的目标就是开源的、可自部署的 BaaS 服务。
|
||||
|
||||
我淘了好久,找到了三个不错的开源项目,分别是 supabase <img alt="Packagist Stars" class="inline" src="https://img.shields.io/packagist/stars/appwrite/appwrite?style=social"/>、appwrite <img alt="Packagist Stars" class="inline" src="https://img.shields.io/packagist/stars/nhost/nhost?style=social"/>、nhost <img alt="Packagist Stars" class="inline" src="https://img.shields.io/packagist/stars/appwrite/appwrite?style=social"/>。他们仨在 Github 上的 Stars 目前是从多到少的。
|
||||
接下来就以自部署的角度来对比下他们三个之间的差异。
|
||||
|
||||
TL; DR, 如果小项目多,推荐使用 appwrite;如果现阶段需要表的关联,建议使用 nhost;supabase 不适合自部署,他没有可以自部署的 serverless functions。
|
||||
|
||||
### Supabase
|
||||
|
||||
Github 上的星星老多了,可以说是目前最火的 BaaS 项目了。他对标的是 Firebase,以 Firebase 的开源版本自居。
|
61
data/blog/sni-reverse-proxy.md
Normal file
61
data/blog/sni-reverse-proxy.md
Normal file
@ -0,0 +1,61 @@
|
||||
---
|
||||
title: 利用 SNI 路由 TLS 连接实现端口复用
|
||||
date: '2022-09-08'
|
||||
tags: ['SNI', 'TLS', 'Reverse Proxy', '反向代理', 'Caddy', 'Xray', 'Vless']
|
||||
draft: false
|
||||
summary: 通过 SNI 反向代理,实现 VLESS 与 Web 站点共享 443 端口。
|
||||
---
|
||||
|
||||
## 前言
|
||||
|
||||
这次的目标是通过 TLS 的 SNI 来实现对 TLS 连接的路由,以实现 SD-WAN 中的 VLESS 连接与 HTTPS 连接复用 443 端口。
|
||||
如此一来,我在外访问家庭网络内的非 Web 服务时,能够很轻松地通过防火墙,因为 443 端口作为 HTTPS 默认端口,并且通过 VLESS 隧道访问的流量特征与 HTTPS 流量特征相同,有效避免被误杀。
|
||||
|
||||
### 试错
|
||||
|
||||
因为苦 Nginx 久矣,所以我近一年来都在用 Caddy 2,而这次也是因为 Caddy 2 生态不够强大,所以绕了一圈。
|
||||
|
||||
#### Caddy 反向代理 Xray
|
||||
|
||||
Caddy 标准版本不提供 TCP 或 TLS 反向代理的能力,但是它有一个非官方的模块支持四层反向代理——[mholt/caddy-l4: Layer 4 (TCP/UDP) app for Caddy](https://github.com/mholt/caddy-l4)。
|
||||
Caddy L4 支持 TCP 和 UDP 反向代理。但是,它目前为止还不支持 Caddyfile 配置,只能通过 JSON 配置。这就不友好了,我也就干脆地放弃这方案了。
|
||||
|
||||
#### Xray 反向代理 Caddy
|
||||
|
||||
Xray 的 VLESS 协议支持[回落](https://xtls.github.io/Xray-docs-next/document/level-1/fallbacks-with-sni.html)功能,能让非 VLESS 连接传递给后备服务。将 Caddy 作为后被服务,则能让 HTTPS 连接交由 Caddy 处理。
|
||||
|
||||
这个方案还行,首先,流量特征与 Caddy 差异小,这得益于都是 Go Lang 开发的程序;其次是性能损耗也可接受。唯一的问题就是,签发的泛域名证书只是 `*.ivanli.cc`,所以 443 端口只能部署 `*.ivanli.cc` 的站点,部署主域或者三级域名之类的站点将会报证书错误。所以这个方案也 pass 掉了。
|
||||
|
||||
### 在 Caddy 和 Xray 之前路由
|
||||
|
||||
使用一个支持 TLS SNI 路由的程序将连接按 SNI 反向代理到 Caddy 或 Xray 中。这个方案没啥局限性,就是浪费了些性能,增加了些延迟。我能接受,毕竟咱有追求不是?
|
||||
|
||||
那接下来就用这个方案来实现,那个路由就由 SNI-Proxy 来实现。
|
||||
|
||||
## 技术栈
|
||||
|
||||
### SNI Proxy
|
||||
|
||||
[dlundquist/sniproxy](https://github.com/dlundquist/sniproxy) 是一款支持对 HTTP 和 TLS 连接按虚拟主机名转发流量的反向代理程序。
|
||||
对于 TLS 连接,它通过 SNI 进行区分并按规则转发到对应的端口,并不需要对 TLS 进行解密。并且它支持 HAProxy 代理协议将原始源地址传递给后端。
|
||||
|
||||
### VLESS
|
||||
|
||||
VLESS 是一种安全高效的数据传输协议,其支持的 xTLS 协议非常适合作为网络隧道传递 TLS 数据,能够极大地降低网关计算资源。
|
||||
|
||||
### Caddy
|
||||
|
||||
一款现代的 HTTP 反向代理程序。
|
||||
|
||||
## 部署步骤
|
||||
|
||||
使用 Docker Compose 部署。
|
||||
|
||||
以下是用到的容器:
|
||||
|
||||
- [caddy - Official Image | Docker Hub](https://registry.hub.docker.com/_/caddy)
|
||||
- [tommylau/sniproxy - Docker Image | Docker Hub](https://registry.hub.docker.com/r/tommylau/sniproxy)
|
||||
- [neilpang/acme.sh - Docker Image | Docker Hub](https://registry.hub.docker.com/r/neilpang/acme.sh)
|
||||
- [gists/xray - Docker Image | Docker Hub](https://registry.hub.docker.com/r/gists/xray)
|
||||
|
||||
部署方式:[利用一台小鸡实现网络自由](./network-freedom-with-vps)
|
@ -1,238 +0,0 @@
|
||||
---
|
||||
title: 'The Time Machine'
|
||||
date: '2018-08-15'
|
||||
tags: ['writings', 'book', 'reflection']
|
||||
draft: false
|
||||
summary: 'The Time Traveller (for so it will be convenient to speak of him) was
|
||||
expounding a recondite matter to us. His pale grey eyes shone and
|
||||
twinkled, and his usually pale face was flushed and animated...'
|
||||
---
|
||||
|
||||
# The Time Machine by H. G. Wells
|
||||
|
||||
_Title_: The Time Machine
|
||||
|
||||
_Author_: H. G. Wells
|
||||
|
||||
_Subject_: Science Fiction
|
||||
|
||||
_Language_: English
|
||||
|
||||
_Source_: [Project Gutenberg](https://www.gutenberg.org/ebooks/35)
|
||||
|
||||
## Introduction
|
||||
|
||||
The Time Traveller (for so it will be convenient to speak of him) was
|
||||
expounding a recondite matter to us. His pale grey eyes shone and
|
||||
twinkled, and his usually pale face was flushed and animated. The fire
|
||||
burnt brightly, and the soft radiance of the incandescent lights in the
|
||||
lilies of silver caught the bubbles that flashed and passed in our
|
||||
glasses. Our chairs, being his patents, embraced and caressed us rather
|
||||
than submitted to be sat upon, and there was that luxurious
|
||||
after-dinner atmosphere, when thought runs gracefully free of the
|
||||
trammels of precision. And he put it to us in this way—marking the
|
||||
points with a lean forefinger—as we sat and lazily admired his
|
||||
earnestness over this new paradox (as we thought it) and his fecundity.
|
||||
|
||||
“You must follow me carefully. I shall have to controvert one or two
|
||||
ideas that are almost universally accepted. The geometry, for instance,
|
||||
they taught you at school is founded on a misconception.”
|
||||
|
||||
“Is not that rather a large thing to expect us to begin upon?” said
|
||||
Filby, an argumentative person with red hair.
|
||||
|
||||
“I do not mean to ask you to accept anything without reasonable ground
|
||||
for it. You will soon admit as much as I need from you. You know of
|
||||
course that a mathematical line, a line of thickness _nil_, has no real
|
||||
existence. They taught you that? Neither has a mathematical plane.
|
||||
These things are mere abstractions.”
|
||||
|
||||
“That is all right,” said the Psychologist.
|
||||
|
||||
“Nor, having only length, breadth, and thickness, can a cube have a
|
||||
real existence.”
|
||||
|
||||
“There I object,” said Filby. “Of course a solid body may exist. All
|
||||
real things—”
|
||||
|
||||
“So most people think. But wait a moment. Can an _instantaneous_ cube
|
||||
exist?”
|
||||
|
||||
“Don’t follow you,” said Filby.
|
||||
|
||||
“Can a cube that does not last for any time at all, have a real
|
||||
existence?”
|
||||
|
||||
Filby became pensive. “Clearly,” the Time Traveller proceeded, “any
|
||||
real body must have extension in _four_ directions: it must have
|
||||
Length, Breadth, Thickness, and—Duration. But through a natural
|
||||
infirmity of the flesh, which I will explain to you in a moment, we
|
||||
incline to overlook this fact. There are really four dimensions, three
|
||||
which we call the three planes of Space, and a fourth, Time. There is,
|
||||
however, a tendency to draw an unreal distinction between the former
|
||||
three dimensions and the latter, because it happens that our
|
||||
consciousness moves intermittently in one direction along the latter
|
||||
from the beginning to the end of our lives.”
|
||||
|
||||
“That,” said a very young man, making spasmodic efforts to relight his
|
||||
cigar over the lamp; “that . . . very clear indeed.”
|
||||
|
||||
“Now, it is very remarkable that this is so extensively overlooked,”
|
||||
continued the Time Traveller, with a slight accession of cheerfulness.
|
||||
“Really this is what is meant by the Fourth Dimension, though some
|
||||
people who talk about the Fourth Dimension do not know they mean it. It
|
||||
is only another way of looking at Time. _There is no difference between
|
||||
Time and any of the three dimensions of Space except that our
|
||||
consciousness moves along it_. But some foolish people have got hold of
|
||||
the wrong side of that idea. You have all heard what they have to say
|
||||
about this Fourth Dimension?”
|
||||
|
||||
“_I_ have not,” said the Provincial Mayor.
|
||||
|
||||
“It is simply this. That Space, as our mathematicians have it, is
|
||||
spoken of as having three dimensions, which one may call Length,
|
||||
Breadth, and Thickness, and is always definable by reference to three
|
||||
planes, each at right angles to the others. But some philosophical
|
||||
people have been asking why _three_ dimensions particularly—why not
|
||||
another direction at right angles to the other three?—and have even
|
||||
tried to construct a Four-Dimensional geometry. Professor Simon Newcomb
|
||||
was expounding this to the New York Mathematical Society only a month
|
||||
or so ago. You know how on a flat surface, which has only two
|
||||
dimensions, we can represent a figure of a three-dimensional solid, and
|
||||
similarly they think that by models of three dimensions they could
|
||||
represent one of four—if they could master the perspective of the
|
||||
thing. See?”
|
||||
|
||||
“I think so,” murmured the Provincial Mayor; and, knitting his brows,
|
||||
he lapsed into an introspective state, his lips moving as one who
|
||||
repeats mystic words. “Yes, I think I see it now,” he said after some
|
||||
time, brightening in a quite transitory manner.
|
||||
|
||||
“Well, I do not mind telling you I have been at work upon this geometry
|
||||
of Four Dimensions for some time. Some of my results are curious. For
|
||||
instance, here is a portrait of a man at eight years old, another at
|
||||
fifteen, another at seventeen, another at twenty-three, and so on. All
|
||||
these are evidently sections, as it were, Three-Dimensional
|
||||
representations of his Four-Dimensioned being, which is a fixed and
|
||||
unalterable thing.
|
||||
|
||||
“Scientific people,” proceeded the Time Traveller, after the pause
|
||||
required for the proper assimilation of this, “know very well that Time
|
||||
is only a kind of Space. Here is a popular scientific diagram, a
|
||||
weather record. This line I trace with my finger shows the movement of
|
||||
the barometer. Yesterday it was so high, yesterday night it fell, then
|
||||
this morning it rose again, and so gently upward to here. Surely the
|
||||
mercury did not trace this line in any of the dimensions of Space
|
||||
generally recognised? But certainly it traced such a line, and that
|
||||
line, therefore, we must conclude, was along the Time-Dimension.”
|
||||
|
||||
“But,” said the Medical Man, staring hard at a coal in the fire, “if
|
||||
Time is really only a fourth dimension of Space, why is it, and why has
|
||||
it always been, regarded as something different? And why cannot we move
|
||||
in Time as we move about in the other dimensions of Space?”
|
||||
|
||||
The Time Traveller smiled. “Are you so sure we can move freely in
|
||||
Space? Right and left we can go, backward and forward freely enough,
|
||||
and men always have done so. I admit we move freely in two dimensions.
|
||||
But how about up and down? Gravitation limits us there.”
|
||||
|
||||
“Not exactly,” said the Medical Man. “There are balloons.”
|
||||
|
||||
“But before the balloons, save for spasmodic jumping and the
|
||||
inequalities of the surface, man had no freedom of vertical movement.”
|
||||
|
||||
“Still they could move a little up and down,” said the Medical Man.
|
||||
|
||||
“Easier, far easier down than up.”
|
||||
|
||||
“And you cannot move at all in Time, you cannot get away from the
|
||||
present moment.”
|
||||
|
||||
“My dear sir, that is just where you are wrong. That is just where the
|
||||
whole world has gone wrong. We are always getting away from the present
|
||||
moment. Our mental existences, which are immaterial and have no
|
||||
dimensions, are passing along the Time-Dimension with a uniform
|
||||
velocity from the cradle to the grave. Just as we should travel _down_
|
||||
if we began our existence fifty miles above the earth’s surface.”
|
||||
|
||||
“But the great difficulty is this,” interrupted the Psychologist. ’You
|
||||
_can_ move about in all directions of Space, but you cannot move about
|
||||
in Time.”
|
||||
|
||||
“That is the germ of my great discovery. But you are wrong to say that
|
||||
we cannot move about in Time. For instance, if I am recalling an
|
||||
incident very vividly I go back to the instant of its occurrence: I
|
||||
become absent-minded, as you say. I jump back for a moment. Of course
|
||||
we have no means of staying back for any length of Time, any more than
|
||||
a savage or an animal has of staying six feet above the ground. But a
|
||||
civilised man is better off than the savage in this respect. He can go
|
||||
up against gravitation in a balloon, and why should he not hope that
|
||||
ultimately he may be able to stop or accelerate his drift along the
|
||||
Time-Dimension, or even turn about and travel the other way?”
|
||||
|
||||
“Oh, _this_,” began Filby, “is all—”
|
||||
|
||||
“Why not?” said the Time Traveller.
|
||||
|
||||
“It’s against reason,” said Filby.
|
||||
|
||||
“What reason?” said the Time Traveller.
|
||||
|
||||
“You can show black is white by argument,” said Filby, “but you will
|
||||
never convince me.”
|
||||
|
||||
“Possibly not,” said the Time Traveller. “But now you begin to see the
|
||||
object of my investigations into the geometry of Four Dimensions. Long
|
||||
ago I had a vague inkling of a machine—”
|
||||
|
||||
“To travel through Time!” exclaimed the Very Young Man.
|
||||
|
||||
“That shall travel indifferently in any direction of Space and Time, as
|
||||
the driver determines.”
|
||||
|
||||
Filby contented himself with laughter.
|
||||
|
||||
“But I have experimental verification,” said the Time Traveller.
|
||||
|
||||
“It would be remarkably convenient for the historian,” the Psychologist
|
||||
suggested. “One might travel back and verify the accepted account of
|
||||
the Battle of Hastings, for instance!”
|
||||
|
||||
“Don’t you think you would attract attention?” said the Medical Man.
|
||||
“Our ancestors had no great tolerance for anachronisms.”
|
||||
|
||||
“One might get one’s Greek from the very lips of Homer and Plato,” the
|
||||
Very Young Man thought.
|
||||
|
||||
“In which case they would certainly plough you for the Little-go. The
|
||||
German scholars have improved Greek so much.”
|
||||
|
||||
“Then there is the future,” said the Very Young Man. “Just think! One
|
||||
might invest all one’s money, leave it to accumulate at interest, and
|
||||
hurry on ahead!”
|
||||
|
||||
“To discover a society,” said I, “erected on a strictly communistic
|
||||
basis.”
|
||||
|
||||
“Of all the wild extravagant theories!” began the Psychologist.
|
||||
|
||||
“Yes, so it seemed to me, and so I never talked of it until—”
|
||||
|
||||
“Experimental verification!” cried I. “You are going to verify _that_?”
|
||||
|
||||
“The experiment!” cried Filby, who was getting brain-weary.
|
||||
|
||||
“Let’s see your experiment anyhow,” said the Psychologist, “though it’s
|
||||
all humbug, you know.”
|
||||
|
||||
The Time Traveller smiled round at us. Then, still smiling faintly, and
|
||||
with his hands deep in his trousers pockets, he walked slowly out of
|
||||
the room, and we heard his slippers shuffling down the long passage to
|
||||
his laboratory.
|
||||
|
||||
The Psychologist looked at us. “I wonder what he’s got?”
|
||||
|
||||
“Some sleight-of-hand trick or other,” said the Medical Man, and Filby
|
||||
tried to tell us about a conjuror he had seen at Burslem, but before he
|
||||
had finished his preface the Time Traveller came back, and Filby’s
|
||||
anecdote collapsed.
|
119
data/blog/thinks-for-2022.md
Normal file
119
data/blog/thinks-for-2022.md
Normal file
@ -0,0 +1,119 @@
|
||||
---
|
||||
title: 再见 2022,你好 2023
|
||||
date: '2022-12-31'
|
||||
tags: ['总结']
|
||||
draft: false
|
||||
summary: 2022 年就要结束了,写个工作与学习的总结,记录下我的 2022 年。
|
||||
---
|
||||
|
||||
二零二二,疫情不出意外地还在我们身边,而我们也早已经习惯了疫情。
|
||||
|
||||
## 电子电路与嵌入式开发
|
||||
|
||||
我一直对电子电路有着兴趣,而今年三月,我突然觉得我行了。购买了仪器和工具设备,开始自学电子电路知识。
|
||||
软件开发一直和数字电路有着密不可分的联系,草草地学习完模拟电路和数字电路后,更加肯定软件和硬件不能独立,而是相辅相成的。
|
||||
而我的第一个目标是构建一个锂电池 UPS,为的是让充当服务器的 J4125 工控机能从容应对突然的断电。
|
||||
项目我拆分了好几部分,一个阶段,一个阶段地完成,向着目标努力。
|
||||
印象比较深的还是第一个部分的实现。第一个部分是实现一个理想二极管。
|
||||
所谓理想二极管就是电流单向流动,并且(几乎)没有普通二极管的压降。
|
||||
还记得我开开心心地在各大电子电路论坛爬楼找思路时,找到了个方案,在电路模拟软件搭建好电路后,运行了模拟,结果挺有效的。
|
||||
我就赶紧画电路板打样了。等待样板寄来的包裹的这几天,我思来想去这电路好像不应该有效啊,换了个模拟软件一跑,坏了,不对。
|
||||
接着就在洞洞板上做实验,果然,效果不对,压降那叫一个大。
|
||||
最后这样改方案,画了第三块板后,终于实现了这一电路。果然实践才是检验真理的唯一途径,三个月从零搞出这么个东西,其实没啥,但是过程确实有趣。
|
||||
就这样,边学边试,软件和硬件都验证完了,过程充满松香味。
|
||||
不过可惜的是,半年后的八月,我换了 AMD 迷你主机,UPS 设计供电已经不满足新主机的要求,项目搁置了。
|
||||
电路的理论论证和各部分的功能验证板已经实现了,外壳也已经设计好了,但是还是没继续往下走,因为当时工作上发生了一些变动,就没余力折腾了。
|
||||
之后又开始折腾屏幕氛围灯,挺有趣的,不过还没做完,目前一周也就花几小时在边学边弄。
|
||||
|
||||
我能想着开始折腾电子电路,还是因为国产单片机的崛起。
|
||||
乐鑫公司的 ESP32 系列单片机真的是物美价廉,白嫖嘉立创的 PCB 也非常的香。
|
||||
学习成本就低了很多,想想去年我入手的树莓派 4B。当时也有考虑折腾物联网,但是一个三四百,也只适合当个上位机干活了。
|
||||
而现在用着 ESP32 模组和开发板,烧了也不心疼,嘿嘿。
|
||||
|
||||
嵌入式开发是一个陌生的领域,C 语言是一个上古的高级语言,而天天写着现代高级语言的我,一时之间回不到大一时写 C 语言的感觉,
|
||||
所以用了一段 C 语言做嵌入式开发后,我看上了 C++。或许是 C++ 没看上我,编译器报错我是一个也看不懂,所以我想起了 Rust。
|
||||
Rust 比着“耶”向我招手,我大意了,就进了这个跟大的坑。
|
||||
|
||||
Rust 语言确实费脑子,因为开发过程中将会被编译器一直教育,非常地严谨。还记得我被引用变量的生命周期教育得死去活来,被结构体没有实现 `Copy`、 `Send`、`Sync` 折磨得痛不欲生。
|
||||
其实本来没这么复杂的,可是我却拿着嵌入式开发和 Rust 一起学,难度不能说陡增吧,只能说是经常看不到明天的太阳。
|
||||
不过我还是磨出来了 UPS 的程序,还行,能用。之后开发屏幕氛围灯就滚回去用 C 语言写了,还别说,嵌入式开发我也学出了点感觉,写起来可亲切了。
|
||||
因为示例多、论坛上的开发者主要也是用 C 写,资料很充足,也没有那群还在 0.0.x 版本的 Rust 库,感觉代码好写多了……
|
||||
但是我贼心不死,拿着 Rust 转眼就配着 Tauri 开始开发氛围灯的上位机程序。
|
||||
不知是没有了 rust-embedded 系的折磨,还是我懂得了 Rust 的脾气,开发得比较顺利,很舒服。
|
||||
|
||||
嵌入式的世界非常的美妙,将虚幻的软件借着硬件能更真实地让我们触碰到。
|
||||
看好 IoT,这是极客们的方向,也是科技改变生活的方向。
|
||||
|
||||
## 元宇宙
|
||||
|
||||
年中因为业务调整,我的工作和元宇宙搭上了边。年中出去嗨皮了一周后,回来就开始学习 Unity 3D。
|
||||
不得不说,国内疫情防控挺好的,走了好几个城市,回来也没阳,哈哈。
|
||||
跑题了,不得不说,Unity 3D 作为入门 3D 游戏开发确实挺合适的,虽然我们当时本想着用 Unity 开发元宇宙项目。
|
||||
正当我在庆幸我还没把 C Sharp 忘光光时,又被要求换成了现在正在使用的 Three.js 作为引擎,也就回到了 Web 领域。
|
||||
接下来的半年,我和我的同事们便开始踩坑之旅。
|
||||
|
||||
因为是全新的领域,我和我的同事经历了 Unity 3D、Lingo 3D、r3f 这三个阶段,踩了许多的坑。
|
||||
不知为什么,他们似乎对游戏开发好像并不觉得是全新的世界,极其低估了所需的知识储备。
|
||||
现在回头看,每次的技术选型其实都不合理。
|
||||
Lingo 3D 作为刚出现的框架,并没有经过市场的检验,也并没有基于该框架商业项目,使用这个框架和二开这个框架并没有什么区别。反对无力,作罢。
|
||||
当我向 Lingo 3D 提了一个 PR 后,同事就抛弃了 Lingo 3D,转向 r3f + BVH 碰撞检测。
|
||||
而接下来,继续遇到了大量的性能问题。
|
||||
|
||||
从项目开发的第一周起,我就有一个 3D 场景渲染性能优化的任务挂着。
|
||||
不过滑稽的是我的开发任务基本上在 WebRTC 相关部分和后端,而前端游戏场景渲染这部分并不是我开发的……
|
||||
虽然处于尴尬的位置,优化是没处优化了,但是问题还是能另起项目去发现和验证。
|
||||
我断断续续地折腾这事,现在回头看看,其实得出的结论挺正确的,但是当时没人懂也没人信,我也是半验证半猜测,没想到正确率还行。
|
||||
|
||||
- 游戏开发确实很考验建模师的素质,调优后的模型性能直接翻倍;
|
||||
- 模型拆分、复用、LOD 是真的有明显的性能提升;
|
||||
- 内存瓶颈是存在的,降低内存占用量能够让程序更稳定
|
||||
|
||||
虽然我对元宇宙没啥兴趣,不过可视化这方向是很有价值的。
|
||||
看好元宇宙的风口,也看好可视化的前景。
|
||||
|
||||
## 自建服务
|
||||
|
||||
八月换了新的迷你主机作为服务器,依然使用移动平台的 CPU,性能和功耗还可以。
|
||||
比之前的机子性能好太多了,当然,满载时也学会芜湖起飞了。
|
||||
我也多部署了几个服务。
|
||||
|
||||
### RSS 阅读器
|
||||
|
||||
现在信息茧房问题挺严重的,所以使用 Miniflux 自建了 RSS 阅读器。目前搜寻了一些个人博客、小众资讯站作为资讯源,感觉挺好的,没有乱七八糟的内容,很舒适。
|
||||
|
||||
### 标签打印机和短网址服务
|
||||
|
||||
另外,买了一个标签打印机,配合短网址服务 Kutt,给我买的一堆电子元件做分类打标签。标签上带了个二维码,里面存了描述元件信息文件的超链接地址。
|
||||
|
||||
### 日志分析服务
|
||||
|
||||
最后,因为部署了太多服务,自建服务又容易挂,所以跑了 Grafana + loki 用作日志分析服务。再配合在云服务器上部署的 Kuma 服务健康监控服务,目前服务状况了然于胸。
|
||||
|
||||
我还购入了国内的 NAT VPS,做内网穿透和 SD-WAN,比使用境外服务器快太多啦,还稳定。
|
||||
公司网络不太稳定,直接组网性能经常断线;
|
||||
使用 VPN 拨入 NAT VPS 后,再由 NAT VPS 与家里组网,就没在掉过线。
|
||||
现在从公网访问我的自建服务是由从境外服务器反向代理的,延迟比较大。
|
||||
目前域名重新备案,希望能套上国内 CDN,这样再由 NAT VPS 反向代理应该能改善国内访问速度。
|
||||
|
||||
## 开发容器与远程开发
|
||||
|
||||
那么,我为什么要组网呢,更大原因还是我想进行远程开发。
|
||||
我在公司使用的是毕业时买的 MacBook Pro 2018 款,性能其实还不错,但是跑测试用例还是有些慢。
|
||||
而且我今年也拥抱了 Dev Container 的开发方式,开发容器能很好地解决开发环境搭建问题。
|
||||
再配合上数据库迁移脚本和数据生成工具,能极大地解决前后端开发时出现的数据污染问题。
|
||||
可谓是 2022 年我最正确的选择了,哈哈哈哈。
|
||||
这个选择是有代价的,那就是每开发一个项目都是有自己的持久化、缓存和应用组件,比较消耗硬件资源。
|
||||
这不就换了个迷你主机嘛,AMD yes!
|
||||
因为在家里,所以就搞起了远程开发,家里网络质量也比较好,拉依赖什么的速度和稳定性比在公司高出了不少,这也极大地改善了我的开发体验。
|
||||
要说这远程开发有啥不好的,那就是怕家里突然断网断电,那就有可能痛失劳动成果了。
|
||||
|
||||
开发容器是个不错的东西,今年看很多开源项目都用上了开发容器,也有很多开源项目还没用上。
|
||||
但很明显,这是未来。我今年在开源社区也算是小小地冒了个泡,无论项目是否有开发容器的配置,我都会拉完项目后在开发容器运行,不用担心弄乱我的电脑,也不用担心环境冲突。
|
||||
而且最重要的,是不用那么担心去年的项目今年怎么也跑不起来。这感觉,经历过的都懂。
|
||||
最后,洁癖万岁~
|
||||
|
||||
比较可惜,VS Code 的 Web 版本还不能支持开发容器,要是支持了,在 iPad 上快乐生产是多么值得的一件事呐。
|
||||
|
||||
## 未来可期
|
||||
|
||||
以上是我在 2022 年的经历,有些是我的计划,有些是命运的安排。贴近底层、满足兴趣,让我接触了嵌入式开发,意外的变动让我接触了 3D 游戏开发;心心念念的 Rust 终于安排上了,为了修自建的服务而改起了好几个 Go 语言项目。回顾过往,我已经摸了好多好多门编程语言了,也换了好多口味的编程风格,也大概摸清了自己向往的方向和风格。希望能够继续用着 TypeScript 和 Rust,快乐地写着后端,玩着前端,搞着偏后端的全栈开发,在 Arch Linux 上维护着服务。我可见不得 Java 和 PHP,希望依旧再也不见。对,还有 Python,我和你不熟,可别过来。
|
7
data/blog/vscode-online.md
Normal file
7
data/blog/vscode-online.md
Normal file
@ -0,0 +1,7 @@
|
||||
---
|
||||
title: 使用 code-server 自部署 Web 版 VS Code
|
||||
date: '2022-12-24'
|
||||
tags: ['Develop', 'VS Code', 'Self Hosted']
|
||||
draft: true
|
||||
summary: 心心念念的在浏览器中写代码,iPad 终于有希望回归生产力了。
|
||||
---
|
@ -1,20 +0,0 @@
|
||||
const projectsData = [
|
||||
// {
|
||||
// title: 'A Search Engine',
|
||||
// description: `What if you could look up any information in the world? Webpages, images, videos
|
||||
// and more. Google has many features to help you find exactly what you're looking
|
||||
// for.`,
|
||||
// imgSrc: '/static/images/google.png',
|
||||
// href: 'https://www.google.com',
|
||||
// },
|
||||
// {
|
||||
// title: 'The Time Machine',
|
||||
// description: `Imagine being able to travel back in time or to the future. Simple turn the knob
|
||||
// to the desired date and press "Go". No more worrying about lost keys or
|
||||
// forgotten headphones with this simple yet affordable solution.`,
|
||||
// imgSrc: '/static/images/time-machine.jpg',
|
||||
// href: '/blog/the-time-machine',
|
||||
// },
|
||||
]
|
||||
|
||||
export default projectsData
|
17
data/projectsData.ts
Normal file
17
data/projectsData.ts
Normal file
@ -0,0 +1,17 @@
|
||||
interface ProjectData {
|
||||
title?: string
|
||||
description?: string
|
||||
imgSrc?: string
|
||||
href?: string
|
||||
}
|
||||
|
||||
const projectsData: ProjectData[] = [
|
||||
{
|
||||
title: 'UPS',
|
||||
description: `一个不间断电源(UPS)的全栈项目。核心硬件使用乐鑫×安信可的 ESP32-C3-32S 模块作为主控,软件部分使用了 Rust + ESP-IDF 开发。`,
|
||||
// imgSrc: '/static/images/google.png',
|
||||
href: 'https://git.ivanli.cc/Ivan/ups-esp32c3-rust',
|
||||
},
|
||||
]
|
||||
|
||||
export default projectsData
|
@ -30,4 +30,4 @@
|
||||
author={Xie, Yihui},
|
||||
year={2016},
|
||||
publisher={CRC Press}
|
||||
}
|
||||
}
|
@ -7,7 +7,7 @@ const siteMetadata = {
|
||||
language: 'zh-CN',
|
||||
theme: 'system', // system, dark or light
|
||||
siteUrl: 'https://ivanli.cc/',
|
||||
siteRepo: 'https://git.ivanli.cc/ivan/taliwind-nextjs-blog',
|
||||
siteRepo: 'https://git.ivanli.cc/ivan/tailwind-nextjs-blog',
|
||||
siteLogo:
|
||||
'https://pan.ivanli.cc/api/v3/file/source/1234/%E5%A4%B4%E5%83%8F.png?sign=xIgy54DyFRYupxjZJbK02HmpKX8C53YR-O0I18Rxm70%3D%3A0',
|
||||
image:
|
||||
@ -33,14 +33,14 @@ const siteMetadata = {
|
||||
newsletter: {
|
||||
// supports mailchimp, buttondown, convertkit, klaviyo, revue, emailoctopus
|
||||
// Please add your .env file and modify it according to your selection
|
||||
provider: 'buttondown',
|
||||
provider: '',
|
||||
},
|
||||
comment: {
|
||||
// If you want to use a commenting system other than giscus you have to add it to the
|
||||
// content security policy in the `next.config.js` file.
|
||||
// Select a provider and use the environment variables associated to it
|
||||
// https://vercel.com/docs/environment-variables
|
||||
provider: 'giscus', // supported providers: giscus, utterances, disqus
|
||||
provider: 'commento', // supported providers: giscus, utterances, disqus
|
||||
giscusConfig: {
|
||||
// Visit the link below, and follow the steps in the 'configuration' section
|
||||
// https://giscus.app/
|
||||
@ -55,10 +55,6 @@ const siteMetadata = {
|
||||
// theme example: light, dark, dark_dimmed, dark_high_contrast
|
||||
// transparent_dark, preferred_color_scheme, custom
|
||||
theme: 'light',
|
||||
// Place the comment box above the comments. options: bottom, top
|
||||
inputPosition: 'bottom',
|
||||
// Choose the language giscus will be displayed in. options: en, es, zh-CN, zh-TW, ko, ja etc
|
||||
lang: 'en',
|
||||
// theme when dark mode
|
||||
darkTheme: 'transparent_dark',
|
||||
// If the theme option above is set to 'custom`
|
||||
@ -82,6 +78,13 @@ const siteMetadata = {
|
||||
// https://help.disqus.com/en/articles/1717111-what-s-a-shortname
|
||||
shortname: process.env.NEXT_PUBLIC_DISQUS_SHORTNAME,
|
||||
},
|
||||
cusdisConfig: {
|
||||
appId: process.env.NEXT_PUBLIC_CUSDIS_APPID,
|
||||
host: process.env.NEXT_PUBLIC_CUSDIS_HOST,
|
||||
},
|
||||
commentoConfig: {
|
||||
url: process.env.NEXT_PUBLIC_COMMENTO_URL,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
@ -1,5 +1,6 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"jsx": "react",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/components/*": ["components/*"],
|
||||
|
@ -1,9 +1,25 @@
|
||||
import SocialIcon from '@/components/social-icons'
|
||||
import Image from '@/components/Image'
|
||||
import { PageSEO } from '@/components/SEO'
|
||||
import SocialIcon from '@/components/social-icons';
|
||||
import Image from '@/components/Image';
|
||||
import { PageSEO } from '@/components/SEO';
|
||||
import { ReactNode } from 'react';
|
||||
import { AuthorFrontMatter } from 'types/AuthorFrontMatter';
|
||||
|
||||
export default function AuthorLayout({ children, frontMatter }) {
|
||||
const { name, avatar, occupation, company, email, twitter, linkedin, github } = frontMatter
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
frontMatter: AuthorFrontMatter;
|
||||
}
|
||||
|
||||
export default function AuthorLayout({ children, frontMatter }: Props) {
|
||||
const {
|
||||
name,
|
||||
avatar,
|
||||
occupation,
|
||||
company,
|
||||
email,
|
||||
twitter,
|
||||
linkedin,
|
||||
github,
|
||||
} = frontMatter;
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -23,7 +39,9 @@ export default function AuthorLayout({ children, frontMatter }) {
|
||||
height="192px"
|
||||
className="h-48 w-48 rounded-full"
|
||||
/>
|
||||
<h3 className="pt-4 pb-2 text-2xl font-bold leading-8 tracking-tight">{name}</h3>
|
||||
<h3 className="pt-4 pb-2 text-2xl font-bold leading-8 tracking-tight">
|
||||
{name}
|
||||
</h3>
|
||||
<div className="text-gray-500 dark:text-gray-400">{occupation}</div>
|
||||
<div className="text-gray-500 dark:text-gray-400">{company}</div>
|
||||
<div className="flex space-x-3 pt-6">
|
||||
@ -33,9 +51,11 @@ export default function AuthorLayout({ children, frontMatter }) {
|
||||
<SocialIcon kind="twitter" href={twitter} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="prose max-w-none pt-8 pb-8 dark:prose-dark xl:col-span-2">{children}</div>
|
||||
<div className="prose max-w-none pt-8 pb-8 dark:prose-dark xl:col-span-2">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
@ -1,20 +1,34 @@
|
||||
import Link from '@/components/Link'
|
||||
import Tag from '@/components/Tag'
|
||||
import siteMetadata from '@/data/siteMetadata'
|
||||
import { useState } from 'react'
|
||||
import Pagination from '@/components/Pagination'
|
||||
import formatDate from '@/lib/utils/formatDate'
|
||||
import Link from '@/components/Link';
|
||||
import Tag from '@/components/Tag';
|
||||
import { ComponentProps, useState } from 'react';
|
||||
import Pagination from '@/components/Pagination';
|
||||
import formatDate from '@/lib/utils/formatDate';
|
||||
import { PostFrontMatter } from 'types/PostFrontMatter';
|
||||
interface Props {
|
||||
posts: PostFrontMatter[];
|
||||
title: string;
|
||||
initialDisplayPosts?: PostFrontMatter[];
|
||||
pagination?: ComponentProps<typeof Pagination>;
|
||||
}
|
||||
|
||||
export default function ListLayout({ posts, title, initialDisplayPosts = [], pagination }) {
|
||||
const [searchValue, setSearchValue] = useState('')
|
||||
export default function ListLayout({
|
||||
posts,
|
||||
title,
|
||||
initialDisplayPosts = [],
|
||||
pagination,
|
||||
}: Props) {
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const filteredBlogPosts = posts.filter((frontMatter) => {
|
||||
const searchContent = frontMatter.title + frontMatter.summary + frontMatter.tags.join(' ')
|
||||
return searchContent.toLowerCase().includes(searchValue.toLowerCase())
|
||||
})
|
||||
const searchContent =
|
||||
frontMatter.title + frontMatter.summary + frontMatter.tags.join(' ');
|
||||
return searchContent.toLowerCase().includes(searchValue.toLowerCase());
|
||||
});
|
||||
|
||||
// If initialDisplayPosts exist, display it if no searchValue is specified
|
||||
const displayPosts =
|
||||
initialDisplayPosts.length > 0 && !searchValue ? initialDisplayPosts : filteredBlogPosts
|
||||
initialDisplayPosts.length > 0 && !searchValue
|
||||
? initialDisplayPosts
|
||||
: filteredBlogPosts;
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -36,8 +50,7 @@ export default function ListLayout({ posts, title, initialDisplayPosts = [], pag
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
stroke="currentColor">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
@ -50,7 +63,7 @@ export default function ListLayout({ posts, title, initialDisplayPosts = [], pag
|
||||
<ul>
|
||||
{!filteredBlogPosts.length && 'No posts found.'}
|
||||
{displayPosts.map((frontMatter) => {
|
||||
const { slug, date, title, summary, tags } = frontMatter
|
||||
const { slug, date, title, summary, tags } = frontMatter;
|
||||
return (
|
||||
<li key={slug} className="py-4">
|
||||
<article className="space-y-2 xl:grid xl:grid-cols-4 xl:items-baseline xl:space-y-0">
|
||||
@ -63,7 +76,9 @@ export default function ListLayout({ posts, title, initialDisplayPosts = [], pag
|
||||
<div className="space-y-3 xl:col-span-3">
|
||||
<div>
|
||||
<h3 className="text-2xl font-bold leading-8 tracking-tight">
|
||||
<Link href={`/blog/${slug}`} className="text-gray-900 dark:text-gray-100">
|
||||
<Link
|
||||
href={`/blog/${slug}`}
|
||||
className="text-gray-900 dark:text-gray-100">
|
||||
{title}
|
||||
</Link>
|
||||
</h3>
|
||||
@ -79,13 +94,16 @@ export default function ListLayout({ posts, title, initialDisplayPosts = [], pag
|
||||
</div>
|
||||
</article>
|
||||
</li>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
{pagination && pagination.totalPages > 1 && !searchValue && (
|
||||
<Pagination currentPage={pagination.currentPage} totalPages={pagination.totalPages} />
|
||||
<Pagination
|
||||
currentPage={pagination.currentPage}
|
||||
totalPages={pagination.totalPages}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
@ -1,23 +1,70 @@
|
||||
import Link from '@/components/Link'
|
||||
import PageTitle from '@/components/PageTitle'
|
||||
import SectionContainer from '@/components/SectionContainer'
|
||||
import { BlogSEO } from '@/components/SEO'
|
||||
import Image from '@/components/Image'
|
||||
import Tag from '@/components/Tag'
|
||||
import siteMetadata from '@/data/siteMetadata'
|
||||
import Comments from '@/components/comments'
|
||||
import ScrollTopAndComment from '@/components/ScrollTopAndComment'
|
||||
import Link from '@/components/Link';
|
||||
import PageTitle from '@/components/PageTitle';
|
||||
import SectionContainer from '@/components/SectionContainer';
|
||||
import { BlogSEO } from '@/components/SEO';
|
||||
import Image from '@/components/Image';
|
||||
import Tag from '@/components/Tag';
|
||||
import siteMetadata from '@/data/siteMetadata';
|
||||
import Comments from '@/components/comments';
|
||||
import ScrollTopAndComment from '@/components/ScrollTopAndComment';
|
||||
import { ReactNode, useMemo } from 'react';
|
||||
import { PostFrontMatter } from 'types/PostFrontMatter';
|
||||
import { AuthorFrontMatter } from 'types/AuthorFrontMatter';
|
||||
|
||||
const editUrl = (fileName) => `${siteMetadata.siteRepo}/blob/master/data/blog/${fileName}`
|
||||
const editUrl = (fileName) =>
|
||||
`${siteMetadata.siteRepo}/raw/master/data/blog/${fileName}`;
|
||||
const discussUrl = (slug) =>
|
||||
`https://mobile.twitter.com/search?q=${encodeURIComponent(
|
||||
`${siteMetadata.siteUrl}/blog/${slug}`
|
||||
)}`
|
||||
)}`;
|
||||
const Copyright = () => (
|
||||
<a
|
||||
rel="license"
|
||||
href="http://creativecommons.org/licenses/by-sa/4.0/"
|
||||
className="inline-flex self-center">
|
||||
<Image
|
||||
className="border-0"
|
||||
alt="知识共享许可协议"
|
||||
width="88"
|
||||
height="15"
|
||||
src="https://i.creativecommons.org/l/by-sa/4.0/80x15.png"
|
||||
/>
|
||||
</a>
|
||||
);
|
||||
|
||||
const postDateTemplate = { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }
|
||||
const postDateTemplate: Intl.DateTimeFormatOptions = {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
};
|
||||
|
||||
export default function PostLayout({ frontMatter, authorDetails, next, prev, children }) {
|
||||
const { slug, fileName, date, title, images, tags } = frontMatter
|
||||
interface Props {
|
||||
frontMatter: PostFrontMatter;
|
||||
authorDetails: AuthorFrontMatter[];
|
||||
next?: { slug: string; title: string };
|
||||
prev?: { slug: string; title: string };
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export default function PostLayout({
|
||||
frontMatter,
|
||||
authorDetails,
|
||||
next,
|
||||
prev,
|
||||
children,
|
||||
}: Props) {
|
||||
const { slug, fileName, date, title, images, tags } = frontMatter;
|
||||
|
||||
const headerStyles = useMemo(
|
||||
() =>
|
||||
images?.[0]
|
||||
? {
|
||||
backgroundImage: `url(${images[0]})`,
|
||||
}
|
||||
: {},
|
||||
[images]
|
||||
);
|
||||
|
||||
return (
|
||||
<SectionContainer>
|
||||
@ -29,14 +76,27 @@ export default function PostLayout({ frontMatter, authorDetails, next, prev, chi
|
||||
<ScrollTopAndComment />
|
||||
<article>
|
||||
<div className="xl:divide-y xl:divide-gray-200 xl:dark:divide-gray-700">
|
||||
<header className="pt-6 xl:pb-6">
|
||||
<div className="space-y-1 text-center">
|
||||
<header className="relative h-48 pt-6 xl:pb-6">
|
||||
{images?.[0] && (
|
||||
<Image
|
||||
alt="background"
|
||||
layout="fill"
|
||||
objectFit="cover"
|
||||
src={images[0]}
|
||||
style={headerStyles}
|
||||
className="blur-xs -z-10 opacity-50 bg-blend-soft-light"
|
||||
/>
|
||||
)}
|
||||
<div className="space-y-5 text-center">
|
||||
<dl className="space-y-10">
|
||||
<div>
|
||||
<dt className="sr-only">Published on</dt>
|
||||
<dd className="text-base font-medium leading-6 text-gray-500 dark:text-gray-400">
|
||||
<time dateTime={date}>
|
||||
{new Date(date).toLocaleDateString(siteMetadata.locale, postDateTemplate)}
|
||||
{new Date(date).toLocaleDateString(
|
||||
siteMetadata.locale,
|
||||
postDateTemplate
|
||||
)}
|
||||
</time>
|
||||
</dd>
|
||||
</div>
|
||||
@ -48,14 +108,15 @@ export default function PostLayout({ frontMatter, authorDetails, next, prev, chi
|
||||
</header>
|
||||
<div
|
||||
className="divide-y divide-gray-200 pb-8 dark:divide-gray-700 xl:grid xl:grid-cols-4 xl:gap-x-6 xl:divide-y-0"
|
||||
style={{ gridTemplateRows: 'auto 1fr' }}
|
||||
>
|
||||
style={{ gridTemplateRows: 'auto 1fr' }}>
|
||||
<dl className="pt-6 pb-10 xl:border-b xl:border-gray-200 xl:pt-11 xl:dark:border-gray-700">
|
||||
<dt className="sr-only">Authors</dt>
|
||||
<dd>
|
||||
<ul className="flex justify-center space-x-8 sm:space-x-12 xl:block xl:space-x-0 xl:space-y-8">
|
||||
{authorDetails.map((author) => (
|
||||
<li className="flex items-center space-x-2" key={author.name}>
|
||||
<li
|
||||
className="flex items-center space-x-2"
|
||||
key={author.name}>
|
||||
{author.avatar && (
|
||||
<Image
|
||||
src={author.avatar}
|
||||
@ -67,15 +128,19 @@ export default function PostLayout({ frontMatter, authorDetails, next, prev, chi
|
||||
)}
|
||||
<dl className="whitespace-nowrap text-sm font-medium leading-5">
|
||||
<dt className="sr-only">Name</dt>
|
||||
<dd className="text-gray-900 dark:text-gray-100">{author.name}</dd>
|
||||
<dd className="text-gray-900 dark:text-gray-100">
|
||||
{author.name}
|
||||
</dd>
|
||||
<dt className="sr-only">Twitter</dt>
|
||||
<dd>
|
||||
{author.twitter && (
|
||||
<Link
|
||||
href={author.twitter}
|
||||
className="text-primary-500 hover:text-primary-600 dark:hover:text-primary-400"
|
||||
>
|
||||
{author.twitter.replace('https://twitter.com/', '@')}
|
||||
className="text-primary-500 hover:text-primary-600 dark:hover:text-primary-400">
|
||||
{author.twitter.replace(
|
||||
'https://twitter.com/',
|
||||
'@'
|
||||
)}
|
||||
</Link>
|
||||
)}
|
||||
</dd>
|
||||
@ -86,13 +151,12 @@ export default function PostLayout({ frontMatter, authorDetails, next, prev, chi
|
||||
</dd>
|
||||
</dl>
|
||||
<div className="divide-y divide-gray-200 dark:divide-gray-700 xl:col-span-3 xl:row-span-2 xl:pb-0">
|
||||
<div className="prose max-w-none pt-10 pb-8 dark:prose-dark">{children}</div>
|
||||
<div className="pt-6 pb-6 text-sm text-gray-700 dark:text-gray-300">
|
||||
<Link href={discussUrl(slug)} rel="nofollow">
|
||||
{'Discuss on Twitter'}
|
||||
</Link>
|
||||
{` • `}
|
||||
<Link href={editUrl(fileName)}>{'View on GitHub'}</Link>
|
||||
<div className="prose max-w-none pt-10 pb-8 dark:prose-dark">
|
||||
{children}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 pt-6 pb-6 text-sm text-gray-700 dark:text-gray-300">
|
||||
<Copyright />
|
||||
<Link href={editUrl(fileName)}>{'View source'}</Link>
|
||||
</div>
|
||||
<Comments frontMatter={frontMatter} />
|
||||
</div>
|
||||
@ -138,8 +202,7 @@ export default function PostLayout({ frontMatter, authorDetails, next, prev, chi
|
||||
<div className="pt-4 xl:pt-8">
|
||||
<Link
|
||||
href="/blog"
|
||||
className="text-primary-500 hover:text-primary-600 dark:hover:text-primary-400"
|
||||
>
|
||||
className="text-primary-500 hover:text-primary-600 dark:hover:text-primary-400">
|
||||
← Back to the blog
|
||||
</Link>
|
||||
</div>
|
||||
@ -148,5 +211,5 @@ export default function PostLayout({ frontMatter, authorDetails, next, prev, chi
|
||||
</div>
|
||||
</article>
|
||||
</SectionContainer>
|
||||
)
|
||||
);
|
||||
}
|
@ -1,18 +1,32 @@
|
||||
import Link from '@/components/Link'
|
||||
import PageTitle from '@/components/PageTitle'
|
||||
import SectionContainer from '@/components/SectionContainer'
|
||||
import { BlogSEO } from '@/components/SEO'
|
||||
import siteMetadata from '@/data/siteMetadata'
|
||||
import formatDate from '@/lib/utils/formatDate'
|
||||
import Comments from '@/components/comments'
|
||||
import ScrollTopAndComment from '@/components/ScrollTopAndComment'
|
||||
import Link from '@/components/Link';
|
||||
import PageTitle from '@/components/PageTitle';
|
||||
import SectionContainer from '@/components/SectionContainer';
|
||||
import { BlogSEO } from '@/components/SEO';
|
||||
import siteMetadata from '@/data/siteMetadata';
|
||||
import formatDate from '@/lib/utils/formatDate';
|
||||
import Comments from '@/components/comments';
|
||||
import ScrollTopAndComment from '@/components/ScrollTopAndComment';
|
||||
import { ReactNode } from 'react';
|
||||
import { PostFrontMatter } from 'types/PostFrontMatter';
|
||||
|
||||
export default function PostLayout({ frontMatter, authorDetails, next, prev, children }) {
|
||||
const { date, title } = frontMatter
|
||||
interface Props {
|
||||
frontMatter: PostFrontMatter;
|
||||
children: ReactNode;
|
||||
next?: { slug: string; title: string };
|
||||
prev?: { slug: string; title: string };
|
||||
}
|
||||
|
||||
export default function PostLayout({
|
||||
frontMatter,
|
||||
next,
|
||||
prev,
|
||||
children,
|
||||
}: Props) {
|
||||
const { slug, date, title } = frontMatter;
|
||||
|
||||
return (
|
||||
<SectionContainer>
|
||||
<BlogSEO url={`${siteMetadata.siteUrl}/blog/${frontMatter.slug}`} {...frontMatter} />
|
||||
<BlogSEO url={`${siteMetadata.siteUrl}/blog/${slug}`} {...frontMatter} />
|
||||
<ScrollTopAndComment />
|
||||
<article>
|
||||
<div>
|
||||
@ -33,10 +47,11 @@ export default function PostLayout({ frontMatter, authorDetails, next, prev, chi
|
||||
</header>
|
||||
<div
|
||||
className="divide-y divide-gray-200 pb-8 dark:divide-gray-700 xl:divide-y-0 "
|
||||
style={{ gridTemplateRows: 'auto 1fr' }}
|
||||
>
|
||||
style={{ gridTemplateRows: 'auto 1fr' }}>
|
||||
<div className="divide-y divide-gray-200 dark:divide-gray-700 xl:col-span-3 xl:row-span-2 xl:pb-0">
|
||||
<div className="prose max-w-none pt-10 pb-8 dark:prose-dark">{children}</div>
|
||||
<div className="prose max-w-none pt-10 pb-8 dark:prose-dark">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
<Comments frontMatter={frontMatter} />
|
||||
<footer>
|
||||
@ -45,8 +60,7 @@ export default function PostLayout({ frontMatter, authorDetails, next, prev, chi
|
||||
<div className="pt-4 xl:pt-8">
|
||||
<Link
|
||||
href={`/blog/${prev.slug}`}
|
||||
className="text-primary-500 hover:text-primary-600 dark:hover:text-primary-400"
|
||||
>
|
||||
className="text-primary-500 hover:text-primary-600 dark:hover:text-primary-400">
|
||||
← {prev.title}
|
||||
</Link>
|
||||
</div>
|
||||
@ -55,8 +69,7 @@ export default function PostLayout({ frontMatter, authorDetails, next, prev, chi
|
||||
<div className="pt-4 xl:pt-8">
|
||||
<Link
|
||||
href={`/blog/${next.slug}`}
|
||||
className="text-primary-500 hover:text-primary-600 dark:hover:text-primary-400"
|
||||
>
|
||||
className="text-primary-500 hover:text-primary-600 dark:hover:text-primary-400">
|
||||
{next.title} →
|
||||
</Link>
|
||||
</div>
|
||||
@ -67,5 +80,5 @@ export default function PostLayout({ frontMatter, authorDetails, next, prev, chi
|
||||
</div>
|
||||
</article>
|
||||
</SectionContainer>
|
||||
)
|
||||
);
|
||||
}
|
@ -1,8 +1,9 @@
|
||||
import { escape } from '@/lib/utils/htmlEscaper'
|
||||
import { escape } from '@/lib/utils/htmlEscaper';
|
||||
|
||||
import siteMetadata from '@/data/siteMetadata'
|
||||
import siteMetadata from '@/data/siteMetadata';
|
||||
import { PostFrontMatter } from 'types/PostFrontMatter';
|
||||
|
||||
const generateRssItem = (post) => `
|
||||
const generateRssItem = (post: PostFrontMatter) => `
|
||||
<item>
|
||||
<guid>${siteMetadata.siteUrl}/blog/${post.slug}</guid>
|
||||
<title>${escape(post.title)}</title>
|
||||
@ -12,21 +13,25 @@ const generateRssItem = (post) => `
|
||||
<author>${siteMetadata.email} (${siteMetadata.author})</author>
|
||||
${post.tags && post.tags.map((t) => `<category>${t}</category>`).join('')}
|
||||
</item>
|
||||
`
|
||||
`;
|
||||
|
||||
const generateRss = (posts, page = 'feed.xml') => `
|
||||
const generateRss = (posts: PostFrontMatter[], page = 'feed.xml') => `
|
||||
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
|
||||
<channel>
|
||||
<title>${escape(siteMetadata.title)}</title>
|
||||
<link>${siteMetadata.siteUrl}/blog</link>
|
||||
<description>${escape(siteMetadata.description)}</description>
|
||||
<language>${siteMetadata.language}</language>
|
||||
<managingEditor>${siteMetadata.email} (${siteMetadata.author})</managingEditor>
|
||||
<managingEditor>${siteMetadata.email} (${
|
||||
siteMetadata.author
|
||||
})</managingEditor>
|
||||
<webMaster>${siteMetadata.email} (${siteMetadata.author})</webMaster>
|
||||
<lastBuildDate>${new Date(posts[0].date).toUTCString()}</lastBuildDate>
|
||||
<atom:link href="${siteMetadata.siteUrl}/${page}" rel="self" type="application/rss+xml"/>
|
||||
<atom:link href="${
|
||||
siteMetadata.siteUrl
|
||||
}/${page}" rel="self" type="application/rss+xml"/>
|
||||
${posts.map(generateRssItem).join('')}
|
||||
</channel>
|
||||
</rss>
|
||||
`
|
||||
export default generateRss
|
||||
`;
|
||||
export default generateRss;
|
136
lib/mdx.js
136
lib/mdx.js
@ -1,136 +0,0 @@
|
||||
import { bundleMDX } from 'mdx-bundler'
|
||||
import fs from 'fs'
|
||||
import matter from 'gray-matter'
|
||||
import path from 'path'
|
||||
import readingTime from 'reading-time'
|
||||
import { visit } from 'unist-util-visit'
|
||||
import getAllFilesRecursively from './utils/files'
|
||||
// Remark packages
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkFootnotes from 'remark-footnotes'
|
||||
import remarkMath from 'remark-math'
|
||||
import remarkExtractFrontmatter from './remark-extract-frontmatter'
|
||||
import remarkCodeTitles from './remark-code-title'
|
||||
import remarkTocHeadings from './remark-toc-headings'
|
||||
import remarkImgToJsx from './remark-img-to-jsx'
|
||||
// Rehype packages
|
||||
import rehypeSlug from 'rehype-slug'
|
||||
import rehypeAutolinkHeadings from 'rehype-autolink-headings'
|
||||
import rehypeKatex from 'rehype-katex'
|
||||
import rehypeCitation from 'rehype-citation'
|
||||
import rehypePrismPlus from 'rehype-prism-plus'
|
||||
import rehypePresetMinify from 'rehype-preset-minify'
|
||||
|
||||
const root = process.cwd()
|
||||
|
||||
export function getFiles(type) {
|
||||
const prefixPaths = path.join(root, 'data', type)
|
||||
const files = getAllFilesRecursively(prefixPaths)
|
||||
// Only want to return blog/path and ignore root, replace is needed to work on Windows
|
||||
return files.map((file) => file.slice(prefixPaths.length + 1).replace(/\\/g, '/'))
|
||||
}
|
||||
|
||||
export function formatSlug(slug) {
|
||||
return slug.replace(/\.(mdx|md)/, '')
|
||||
}
|
||||
|
||||
export function dateSortDesc(a, b) {
|
||||
if (a > b) return -1
|
||||
if (a < b) return 1
|
||||
return 0
|
||||
}
|
||||
|
||||
export async function getFileBySlug(type, slug) {
|
||||
const mdxPath = path.join(root, 'data', type, `${slug}.mdx`)
|
||||
const mdPath = path.join(root, 'data', type, `${slug}.md`)
|
||||
const source = fs.existsSync(mdxPath)
|
||||
? fs.readFileSync(mdxPath, 'utf8')
|
||||
: fs.readFileSync(mdPath, 'utf8')
|
||||
|
||||
// https://github.com/kentcdodds/mdx-bundler#nextjs-esbuild-enoent
|
||||
if (process.platform === 'win32') {
|
||||
process.env.ESBUILD_BINARY_PATH = path.join(root, 'node_modules', 'esbuild', 'esbuild.exe')
|
||||
} else {
|
||||
process.env.ESBUILD_BINARY_PATH = path.join(root, 'node_modules', 'esbuild', 'bin', 'esbuild')
|
||||
}
|
||||
|
||||
let toc = []
|
||||
|
||||
const { code, frontmatter } = await bundleMDX({
|
||||
source,
|
||||
// mdx imports can be automatically source from the components directory
|
||||
cwd: path.join(root, 'components'),
|
||||
xdmOptions(options, frontmatter) {
|
||||
// this is the recommended way to add custom remark/rehype plugins:
|
||||
// The syntax might look weird, but it protects you in case we add/remove
|
||||
// plugins in the future.
|
||||
options.remarkPlugins = [
|
||||
...(options.remarkPlugins ?? []),
|
||||
remarkExtractFrontmatter,
|
||||
[remarkTocHeadings, { exportRef: toc }],
|
||||
remarkGfm,
|
||||
remarkCodeTitles,
|
||||
[remarkFootnotes, { inlineNotes: true }],
|
||||
remarkMath,
|
||||
remarkImgToJsx,
|
||||
]
|
||||
options.rehypePlugins = [
|
||||
...(options.rehypePlugins ?? []),
|
||||
rehypeSlug,
|
||||
rehypeAutolinkHeadings,
|
||||
rehypeKatex,
|
||||
[rehypeCitation, { path: path.join(root, 'data') }],
|
||||
[rehypePrismPlus, { ignoreMissing: true }],
|
||||
rehypePresetMinify,
|
||||
]
|
||||
return options
|
||||
},
|
||||
esbuildOptions: (options) => {
|
||||
options.loader = {
|
||||
...options.loader,
|
||||
'.js': 'jsx',
|
||||
}
|
||||
return options
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
mdxSource: code,
|
||||
toc,
|
||||
frontMatter: {
|
||||
readingTime: readingTime(code),
|
||||
slug: slug || null,
|
||||
fileName: fs.existsSync(mdxPath) ? `${slug}.mdx` : `${slug}.md`,
|
||||
...frontmatter,
|
||||
date: frontmatter.date ? new Date(frontmatter.date).toISOString() : null,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAllFilesFrontMatter(folder) {
|
||||
const prefixPaths = path.join(root, 'data', folder)
|
||||
|
||||
const files = getAllFilesRecursively(prefixPaths)
|
||||
|
||||
const allFrontMatter = []
|
||||
|
||||
files.forEach((file) => {
|
||||
// Replace is needed to work on Windows
|
||||
const fileName = file.slice(prefixPaths.length + 1).replace(/\\/g, '/')
|
||||
// Remove Unexpected File
|
||||
if (path.extname(fileName) !== '.md' && path.extname(fileName) !== '.mdx') {
|
||||
return
|
||||
}
|
||||
const source = fs.readFileSync(file, 'utf8')
|
||||
const { data: frontmatter } = matter(source)
|
||||
if (frontmatter.draft !== true) {
|
||||
allFrontMatter.push({
|
||||
...frontmatter,
|
||||
slug: formatSlug(fileName),
|
||||
date: frontmatter.date ? new Date(frontmatter.date).toISOString() : null,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return allFrontMatter.sort((a, b) => dateSortDesc(a.date, b.date))
|
||||
}
|
157
lib/mdx.ts
Normal file
157
lib/mdx.ts
Normal file
@ -0,0 +1,157 @@
|
||||
import { bundleMDX } from 'mdx-bundler';
|
||||
import fs from 'fs';
|
||||
import matter from 'gray-matter';
|
||||
import path from 'path';
|
||||
import readingTime from 'reading-time';
|
||||
import getAllFilesRecursively from './utils/files';
|
||||
import { PostFrontMatter } from 'types/PostFrontMatter';
|
||||
import { AuthorFrontMatter } from 'types/AuthorFrontMatter';
|
||||
import { Toc } from 'types/Toc';
|
||||
// Remark packages
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import remarkFootnotes from 'remark-footnotes';
|
||||
import remarkMath from 'remark-math';
|
||||
import remarkExtractFrontmatter from './remark-extract-frontmatter';
|
||||
import remarkCodeTitles from './remark-code-title';
|
||||
import remarkTocHeadings from './remark-toc-headings';
|
||||
import remarkImgToJsx from './remark-img-to-jsx';
|
||||
// Rehype packages
|
||||
import rehypeSlug from 'rehype-slug';
|
||||
import rehypeAutolinkHeadings from 'rehype-autolink-headings';
|
||||
import rehypeKatex from 'rehype-katex';
|
||||
import rehypeCitation from 'rehype-citation';
|
||||
import rehypePrismPlus from 'rehype-prism-plus';
|
||||
import rehypePresetMinify from 'rehype-preset-minify';
|
||||
|
||||
const root = process.cwd();
|
||||
|
||||
export function getFiles(type: 'blog' | 'authors') {
|
||||
const prefixPaths = path.join(root, 'data', type);
|
||||
const files = getAllFilesRecursively(prefixPaths);
|
||||
// Only want to return blog/path and ignore root, replace is needed to work on Windows
|
||||
return files.map((file) =>
|
||||
file.slice(prefixPaths.length + 1).replace(/\\/g, '/')
|
||||
);
|
||||
}
|
||||
|
||||
export function formatSlug(slug: string) {
|
||||
return slug.replace(/\.(mdx|md)/, '');
|
||||
}
|
||||
|
||||
export function dateSortDesc(a: string, b: string) {
|
||||
if (a > b) return -1;
|
||||
if (a < b) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function getFileBySlug<T>(
|
||||
type: 'authors' | 'blog',
|
||||
slug: string | string[]
|
||||
) {
|
||||
const mdxPath = path.join(root, 'data', type, `${slug}.mdx`);
|
||||
const mdPath = path.join(root, 'data', type, `${slug}.md`);
|
||||
const source = fs.existsSync(mdxPath)
|
||||
? fs.readFileSync(mdxPath, 'utf8')
|
||||
: fs.readFileSync(mdPath, 'utf8');
|
||||
|
||||
// https://github.com/kentcdodds/mdx-bundler#nextjs-esbuild-enoent
|
||||
if (process.platform === 'win32') {
|
||||
process.env.ESBUILD_BINARY_PATH = path.join(
|
||||
root,
|
||||
'node_modules',
|
||||
'esbuild',
|
||||
'esbuild.exe'
|
||||
);
|
||||
} else {
|
||||
process.env.ESBUILD_BINARY_PATH = path.join(
|
||||
root,
|
||||
'node_modules',
|
||||
'esbuild',
|
||||
'bin',
|
||||
'esbuild'
|
||||
);
|
||||
}
|
||||
|
||||
const toc: Toc = [];
|
||||
|
||||
const { code, frontmatter } = await bundleMDX({
|
||||
source,
|
||||
// mdx imports can be automatically source from the components directory
|
||||
cwd: path.join(root, 'components'),
|
||||
xdmOptions(options, frontmatter) {
|
||||
// this is the recommended way to add custom remark/rehype plugins:
|
||||
// The syntax might look weird, but it protects you in case we add/remove
|
||||
// plugins in the future.
|
||||
options.remarkPlugins = [
|
||||
...(options.remarkPlugins ?? []),
|
||||
remarkExtractFrontmatter,
|
||||
[remarkTocHeadings, { exportRef: toc }],
|
||||
remarkGfm,
|
||||
remarkCodeTitles,
|
||||
[remarkFootnotes, { inlineNotes: true }],
|
||||
remarkMath,
|
||||
remarkImgToJsx,
|
||||
];
|
||||
options.rehypePlugins = [
|
||||
...(options.rehypePlugins ?? []),
|
||||
rehypeSlug,
|
||||
rehypeAutolinkHeadings,
|
||||
rehypeKatex,
|
||||
[rehypeCitation, { path: path.join(root, 'data') }],
|
||||
[rehypePrismPlus, { ignoreMissing: true }],
|
||||
rehypePresetMinify,
|
||||
];
|
||||
return options;
|
||||
},
|
||||
esbuildOptions: (options) => {
|
||||
options.loader = {
|
||||
...options.loader,
|
||||
'.js': 'jsx',
|
||||
};
|
||||
return options;
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
mdxSource: code,
|
||||
toc,
|
||||
frontMatter: {
|
||||
readingTime: readingTime(code),
|
||||
slug: slug || null,
|
||||
fileName: fs.existsSync(mdxPath) ? `${slug}.mdx` : `${slug}.md`,
|
||||
...frontmatter,
|
||||
date: frontmatter.date ? new Date(frontmatter.date).toISOString() : null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function getAllFilesFrontMatter(folder: 'blog') {
|
||||
const prefixPaths = path.join(root, 'data', folder);
|
||||
|
||||
const files = getAllFilesRecursively(prefixPaths);
|
||||
|
||||
const allFrontMatter: PostFrontMatter[] = [];
|
||||
|
||||
files.forEach((file: string) => {
|
||||
// Replace is needed to work on Windows
|
||||
const fileName = file.slice(prefixPaths.length + 1).replace(/\\/g, '/');
|
||||
// Remove Unexpected File
|
||||
if (path.extname(fileName) !== '.md' && path.extname(fileName) !== '.mdx') {
|
||||
return;
|
||||
}
|
||||
const source = fs.readFileSync(file, 'utf8');
|
||||
const matterFile = matter(source);
|
||||
const frontmatter = matterFile.data as AuthorFrontMatter | PostFrontMatter;
|
||||
if ('draft' in frontmatter && frontmatter.draft !== true) {
|
||||
allFrontMatter.push({
|
||||
...frontmatter,
|
||||
slug: formatSlug(fileName),
|
||||
date: frontmatter.date
|
||||
? new Date(frontmatter.date).toISOString()
|
||||
: null,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return allFrontMatter.sort((a, b) => dateSortDesc(a.date, b.date));
|
||||
}
|
@ -1,32 +0,0 @@
|
||||
import { visit } from 'unist-util-visit'
|
||||
|
||||
export default function remarkCodeTitles() {
|
||||
return (tree) =>
|
||||
visit(tree, 'code', (node, index, parent) => {
|
||||
const nodeLang = node.lang || ''
|
||||
let language = ''
|
||||
let title = ''
|
||||
|
||||
if (nodeLang.includes(':')) {
|
||||
language = nodeLang.slice(0, nodeLang.search(':'))
|
||||
title = nodeLang.slice(nodeLang.search(':') + 1, nodeLang.length)
|
||||
}
|
||||
|
||||
if (!title) {
|
||||
return
|
||||
}
|
||||
|
||||
const className = 'remark-code-title'
|
||||
|
||||
const titleNode = {
|
||||
type: 'mdxJsxFlowElement',
|
||||
name: 'div',
|
||||
attributes: [{ type: 'mdxJsxAttribute', name: 'className', value: className }],
|
||||
children: [{ type: 'text', value: title }],
|
||||
data: { _xdmExplicitJsx: true },
|
||||
}
|
||||
|
||||
parent.children.splice(index, 0, titleNode)
|
||||
node.lang = language
|
||||
})
|
||||
}
|
38
lib/remark-code-title.ts
Normal file
38
lib/remark-code-title.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import { visit, Parent } from 'unist-util-visit';
|
||||
|
||||
export default function remarkCodeTitles() {
|
||||
return (tree: Parent & { lang?: string }) =>
|
||||
visit(
|
||||
tree,
|
||||
'code',
|
||||
(node: Parent & { lang?: string }, index, parent: Parent) => {
|
||||
const nodeLang = node.lang || '';
|
||||
let language = '';
|
||||
let title = '';
|
||||
|
||||
if (nodeLang.includes(':')) {
|
||||
language = nodeLang.slice(0, nodeLang.search(':'));
|
||||
title = nodeLang.slice(nodeLang.search(':') + 1, nodeLang.length);
|
||||
}
|
||||
|
||||
if (!title) {
|
||||
return;
|
||||
}
|
||||
|
||||
const className = 'remark-code-title';
|
||||
|
||||
const titleNode = {
|
||||
type: 'mdxJsxFlowElement',
|
||||
name: 'div',
|
||||
attributes: [
|
||||
{ type: 'mdxJsxAttribute', name: 'className', value: className },
|
||||
],
|
||||
children: [{ type: 'text', value: title }],
|
||||
data: { _xdmExplicitJsx: true },
|
||||
};
|
||||
|
||||
parent.children.splice(index, 0, titleNode);
|
||||
node.lang = language;
|
||||
}
|
||||
);
|
||||
}
|
@ -1,10 +0,0 @@
|
||||
import { visit } from 'unist-util-visit'
|
||||
import { load } from 'js-yaml'
|
||||
|
||||
export default function extractFrontmatter() {
|
||||
return (tree, file) => {
|
||||
visit(tree, 'yaml', (node, index, parent) => {
|
||||
file.data.frontmatter = load(node.value)
|
||||
})
|
||||
}
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user