Compare commits
74 Commits
11b9017a07
...
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 |
@@ -11,4 +11,4 @@ FROM mcr.microsoft.com/vscode/devcontainers/typescript-node:0-${VARIANT}
|
||||
# RUN su node -c "source /usr/local/share/nvm/nvm.sh && nvm install ${EXTRA_NODE_VERSION}"
|
||||
|
||||
# [Optional] Uncomment if you want to install more global node packages
|
||||
# RUN su node -c "npm install -g pnpm"
|
||||
RUN su node -c "npm install -g pnpm"
|
||||
|
@@ -11,7 +11,6 @@
|
||||
"VARIANT": "16-bullseye"
|
||||
}
|
||||
},
|
||||
|
||||
// Configure tool-specific properties.
|
||||
"customizations": {
|
||||
// Configure properties specific to VS Code.
|
||||
@@ -40,21 +39,17 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
// 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"
|
||||
"git-lfs": "latest",
|
||||
"ghcr.io/devcontainers/features/docker-in-docker:2": {}
|
||||
},
|
||||
"mounts": [
|
||||
"source=${localEnv:HOME}${localEnv:USERPROFILE}/.ssh,target=/home/node/.ssh,type=bind,consistency=cached"
|
||||
],
|
||||
"postStartCommand": "npm ci && npm run dev"
|
||||
"mounts": [],
|
||||
"postStartCommand": "pnpm install && npm run dev"
|
||||
}
|
||||
|
@@ -36,3 +36,5 @@ yarn-error.log*
|
||||
.env.production.local
|
||||
|
||||
secrets.txt
|
||||
|
||||
.pnpm-store
|
87
.drone.yml
87
.drone.yml
@@ -1,6 +1,6 @@
|
||||
---
|
||||
kind: pipeline
|
||||
name: base
|
||||
name: deps
|
||||
type: docker
|
||||
|
||||
steps:
|
||||
@@ -12,11 +12,11 @@ steps:
|
||||
from_secret: ivan-docker-username
|
||||
password:
|
||||
from_secret: ivan-docker-password
|
||||
repo: docker-registry.ivanli.cc/ivan/tailwind-nextjs-blog-base
|
||||
repo: docker-registry.ivanli.cc/ivan/tailwind-nextjs-blog-deps
|
||||
cache_from:
|
||||
- docker-registry.ivanli.cc/ivan/tailwind-nextjs-blog-base:${DRONE_BRANCH}${DRONE_TAG}-amd64
|
||||
- docker-registry.ivanli.cc/ivan/tailwind-nextjs-blog-deps:${DRONE_BRANCH}${DRONE_TAG}-amd64
|
||||
dockerfile: Dockerfile
|
||||
target: base
|
||||
target: deps
|
||||
tags:
|
||||
- '${DRONE_COMMIT_SHA:0:8}-amd64'
|
||||
- '${DRONE_BRANCH}${DRONE_TAG}-amd64'
|
||||
@@ -57,7 +57,7 @@ kind: pipeline
|
||||
name: linux-amd64
|
||||
type: docker
|
||||
depends_on:
|
||||
- base
|
||||
- deps
|
||||
|
||||
steps:
|
||||
- name: build&publish
|
||||
@@ -72,7 +72,7 @@ steps:
|
||||
dockerfile: Dockerfile
|
||||
target: release
|
||||
cache_from:
|
||||
- docker-registry.ivanli.cc/ivan/tailwind-nextjs-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}'
|
||||
@@ -119,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:
|
||||
@@ -170,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=
|
||||
|
2
.gitignore
vendored
2
.gitignore
vendored
@@ -36,3 +36,5 @@ yarn-error.log*
|
||||
.env.production.local
|
||||
|
||||
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,
|
||||
};
|
18
.vscode/settings.json
vendored
18
.vscode/settings.json
vendored
@@ -1,12 +1,30 @@
|
||||
{
|
||||
"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"
|
||||
|
33
Dockerfile
33
Dockerfile
@@ -1,14 +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 package-lock.json package.json ./
|
||||
RUN npm ci --no-audit
|
||||
COPY pnpm-lock.yaml package.json ./
|
||||
RUN pnpm i
|
||||
|
||||
FROM deps as build
|
||||
WORKDIR /app
|
||||
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'
|
147
README.md
147
README.md
@@ -3,135 +3,14 @@
|
||||
# Ivan Li's Blog
|
||||
|
||||
[](https://ci.ivanli.cc/Ivan/tailwind-nextjs-blog)
|
||||
[](https://ivanli.cc)
|
||||
|
||||
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
|
||||
@@ -139,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.
|
||||
@@ -228,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" rel="nofollow">
|
||||
<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;
|
@@ -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
|
||||
|
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
|
||||
```
|
@@ -22,6 +22,11 @@ Arch Linux 准入门槛确实有点高,在 PVE 中,使用 LCX 容器运行 A
|
||||
|
||||
位置(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
|
||||
@@ -69,6 +74,13 @@ nano /etc/pacman.d/mirrorlist
|
||||
```
|
||||
|
||||
选择你喜欢并且方便连接的镜像,然后删除该行的“#”取消注释。可以选择一个或多个,在前面的优先级高。
|
||||
|
||||
开启并行下载,在 `/etc/pacman.conf` 中取消 `ParallelDownloads` 前的注释,值为并行下载数:
|
||||
|
||||
```bash
|
||||
sed -i "s/#ParallelDownloads = 5/ParallelDownloads = 5/" /etc/pacman.conf
|
||||
```
|
||||
|
||||
接下来我们更新已安装的软件,我们的哲学就是时刻保持最新。
|
||||
|
||||
```bash
|
||||
@@ -98,6 +110,12 @@ _参考:[Cant Upgrade because of keyring - Technical Issues and Assistance / P
|
||||
|
||||
### 3. 创建用户
|
||||
|
||||
首先,安装 `sudo`:
|
||||
|
||||
```bash
|
||||
pacman -S sudo
|
||||
```
|
||||
|
||||
让我们给自己分配一个具有 sudo 权限的账户
|
||||
|
||||
```zsh
|
||||
@@ -113,12 +131,6 @@ _参考:[Create a Sudo User on Arch Linux - Vultr.com](https://www.vultr.com/d
|
||||
EDITOR=vim visudo
|
||||
```
|
||||
|
||||
安装 `sudo`:
|
||||
|
||||
```bash
|
||||
pacman -S sudo
|
||||
```
|
||||
|
||||
接下来使用刚刚创建的用户登录吧!
|
||||
|
||||
### 4. 使用 SSH 远程登录
|
||||
|
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
|
||||
```
|
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 系统了。后面有机会的话,我得把这些配置脚本化,不然天天配也是有点蠢,哈哈。
|
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,8 +1,19 @@
|
||||
---
|
||||
title: 利用一台小鸡实现网络自由
|
||||
date: '2022-10-06'
|
||||
tags: ['SNI', 'TLS', 'Reverse Proxy', '反向代理', '正向代理', ‘内网穿透', 'Caddy', 'Xray', 'Vless']
|
||||
draft: false
|
||||
tags:
|
||||
[
|
||||
'SNI',
|
||||
'TLS',
|
||||
'Reverse Proxy',
|
||||
'反向代理',
|
||||
'正向代理',
|
||||
‘内网穿透',
|
||||
'Caddy',
|
||||
'Xray',
|
||||
'Vless',
|
||||
]
|
||||
draft: true
|
||||
summary: SNI Proxy 进行 TLS 分流;Caddy 对网站和 Xray 进行反向代理;Xray 实现正向、反向代理(内网穿透)。
|
||||
---
|
||||
|
||||
|
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 的开源版本自居。
|
@@ -8,7 +8,7 @@ summary: 通过 SNI 反向代理,实现 VLESS 与 Web 站点共享 443 端口
|
||||
|
||||
## 前言
|
||||
|
||||
这次的目标是通过 TLS 的 SNI 来实现对 TLS 连接的路由,以实现 SD-LAN 中的 VLESS 连接与 HTTPS 连接复用 443 端口。
|
||||
这次的目标是通过 TLS 的 SNI 来实现对 TLS 连接的路由,以实现 SD-WAN 中的 VLESS 连接与 HTTPS 连接复用 443 端口。
|
||||
如此一来,我在外访问家庭网络内的非 Web 服务时,能够很轻松地通过防火墙,因为 443 端口作为 HTTPS 默认端口,并且通过 VLESS 隧道访问的流量特征与 HTTPS 流量特征相同,有效避免被误杀。
|
||||
|
||||
### 试错
|
||||
|
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,18 +0,0 @@
|
||||
const projectsData = [
|
||||
{
|
||||
title: 'UPS',
|
||||
description: `一个不间断电源(UPS)的全栈项目。核心硬件使用乐鑫×安信可的 ESP32-C3-32S 模块作为主控,软件部分使用了 Rust + ESP-IDF 开发。`,
|
||||
// imgSrc: '/static/images/google.png',
|
||||
href: 'https://git.ivanli.cc/Ivan/ups-esp32c3-rust',
|
||||
},
|
||||
// {
|
||||
// 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
|
@@ -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,25 +1,27 @@
|
||||
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 { useMemo } from 'react'
|
||||
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}/raw/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"
|
||||
>
|
||||
className="inline-flex self-center">
|
||||
<Image
|
||||
className="border-0"
|
||||
alt="知识共享许可协议"
|
||||
@@ -28,12 +30,31 @@ const Copyright = () => (
|
||||
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(
|
||||
() =>
|
||||
@@ -43,7 +64,7 @@ export default function PostLayout({ frontMatter, authorDetails, next, prev, chi
|
||||
}
|
||||
: {},
|
||||
[images]
|
||||
)
|
||||
);
|
||||
|
||||
return (
|
||||
<SectionContainer>
|
||||
@@ -72,7 +93,10 @@ export default function PostLayout({ frontMatter, authorDetails, next, prev, chi
|
||||
<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>
|
||||
@@ -84,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}
|
||||
@@ -103,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>
|
||||
@@ -122,10 +151,11 @@ 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="flex items-center pt-6 pb-6 text-sm text-gray-700 dark:text-gray-300">
|
||||
<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} />
|
||||
@@ -172,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>
|
||||
@@ -182,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)
|
||||
})
|
||||
}
|
||||
}
|
12
lib/remark-extract-frontmatter.ts
Normal file
12
lib/remark-extract-frontmatter.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { VFile } from 'vfile';
|
||||
import { visit, Parent } from 'unist-util-visit';
|
||||
import { load } from 'js-yaml';
|
||||
|
||||
export default function extractFrontmatter() {
|
||||
return (tree: Parent, file: VFile) => {
|
||||
visit(tree, 'yaml', (node: Parent) => {
|
||||
//@ts-ignore
|
||||
file.data.frontmatter = load(node.value);
|
||||
});
|
||||
};
|
||||
}
|
@@ -1,35 +0,0 @@
|
||||
import { visit } from 'unist-util-visit'
|
||||
import sizeOf from 'image-size'
|
||||
import fs from 'fs'
|
||||
|
||||
export default function remarkImgToJsx() {
|
||||
return (tree) => {
|
||||
visit(
|
||||
tree,
|
||||
// only visit p tags that contain an img element
|
||||
(node) => node.type === 'paragraph' && node.children.some((n) => n.type === 'image'),
|
||||
(node) => {
|
||||
const imageNode = node.children.find((n) => n.type === 'image')
|
||||
|
||||
// only local files
|
||||
if (fs.existsSync(`${process.cwd()}/public${imageNode.url}`)) {
|
||||
const dimensions = sizeOf(`${process.cwd()}/public${imageNode.url}`)
|
||||
|
||||
// Convert original node to next/image
|
||||
;(imageNode.type = 'mdxJsxFlowElement'),
|
||||
(imageNode.name = 'Image'),
|
||||
(imageNode.attributes = [
|
||||
{ type: 'mdxJsxAttribute', name: 'alt', value: imageNode.alt },
|
||||
{ type: 'mdxJsxAttribute', name: 'src', value: imageNode.url },
|
||||
{ type: 'mdxJsxAttribute', name: 'width', value: dimensions.width },
|
||||
{ type: 'mdxJsxAttribute', name: 'height', value: dimensions.height },
|
||||
])
|
||||
|
||||
// Change node type from p to div to avoid nesting error
|
||||
node.type = 'div'
|
||||
node.children = [imageNode]
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
55
lib/remark-img-to-jsx.ts
Normal file
55
lib/remark-img-to-jsx.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { Literal } from 'unist';
|
||||
import { visit, Parent, Node } from 'unist-util-visit';
|
||||
import sizeOf from 'image-size';
|
||||
import fs from 'fs';
|
||||
|
||||
type ImageNode = Parent & {
|
||||
url: string;
|
||||
alt: string;
|
||||
name: string;
|
||||
attributes: (Literal & { name: string })[];
|
||||
};
|
||||
|
||||
export default function remarkImgToJsx() {
|
||||
return (tree: Node) => {
|
||||
visit(
|
||||
tree,
|
||||
// only visit p tags that contain an img element
|
||||
(node: Parent): node is Parent =>
|
||||
node.type === 'paragraph' &&
|
||||
node.children.some((n) => n.type === 'image'),
|
||||
(node: Parent) => {
|
||||
const imageNode = node.children.find(
|
||||
(n) => n.type === 'image'
|
||||
) as ImageNode;
|
||||
|
||||
// only local files
|
||||
if (fs.existsSync(`${process.cwd()}/public${imageNode.url}`)) {
|
||||
const dimensions = sizeOf(`${process.cwd()}/public${imageNode.url}`);
|
||||
|
||||
// Convert original node to next/image
|
||||
(imageNode.type = 'mdxJsxFlowElement'),
|
||||
(imageNode.name = 'Image'),
|
||||
(imageNode.attributes = [
|
||||
{ type: 'mdxJsxAttribute', name: 'alt', value: imageNode.alt },
|
||||
{ type: 'mdxJsxAttribute', name: 'src', value: imageNode.url },
|
||||
{
|
||||
type: 'mdxJsxAttribute',
|
||||
name: 'width',
|
||||
value: dimensions.width,
|
||||
},
|
||||
{
|
||||
type: 'mdxJsxAttribute',
|
||||
name: 'height',
|
||||
value: dimensions.height,
|
||||
},
|
||||
]);
|
||||
|
||||
// Change node type from p to div to avoid nesting error
|
||||
node.type = 'div';
|
||||
node.children = [imageNode];
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
}
|
@@ -1,15 +0,0 @@
|
||||
import { visit } from 'unist-util-visit'
|
||||
import { slug } from 'github-slugger'
|
||||
import { toString } from 'mdast-util-to-string'
|
||||
|
||||
export default function remarkTocHeadings(options) {
|
||||
return (tree) =>
|
||||
visit(tree, 'heading', (node, index, parent) => {
|
||||
const textContent = toString(node)
|
||||
options.exportRef.push({
|
||||
value: textContent,
|
||||
url: '#' + slug(textContent),
|
||||
depth: node.depth,
|
||||
})
|
||||
})
|
||||
}
|
17
lib/remark-toc-headings.ts
Normal file
17
lib/remark-toc-headings.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
//@ts-nocheck
|
||||
import { Parent } from 'unist';
|
||||
import { visit } from 'unist-util-visit';
|
||||
import { slug } from 'github-slugger';
|
||||
import { toString } from 'mdast-util-to-string';
|
||||
|
||||
export default function remarkTocHeadings(options) {
|
||||
return (tree: Parent) =>
|
||||
visit(tree, 'heading', (node) => {
|
||||
const textContent = toString(node);
|
||||
options.exportRef.push({
|
||||
value: textContent,
|
||||
url: '#' + slug(textContent),
|
||||
depth: node.depth,
|
||||
});
|
||||
});
|
||||
}
|
30
lib/tags.js
30
lib/tags.js
@@ -1,30 +0,0 @@
|
||||
import fs from 'fs'
|
||||
import matter from 'gray-matter'
|
||||
import path from 'path'
|
||||
import { getFiles } from './mdx'
|
||||
import kebabCase from './utils/kebabCase'
|
||||
|
||||
const root = process.cwd()
|
||||
|
||||
export async function getAllTags(type) {
|
||||
const files = await getFiles(type)
|
||||
|
||||
let tagCount = {}
|
||||
// Iterate through each post, putting all found tags into `tags`
|
||||
files.forEach((file) => {
|
||||
const source = fs.readFileSync(path.join(root, 'data', type, file), 'utf8')
|
||||
const { data } = matter(source)
|
||||
if (data.tags && data.draft !== true) {
|
||||
data.tags.forEach((tag) => {
|
||||
const formattedTag = kebabCase(tag)
|
||||
if (formattedTag in tagCount) {
|
||||
tagCount[formattedTag] += 1
|
||||
} else {
|
||||
tagCount[formattedTag] = 1
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return tagCount
|
||||
}
|
32
lib/tags.ts
Normal file
32
lib/tags.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { PostFrontMatter } from 'types/PostFrontMatter';
|
||||
import fs from 'fs';
|
||||
import matter from 'gray-matter';
|
||||
import path from 'path';
|
||||
import { getFiles } from './mdx';
|
||||
import kebabCase from './utils/kebabCase';
|
||||
|
||||
const root = process.cwd();
|
||||
|
||||
export async function getAllTags(type: 'blog' | 'authors') {
|
||||
const files = getFiles(type);
|
||||
|
||||
const tagCount: Record<string, number> = {};
|
||||
// Iterate through each post, putting all found tags into `tags`
|
||||
files.forEach((file) => {
|
||||
const source = fs.readFileSync(path.join(root, 'data', type, file), 'utf8');
|
||||
const matterFile = matter(source);
|
||||
const data = matterFile.data as PostFrontMatter;
|
||||
if (data.tags && data.draft !== true) {
|
||||
data.tags.forEach((tag) => {
|
||||
const formattedTag = kebabCase(tag);
|
||||
if (formattedTag in tagCount) {
|
||||
tagCount[formattedTag] += 1;
|
||||
} else {
|
||||
tagCount[formattedTag] = 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return tagCount;
|
||||
}
|
@@ -1,23 +0,0 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
const pipe =
|
||||
(...fns) =>
|
||||
(x) =>
|
||||
fns.reduce((v, f) => f(v), x)
|
||||
|
||||
const flattenArray = (input) =>
|
||||
input.reduce((acc, item) => [...acc, ...(Array.isArray(item) ? item : [item])], [])
|
||||
|
||||
const map = (fn) => (input) => input.map(fn)
|
||||
|
||||
const walkDir = (fullPath) => {
|
||||
return fs.statSync(fullPath).isFile() ? fullPath : getAllFilesRecursively(fullPath)
|
||||
}
|
||||
|
||||
const pathJoinPrefix = (prefix) => (extraPath) => path.join(prefix, extraPath)
|
||||
|
||||
const getAllFilesRecursively = (folder) =>
|
||||
pipe(fs.readdirSync, map(pipe(pathJoinPrefix(folder), walkDir)), flattenArray)(folder)
|
||||
|
||||
export default getAllFilesRecursively
|
33
lib/utils/files.ts
Normal file
33
lib/utils/files.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const pipe =
|
||||
(...fns) =>
|
||||
(x) =>
|
||||
fns.reduce((v, f) => f(v), x);
|
||||
|
||||
const flattenArray = (input) =>
|
||||
input.reduce(
|
||||
(acc, item) => [...acc, ...(Array.isArray(item) ? item : [item])],
|
||||
[]
|
||||
);
|
||||
|
||||
const map = (fn) => (input) => input.map(fn);
|
||||
|
||||
const walkDir = (fullPath: string) => {
|
||||
return fs.statSync(fullPath).isFile()
|
||||
? fullPath
|
||||
: getAllFilesRecursively(fullPath);
|
||||
};
|
||||
|
||||
const pathJoinPrefix = (prefix: string) => (extraPath: string) =>
|
||||
path.join(prefix, extraPath);
|
||||
|
||||
const getAllFilesRecursively = (folder: string): string[] =>
|
||||
pipe(
|
||||
fs.readdirSync,
|
||||
map(pipe(pathJoinPrefix(folder), walkDir)),
|
||||
flattenArray
|
||||
)(folder);
|
||||
|
||||
export default getAllFilesRecursively;
|
@@ -1,14 +0,0 @@
|
||||
import siteMetadata from '@/data/siteMetadata'
|
||||
|
||||
const formatDate = (date) => {
|
||||
const options = {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
}
|
||||
const now = new Date(date).toLocaleDateString(siteMetadata.locale, options)
|
||||
|
||||
return now
|
||||
}
|
||||
|
||||
export default formatDate
|
14
lib/utils/formatDate.ts
Normal file
14
lib/utils/formatDate.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import siteMetadata from '@/data/siteMetadata';
|
||||
|
||||
const formatDate = (date: string) => {
|
||||
const options: Intl.DateTimeFormatOptions = {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
};
|
||||
const now = new Date(date).toLocaleDateString(siteMetadata.locale, options);
|
||||
|
||||
return now;
|
||||
};
|
||||
|
||||
export default formatDate;
|
@@ -1,8 +1,7 @@
|
||||
const { replace } = ''
|
||||
const { replace } = '';
|
||||
|
||||
// escape
|
||||
const es = /&(?:amp|#38|lt|#60|gt|#62|apos|#39|quot|#34);/g
|
||||
const ca = /[&<>'"]/g
|
||||
const ca = /[&<>'"]/g;
|
||||
|
||||
const esca = {
|
||||
'&': '&',
|
||||
@@ -10,8 +9,8 @@ const esca = {
|
||||
'>': '>',
|
||||
"'": ''',
|
||||
'"': '"',
|
||||
}
|
||||
const pe = (m) => esca[m]
|
||||
};
|
||||
const pe = (m: keyof typeof esca) => esca[m];
|
||||
|
||||
/**
|
||||
* Safely escape HTML entities such as `&`, `<`, `>`, `"`, and `'`.
|
||||
@@ -20,4 +19,4 @@ const pe = (m) => esca[m]
|
||||
* the input type is unexpected, except for boolean and numbers,
|
||||
* converted as string.
|
||||
*/
|
||||
export const escape = (es) => replace.call(es, ca, pe)
|
||||
export const escape = (es: string): string => replace.call(es, ca, pe);
|
@@ -1,5 +0,0 @@
|
||||
import { slug } from 'github-slugger'
|
||||
|
||||
const kebabCase = (str) => slug(str)
|
||||
|
||||
export default kebabCase
|
5
lib/utils/kebabCase.ts
Normal file
5
lib/utils/kebabCase.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { slug } from 'github-slugger';
|
||||
|
||||
const kebabCase = (str: string) => slug(str);
|
||||
|
||||
export default kebabCase;
|
5
next-env.d.ts
vendored
Normal file
5
next-env.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
@@ -1,18 +1,19 @@
|
||||
const withBundleAnalyzer = require('@next/bundle-analyzer')({
|
||||
enabled: process.env.ANALYZE === 'true',
|
||||
})
|
||||
});
|
||||
const withExportImages = require('next-export-optimize-images');
|
||||
|
||||
// You might need to insert additional domains in script-src if you are using external services
|
||||
const ContentSecurityPolicy = `
|
||||
default-src 'self';
|
||||
script-src 'self' 'unsafe-eval' 'unsafe-inline' giscus.app;
|
||||
style-src 'self' 'unsafe-inline';
|
||||
script-src 'self' 'unsafe-eval' 'unsafe-inline' giscus.app comment.ivanli.cc localhost:8080;
|
||||
style-src 'self' 'unsafe-inline' comment.ivanli.cc localhost:8080;
|
||||
img-src * blob: data:;
|
||||
media-src 'none';
|
||||
connect-src *;
|
||||
font-src 'self';
|
||||
font-src 'self' comment.ivanli.cc localhost:8080;
|
||||
frame-src giscus.app
|
||||
`
|
||||
`;
|
||||
|
||||
const securityHeaders = [
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP
|
||||
@@ -50,11 +51,15 @@ const securityHeaders = [
|
||||
key: 'Permissions-Policy',
|
||||
value: 'camera=(), microphone=(), geolocation=()',
|
||||
},
|
||||
]
|
||||
];
|
||||
|
||||
module.exports = withBundleAnalyzer({
|
||||
/**
|
||||
* @type {import('next/dist/next-server/server/config').NextConfig}
|
||||
**/
|
||||
module.exports = withExportImages(
|
||||
withBundleAnalyzer({
|
||||
reactStrictMode: true,
|
||||
pageExtensions: ['js', 'jsx', 'md', 'mdx'],
|
||||
pageExtensions: ['ts', 'tsx', 'js', 'jsx', 'md', 'mdx'],
|
||||
eslint: {
|
||||
dirs: ['pages', 'components', 'lib', 'layouts', 'scripts'],
|
||||
},
|
||||
@@ -67,13 +72,26 @@ module.exports = withBundleAnalyzer({
|
||||
source: '/(.*)',
|
||||
headers: securityHeaders,
|
||||
},
|
||||
]
|
||||
];
|
||||
},
|
||||
webpack: (config, { dev, isServer }) => {
|
||||
config.module.rules.push({
|
||||
test: /\.(png|jpe?g|gif|mp4)$/i,
|
||||
use: [
|
||||
{
|
||||
loader: 'file-loader',
|
||||
options: {
|
||||
publicPath: '/_next',
|
||||
name: 'static/media/[name].[hash].[ext]',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
config.module.rules.push({
|
||||
test: /\.svg$/,
|
||||
use: ['@svgr/webpack'],
|
||||
})
|
||||
});
|
||||
|
||||
if (!dev && !isServer) {
|
||||
// Replace React with Preact only in client production build
|
||||
@@ -82,9 +100,11 @@ module.exports = withBundleAnalyzer({
|
||||
react: 'preact/compat',
|
||||
'react-dom/test-utils': 'preact/test-utils',
|
||||
'react-dom': 'preact/compat',
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
return config
|
||||
return config;
|
||||
},
|
||||
trailingSlash: true,
|
||||
})
|
||||
);
|
||||
|
26504
package-lock.json
generated
26504
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user