Compare commits
38 Commits
10f64a9ba4
...
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 |
@ -11,4 +11,4 @@ FROM mcr.microsoft.com/vscode/devcontainers/typescript-node:0-${VARIANT}
|
|||||||
# RUN su node -c "source /usr/local/share/nvm/nvm.sh && nvm install ${EXTRA_NODE_VERSION}"
|
# RUN su node -c "source /usr/local/share/nvm/nvm.sh && nvm install ${EXTRA_NODE_VERSION}"
|
||||||
|
|
||||||
# [Optional] Uncomment if you want to install more global node packages
|
# [Optional] Uncomment if you want to install more global node packages
|
||||||
# RUN su node -c "npm install -g pnpm"
|
RUN su node -c "npm install -g pnpm"
|
||||||
|
@ -11,7 +11,6 @@
|
|||||||
"VARIANT": "16-bullseye"
|
"VARIANT": "16-bullseye"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Configure tool-specific properties.
|
// Configure tool-specific properties.
|
||||||
"customizations": {
|
"customizations": {
|
||||||
// Configure properties specific to VS Code.
|
// Configure properties specific to VS Code.
|
||||||
@ -40,21 +39,17 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||||
"forwardPorts": [3000],
|
"forwardPorts": [3000],
|
||||||
|
|
||||||
// Use 'postCreateCommand' to run commands after the container is created.
|
// Use 'postCreateCommand' to run commands after the container is created.
|
||||||
// "postCreateCommand": "yarn install",
|
// "postCreateCommand": "yarn install",
|
||||||
|
|
||||||
// Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
|
// Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
|
||||||
"remoteUser": "node",
|
"remoteUser": "node",
|
||||||
"features": {
|
"features": {
|
||||||
"git": "os-provided",
|
"git": "os-provided",
|
||||||
"git-lfs": "latest"
|
"git-lfs": "latest",
|
||||||
|
"ghcr.io/devcontainers/features/docker-in-docker:2": {}
|
||||||
},
|
},
|
||||||
"mounts": [
|
"mounts": [],
|
||||||
"source=${localEnv:HOME}${localEnv:USERPROFILE}/.ssh,target=/home/node/.ssh,type=bind,consistency=cached"
|
"postStartCommand": "pnpm install && npm run dev"
|
||||||
],
|
|
||||||
"postStartCommand": "npm ci && npm run dev"
|
|
||||||
}
|
}
|
||||||
|
@ -35,4 +35,6 @@ yarn-error.log*
|
|||||||
.env.test.local
|
.env.test.local
|
||||||
.env.production.local
|
.env.production.local
|
||||||
|
|
||||||
secrets.txt
|
secrets.txt
|
||||||
|
|
||||||
|
.pnpm-store
|
75
.drone.yml
75
.drone.yml
@ -119,12 +119,12 @@ kind: pipeline
|
|||||||
type: docker
|
type: docker
|
||||||
name: deploy
|
name: deploy
|
||||||
clone:
|
clone:
|
||||||
disable: true
|
disable: false
|
||||||
depends_on:
|
depends_on:
|
||||||
- linux-amd64
|
- linux-amd64
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: deploy
|
- name: watchtower-online
|
||||||
image: plugins/webhook
|
image: plugins/webhook
|
||||||
settings:
|
settings:
|
||||||
token_value:
|
token_value:
|
||||||
@ -170,3 +170,74 @@ steps:
|
|||||||
```
|
```
|
||||||
🌐 {{ build.link }}
|
🌐 {{ build.link }}
|
||||||
{{/success}}
|
{{/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
|
2
.gitignore
vendored
2
.gitignore
vendored
@ -36,3 +36,5 @@ yarn-error.log*
|
|||||||
.env.production.local
|
.env.production.local
|
||||||
|
|
||||||
secrets.txt
|
secrets.txt
|
||||||
|
|
||||||
|
.pnpm-store
|
@ -1,2 +1,6 @@
|
|||||||
{
|
module.exports = {
|
||||||
}
|
singleQuote: true,
|
||||||
|
trailingCommas: 'all',
|
||||||
|
bracketSpacing: true,
|
||||||
|
bracketSameLine: true,
|
||||||
|
};
|
||||||
|
5
.vscode/settings.json
vendored
5
.vscode/settings.json
vendored
@ -7,15 +7,20 @@
|
|||||||
"Commento",
|
"Commento",
|
||||||
"CONVERTKIT",
|
"CONVERTKIT",
|
||||||
"Cusdis",
|
"Cusdis",
|
||||||
|
"Discuz",
|
||||||
"Disqus",
|
"Disqus",
|
||||||
"dokodemo",
|
"dokodemo",
|
||||||
"EMAILOCTOPUS",
|
"EMAILOCTOPUS",
|
||||||
"fullchain",
|
"fullchain",
|
||||||
"Giscus",
|
"Giscus",
|
||||||
"KLAVIYO",
|
"KLAVIYO",
|
||||||
|
"Kutt",
|
||||||
"lastmod",
|
"lastmod",
|
||||||
|
"Logseq",
|
||||||
"MAILCHIMP",
|
"MAILCHIMP",
|
||||||
|
"Miniflux",
|
||||||
"nextjs",
|
"nextjs",
|
||||||
|
"Nuxt",
|
||||||
"outbounds",
|
"outbounds",
|
||||||
"rprx",
|
"rprx",
|
||||||
"unist",
|
"unist",
|
||||||
|
21
Dockerfile
21
Dockerfile
@ -12,11 +12,24 @@ FROM deps as build
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY . .
|
COPY . .
|
||||||
COPY --from=deps /app ./
|
COPY --from=deps /app ./
|
||||||
RUN pnpm build &&\
|
RUN pnpm build
|
||||||
pnpm prune --prod --config.ignore-scripts=true
|
|
||||||
|
FROM build as pre-release
|
||||||
|
WORKDIR /app
|
||||||
|
RUN pnpm prune --prod --config.ignore-scripts=true
|
||||||
|
|
||||||
FROM node:16-alpine as release
|
FROM node:16-alpine as release
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=build /app ./
|
COPY --from=pre-release /app ./
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
CMD npm run serve -- -p 80
|
CMD npm run serve -- -p 80
|
||||||
|
|
||||||
|
FROM build as export
|
||||||
|
WORKDIR /app
|
||||||
|
RUN npm run export
|
||||||
|
|
||||||
|
FROM alpine:latest as upload
|
||||||
|
RUN apk add lftp
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=export /app/out ./
|
||||||
|
CMD lftp -u "${FTP_ACCOUNT},${FTP_PASSWORD}" "${FTP_HOST}" -e 'set ftp:ssl-allow off && set use-feat no && mirror -c -R --use-pget-n=10 . ./WEB && exit'
|
97
README.md
97
README.md
@ -5,85 +5,12 @@
|
|||||||
[](https://ci.ivanli.cc/Ivan/tailwind-nextjs-blog)
|
[](https://ci.ivanli.cc/Ivan/tailwind-nextjs-blog)
|
||||||
[](https://ivanli.cc)
|
[](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.
|
Ivan Li's Blog, base [timlrx/tailwind-nextjs-starter-blog](https://github.com/timlrx/tailwind-nextjs-starter-blog)
|
||||||
|
|
||||||
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!
|
|
||||||
|
|
||||||
## 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
|
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
pnpm install
|
||||||
```
|
```
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
@ -91,13 +18,13 @@ npm install
|
|||||||
First, run the development server:
|
First, run the development server:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm start
|
pnpm start
|
||||||
```
|
```
|
||||||
|
|
||||||
or
|
or
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run dev
|
pnpm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||||
@ -180,18 +107,4 @@ Follow the interactive prompt to generate a post with pre-filled front matter.
|
|||||||
|
|
||||||
## Deploy
|
## Deploy
|
||||||
|
|
||||||
**Vercel**
|
Drone CI.
|
||||||
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).
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
[MIT](https://github.com/timlrx/tailwind-nextjs-starter-blog/blob/master/LICENSE) © [Timothy Lin](https://www.timlrx.com)
|
|
||||||
|
@ -1,13 +1,12 @@
|
|||||||
import Image from './Image'
|
import Image from './Image';
|
||||||
import Link from './Link'
|
import Link from './Link';
|
||||||
|
|
||||||
const Card = ({ title, description, imgSrc, href }) => (
|
const Card = ({ title, description, imgSrc, href }) => (
|
||||||
<div className="md p-4 md:w-1/2" style={{ maxWidth: '544px' }}>
|
<div className="md p-4 md:w-1/2" style={{ maxWidth: '544px' }}>
|
||||||
<div
|
<div
|
||||||
className={`${
|
className={`${
|
||||||
imgSrc && 'h-full'
|
imgSrc && 'h-full'
|
||||||
} overflow-hidden rounded-md border-2 border-gray-200 border-opacity-60 dark:border-gray-700`}
|
} overflow-hidden rounded-md border-2 border-gray-200 border-opacity-60 dark:border-gray-700`}>
|
||||||
>
|
|
||||||
{imgSrc &&
|
{imgSrc &&
|
||||||
(href ? (
|
(href ? (
|
||||||
<Link href={href} aria-label={`Link to ${title}`}>
|
<Link href={href} aria-label={`Link to ${title}`}>
|
||||||
@ -38,19 +37,20 @@ const Card = ({ title, description, imgSrc, href }) => (
|
|||||||
title
|
title
|
||||||
)}
|
)}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="prose mb-3 max-w-none text-gray-500 dark:text-gray-400">{description}</p>
|
<p className="prose mb-3 max-w-none text-gray-500 dark:text-gray-400">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
{href && (
|
{href && (
|
||||||
<Link
|
<Link
|
||||||
href={href}
|
href={href}
|
||||||
className="text-base font-medium leading-6 text-primary-500 hover:text-primary-600 dark:hover:text-primary-400"
|
className="text-base font-medium leading-6 text-primary-500 hover:text-primary-600 dark:hover:text-primary-400"
|
||||||
aria-label={`Link to ${title}`}
|
aria-label={`Link to ${title}`}>
|
||||||
>
|
|
||||||
Learn more →
|
Learn more →
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
|
|
||||||
export default Card
|
export default Card;
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react';
|
||||||
import Router from 'next/router'
|
import Router from 'next/router';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Client-side complement to next-remote-watch
|
* Client-side complement to next-remote-watch
|
||||||
@ -10,14 +10,14 @@ export const ClientReload = () => {
|
|||||||
// Exclude socket.io from prod bundle
|
// Exclude socket.io from prod bundle
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
import('socket.io-client').then((module) => {
|
import('socket.io-client').then((module) => {
|
||||||
const socket = module.io()
|
const socket = module.io();
|
||||||
socket.on('reload', () => {
|
socket.on('reload', () => {
|
||||||
Router.replace(Router.asPath, undefined, {
|
Router.replace(Router.asPath, undefined, {
|
||||||
scroll: false,
|
scroll: false,
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
return null
|
return null;
|
||||||
}
|
};
|
||||||
|
@ -1,13 +1,17 @@
|
|||||||
import Link from './Link'
|
import Link from './Link';
|
||||||
import siteMetadata from '@/data/siteMetadata'
|
import siteMetadata from '@/data/siteMetadata';
|
||||||
import SocialIcon from '@/components/social-icons'
|
import SocialIcon from '@/components/social-icons';
|
||||||
|
|
||||||
export default function Footer() {
|
export default function Footer() {
|
||||||
return (
|
return (
|
||||||
<footer>
|
<footer>
|
||||||
<div className="mt-16 flex flex-col items-center">
|
<div className="mt-16 flex flex-col items-center">
|
||||||
<div className="mb-3 flex space-x-4">
|
<div className="mb-3 flex space-x-4">
|
||||||
<SocialIcon kind="mail" href={`mailto:${siteMetadata.email}`} size={6} />
|
<SocialIcon
|
||||||
|
kind="mail"
|
||||||
|
href={`mailto:${siteMetadata.email}`}
|
||||||
|
size={6}
|
||||||
|
/>
|
||||||
<SocialIcon kind="github" href={siteMetadata.github} size={6} />
|
<SocialIcon kind="github" href={siteMetadata.github} size={6} />
|
||||||
<SocialIcon kind="facebook" href={siteMetadata.facebook} size={6} />
|
<SocialIcon kind="facebook" href={siteMetadata.facebook} size={6} />
|
||||||
<SocialIcon kind="youtube" href={siteMetadata.youtube} size={6} />
|
<SocialIcon kind="youtube" href={siteMetadata.youtube} size={6} />
|
||||||
@ -21,12 +25,19 @@ export default function Footer() {
|
|||||||
<div>{` • `}</div>
|
<div>{` • `}</div>
|
||||||
<Link href="/">{siteMetadata.title}</Link>
|
<Link href="/">{siteMetadata.title}</Link>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mb-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
<Link href="https://beian.miit.gov.cn" rel="nofollow">
|
||||||
|
闽ICP备2023000043号
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
<div className="mb-8 text-sm text-gray-500 dark:text-gray-400">
|
<div className="mb-8 text-sm text-gray-500 dark:text-gray-400">
|
||||||
<Link href="https://github.com/timlrx/tailwind-nextjs-starter-blog" rel="nofollow">
|
<Link
|
||||||
|
href="https://github.com/timlrx/tailwind-nextjs-starter-blog"
|
||||||
|
rel="nofollow">
|
||||||
Tailwind Nextjs Theme
|
Tailwind Nextjs Theme
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import NextImage, { ImageProps } from 'next/image'
|
import NextImage, { ImageProps } from 'next/image';
|
||||||
|
|
||||||
const Image = ({ ...rest }: ImageProps) => <NextImage {...rest} />
|
const Image = ({ ...rest }: ImageProps) => <NextImage {...rest} />;
|
||||||
|
|
||||||
export default Image
|
export default Image;
|
||||||
|
@ -1,15 +1,15 @@
|
|||||||
import siteMetadata from '@/data/siteMetadata'
|
import siteMetadata from '@/data/siteMetadata';
|
||||||
import headerNavLinks from '@/data/headerNavLinks'
|
import headerNavLinks from '@/data/headerNavLinks';
|
||||||
import Logo from '@/data/logo.svg'
|
import Logo from '@/data/logo.svg';
|
||||||
import Link from './Link'
|
import Link from './Link';
|
||||||
import SectionContainer from './SectionContainer'
|
import SectionContainer from './SectionContainer';
|
||||||
import Footer from './Footer'
|
import Footer from './Footer';
|
||||||
import MobileNav from './MobileNav'
|
import MobileNav from './MobileNav';
|
||||||
import ThemeSwitch from './ThemeSwitch'
|
import ThemeSwitch from './ThemeSwitch';
|
||||||
import { ReactNode } from 'react'
|
import { ReactNode } from 'react';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
children: ReactNode
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
const LayoutWrapper = ({ children }: Props) => {
|
const LayoutWrapper = ({ children }: Props) => {
|
||||||
@ -39,8 +39,7 @@ const LayoutWrapper = ({ children }: Props) => {
|
|||||||
<Link
|
<Link
|
||||||
key={link.title}
|
key={link.title}
|
||||||
href={link.href}
|
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.title}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
@ -53,7 +52,7 @@ const LayoutWrapper = ({ children }: Props) => {
|
|||||||
<Footer />
|
<Footer />
|
||||||
</div>
|
</div>
|
||||||
</SectionContainer>
|
</SectionContainer>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default LayoutWrapper
|
export default LayoutWrapper;
|
||||||
|
@ -1,27 +1,30 @@
|
|||||||
/* eslint-disable jsx-a11y/anchor-has-content */
|
/* eslint-disable jsx-a11y/anchor-has-content */
|
||||||
import Link from 'next/link'
|
import Link from 'next/link';
|
||||||
import { AnchorHTMLAttributes, DetailedHTMLProps } from 'react'
|
import { AnchorHTMLAttributes, DetailedHTMLProps } from 'react';
|
||||||
|
|
||||||
const CustomLink = ({
|
const CustomLink = ({
|
||||||
href,
|
href,
|
||||||
...rest
|
...rest
|
||||||
}: DetailedHTMLProps<AnchorHTMLAttributes<HTMLAnchorElement>, HTMLAnchorElement>) => {
|
}: DetailedHTMLProps<
|
||||||
const isInternalLink = href && href.startsWith('/')
|
AnchorHTMLAttributes<HTMLAnchorElement>,
|
||||||
const isAnchorLink = href && href.startsWith('#')
|
HTMLAnchorElement
|
||||||
|
>) => {
|
||||||
|
const isInternalLink = href && href.startsWith('/');
|
||||||
|
const isAnchorLink = href && href.startsWith('#');
|
||||||
|
|
||||||
if (isInternalLink) {
|
if (isInternalLink) {
|
||||||
return (
|
return (
|
||||||
<Link href={href}>
|
<Link href={href}>
|
||||||
<a {...rest} />
|
<a {...rest} />
|
||||||
</Link>
|
</Link>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isAnchorLink) {
|
if (isAnchorLink) {
|
||||||
return <a href={href} {...rest} />
|
return <a href={href} {...rest} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <a target="_blank" rel="noopener noreferrer" href={href} {...rest} />
|
return <a target="_blank" rel="noopener noreferrer" href={href} {...rest} />;
|
||||||
}
|
};
|
||||||
|
|
||||||
export default CustomLink
|
export default CustomLink;
|
||||||
|
@ -1,16 +1,19 @@
|
|||||||
/* eslint-disable react/display-name */
|
/* eslint-disable react/display-name */
|
||||||
import React, { useMemo } from 'react'
|
import React, { useMemo } from 'react';
|
||||||
import { ComponentMap, getMDXComponent } from 'mdx-bundler/client'
|
import { ComponentMap, getMDXComponent } from 'mdx-bundler/client';
|
||||||
import Image from './Image'
|
import Image from './Image';
|
||||||
import CustomLink from './Link'
|
import CustomLink from './Link';
|
||||||
import TOCInline from './TOCInline'
|
import TOCInline from './TOCInline';
|
||||||
import Pre from './Pre'
|
import Pre from './Pre';
|
||||||
import { BlogNewsletterForm } from './NewsletterForm'
|
import { BlogNewsletterForm } from './NewsletterForm';
|
||||||
|
|
||||||
const Wrapper: React.ComponentType<{ layout: string }> = ({ layout, ...rest }) => {
|
const Wrapper: React.ComponentType<{ layout: string }> = ({
|
||||||
const Layout = require(`../layouts/${layout}`).default
|
layout,
|
||||||
return <Layout {...rest} />
|
...rest
|
||||||
}
|
}) => {
|
||||||
|
const Layout = require(`../layouts/${layout}`).default;
|
||||||
|
return <Layout {...rest} />;
|
||||||
|
};
|
||||||
|
|
||||||
export const MDXComponents: ComponentMap = {
|
export const MDXComponents: ComponentMap = {
|
||||||
Image,
|
Image,
|
||||||
@ -21,16 +24,16 @@ export const MDXComponents: ComponentMap = {
|
|||||||
wrapper: Wrapper,
|
wrapper: Wrapper,
|
||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
BlogNewsletterForm,
|
BlogNewsletterForm,
|
||||||
}
|
};
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
layout: string
|
layout: string;
|
||||||
mdxSource: string
|
mdxSource: string;
|
||||||
[key: string]: unknown
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MDXLayoutRenderer = ({ layout, mdxSource, ...rest }: Props) => {
|
export const MDXLayoutRenderer = ({ layout, mdxSource, ...rest }: Props) => {
|
||||||
const MDXLayout = useMemo(() => getMDXComponent(mdxSource), [mdxSource])
|
const MDXLayout = useMemo(() => getMDXComponent(mdxSource), [mdxSource]);
|
||||||
|
|
||||||
return <MDXLayout layout={layout} components={MDXComponents} {...rest} />
|
return <MDXLayout layout={layout} components={MDXComponents} {...rest} />;
|
||||||
}
|
};
|
||||||
|
@ -1,21 +1,21 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react';
|
||||||
import Link from './Link'
|
import Link from './Link';
|
||||||
import headerNavLinks from '@/data/headerNavLinks'
|
import headerNavLinks from '@/data/headerNavLinks';
|
||||||
|
|
||||||
const MobileNav = () => {
|
const MobileNav = () => {
|
||||||
const [navShow, setNavShow] = useState(false)
|
const [navShow, setNavShow] = useState(false);
|
||||||
|
|
||||||
const onToggleNav = () => {
|
const onToggleNav = () => {
|
||||||
setNavShow((status) => {
|
setNavShow((status) => {
|
||||||
if (status) {
|
if (status) {
|
||||||
document.body.style.overflow = 'auto'
|
document.body.style.overflow = 'auto';
|
||||||
} else {
|
} else {
|
||||||
// Prevent scrolling
|
// Prevent scrolling
|
||||||
document.body.style.overflow = 'hidden'
|
document.body.style.overflow = 'hidden';
|
||||||
}
|
}
|
||||||
return !status
|
return !status;
|
||||||
})
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="sm:hidden">
|
<div className="sm:hidden">
|
||||||
@ -23,14 +23,12 @@ const MobileNav = () => {
|
|||||||
type="button"
|
type="button"
|
||||||
className="ml-1 mr-1 h-8 w-8 rounded py-1"
|
className="ml-1 mr-1 h-8 w-8 rounded py-1"
|
||||||
aria-label="Toggle Menu"
|
aria-label="Toggle Menu"
|
||||||
onClick={onToggleNav}
|
onClick={onToggleNav}>
|
||||||
>
|
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
viewBox="0 0 20 20"
|
viewBox="0 0 20 20"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
className="text-gray-900 dark:text-gray-100"
|
className="text-gray-900 dark:text-gray-100">
|
||||||
>
|
|
||||||
{navShow ? (
|
{navShow ? (
|
||||||
<path
|
<path
|
||||||
fillRule="evenodd"
|
fillRule="evenodd"
|
||||||
@ -49,22 +47,19 @@ const MobileNav = () => {
|
|||||||
<div
|
<div
|
||||||
className={`fixed top-24 right-0 z-10 h-full w-full transform bg-gray-200 opacity-95 duration-300 ease-in-out dark:bg-gray-800 ${
|
className={`fixed 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'
|
navShow ? 'translate-x-0' : 'translate-x-full'
|
||||||
}`}
|
}`}>
|
||||||
>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="toggle modal"
|
aria-label="toggle modal"
|
||||||
className="fixed h-full w-full cursor-auto focus:outline-none"
|
className="fixed h-full w-full cursor-auto focus:outline-none"
|
||||||
onClick={onToggleNav}
|
onClick={onToggleNav}></button>
|
||||||
></button>
|
|
||||||
<nav className="fixed mt-8 h-full">
|
<nav className="fixed mt-8 h-full">
|
||||||
{headerNavLinks.map((link) => (
|
{headerNavLinks.map((link) => (
|
||||||
<div key={link.title} className="px-12 py-4">
|
<div key={link.title} className="px-12 py-4">
|
||||||
<Link
|
<Link
|
||||||
href={link.href}
|
href={link.href}
|
||||||
className="text-2xl font-bold tracking-widest text-gray-900 dark:text-gray-100"
|
className="text-2xl font-bold tracking-widest text-gray-900 dark:text-gray-100"
|
||||||
onClick={onToggleNav}
|
onClick={onToggleNav}>
|
||||||
>
|
|
||||||
{link.title}
|
{link.title}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@ -72,7 +67,7 @@ const MobileNav = () => {
|
|||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default MobileNav
|
export default MobileNav;
|
||||||
|
@ -1,15 +1,15 @@
|
|||||||
import React, { 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 NewsletterForm = ({ title = 'Subscribe to the newsletter' }) => {
|
||||||
const inputEl = useRef<HTMLInputElement>(null)
|
const inputEl = useRef<HTMLInputElement>(null);
|
||||||
const [error, setError] = useState(false)
|
const [error, setError] = useState(false);
|
||||||
const [message, setMessage] = useState('')
|
const [message, setMessage] = useState('');
|
||||||
const [subscribed, setSubscribed] = useState(false)
|
const [subscribed, setSubscribed] = useState(false);
|
||||||
|
|
||||||
const subscribe = async (e: React.FormEvent<HTMLFormElement>) => {
|
const subscribe = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault()
|
e.preventDefault();
|
||||||
|
|
||||||
const res = await fetch(`/api/${siteMetadata.newsletter.provider}`, {
|
const res = await fetch(`/api/${siteMetadata.newsletter.provider}`, {
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@ -19,24 +19,28 @@ const NewsletterForm = ({ title = 'Subscribe to the newsletter' }) => {
|
|||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
})
|
});
|
||||||
|
|
||||||
const { error } = await res.json()
|
const { error } = await res.json();
|
||||||
if (error) {
|
if (error) {
|
||||||
setError(true)
|
setError(true);
|
||||||
setMessage('Your e-mail address is invalid or you are already subscribed!')
|
setMessage(
|
||||||
return
|
'Your e-mail address is invalid or you are already subscribed!'
|
||||||
|
);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
inputEl.current.value = ''
|
inputEl.current.value = '';
|
||||||
setError(false)
|
setError(false);
|
||||||
setSubscribed(true)
|
setSubscribed(true);
|
||||||
setMessage('Successfully! 🎉 You are now subscribed.')
|
setMessage('Successfully! 🎉 You are now subscribed.');
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<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}>
|
<form className="flex flex-col sm:flex-row" onSubmit={subscribe}>
|
||||||
<div>
|
<div>
|
||||||
<label className="sr-only" htmlFor="email-input">
|
<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"
|
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"
|
id="email-input"
|
||||||
name="email"
|
name="email"
|
||||||
placeholder={subscribed ? "You're subscribed ! 🎉" : 'Enter your email'}
|
placeholder={
|
||||||
|
subscribed ? "You're subscribed ! 🎉" : 'Enter your email'
|
||||||
|
}
|
||||||
ref={inputEl}
|
ref={inputEl}
|
||||||
required
|
required
|
||||||
type="email"
|
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">
|
<div className="mt-2 flex w-full rounded-md shadow-sm sm:mt-0 sm:ml-3">
|
||||||
<button
|
<button
|
||||||
className={`w-full rounded-md bg-primary-500 py-2 px-4 font-medium text-white sm:py-0 ${
|
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`}
|
} focus:outline-none focus:ring-2 focus:ring-primary-600 focus:ring-offset-2 dark:ring-offset-black`}
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={subscribed}
|
disabled={subscribed}>
|
||||||
>
|
|
||||||
{subscribed ? 'Thank you!' : 'Sign up'}
|
{subscribed ? 'Thank you!' : 'Sign up'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{error && (
|
{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>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default NewsletterForm
|
export default NewsletterForm;
|
||||||
|
|
||||||
export const BlogNewsletterForm = ({ title }) => (
|
export const BlogNewsletterForm = ({ title }) => (
|
||||||
<div className="flex items-center justify-center">
|
<div className="flex items-center justify-center">
|
||||||
@ -81,4 +90,4 @@ export const BlogNewsletterForm = ({ title }) => (
|
|||||||
<NewsletterForm title={title} />
|
<NewsletterForm title={title} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { ReactNode } from 'react'
|
import { ReactNode } from 'react';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
children: ReactNode
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PageTitle({ children }: Props) {
|
export default function PageTitle({ children }: Props) {
|
||||||
@ -9,5 +9,5 @@ export default function PageTitle({ children }: Props) {
|
|||||||
<h1 className="text-3xl font-extrabold leading-9 tracking-tight text-gray-900 dark:text-gray-100 sm:text-4xl sm:leading-10 md:text-5xl md:leading-14">
|
<h1 className="text-3xl font-extrabold leading-9 tracking-tight text-gray-900 dark:text-gray-100 sm:text-4xl sm:leading-10 md:text-5xl md:leading-14">
|
||||||
{children}
|
{children}
|
||||||
</h1>
|
</h1>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,24 +1,29 @@
|
|||||||
import Link from '@/components/Link'
|
import Link from '@/components/Link';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
totalPages: number
|
totalPages: number;
|
||||||
currentPage: number
|
currentPage: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Pagination({ totalPages, currentPage }: Props) {
|
export default function Pagination({ totalPages, currentPage }: Props) {
|
||||||
const prevPage = currentPage - 1 > 0
|
const prevPage = currentPage - 1 > 0;
|
||||||
const nextPage = currentPage + 1 <= totalPages
|
const nextPage = currentPage + 1 <= totalPages;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2 pt-6 pb-8 md:space-y-5">
|
<div className="space-y-2 pt-6 pb-8 md:space-y-5">
|
||||||
<nav className="flex justify-between">
|
<nav className="flex justify-between">
|
||||||
{!prevPage && (
|
{!prevPage && (
|
||||||
<button className="cursor-auto disabled:opacity-50" disabled={!prevPage}>
|
<button
|
||||||
|
className="cursor-auto disabled:opacity-50"
|
||||||
|
disabled={!prevPage}>
|
||||||
Previous
|
Previous
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{prevPage && (
|
{prevPage && (
|
||||||
<Link href={currentPage - 1 === 1 ? `/blog/` : `/blog/page/${currentPage - 1}`}>
|
<Link
|
||||||
|
href={
|
||||||
|
currentPage - 1 === 1 ? `/blog/` : `/blog/page/${currentPage - 1}`
|
||||||
|
}>
|
||||||
<button>Previous</button>
|
<button>Previous</button>
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
@ -26,7 +31,9 @@ export default function Pagination({ totalPages, currentPage }: Props) {
|
|||||||
{currentPage} of {totalPages}
|
{currentPage} of {totalPages}
|
||||||
</span>
|
</span>
|
||||||
{!nextPage && (
|
{!nextPage && (
|
||||||
<button className="cursor-auto disabled:opacity-50" disabled={!nextPage}>
|
<button
|
||||||
|
className="cursor-auto disabled:opacity-50"
|
||||||
|
disabled={!nextPage}>
|
||||||
Next
|
Next
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@ -37,5 +44,5 @@ export default function Pagination({ totalPages, currentPage }: Props) {
|
|||||||
)}
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,31 +1,35 @@
|
|||||||
import { useState, useRef, ReactNode } from 'react'
|
import { useState, useRef, ReactNode } from 'react';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
children: ReactNode
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Pre = ({ children }: Props) => {
|
const Pre = ({ children }: Props) => {
|
||||||
const textInput = useRef(null)
|
const textInput = useRef(null);
|
||||||
const [hovered, setHovered] = useState(false)
|
const [hovered, setHovered] = useState(false);
|
||||||
const [copied, setCopied] = useState(false)
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
const onEnter = () => {
|
const onEnter = () => {
|
||||||
setHovered(true)
|
setHovered(true);
|
||||||
}
|
};
|
||||||
const onExit = () => {
|
const onExit = () => {
|
||||||
setHovered(false)
|
setHovered(false);
|
||||||
setCopied(false)
|
setCopied(false);
|
||||||
}
|
};
|
||||||
const onCopy = () => {
|
const onCopy = () => {
|
||||||
setCopied(true)
|
setCopied(true);
|
||||||
navigator.clipboard.writeText(textInput.current.textContent)
|
navigator.clipboard.writeText(textInput.current.textContent);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setCopied(false)
|
setCopied(false);
|
||||||
}, 2000)
|
}, 2000);
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={textInput} onMouseEnter={onEnter} onMouseLeave={onExit} className="relative">
|
<div
|
||||||
|
ref={textInput}
|
||||||
|
onMouseEnter={onEnter}
|
||||||
|
onMouseLeave={onExit}
|
||||||
|
className="relative">
|
||||||
{hovered && (
|
{hovered && (
|
||||||
<button
|
<button
|
||||||
aria-label="Copy code"
|
aria-label="Copy code"
|
||||||
@ -35,15 +39,13 @@ const Pre = ({ children }: Props) => {
|
|||||||
? 'border-green-400 focus:border-green-400 focus:outline-none'
|
? 'border-green-400 focus:border-green-400 focus:outline-none'
|
||||||
: 'border-gray-300'
|
: 'border-gray-300'
|
||||||
}`}
|
}`}
|
||||||
onClick={onCopy}
|
onClick={onCopy}>
|
||||||
>
|
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
fill="none"
|
fill="none"
|
||||||
className={copied ? 'text-green-400' : 'text-gray-300'}
|
className={copied ? 'text-green-400' : 'text-gray-300'}>
|
||||||
>
|
|
||||||
{copied ? (
|
{copied ? (
|
||||||
<>
|
<>
|
||||||
<path
|
<path
|
||||||
@ -69,7 +71,7 @@ const Pre = ({ children }: Props) => {
|
|||||||
|
|
||||||
<pre>{children}</pre>
|
<pre>{children}</pre>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default Pre
|
export default Pre;
|
||||||
|
@ -1,21 +1,21 @@
|
|||||||
import Head from 'next/head'
|
import Head from 'next/head';
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router';
|
||||||
import siteMetadata from '@/data/siteMetadata'
|
import siteMetadata from '@/data/siteMetadata';
|
||||||
import { AuthorFrontMatter } from 'types/AuthorFrontMatter'
|
import { AuthorFrontMatter } from 'types/AuthorFrontMatter';
|
||||||
import { PostFrontMatter } from 'types/PostFrontMatter'
|
import { PostFrontMatter } from 'types/PostFrontMatter';
|
||||||
|
|
||||||
interface CommonSEOProps {
|
interface CommonSEOProps {
|
||||||
title: string
|
title: string;
|
||||||
description: string
|
description: string;
|
||||||
ogType: string
|
ogType: string;
|
||||||
ogImage:
|
ogImage:
|
||||||
| string
|
| string
|
||||||
| {
|
| {
|
||||||
'@type': string
|
'@type': string;
|
||||||
url: string
|
url: string;
|
||||||
}[]
|
}[];
|
||||||
twImage: string
|
twImage: string;
|
||||||
canonicalUrl?: string
|
canonicalUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CommonSEO = ({
|
const CommonSEO = ({
|
||||||
@ -26,19 +26,24 @@ const CommonSEO = ({
|
|||||||
twImage,
|
twImage,
|
||||||
canonicalUrl,
|
canonicalUrl,
|
||||||
}: CommonSEOProps) => {
|
}: CommonSEOProps) => {
|
||||||
const router = useRouter()
|
const router = useRouter();
|
||||||
return (
|
return (
|
||||||
<Head>
|
<Head>
|
||||||
<title>{title}</title>
|
<title>{title}</title>
|
||||||
<meta name="robots" content="follow, index" />
|
<meta name="robots" content="follow, index" />
|
||||||
<meta name="description" content={description} />
|
<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:type" content={ogType} />
|
||||||
<meta property="og:site_name" content={siteMetadata.title} />
|
<meta property="og:site_name" content={siteMetadata.title} />
|
||||||
<meta property="og:description" content={description} />
|
<meta property="og:description" content={description} />
|
||||||
<meta property="og:title" content={title} />
|
<meta property="og:title" content={title} />
|
||||||
{Array.isArray(ogImage) ? (
|
{Array.isArray(ogImage) ? (
|
||||||
ogImage.map(({ url }) => <meta property="og:image" content={url} key={url} />)
|
ogImage.map(({ url }) => (
|
||||||
|
<meta property="og:image" content={url} key={url} />
|
||||||
|
))
|
||||||
) : (
|
) : (
|
||||||
<meta property="og:image" content={ogImage} key={ogImage} />
|
<meta property="og:image" content={ogImage} key={ogImage} />
|
||||||
)}
|
)}
|
||||||
@ -49,20 +54,24 @@ const CommonSEO = ({
|
|||||||
<meta name="twitter:image" content={twImage} />
|
<meta name="twitter:image" content={twImage} />
|
||||||
<link
|
<link
|
||||||
rel="canonical"
|
rel="canonical"
|
||||||
href={canonicalUrl ? canonicalUrl : `${siteMetadata.siteUrl}${router.asPath}`}
|
href={
|
||||||
|
canonicalUrl
|
||||||
|
? canonicalUrl
|
||||||
|
: `${siteMetadata.siteUrl}${router.asPath}`
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</Head>
|
</Head>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
interface PageSEOProps {
|
interface PageSEOProps {
|
||||||
title: string
|
title: string;
|
||||||
description: string
|
description: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PageSEO = ({ title, description }: PageSEOProps) => {
|
export const PageSEO = ({ title, description }: PageSEOProps) => {
|
||||||
const ogImageUrl = siteMetadata.siteUrl + siteMetadata.socialBanner
|
const ogImageUrl = siteMetadata.siteUrl + siteMetadata.socialBanner;
|
||||||
const twImageUrl = siteMetadata.siteUrl + siteMetadata.socialBanner
|
const twImageUrl = siteMetadata.siteUrl + siteMetadata.socialBanner;
|
||||||
return (
|
return (
|
||||||
<CommonSEO
|
<CommonSEO
|
||||||
title={title}
|
title={title}
|
||||||
@ -71,13 +80,13 @@ export const PageSEO = ({ title, description }: PageSEOProps) => {
|
|||||||
ogImage={ogImageUrl}
|
ogImage={ogImageUrl}
|
||||||
twImage={twImageUrl}
|
twImage={twImageUrl}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export const TagSEO = ({ title, description }: PageSEOProps) => {
|
export const TagSEO = ({ title, description }: PageSEOProps) => {
|
||||||
const ogImageUrl = siteMetadata.siteUrl + siteMetadata.socialBanner
|
const ogImageUrl = siteMetadata.siteUrl + siteMetadata.socialBanner;
|
||||||
const twImageUrl = siteMetadata.siteUrl + siteMetadata.socialBanner
|
const twImageUrl = siteMetadata.siteUrl + siteMetadata.socialBanner;
|
||||||
const router = useRouter()
|
const router = useRouter();
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<CommonSEO
|
<CommonSEO
|
||||||
@ -96,12 +105,12 @@ export const TagSEO = ({ title, description }: PageSEOProps) => {
|
|||||||
/>
|
/>
|
||||||
</Head>
|
</Head>
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
interface BlogSeoProps extends PostFrontMatter {
|
interface BlogSeoProps extends PostFrontMatter {
|
||||||
authorDetails?: AuthorFrontMatter[]
|
authorDetails?: AuthorFrontMatter[];
|
||||||
url: string
|
url: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BlogSEO = ({
|
export const BlogSEO = ({
|
||||||
@ -114,35 +123,35 @@ export const BlogSEO = ({
|
|||||||
images = [],
|
images = [],
|
||||||
canonicalUrl,
|
canonicalUrl,
|
||||||
}: BlogSeoProps) => {
|
}: BlogSeoProps) => {
|
||||||
const publishedAt = new Date(date).toISOString()
|
const publishedAt = new Date(date).toISOString();
|
||||||
const modifiedAt = new Date(lastmod || date).toISOString()
|
const modifiedAt = new Date(lastmod || date).toISOString();
|
||||||
const imagesArr =
|
const imagesArr =
|
||||||
images.length === 0
|
images.length === 0
|
||||||
? [siteMetadata.socialBanner]
|
? [siteMetadata.socialBanner]
|
||||||
: typeof images === 'string'
|
: typeof images === 'string'
|
||||||
? [images]
|
? [images]
|
||||||
: images
|
: images;
|
||||||
|
|
||||||
const featuredImages = imagesArr.map((img) => {
|
const featuredImages = imagesArr.map((img) => {
|
||||||
return {
|
return {
|
||||||
'@type': 'ImageObject',
|
'@type': 'ImageObject',
|
||||||
url: `${siteMetadata.siteUrl}${img}`,
|
url: `${siteMetadata.siteUrl}${img}`,
|
||||||
}
|
};
|
||||||
})
|
});
|
||||||
|
|
||||||
let authorList
|
let authorList;
|
||||||
if (authorDetails) {
|
if (authorDetails) {
|
||||||
authorList = authorDetails.map((author) => {
|
authorList = authorDetails.map((author) => {
|
||||||
return {
|
return {
|
||||||
'@type': 'Person',
|
'@type': 'Person',
|
||||||
name: author.name,
|
name: author.name,
|
||||||
}
|
};
|
||||||
})
|
});
|
||||||
} else {
|
} else {
|
||||||
authorList = {
|
authorList = {
|
||||||
'@type': 'Person',
|
'@type': 'Person',
|
||||||
name: siteMetadata.author,
|
name: siteMetadata.author,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const structuredData = {
|
const structuredData = {
|
||||||
@ -166,9 +175,9 @@ export const BlogSEO = ({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
description: summary,
|
description: summary,
|
||||||
}
|
};
|
||||||
|
|
||||||
const twImageUrl = featuredImages[0].url
|
const twImageUrl = featuredImages[0].url;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -181,8 +190,12 @@ export const BlogSEO = ({
|
|||||||
canonicalUrl={canonicalUrl}
|
canonicalUrl={canonicalUrl}
|
||||||
/>
|
/>
|
||||||
<Head>
|
<Head>
|
||||||
{date && <meta property="article:published_time" content={publishedAt} />}
|
{date && (
|
||||||
{lastmod && <meta property="article:modified_time" content={modifiedAt} />}
|
<meta property="article:published_time" content={publishedAt} />
|
||||||
|
)}
|
||||||
|
{lastmod && (
|
||||||
|
<meta property="article:modified_time" content={modifiedAt} />
|
||||||
|
)}
|
||||||
<script
|
<script
|
||||||
type="application/ld+json"
|
type="application/ld+json"
|
||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{
|
||||||
@ -191,5 +204,5 @@ export const BlogSEO = ({
|
|||||||
/>
|
/>
|
||||||
</Head>
|
</Head>
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
@ -1,34 +1,34 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
const ScrollTopAndComment = () => {
|
const ScrollTopAndComment = () => {
|
||||||
const [show, setShow] = useState(false)
|
const [show, setShow] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleWindowScroll = () => {
|
const handleWindowScroll = () => {
|
||||||
if (window.scrollY > 50) setShow(true)
|
if (window.scrollY > 50) setShow(true);
|
||||||
else setShow(false)
|
else setShow(false);
|
||||||
}
|
};
|
||||||
|
|
||||||
window.addEventListener('scroll', handleWindowScroll)
|
window.addEventListener('scroll', handleWindowScroll);
|
||||||
return () => window.removeEventListener('scroll', handleWindowScroll)
|
return () => window.removeEventListener('scroll', handleWindowScroll);
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
const handleScrollTop = () => {
|
const handleScrollTop = () => {
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
}
|
};
|
||||||
const handleScrollToComment = () => {
|
const handleScrollToComment = () => {
|
||||||
document.getElementById('comment').scrollIntoView()
|
document.getElementById('comment').scrollIntoView();
|
||||||
}
|
};
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`fixed right-8 bottom-8 hidden flex-col gap-3 ${show ? 'md:flex' : 'md:hidden'}`}
|
className={`fixed right-8 bottom-8 hidden flex-col gap-3 ${
|
||||||
>
|
show ? 'md:flex' : 'md:hidden'
|
||||||
|
}`}>
|
||||||
<button
|
<button
|
||||||
aria-label="Scroll To Comment"
|
aria-label="Scroll To Comment"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleScrollToComment}
|
onClick={handleScrollToComment}
|
||||||
className="rounded-full bg-gray-200 p-2 text-gray-500 transition-all hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-400 dark:hover:bg-gray-600"
|
className="rounded-full bg-gray-200 p-2 text-gray-500 transition-all hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-400 dark:hover:bg-gray-600">
|
||||||
>
|
|
||||||
<svg className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
<svg className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||||
<path
|
<path
|
||||||
fillRule="evenodd"
|
fillRule="evenodd"
|
||||||
@ -41,8 +41,7 @@ const ScrollTopAndComment = () => {
|
|||||||
aria-label="Scroll To Top"
|
aria-label="Scroll To Top"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleScrollTop}
|
onClick={handleScrollTop}
|
||||||
className="rounded-full bg-gray-200 p-2 text-gray-500 transition-all hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-400 dark:hover:bg-gray-600"
|
className="rounded-full bg-gray-200 p-2 text-gray-500 transition-all hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-400 dark:hover:bg-gray-600">
|
||||||
>
|
|
||||||
<svg className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
<svg className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||||
<path
|
<path
|
||||||
fillRule="evenodd"
|
fillRule="evenodd"
|
||||||
@ -52,7 +51,7 @@ const ScrollTopAndComment = () => {
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default ScrollTopAndComment
|
export default ScrollTopAndComment;
|
||||||
|
@ -1,9 +1,13 @@
|
|||||||
import { ReactNode } from 'react'
|
import { ReactNode } from 'react';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
children: ReactNode
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SectionContainer({ children }: Props) {
|
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>
|
return (
|
||||||
|
<div className="mx-auto max-w-3xl px-4 sm:px-6 xl:max-w-5xl xl:px-0">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,12 +1,12 @@
|
|||||||
import { Toc } from 'types/Toc'
|
import { Toc } from 'types/Toc';
|
||||||
|
|
||||||
interface TOCInlineProps {
|
interface TOCInlineProps {
|
||||||
toc: Toc
|
toc: Toc;
|
||||||
indentDepth?: number
|
indentDepth?: number;
|
||||||
fromHeading?: number
|
fromHeading?: number;
|
||||||
toHeading?: number
|
toHeading?: number;
|
||||||
asDisclosure?: boolean
|
asDisclosure?: boolean;
|
||||||
exclude?: string | string[]
|
exclude?: string | string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -34,35 +34,41 @@ const TOCInline = ({
|
|||||||
}: TOCInlineProps) => {
|
}: TOCInlineProps) => {
|
||||||
const re = Array.isArray(exclude)
|
const re = Array.isArray(exclude)
|
||||||
? new RegExp('^(' + exclude.join('|') + ')$', 'i')
|
? new RegExp('^(' + exclude.join('|') + ')$', 'i')
|
||||||
: new RegExp('^(' + exclude + ')$', 'i')
|
: new RegExp('^(' + exclude + ')$', 'i');
|
||||||
|
|
||||||
const filteredToc = toc.filter(
|
const filteredToc = toc.filter(
|
||||||
(heading) =>
|
(heading) =>
|
||||||
heading.depth >= fromHeading && heading.depth <= toHeading && !re.test(heading.value)
|
heading.depth >= fromHeading &&
|
||||||
)
|
heading.depth <= toHeading &&
|
||||||
|
!re.test(heading.value)
|
||||||
|
);
|
||||||
|
|
||||||
const tocList = (
|
const tocList = (
|
||||||
<ul>
|
<ul>
|
||||||
{filteredToc.map((heading) => (
|
{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>
|
<a href={heading.url}>{heading.value}</a>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
)
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{asDisclosure ? (
|
{asDisclosure ? (
|
||||||
<details open>
|
<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>
|
<div className="ml-6">{tocList}</div>
|
||||||
</details>
|
</details>
|
||||||
) : (
|
) : (
|
||||||
tocList
|
tocList
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default TOCInline
|
export default TOCInline;
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
import Link from 'next/link'
|
import Link from 'next/link';
|
||||||
import kebabCase from '@/lib/utils/kebabCase'
|
import kebabCase from '@/lib/utils/kebabCase';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
text: string
|
text: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Tag = ({ text }: Props) => {
|
const Tag = ({ text }: Props) => {
|
||||||
@ -12,7 +12,7 @@ const Tag = ({ text }: Props) => {
|
|||||||
{text.split(' ').join('-')}
|
{text.split(' ').join('-')}
|
||||||
</a>
|
</a>
|
||||||
</Link>
|
</Link>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default Tag
|
export default Tag;
|
||||||
|
@ -1,26 +1,28 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react';
|
||||||
import { useTheme } from 'next-themes'
|
import { useTheme } from 'next-themes';
|
||||||
|
|
||||||
const ThemeSwitch = () => {
|
const ThemeSwitch = () => {
|
||||||
const [mounted, setMounted] = useState(false)
|
const [mounted, setMounted] = useState(false);
|
||||||
const { theme, setTheme, resolvedTheme } = useTheme()
|
const { theme, setTheme, resolvedTheme } = useTheme();
|
||||||
|
|
||||||
// When mounted on client, now we can show the UI
|
// When mounted on client, now we can show the UI
|
||||||
useEffect(() => setMounted(true), [])
|
useEffect(() => setMounted(true), []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
aria-label="Toggle Dark Mode"
|
aria-label="Toggle Dark Mode"
|
||||||
type="button"
|
type="button"
|
||||||
className="ml-1 mr-1 h-8 w-8 rounded p-1 sm:ml-4"
|
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
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
viewBox="0 0 20 20"
|
viewBox="0 0 20 20"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
className="text-gray-900 dark:text-gray-100"
|
className="text-gray-900 dark:text-gray-100">
|
||||||
>
|
|
||||||
{mounted && (theme === 'dark' || resolvedTheme === 'dark') ? (
|
{mounted && (theme === 'dark' || resolvedTheme === 'dark') ? (
|
||||||
<path
|
<path
|
||||||
fillRule="evenodd"
|
fillRule="evenodd"
|
||||||
@ -32,7 +34,7 @@ const ThemeSwitch = () => {
|
|||||||
)}
|
)}
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</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 = () => {
|
const GAScript = () => {
|
||||||
return (
|
return (
|
||||||
@ -21,10 +21,10 @@ const GAScript = () => {
|
|||||||
`}
|
`}
|
||||||
</Script>
|
</Script>
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default GAScript
|
export default GAScript;
|
||||||
|
|
||||||
// https://developers.google.com/analytics/devguides/collection/gtagjs/events
|
// https://developers.google.com/analytics/devguides/collection/gtagjs/events
|
||||||
export const logEvent = (action, category, label, value) => {
|
export const logEvent = (action, category, label, value) => {
|
||||||
@ -32,5 +32,5 @@ export const logEvent = (action, category, label, value) => {
|
|||||||
event_category: category,
|
event_category: category,
|
||||||
event_label: label,
|
event_label: label,
|
||||||
value: value,
|
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 = () => {
|
const PlausibleScript = () => {
|
||||||
return (
|
return (
|
||||||
@ -16,12 +16,12 @@ const PlausibleScript = () => {
|
|||||||
`}
|
`}
|
||||||
</Script>
|
</Script>
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default PlausibleScript
|
export default PlausibleScript;
|
||||||
|
|
||||||
// https://plausible.io/docs/custom-event-goals
|
// https://plausible.io/docs/custom-event-goals
|
||||||
export const logEvent = (eventName, ...rest) => {
|
export const logEvent = (eventName, ...rest) => {
|
||||||
return window.plausible?.(eventName, ...rest)
|
return window.plausible?.(eventName, ...rest);
|
||||||
}
|
};
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import Script from 'next/script'
|
import Script from 'next/script';
|
||||||
|
|
||||||
const SimpleAnalyticsScript = () => {
|
const SimpleAnalyticsScript = () => {
|
||||||
return (
|
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]};
|
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>
|
||||||
<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
|
// https://docs.simpleanalytics.com/events
|
||||||
export const logEvent = (eventName, callback) => {
|
export const logEvent = (eventName, callback) => {
|
||||||
if (callback) {
|
if (callback) {
|
||||||
return window.sa_event?.(eventName, callback)
|
return window.sa_event?.(eventName, callback);
|
||||||
} else {
|
} 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 = () => {
|
const UmamiScript = () => {
|
||||||
return (
|
return (
|
||||||
@ -12,7 +12,7 @@ const UmamiScript = () => {
|
|||||||
src="https://umami.example.com/umami.js" // Replace with your umami instance
|
src="https://umami.example.com/umami.js" // Replace with your umami instance
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default UmamiScript
|
export default UmamiScript;
|
||||||
|
@ -1,28 +1,32 @@
|
|||||||
import GA from './GoogleAnalytics'
|
import GA from './GoogleAnalytics';
|
||||||
import Plausible from './Plausible'
|
import Plausible from './Plausible';
|
||||||
import SimpleAnalytics from './SimpleAnalytics'
|
import SimpleAnalytics from './SimpleAnalytics';
|
||||||
import Umami from './Umami'
|
import Umami from './Umami';
|
||||||
import siteMetadata from '@/data/siteMetadata'
|
import siteMetadata from '@/data/siteMetadata';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
gtag?: (...args: any[]) => void
|
gtag?: (...args: any[]) => void;
|
||||||
plausible?: (...args: any[]) => void
|
plausible?: (...args: any[]) => void;
|
||||||
sa_event?: (...args: any[]) => void
|
sa_event?: (...args: any[]) => void;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const isProduction = process.env.NODE_ENV === 'production'
|
const isProduction = process.env.NODE_ENV === 'production';
|
||||||
|
|
||||||
const Analytics = () => {
|
const Analytics = () => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{isProduction && siteMetadata.analytics.plausibleDataDomain && <Plausible />}
|
{isProduction && siteMetadata.analytics.plausibleDataDomain && (
|
||||||
{isProduction && siteMetadata.analytics.simpleAnalytics && <SimpleAnalytics />}
|
<Plausible />
|
||||||
|
)}
|
||||||
|
{isProduction && siteMetadata.analytics.simpleAnalytics && (
|
||||||
|
<SimpleAnalytics />
|
||||||
|
)}
|
||||||
{isProduction && siteMetadata.analytics.umamiWebsiteId && <Umami />}
|
{isProduction && siteMetadata.analytics.umamiWebsiteId && <Umami />}
|
||||||
{isProduction && siteMetadata.analytics.googleAnalyticsId && <GA />}
|
{isProduction && siteMetadata.analytics.googleAnalyticsId && <GA />}
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default Analytics
|
export default Analytics;
|
||||||
|
@ -1,30 +1,33 @@
|
|||||||
import React, { useMemo, useState } from 'react'
|
import React, { useMemo, useState } from 'react';
|
||||||
|
|
||||||
import siteMetadata from '@/data/siteMetadata'
|
import siteMetadata from '@/data/siteMetadata';
|
||||||
import { PostFrontMatter } from 'types/PostFrontMatter'
|
import { PostFrontMatter } from 'types/PostFrontMatter';
|
||||||
import { useTheme } from 'next-themes'
|
import { useTheme } from 'next-themes';
|
||||||
import ReactCommento from './commento/ReactCommento'
|
import ReactCommento from './commento/ReactCommento';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
frontMatter: PostFrontMatter
|
frontMatter: PostFrontMatter;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Commento = ({ frontMatter }: Props) => {
|
const Commento = ({ frontMatter }: Props) => {
|
||||||
const { resolvedTheme } = useTheme()
|
const { resolvedTheme } = useTheme();
|
||||||
const commentsTheme = useMemo(() => {
|
const commentsTheme = useMemo(() => {
|
||||||
switch (resolvedTheme) {
|
switch (resolvedTheme) {
|
||||||
case 'light':
|
case 'light':
|
||||||
case 'dark':
|
case 'dark':
|
||||||
return resolvedTheme
|
return resolvedTheme;
|
||||||
default:
|
default:
|
||||||
return 'auto'
|
return 'auto';
|
||||||
}
|
}
|
||||||
}, [resolvedTheme])
|
}, [resolvedTheme]);
|
||||||
return (
|
return (
|
||||||
<div className="my-2">
|
<div className="my-2">
|
||||||
<ReactCommento url={siteMetadata.comment.commentoConfig.url} pageId={frontMatter.slug} />
|
<ReactCommento
|
||||||
|
url={siteMetadata.comment.commentoConfig.url}
|
||||||
|
pageId={frontMatter.slug}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default Commento
|
export default Commento;
|
||||||
|
@ -1,25 +1,25 @@
|
|||||||
import React, { useMemo, useState } from 'react'
|
import React, { useMemo, useState } from 'react';
|
||||||
|
|
||||||
import siteMetadata from '@/data/siteMetadata'
|
import siteMetadata from '@/data/siteMetadata';
|
||||||
import { PostFrontMatter } from 'types/PostFrontMatter'
|
import { PostFrontMatter } from 'types/PostFrontMatter';
|
||||||
import { ReactCusdis } from 'react-cusdis'
|
import { ReactCusdis } from 'react-cusdis';
|
||||||
import { useTheme } from 'next-themes'
|
import { useTheme } from 'next-themes';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
frontMatter: PostFrontMatter
|
frontMatter: PostFrontMatter;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Cusdis = ({ frontMatter }: Props) => {
|
const Cusdis = ({ frontMatter }: Props) => {
|
||||||
const { resolvedTheme } = useTheme()
|
const { resolvedTheme } = useTheme();
|
||||||
const commentsTheme = useMemo(() => {
|
const commentsTheme = useMemo(() => {
|
||||||
switch (resolvedTheme) {
|
switch (resolvedTheme) {
|
||||||
case 'light':
|
case 'light':
|
||||||
case 'dark':
|
case 'dark':
|
||||||
return resolvedTheme
|
return resolvedTheme;
|
||||||
default:
|
default:
|
||||||
return 'auto'
|
return 'auto';
|
||||||
}
|
}
|
||||||
}, [resolvedTheme])
|
}, [resolvedTheme]);
|
||||||
return (
|
return (
|
||||||
<div className="my-2">
|
<div className="my-2">
|
||||||
<ReactCusdis
|
<ReactCusdis
|
||||||
@ -35,7 +35,7 @@ const Cusdis = ({ frontMatter }: Props) => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default Cusdis
|
export default Cusdis;
|
||||||
|
@ -1,46 +1,51 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
import siteMetadata from '@/data/siteMetadata'
|
import siteMetadata from '@/data/siteMetadata';
|
||||||
import { PostFrontMatter } from 'types/PostFrontMatter'
|
import { PostFrontMatter } from 'types/PostFrontMatter';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
frontMatter: PostFrontMatter
|
frontMatter: PostFrontMatter;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Disqus = ({ frontMatter }: Props) => {
|
const Disqus = ({ frontMatter }: Props) => {
|
||||||
const [enableLoadComments, setEnabledLoadComments] = useState(true)
|
const [enableLoadComments, setEnabledLoadComments] = useState(true);
|
||||||
|
|
||||||
const COMMENTS_ID = 'disqus_thread'
|
const COMMENTS_ID = 'disqus_thread';
|
||||||
|
|
||||||
function LoadComments() {
|
function LoadComments() {
|
||||||
setEnabledLoadComments(false)
|
setEnabledLoadComments(false);
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
window.disqus_config = function () {
|
window.disqus_config = function () {
|
||||||
this.page.url = window.location.href
|
this.page.url = window.location.href;
|
||||||
this.page.identifier = frontMatter.slug
|
this.page.identifier = frontMatter.slug;
|
||||||
}
|
};
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
if (window.DISQUS === undefined) {
|
if (window.DISQUS === undefined) {
|
||||||
const script = document.createElement('script')
|
const script = document.createElement('script');
|
||||||
script.src = 'https://' + siteMetadata.comment.disqusConfig.shortname + '.disqus.com/embed.js'
|
script.src =
|
||||||
|
'https://' +
|
||||||
|
siteMetadata.comment.disqusConfig.shortname +
|
||||||
|
'.disqus.com/embed.js';
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
script.setAttribute('data-timestamp', +new Date())
|
script.setAttribute('data-timestamp', +new Date());
|
||||||
script.setAttribute('crossorigin', 'anonymous')
|
script.setAttribute('crossorigin', 'anonymous');
|
||||||
script.async = true
|
script.async = true;
|
||||||
document.body.appendChild(script)
|
document.body.appendChild(script);
|
||||||
} else {
|
} else {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
window.DISQUS.reset({ reload: true })
|
window.DISQUS.reset({ reload: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pt-6 pb-6 text-center text-gray-700 dark:text-gray-300">
|
<div className="pt-6 pb-6 text-center text-gray-700 dark:text-gray-300">
|
||||||
{enableLoadComments && <button onClick={LoadComments}>Load Comments</button>}
|
{enableLoadComments && (
|
||||||
|
<button onClick={LoadComments}>Load Comments</button>
|
||||||
|
)}
|
||||||
<div className="disqus-frame" id={COMMENTS_ID} />
|
<div className="disqus-frame" id={COMMENTS_ID} />
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default Disqus
|
export default Disqus;
|
||||||
|
@ -1,61 +1,78 @@
|
|||||||
import React, { useState, useEffect, useCallback } from 'react'
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import { useTheme } from 'next-themes'
|
import { useTheme } from 'next-themes';
|
||||||
|
|
||||||
import siteMetadata from '@/data/siteMetadata'
|
import siteMetadata from '@/data/siteMetadata';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
mapping: string
|
mapping: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Giscus = ({ mapping }: Props) => {
|
const Giscus = ({ mapping }: Props) => {
|
||||||
const [enableLoadComments, setEnabledLoadComments] = useState(true)
|
const [enableLoadComments, setEnabledLoadComments] = useState(true);
|
||||||
const { theme, resolvedTheme } = useTheme()
|
const { theme, resolvedTheme } = useTheme();
|
||||||
const commentsTheme =
|
const commentsTheme =
|
||||||
siteMetadata.comment.giscusConfig.themeURL === ''
|
siteMetadata.comment.giscusConfig.themeURL === ''
|
||||||
? theme === 'dark' || resolvedTheme === 'dark'
|
? theme === 'dark' || resolvedTheme === 'dark'
|
||||||
? siteMetadata.comment.giscusConfig.darkTheme
|
? siteMetadata.comment.giscusConfig.darkTheme
|
||||||
: siteMetadata.comment.giscusConfig.theme
|
: siteMetadata.comment.giscusConfig.theme
|
||||||
: siteMetadata.comment.giscusConfig.themeURL
|
: siteMetadata.comment.giscusConfig.themeURL;
|
||||||
|
|
||||||
const COMMENTS_ID = 'comments-container'
|
const COMMENTS_ID = 'comments-container';
|
||||||
|
|
||||||
const LoadComments = useCallback(() => {
|
const LoadComments = useCallback(() => {
|
||||||
setEnabledLoadComments(false)
|
setEnabledLoadComments(false);
|
||||||
const script = document.createElement('script')
|
const script = document.createElement('script');
|
||||||
script.src = 'https://giscus.app/client.js'
|
script.src = 'https://giscus.app/client.js';
|
||||||
script.setAttribute('data-repo', siteMetadata.comment.giscusConfig.repo)
|
script.setAttribute('data-repo', siteMetadata.comment.giscusConfig.repo);
|
||||||
script.setAttribute('data-repo-id', siteMetadata.comment.giscusConfig.repositoryId)
|
script.setAttribute(
|
||||||
script.setAttribute('data-category', siteMetadata.comment.giscusConfig.category)
|
'data-repo-id',
|
||||||
script.setAttribute('data-category-id', siteMetadata.comment.giscusConfig.categoryId)
|
siteMetadata.comment.giscusConfig.repositoryId
|
||||||
script.setAttribute('data-mapping', mapping)
|
);
|
||||||
script.setAttribute('data-reactions-enabled', siteMetadata.comment.giscusConfig.reactions)
|
script.setAttribute(
|
||||||
script.setAttribute('data-emit-metadata', siteMetadata.comment.giscusConfig.metadata)
|
'data-category',
|
||||||
script.setAttribute('data-theme', commentsTheme)
|
siteMetadata.comment.giscusConfig.category
|
||||||
script.setAttribute('crossorigin', 'anonymous')
|
);
|
||||||
script.async = true
|
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)
|
const comments = document.getElementById(COMMENTS_ID);
|
||||||
if (comments) comments.appendChild(script)
|
if (comments) comments.appendChild(script);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
const comments = document.getElementById(COMMENTS_ID)
|
const comments = document.getElementById(COMMENTS_ID);
|
||||||
if (comments) comments.innerHTML = ''
|
if (comments) comments.innerHTML = '';
|
||||||
}
|
};
|
||||||
}, [commentsTheme, mapping])
|
}, [commentsTheme, mapping]);
|
||||||
|
|
||||||
// Reload on theme change
|
// Reload on theme change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const iframe = document.querySelector('iframe.giscus-frame')
|
const iframe = document.querySelector('iframe.giscus-frame');
|
||||||
if (!iframe) return
|
if (!iframe) return;
|
||||||
LoadComments()
|
LoadComments();
|
||||||
}, [LoadComments])
|
}, [LoadComments]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pt-6 pb-6 text-center text-gray-700 dark:text-gray-300">
|
<div className="pt-6 pb-6 text-center text-gray-700 dark:text-gray-300">
|
||||||
{enableLoadComments && <button onClick={LoadComments}>Load Comments</button>}
|
{enableLoadComments && (
|
||||||
|
<button onClick={LoadComments}>Load Comments</button>
|
||||||
|
)}
|
||||||
<div className="giscus" id={COMMENTS_ID} />
|
<div className="giscus" id={COMMENTS_ID} />
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default Giscus
|
export default Giscus;
|
||||||
|
@ -1,56 +1,58 @@
|
|||||||
import React, { useState, useEffect, useCallback } from 'react'
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import { useTheme } from 'next-themes'
|
import { useTheme } from 'next-themes';
|
||||||
|
|
||||||
import siteMetadata from '@/data/siteMetadata'
|
import siteMetadata from '@/data/siteMetadata';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
issueTerm: string
|
issueTerm: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Utterances = ({ issueTerm }: Props) => {
|
const Utterances = ({ issueTerm }: Props) => {
|
||||||
const [enableLoadComments, setEnabledLoadComments] = useState(true)
|
const [enableLoadComments, setEnabledLoadComments] = useState(true);
|
||||||
const { theme, resolvedTheme } = useTheme()
|
const { theme, resolvedTheme } = useTheme();
|
||||||
const commentsTheme =
|
const commentsTheme =
|
||||||
theme === 'dark' || resolvedTheme === 'dark'
|
theme === 'dark' || resolvedTheme === 'dark'
|
||||||
? siteMetadata.comment.utterancesConfig.darkTheme
|
? siteMetadata.comment.utterancesConfig.darkTheme
|
||||||
: siteMetadata.comment.utterancesConfig.theme
|
: siteMetadata.comment.utterancesConfig.theme;
|
||||||
|
|
||||||
const COMMENTS_ID = 'comments-container'
|
const COMMENTS_ID = 'comments-container';
|
||||||
|
|
||||||
const LoadComments = useCallback(() => {
|
const LoadComments = useCallback(() => {
|
||||||
setEnabledLoadComments(false)
|
setEnabledLoadComments(false);
|
||||||
const script = document.createElement('script')
|
const script = document.createElement('script');
|
||||||
script.src = 'https://utteranc.es/client.js'
|
script.src = 'https://utteranc.es/client.js';
|
||||||
script.setAttribute('repo', siteMetadata.comment.utterancesConfig.repo)
|
script.setAttribute('repo', siteMetadata.comment.utterancesConfig.repo);
|
||||||
script.setAttribute('issue-term', issueTerm)
|
script.setAttribute('issue-term', issueTerm);
|
||||||
script.setAttribute('label', siteMetadata.comment.utterancesConfig.label)
|
script.setAttribute('label', siteMetadata.comment.utterancesConfig.label);
|
||||||
script.setAttribute('theme', commentsTheme)
|
script.setAttribute('theme', commentsTheme);
|
||||||
script.setAttribute('crossorigin', 'anonymous')
|
script.setAttribute('crossorigin', 'anonymous');
|
||||||
script.async = true
|
script.async = true;
|
||||||
|
|
||||||
const comments = document.getElementById(COMMENTS_ID)
|
const comments = document.getElementById(COMMENTS_ID);
|
||||||
if (comments) comments.appendChild(script)
|
if (comments) comments.appendChild(script);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
const comments = document.getElementById(COMMENTS_ID)
|
const comments = document.getElementById(COMMENTS_ID);
|
||||||
if (comments) comments.innerHTML = ''
|
if (comments) comments.innerHTML = '';
|
||||||
}
|
};
|
||||||
}, [commentsTheme, issueTerm])
|
}, [commentsTheme, issueTerm]);
|
||||||
|
|
||||||
// Reload on theme change
|
// Reload on theme change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const iframe = document.querySelector('iframe.utterances-frame')
|
const iframe = document.querySelector('iframe.utterances-frame');
|
||||||
if (!iframe) return
|
if (!iframe) return;
|
||||||
LoadComments()
|
LoadComments();
|
||||||
}, [LoadComments])
|
}, [LoadComments]);
|
||||||
|
|
||||||
// Added `relative` to fix a weird bug with `utterances-frame` position
|
// Added `relative` to fix a weird bug with `utterances-frame` position
|
||||||
return (
|
return (
|
||||||
<div className="pt-6 pb-6 text-center text-gray-700 dark:text-gray-300">
|
<div className="pt-6 pb-6 text-center text-gray-700 dark:text-gray-300">
|
||||||
{enableLoadComments && <button onClick={LoadComments}>Load Comments</button>}
|
{enableLoadComments && (
|
||||||
|
<button onClick={LoadComments}>Load Comments</button>
|
||||||
|
)}
|
||||||
<div className="utterances-frame relative" id={COMMENTS_ID} />
|
<div className="utterances-frame relative" id={COMMENTS_ID} />
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default Utterances
|
export default Utterances;
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
import { createRef } from 'preact'
|
import { createRef } from 'preact';
|
||||||
import React, { useLayoutEffect, useMemo, useRef } from 'react'
|
import React, { useLayoutEffect, useMemo, useRef } from 'react';
|
||||||
|
|
||||||
interface DataAttributes {
|
interface DataAttributes {
|
||||||
[key: string]: string | boolean | undefined
|
[key: string]: string | boolean | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const insertScript = (
|
const insertScript = (
|
||||||
@ -11,28 +11,28 @@ const insertScript = (
|
|||||||
dataAttributes: DataAttributes,
|
dataAttributes: DataAttributes,
|
||||||
onload = () => {}
|
onload = () => {}
|
||||||
) => {
|
) => {
|
||||||
const script = window.document.createElement('script')
|
const script = window.document.createElement('script');
|
||||||
script.async = true
|
script.async = true;
|
||||||
script.src = src
|
script.src = src;
|
||||||
script.id = id
|
script.id = id;
|
||||||
if (document.getElementById(id)) {
|
if (document.getElementById(id)) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
script.addEventListener('load', onload, { capture: true, once: true })
|
script.addEventListener('load', onload, { capture: true, once: true });
|
||||||
|
|
||||||
Object.entries(dataAttributes).forEach(([key, value]) => {
|
Object.entries(dataAttributes).forEach(([key, value]) => {
|
||||||
if (value === undefined) {
|
if (value === undefined) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
script.setAttribute(`data-${key}`, value.toString())
|
script.setAttribute(`data-${key}`, value.toString());
|
||||||
})
|
});
|
||||||
|
|
||||||
document.body.appendChild(script)
|
document.body.appendChild(script);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
script.remove()
|
script.remove();
|
||||||
}
|
};
|
||||||
}
|
};
|
||||||
|
|
||||||
const ReactCommento = ({
|
const ReactCommento = ({
|
||||||
url,
|
url,
|
||||||
@ -42,22 +42,25 @@ const ReactCommento = ({
|
|||||||
hideDeleted,
|
hideDeleted,
|
||||||
pageId,
|
pageId,
|
||||||
}: {
|
}: {
|
||||||
url: string
|
url: string;
|
||||||
cssOverride?: string
|
cssOverride?: string;
|
||||||
autoInit?: boolean
|
autoInit?: boolean;
|
||||||
noFonts?: boolean
|
noFonts?: boolean;
|
||||||
hideDeleted?: boolean
|
hideDeleted?: boolean;
|
||||||
pageId?: string
|
pageId?: string;
|
||||||
}) => {
|
}) => {
|
||||||
const containerId = useMemo(() => `commento-${Math.random().toString().slice(2, 8)}`, [])
|
const containerId = useMemo(
|
||||||
const container = createRef<HTMLDivElement>()
|
() => `commento-${Math.random().toString().slice(2, 8)}`,
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
const container = createRef<HTMLDivElement>();
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!window) {
|
if (!window) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
window['commento'] = container.current
|
window['commento'] = container.current;
|
||||||
|
|
||||||
const removeScript = insertScript(
|
const removeScript = insertScript(
|
||||||
url,
|
url,
|
||||||
@ -71,11 +74,20 @@ const ReactCommento = ({
|
|||||||
'id-root': containerId,
|
'id-root': containerId,
|
||||||
},
|
},
|
||||||
() => {
|
() => {
|
||||||
removeScript()
|
removeScript();
|
||||||
}
|
}
|
||||||
)
|
);
|
||||||
}, [autoInit, cssOverride, hideDeleted, noFonts, pageId, url, containerId, container])
|
}, [
|
||||||
|
autoInit,
|
||||||
|
cssOverride,
|
||||||
|
hideDeleted,
|
||||||
|
noFonts,
|
||||||
|
pageId,
|
||||||
|
url,
|
||||||
|
containerId,
|
||||||
|
container,
|
||||||
|
]);
|
||||||
|
|
||||||
return <div ref={container} id={containerId} />
|
return <div ref={container} id={containerId} />;
|
||||||
}
|
};
|
||||||
export default ReactCommento
|
export default ReactCommento;
|
||||||
|
@ -1,68 +1,69 @@
|
|||||||
import React from 'react'
|
import React from 'react';
|
||||||
|
|
||||||
import siteMetadata from '@/data/siteMetadata'
|
import siteMetadata from '@/data/siteMetadata';
|
||||||
import dynamic from 'next/dynamic'
|
import dynamic from 'next/dynamic';
|
||||||
import { PostFrontMatter } from 'types/PostFrontMatter'
|
import { PostFrontMatter } from 'types/PostFrontMatter';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
frontMatter: PostFrontMatter
|
frontMatter: PostFrontMatter;
|
||||||
}
|
}
|
||||||
|
|
||||||
const UtterancesComponent = dynamic(
|
const UtterancesComponent = dynamic(
|
||||||
() => {
|
() => {
|
||||||
return import('@/components/comments/Utterances')
|
return import('@/components/comments/Utterances');
|
||||||
},
|
},
|
||||||
{ ssr: false }
|
{ ssr: false }
|
||||||
)
|
);
|
||||||
const GiscusComponent = dynamic(
|
const GiscusComponent = dynamic(
|
||||||
() => {
|
() => {
|
||||||
return import('@/components/comments/Giscus')
|
return import('@/components/comments/Giscus');
|
||||||
},
|
},
|
||||||
{ ssr: false }
|
{ ssr: false }
|
||||||
)
|
);
|
||||||
const DisqusComponent = dynamic(
|
const DisqusComponent = dynamic(
|
||||||
() => {
|
() => {
|
||||||
return import('@/components/comments/Disqus')
|
return import('@/components/comments/Disqus');
|
||||||
},
|
},
|
||||||
{ ssr: false }
|
{ ssr: false }
|
||||||
)
|
);
|
||||||
const CusdisComponent = dynamic(
|
const CusdisComponent = dynamic(
|
||||||
() => {
|
() => {
|
||||||
return import('@/components/comments/Cusdis')
|
return import('@/components/comments/Cusdis');
|
||||||
},
|
},
|
||||||
{ ssr: false }
|
{ ssr: false }
|
||||||
)
|
);
|
||||||
const CommentoComponent = dynamic(
|
const CommentoComponent = dynamic(
|
||||||
() => {
|
() => {
|
||||||
return import('@/components/comments/Commento')
|
return import('@/components/comments/Commento');
|
||||||
},
|
},
|
||||||
{ ssr: false }
|
{ ssr: false }
|
||||||
)
|
);
|
||||||
|
|
||||||
const Comments = ({ frontMatter }: Props) => {
|
const Comments = ({ frontMatter }: Props) => {
|
||||||
let term
|
let term;
|
||||||
switch (
|
switch (
|
||||||
siteMetadata.comment.giscusConfig.mapping ||
|
siteMetadata.comment.giscusConfig.mapping ||
|
||||||
siteMetadata.comment.utterancesConfig.issueTerm
|
siteMetadata.comment.utterancesConfig.issueTerm
|
||||||
) {
|
) {
|
||||||
case 'pathname':
|
case 'pathname':
|
||||||
term = frontMatter.slug
|
term = frontMatter.slug;
|
||||||
break
|
break;
|
||||||
case 'url':
|
case 'url':
|
||||||
term = window.location.href
|
term = window.location.href;
|
||||||
break
|
break;
|
||||||
case 'title':
|
case 'title':
|
||||||
term = frontMatter.title
|
term = frontMatter.title;
|
||||||
break
|
break;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div id="comment">
|
<div id="comment">
|
||||||
{siteMetadata.comment && siteMetadata.comment.provider === 'giscus' && (
|
{siteMetadata.comment && siteMetadata.comment.provider === 'giscus' && (
|
||||||
<GiscusComponent mapping={term} />
|
<GiscusComponent mapping={term} />
|
||||||
)}
|
)}
|
||||||
{siteMetadata.comment && siteMetadata.comment.provider === 'utterances' && (
|
{siteMetadata.comment &&
|
||||||
<UtterancesComponent issueTerm={term} />
|
siteMetadata.comment.provider === 'utterances' && (
|
||||||
)}
|
<UtterancesComponent issueTerm={term} />
|
||||||
|
)}
|
||||||
{siteMetadata.comment && siteMetadata.comment.provider === 'disqus' && (
|
{siteMetadata.comment && siteMetadata.comment.provider === 'disqus' && (
|
||||||
<DisqusComponent frontMatter={frontMatter} />
|
<DisqusComponent frontMatter={frontMatter} />
|
||||||
)}
|
)}
|
||||||
@ -73,7 +74,7 @@ const Comments = ({ frontMatter }: Props) => {
|
|||||||
<CommentoComponent frontMatter={frontMatter} />
|
<CommentoComponent frontMatter={frontMatter} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default Comments
|
export default Comments;
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
import Mail from './mail.svg'
|
import Mail from './mail.svg';
|
||||||
import Github from './github.svg'
|
import Github from './github.svg';
|
||||||
import Facebook from './facebook.svg'
|
import Facebook from './facebook.svg';
|
||||||
import Youtube from './youtube.svg'
|
import Youtube from './youtube.svg';
|
||||||
import Linkedin from './linkedin.svg'
|
import Linkedin from './linkedin.svg';
|
||||||
import Twitter from './twitter.svg'
|
import Twitter from './twitter.svg';
|
||||||
|
|
||||||
// Icons taken from: https://simpleicons.org/
|
// Icons taken from: https://simpleicons.org/
|
||||||
|
|
||||||
@ -14,27 +14,30 @@ const components = {
|
|||||||
youtube: Youtube,
|
youtube: Youtube,
|
||||||
linkedin: Linkedin,
|
linkedin: Linkedin,
|
||||||
twitter: Twitter,
|
twitter: Twitter,
|
||||||
}
|
};
|
||||||
|
|
||||||
const SocialIcon = ({ kind, href, size = 8 }) => {
|
const SocialIcon = ({ kind, href, size = 8 }) => {
|
||||||
if (!href || (kind === 'mail' && !/^mailto:\w+([.-]?\w+)@\w+([.-]?\w+)(.\w{2,3})+$/.test(href)))
|
if (
|
||||||
return null
|
!href ||
|
||||||
|
(kind === 'mail' &&
|
||||||
|
!/^mailto:\w+([.-]?\w+)@\w+([.-]?\w+)(.\w{2,3})+$/.test(href))
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
|
||||||
const SocialSvg = components[kind]
|
const SocialSvg = components[kind];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
className="text-sm text-gray-500 transition hover:text-gray-600"
|
className="text-sm text-gray-500 transition hover:text-gray-600"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
href={href}
|
href={href}>
|
||||||
>
|
|
||||||
<span className="sr-only">{kind}</span>
|
<span className="sr-only">{kind}</span>
|
||||||
<SocialSvg
|
<SocialSvg
|
||||||
className={`fill-current text-gray-700 hover:text-blue-500 dark:text-gray-200 dark:hover:text-blue-400 h-${size} w-${size}`}
|
className={`fill-current text-gray-700 hover:text-blue-500 dark:text-gray-200 dark:hover:text-blue-400 h-${size} w-${size}`}
|
||||||
/>
|
/>
|
||||||
</a>
|
</a>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default SocialIcon
|
export default SocialIcon;
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: Ivan Li
|
name: Ivan Li
|
||||||
avatar: https://pan.ivanli.cc/api/v3/file/source/1234/%E5%A4%B4%E5%83%8F.png?sign=xIgy54DyFRYupxjZJbK02HmpKX8C53YR-O0I18Rxm70%3D%3A0
|
avatar: https://pan.ivanli.cc/api/v3/file/source/1234/头像.png?sign=xIgy54DyFRYupxjZJbK02HmpKX8C53YR-O0I18Rxm70%3D%3A0
|
||||||
occupation: Web Full Stack Developer
|
occupation: Web Full Stack Developer
|
||||||
email: master@ivanli.cc
|
email: master@ivanli.cc
|
||||||
github: https://github.com/IvanLi-CN
|
github: https://github.com/IvanLi-CN
|
||||||
|
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)
|
位置(Location)
|
||||||
先编辑 `/etc/locale.gen`,取消 `en_US.UTF-8 UTF-8` 的注释。
|
先编辑 `/etc/locale.gen`,取消 `en_US.UTF-8 UTF-8` 的注释。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sed -i "s/#en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/" /etc/locale.gen
|
||||||
|
```
|
||||||
|
|
||||||
然后执行:
|
然后执行:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@ -69,10 +74,17 @@ nano /etc/pacman.d/mirrorlist
|
|||||||
```
|
```
|
||||||
|
|
||||||
选择你喜欢并且方便连接的镜像,然后删除该行的“#”取消注释。可以选择一个或多个,在前面的优先级高。
|
选择你喜欢并且方便连接的镜像,然后删除该行的“#”取消注释。可以选择一个或多个,在前面的优先级高。
|
||||||
|
|
||||||
|
开启并行下载,在 `/etc/pacman.conf` 中取消 `ParallelDownloads` 前的注释,值为并行下载数:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sed -i "s/#ParallelDownloads = 5/ParallelDownloads = 5/" /etc/pacman.conf
|
||||||
|
```
|
||||||
|
|
||||||
接下来我们更新已安装的软件,我们的哲学就是时刻保持最新。
|
接下来我们更新已安装的软件,我们的哲学就是时刻保持最新。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pacman -Syu
|
pacman -Syu
|
||||||
```
|
```
|
||||||
|
|
||||||
**一般来说,执行上面的命令后,会拉取索引数据库,之后会优先更新 `archlinuxx-keyring`。如果不是这样的话,应当手动执行下面的代码:**
|
**一般来说,执行上面的命令后,会拉取索引数据库,之后会优先更新 `archlinuxx-keyring`。如果不是这样的话,应当手动执行下面的代码:**
|
||||||
@ -98,6 +110,12 @@ _参考:[Cant Upgrade because of keyring - Technical Issues and Assistance / P
|
|||||||
|
|
||||||
### 3. 创建用户
|
### 3. 创建用户
|
||||||
|
|
||||||
|
首先,安装 `sudo`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pacman -S sudo
|
||||||
|
```
|
||||||
|
|
||||||
让我们给自己分配一个具有 sudo 权限的账户
|
让我们给自己分配一个具有 sudo 权限的账户
|
||||||
|
|
||||||
```zsh
|
```zsh
|
||||||
@ -113,12 +131,6 @@ _参考:[Create a Sudo User on Arch Linux - Vultr.com](https://www.vultr.com/d
|
|||||||
EDITOR=vim visudo
|
EDITOR=vim visudo
|
||||||
```
|
```
|
||||||
|
|
||||||
安装 `sudo`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pacman -S sudo
|
|
||||||
```
|
|
||||||
|
|
||||||
接下来使用刚刚创建的用户登录吧!
|
接下来使用刚刚创建的用户登录吧!
|
||||||
|
|
||||||
### 4. 使用 SSH 远程登录
|
### 4. 使用 SSH 远程登录
|
||||||
|
181
data/blog/debian-desktop-environment.md
Normal file
181
data/blog/debian-desktop-environment.md
Normal file
@ -0,0 +1,181 @@
|
|||||||
|
---
|
||||||
|
title: 在 PVE 宿主机上使用桌面环境
|
||||||
|
date: '2022-10-28'
|
||||||
|
tags: ['PVE', 'PVE', 'DE', '环境搭建', 'Debian']
|
||||||
|
draft: false
|
||||||
|
summary: 虽然 PVE 宿主机不应该安装乱七八糟的东西,但是我穷,为了物尽其用,为了在主力电脑翻车时有一个立即可用的备用环境,所以还是安装了基础的桌面环境。现在的 Linux 桌面环境越来越好了,我选择安装 KDE Plasma 作为桌面环境,并且默认关闭,按需启用。
|
||||||
|
images:
|
||||||
|
[
|
||||||
|
'https://pan.ivanli.cc/api/v3/file/source/2243/1200px-Kde_dragons.png?sign=yGZL9jYeVt53Ve43ddhHt_0EzVV2cW_WbxHc0dEcwWY%3D%3A0',
|
||||||
|
]
|
||||||
|
---
|
||||||
|
|
||||||
|
## 前言
|
||||||
|
|
||||||
|
过几天就是双十一了,或许是我去购物网站上看了下显卡价格吧,我的显卡当晚就闹情绪,不工作还引发宕机。虽然拔了显卡,我还能用核显开机,但是我懒呀,所以我花了一个晚上在 PVE 宿主机上搭了一个临时环境,用于日常娱乐(看番、听歌)和一般工作(敲代码)。还别说,我在一开始装 PVE 时,就预先装上了桌面环境,这就是预判呀!
|
||||||
|
|
||||||
|
现在 Linux 桌面环境已经非常好了,相比 17 年左右的体验,又上了一个新的台阶。不过,作为临时应急环境,倒也不会去装那些没啥用的国产软件,本着够用就好的原则,主要是以 Web App > Web > Linux Client 的顺序挑选软件。一般来说,我用到的也不多:
|
||||||
|
|
||||||
|
- **浏览器:Google Chrome**。主要是好用,能同步,还能远程桌面。
|
||||||
|
|
||||||
|
## 准备
|
||||||
|
|
||||||
|
首先应该拥有自己的账户,否则你将会发现自己无法登录桌面环境。因为桌面环境默认在登录时没有 `root` 用户选项。
|
||||||
|
|
||||||
|
### 创建账户:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
useradd -m ivan
|
||||||
|
passwd ivan
|
||||||
|
usermod -aG wheel ivan
|
||||||
|
```
|
||||||
|
|
||||||
|
给刚刚创建的账户分配一个具有 sudo 权限的账户
|
||||||
|
|
||||||
|
```bash
|
||||||
|
EDITOR=vim visudo
|
||||||
|
```
|
||||||
|
|
||||||
|
找到 `%wheel ALL=(ALL: ALL) ALL` 这行,取消这行的注释。
|
||||||
|
|
||||||
|
现在,你自己的账号具有 sudo 权限了。
|
||||||
|
|
||||||
|
### 生成 SSH 密钥
|
||||||
|
|
||||||
|
2022 年,应该生成 `ed25519` 算法的密钥:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh-keygen -t ed25519
|
||||||
|
```
|
||||||
|
|
||||||
|
## 启用和禁用桌面环境
|
||||||
|
|
||||||
|
**使用 `root` 账户执行下面的命令!**
|
||||||
|
|
||||||
|
查看当前的默认目标:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl get-default
|
||||||
|
```
|
||||||
|
|
||||||
|
临时禁用图形界面:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
init 3
|
||||||
|
```
|
||||||
|
|
||||||
|
临时启用图形界面:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
init 5
|
||||||
|
```
|
||||||
|
|
||||||
|
永久禁用图形界面:重启生效:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl set-default multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
永久启用图形界面,重启生效:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl set-default graphical.target
|
||||||
|
```
|
||||||
|
|
||||||
|
## Google Chrome Browser
|
||||||
|
|
||||||
|
安装方式就是直接[官网下载](https://www.google.com/chrome/)。下载完成后双击打开安装。
|
||||||
|
|
||||||
|
或者通过命令行安装:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
wget https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb
|
||||||
|
|
||||||
|
sudo apt install ./google-chrome-stable_current_amd64.deb
|
||||||
|
```
|
||||||
|
|
||||||
|
安装过程中可能会出错,可以使用命令进行安装,然后根据提示修复问题。修复过程中可能会重启电脑。具体情况我没留意,下次遇到的话再补充,嘿嘿。
|
||||||
|
|
||||||
|
## VS Code
|
||||||
|
|
||||||
|
同样从官网下载安装:[Download Visual Studio Code - Mac, Linux, Windows](https://code.visualstudio.com/download)
|
||||||
|
|
||||||
|
### 同步问题
|
||||||
|
|
||||||
|
参考:[Visual Studio Code 中的设置同步](https://code.visualstudio.com/docs/editor/settings-sync#_linux)
|
||||||
|
|
||||||
|
我用的是 KDE Plasma,似乎[再等等](https://github.com/microsoft/vscode/issues/104319#issuecomment-1250089491)就能直接正常使用了,所以我先忍受同步问题吧。
|
||||||
|
|
||||||
|
## 中文输入法
|
||||||
|
|
||||||
|
我使用 iBus + Rime + 小鹤音形.
|
||||||
|
执行以下命令安装 iBus + Rime:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt install ibus ibus-rime
|
||||||
|
```
|
||||||
|
|
||||||
|
接下来配置小鹤音形方案。
|
||||||
|
访问[小鹤的网盘](http://flypy.ysepan.com/)下载小鹤音形的挂接文件,小狼毫、鼠须管的都可以。
|
||||||
|
下载完成后解压出来,把压缩文件里的 `rime` 目录复制到 `/home/ivan/.config/ibus/rime`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 如果你没有 unzip,通过下面命令安装:
|
||||||
|
# sudo apt install unzip
|
||||||
|
|
||||||
|
cd ~/Downloads
|
||||||
|
unzip '小鹤音形“鼠须管”for macOS.zip'
|
||||||
|
cd '小鹤音形Rime平台鼠须管for macOS'
|
||||||
|
cp -r ./rime ~/.config/ibus/rime
|
||||||
|
```
|
||||||
|
|
||||||
|
创建 `~/.config/ibus/rime/default.custom.yaml` 文件,并设为以下内容:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
patch:
|
||||||
|
schema_list:
|
||||||
|
- { schema: flypy }
|
||||||
|
- { schema: luna_pinyin }
|
||||||
|
```
|
||||||
|
|
||||||
|
参考:[分享我的输入法配置 (Rime 小狼豪 + 小鹤音形) - 炒饭之道](https://itx.ink/2018/11/21/SHARE_MY_RIME/)
|
||||||
|
|
||||||
|
配置 iBus 环境变量:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat >> ~/.zshrc <<EOF
|
||||||
|
|
||||||
|
# ibus
|
||||||
|
export GTK_IM_MODULE=ibus
|
||||||
|
export XMODIFIERS=@im=ibus
|
||||||
|
export QT_IM_MODULE=ibus
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
启动 ibus
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ibus-setup
|
||||||
|
```
|
||||||
|
|
||||||
|
在打开的 GUI 中添加中文输入法,找到 Rime 并添加输入法:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
现在,新打开的软件应该能使用输入法了。像 Chrome 这类,关闭后还需要手动杀死进程后再打开才能使用。最简单的方法就是重启电脑啦~
|
||||||
|
|
||||||
|
## 快捷键
|
||||||
|
|
||||||
|
我习惯使用 Mac OS 系统的快捷键,所以 [Kinto](https://github.com/rbreaves/kinto) 是我的不二之选。key
|
||||||
|
|
||||||
|
安装:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/bin/bash -c "$(wget -qO- https://raw.githubusercontent.com/rbreaves/kinto/HEAD/install/linux.sh || curl -fsSL https://raw.githubusercontent.com/rbreaves/kinto/HEAD/install/linux.sh)"
|
||||||
|
```
|
||||||
|
|
||||||
|
卸载:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/bin/bash <( wget -qO- https://raw.githubusercontent.com/rbreaves/kinto/HEAD/install/linux.sh || curl -fsSL https://raw.githubusercontent.com/rbreaves/kinto/HEAD/install/linux.sh ) -r
|
||||||
|
```
|
@ -1,10 +1,13 @@
|
|||||||
---
|
---
|
||||||
title: 安装并配置 Arch Linux
|
title: 安装并配置 Arch Linux
|
||||||
date: '2022-10-17'
|
date: '2022-10-17'
|
||||||
tags: ['Arch Linux', '环境搭建‘, 'VPS']
|
tags: ['Arch Linux', '环境搭建', 'VPS']
|
||||||
draft: false
|
draft: false
|
||||||
summary: 又到了新装 Arch Linux 的日子了。这次又是温故而知新的机会,把之前写的笔记稍微整理了一下,在这里记录下教徒搭窝的备忘录。
|
summary: 又到了新装 Arch Linux 的日子了。这次又是温故而知新的机会,把之前写的笔记稍微整理了一下,在这里记录下教徒搭窝的备忘录。
|
||||||
images: ['https://pan.ivanli.cc/api/v3/file/source/2238/archlinux-logo-light.png?sign=bWxqFFy3RUDT5UsWb4UD5byt-_L4h79wede3runRKFc%3D%3A0']
|
images:
|
||||||
|
[
|
||||||
|
'https://pan.ivanli.cc/api/v3/file/source/2238/archlinux-logo-light.png?sign=bWxqFFy3RUDT5UsWb4UD5byt-_L4h79wede3runRKFc%3D%3A0',
|
||||||
|
]
|
||||||
---
|
---
|
||||||
|
|
||||||
## 起势
|
## 起势
|
||||||
@ -28,7 +31,15 @@ chmod +x vps2arch
|
|||||||
./vps2arch
|
./vps2arch
|
||||||
```
|
```
|
||||||
|
|
||||||
等待几分钟就完成了。如果是中国大陆境内的机子,建议全局代理或使用自定义的系统镜像源。可以从[这个网站](https://archlinux.org/mirrorlist/?country=HK&protocol=https&use_mirror_status=on)获取镜像地址。地址上有查询参数,可以根据自己需要修改。
|
等待几分钟就完成了。如果是中国大陆境内的机子,建议全局代理或使用自定义的系统镜像源。可以从下面的网站获取镜像地址。地址上有查询参数,可以根据自己需要修改。
|
||||||
|
|
||||||
|
> [https://archlinux.org/mirrorlist/?country=HK&protocol=https&use_mirror_status=on](https://archlinux.org/mirrorlist/?country=HK&protocol=https&use_mirror_status=on)
|
||||||
|
|
||||||
|
推荐使用 `https://hkg.mirror.rackspace.com/archlinux/`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./vps2arch -m 'https://hkg.mirror.rackspace.com/archlinux/'
|
||||||
|
```
|
||||||
|
|
||||||
如果系统装不上,可以在 IDC 面板上重装其他系统后再试,推荐使用 Debian。
|
如果系统装不上,可以在 IDC 面板上重装其他系统后再试,推荐使用 Debian。
|
||||||
|
|
||||||
@ -54,7 +65,7 @@ ssh-keygen -R '[20.20.20.20]:20000'
|
|||||||
设置主机名:
|
设置主机名:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo hostnamectl set-hostname arch.example.com
|
hostnamectl set-hostname arch.example.com
|
||||||
```
|
```
|
||||||
|
|
||||||
启用 pacman 并行下载:
|
启用 pacman 并行下载:
|
||||||
@ -142,9 +153,12 @@ zinit ice depth=1; zinit light romkatv/powerlevel10k' >> ~/.zshrc
|
|||||||
避免优先匹配到子目录,在 `.zshrc` 中添加如下行:
|
避免优先匹配到子目录,在 `.zshrc` 中添加如下行:
|
||||||
|
|
||||||
```zsh
|
```zsh
|
||||||
|
cat >> ~/.zshrc <<EOF
|
||||||
|
|
||||||
# zsh-z
|
# zsh-z
|
||||||
ZSHZ_UNCOMMON=1
|
ZSHZ_UNCOMMON=1
|
||||||
ZSHZ_TRAILING_SLASH=1
|
ZSHZ_TRAILING_SLASH=1
|
||||||
|
EOF
|
||||||
```
|
```
|
||||||
|
|
||||||
#### History
|
#### History
|
||||||
@ -152,15 +166,37 @@ ZSHZ_TRAILING_SLASH=1
|
|||||||
配置历史记录,在 `.zshrc` 中添加如下行:
|
配置历史记录,在 `.zshrc` 中添加如下行:
|
||||||
|
|
||||||
```zsh
|
```zsh
|
||||||
|
cat >> ~/.zshrc <<EOF
|
||||||
|
|
||||||
|
# History
|
||||||
HISTFILE=~/.zsh_history
|
HISTFILE=~/.zsh_history
|
||||||
HISTSIZE=10000
|
HISTSIZE=10000
|
||||||
SAVEHIST=1000
|
SAVEHIST=1000
|
||||||
setopt INC_APPEND_HISTORY_TIME
|
setopt INC_APPEND_HISTORY_TIME
|
||||||
|
EOF
|
||||||
```
|
```
|
||||||
|
|
||||||
详细配置参考:[Better zsh history | SoberKoder](https://www.soberkoder.com/better-zsh-history/)
|
详细配置参考:[Better zsh history | SoberKoder](https://www.soberkoder.com/better-zsh-history/)
|
||||||
文档:[zsh: 16 Options](https://zsh.sourceforge.io/Doc/Release/Options.html)
|
文档:[zsh: 16 Options](https://zsh.sourceforge.io/Doc/Release/Options.html)
|
||||||
|
|
||||||
|
然后进入到 `zsh` 中,执行一次 `source ~/.zshrc`:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
zsh
|
||||||
|
|
||||||
|
source ~/.zshrc
|
||||||
|
```
|
||||||
|
|
||||||
|
设置 Zsh 为默认的 shell 程序:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 列出所有已安装的 shell 程序
|
||||||
|
chsh -l
|
||||||
|
# 从上面的结果中找到 zsh 的完整路径
|
||||||
|
# 我的是 /bin/zsh
|
||||||
|
chsh -s /bin/zsh
|
||||||
|
```
|
||||||
|
|
||||||
### Docker
|
### Docker
|
||||||
|
|
||||||
安装 Docker 和 Docker Compose 也很简单:
|
安装 Docker 和 Docker Compose 也很简单:
|
||||||
|
@ -1,8 +1,19 @@
|
|||||||
---
|
---
|
||||||
title: 利用一台小鸡实现网络自由
|
title: 利用一台小鸡实现网络自由
|
||||||
date: '2022-10-06'
|
date: '2022-10-06'
|
||||||
tags: ['SNI', 'TLS', 'Reverse Proxy', '反向代理', '正向代理', ‘内网穿透', 'Caddy', 'Xray', 'Vless']
|
tags:
|
||||||
draft: false
|
[
|
||||||
|
'SNI',
|
||||||
|
'TLS',
|
||||||
|
'Reverse Proxy',
|
||||||
|
'反向代理',
|
||||||
|
'正向代理',
|
||||||
|
‘内网穿透',
|
||||||
|
'Caddy',
|
||||||
|
'Xray',
|
||||||
|
'Vless',
|
||||||
|
]
|
||||||
|
draft: true
|
||||||
summary: SNI Proxy 进行 TLS 分流;Caddy 对网站和 Xray 进行反向代理;Xray 实现正向、反向代理(内网穿透)。
|
summary: SNI Proxy 进行 TLS 分流;Caddy 对网站和 Xray 进行反向代理;Xray 实现正向、反向代理(内网穿透)。
|
||||||
---
|
---
|
||||||
|
|
||||||
|
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 的开源版本自居。
|
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,16 +1,25 @@
|
|||||||
import SocialIcon from '@/components/social-icons'
|
import SocialIcon from '@/components/social-icons';
|
||||||
import Image from '@/components/Image'
|
import Image from '@/components/Image';
|
||||||
import { PageSEO } from '@/components/SEO'
|
import { PageSEO } from '@/components/SEO';
|
||||||
import { ReactNode } from 'react'
|
import { ReactNode } from 'react';
|
||||||
import { AuthorFrontMatter } from 'types/AuthorFrontMatter'
|
import { AuthorFrontMatter } from 'types/AuthorFrontMatter';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
children: ReactNode
|
children: ReactNode;
|
||||||
frontMatter: AuthorFrontMatter
|
frontMatter: AuthorFrontMatter;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AuthorLayout({ children, frontMatter }: Props) {
|
export default function AuthorLayout({ children, frontMatter }: Props) {
|
||||||
const { name, avatar, occupation, company, email, twitter, linkedin, github } = frontMatter
|
const {
|
||||||
|
name,
|
||||||
|
avatar,
|
||||||
|
occupation,
|
||||||
|
company,
|
||||||
|
email,
|
||||||
|
twitter,
|
||||||
|
linkedin,
|
||||||
|
github,
|
||||||
|
} = frontMatter;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -30,7 +39,9 @@ export default function AuthorLayout({ children, frontMatter }: Props) {
|
|||||||
height="192px"
|
height="192px"
|
||||||
className="h-48 w-48 rounded-full"
|
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">{occupation}</div>
|
||||||
<div className="text-gray-500 dark:text-gray-400">{company}</div>
|
<div className="text-gray-500 dark:text-gray-400">{company}</div>
|
||||||
<div className="flex space-x-3 pt-6">
|
<div className="flex space-x-3 pt-6">
|
||||||
@ -40,9 +51,11 @@ export default function AuthorLayout({ children, frontMatter }: Props) {
|
|||||||
<SocialIcon kind="twitter" href={twitter} />
|
<SocialIcon kind="twitter" href={twitter} />
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,26 +1,34 @@
|
|||||||
import Link from '@/components/Link'
|
import Link from '@/components/Link';
|
||||||
import Tag from '@/components/Tag'
|
import Tag from '@/components/Tag';
|
||||||
import { ComponentProps, useState } from 'react'
|
import { ComponentProps, useState } from 'react';
|
||||||
import Pagination from '@/components/Pagination'
|
import Pagination from '@/components/Pagination';
|
||||||
import formatDate from '@/lib/utils/formatDate'
|
import formatDate from '@/lib/utils/formatDate';
|
||||||
import { PostFrontMatter } from 'types/PostFrontMatter'
|
import { PostFrontMatter } from 'types/PostFrontMatter';
|
||||||
interface Props {
|
interface Props {
|
||||||
posts: PostFrontMatter[]
|
posts: PostFrontMatter[];
|
||||||
title: string
|
title: string;
|
||||||
initialDisplayPosts?: PostFrontMatter[]
|
initialDisplayPosts?: PostFrontMatter[];
|
||||||
pagination?: ComponentProps<typeof Pagination>
|
pagination?: ComponentProps<typeof Pagination>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ListLayout({ posts, title, initialDisplayPosts = [], pagination }: Props) {
|
export default function ListLayout({
|
||||||
const [searchValue, setSearchValue] = useState('')
|
posts,
|
||||||
|
title,
|
||||||
|
initialDisplayPosts = [],
|
||||||
|
pagination,
|
||||||
|
}: Props) {
|
||||||
|
const [searchValue, setSearchValue] = useState('');
|
||||||
const filteredBlogPosts = posts.filter((frontMatter) => {
|
const filteredBlogPosts = posts.filter((frontMatter) => {
|
||||||
const searchContent = frontMatter.title + frontMatter.summary + frontMatter.tags.join(' ')
|
const searchContent =
|
||||||
return searchContent.toLowerCase().includes(searchValue.toLowerCase())
|
frontMatter.title + frontMatter.summary + frontMatter.tags.join(' ');
|
||||||
})
|
return searchContent.toLowerCase().includes(searchValue.toLowerCase());
|
||||||
|
});
|
||||||
|
|
||||||
// If initialDisplayPosts exist, display it if no searchValue is specified
|
// If initialDisplayPosts exist, display it if no searchValue is specified
|
||||||
const displayPosts =
|
const displayPosts =
|
||||||
initialDisplayPosts.length > 0 && !searchValue ? initialDisplayPosts : filteredBlogPosts
|
initialDisplayPosts.length > 0 && !searchValue
|
||||||
|
? initialDisplayPosts
|
||||||
|
: filteredBlogPosts;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -42,8 +50,7 @@ export default function ListLayout({ posts, title, initialDisplayPosts = [], pag
|
|||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
fill="none"
|
fill="none"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
stroke="currentColor"
|
stroke="currentColor">
|
||||||
>
|
|
||||||
<path
|
<path
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
@ -56,7 +63,7 @@ export default function ListLayout({ posts, title, initialDisplayPosts = [], pag
|
|||||||
<ul>
|
<ul>
|
||||||
{!filteredBlogPosts.length && 'No posts found.'}
|
{!filteredBlogPosts.length && 'No posts found.'}
|
||||||
{displayPosts.map((frontMatter) => {
|
{displayPosts.map((frontMatter) => {
|
||||||
const { slug, date, title, summary, tags } = frontMatter
|
const { slug, date, title, summary, tags } = frontMatter;
|
||||||
return (
|
return (
|
||||||
<li key={slug} className="py-4">
|
<li key={slug} className="py-4">
|
||||||
<article className="space-y-2 xl:grid xl:grid-cols-4 xl:items-baseline xl:space-y-0">
|
<article className="space-y-2 xl:grid xl:grid-cols-4 xl:items-baseline xl:space-y-0">
|
||||||
@ -69,7 +76,9 @@ export default function ListLayout({ posts, title, initialDisplayPosts = [], pag
|
|||||||
<div className="space-y-3 xl:col-span-3">
|
<div className="space-y-3 xl:col-span-3">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-2xl font-bold leading-8 tracking-tight">
|
<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}
|
{title}
|
||||||
</Link>
|
</Link>
|
||||||
</h3>
|
</h3>
|
||||||
@ -85,13 +94,16 @@ export default function ListLayout({ posts, title, initialDisplayPosts = [], pag
|
|||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</li>
|
</li>
|
||||||
)
|
);
|
||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
{pagination && pagination.totalPages > 1 && !searchValue && (
|
{pagination && pagination.totalPages > 1 && !searchValue && (
|
||||||
<Pagination currentPage={pagination.currentPage} totalPages={pagination.totalPages} />
|
<Pagination
|
||||||
|
currentPage={pagination.currentPage}
|
||||||
|
totalPages={pagination.totalPages}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,27 +1,27 @@
|
|||||||
import Link from '@/components/Link'
|
import Link from '@/components/Link';
|
||||||
import PageTitle from '@/components/PageTitle'
|
import PageTitle from '@/components/PageTitle';
|
||||||
import SectionContainer from '@/components/SectionContainer'
|
import SectionContainer from '@/components/SectionContainer';
|
||||||
import { BlogSEO } from '@/components/SEO'
|
import { BlogSEO } from '@/components/SEO';
|
||||||
import Image from '@/components/Image'
|
import Image from '@/components/Image';
|
||||||
import Tag from '@/components/Tag'
|
import Tag from '@/components/Tag';
|
||||||
import siteMetadata from '@/data/siteMetadata'
|
import siteMetadata from '@/data/siteMetadata';
|
||||||
import Comments from '@/components/comments'
|
import Comments from '@/components/comments';
|
||||||
import ScrollTopAndComment from '@/components/ScrollTopAndComment'
|
import ScrollTopAndComment from '@/components/ScrollTopAndComment';
|
||||||
import { ReactNode, useMemo } from 'react'
|
import { ReactNode, useMemo } from 'react';
|
||||||
import { PostFrontMatter } from 'types/PostFrontMatter'
|
import { PostFrontMatter } from 'types/PostFrontMatter';
|
||||||
import { AuthorFrontMatter } from 'types/AuthorFrontMatter'
|
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) =>
|
const discussUrl = (slug) =>
|
||||||
`https://mobile.twitter.com/search?q=${encodeURIComponent(
|
`https://mobile.twitter.com/search?q=${encodeURIComponent(
|
||||||
`${siteMetadata.siteUrl}/blog/${slug}`
|
`${siteMetadata.siteUrl}/blog/${slug}`
|
||||||
)}`
|
)}`;
|
||||||
const Copyright = () => (
|
const Copyright = () => (
|
||||||
<a
|
<a
|
||||||
rel="license"
|
rel="license"
|
||||||
href="http://creativecommons.org/licenses/by-sa/4.0/"
|
href="http://creativecommons.org/licenses/by-sa/4.0/"
|
||||||
className="inline-flex self-center"
|
className="inline-flex self-center">
|
||||||
>
|
|
||||||
<Image
|
<Image
|
||||||
className="border-0"
|
className="border-0"
|
||||||
alt="知识共享许可协议"
|
alt="知识共享许可协议"
|
||||||
@ -30,25 +30,31 @@ const Copyright = () => (
|
|||||||
src="https://i.creativecommons.org/l/by-sa/4.0/80x15.png"
|
src="https://i.creativecommons.org/l/by-sa/4.0/80x15.png"
|
||||||
/>
|
/>
|
||||||
</a>
|
</a>
|
||||||
)
|
);
|
||||||
|
|
||||||
const postDateTemplate: Intl.DateTimeFormatOptions = {
|
const postDateTemplate: Intl.DateTimeFormatOptions = {
|
||||||
weekday: 'long',
|
weekday: 'long',
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'long',
|
month: 'long',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
}
|
};
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
frontMatter: PostFrontMatter
|
frontMatter: PostFrontMatter;
|
||||||
authorDetails: AuthorFrontMatter[]
|
authorDetails: AuthorFrontMatter[];
|
||||||
next?: { slug: string; title: string }
|
next?: { slug: string; title: string };
|
||||||
prev?: { slug: string; title: string }
|
prev?: { slug: string; title: string };
|
||||||
children: ReactNode
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PostLayout({ frontMatter, authorDetails, next, prev, children }: Props) {
|
export default function PostLayout({
|
||||||
const { slug, fileName, date, title, images, tags } = frontMatter
|
frontMatter,
|
||||||
|
authorDetails,
|
||||||
|
next,
|
||||||
|
prev,
|
||||||
|
children,
|
||||||
|
}: Props) {
|
||||||
|
const { slug, fileName, date, title, images, tags } = frontMatter;
|
||||||
|
|
||||||
const headerStyles = useMemo(
|
const headerStyles = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@ -58,7 +64,7 @@ export default function PostLayout({ frontMatter, authorDetails, next, prev, chi
|
|||||||
}
|
}
|
||||||
: {},
|
: {},
|
||||||
[images]
|
[images]
|
||||||
)
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionContainer>
|
<SectionContainer>
|
||||||
@ -87,7 +93,10 @@ export default function PostLayout({ frontMatter, authorDetails, next, prev, chi
|
|||||||
<dt className="sr-only">Published on</dt>
|
<dt className="sr-only">Published on</dt>
|
||||||
<dd className="text-base font-medium leading-6 text-gray-500 dark:text-gray-400">
|
<dd className="text-base font-medium leading-6 text-gray-500 dark:text-gray-400">
|
||||||
<time dateTime={date}>
|
<time dateTime={date}>
|
||||||
{new Date(date).toLocaleDateString(siteMetadata.locale, postDateTemplate)}
|
{new Date(date).toLocaleDateString(
|
||||||
|
siteMetadata.locale,
|
||||||
|
postDateTemplate
|
||||||
|
)}
|
||||||
</time>
|
</time>
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
@ -99,14 +108,15 @@ export default function PostLayout({ frontMatter, authorDetails, next, prev, chi
|
|||||||
</header>
|
</header>
|
||||||
<div
|
<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"
|
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">
|
<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>
|
<dt className="sr-only">Authors</dt>
|
||||||
<dd>
|
<dd>
|
||||||
<ul className="flex justify-center space-x-8 sm:space-x-12 xl:block xl:space-x-0 xl:space-y-8">
|
<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) => (
|
{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 && (
|
{author.avatar && (
|
||||||
<Image
|
<Image
|
||||||
src={author.avatar}
|
src={author.avatar}
|
||||||
@ -118,15 +128,19 @@ export default function PostLayout({ frontMatter, authorDetails, next, prev, chi
|
|||||||
)}
|
)}
|
||||||
<dl className="whitespace-nowrap text-sm font-medium leading-5">
|
<dl className="whitespace-nowrap text-sm font-medium leading-5">
|
||||||
<dt className="sr-only">Name</dt>
|
<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>
|
<dt className="sr-only">Twitter</dt>
|
||||||
<dd>
|
<dd>
|
||||||
{author.twitter && (
|
{author.twitter && (
|
||||||
<Link
|
<Link
|
||||||
href={author.twitter}
|
href={author.twitter}
|
||||||
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">
|
||||||
>
|
{author.twitter.replace(
|
||||||
{author.twitter.replace('https://twitter.com/', '@')}
|
'https://twitter.com/',
|
||||||
|
'@'
|
||||||
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
</dd>
|
</dd>
|
||||||
@ -137,7 +151,9 @@ export default function PostLayout({ frontMatter, authorDetails, next, prev, chi
|
|||||||
</dd>
|
</dd>
|
||||||
</dl>
|
</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="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 className="flex items-center gap-4 pt-6 pb-6 text-sm text-gray-700 dark:text-gray-300">
|
<div className="flex items-center gap-4 pt-6 pb-6 text-sm text-gray-700 dark:text-gray-300">
|
||||||
<Copyright />
|
<Copyright />
|
||||||
<Link href={editUrl(fileName)}>{'View source'}</Link>
|
<Link href={editUrl(fileName)}>{'View source'}</Link>
|
||||||
@ -186,8 +202,7 @@ export default function PostLayout({ frontMatter, authorDetails, next, prev, chi
|
|||||||
<div className="pt-4 xl:pt-8">
|
<div className="pt-4 xl:pt-8">
|
||||||
<Link
|
<Link
|
||||||
href="/blog"
|
href="/blog"
|
||||||
className="text-primary-500 hover:text-primary-600 dark:hover:text-primary-400"
|
className="text-primary-500 hover:text-primary-600 dark:hover:text-primary-400">
|
||||||
>
|
|
||||||
← Back to the blog
|
← Back to the blog
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@ -196,5 +211,5 @@ export default function PostLayout({ frontMatter, authorDetails, next, prev, chi
|
|||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</SectionContainer>
|
</SectionContainer>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,23 +1,28 @@
|
|||||||
import Link from '@/components/Link'
|
import Link from '@/components/Link';
|
||||||
import PageTitle from '@/components/PageTitle'
|
import PageTitle from '@/components/PageTitle';
|
||||||
import SectionContainer from '@/components/SectionContainer'
|
import SectionContainer from '@/components/SectionContainer';
|
||||||
import { BlogSEO } from '@/components/SEO'
|
import { BlogSEO } from '@/components/SEO';
|
||||||
import siteMetadata from '@/data/siteMetadata'
|
import siteMetadata from '@/data/siteMetadata';
|
||||||
import formatDate from '@/lib/utils/formatDate'
|
import formatDate from '@/lib/utils/formatDate';
|
||||||
import Comments from '@/components/comments'
|
import Comments from '@/components/comments';
|
||||||
import ScrollTopAndComment from '@/components/ScrollTopAndComment'
|
import ScrollTopAndComment from '@/components/ScrollTopAndComment';
|
||||||
import { ReactNode } from 'react'
|
import { ReactNode } from 'react';
|
||||||
import { PostFrontMatter } from 'types/PostFrontMatter'
|
import { PostFrontMatter } from 'types/PostFrontMatter';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
frontMatter: PostFrontMatter
|
frontMatter: PostFrontMatter;
|
||||||
children: ReactNode
|
children: ReactNode;
|
||||||
next?: { slug: string; title: string }
|
next?: { slug: string; title: string };
|
||||||
prev?: { slug: string; title: string }
|
prev?: { slug: string; title: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PostLayout({ frontMatter, next, prev, children }: Props) {
|
export default function PostLayout({
|
||||||
const { slug, date, title } = frontMatter
|
frontMatter,
|
||||||
|
next,
|
||||||
|
prev,
|
||||||
|
children,
|
||||||
|
}: Props) {
|
||||||
|
const { slug, date, title } = frontMatter;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionContainer>
|
<SectionContainer>
|
||||||
@ -42,10 +47,11 @@ export default function PostLayout({ frontMatter, next, prev, children }: Props)
|
|||||||
</header>
|
</header>
|
||||||
<div
|
<div
|
||||||
className="divide-y divide-gray-200 pb-8 dark:divide-gray-700 xl:divide-y-0 "
|
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="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>
|
</div>
|
||||||
<Comments frontMatter={frontMatter} />
|
<Comments frontMatter={frontMatter} />
|
||||||
<footer>
|
<footer>
|
||||||
@ -54,8 +60,7 @@ export default function PostLayout({ frontMatter, next, prev, children }: Props)
|
|||||||
<div className="pt-4 xl:pt-8">
|
<div className="pt-4 xl:pt-8">
|
||||||
<Link
|
<Link
|
||||||
href={`/blog/${prev.slug}`}
|
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}
|
← {prev.title}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@ -64,8 +69,7 @@ export default function PostLayout({ frontMatter, next, prev, children }: Props)
|
|||||||
<div className="pt-4 xl:pt-8">
|
<div className="pt-4 xl:pt-8">
|
||||||
<Link
|
<Link
|
||||||
href={`/blog/${next.slug}`}
|
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} →
|
{next.title} →
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@ -76,5 +80,5 @@ export default function PostLayout({ frontMatter, next, prev, children }: Props)
|
|||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</SectionContainer>
|
</SectionContainer>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
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'
|
import { PostFrontMatter } from 'types/PostFrontMatter';
|
||||||
|
|
||||||
const generateRssItem = (post: PostFrontMatter) => `
|
const generateRssItem = (post: PostFrontMatter) => `
|
||||||
<item>
|
<item>
|
||||||
@ -13,7 +13,7 @@ const generateRssItem = (post: PostFrontMatter) => `
|
|||||||
<author>${siteMetadata.email} (${siteMetadata.author})</author>
|
<author>${siteMetadata.email} (${siteMetadata.author})</author>
|
||||||
${post.tags && post.tags.map((t) => `<category>${t}</category>`).join('')}
|
${post.tags && post.tags.map((t) => `<category>${t}</category>`).join('')}
|
||||||
</item>
|
</item>
|
||||||
`
|
`;
|
||||||
|
|
||||||
const generateRss = (posts: PostFrontMatter[], page = 'feed.xml') => `
|
const generateRss = (posts: PostFrontMatter[], page = 'feed.xml') => `
|
||||||
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
|
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
|
||||||
@ -22,12 +22,16 @@ const generateRss = (posts: PostFrontMatter[], page = 'feed.xml') => `
|
|||||||
<link>${siteMetadata.siteUrl}/blog</link>
|
<link>${siteMetadata.siteUrl}/blog</link>
|
||||||
<description>${escape(siteMetadata.description)}</description>
|
<description>${escape(siteMetadata.description)}</description>
|
||||||
<language>${siteMetadata.language}</language>
|
<language>${siteMetadata.language}</language>
|
||||||
<managingEditor>${siteMetadata.email} (${siteMetadata.author})</managingEditor>
|
<managingEditor>${siteMetadata.email} (${
|
||||||
|
siteMetadata.author
|
||||||
|
})</managingEditor>
|
||||||
<webMaster>${siteMetadata.email} (${siteMetadata.author})</webMaster>
|
<webMaster>${siteMetadata.email} (${siteMetadata.author})</webMaster>
|
||||||
<lastBuildDate>${new Date(posts[0].date).toUTCString()}</lastBuildDate>
|
<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('')}
|
${posts.map(generateRssItem).join('')}
|
||||||
</channel>
|
</channel>
|
||||||
</rss>
|
</rss>
|
||||||
`
|
`;
|
||||||
export default generateRss
|
export default generateRss;
|
||||||
|
130
lib/mdx.ts
130
lib/mdx.ts
@ -1,62 +1,78 @@
|
|||||||
import { bundleMDX } from 'mdx-bundler'
|
import { bundleMDX } from 'mdx-bundler';
|
||||||
import fs from 'fs'
|
import fs from 'fs';
|
||||||
import matter from 'gray-matter'
|
import matter from 'gray-matter';
|
||||||
import path from 'path'
|
import path from 'path';
|
||||||
import readingTime from 'reading-time'
|
import readingTime from 'reading-time';
|
||||||
import getAllFilesRecursively from './utils/files'
|
import getAllFilesRecursively from './utils/files';
|
||||||
import { PostFrontMatter } from 'types/PostFrontMatter'
|
import { PostFrontMatter } from 'types/PostFrontMatter';
|
||||||
import { AuthorFrontMatter } from 'types/AuthorFrontMatter'
|
import { AuthorFrontMatter } from 'types/AuthorFrontMatter';
|
||||||
import { Toc } from 'types/Toc'
|
import { Toc } from 'types/Toc';
|
||||||
// Remark packages
|
// Remark packages
|
||||||
import remarkGfm from 'remark-gfm'
|
import remarkGfm from 'remark-gfm';
|
||||||
import remarkFootnotes from 'remark-footnotes'
|
import remarkFootnotes from 'remark-footnotes';
|
||||||
import remarkMath from 'remark-math'
|
import remarkMath from 'remark-math';
|
||||||
import remarkExtractFrontmatter from './remark-extract-frontmatter'
|
import remarkExtractFrontmatter from './remark-extract-frontmatter';
|
||||||
import remarkCodeTitles from './remark-code-title'
|
import remarkCodeTitles from './remark-code-title';
|
||||||
import remarkTocHeadings from './remark-toc-headings'
|
import remarkTocHeadings from './remark-toc-headings';
|
||||||
import remarkImgToJsx from './remark-img-to-jsx'
|
import remarkImgToJsx from './remark-img-to-jsx';
|
||||||
// Rehype packages
|
// Rehype packages
|
||||||
import rehypeSlug from 'rehype-slug'
|
import rehypeSlug from 'rehype-slug';
|
||||||
import rehypeAutolinkHeadings from 'rehype-autolink-headings'
|
import rehypeAutolinkHeadings from 'rehype-autolink-headings';
|
||||||
import rehypeKatex from 'rehype-katex'
|
import rehypeKatex from 'rehype-katex';
|
||||||
import rehypeCitation from 'rehype-citation'
|
import rehypeCitation from 'rehype-citation';
|
||||||
import rehypePrismPlus from 'rehype-prism-plus'
|
import rehypePrismPlus from 'rehype-prism-plus';
|
||||||
import rehypePresetMinify from 'rehype-preset-minify'
|
import rehypePresetMinify from 'rehype-preset-minify';
|
||||||
|
|
||||||
const root = process.cwd()
|
const root = process.cwd();
|
||||||
|
|
||||||
export function getFiles(type: 'blog' | 'authors') {
|
export function getFiles(type: 'blog' | 'authors') {
|
||||||
const prefixPaths = path.join(root, 'data', type)
|
const prefixPaths = path.join(root, 'data', type);
|
||||||
const files = getAllFilesRecursively(prefixPaths)
|
const files = getAllFilesRecursively(prefixPaths);
|
||||||
// Only want to return blog/path and ignore root, replace is needed to work on Windows
|
// 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, '/'))
|
return files.map((file) =>
|
||||||
|
file.slice(prefixPaths.length + 1).replace(/\\/g, '/')
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatSlug(slug: string) {
|
export function formatSlug(slug: string) {
|
||||||
return slug.replace(/\.(mdx|md)/, '')
|
return slug.replace(/\.(mdx|md)/, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function dateSortDesc(a: string, b: string) {
|
export function dateSortDesc(a: string, b: string) {
|
||||||
if (a > b) return -1
|
if (a > b) return -1;
|
||||||
if (a < b) return 1
|
if (a < b) return 1;
|
||||||
return 0
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getFileBySlug<T>(type: 'authors' | 'blog', slug: string | string[]) {
|
export async function getFileBySlug<T>(
|
||||||
const mdxPath = path.join(root, 'data', type, `${slug}.mdx`)
|
type: 'authors' | 'blog',
|
||||||
const mdPath = path.join(root, 'data', type, `${slug}.md`)
|
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)
|
const source = fs.existsSync(mdxPath)
|
||||||
? fs.readFileSync(mdxPath, 'utf8')
|
? fs.readFileSync(mdxPath, 'utf8')
|
||||||
: fs.readFileSync(mdPath, 'utf8')
|
: fs.readFileSync(mdPath, 'utf8');
|
||||||
|
|
||||||
// https://github.com/kentcdodds/mdx-bundler#nextjs-esbuild-enoent
|
// https://github.com/kentcdodds/mdx-bundler#nextjs-esbuild-enoent
|
||||||
if (process.platform === 'win32') {
|
if (process.platform === 'win32') {
|
||||||
process.env.ESBUILD_BINARY_PATH = path.join(root, 'node_modules', 'esbuild', 'esbuild.exe')
|
process.env.ESBUILD_BINARY_PATH = path.join(
|
||||||
|
root,
|
||||||
|
'node_modules',
|
||||||
|
'esbuild',
|
||||||
|
'esbuild.exe'
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
process.env.ESBUILD_BINARY_PATH = path.join(root, 'node_modules', 'esbuild', 'bin', 'esbuild')
|
process.env.ESBUILD_BINARY_PATH = path.join(
|
||||||
|
root,
|
||||||
|
'node_modules',
|
||||||
|
'esbuild',
|
||||||
|
'bin',
|
||||||
|
'esbuild'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const toc: Toc = []
|
const toc: Toc = [];
|
||||||
|
|
||||||
const { code, frontmatter } = await bundleMDX({
|
const { code, frontmatter } = await bundleMDX({
|
||||||
source,
|
source,
|
||||||
@ -75,7 +91,7 @@ export async function getFileBySlug<T>(type: 'authors' | 'blog', slug: string |
|
|||||||
[remarkFootnotes, { inlineNotes: true }],
|
[remarkFootnotes, { inlineNotes: true }],
|
||||||
remarkMath,
|
remarkMath,
|
||||||
remarkImgToJsx,
|
remarkImgToJsx,
|
||||||
]
|
];
|
||||||
options.rehypePlugins = [
|
options.rehypePlugins = [
|
||||||
...(options.rehypePlugins ?? []),
|
...(options.rehypePlugins ?? []),
|
||||||
rehypeSlug,
|
rehypeSlug,
|
||||||
@ -84,17 +100,17 @@ export async function getFileBySlug<T>(type: 'authors' | 'blog', slug: string |
|
|||||||
[rehypeCitation, { path: path.join(root, 'data') }],
|
[rehypeCitation, { path: path.join(root, 'data') }],
|
||||||
[rehypePrismPlus, { ignoreMissing: true }],
|
[rehypePrismPlus, { ignoreMissing: true }],
|
||||||
rehypePresetMinify,
|
rehypePresetMinify,
|
||||||
]
|
];
|
||||||
return options
|
return options;
|
||||||
},
|
},
|
||||||
esbuildOptions: (options) => {
|
esbuildOptions: (options) => {
|
||||||
options.loader = {
|
options.loader = {
|
||||||
...options.loader,
|
...options.loader,
|
||||||
'.js': 'jsx',
|
'.js': 'jsx',
|
||||||
}
|
};
|
||||||
return options
|
return options;
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
mdxSource: code,
|
mdxSource: code,
|
||||||
@ -106,34 +122,36 @@ export async function getFileBySlug<T>(type: 'authors' | 'blog', slug: string |
|
|||||||
...frontmatter,
|
...frontmatter,
|
||||||
date: frontmatter.date ? new Date(frontmatter.date).toISOString() : null,
|
date: frontmatter.date ? new Date(frontmatter.date).toISOString() : null,
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAllFilesFrontMatter(folder: 'blog') {
|
export async function getAllFilesFrontMatter(folder: 'blog') {
|
||||||
const prefixPaths = path.join(root, 'data', folder)
|
const prefixPaths = path.join(root, 'data', folder);
|
||||||
|
|
||||||
const files = getAllFilesRecursively(prefixPaths)
|
const files = getAllFilesRecursively(prefixPaths);
|
||||||
|
|
||||||
const allFrontMatter: PostFrontMatter[] = []
|
const allFrontMatter: PostFrontMatter[] = [];
|
||||||
|
|
||||||
files.forEach((file: string) => {
|
files.forEach((file: string) => {
|
||||||
// Replace is needed to work on Windows
|
// Replace is needed to work on Windows
|
||||||
const fileName = file.slice(prefixPaths.length + 1).replace(/\\/g, '/')
|
const fileName = file.slice(prefixPaths.length + 1).replace(/\\/g, '/');
|
||||||
// Remove Unexpected File
|
// Remove Unexpected File
|
||||||
if (path.extname(fileName) !== '.md' && path.extname(fileName) !== '.mdx') {
|
if (path.extname(fileName) !== '.md' && path.extname(fileName) !== '.mdx') {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
const source = fs.readFileSync(file, 'utf8')
|
const source = fs.readFileSync(file, 'utf8');
|
||||||
const matterFile = matter(source)
|
const matterFile = matter(source);
|
||||||
const frontmatter = matterFile.data as AuthorFrontMatter | PostFrontMatter
|
const frontmatter = matterFile.data as AuthorFrontMatter | PostFrontMatter;
|
||||||
if ('draft' in frontmatter && frontmatter.draft !== true) {
|
if ('draft' in frontmatter && frontmatter.draft !== true) {
|
||||||
allFrontMatter.push({
|
allFrontMatter.push({
|
||||||
...frontmatter,
|
...frontmatter,
|
||||||
slug: formatSlug(fileName),
|
slug: formatSlug(fileName),
|
||||||
date: frontmatter.date ? new Date(frontmatter.date).toISOString() : null,
|
date: frontmatter.date
|
||||||
})
|
? new Date(frontmatter.date).toISOString()
|
||||||
|
: null,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
return allFrontMatter.sort((a, b) => dateSortDesc(a.date, b.date))
|
return allFrontMatter.sort((a, b) => dateSortDesc(a.date, b.date));
|
||||||
}
|
}
|
||||||
|
@ -1,32 +1,38 @@
|
|||||||
import { visit, Parent } from 'unist-util-visit'
|
import { visit, Parent } from 'unist-util-visit';
|
||||||
|
|
||||||
export default function remarkCodeTitles() {
|
export default function remarkCodeTitles() {
|
||||||
return (tree: Parent & { lang?: string }) =>
|
return (tree: Parent & { lang?: string }) =>
|
||||||
visit(tree, 'code', (node: Parent & { lang?: string }, index, parent: Parent) => {
|
visit(
|
||||||
const nodeLang = node.lang || ''
|
tree,
|
||||||
let language = ''
|
'code',
|
||||||
let title = ''
|
(node: Parent & { lang?: string }, index, parent: Parent) => {
|
||||||
|
const nodeLang = node.lang || '';
|
||||||
|
let language = '';
|
||||||
|
let title = '';
|
||||||
|
|
||||||
if (nodeLang.includes(':')) {
|
if (nodeLang.includes(':')) {
|
||||||
language = nodeLang.slice(0, nodeLang.search(':'))
|
language = nodeLang.slice(0, nodeLang.search(':'));
|
||||||
title = nodeLang.slice(nodeLang.search(':') + 1, nodeLang.length)
|
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;
|
||||||
}
|
}
|
||||||
|
);
|
||||||
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,12 +1,12 @@
|
|||||||
import { VFile } from 'vfile'
|
import { VFile } from 'vfile';
|
||||||
import { visit, Parent } from 'unist-util-visit'
|
import { visit, Parent } from 'unist-util-visit';
|
||||||
import { load } from 'js-yaml'
|
import { load } from 'js-yaml';
|
||||||
|
|
||||||
export default function extractFrontmatter() {
|
export default function extractFrontmatter() {
|
||||||
return (tree: Parent, file: VFile) => {
|
return (tree: Parent, file: VFile) => {
|
||||||
visit(tree, 'yaml', (node: Parent) => {
|
visit(tree, 'yaml', (node: Parent) => {
|
||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
file.data.frontmatter = load(node.value)
|
file.data.frontmatter = load(node.value);
|
||||||
})
|
});
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
@ -1,14 +1,14 @@
|
|||||||
import { Literal } from 'unist'
|
import { Literal } from 'unist';
|
||||||
import { visit, Parent, Node } from 'unist-util-visit'
|
import { visit, Parent, Node } from 'unist-util-visit';
|
||||||
import sizeOf from 'image-size'
|
import sizeOf from 'image-size';
|
||||||
import fs from 'fs'
|
import fs from 'fs';
|
||||||
|
|
||||||
type ImageNode = Parent & {
|
type ImageNode = Parent & {
|
||||||
url: string
|
url: string;
|
||||||
alt: string
|
alt: string;
|
||||||
name: string
|
name: string;
|
||||||
attributes: (Literal & { name: string })[]
|
attributes: (Literal & { name: string })[];
|
||||||
}
|
};
|
||||||
|
|
||||||
export default function remarkImgToJsx() {
|
export default function remarkImgToJsx() {
|
||||||
return (tree: Node) => {
|
return (tree: Node) => {
|
||||||
@ -16,29 +16,40 @@ export default function remarkImgToJsx() {
|
|||||||
tree,
|
tree,
|
||||||
// only visit p tags that contain an img element
|
// only visit p tags that contain an img element
|
||||||
(node: Parent): node is Parent =>
|
(node: Parent): node is Parent =>
|
||||||
node.type === 'paragraph' && node.children.some((n) => n.type === 'image'),
|
node.type === 'paragraph' &&
|
||||||
|
node.children.some((n) => n.type === 'image'),
|
||||||
(node: Parent) => {
|
(node: Parent) => {
|
||||||
const imageNode = node.children.find((n) => n.type === 'image') as ImageNode
|
const imageNode = node.children.find(
|
||||||
|
(n) => n.type === 'image'
|
||||||
|
) as ImageNode;
|
||||||
|
|
||||||
// only local files
|
// only local files
|
||||||
if (fs.existsSync(`${process.cwd()}/public${imageNode.url}`)) {
|
if (fs.existsSync(`${process.cwd()}/public${imageNode.url}`)) {
|
||||||
const dimensions = sizeOf(`${process.cwd()}/public${imageNode.url}`)
|
const dimensions = sizeOf(`${process.cwd()}/public${imageNode.url}`);
|
||||||
|
|
||||||
// Convert original node to next/image
|
// Convert original node to next/image
|
||||||
;(imageNode.type = 'mdxJsxFlowElement'),
|
(imageNode.type = 'mdxJsxFlowElement'),
|
||||||
(imageNode.name = 'Image'),
|
(imageNode.name = 'Image'),
|
||||||
(imageNode.attributes = [
|
(imageNode.attributes = [
|
||||||
{ type: 'mdxJsxAttribute', name: 'alt', value: imageNode.alt },
|
{ type: 'mdxJsxAttribute', name: 'alt', value: imageNode.alt },
|
||||||
{ type: 'mdxJsxAttribute', name: 'src', value: imageNode.url },
|
{ type: 'mdxJsxAttribute', name: 'src', value: imageNode.url },
|
||||||
{ type: 'mdxJsxAttribute', name: 'width', value: dimensions.width },
|
{
|
||||||
{ type: 'mdxJsxAttribute', name: 'height', value: dimensions.height },
|
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
|
// Change node type from p to div to avoid nesting error
|
||||||
node.type = 'div'
|
node.type = 'div';
|
||||||
node.children = [imageNode]
|
node.children = [imageNode];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
@ -1,17 +1,17 @@
|
|||||||
//@ts-nocheck
|
//@ts-nocheck
|
||||||
import { Parent } from 'unist'
|
import { Parent } from 'unist';
|
||||||
import { visit } from 'unist-util-visit'
|
import { visit } from 'unist-util-visit';
|
||||||
import { slug } from 'github-slugger'
|
import { slug } from 'github-slugger';
|
||||||
import { toString } from 'mdast-util-to-string'
|
import { toString } from 'mdast-util-to-string';
|
||||||
|
|
||||||
export default function remarkTocHeadings(options) {
|
export default function remarkTocHeadings(options) {
|
||||||
return (tree: Parent) =>
|
return (tree: Parent) =>
|
||||||
visit(tree, 'heading', (node) => {
|
visit(tree, 'heading', (node) => {
|
||||||
const textContent = toString(node)
|
const textContent = toString(node);
|
||||||
options.exportRef.push({
|
options.exportRef.push({
|
||||||
value: textContent,
|
value: textContent,
|
||||||
url: '#' + slug(textContent),
|
url: '#' + slug(textContent),
|
||||||
depth: node.depth,
|
depth: node.depth,
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
36
lib/tags.ts
36
lib/tags.ts
@ -1,32 +1,32 @@
|
|||||||
import { PostFrontMatter } from 'types/PostFrontMatter'
|
import { PostFrontMatter } from 'types/PostFrontMatter';
|
||||||
import fs from 'fs'
|
import fs from 'fs';
|
||||||
import matter from 'gray-matter'
|
import matter from 'gray-matter';
|
||||||
import path from 'path'
|
import path from 'path';
|
||||||
import { getFiles } from './mdx'
|
import { getFiles } from './mdx';
|
||||||
import kebabCase from './utils/kebabCase'
|
import kebabCase from './utils/kebabCase';
|
||||||
|
|
||||||
const root = process.cwd()
|
const root = process.cwd();
|
||||||
|
|
||||||
export async function getAllTags(type: 'blog' | 'authors') {
|
export async function getAllTags(type: 'blog' | 'authors') {
|
||||||
const files = getFiles(type)
|
const files = getFiles(type);
|
||||||
|
|
||||||
const tagCount: Record<string, number> = {}
|
const tagCount: Record<string, number> = {};
|
||||||
// Iterate through each post, putting all found tags into `tags`
|
// Iterate through each post, putting all found tags into `tags`
|
||||||
files.forEach((file) => {
|
files.forEach((file) => {
|
||||||
const source = fs.readFileSync(path.join(root, 'data', type, file), 'utf8')
|
const source = fs.readFileSync(path.join(root, 'data', type, file), 'utf8');
|
||||||
const matterFile = matter(source)
|
const matterFile = matter(source);
|
||||||
const data = matterFile.data as PostFrontMatter
|
const data = matterFile.data as PostFrontMatter;
|
||||||
if (data.tags && data.draft !== true) {
|
if (data.tags && data.draft !== true) {
|
||||||
data.tags.forEach((tag) => {
|
data.tags.forEach((tag) => {
|
||||||
const formattedTag = kebabCase(tag)
|
const formattedTag = kebabCase(tag);
|
||||||
if (formattedTag in tagCount) {
|
if (formattedTag in tagCount) {
|
||||||
tagCount[formattedTag] += 1
|
tagCount[formattedTag] += 1;
|
||||||
} else {
|
} else {
|
||||||
tagCount[formattedTag] = 1
|
tagCount[formattedTag] = 1;
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
return tagCount
|
return tagCount;
|
||||||
}
|
}
|
||||||
|
@ -1,23 +1,33 @@
|
|||||||
import fs from 'fs'
|
import fs from 'fs';
|
||||||
import path from 'path'
|
import path from 'path';
|
||||||
|
|
||||||
const pipe =
|
const pipe =
|
||||||
(...fns) =>
|
(...fns) =>
|
||||||
(x) =>
|
(x) =>
|
||||||
fns.reduce((v, f) => f(v), x)
|
fns.reduce((v, f) => f(v), x);
|
||||||
|
|
||||||
const flattenArray = (input) =>
|
const flattenArray = (input) =>
|
||||||
input.reduce((acc, item) => [...acc, ...(Array.isArray(item) ? item : [item])], [])
|
input.reduce(
|
||||||
|
(acc, item) => [...acc, ...(Array.isArray(item) ? item : [item])],
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
const map = (fn) => (input) => input.map(fn)
|
const map = (fn) => (input) => input.map(fn);
|
||||||
|
|
||||||
const walkDir = (fullPath: string) => {
|
const walkDir = (fullPath: string) => {
|
||||||
return fs.statSync(fullPath).isFile() ? fullPath : getAllFilesRecursively(fullPath)
|
return fs.statSync(fullPath).isFile()
|
||||||
}
|
? fullPath
|
||||||
|
: getAllFilesRecursively(fullPath);
|
||||||
|
};
|
||||||
|
|
||||||
const pathJoinPrefix = (prefix: string) => (extraPath: string) => path.join(prefix, extraPath)
|
const pathJoinPrefix = (prefix: string) => (extraPath: string) =>
|
||||||
|
path.join(prefix, extraPath);
|
||||||
|
|
||||||
const getAllFilesRecursively = (folder: string): string[] =>
|
const getAllFilesRecursively = (folder: string): string[] =>
|
||||||
pipe(fs.readdirSync, map(pipe(pathJoinPrefix(folder), walkDir)), flattenArray)(folder)
|
pipe(
|
||||||
|
fs.readdirSync,
|
||||||
|
map(pipe(pathJoinPrefix(folder), walkDir)),
|
||||||
|
flattenArray
|
||||||
|
)(folder);
|
||||||
|
|
||||||
export default getAllFilesRecursively
|
export default getAllFilesRecursively;
|
||||||
|
@ -1,14 +1,14 @@
|
|||||||
import siteMetadata from '@/data/siteMetadata'
|
import siteMetadata from '@/data/siteMetadata';
|
||||||
|
|
||||||
const formatDate = (date: string) => {
|
const formatDate = (date: string) => {
|
||||||
const options: Intl.DateTimeFormatOptions = {
|
const options: Intl.DateTimeFormatOptions = {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'long',
|
month: 'long',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
}
|
};
|
||||||
const now = new Date(date).toLocaleDateString(siteMetadata.locale, options)
|
const now = new Date(date).toLocaleDateString(siteMetadata.locale, options);
|
||||||
|
|
||||||
return now
|
return now;
|
||||||
}
|
};
|
||||||
|
|
||||||
export default formatDate
|
export default formatDate;
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
const { replace } = ''
|
const { replace } = '';
|
||||||
|
|
||||||
// escape
|
// escape
|
||||||
const ca = /[&<>'"]/g
|
const ca = /[&<>'"]/g;
|
||||||
|
|
||||||
const esca = {
|
const esca = {
|
||||||
'&': '&',
|
'&': '&',
|
||||||
@ -9,8 +9,8 @@ const esca = {
|
|||||||
'>': '>',
|
'>': '>',
|
||||||
"'": ''',
|
"'": ''',
|
||||||
'"': '"',
|
'"': '"',
|
||||||
}
|
};
|
||||||
const pe = (m: keyof typeof esca) => esca[m]
|
const pe = (m: keyof typeof esca) => esca[m];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Safely escape HTML entities such as `&`, `<`, `>`, `"`, and `'`.
|
* Safely escape HTML entities such as `&`, `<`, `>`, `"`, and `'`.
|
||||||
@ -19,4 +19,4 @@ const pe = (m: keyof typeof esca) => esca[m]
|
|||||||
* the input type is unexpected, except for boolean and numbers,
|
* the input type is unexpected, except for boolean and numbers,
|
||||||
* converted as string.
|
* converted as string.
|
||||||
*/
|
*/
|
||||||
export const escape = (es: string): string => replace.call(es, ca, pe)
|
export const escape = (es: string): string => replace.call(es, ca, pe);
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { slug } from 'github-slugger'
|
import { slug } from 'github-slugger';
|
||||||
|
|
||||||
const kebabCase = (str: string) => slug(str)
|
const kebabCase = (str: string) => slug(str);
|
||||||
|
|
||||||
export default kebabCase
|
export default kebabCase;
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
const withBundleAnalyzer = require('@next/bundle-analyzer')({
|
const withBundleAnalyzer = require('@next/bundle-analyzer')({
|
||||||
enabled: process.env.ANALYZE === 'true',
|
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
|
// You might need to insert additional domains in script-src if you are using external services
|
||||||
const ContentSecurityPolicy = `
|
const ContentSecurityPolicy = `
|
||||||
@ -12,7 +13,7 @@ const ContentSecurityPolicy = `
|
|||||||
connect-src *;
|
connect-src *;
|
||||||
font-src 'self' comment.ivanli.cc localhost:8080;
|
font-src 'self' comment.ivanli.cc localhost:8080;
|
||||||
frame-src giscus.app
|
frame-src giscus.app
|
||||||
`
|
`;
|
||||||
|
|
||||||
const securityHeaders = [
|
const securityHeaders = [
|
||||||
// https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP
|
// https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP
|
||||||
@ -50,57 +51,60 @@ const securityHeaders = [
|
|||||||
key: 'Permissions-Policy',
|
key: 'Permissions-Policy',
|
||||||
value: 'camera=(), microphone=(), geolocation=()',
|
value: 'camera=(), microphone=(), geolocation=()',
|
||||||
},
|
},
|
||||||
]
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @type {import('next/dist/next-server/server/config').NextConfig}
|
* @type {import('next/dist/next-server/server/config').NextConfig}
|
||||||
**/
|
**/
|
||||||
module.exports = withBundleAnalyzer({
|
module.exports = withExportImages(
|
||||||
reactStrictMode: true,
|
withBundleAnalyzer({
|
||||||
pageExtensions: ['ts', 'tsx', 'js', 'jsx', 'md', 'mdx'],
|
reactStrictMode: true,
|
||||||
eslint: {
|
pageExtensions: ['ts', 'tsx', 'js', 'jsx', 'md', 'mdx'],
|
||||||
dirs: ['pages', 'components', 'lib', 'layouts', 'scripts'],
|
eslint: {
|
||||||
},
|
dirs: ['pages', 'components', 'lib', 'layouts', 'scripts'],
|
||||||
images: {
|
},
|
||||||
domains: ['pan.ivanli.cc', 'i.creativecommons.org'],
|
images: {
|
||||||
},
|
domains: ['pan.ivanli.cc', 'i.creativecommons.org'],
|
||||||
async headers() {
|
},
|
||||||
return [
|
async headers() {
|
||||||
{
|
return [
|
||||||
source: '/(.*)',
|
|
||||||
headers: securityHeaders,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
},
|
|
||||||
webpack: (config, { dev, isServer }) => {
|
|
||||||
config.module.rules.push({
|
|
||||||
test: /\.(png|jpe?g|gif|mp4)$/i,
|
|
||||||
use: [
|
|
||||||
{
|
{
|
||||||
loader: 'file-loader',
|
source: '/(.*)',
|
||||||
options: {
|
headers: securityHeaders,
|
||||||
publicPath: '/_next',
|
|
||||||
name: 'static/media/[name].[hash].[ext]',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
],
|
];
|
||||||
})
|
},
|
||||||
|
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({
|
config.module.rules.push({
|
||||||
test: /\.svg$/,
|
test: /\.svg$/,
|
||||||
use: ['@svgr/webpack'],
|
use: ['@svgr/webpack'],
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!dev && !isServer) {
|
if (!dev && !isServer) {
|
||||||
// Replace React with Preact only in client production build
|
// Replace React with Preact only in client production build
|
||||||
Object.assign(config.resolve.alias, {
|
Object.assign(config.resolve.alias, {
|
||||||
'react/jsx-runtime.js': 'preact/compat/jsx-runtime',
|
'react/jsx-runtime.js': 'preact/compat/jsx-runtime',
|
||||||
react: 'preact/compat',
|
react: 'preact/compat',
|
||||||
'react-dom/test-utils': 'preact/test-utils',
|
'react-dom/test-utils': 'preact/test-utils',
|
||||||
'react-dom': 'preact/compat',
|
'react-dom': 'preact/compat',
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return config
|
return config;
|
||||||
},
|
},
|
||||||
})
|
trailingSlash: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
@ -6,6 +6,7 @@
|
|||||||
"start": "cross-env SOCKET=true node ./scripts/next-remote-watch.js ./data",
|
"start": "cross-env SOCKET=true node ./scripts/next-remote-watch.js ./data",
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build && node ./scripts/generate-sitemap",
|
"build": "next build && node ./scripts/generate-sitemap",
|
||||||
|
"export": "next export && next-export-optimize-images",
|
||||||
"serve": "next start",
|
"serve": "next start",
|
||||||
"analyze": "cross-env ANALYZE=true next build",
|
"analyze": "cross-env ANALYZE=true next build",
|
||||||
"lint": "next lint --fix --dir pages --dir components --dir lib --dir layouts --dir scripts",
|
"lint": "next lint --fix --dir pages --dir components --dir lib --dir layouts --dir scripts",
|
||||||
@ -66,6 +67,7 @@
|
|||||||
"husky": "^6.0.0",
|
"husky": "^6.0.0",
|
||||||
"inquirer": "^8.1.1",
|
"inquirer": "^8.1.1",
|
||||||
"lint-staged": "^11.0.0",
|
"lint-staged": "^11.0.0",
|
||||||
|
"next-export-optimize-images": "^2.0.0",
|
||||||
"next-remote-watch": "^1.0.0",
|
"next-remote-watch": "^1.0.0",
|
||||||
"prettier": "^2.5.1",
|
"prettier": "^2.5.1",
|
||||||
"prettier-plugin-tailwindcss": "^0.1.4",
|
"prettier-plugin-tailwindcss": "^0.1.4",
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import Link from '@/components/Link'
|
import Link from '@/components/Link';
|
||||||
|
|
||||||
export default function FourZeroFour() {
|
export default function FourZeroFour() {
|
||||||
return (
|
return (
|
||||||
@ -12,7 +12,9 @@ export default function FourZeroFour() {
|
|||||||
<p className="mb-4 text-xl font-bold leading-normal md:text-2xl">
|
<p className="mb-4 text-xl font-bold leading-normal md:text-2xl">
|
||||||
Sorry we couldn't find this page.
|
Sorry we couldn't find this page.
|
||||||
</p>
|
</p>
|
||||||
<p className="mb-8">But dont worry, you can find plenty of other things on our homepage.</p>
|
<p className="mb-8">
|
||||||
|
But dont worry, you can find plenty of other things on our homepage.
|
||||||
|
</p>
|
||||||
<Link href="/">
|
<Link href="/">
|
||||||
<button className="focus:shadow-outline-blue inline rounded-lg border border-transparent bg-blue-600 px-4 py-2 text-sm font-medium leading-5 text-white shadow transition-colors duration-150 hover:bg-blue-700 focus:outline-none dark:hover:bg-blue-500">
|
<button className="focus:shadow-outline-blue inline rounded-lg border border-transparent bg-blue-600 px-4 py-2 text-sm font-medium leading-5 text-white shadow transition-colors duration-150 hover:bg-blue-700 focus:outline-none dark:hover:bg-blue-500">
|
||||||
Back to homepage
|
Back to homepage
|
||||||
@ -20,5 +22,5 @@ export default function FourZeroFour() {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,20 +1,20 @@
|
|||||||
import '@/css/tailwind.css'
|
import '@/css/tailwind.css';
|
||||||
import '@/css/prism.css'
|
import '@/css/prism.css';
|
||||||
import 'katex/dist/katex.css'
|
import 'katex/dist/katex.css';
|
||||||
|
|
||||||
import '@fontsource/inter/variable.css'
|
import '@fontsource/inter/variable.css';
|
||||||
|
|
||||||
import { ThemeProvider } from 'next-themes'
|
import { ThemeProvider } from 'next-themes';
|
||||||
import type { AppProps } from 'next/app'
|
import type { AppProps } from 'next/app';
|
||||||
import Head from 'next/head'
|
import Head from 'next/head';
|
||||||
|
|
||||||
import siteMetadata from '@/data/siteMetadata'
|
import siteMetadata from '@/data/siteMetadata';
|
||||||
import Analytics from '@/components/analytics'
|
import Analytics from '@/components/analytics';
|
||||||
import LayoutWrapper from '@/components/LayoutWrapper'
|
import LayoutWrapper from '@/components/LayoutWrapper';
|
||||||
import { ClientReload } from '@/components/ClientReload'
|
import { ClientReload } from '@/components/ClientReload';
|
||||||
|
|
||||||
const isDevelopment = process.env.NODE_ENV === 'development'
|
const isDevelopment = process.env.NODE_ENV === 'development';
|
||||||
const isSocket = process.env.SOCKET
|
const isSocket = process.env.SOCKET;
|
||||||
|
|
||||||
export default function App({ Component, pageProps }: AppProps) {
|
export default function App({ Component, pageProps }: AppProps) {
|
||||||
return (
|
return (
|
||||||
@ -28,5 +28,5 @@ export default function App({ Component, pageProps }: AppProps) {
|
|||||||
<Component {...pageProps} />
|
<Component {...pageProps} />
|
||||||
</LayoutWrapper>
|
</LayoutWrapper>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,11 +1,15 @@
|
|||||||
import Document, { Html, Head, Main, NextScript } from 'next/document'
|
import Document, { Html, Head, Main, NextScript } from 'next/document';
|
||||||
|
|
||||||
class MyDocument extends Document {
|
class MyDocument extends Document {
|
||||||
render() {
|
render() {
|
||||||
return (
|
return (
|
||||||
<Html lang="en" className="scroll-smooth">
|
<Html lang="en" className="scroll-smooth">
|
||||||
<Head>
|
<Head>
|
||||||
<link rel="apple-touch-icon" sizes="76x76" href="/static/favicons/apple-touch-icon.png" />
|
<link
|
||||||
|
rel="apple-touch-icon"
|
||||||
|
sizes="76x76"
|
||||||
|
href="/static/favicons/apple-touch-icon.png"
|
||||||
|
/>
|
||||||
<link
|
<link
|
||||||
rel="icon"
|
rel="icon"
|
||||||
type="image/png"
|
type="image/png"
|
||||||
@ -19,10 +23,22 @@ class MyDocument extends Document {
|
|||||||
href="/static/favicons/favicon-16x16.png"
|
href="/static/favicons/favicon-16x16.png"
|
||||||
/>
|
/>
|
||||||
<link rel="manifest" href="/static/favicons/site.webmanifest" />
|
<link rel="manifest" href="/static/favicons/site.webmanifest" />
|
||||||
<link rel="mask-icon" href="/static/favicons/safari-pinned-tab.svg" color="#5bbad5" />
|
<link
|
||||||
|
rel="mask-icon"
|
||||||
|
href="/static/favicons/safari-pinned-tab.svg"
|
||||||
|
color="#5bbad5"
|
||||||
|
/>
|
||||||
<meta name="msapplication-TileColor" content="#000000" />
|
<meta name="msapplication-TileColor" content="#000000" />
|
||||||
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#fff" />
|
<meta
|
||||||
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#000" />
|
name="theme-color"
|
||||||
|
media="(prefers-color-scheme: light)"
|
||||||
|
content="#fff"
|
||||||
|
/>
|
||||||
|
<meta
|
||||||
|
name="theme-color"
|
||||||
|
media="(prefers-color-scheme: dark)"
|
||||||
|
content="#000"
|
||||||
|
/>
|
||||||
<link rel="alternate" type="application/rss+xml" href="/feed.xml" />
|
<link rel="alternate" type="application/rss+xml" href="/feed.xml" />
|
||||||
</Head>
|
</Head>
|
||||||
<body className="bg-white text-black antialiased dark:bg-gray-900 dark:text-white">
|
<body className="bg-white text-black antialiased dark:bg-gray-900 dark:text-white">
|
||||||
@ -30,8 +46,8 @@ class MyDocument extends Document {
|
|||||||
<NextScript />
|
<NextScript />
|
||||||
</body>
|
</body>
|
||||||
</Html>
|
</Html>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default MyDocument
|
export default MyDocument;
|
||||||
|
@ -1,21 +1,25 @@
|
|||||||
import { MDXLayoutRenderer } from '@/components/MDXComponents'
|
import { MDXLayoutRenderer } from '@/components/MDXComponents';
|
||||||
import { getFileBySlug } from '@/lib/mdx'
|
import { getFileBySlug } from '@/lib/mdx';
|
||||||
import { GetStaticProps, InferGetStaticPropsType } from 'next'
|
import { GetStaticProps, InferGetStaticPropsType } from 'next';
|
||||||
import { AuthorFrontMatter } from 'types/AuthorFrontMatter'
|
import { AuthorFrontMatter } from 'types/AuthorFrontMatter';
|
||||||
|
|
||||||
const DEFAULT_LAYOUT = 'AuthorLayout'
|
const DEFAULT_LAYOUT = 'AuthorLayout';
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
export const getStaticProps: GetStaticProps<{
|
export const getStaticProps: GetStaticProps<{
|
||||||
authorDetails: { mdxSource: string; frontMatter: AuthorFrontMatter }
|
authorDetails: { mdxSource: string; frontMatter: AuthorFrontMatter };
|
||||||
}> = async () => {
|
}> = async () => {
|
||||||
const authorDetails = await getFileBySlug<AuthorFrontMatter>('authors', ['default'])
|
const authorDetails = await getFileBySlug<AuthorFrontMatter>('authors', [
|
||||||
const { mdxSource, frontMatter } = authorDetails
|
'default',
|
||||||
return { props: { authorDetails: { mdxSource, frontMatter } } }
|
]);
|
||||||
}
|
const { mdxSource, frontMatter } = authorDetails;
|
||||||
|
return { props: { authorDetails: { mdxSource, frontMatter } } };
|
||||||
|
};
|
||||||
|
|
||||||
export default function About({ authorDetails }: InferGetStaticPropsType<typeof getStaticProps>) {
|
export default function About({
|
||||||
const { mdxSource, frontMatter } = authorDetails
|
authorDetails,
|
||||||
|
}: InferGetStaticPropsType<typeof getStaticProps>) {
|
||||||
|
const { mdxSource, frontMatter } = authorDetails;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MDXLayoutRenderer
|
<MDXLayoutRenderer
|
||||||
@ -23,5 +27,5 @@ export default function About({ authorDetails }: InferGetStaticPropsType<typeof
|
|||||||
mdxSource={mdxSource}
|
mdxSource={mdxSource}
|
||||||
frontMatter={frontMatter}
|
frontMatter={frontMatter}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,15 +1,15 @@
|
|||||||
import { NextApiRequest, NextApiResponse } from 'next'
|
import { NextApiRequest, NextApiResponse } from 'next';
|
||||||
|
|
||||||
// eslint-disable-next-line import/no-anonymous-default-export
|
// eslint-disable-next-line import/no-anonymous-default-export
|
||||||
export default async (req: NextApiRequest, res: NextApiResponse) => {
|
export default async (req: NextApiRequest, res: NextApiResponse) => {
|
||||||
const { email } = req.body
|
const { email } = req.body;
|
||||||
if (!email) {
|
if (!email) {
|
||||||
return res.status(400).json({ error: 'Email is required' })
|
return res.status(400).json({ error: 'Email is required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const API_KEY = process.env.BUTTONDOWN_API_KEY
|
const API_KEY = process.env.BUTTONDOWN_API_KEY;
|
||||||
const buttondownRoute = `${process.env.BUTTONDOWN_API_URL}subscribers`
|
const buttondownRoute = `${process.env.BUTTONDOWN_API_URL}subscribers`;
|
||||||
const response = await fetch(buttondownRoute, {
|
const response = await fetch(buttondownRoute, {
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
email,
|
email,
|
||||||
@ -19,14 +19,16 @@ export default async (req: NextApiRequest, res: NextApiResponse) => {
|
|||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
})
|
});
|
||||||
|
|
||||||
if (response.status >= 400) {
|
if (response.status >= 400) {
|
||||||
return res.status(500).json({ error: `There was an error subscribing to the list.` })
|
return res
|
||||||
|
.status(500)
|
||||||
|
.json({ error: `There was an error subscribing to the list.` });
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.status(201).json({ error: '' })
|
return res.status(201).json({ error: '' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return res.status(500).json({ error: error.message || error.toString() })
|
return res.status(500).json({ error: error.message || error.toString() });
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
@ -1,20 +1,20 @@
|
|||||||
import { NextApiRequest, NextApiResponse } from 'next'
|
import { NextApiRequest, NextApiResponse } from 'next';
|
||||||
|
|
||||||
/* eslint-disable import/no-anonymous-default-export */
|
/* eslint-disable import/no-anonymous-default-export */
|
||||||
export default async (req: NextApiRequest, res: NextApiResponse) => {
|
export default async (req: NextApiRequest, res: NextApiResponse) => {
|
||||||
const { email } = req.body
|
const { email } = req.body;
|
||||||
|
|
||||||
if (!email) {
|
if (!email) {
|
||||||
return res.status(400).json({ error: 'Email is required' })
|
return res.status(400).json({ error: 'Email is required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const FORM_ID = process.env.CONVERTKIT_FORM_ID
|
const FORM_ID = process.env.CONVERTKIT_FORM_ID;
|
||||||
const API_KEY = process.env.CONVERTKIT_API_KEY
|
const API_KEY = process.env.CONVERTKIT_API_KEY;
|
||||||
const API_URL = process.env.CONVERTKIT_API_URL
|
const API_URL = process.env.CONVERTKIT_API_URL;
|
||||||
|
|
||||||
// Send request to ConvertKit
|
// Send request to ConvertKit
|
||||||
const data = { email, api_key: API_KEY }
|
const data = { email, api_key: API_KEY };
|
||||||
|
|
||||||
const response = await fetch(`${API_URL}forms/${FORM_ID}/subscribe`, {
|
const response = await fetch(`${API_URL}forms/${FORM_ID}/subscribe`, {
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
@ -22,16 +22,16 @@ export default async (req: NextApiRequest, res: NextApiResponse) => {
|
|||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
})
|
});
|
||||||
|
|
||||||
if (response.status >= 400) {
|
if (response.status >= 400) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
error: `There was an error subscribing to the list.`,
|
error: `There was an error subscribing to the list.`,
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.status(201).json({ error: '' })
|
return res.status(201).json({ error: '' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return res.status(500).json({ error: error.message || error.toString() })
|
return res.status(500).json({ error: error.message || error.toString() });
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
@ -1,15 +1,15 @@
|
|||||||
import { NextApiRequest, NextApiResponse } from 'next'
|
import { NextApiRequest, NextApiResponse } from 'next';
|
||||||
|
|
||||||
/* eslint-disable import/no-anonymous-default-export */
|
/* eslint-disable import/no-anonymous-default-export */
|
||||||
export default async (req: NextApiRequest, res: NextApiResponse) => {
|
export default async (req: NextApiRequest, res: NextApiResponse) => {
|
||||||
const { email } = req.body
|
const { email } = req.body;
|
||||||
if (!email) {
|
if (!email) {
|
||||||
return res.status(400).json({ error: 'Email is required' })
|
return res.status(400).json({ error: 'Email is required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const API_KEY = process.env.KLAVIYO_API_KEY
|
const API_KEY = process.env.KLAVIYO_API_KEY;
|
||||||
const LIST_ID = process.env.KLAVIYO_LIST_ID
|
const LIST_ID = process.env.KLAVIYO_LIST_ID;
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`https://a.klaviyo.com/api/v2/list/${LIST_ID}/subscribe?api_key=${API_KEY}`,
|
`https://a.klaviyo.com/api/v2/list/${LIST_ID}/subscribe?api_key=${API_KEY}`,
|
||||||
{
|
{
|
||||||
@ -24,14 +24,14 @@ export default async (req: NextApiRequest, res: NextApiResponse) => {
|
|||||||
profiles: [{ email: email }],
|
profiles: [{ email: email }],
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
)
|
);
|
||||||
if (response.status >= 400) {
|
if (response.status >= 400) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
error: `There was an error subscribing to the list.`,
|
error: `There was an error subscribing to the list.`,
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
return res.status(201).json({ error: '' })
|
return res.status(201).json({ error: '' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return res.status(500).json({ error: error.message || error.toString() })
|
return res.status(500).json({ error: error.message || error.toString() });
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
@ -1,26 +1,26 @@
|
|||||||
import { NextApiRequest, NextApiResponse } from 'next'
|
import { NextApiRequest, NextApiResponse } from 'next';
|
||||||
import mailchimp from '@mailchimp/mailchimp_marketing'
|
import mailchimp from '@mailchimp/mailchimp_marketing';
|
||||||
|
|
||||||
mailchimp.setConfig({
|
mailchimp.setConfig({
|
||||||
apiKey: process.env.MAILCHIMP_API_KEY,
|
apiKey: process.env.MAILCHIMP_API_KEY,
|
||||||
server: process.env.MAILCHIMP_API_SERVER, // E.g. us1
|
server: process.env.MAILCHIMP_API_SERVER, // E.g. us1
|
||||||
})
|
});
|
||||||
|
|
||||||
// eslint-disable-next-line import/no-anonymous-default-export
|
// eslint-disable-next-line import/no-anonymous-default-export
|
||||||
export default async (req: NextApiRequest, res: NextApiResponse) => {
|
export default async (req: NextApiRequest, res: NextApiResponse) => {
|
||||||
const { email } = req.body
|
const { email } = req.body;
|
||||||
|
|
||||||
if (!email) {
|
if (!email) {
|
||||||
return res.status(400).json({ error: 'Email is required' })
|
return res.status(400).json({ error: 'Email is required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await mailchimp.lists.addListMember(process.env.MAILCHIMP_AUDIENCE_ID, {
|
await mailchimp.lists.addListMember(process.env.MAILCHIMP_AUDIENCE_ID, {
|
||||||
email_address: email,
|
email_address: email,
|
||||||
status: 'subscribed',
|
status: 'subscribed',
|
||||||
})
|
});
|
||||||
return res.status(201).json({ error: '' })
|
return res.status(201).json({ error: '' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return res.status(500).json({ error: error.message || error.toString() })
|
return res.status(500).json({ error: error.message || error.toString() });
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
@ -1,26 +1,26 @@
|
|||||||
import { getAllFilesFrontMatter } from '@/lib/mdx'
|
import { getAllFilesFrontMatter } from '@/lib/mdx';
|
||||||
import siteMetadata from '@/data/siteMetadata'
|
import siteMetadata from '@/data/siteMetadata';
|
||||||
import ListLayout from '@/layouts/ListLayout'
|
import ListLayout from '@/layouts/ListLayout';
|
||||||
import { PageSEO } from '@/components/SEO'
|
import { PageSEO } from '@/components/SEO';
|
||||||
import { GetStaticProps, InferGetStaticPropsType } from 'next'
|
import { GetStaticProps, InferGetStaticPropsType } from 'next';
|
||||||
import { ComponentProps } from 'react'
|
import { ComponentProps } from 'react';
|
||||||
|
|
||||||
export const POSTS_PER_PAGE = 5
|
export const POSTS_PER_PAGE = 5;
|
||||||
|
|
||||||
export const getStaticProps: GetStaticProps<{
|
export const getStaticProps: GetStaticProps<{
|
||||||
posts: ComponentProps<typeof ListLayout>['posts']
|
posts: ComponentProps<typeof ListLayout>['posts'];
|
||||||
initialDisplayPosts: ComponentProps<typeof ListLayout>['initialDisplayPosts']
|
initialDisplayPosts: ComponentProps<typeof ListLayout>['initialDisplayPosts'];
|
||||||
pagination: ComponentProps<typeof ListLayout>['pagination']
|
pagination: ComponentProps<typeof ListLayout>['pagination'];
|
||||||
}> = async () => {
|
}> = async () => {
|
||||||
const posts = await getAllFilesFrontMatter('blog')
|
const posts = await getAllFilesFrontMatter('blog');
|
||||||
const initialDisplayPosts = posts.slice(0, POSTS_PER_PAGE)
|
const initialDisplayPosts = posts.slice(0, POSTS_PER_PAGE);
|
||||||
const pagination = {
|
const pagination = {
|
||||||
currentPage: 1,
|
currentPage: 1,
|
||||||
totalPages: Math.ceil(posts.length / POSTS_PER_PAGE),
|
totalPages: Math.ceil(posts.length / POSTS_PER_PAGE),
|
||||||
}
|
};
|
||||||
|
|
||||||
return { props: { initialDisplayPosts, posts, pagination } }
|
return { props: { initialDisplayPosts, posts, pagination } };
|
||||||
}
|
};
|
||||||
|
|
||||||
export default function Blog({
|
export default function Blog({
|
||||||
posts,
|
posts,
|
||||||
@ -29,7 +29,10 @@ export default function Blog({
|
|||||||
}: InferGetStaticPropsType<typeof getStaticProps>) {
|
}: InferGetStaticPropsType<typeof getStaticProps>) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageSEO title={`Blog - ${siteMetadata.author}`} description={siteMetadata.description} />
|
<PageSEO
|
||||||
|
title={`Blog - ${siteMetadata.author}`}
|
||||||
|
description={siteMetadata.description}
|
||||||
|
/>
|
||||||
<ListLayout
|
<ListLayout
|
||||||
posts={posts}
|
posts={posts}
|
||||||
initialDisplayPosts={initialDisplayPosts}
|
initialDisplayPosts={initialDisplayPosts}
|
||||||
@ -37,5 +40,5 @@ export default function Blog({
|
|||||||
title="All Posts"
|
title="All Posts"
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,17 +1,22 @@
|
|||||||
import fs from 'fs'
|
import fs from 'fs';
|
||||||
import PageTitle from '@/components/PageTitle'
|
import PageTitle from '@/components/PageTitle';
|
||||||
import generateRss from '@/lib/generate-rss'
|
import generateRss from '@/lib/generate-rss';
|
||||||
import { MDXLayoutRenderer } from '@/components/MDXComponents'
|
import { MDXLayoutRenderer } from '@/components/MDXComponents';
|
||||||
import { formatSlug, getAllFilesFrontMatter, getFileBySlug, getFiles } from '@/lib/mdx'
|
import {
|
||||||
import { GetStaticProps, InferGetStaticPropsType } from 'next'
|
formatSlug,
|
||||||
import { AuthorFrontMatter } from 'types/AuthorFrontMatter'
|
getAllFilesFrontMatter,
|
||||||
import { PostFrontMatter } from 'types/PostFrontMatter'
|
getFileBySlug,
|
||||||
import { Toc } from 'types/Toc'
|
getFiles,
|
||||||
|
} from '@/lib/mdx';
|
||||||
|
import { GetStaticProps, InferGetStaticPropsType } from 'next';
|
||||||
|
import { AuthorFrontMatter } from 'types/AuthorFrontMatter';
|
||||||
|
import { PostFrontMatter } from 'types/PostFrontMatter';
|
||||||
|
import { Toc } from 'types/Toc';
|
||||||
|
|
||||||
const DEFAULT_LAYOUT = 'PostLayout'
|
const DEFAULT_LAYOUT = 'PostLayout';
|
||||||
|
|
||||||
export async function getStaticPaths() {
|
export async function getStaticPaths() {
|
||||||
const posts = getFiles('blog')
|
const posts = getFiles('blog');
|
||||||
return {
|
return {
|
||||||
paths: posts.map((p) => ({
|
paths: posts.map((p) => ({
|
||||||
params: {
|
params: {
|
||||||
@ -19,34 +24,38 @@ export async function getStaticPaths() {
|
|||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
fallback: false,
|
fallback: false,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
export const getStaticProps: GetStaticProps<{
|
export const getStaticProps: GetStaticProps<{
|
||||||
post: { mdxSource: string; toc: Toc; frontMatter: PostFrontMatter }
|
post: { mdxSource: string; toc: Toc; frontMatter: PostFrontMatter };
|
||||||
authorDetails: AuthorFrontMatter[]
|
authorDetails: AuthorFrontMatter[];
|
||||||
prev?: { slug: string; title: string }
|
prev?: { slug: string; title: string };
|
||||||
next?: { slug: string; title: string }
|
next?: { slug: string; title: string };
|
||||||
}> = async ({ params }) => {
|
}> = async ({ params }) => {
|
||||||
const slug = (params.slug as string[]).join('/')
|
const slug = (params.slug as string[]).join('/');
|
||||||
const allPosts = await getAllFilesFrontMatter('blog')
|
const allPosts = await getAllFilesFrontMatter('blog');
|
||||||
const postIndex = allPosts.findIndex((post) => formatSlug(post.slug) === slug)
|
const postIndex = allPosts.findIndex(
|
||||||
const prev: { slug: string; title: string } = allPosts[postIndex + 1] || null
|
(post) => formatSlug(post.slug) === slug
|
||||||
const next: { slug: string; title: string } = allPosts[postIndex - 1] || null
|
);
|
||||||
const post = await getFileBySlug<PostFrontMatter>('blog', slug)
|
const prev: { slug: string; title: string } = allPosts[postIndex + 1] || null;
|
||||||
|
const next: { slug: string; title: string } = allPosts[postIndex - 1] || null;
|
||||||
|
const post = await getFileBySlug<PostFrontMatter>('blog', slug);
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const authorList = post.frontMatter.authors || ['default']
|
const authorList = post.frontMatter.authors || ['default'];
|
||||||
const authorPromise = authorList.map(async (author) => {
|
const authorPromise = authorList.map(async (author) => {
|
||||||
const authorResults = await getFileBySlug<AuthorFrontMatter>('authors', [author])
|
const authorResults = await getFileBySlug<AuthorFrontMatter>('authors', [
|
||||||
return authorResults.frontMatter
|
author,
|
||||||
})
|
]);
|
||||||
const authorDetails = await Promise.all(authorPromise)
|
return authorResults.frontMatter;
|
||||||
|
});
|
||||||
|
const authorDetails = await Promise.all(authorPromise);
|
||||||
|
|
||||||
// rss
|
// rss
|
||||||
if (allPosts.length > 0) {
|
if (allPosts.length > 0) {
|
||||||
const rss = generateRss(allPosts)
|
const rss = generateRss(allPosts);
|
||||||
fs.writeFileSync('./public/feed.xml', rss)
|
fs.writeFileSync('./public/feed.xml', rss);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -56,8 +65,8 @@ export const getStaticProps: GetStaticProps<{
|
|||||||
prev,
|
prev,
|
||||||
next,
|
next,
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
}
|
};
|
||||||
|
|
||||||
export default function Blog({
|
export default function Blog({
|
||||||
post,
|
post,
|
||||||
@ -65,7 +74,7 @@ export default function Blog({
|
|||||||
prev,
|
prev,
|
||||||
next,
|
next,
|
||||||
}: InferGetStaticPropsType<typeof getStaticProps>) {
|
}: InferGetStaticPropsType<typeof getStaticProps>) {
|
||||||
const { mdxSource, toc, frontMatter } = post
|
const { mdxSource, toc, frontMatter } = post;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -90,5 +99,5 @@ export default function Blog({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,42 +1,42 @@
|
|||||||
import { PageSEO } from '@/components/SEO'
|
import { PageSEO } from '@/components/SEO';
|
||||||
import siteMetadata from '@/data/siteMetadata'
|
import siteMetadata from '@/data/siteMetadata';
|
||||||
import { getAllFilesFrontMatter } from '@/lib/mdx'
|
import { getAllFilesFrontMatter } from '@/lib/mdx';
|
||||||
import ListLayout from '@/layouts/ListLayout'
|
import ListLayout from '@/layouts/ListLayout';
|
||||||
import { POSTS_PER_PAGE } from '../../blog'
|
import { POSTS_PER_PAGE } from '../../blog';
|
||||||
import { GetStaticPaths, GetStaticProps, InferGetStaticPropsType } from 'next'
|
import { GetStaticPaths, GetStaticProps, InferGetStaticPropsType } from 'next';
|
||||||
import { PostFrontMatter } from 'types/PostFrontMatter'
|
import { PostFrontMatter } from 'types/PostFrontMatter';
|
||||||
|
|
||||||
export const getStaticPaths: GetStaticPaths<{ page: string }> = async () => {
|
export const getStaticPaths: GetStaticPaths<{ page: string }> = async () => {
|
||||||
const totalPosts = await getAllFilesFrontMatter('blog')
|
const totalPosts = await getAllFilesFrontMatter('blog');
|
||||||
const totalPages = Math.ceil(totalPosts.length / POSTS_PER_PAGE)
|
const totalPages = Math.ceil(totalPosts.length / POSTS_PER_PAGE);
|
||||||
const paths = Array.from({ length: totalPages }, (_, i) => ({
|
const paths = Array.from({ length: totalPages }, (_, i) => ({
|
||||||
params: { page: (i + 1).toString() },
|
params: { page: (i + 1).toString() },
|
||||||
}))
|
}));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
paths,
|
paths,
|
||||||
fallback: false,
|
fallback: false,
|
||||||
}
|
};
|
||||||
}
|
};
|
||||||
|
|
||||||
export const getStaticProps: GetStaticProps<{
|
export const getStaticProps: GetStaticProps<{
|
||||||
posts: PostFrontMatter[]
|
posts: PostFrontMatter[];
|
||||||
initialDisplayPosts: PostFrontMatter[]
|
initialDisplayPosts: PostFrontMatter[];
|
||||||
pagination: { currentPage: number; totalPages: number }
|
pagination: { currentPage: number; totalPages: number };
|
||||||
}> = async (context) => {
|
}> = async (context) => {
|
||||||
const {
|
const {
|
||||||
params: { page },
|
params: { page },
|
||||||
} = context
|
} = context;
|
||||||
const posts = await getAllFilesFrontMatter('blog')
|
const posts = await getAllFilesFrontMatter('blog');
|
||||||
const pageNumber = parseInt(page as string)
|
const pageNumber = parseInt(page as string);
|
||||||
const initialDisplayPosts = posts.slice(
|
const initialDisplayPosts = posts.slice(
|
||||||
POSTS_PER_PAGE * (pageNumber - 1),
|
POSTS_PER_PAGE * (pageNumber - 1),
|
||||||
POSTS_PER_PAGE * pageNumber
|
POSTS_PER_PAGE * pageNumber
|
||||||
)
|
);
|
||||||
const pagination = {
|
const pagination = {
|
||||||
currentPage: pageNumber,
|
currentPage: pageNumber,
|
||||||
totalPages: Math.ceil(posts.length / POSTS_PER_PAGE),
|
totalPages: Math.ceil(posts.length / POSTS_PER_PAGE),
|
||||||
}
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: {
|
props: {
|
||||||
@ -44,8 +44,8 @@ export const getStaticProps: GetStaticProps<{
|
|||||||
initialDisplayPosts,
|
initialDisplayPosts,
|
||||||
pagination,
|
pagination,
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
}
|
};
|
||||||
|
|
||||||
export default function PostPage({
|
export default function PostPage({
|
||||||
posts,
|
posts,
|
||||||
@ -54,7 +54,10 @@ export default function PostPage({
|
|||||||
}: InferGetStaticPropsType<typeof getStaticProps>) {
|
}: InferGetStaticPropsType<typeof getStaticProps>) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageSEO title={siteMetadata.title} description={siteMetadata.description} />
|
<PageSEO
|
||||||
|
title={siteMetadata.title}
|
||||||
|
description={siteMetadata.description}
|
||||||
|
/>
|
||||||
<ListLayout
|
<ListLayout
|
||||||
posts={posts}
|
posts={posts}
|
||||||
initialDisplayPosts={initialDisplayPosts}
|
initialDisplayPosts={initialDisplayPosts}
|
||||||
@ -62,5 +65,5 @@ export default function PostPage({
|
|||||||
title="All Posts"
|
title="All Posts"
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,25 +1,32 @@
|
|||||||
import Link from '@/components/Link'
|
import Link from '@/components/Link';
|
||||||
import { PageSEO } from '@/components/SEO'
|
import { PageSEO } from '@/components/SEO';
|
||||||
import Tag from '@/components/Tag'
|
import Tag from '@/components/Tag';
|
||||||
import siteMetadata from '@/data/siteMetadata'
|
import siteMetadata from '@/data/siteMetadata';
|
||||||
import { getAllFilesFrontMatter } from '@/lib/mdx'
|
import { getAllFilesFrontMatter } from '@/lib/mdx';
|
||||||
import formatDate from '@/lib/utils/formatDate'
|
import formatDate from '@/lib/utils/formatDate';
|
||||||
import { GetStaticProps, InferGetStaticPropsType } from 'next'
|
import { GetStaticProps, InferGetStaticPropsType } from 'next';
|
||||||
import { PostFrontMatter } from 'types/PostFrontMatter'
|
import { PostFrontMatter } from 'types/PostFrontMatter';
|
||||||
import NewsletterForm from '@/components/NewsletterForm'
|
import NewsletterForm from '@/components/NewsletterForm';
|
||||||
|
|
||||||
const MAX_DISPLAY = 5
|
const MAX_DISPLAY = 5;
|
||||||
|
|
||||||
export const getStaticProps: GetStaticProps<{ posts: PostFrontMatter[] }> = async () => {
|
export const getStaticProps: GetStaticProps<{
|
||||||
const posts = await getAllFilesFrontMatter('blog')
|
posts: PostFrontMatter[];
|
||||||
|
}> = async () => {
|
||||||
|
const posts = await getAllFilesFrontMatter('blog');
|
||||||
|
|
||||||
return { props: { posts } }
|
return { props: { posts } };
|
||||||
}
|
};
|
||||||
|
|
||||||
export default function Home({ posts }: InferGetStaticPropsType<typeof getStaticProps>) {
|
export default function Home({
|
||||||
|
posts,
|
||||||
|
}: InferGetStaticPropsType<typeof getStaticProps>) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageSEO title={siteMetadata.title} description={siteMetadata.description} />
|
<PageSEO
|
||||||
|
title={siteMetadata.title}
|
||||||
|
description={siteMetadata.description}
|
||||||
|
/>
|
||||||
<div className="divide-y divide-gray-200 dark:divide-gray-700">
|
<div className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
<div className="space-y-2 pt-6 pb-8 md:space-y-5">
|
<div className="space-y-2 pt-6 pb-8 md:space-y-5">
|
||||||
<h1 className="text-3xl font-extrabold leading-9 tracking-tight text-gray-900 dark:text-gray-100 sm:text-4xl sm:leading-10 md:text-6xl md:leading-14">
|
<h1 className="text-3xl font-extrabold leading-9 tracking-tight text-gray-900 dark:text-gray-100 sm:text-4xl sm:leading-10 md:text-6xl md:leading-14">
|
||||||
@ -32,7 +39,7 @@ export default function Home({ posts }: InferGetStaticPropsType<typeof getStatic
|
|||||||
<ul className="divide-y divide-gray-200 dark:divide-gray-700">
|
<ul className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
{!posts.length && '没有找到文章。 😭'}
|
{!posts.length && '没有找到文章。 😭'}
|
||||||
{posts.slice(0, MAX_DISPLAY).map((frontMatter) => {
|
{posts.slice(0, MAX_DISPLAY).map((frontMatter) => {
|
||||||
const { slug, date, title, summary, tags } = frontMatter
|
const { slug, date, title, summary, tags } = frontMatter;
|
||||||
return (
|
return (
|
||||||
<li key={slug} className="py-12">
|
<li key={slug} className="py-12">
|
||||||
<article>
|
<article>
|
||||||
@ -49,8 +56,7 @@ export default function Home({ posts }: InferGetStaticPropsType<typeof getStatic
|
|||||||
<h2 className="text-2xl font-bold leading-8 tracking-tight">
|
<h2 className="text-2xl font-bold leading-8 tracking-tight">
|
||||||
<Link
|
<Link
|
||||||
href={`/blog/${slug}`}
|
href={`/blog/${slug}`}
|
||||||
className="text-gray-900 dark:text-gray-100"
|
className="text-gray-900 dark:text-gray-100">
|
||||||
>
|
|
||||||
{title}
|
{title}
|
||||||
</Link>
|
</Link>
|
||||||
</h2>
|
</h2>
|
||||||
@ -68,8 +74,7 @@ export default function Home({ posts }: InferGetStaticPropsType<typeof getStatic
|
|||||||
<Link
|
<Link
|
||||||
href={`/blog/${slug}`}
|
href={`/blog/${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"
|
||||||
aria-label={`Read "${title}"`}
|
aria-label={`Read "${title}"`}>
|
||||||
>
|
|
||||||
Read more →
|
Read more →
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@ -77,7 +82,7 @@ export default function Home({ posts }: InferGetStaticPropsType<typeof getStatic
|
|||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</li>
|
</li>
|
||||||
)
|
);
|
||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@ -86,8 +91,7 @@ export default function Home({ posts }: InferGetStaticPropsType<typeof getStatic
|
|||||||
<Link
|
<Link
|
||||||
href="/blog"
|
href="/blog"
|
||||||
className="text-primary-500 hover:text-primary-600 dark:hover:text-primary-400"
|
className="text-primary-500 hover:text-primary-600 dark:hover:text-primary-400"
|
||||||
aria-label="all posts"
|
aria-label="all posts">
|
||||||
>
|
|
||||||
All Posts →
|
All Posts →
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@ -98,5 +102,5 @@ export default function Home({ posts }: InferGetStaticPropsType<typeof getStatic
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,12 +1,15 @@
|
|||||||
import siteMetadata from '@/data/siteMetadata'
|
import siteMetadata from '@/data/siteMetadata';
|
||||||
import projectsData from '@/data/projectsData'
|
import projectsData from '@/data/projectsData';
|
||||||
import Card from '@/components/Card'
|
import Card from '@/components/Card';
|
||||||
import { PageSEO } from '@/components/SEO'
|
import { PageSEO } from '@/components/SEO';
|
||||||
|
|
||||||
export default function Projects() {
|
export default function Projects() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageSEO title={`Projects - ${siteMetadata.author}`} description={siteMetadata.description} />
|
<PageSEO
|
||||||
|
title={`Projects - ${siteMetadata.author}`}
|
||||||
|
description={siteMetadata.description}
|
||||||
|
/>
|
||||||
<div className="divide-y divide-gray-200 dark:divide-gray-700">
|
<div className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
<div className="space-y-2 pt-6 pb-8 md:space-y-5">
|
<div className="space-y-2 pt-6 pb-8 md:space-y-5">
|
||||||
<h1 className="text-3xl font-extrabold leading-9 tracking-tight text-gray-900 dark:text-gray-100 sm:text-4xl sm:leading-10 md:text-6xl md:leading-14">
|
<h1 className="text-3xl font-extrabold leading-9 tracking-tight text-gray-900 dark:text-gray-100 sm:text-4xl sm:leading-10 md:text-6xl md:leading-14">
|
||||||
@ -31,5 +34,5 @@ export default function Projects() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,22 +1,29 @@
|
|||||||
import Link from '@/components/Link'
|
import Link from '@/components/Link';
|
||||||
import { PageSEO } from '@/components/SEO'
|
import { PageSEO } from '@/components/SEO';
|
||||||
import Tag from '@/components/Tag'
|
import Tag from '@/components/Tag';
|
||||||
import siteMetadata from '@/data/siteMetadata'
|
import siteMetadata from '@/data/siteMetadata';
|
||||||
import { getAllTags } from '@/lib/tags'
|
import { getAllTags } from '@/lib/tags';
|
||||||
import kebabCase from '@/lib/utils/kebabCase'
|
import kebabCase from '@/lib/utils/kebabCase';
|
||||||
import { GetStaticProps, InferGetStaticPropsType } from 'next'
|
import { GetStaticProps, InferGetStaticPropsType } from 'next';
|
||||||
|
|
||||||
export const getStaticProps: GetStaticProps<{ tags: Record<string, number> }> = async () => {
|
export const getStaticProps: GetStaticProps<{
|
||||||
const tags = await getAllTags('blog')
|
tags: Record<string, number>;
|
||||||
|
}> = async () => {
|
||||||
|
const tags = await getAllTags('blog');
|
||||||
|
|
||||||
return { props: { tags } }
|
return { props: { tags } };
|
||||||
}
|
};
|
||||||
|
|
||||||
export default function Tags({ tags }: InferGetStaticPropsType<typeof getStaticProps>) {
|
export default function Tags({
|
||||||
const sortedTags = Object.keys(tags).sort((a, b) => tags[b] - tags[a])
|
tags,
|
||||||
|
}: InferGetStaticPropsType<typeof getStaticProps>) {
|
||||||
|
const sortedTags = Object.keys(tags).sort((a, b) => tags[b] - tags[a]);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageSEO title={`Tags - ${siteMetadata.author}`} description="Things I blog about" />
|
<PageSEO
|
||||||
|
title={`Tags - ${siteMetadata.author}`}
|
||||||
|
description="Things I blog about"
|
||||||
|
/>
|
||||||
<div className="flex flex-col items-start justify-start divide-y divide-gray-200 dark:divide-gray-700 md:mt-24 md:flex-row md:items-center md:justify-center md:space-x-6 md:divide-y-0">
|
<div className="flex flex-col items-start justify-start divide-y divide-gray-200 dark:divide-gray-700 md:mt-24 md:flex-row md:items-center md:justify-center md:space-x-6 md:divide-y-0">
|
||||||
<div className="space-x-2 pt-6 pb-8 md:space-y-5">
|
<div className="space-x-2 pt-6 pb-8 md:space-y-5">
|
||||||
<h1 className="text-3xl font-extrabold leading-9 tracking-tight text-gray-900 dark:text-gray-100 sm:text-4xl sm:leading-10 md:border-r-2 md:px-6 md:text-6xl md:leading-14">
|
<h1 className="text-3xl font-extrabold leading-9 tracking-tight text-gray-900 dark:text-gray-100 sm:text-4xl sm:leading-10 md:border-r-2 md:px-6 md:text-6xl md:leading-14">
|
||||||
@ -31,15 +38,14 @@ export default function Tags({ tags }: InferGetStaticPropsType<typeof getStaticP
|
|||||||
<Tag text={t} />
|
<Tag text={t} />
|
||||||
<Link
|
<Link
|
||||||
href={`/tags/${kebabCase(t)}`}
|
href={`/tags/${kebabCase(t)}`}
|
||||||
className="-ml-2 text-sm font-semibold uppercase text-gray-600 dark:text-gray-300"
|
className="-ml-2 text-sm font-semibold uppercase text-gray-600 dark:text-gray-300">
|
||||||
>
|
|
||||||
{` (${tags[t]})`}
|
{` (${tags[t]})`}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,19 +1,19 @@
|
|||||||
import { TagSEO } from '@/components/SEO'
|
import { TagSEO } from '@/components/SEO';
|
||||||
import siteMetadata from '@/data/siteMetadata'
|
import siteMetadata from '@/data/siteMetadata';
|
||||||
import ListLayout from '@/layouts/ListLayout'
|
import ListLayout from '@/layouts/ListLayout';
|
||||||
import generateRss from '@/lib/generate-rss'
|
import generateRss from '@/lib/generate-rss';
|
||||||
import { getAllFilesFrontMatter } from '@/lib/mdx'
|
import { getAllFilesFrontMatter } from '@/lib/mdx';
|
||||||
import { getAllTags } from '@/lib/tags'
|
import { getAllTags } from '@/lib/tags';
|
||||||
import kebabCase from '@/lib/utils/kebabCase'
|
import kebabCase from '@/lib/utils/kebabCase';
|
||||||
import fs from 'fs'
|
import fs from 'fs';
|
||||||
import { GetStaticProps, InferGetStaticPropsType } from 'next'
|
import { GetStaticProps, InferGetStaticPropsType } from 'next';
|
||||||
import path from 'path'
|
import path from 'path';
|
||||||
import { PostFrontMatter } from 'types/PostFrontMatter'
|
import { PostFrontMatter } from 'types/PostFrontMatter';
|
||||||
|
|
||||||
const root = process.cwd()
|
const root = process.cwd();
|
||||||
|
|
||||||
export async function getStaticPaths() {
|
export async function getStaticPaths() {
|
||||||
const tags = await getAllTags('blog')
|
const tags = await getAllTags('blog');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
paths: Object.keys(tags).map((tag) => ({
|
paths: Object.keys(tags).map((tag) => ({
|
||||||
@ -22,32 +22,37 @@ export async function getStaticPaths() {
|
|||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
fallback: false,
|
fallback: false,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getStaticProps: GetStaticProps<{ posts: PostFrontMatter[]; tag: string }> = async (
|
export const getStaticProps: GetStaticProps<{
|
||||||
context
|
posts: PostFrontMatter[];
|
||||||
) => {
|
tag: string;
|
||||||
const tag = context.params.tag as string
|
}> = async (context) => {
|
||||||
const allPosts = await getAllFilesFrontMatter('blog')
|
const tag = context.params.tag as string;
|
||||||
|
const allPosts = await getAllFilesFrontMatter('blog');
|
||||||
const filteredPosts = allPosts.filter(
|
const filteredPosts = allPosts.filter(
|
||||||
(post) => post.draft !== true && post.tags.map((t) => kebabCase(t)).includes(tag)
|
(post) =>
|
||||||
)
|
post.draft !== true && post.tags.map((t) => kebabCase(t)).includes(tag)
|
||||||
|
);
|
||||||
|
|
||||||
// rss
|
// rss
|
||||||
if (filteredPosts.length > 0) {
|
if (filteredPosts.length > 0) {
|
||||||
const rss = generateRss(filteredPosts, `tags/${tag}/feed.xml`)
|
const rss = generateRss(filteredPosts, `tags/${tag}/feed.xml`);
|
||||||
const rssPath = path.join(root, 'public', 'tags', tag)
|
const rssPath = path.join(root, 'public', 'tags', tag);
|
||||||
fs.mkdirSync(rssPath, { recursive: true })
|
fs.mkdirSync(rssPath, { recursive: true });
|
||||||
fs.writeFileSync(path.join(rssPath, 'feed.xml'), rss)
|
fs.writeFileSync(path.join(rssPath, 'feed.xml'), rss);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { props: { posts: filteredPosts, tag } }
|
return { props: { posts: filteredPosts, tag } };
|
||||||
}
|
};
|
||||||
|
|
||||||
export default function Tag({ posts, tag }: InferGetStaticPropsType<typeof getStaticProps>) {
|
export default function Tag({
|
||||||
|
posts,
|
||||||
|
tag,
|
||||||
|
}: InferGetStaticPropsType<typeof getStaticProps>) {
|
||||||
// Capitalize first letter and convert space to dash
|
// Capitalize first letter and convert space to dash
|
||||||
const title = tag[0].toUpperCase() + tag.split(' ').join('-').slice(1)
|
const title = tag[0].toUpperCase() + tag.split(' ').join('-').slice(1);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<TagSEO
|
<TagSEO
|
||||||
@ -56,5 +61,5 @@ export default function Tag({ posts, tag }: InferGetStaticPropsType<typeof getSt
|
|||||||
/>
|
/>
|
||||||
<ListLayout posts={posts} title={title} />
|
<ListLayout posts={posts} title={title} />
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
3378
pnpm-lock.yaml
generated
3378
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -1,36 +1,39 @@
|
|||||||
const fs = require('fs')
|
const fs = require('fs');
|
||||||
const path = require('path')
|
const path = require('path');
|
||||||
const inquirer = require('inquirer')
|
const inquirer = require('inquirer');
|
||||||
const dedent = require('dedent')
|
const dedent = require('dedent');
|
||||||
|
|
||||||
const root = process.cwd()
|
const root = process.cwd();
|
||||||
|
|
||||||
const getAuthors = () => {
|
const getAuthors = () => {
|
||||||
const authorPath = path.join(root, 'data', 'authors')
|
const authorPath = path.join(root, 'data', 'authors');
|
||||||
const authorList = fs.readdirSync(authorPath).map((filename) => path.parse(filename).name)
|
const authorList = fs
|
||||||
return authorList
|
.readdirSync(authorPath)
|
||||||
}
|
.map((filename) => path.parse(filename).name);
|
||||||
|
return authorList;
|
||||||
|
};
|
||||||
|
|
||||||
const getLayouts = () => {
|
const getLayouts = () => {
|
||||||
const layoutPath = path.join(root, 'layouts')
|
const layoutPath = path.join(root, 'layouts');
|
||||||
const layoutList = fs
|
const layoutList = fs
|
||||||
.readdirSync(layoutPath)
|
.readdirSync(layoutPath)
|
||||||
.map((filename) => path.parse(filename).name)
|
.map((filename) => path.parse(filename).name)
|
||||||
.filter((file) => file.toLowerCase().includes('post'))
|
.filter((file) => file.toLowerCase().includes('post'));
|
||||||
return layoutList
|
return layoutList;
|
||||||
}
|
};
|
||||||
|
|
||||||
const genFrontMatter = (answers) => {
|
const genFrontMatter = (answers) => {
|
||||||
let d = new Date()
|
let d = new Date();
|
||||||
const date = [
|
const date = [
|
||||||
d.getFullYear(),
|
d.getFullYear(),
|
||||||
('0' + (d.getMonth() + 1)).slice(-2),
|
('0' + (d.getMonth() + 1)).slice(-2),
|
||||||
('0' + d.getDate()).slice(-2),
|
('0' + d.getDate()).slice(-2),
|
||||||
].join('-')
|
].join('-');
|
||||||
const tagArray = answers.tags.split(',')
|
const tagArray = answers.tags.split(',');
|
||||||
tagArray.forEach((tag, index) => (tagArray[index] = tag.trim()))
|
tagArray.forEach((tag, index) => (tagArray[index] = tag.trim()));
|
||||||
const tags = "'" + tagArray.join("','") + "'"
|
const tags = "'" + tagArray.join("','") + "'";
|
||||||
const authorArray = answers.authors.length > 0 ? "'" + answers.authors.join("','") + "'" : ''
|
const authorArray =
|
||||||
|
answers.authors.length > 0 ? "'" + answers.authors.join("','") + "'" : '';
|
||||||
|
|
||||||
let frontMatter = dedent`---
|
let frontMatter = dedent`---
|
||||||
title: ${answers.title ? answers.title : 'Untitled'}
|
title: ${answers.title ? answers.title : 'Untitled'}
|
||||||
@ -41,16 +44,16 @@ const genFrontMatter = (answers) => {
|
|||||||
images: []
|
images: []
|
||||||
layout: ${answers.layout}
|
layout: ${answers.layout}
|
||||||
canonicalUrl: ${answers.canonicalUrl}
|
canonicalUrl: ${answers.canonicalUrl}
|
||||||
`
|
`;
|
||||||
|
|
||||||
if (answers.authors.length > 0) {
|
if (answers.authors.length > 0) {
|
||||||
frontMatter = frontMatter + '\n' + `authors: [${authorArray}]`
|
frontMatter = frontMatter + '\n' + `authors: [${authorArray}]`;
|
||||||
}
|
}
|
||||||
|
|
||||||
frontMatter = frontMatter + '\n---'
|
frontMatter = frontMatter + '\n---';
|
||||||
|
|
||||||
return frontMatter
|
return frontMatter;
|
||||||
}
|
};
|
||||||
|
|
||||||
inquirer
|
inquirer
|
||||||
.prompt([
|
.prompt([
|
||||||
@ -105,24 +108,25 @@ inquirer
|
|||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.replace(/[^a-zA-Z0-9 ]/g, '')
|
.replace(/[^a-zA-Z0-9 ]/g, '')
|
||||||
.replace(/ /g, '-')
|
.replace(/ /g, '-')
|
||||||
.replace(/-+/g, '-')
|
.replace(/-+/g, '-');
|
||||||
const frontMatter = genFrontMatter(answers)
|
const frontMatter = genFrontMatter(answers);
|
||||||
if (!fs.existsSync('data/blog')) fs.mkdirSync('data/blog', { recursive: true })
|
if (!fs.existsSync('data/blog'))
|
||||||
|
fs.mkdirSync('data/blog', { recursive: true });
|
||||||
const filePath = `data/blog/${fileName ? fileName : 'untitled'}.${
|
const filePath = `data/blog/${fileName ? fileName : 'untitled'}.${
|
||||||
answers.extension ? answers.extension : 'md'
|
answers.extension ? answers.extension : 'md'
|
||||||
}`
|
}`;
|
||||||
fs.writeFile(filePath, frontMatter, { flag: 'wx' }, (err) => {
|
fs.writeFile(filePath, frontMatter, { flag: 'wx' }, (err) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
throw err
|
throw err;
|
||||||
} else {
|
} else {
|
||||||
console.log(`Blog post generated successfully at ${filePath}`)
|
console.log(`Blog post generated successfully at ${filePath}`);
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
if (error.isTtyError) {
|
if (error.isTtyError) {
|
||||||
console.log("Prompt couldn't be rendered in the current environment")
|
console.log("Prompt couldn't be rendered in the current environment");
|
||||||
} else {
|
} else {
|
||||||
console.log('Something went wrong, sorry!')
|
console.log('Something went wrong, sorry!');
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
@ -1,11 +1,11 @@
|
|||||||
const fs = require('fs')
|
const fs = require('fs');
|
||||||
const globby = require('globby')
|
const globby = require('globby');
|
||||||
const matter = require('gray-matter')
|
const matter = require('gray-matter');
|
||||||
const prettier = require('prettier')
|
const prettier = require('prettier');
|
||||||
const siteMetadata = require('../data/siteMetadata')
|
const siteMetadata = require('../data/siteMetadata');
|
||||||
|
|
||||||
;(async () => {
|
(async () => {
|
||||||
const prettierConfig = await prettier.resolveConfig('./.prettierrc.js')
|
const prettierConfig = await prettier.resolveConfig('./.prettierrc.js');
|
||||||
const pages = await globby([
|
const pages = await globby([
|
||||||
'pages/*.js',
|
'pages/*.js',
|
||||||
'pages/*.tsx',
|
'pages/*.tsx',
|
||||||
@ -15,7 +15,7 @@ const siteMetadata = require('../data/siteMetadata')
|
|||||||
'!pages/_*.js',
|
'!pages/_*.js',
|
||||||
'!pages/_*.tsx',
|
'!pages/_*.tsx',
|
||||||
'!pages/api',
|
'!pages/api',
|
||||||
])
|
]);
|
||||||
|
|
||||||
const sitemap = `
|
const sitemap = `
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
@ -24,13 +24,13 @@ const siteMetadata = require('../data/siteMetadata')
|
|||||||
.map((page) => {
|
.map((page) => {
|
||||||
// Exclude drafts from the sitemap
|
// Exclude drafts from the sitemap
|
||||||
if (page.search('.md') >= 1 && fs.existsSync(page)) {
|
if (page.search('.md') >= 1 && fs.existsSync(page)) {
|
||||||
const source = fs.readFileSync(page, 'utf8')
|
const source = fs.readFileSync(page, 'utf8');
|
||||||
const fm = matter(source)
|
const fm = matter(source);
|
||||||
if (fm.data.draft) {
|
if (fm.data.draft) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
if (fm.data.canonicalUrl) {
|
if (fm.data.canonicalUrl) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const path = page
|
const path = page
|
||||||
@ -41,26 +41,29 @@ const siteMetadata = require('../data/siteMetadata')
|
|||||||
.replace('.tsx', '')
|
.replace('.tsx', '')
|
||||||
.replace('.mdx', '')
|
.replace('.mdx', '')
|
||||||
.replace('.md', '')
|
.replace('.md', '')
|
||||||
.replace('/feed.xml', '')
|
.replace('/feed.xml', '');
|
||||||
const route = path === '/index' ? '' : path
|
const route = path === '/index' ? '' : path;
|
||||||
if (page.search('pages/404.') > -1 || page.search(`pages/blog/[...slug].`) > -1) {
|
if (
|
||||||
return
|
page.search('pages/404.') > -1 ||
|
||||||
|
page.search(`pages/blog/[...slug].`) > -1
|
||||||
|
) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
return `
|
return `
|
||||||
<url>
|
<url>
|
||||||
<loc>${siteMetadata.siteUrl}${route}</loc>
|
<loc>${siteMetadata.siteUrl}${route}</loc>
|
||||||
</url>
|
</url>
|
||||||
`
|
`;
|
||||||
})
|
})
|
||||||
.join('')}
|
.join('')}
|
||||||
</urlset>
|
</urlset>
|
||||||
`
|
`;
|
||||||
|
|
||||||
const formatted = prettier.format(sitemap, {
|
const formatted = prettier.format(sitemap, {
|
||||||
...prettierConfig,
|
...prettierConfig,
|
||||||
parser: 'html',
|
parser: 'html',
|
||||||
})
|
});
|
||||||
|
|
||||||
// eslint-disable-next-line no-sync
|
// eslint-disable-next-line no-sync
|
||||||
fs.writeFileSync('public/sitemap.xml', formatted)
|
fs.writeFileSync('public/sitemap.xml', formatted);
|
||||||
})()
|
})();
|
||||||
|
@ -5,116 +5,130 @@
|
|||||||
// The app listens to the event and triggers a client-side router refresh
|
// The app listens to the event and triggers a client-side router refresh
|
||||||
// see components/ClientReload.js
|
// see components/ClientReload.js
|
||||||
|
|
||||||
const chalk = require('chalk')
|
const chalk = require('chalk');
|
||||||
const chokidar = require('chokidar')
|
const chokidar = require('chokidar');
|
||||||
const program = require('commander')
|
const program = require('commander');
|
||||||
const http = require('http')
|
const http = require('http');
|
||||||
const SocketIO = require('socket.io')
|
const SocketIO = require('socket.io');
|
||||||
const express = require('express')
|
const express = require('express');
|
||||||
const spawn = require('child_process').spawn
|
const spawn = require('child_process').spawn;
|
||||||
const next = require('next')
|
const next = require('next');
|
||||||
const path = require('path')
|
const path = require('path');
|
||||||
const { parse } = require('url')
|
const { parse } = require('url');
|
||||||
|
|
||||||
const pkg = require('../package.json')
|
const pkg = require('../package.json');
|
||||||
|
|
||||||
const defaultWatchEvent = 'change'
|
const defaultWatchEvent = 'change';
|
||||||
|
|
||||||
program.storeOptionsAsProperties().version(pkg.version)
|
program.storeOptionsAsProperties().version(pkg.version);
|
||||||
program
|
program
|
||||||
.option('-r, --root [dir]', 'root directory of your nextjs app')
|
.option('-r, --root [dir]', 'root directory of your nextjs app')
|
||||||
.option('-s, --script [path]', 'path to the script you want to trigger on a watcher event', false)
|
.option(
|
||||||
|
'-s, --script [path]',
|
||||||
|
'path to the script you want to trigger on a watcher event',
|
||||||
|
false
|
||||||
|
)
|
||||||
.option('-c, --command [cmd]', 'command to execute on a watcher event', false)
|
.option('-c, --command [cmd]', 'command to execute on a watcher event', false)
|
||||||
.option(
|
.option(
|
||||||
'-e, --event [name]',
|
'-e, --event [name]',
|
||||||
`name of event to watch, defaults to ${defaultWatchEvent}`,
|
`name of event to watch, defaults to ${defaultWatchEvent}`,
|
||||||
defaultWatchEvent
|
defaultWatchEvent
|
||||||
)
|
)
|
||||||
.option('-p, --polling [name]', `use polling for the watcher, defaults to false`, false)
|
.option(
|
||||||
.parse(process.argv)
|
'-p, --polling [name]',
|
||||||
|
`use polling for the watcher, defaults to false`,
|
||||||
|
false
|
||||||
|
)
|
||||||
|
.parse(process.argv);
|
||||||
|
|
||||||
const shell = process.env.SHELL
|
const shell = process.env.SHELL;
|
||||||
const app = next({ dev: true, dir: program.root || process.cwd() })
|
const app = next({ dev: true, dir: program.root || process.cwd() });
|
||||||
const port = parseInt(process.env.PORT, 10) || 3000
|
const port = parseInt(process.env.PORT, 10) || 3000;
|
||||||
const handle = app.getRequestHandler()
|
const handle = app.getRequestHandler();
|
||||||
|
|
||||||
app.prepare().then(() => {
|
app.prepare().then(() => {
|
||||||
// if directories are provided, watch them for changes and trigger reload
|
// if directories are provided, watch them for changes and trigger reload
|
||||||
if (program.args.length > 0) {
|
if (program.args.length > 0) {
|
||||||
chokidar
|
chokidar
|
||||||
.watch(program.args, { usePolling: Boolean(program.polling) })
|
.watch(program.args, { usePolling: Boolean(program.polling) })
|
||||||
.on(program.event, async (filePathContext, eventContext = defaultWatchEvent) => {
|
.on(
|
||||||
// Emit changes via socketio
|
program.event,
|
||||||
io.sockets.emit('reload', filePathContext)
|
async (filePathContext, eventContext = defaultWatchEvent) => {
|
||||||
app.server.hotReloader.send('building')
|
// Emit changes via socketio
|
||||||
|
io.sockets.emit('reload', filePathContext);
|
||||||
|
app.server.hotReloader.send('building');
|
||||||
|
|
||||||
if (program.command) {
|
if (program.command) {
|
||||||
// Use spawn here so that we can pipe stdio from the command without buffering
|
// Use spawn here so that we can pipe stdio from the command without buffering
|
||||||
spawn(
|
spawn(
|
||||||
shell,
|
shell,
|
||||||
[
|
[
|
||||||
'-c',
|
'-c',
|
||||||
program.command
|
program.command
|
||||||
.replace(/\{event\}/gi, filePathContext)
|
.replace(/\{event\}/gi, filePathContext)
|
||||||
.replace(/\{path\}/gi, eventContext),
|
.replace(/\{path\}/gi, eventContext),
|
||||||
],
|
],
|
||||||
{
|
{
|
||||||
stdio: 'inherit',
|
stdio: 'inherit',
|
||||||
}
|
}
|
||||||
)
|
);
|
||||||
}
|
|
||||||
|
|
||||||
if (program.script) {
|
|
||||||
try {
|
|
||||||
// find the path of your --script script
|
|
||||||
const scriptPath = path.join(process.cwd(), program.script.toString())
|
|
||||||
|
|
||||||
// require your --script script
|
|
||||||
const executeFile = require(scriptPath)
|
|
||||||
|
|
||||||
// run the exported function from your --script script
|
|
||||||
executeFile(filePathContext, eventContext)
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Remote script failed')
|
|
||||||
console.error(e)
|
|
||||||
return e
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
app.server.hotReloader.send('reloadPage')
|
if (program.script) {
|
||||||
})
|
try {
|
||||||
|
// find the path of your --script script
|
||||||
|
const scriptPath = path.join(
|
||||||
|
process.cwd(),
|
||||||
|
program.script.toString()
|
||||||
|
);
|
||||||
|
|
||||||
|
// require your --script script
|
||||||
|
const executeFile = require(scriptPath);
|
||||||
|
|
||||||
|
// run the exported function from your --script script
|
||||||
|
executeFile(filePathContext, eventContext);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Remote script failed');
|
||||||
|
console.error(e);
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
app.server.hotReloader.send('reloadPage');
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// create an express server
|
// create an express server
|
||||||
const expressApp = express()
|
const expressApp = express();
|
||||||
const server = http.createServer(expressApp)
|
const server = http.createServer(expressApp);
|
||||||
|
|
||||||
// watch files with socketIO
|
// watch files with socketIO
|
||||||
const io = SocketIO(server)
|
const io = SocketIO(server);
|
||||||
|
|
||||||
// special handling for mdx reload route
|
// special handling for mdx reload route
|
||||||
const reloadRoute = express.Router()
|
const reloadRoute = express.Router();
|
||||||
reloadRoute.use(express.json())
|
reloadRoute.use(express.json());
|
||||||
reloadRoute.all('/', (req, res) => {
|
reloadRoute.all('/', (req, res) => {
|
||||||
// log message if present
|
// log message if present
|
||||||
const msg = req.body.message
|
const msg = req.body.message;
|
||||||
const color = req.body.color
|
const color = req.body.color;
|
||||||
msg && console.log(color ? chalk[color](msg) : msg)
|
msg && console.log(color ? chalk[color](msg) : msg);
|
||||||
|
|
||||||
// reload the nextjs app
|
// reload the nextjs app
|
||||||
app.server.hotReloader.send('building')
|
app.server.hotReloader.send('building');
|
||||||
app.server.hotReloader.send('reloadPage')
|
app.server.hotReloader.send('reloadPage');
|
||||||
res.end('Reload initiated')
|
res.end('Reload initiated');
|
||||||
})
|
});
|
||||||
|
|
||||||
expressApp.use('/__next_reload', reloadRoute)
|
expressApp.use('/__next_reload', reloadRoute);
|
||||||
|
|
||||||
// handle all other routes with next.js
|
// handle all other routes with next.js
|
||||||
expressApp.all('*', (req, res) => handle(req, res, parse(req.url, true)))
|
expressApp.all('*', (req, res) => handle(req, res, parse(req.url, true)));
|
||||||
|
|
||||||
// fire it up
|
// fire it up
|
||||||
server.listen(port, (err) => {
|
server.listen(port, (err) => {
|
||||||
if (err) throw err
|
if (err) throw err;
|
||||||
console.log(`> Ready on http://localhost:${port}`)
|
console.log(`> Ready on http://localhost:${port}`);
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
Reference in New Issue
Block a user