Newt + Next.js + TypeScript + Mantine + Tailwindcss + Eslint + Prettierでブログを作成してみた
概要
HeadlessCMS(Newt)とNext.jsを使ってブログを作成しました。
ブログ → https://blog-app-rouge-nine.vercel.app/
github → https://github.com/shun1121/blog-app
機能
実装したもの
・記事一覧ページ
・記事詳細ページ
・目次のハイライト
・シンタックスハイライト
・ダークモード
・ページネーション
今後実装したいもの
・Google Analytics
・検索機能
主な使用技術
・Next.js(SSG): 12.1.5
・React: 18.0.0
・TypeScript: 4.6.3
・Tailwind CSS: ^3.0.24
・@mantine/core、hooks、next: ^4.2.6
・cheerio: ^1.0.0-rc.11
・highlight.js: ^11.5.1
・tocbot: ^4.18.2
開発
プロジェクトの作成
環境構築
こちらを参考にTypeScriptとTailwind Cssを導入。
また、こちらを参考にEslintとPrettierを導入しました。
Nextのプロジェクトを作成します。
npx create-next-app プロジェクト名
TypeScriptの導入
次にTypeScriptを導入していきます。
yarn add -D typescript @types/react @types/react-dom @types/node
tsconfig.jsonを作成し、以下設定を記述します。
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"baseUrl": ".",
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve"
},
"include": ["src", "next-env.d.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}
Tailwind CSSを導入
Tailwind CSSを入れるのに必要なライブラリをインストール。
yarn add tailwindcss postcss autoprefixer
次に
npx tailwindcss init -p
tailwind.config.jsの中身
module.exports = {
content: ['./src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {},
},
plugins: [],
}
Eslintの設定
create-next-app
を行なった段階でeslintrc.jsonは作成されます。設定事項を記述していきます。
{
"root": true,
"env": {
"browser": true,
"es6": true,
"node": true
},
"settings": {
"react": {
"version": "detect"
}
},
"parser": "@typescript-eslint/parser",
"parserOptions": {
"sourceType": "module",
"ecmaVersion": 2020,
"ecmaFeatures": {
"jsx": true
},
"project": "./tsconfig.eslint.json"
},
"plugins": ["react", "@typescript-eslint"],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended",
"plugin:react/recommended",
],
"rules": {
"@typescript-eslint/explicit-function-return-type": 0,
"@typescript-eslint/no-explicit-any": 0,
"@typescript-eslint/no-empty-function": 0,
"no-empty-function": 0,
"@typescript-eslint/ban-ts-comment": 0,
"react/jsx-uses-react": "off",
"react/react-in-jsx-scope": "off",
"react/prop-types": "off"
}
}
tsconfig.eslint.jsonの設定
TypeScriptでEslintを設定するために必要なtsconfig.eslint.jsonを作成し、設定事項を記述します。
{
"extends": "./tsconfig.json",
"includes": [
"src/**/*.ts",
"src/**/*.tsx",
".eslintrc.json",
],
"exclude": [
"node_modules",
"dist"
]
}
*Eslintの導入の際にでたエラー。
1、 「React' must be in scope when using JSX」
→ .eslintrc.jsonで以下の記述をすることで解決。
"rules": {
"react/jsx-uses-react": "off",
"react/react-in-jsx-scope": "off"
}
2、 「’○○’ is missing in props validation」
→ .eslintrc.jsonのrulesに次の記述を加える。
"react/prop-types": "off"
Prettierの導入
次のパッケージをインストール
yarn add -D prettier eslint-config-prettier
EslintとPrettierを併用して利用するため、.eslintrc.jsonを変更。
PrettierはEslintとの競合を上書きして無効にするため、extendsの最後に記述。
{
...
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended",
"plugin:react/recommended",
"prettier"
],
...
}
.prettierrcの設定。
{
"trailingComma": "all",
"tabWidth": 2,
"semi": false,
"singleQuote": true,
"jsxSingleQuote": true,
"printWidth": 100
}
package.jsonのscriptを修正する。
{
...
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint --dir src",
"fix:lint": "next lint --fix",
"fix:prettier": "prettier --write .",
"format": "prettier --write --ignore-path .gitignore './**/*.{js,jsx,ts,tsx,json}'"
},
...
}
ダークモードの設定
Mantineを使ってダークモードの実装を行います。
まずは、mantine/coreをインストールします。
yarn add @mantine/hooks @mantine/core @mantine/next
src/pages/_app.tsxに下記コードを記述します。
...
import { MantineProvider, ColorSchemeProvider, ColorScheme } from '@mantine/core';
function MyApp({ Component, pageProps }: AppProps) {
return <Component {...pageProps} />
const [colorScheme, setColorScheme] = useState<ColorScheme>('dark');
const toggleColorScheme = (value?: ColorScheme) =>
setColorScheme(value || (colorScheme === 'dark' ? 'light' : 'dark'));
return (
<ColorSchemeProvider colorScheme={colorScheme} toggleColorScheme={toggleColorScheme}>
<MantineProvider theme={{ colorScheme }} withGlobalStyles withNormalizeCSS>
<Component {...pageProps} />
</MantineProvider>
</ColorSchemeProvider>
)
}
...
まず、colorSchemeの初期値を設定します。
colorSchemeステートはダークモードとライトモードを切り替えるのに、toggleColorSchemeの中で使用されます。
const [colorScheme, setColorScheme] = useState<ColorScheme>('dark')
const toggleColorScheme = (value?: ColorScheme) =>
setColorScheme(value || (colorScheme === 'dark' ? 'light' : 'dark'))
ここでvalueがtrueじゃなければ、論理和の右側の処理がcolorSchemeに代入されます。
src/pages/_app.tsxをラップしている、カラースキームを管理するColorSchemeProviderにcolorSchemeとtoggleColorSchemeを渡し、useMantineColorSchemeフックを利用して自分の好みの場所にトグルをつけていきます。
src/components/toggleTheme.tsx
import { ActionIcon, useMantineColorScheme } from '@mantine/core'
import { Sun, MoonStars } from 'tabler-icons-react'
export const Toggle = () => {
const { colorScheme, toggleColorScheme } = useMantineColorScheme()
const dark = colorScheme === 'dark'
return (
<ActionIcon
variant='outline'
color={dark ? 'orange' : 'blue'}
onClick={() => toggleColorScheme()}
title='Toggle color scheme'
>
{dark ? <Sun size={20} /> : <MoonStars size={20} />}
</ActionIcon>
)
}
onClickでトグルが走り、_app.tsxのcolorSchemeの値が切り替わります。
Newtの導入
まず、以下のパッケージをインストールします。
yarn add newt-client-js
次に、スペースの認証を行います。今回はapiを使用するのでapiTypeは'api'と記述します。
src/libs/client.ts
import { createClient } from 'newt-client-js'
export const client = createClient({
spaceUid: 'スペースの名前',
token: process.env.API_KEY || '',
apiType: 'api',
})
Newtで投稿した記事を取得するためにsrc/pages/index.tsxに以下を記述します。
import { createStyles } from '@mantine/core'
import { Content, Contents } from 'newt-client-js'
import type { GetStaticProps, NextPage } from 'next'
import Link from 'next/link'
import { BlogList } from '../components/blogList'
import { Footer } from '../components/footer'
import { HeaderResponsive } from '../components/header'
import { client } from '../libs/client'
import { links } from '../mock/headerLink'
import styles from '../styles/Home.module.css'
...
const Home: NextPage<Contents<Item>> = (props) => {
const { classes } = useStyles()
return (
<div className={classes.width}>
<div className={styles.container}>
<HeaderResponsive links={links} />
<div className={classes.wrapper}>
<BlogList blogs={props.items} />
<div className={classes.buttonWrapper}>
<div className={classes.button}>
<Link href='/blog/page/1'>
<a className='flex w-full h-full justify-center items-center font-bold'>
記事一覧へ
</a>
</Link>
</div>
</div>
</div>
<Footer />
</div>
</div>
)
}
export const getStaticProps: GetStaticProps<Contents<Item>> = async () => {
const data = await client.getContents<Item>({
appUid: 'appUid',
modelUid: 'modelUid',
query: {
limit: 4,
},
})
return {
props: data,
}
}
export default Home
src/pages/blog/[id].tsxに詳細ページを表示します。
import { createStyles } from '@mantine/core'
import * as cheerio from 'cheerio'
import dayjs from 'dayjs'
import hljs from 'highlight.js'
import { GetStaticPaths, GetStaticProps, NextPage } from 'next'
import { useEffect } from 'react'
import tocbot from 'tocbot'
import { Item } from '..'
import { Footer } from '../../components/footer'
import { HeaderResponsive } from '../../components/header'
import { Profile } from '../../components/profile'
import { client } from '../../libs/client'
import { links } from '../../mock/headerLink'
import 'highlight.js/styles/hybrid.css';
...
const Blog: NextPage<Data> = (props) => {
const { classes } = useStyles()
useEffect(() => {
tocbot.init({
tocSelector: '.toc',
contentSelector: 'body',
headingSelector: 'h2, h3',
})
return () => tocbot.destroy()
})
return (
<div className={classes.container}>
<HeaderResponsive links={links} />
<div className={classes.background}>
<div className='flex space-x-6 justify-center pt-8'>
<section className={classes.sectionWrapper}>
<div className={classes.section}>
<h1 className='font-bold'>{props.data.title}</h1>
<p className='text-[14px] mt-2 mb-6'>
{dayjs(props.data._sys.updatedAt).format('YYYY年MM月DD日')}
</p>
<div
dangerouslySetInnerHTML={{
__html: props.highlightedBody,
}}
/>
</div>
<div>
<Profile
avatar='https://storage.googleapis.com/newt-images/accounts/62613f9a7a9cb90018e3e90e/1655363247483/20210112_125421.jpeg'
name=''
title=''
/>
</div>
</section>
<aside className='hidden sm:hidden md:hidden lg:block xl:block'>
<div className='sticky top-12'>
<div className={classes.side}>
<p className='text-lg pb-3 font-bold'>目次</p>
<nav className='toc' />
</div>
</div>
</aside>
</div>
</div>
<Footer />
</div>
)
}
export const getStaticPaths: GetStaticPaths<{ id: string }> = async () => {
const data = await client.getContents<Item>({
appUid: ' appUid',
modelUid: 'modelUid',
})
const ids = data.items.map((item) => `/appUid/${item._id}`)
return {
paths: ids,
fallback: false,
}
}
export const getStaticProps: GetStaticProps<{}, { id: string }> = async (context) => {
if (!context.params) {
return { notFound: true }
}
const data = await client.getContent<Item>({
appUid: 'appUid',
modelUid: 'modelUid',
contentId: context.params.id,
})
const $ = cheerio.load(data.body, { decodeEntities: false })
$('h2, h3').each((index, elm) => {
$(elm).html()
$(elm).addClass('headings')
$(elm).attr('id', `${index}`)
})
$('pre code').each((_, elm) => {
const result = hljs.highlightAuto($(elm).text());
$(elm).html(result.value);
$(elm).addClass('hljs');
});
return {
props: {
data: data,
highlightedBody:$.html()
},
}
}
export default Blog
記事の中身を取得したいので、属性にdangerouslySetInnerHTMLを使います。
<div dangerouslySetInnerHTML={{ __html: props.highlightedBody }}/>
目次ハイライトの実装
詳細ページに目次のハイライトを実装していきます。
今回はtocbotを使用します。
yarn add tocbot
次に、src/pages/blog/[id].tsxにtocbotで目次を表示していきます。
import { createStyles } from '@mantine/core'
import * as cheerio from 'cheerio'
import dayjs from 'dayjs'
import hljs from 'highlight.js'
import { GetStaticPaths, GetStaticProps, NextPage } from 'next'
import { useEffect } from 'react'
import tocbot from 'tocbot'
import { Item } from '..'
import { Footer } from '../../components/footer'
import { HeaderResponsive } from '../../components/header'
import { Profile } from '../../components/profile'
import { client } from '../../libs/client'
import { links } from '../../mock/headerLink'
import 'highlight.js/styles/hybrid.css';
...
const Blog: NextPage<Data> = (props) => {
const { classes } = useStyles()
useEffect(() => {
tocbot.init({
tocSelector: '.toc',
contentSelector: 'body',
headingSelector: 'h2, h3',
})
return () => tocbot.destroy()
})
return (
<div className={classes.container}>
<div className='flex space-x-6 justify-center pt-8'>
<section className={classes.sectionWrapper}>
...
</section>
<aside className='hidden sm:hidden md:hidden lg:block xl:block'>
<div className='sticky top-12'>
<div className={classes.side}>
<p className='text-lg pb-3 font-bold'>目次</p>
<nav className='toc' />
</div>
</div>
</aside>
</div>
</div>
)
}
export const getStaticPaths: GetStaticPaths<{ id: string }> = async () => {
const data = await client.getContents<Item>({
appUid: ' appUid',
modelUid: 'modelUid',
})
const ids = data.items.map((item) => `/appUid/${item._id}`)
return {
paths: ids,
fallback: false,
}
}
export const getStaticProps: GetStaticProps<{}, { id: string }> = async (context) => {
if (!context.params) {
return { notFound: true }
}
const data = await client.getContent<Item>({
appUid: 'appUid',
modelUid: 'modelUid',
contentId: context.params.id,
})
const $ = cheerio.load(data.body, { decodeEntities: false })
$('h2, h3').each((index, elm) => {
$(elm).html()
$(elm).addClass('headings')
$(elm).attr('id', `${index}`)
})
$('pre code').each((_, elm) => {
const result = hljs.highlightAuto($(elm).text());
$(elm).html(result.value);
$(elm).addClass('hljs');
});
return {
props: {
data: data,
highlightedBody:$.html()
},
}
}
export default Blog
useEffectの中に以下の設定を記述します。
useEffect(() => {
tocbot.init({ // tocbotの初期化
tocSelector: '.toc', // 目次が表示される場所のクラスを指定
contentSelector: 'body', // 目次を作成に必要なhタグをどこから取得するか指定
headingSelector: 'h2, h3', // 目次に使うhタグの指定
})
return () => tocbot.destroy() // tocbotの結果、イベントリスナーを削除
}, [])
現状、contentSelectorで指定しているbody内の要素にはclassやidなどの属性が付与されていないので、cheerioを使って設定していきます。
まず、cheerioをインストールします。
yarn add cheerio
次に、詳細ページのgetStaticPropsにhtmlをロードし、属性を付与する処理をしていきます。
まずはload関数でgetStaticProps内で取得しているdata.bodyを読み込みます。
*decodeEntities: falseでhtmlエンティティの解読の設定をオフにしています。
そして、ロードしたbodyの中にあるh2、h3にclass='headings、'id='${index}'を付与します。
import * as cheerio from 'cheerio'
...
export const getStaticProps: GetStaticProps<{}, { id: string }> = async (context) => {
if (!context.params) {
return { notFound: true }
}
const data = await client.getContent<Item>({
appUid: 'appUid',
modelUid: 'modelUid',
contentId: context.params.id,
})
// ↓ ここから
const $ = cheerio.load(data.body, { decodeEntities: false })
$('h2, h3').each((index, elm) => {
$(elm).html()
$(elm).addClass('headings')
$(elm).attr('id', `${index}`)
})
$('pre code').each((_, elm) => {
const result = hljs.highlightAuto($(elm).text());
$(elm).html(result.value);
$(elm).addClass('hljs');
});
return {
props: {
data: data,
highlightedBody:$.html()
},
}
}
export default Blog
シンタックスハイライトの実装
シンタックスハイライトにhighlight.jsを使用します。
yarn add highlight.js
詳細ページのgetStaticPropsでcheerioを使って属性を当て、hljs.highlighyAuto()
の引数に文字列を入れることで文字列がハイライトされます。今回は詳細ページでNewtで取得したリッチエディタ箇所(preタグとcodeタグ内)をハイライトします。 返り値をhighlightedBody:$.html()
とするとシンタックスハイライトが適用されたhtmlコードがpropsに渡されます。
export const getStaticProps: GetStaticProps<{}, { id: string }> = async (context) => {
...
const $ = cheerio.load(data.body, { decodeEntities: false })
...
$('pre code').each((_, elm) => {
const result = hljs.highlightAuto($(elm).text());
$(elm).html(result.value); // リッチエディタ内のタグ付きhtml文字列を挿入
$(elm).addClass('hljs'); // クラス名に'hljs'を追記
});
return {
props: {
data: data,
highlightedBody:$.html()
},
}
}
getStaticPropsで渡ってきたhighlightedBodyを表示します。
<div
dangerouslySetInnerHTML={{
__html: props.highlightedBody,
}}
/>
ページネーションの実装
とします。ページネーションコンポーネントを作成していきます。作成するファイルのパスはsrc/components/pagination.tsxとします。
type Pagenation = {
currentPageNum: number
maxPageNum: number
}
export const Pagination = ({ currentPageNum, maxPageNum }: Pagenation) => {
const { classes } = useStyles()
const prevPage = currentPageNum - 1
const nextPage = currentPageNum + 1
return (
<div className='mt-10'>
<div className='mx-auto flex items-center justify-between max-w-[930px]'>
<div className={currentPageNum !== 1 ? classes.button : classes.hideButton}>
<Link href={`/blog/page/${prevPage}`}>
<a className='flex w-full h-full justify-center items-center font-bold'>戻る</a>
</Link>
</div>
<div className={currentPageNum !== maxPageNum ? classes.button : classes.hideButton}>
<Link href={`/blog/page/${nextPage}`}>
<a className='flex w-full h-full justify-center items-center font-bold'>次へ</a>
</Link>
</div>
</div>
</div>
)
}
今回は「戻る」、「次へ」という形のページネーションにしていきます。
Paginationコンポーネントは、引数で受け取るcurrentPageNum(現在ページのurl最後のパラメータ)をもとに、前のページ(prevPage)と次のページ(nextPage)のパラメータを設定します。もう一つの引数のmaxPageNumは「次へ」を押して行った際の最後のページのパラメータを受け取ります。
このmaxPageNumが現在のページのパラメータと一致すれば「次へ」ボタンを表示しないという処理を行なっています。最後にスタイルを整えてPaginationコンポーネントは完成です。
次に、一覧ページにPaginationコンポーネントを表示していきます。パラメータの数値によって動的に遷移させたいので、src/pages/blog/page/[id].tsxに記述していきます。
...
type Props = {
items: Item[]
currentPageNumber: number
total: number
}
const PaginationId: NextPage<Props> = ({ items, currentPageNumber, total }) => {
const { classes } = useStyles()
return (
<div className={items.length <= 2 ? classes.width2 : classes.width}>
<div className={styles.container}>
<HeaderResponsive links={links} />
<div className={classes.wrapper}>
<BlogList blogs={items} />
<Pagination currentPageNum={currentPageNumber} maxPageNum={Math.ceil(total / 6)} />
</div>
{items.length <= 2 ? (
<div className={classes.footer}>
<Footer />
</div>
) : (
<Footer />
)}
</div>
</div>
)
}
export const getStaticPaths: GetStaticPaths = async () => {
const data = await client.getContents<Item>({
appUid: 'appUid',
modelUid: 'modelUid',
})
const range = (start: number, end: number) => [...Array(end - start + 1)].map((_, i) => start + i)
const { total } = data
const paths = range(1, Math.ceil(total / 4)).map((i) => `/blog/page/${i}`)
return {
paths,
fallback: false,
}
}
export const getStaticProps: GetStaticProps = async (ctx: GetStaticPropsContext) => {
if (!ctx.params) {
return { notFound: true }
}
const pageId = Number(ctx.params.id)
const data = await client.getContents<Item>({
appUid: 'appUid',
modelUid: 'modelUid',
})
const postsPerPage = data.items.slice(pageId * 6 - 6, pageId * 6)
return {
props: {
items: postsPerPage,
total: data.total,
currentPageNumber: pageId,
},
}
}
export default PaginationId
getStaticPathsから確認していきます。
const data = await client.getContents<Item>({
appUid: 'appUid',
modelUid: 'modelUid',
})
const range = (start: number, end: number) => [...Array(end - start + 1)].map((_, i) => start + i)
const { total } = data
const paths = range(1, Math.ceil(total / 4)).map((i) => `/blog/page/${i}`)
return {
paths,
fallback: false,
}
まず、取得した投稿記事一覧をdataに代入します。
次にページングの際にurlに使うパラメータを設定したいので、range関数を用います。
range関数には第一引数に1、第二引数に全投稿記事(total)を4で割った数以上の最小の整数を渡します。(全記事数が14なら3.5つまり4)
全記事数が14の場合、要素が4つの配列ができます([1,2,3,4])。それをmap関数を用い、/blog/page/${i}
という形にして配列をpathsに代入します。最後に、pathsとfallback: falseをリターンします。
生成されるルーティングパスの例は以下です。
console.log(paths)
→ [ '/blog/page/1', '/blog/page/2', '/blog/page/3', '/blog/page/4' ]
<参考>
https://qiita.com/NNNiNiNNN/items/3743ce6db31a421d88d0
https://qiita.com/suin/items/1b39ce57dd660f12f34b
次にgetStaticPropsを確認していきます。
export const getStaticProps: GetStaticProps = async (ctx: GetStaticPropsContext) => {
if (!ctx.params) {
return { notFound: true }
}
const pageId = Number(ctx.params.id)
console.log(pageId)
const data = await client.getContents<Item>({
appUid: 'appUid',
modelUid: 'modelUid',
})
const postsPerPage = data.items.slice(pageId * 6 - 6, pageId * 6)
return {
props: {
items: postsPerPage,
total: data.total,
currentPageNumber: pageId,
},
}
}
if節で現在ページのパラメータがなければ、return { notFound: true }
で404ページを返すようにします。
pageIdで取得した現在ページのパラメータを用いて、1つのページに表示する記事数を決め、表示する記事をpostsPerPageに代入します。
slice関数の引数は配列番号であり、第1引数番目の要素から第2引数番目の要素まで(第2引数番目は含まない)を表示します。
最後にpropsとしてitems、total、currentPageNumberをリターンしています。
PaginationIdコンポーネントでgetStaticPropsのitems、currentPageNumber、totalを引数として受け取ります。
const PaginationId: NextPage<Props> = ({ items, currentPageNumber, total }) => {
...
return (
<div className={items.length <= 2 ? classes.width2 : classes.width}>
...
<Pagination currentPageNum={currentPageNumber} maxPageNum={Math.ceil(total / 6)} />
...
</div>
)
}
PaginationコンポーネントでcurrentPageNumとmaxPageNumを受け取ることで適切にページネーションが動きます。
参考
環境設定
https://zenn.dev/hungry_goat/articles/b7ea123eeaaa44
https://zenn.dev/kurao/articles/456f44a6f43d89
tailwindcssの導入
https://zenn.dev/motonosuke/articles/56e21e06ce641c
https://tailwindcss.com/docs/installation
https://zenn.dev/nbr41to/articles/276f40041ad9fe
https://zenn.dev/taichifukumoto/articles/setup-next-12-typescript-tailwind-tamplate#eslint-plugin-tailwind-%E3%81%AE%E5%B0%8E%E5%85%A5
newtの使い方
https://github.com/Newt-Inc/newt-client-js
https://www.newt.so/docs/content
https://developers.newt.so/apis/api#section/Common-Resource-Attributes
https://app.newt.so/space-shunsuke/app/blog
https://www.youtube.com/watch?v=FeiAd9E2128&t=0s
https://www.sunapro.com/eslint-with-prettier/
token型エラー回避
https://qiita.com/hinako_n/items/e53b02c241b8e35d42cb
cheerioの使い方
https://github.com/cheeriojs/cheerio
https://kawa.dev/posts/nextjs-toc-ssg
https://blog.microcms.io/contents-parse-way/
目次設定https://gizanbeak.com/post/tocbot#%E5%85%A8%E4%BD%93%E3%81%AE%E3%82%B3%E3%83%BC%E3%83%89
https://tscanlin.github.io/tocbot/#api
https://mae.chab.in/archives/59690
ページネーション
https://github.com/harokki/blog-with-micro-cms/blob/332beaa56d25afea9631230d6782c57118321a8b/src/pages/blog/page/%5Bid%5D.tsx
https://zenn.dev/rokki188/articles/948d53199508c7
https://blog.hpfull.jp/nextjs-microcms-pagination/
https://blog.microcms.io/next-pagination/
https://www.ravness.com/posts/blogpagination
https://www.ipentec.com/document/css-visibility-property
シンタックスハイライト
https://qiita.com/cawauchi/items/ff6489b17800c5676908
https://highlightjs.org/
TypeScript Eslint エラー
https://zenn.dev/ryuu/scraps/583dad79532879
https://cpoint-lab.co.jp/article/202107/20531/
https://wonwon-eater.com/ts-eslint-import-error/