1
/
5

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/

株式会社ツナググループ・ホールディングス's job postings
3 Likes
3 Likes

Weekly ranking

Show other rankings