Open13

nextjs + mantine + mdx で自作ブログを作る

nyatintenyatinte

DevContainerを作成

Node.js

18
作成

割と時間がかかるけど我慢
10分くらいかかった

// For format details, see https://aka.ms/devcontainer.json. For config options, see the README at:
// https://github.com/microsoft/vscode-dev-containers/tree/v0.245.2/containers/typescript-node
{
+	"name": "nichiBlog",
+	"workspaceFolder": "/workspace",
	"build": {
		"dockerfile": "Dockerfile",
		// Update 'VARIANT' to pick a Node version: 18, 16, 14.
		// Append -bullseye or -buster to pin to an OS version.
		// Use -bullseye variants on local on arm64/Apple Silicon.
		"args": { 
			"VARIANT": "18"
		}
	},

	// Configure tool-specific properties.
	"customizations": {
		// Configure properties specific to VS Code.
		"vscode": {
			// Add the IDs of extensions you want installed when the container is created.
			"extensions": [
				"dbaeumer.vscode-eslint",
+				"esbenp.prettier-vscode",
+				"GitHub.copilot"
			]
		}
	},

	// Use 'forwardPorts' to make a list of ports inside the container available locally.
	// "forwardPorts": [],

	// Use 'postCreateCommand' to run commands after the container is created.
	// "postCreateCommand": "yarn install",

	// Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
	"remoteUser": "node"
}

ちょっといじってまたrebuild

とりあえずOK
githubに上げておく
vscodeのコマンドで

> publish to github

みたいなのがあるのでこれを使えばめちゃくちゃ早く公開できる

nyatintenyatinte

nextjsを導入する

yarn create next-app --typescript

✔ What is your project named? … frontend

色々インストールされるので待つ

https://zenn.dev/andynuma/scraps/a159295f8eda11
こちらの記事を参考に色々追加する

eslint + prettier

yarn add --save-dev eslint prettier eslint-config-prettier eslint-plugin-unused-imports eslint-plugin-simple-import-sort eslint-plugin-testing-library
/.vscode/setting.json
{
  "editor.defaultFormatter": "esbenp.prettier-vscode",
  "editor.formatOnSave": true,
  "editor.codeActionsOnSave": {
    "source.fixAll.eslint": true
  }
}
.prettierrc
{
  "semi": true,
  "singleQuote": true,
  "tabWidth": 2,
  "useTabs": false
}
eslintrc.json
{
  "extends": [
    "next/core-web-vitals",
    "plugin:testing-library/react",
    "prettier"
  ],
  "plugins": ["unused-imports", "simple-import-sort"],
  "rules": {
    "unused-imports/no-unused-imports": "error",
    "simple-import-sort/imports": "error"
  }
}

baseUrIも変更する

https://qiita.com/1v1ura/items/fc8b7d663e301e9228ba

そもそもbaseUrIとは何?という人は↑の記事が参考になります

tsconfig.json
{
  "compilerOptions": {
    "target": "es5",
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "preserve",
    "incremental": true,
+    "baseUrl": "./src"
  },
  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
  "exclude": ["node_modules"]
}

ここで一区切り

nyatintenyatinte

Mantineの導入

https://mantine.dev/
UIライブラリとしてMantineを試してみます

ChakraUIも候補に上がりましたが、MantineUIのHooksやコンポーネントの豊富さからスピーディーな開発が可能だと考えたのが採用理由です

公式のnext用のgetting startedを参考に進めていきます
https://mantine.dev/guides/next/

プラグインは特に追加せず、必要になったら追加していきます

ここで注意なのがfrontendフォルダ上で行うこと

frontend
yarn add @mantine/core @mantine/hooks @mantine/next @emotion/server @emotion/react

セットアップのために/pages配下を一旦まっさらにしてしまいましょう。ついでに/stylesも消してしまいます。

frontend/src/pages/_document.tsx
import { createGetInitialProps } from '@mantine/next';
import Document, { Head, Html, Main, NextScript } from 'next/document';

const getInitialProps = createGetInitialProps();

export default class _Document extends Document {
  static getInitialProps = getInitialProps;

  render() {
    return (
      <Html>
        <Head />
        <body>
          <Main />
          <NextScript />
        </body>
      </Html>
    );
  }
}
frontend/src/pages/_app.tsx
import { AppProps } from 'next/app';
import Head from 'next/head';
import { MantineProvider } from '@mantine/core';

export default function App(props: AppProps) {
  const { Component, pageProps } = props;

  return (
    <>
      <Head>
        <title>Page title</title>
        <meta
          name="viewport"
          content="minimum-scale=1, initial-scale=1, width=device-width"
        />
      </Head>

      <MantineProvider
        withGlobalStyles
        withNormalizeCSS
        theme={{
          /** Put your mantine theme override here */
          colorScheme: 'light',
        }}
      >
        <Component {...pageProps} />
      </MantineProvider>
    </>
  );
}

_app.tsx_document.tsxについては以下の記事が参考になるかと思います
https://tyotto-good.com/blog/next-document-app

あとは適当にページを作ってyarn devします

src/pages/index.tsx
import { Container, Paper, Title } from '@mantine/core';
import { NextPage } from 'next';

const Page: NextPage = () => {
  return (
    <Container p="md" sx={{ textAlign: 'center' }}>
      <Paper shadow="xs" p="md">
        <Title>Hello Next.js + Mantine</Title>
      </Paper>
    </Container>
  );
};

export default Page;

こんな感じになれば成功です。

これでMantineの導入が完了しました

nyatintenyatinte

eslintが効かない

せっかくunused-importsとかのプラグインを入れたのに効かない

と思っていたら思いっきりディレクトリをミスっていました

修正としてはworkspace.eslintrc.json.prettierrcfrontendに移行します

├── frontend
│   ├── next.config.js
│   ├── next-env.d.ts
│   ├── package.json
│   ├── README.md
│   ├── src
│   │   └── pages
│   │       ├── _app.tsx
│   │       ├── _document.tsx
│   │       └── index.tsx
│   ├── tsconfig.json
│   └── yarn.lock
└── package.json

こんな感じ

package.json
{}
frontend/package.json
{
  "name": "frontend",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint"
  },
  "dependencies": {
    "@emotion/react": "^11.10.5",
    "@emotion/server": "^11.10.0",
    "@mantine/core": "^5.6.3",
    "@mantine/hooks": "^5.6.3",
    "@mantine/next": "^5.6.3",
    "next": "13.0.0",
    "react": "18.2.0",
    "react-dom": "18.2.0"
  },
  "devDependencies": {
    "@types/node": "18.11.7",
    "@types/react": "18.0.24",
    "@types/react-dom": "18.0.8",
    "eslint": "^8.26.0",
    "eslint-config-next": "^13.0.0",
    "typescript": "4.8.4",
+    "eslint-config-prettier": "^8.5.0",
+    "eslint-plugin-simple-import-sort": "^8.0.0",
+    "eslint-plugin-testing-library": "^5.9.1",
+    "eslint-plugin-unused-imports": "^2.0.0",
+    "prettier": "^2.7.1"
  }
}

これでOKです
ちゃんとlintが機能しますね

nyatintenyatinte

ブログの外観を作る

Mantineを触りつつ、外観を先に作ってしまいます

MantineUIから良さげなやつを引っ張ってきて使ってしまいましょう

https://ui.mantine.dev/category/article-cards#articles-cards-grid

https://ui.mantine.dev/component/header-simple

今回はmdxというものを使ってブログを書いていきます。
イメージとしてはマークダウンの中でcomponentが使えるといった感じです

せっかく自分でブログを作るならちょっと面白いcomponentなんかを使って個性を出したい!ということでこれを採用します

他にもreact-markdownmarkdown-itなどでmarkdown形式のブログを書くことができるのでそこはお好みで。

https://nextjs.org/docs/advanced-features/using-mdx
上記の記事に従って色々設定していきます

frontend
yarn add  @next/mdx @mdx-js/loader @mdx-js/react
frontend/next.config.js
- /** @type {import('next').NextConfig} */
- const nextConfig = {
-   reactStrictMode: true,
-   swcMinify: true,
- }
- 
- module.exports = nextConfig
+ const withMDX = require('@next/mdx')({
+   extension: /\.mdx?$/,
+   options: {
+     // If you use remark-gfm, you'll need to use next.config.mjs
+     // as the package is ESM only
+     // https://github.com/remarkjs/remark-gfm#install
+     remarkPlugins: [],
+     rehypePlugins: [],
+     // If you use `MDXProvider`, uncomment the following line.
+     // providerImportSource: "@mdx-js/react",
+   },
+ });
+ module.exports = withMDX({
+   // Append the default value with md extensions
+   pageExtensions: ['ts', 'tsx', 'js', 'jsx', 'md', 'mdx'],
+ });

また、追加でmdxファイルに関する拡張機能をインストールします
mdx
インストール後、歯車マークを押してadd to .devcontainer.jsonをクリックすることで.devcontainerに自動で書き込みしてくれます

ここまで設定したら適当に1つページを作ってみましょう

frontend/[blog]/sample.mdx
import { Button } from '@mantine/core';

# My MDX page

This is a list in markdown:

- One
- Two
- Three

Checkout my React component:
<Button>ボタンも追加できるよ</Button>

そしたら http://localhost:3000/blog/sample にアクセスします

うまくいけばこんな感じでページが表示できると思います

nyatintenyatinte

mdxをもう少し触ってみる

Frontmatterの利用

https://nextjs.org/docs/advanced-features/using-mdx#frontmatter

frontmatter は YAML のようなキーと値の組み合わせで、ページに関するデータを保存するために使用できます。gray-matterのようにMDXコンテンツにfrontmatterを追加するソリューションはたくさんありますが、@next/mdxはデフォルトではfrontmatterをサポートしていません。
@next/mdxでページのメタデータにアクセスするには、.mdxファイル内からmetaオブジェクトをエクスポートすることができます。

とのことです。実際に触るとイメージがつくかと思います。

pages/[blog]/sample.mdx
import { Button } from '@mantine/core';
import { ArticleLayout } from 'components/layouts/ArticleLayout';

export const meta = {
  title: 'サンプル',
  description: 'サンプルページです',
  publishedAt: '2022-10-31',
};

# My MDX Page with a Layout

This is a list in markdown:

- One
- Two
- Three

Checkout my React component:

<Button>ボタンもクリックできるよ</Button>

export default ({ children }) => (
  <ArticleLayout meta={meta}>{children}</ArticleLayout>
);

frontend/src/components/layouts/ArticleLayout.tsx
import { Container, Text, Title } from '@mantine/core';
import { ReactNode } from 'react';

type meta = {
  title: string;
  description: string;
  publishedAt: string;
  image?: string;
};
type ArticleLayoutProps = {
  children: ReactNode;
  meta: meta;
};
export const ArticleLayout = ({ children, meta }: ArticleLayoutProps) => {
  return (
    <Container>
      <Title>{meta.title}</Title>
      <Text size="xs">{meta.description}</Text>
      <Text size="xs">{meta.publishedAt}</Text>
      {children}
    </Container>
  );
};

変更点はmetaというオブジェクトに記事の概要を詰め込んでexportしていることです。これにより他のcomponentでimportして情報を使用することができます

Custom Element

https://nextjs.org/docs/advanced-features/using-mdx#custom-elements

対応するタグを任意のcomponentにすることができます

nyatintenyatinte

metaデータを受け取る

記事一覧ページを作るためにmetaデータを配列で受け取りたいです。
イメージとしてはこんな感じ


│   └── pages
│       ├── _app.tsx
│       ├── blog
│       │   ├── index.tsx
│       │   └── sample1.mdx
│       │   └── sample2.mdx
│       │   └── sample3.mdx
│       ├── _document.tsx

[
  {id:1, title:'sample1', date:'2021-01-01'},
  {id:2, title:'sample2', date:'2021-01-02'},
  {id:3, title:'sample3', date:'2021-01-03'},
]

ここでめちゃくちゃ詰まりました

import {meta} from '*.mdx'

みたいにはできないし…

色々調べていたらこんなdiscussionsを発見
https://github.com/mdx-js/mdx/discussions/1351

これを参考にちょっと書いてみる

blog/index.tsx
import { Container, SimpleGrid } from '@mantine/core';
import { ArticleCard } from 'components/ArticleCard';
import { globby } from 'globby';
import { useMemo, useState } from 'react';

export type article = {
  id: string;
  title: string;
  image?: string;
  published: boolean;
  date: string;
};

const Page = ({ mdxFilesMeta }: { mdxFilesMeta: article[] }) => {
  // 昇順・降順の切り替え
  const [order, setOrder] = useState<'asc' | 'desc'>('desc');

  const sortedMdxFilesMeta = useMemo(
    () =>
      mdxFilesMeta.sort((a, b) => {
        if (order === 'asc') {
          return new Date(a.date).getTime() - new Date(b.date).getTime();
        }

        return new Date(b.date).getTime() - new Date(a.date).getTime();
      }),
    [mdxFilesMeta, order]
  );

  const cards = sortedMdxFilesMeta.map((article) => {
    // 公開している記事のみ表示
    if (article.published) {
      return <ArticleCard key={article.id} article={article} />;
    }
  });

  return (
    <Container py="xl">
      <SimpleGrid cols={2} breakpoints={[{ maxWidth: 'sm', cols: 1 }]}>
        {cards}
      </SimpleGrid>
    </Container>
  );
};

export default Page;

// .mdxファイルを読み込み、記事の情報を取得する
export async function getStaticProps() {
  const mdxFiles = await globby('src/pages/blog/*.mdx');
  const mdxFilesName = mdxFiles.map((file) => file.split('/').pop());
  const mdxFilesMeta = mdxFilesName.map(async (file) => {
    // jsonにundifinedが入っているとエラーになるので、nullを入れる
    try {
      const { meta } = await import(`/${file}`);
      if (meta) {
        return meta as article;
      }
      return null;
    } catch (e) {
      return null;
    }
  });

  return {
    // propsとして渡す
    props: {
      mdxFilesMeta: (await Promise.all(mdxFilesMeta)).filter(
        (article) => article !== null
      ) as article[],
    },
  };
}

blog/sample1.mdx
import { Button } from '@mantine/core';
import { ArticleLayout } from 'components/layouts/ArticleLayout';

export const meta = {
  id: 'sample1',
  title: 'サンプル',
  date: '2021-01-01',
  published: false,
};

# My MDX Page with a Layout

This is a list in markdown:

- One
- Two
- Three

Checkout my React component:

<Button>ボタンもクリックできるよ</Button>

export default ({ children }) => (
  <ArticleLayout meta={meta}>{children}</ArticleLayout>
);
blog/sample2.mdx
import { Button } from '@mantine/core';
import { ArticleLayout } from 'components/layouts/ArticleLayout';

export const meta = {
  title: 'サンプル',
  date: '2021-01-01',
  id: 'sample',
  published: true,
};

# My MDX Page with a Layout

This is a list in markdown:

- One
- Two
- Three

Checkout my React component:

<Button>ボタンもクリックできるよ</Button>

export default ({ children }) => (
  <ArticleLayout meta={meta}>{children}</ArticleLayout>
);
blog/sample3.mdx

↑何もexportされていないとどうなるか

src/components/ArticleCard.tsx
import { AspectRatio, Card, createStyles, Image, Text } from '@mantine/core';
import NoImage from 'images/noImage.png';
import Link from 'next/link';
import type { article } from 'pages/blog';

const useStyles = createStyles((theme) => ({
  card: {
    transition: 'transform 150ms ease, box-shadow 150ms ease',

    '&:hover': {
      transform: 'scale(1.01)',
      boxShadow: theme.shadows.md,
    },
  },

  title: {
    fontFamily: `Greycliff CF, ${theme.fontFamily}`,
    fontWeight: 600,
  },
}));

type ArticleCardProps = {
  article: article;
};
export const ArticleCard = ({ article }: ArticleCardProps) => {
  const { classes } = useStyles();

  return (
    <Link href={`/blog/${article.id}`} style={{ textDecoration: 'none' }}>
      <Card p="md" radius="md" className={classes.card}>
        <AspectRatio ratio={1920 / 1080}>
          <Image src={article.image || NoImage.src} alt="article.image" />
        </AspectRatio>
        <Text
          color="dimmed"
          size="xs"
          transform="uppercase"
          weight={700}
          mt="md"
        >
          {article.date}
        </Text>
        <Text className={classes.title} mt={5}>
          {article.title}
        </Text>
      </Card>
    </Link>
  );
};

ブラウザで見てみる

ちゃんとpublishtrueのものだけが表示されています
また、ArticleCardで編集したことにより、クリックでページ遷移ができるようになりました

nyatintenyatinte

コマンドで楽々テンプレート作成

記事を表示する土台ができましたが、毎回export const meta...みたいに書いていくのは面倒です。
なのでコマンドで一発で生成できるようにします

追加で必要なモジュールをいくつかインストールします

yarn add ts-node date-fns date-fns-tz

実行エンジンとしてts-nodeを、日時を扱うためにdate-fns, date-fns-tzをインストールしました

続いて実行ファイルを作ります

import { format } from 'date-fns';
import { utcToZonedTime } from 'date-fns-tz';
import fs from 'fs';

const utcDate = new Date();
const jstDate = utcToZonedTime(utcDate, 'Asia/Tokyo');
const jstString = format(jstDate, 'yyyy-MM-dd HH:mm:ss');

const createTemplate = (id: string, title: string) => {
  return `import { ArticleLayout } from 'components/layouts/ArticleLayout';

export const meta = {
  id: '${id}',
  title: '${title}',
  date: '${jstString}',
  // image: '',
  published: true,
};

# ${title}

export default ({ children }) => (
  <ArticleLayout meta={meta}>{children}</ArticleLayout>
);

`;
};

const template = createTemplate(process.argv[2], process.argv[3]);

// 上書き保存を防ぐために、ファイルが存在しない場合のみ保存する
if (fs.existsSync(`./src/pages/blog/${process.argv[2]}.mdx`)) {
  console.error('既にファイルが存在しています');
  process.exit(1);
}
fs.writeFileSync(`./src/pages/blog/${process.argv[2]}.mdx`, template);

設定も少し編集します

tsconfig.json
{
  "compilerOptions": {
    "target": "es5",
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "noEmit": true,
    "esModuleInterop": true,
+    "module": "CommonJS",
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "preserve",
    "incremental": true,
    "baseUrl": "./src"
  },
  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "**/*.mdx"],
  "exclude": ["node_modules"]
}
package.json
...
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint",
+    "ca": "ts-node ./src/bin/createArticle.ts"
...
  },

cacreate articleの略です。お好きに命名してください

ここまでできたらテストしてみます

yarn ca test 作成テスト
$ ts-node ./src/bin/createArticle.ts test 作成テスト
id: test
title: 作成テスト
Done in 4.83s.
pages/blog/test.mdx


export const meta = {
  id: 'test',
  title: '2022-10-31 21:38:50,
  // image: '',
  published: false,
};

# 作成テスト

export default ({ children }) => (
  <ArticleLayout meta={meta}>{children}</ArticleLayout>
);

このようにファイルが作成されていれば成功です
あとは記事を書いてpublishedtrueにすれば公開完了です
これで簡単に記事を書き始められますね!

nyatintenyatinte

画像を扱えるようにする

記事のトップ画像を変更できるようにします。

pages/blog/index.tsx
export type article = {
  id: string;
  title: string;
  date: string;
-  image?: string;
+  image?: StaticImageData | string;
  published: boolean;
};
components/ArticleCard.tsx
export const ArticleCard = ({ article }: ArticleCardProps) => {
  const { classes } = useStyles();

+  const src =
+    typeof article.image === 'string' ? article.image : article.image?.src;
  console.log(article);

  return (
    <Link href={`/blog/${article.id}`} style={{ textDecoration: 'none' }}>
      <Card p="md" radius="md" className={classes.card}>
        <AspectRatio ratio={1920 / 1080}>
+          <Image src={src || NoImage.src} alt="article.image" />

mdxファイルの方では

import { ArticleLayout } from 'components/layouts/ArticleLayout';
import leaf from 'images/leaf.png';

export const meta = {
  id: 'test2',
  title: '作成テスト2',
  date: '2022-10-31 21:48:12',
  image: leaf,
  published: true,
};

# 作成テスト 2


export default ({ children }) => (
  <ArticleLayout meta={meta}>{children}</ArticleLayout>
);

このようにすればOKです。
ローカルの画像の場合はimportしてStaticImageDataとして、ネット上の画像の場合はURLをstringで渡せばトップ画像を追加できます

nyatintenyatinte

ソート機能の追加

前の記事でしれっとソートロジックを組んでおいたのでボタンを追加していい感じに表示したいと思います。

Mantineでアイコンボタンを表現するために
tablericonというのを使います
https://tabler-icons-react.vercel.app/

yarn add tabler-icons-react

https://mantine.dev/core/action-icon/
ドキュメントを参考にボタンを作ります

pages/blog/index.tsx
......
const Page = ({ mdxFilesMeta }: { mdxFilesMeta: article[] }) => {
  // 昇順・降順の切り替え
  const [order, setOrder] = useState<'asc' | 'desc'>('desc');
  const handleClickToggleOrder = () => {
    setOrder((prev) => (prev === 'asc' ? 'desc' : 'asc'));
  };

  const sortedMdxFilesMeta = useMemo(
    () =>
      mdxFilesMeta.sort((a, b) => {
        if (order === 'asc') {
          return new Date(a.date).getTime() - new Date(b.date).getTime();
        }

        return new Date(b.date).getTime() - new Date(a.date).getTime();
      }),
    [mdxFilesMeta, order]
  );

  const cards = sortedMdxFilesMeta.map((article) => {
    if (article.published) {
      return <ArticleCard key={article.id} article={article} />;
    }
  });

  return (
    <Container py="xl">
      <ActionIcon onClick={handleClickToggleOrder} size="lg" color="green">
        <ArrowsSort size={30} />
      </ActionIcon>
      <SimpleGrid cols={2} breakpoints={[{ maxWidth: 'sm', cols: 1 }]}>
        {cards}
      </SimpleGrid>
    </Container>
  );
};
......

簡単にソートが実装できました

nyatintenyatinte

ページネーションを実装する

記事の数が増えてくると、記事一覧ページが縦に長くなって見づらくなってしまいます。
それを防ぐためにページネーション機能を実装します。

まずはサンプルとなるデータをいくつか作成してしまいましょう

src/bin/createSampleArticle.tsx
import { format } from 'date-fns';
import { utcToZonedTime } from 'date-fns-tz';
import fs from 'fs';

const createArticle = (id: string, title: string) => {
  const utcDate = new Date();
  const jstDate = utcToZonedTime(utcDate, 'Asia/Tokyo');
  const jstString = format(jstDate, 'yyyy-MM-dd HH:mm:ss');

  const createTemplate = (id: string, title: string) => {
    return `import { ArticleLayout } from 'components/layouts/ArticleLayout';

export const meta = {
  id: '${id}',
  title: '${title}',
  date: '${jstString}',
  // image: '',
  published: true,
};

# ${title}

export default ({ children }) => (
  <ArticleLayout meta={meta}>{children}</ArticleLayout>
);

`;
  };

  const template = createTemplate(id, title);

  // 上書き保存を防ぐために、ファイルが存在しない場合のみ保存する
  if (fs.existsSync(`./src/pages/blog/${id}.mdx`)) {
    console.error('既にファイルが存在しています');
    return false;
  }
  fs.writeFileSync(`./src/pages/blog/${id}.mdx`, template);
};

const sleep = (msec: number) =>
  new Promise((resolve) => setTimeout(resolve, msec));

const createSampleArticles = async () => {
  for (let i = 0; i < 20; i++) {
    const id = `sample-${i}`;
    const title = `サンプル記事 ${i}`;
    createArticle(id, title);
    await sleep(1000);
  }
};

createSampleArticles();

package.json
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint",
    "ca": "ts-node ./src/bin/createArticle.ts",
+    "csa": "ts-node ./src/bin/createSampleArticle.ts"
  },

20件記事が並んでいます。
これを今回は6件に分割してみます。
Mantineでページネーションについてのコンポーネントが提供されているので使います
https://mantine.dev/core/pagination/

もしくは
https://mantine.dev/hooks/use-pagination/
を使えばカスタムコンポーネントを作成できます

pages/blog/index.tsx
+import { ActionIcon, Container, Pagination, SimpleGrid } from '@mantine/core';

...

const Page = ({ mdxFilesMeta }: { mdxFilesMeta: article[] }) => {
  // 昇順・降順の切り替え
  const [order, setOrder] = useState<'asc' | 'desc'>('desc');
  // ページネーション
+  const [page, setPage] = useState(1);
+  const fileLength = mdxFilesMeta.length;
+  const perPage = 6;
+  const totalPage = Math.ceil(fileLength / perPage);
+  const start = (page - 1) * perPage;
+  const end = start + perPage;

  const sortedMdxFilesMeta = useMemo(
    () =>
      mdxFilesMeta.sort((a, b) => {
        if (order === 'asc') {
          return new Date(a.date).getTime() - new Date(b.date).getTime();
        }

        return new Date(b.date).getTime() - new Date(a.date).getTime();
      }),
    [mdxFilesMeta, order]
  );

  const handleClickToggleOrder = () => {
    setOrder((prev) => (prev === 'asc' ? 'desc' : 'asc'));
  };
+  const cards = sortedMdxFilesMeta.slice(start, end).map((article) => {
    if (article.published) {
      return <ArticleCard key={article.id} article={article} />;
    }
  });

  return (
    <Container py="xl">
      <ActionIcon onClick={handleClickToggleOrder} size="lg" color="green">
        <ArrowsSort size={30} />
      </ActionIcon>
      <SimpleGrid cols={2} breakpoints={[{ maxWidth: 'sm', cols: 1 }]}>
        {cards}
      </SimpleGrid>
+      <Pagination total={totalPage} page={page} onChange={setPage} />
    </Container>
  );
};

export default Page;

...

愚直な手法ではありますが、ページネーションが実装できました
もしもっといい方法があれば教えてほしいです

nyatintenyatinte

mdxProviderを利用してマークダウンをリッチな見た目に

前回までは記事一覧ページの部分を中心に変更していきました。
今回からはついにmdxを最大限活用した記事を書いていきます

初めに今回の完成物をお見せしたいと思います
記事

記事のコード
import { ArticleLayout } from 'components/layouts/ArticleLayout';

export const meta = {
  id: 'e3ae4b02c52430bae7aecd1b',
  title: 'shottrでスクショ効率化',
  date: '2022-11-02 19:56:41',
  image: 'https://shottr.cc/assets/logo.png',
  published: true,
};

みなさんはスクショを撮るときにどうしていますか?
多くの人が使っているのは、デフォルトのスクショ機能だと思います。僕もそうしていました。

今回紹介するのは、スクショを撮るときに痒いところに手が届くようになる[Shottr](https://shottr.cc/)というサービスです。

https://shottr.cc/

::warning
2022/11/03 現在、MacOS のみの対応となっています
::

## インストール

[公式のサイト](https://shottr.cc/)上部にある「Download」ボタンからダウンロードできます。

## 何ができるの?

色々な機能が用意されていますが、その中でも特に便利だと感じたものを 3 つ抜粋して紹介します。

### 1. ピン留めしてどこでも参照する

スクショをしたあとの編集画面で、ピン留めボタンを押すと、ピン留めすることができます。
ウィンドウを跨いで表示することができるのでメモにも使えます!
デザインしたいコンポーネントの画像を VSCode にピン留めして、デザインをするとめちゃくちゃ捗ります!

### 2. 簡単にサイズを取得

rular を選択し、サイズを知りたい部分にカーソルを合わせることで簡単にサイズを取得できます。
PX と PT の 2 つの単位に対応しており、地味に便利です。
チームメンバーに「ここもう少し小さくして!」と要望を出すのにも使えそうです。

### 3. ぼかし・消しゴム機能でプライバシーを守る

スクショを共有しようと思ったら見せたくないものまで含まれていた…ということはありませんか?
そんな時にぼかし・消しゴム機能を使えば、わざわざ画像編集ソフトを起動する必要がなくなります。

export default ({ children }) => (
  <ArticleLayout meta={meta}>{children}</ArticleLayout>
);

今回は最近使っているスクショの便利ツールのshotrrについてのサンプル記事を書いてみました。

内容はさておき、注目してほしいのは、記事自体はほとんどマークダウンのように書けている点です

マークダウン部分抜粋
...
今回紹介するのは、スクショを撮るときに痒いところに手が届くようになる[Shottr](https://shottr.cc/)というサービスです。

https://shottr.cc/

::warning
2022/11/03 現在、MacOS のみの対応となっています
::

## インストール

[公式のサイト](https://shottr.cc/)上部にある「Download」ボタンからダウンロードできます。

## 何ができるの?

色々な機能が用意されていますが、その中でも特に便利だと感じたものを 3 つ抜粋して紹介します。

### 1. ピン留めしてどこでも参照する

スクショをしたあとの編集画面で、ピン留めボタンを押すと、ピン留めすることができます。
ウィンドウを跨いで表示することができるのでメモにも使えます!
デザインしたいコンポーネントの画像を VSCode にピン留めして、デザインをするとめちゃくちゃ捗ります!

...

シンプルなマークダウン記法なのにかかわらず、リンクカードや見出しがいい感じに表示されていますね。
また::warningというような、カスタムのマークダウンもいい感じに動いています。

この仕組みを実装しているのが[MDXProvider](https://mdxjs.com/docs/using-mdx/#mdx-provider)です

components/MDXCustomProvider
import { MDXProvider } from '@mdx-js/react';
import { MDXComponents } from 'mdx/types';
import { ReactNode } from 'react';

import { CodeBlock } from './uiParts/code/CodeBlock';
import { SpaceDivider } from './uiParts/divider/spaceDivider';
import { HeadingBox } from './uiParts/heading/HeadingBox';
import { TooltipLink } from './uiParts/Link/TooltipLink';
import { MDXCustomText } from './uiParts/text/MDXCustomText';

const components = {
  code: CodeBlock,
  a: TooltipLink,
  hr: SpaceDivider,
  h2: HeadingBox,
  p: MDXCustomText,
} as MDXComponents;

const MDXCustomProvider = ({ children }: { children: ReactNode }) => {
  return <MDXProvider components={components}>{children}</MDXProvider>;
};

export default MDXCustomProvider;

↑で定義したMDXCustomProvider

...
export const ArticleLayout = ({ children, meta }: ArticleLayoutProps) => {
  const { classes } = useStyles();
  const { title, date } = meta;
  return (
    <Box className={classes.root}>
      <Container className={classes.container}>
        <Box className={classes.info}>
          <Title className={classes.title}>{title}</Title>
          <CircleDate date={new Date(date)} />
        </Box>
        <Divider mb={'md'} />
+        <MDXCustomProvider>{children}</MDXCustomProvider>
      </Container>
    </Box>
  );
};

のように使用することで、MDXをwrapします

MDXProviderの仕組み

マークダウンがHTMLに変換されるとき、マークダウンの記法に対応したタグとして出力されます

例えば

md
# hello world!

html
<h1>hello world!</h1>

となります

他にも色々な対応があります。詳しくは以下の記事が参考になります
https://www.sirochro.com/note/html-markdown-list/

この#h1に変換するのを自分で定義できるのがMDXProviderになります

components/MDXCustomProvider
...
const components = {
  code: CodeBlock,
  a: TooltipLink,
  hr: SpaceDivider,
  h2: HeadingBox,
  p: MDXCustomText,
}

const MDXCustomProvider = ({ children }: { children: ReactNode }) => {
  return <MDXProvider components={components}>{children}</MDXProvider>;
};
...

componentsにその対応をオブジェクト形式で渡していきます。上記の例でいくと、マークダウンが変換されて、<a>タグになるものは、自分で定義したTooltipLinkというコンポーネントとして出力されます。

コンポーネント自体はこのようになっています

components/uiParts/Link/TooltipLink.tsx
import { Tooltip } from '@mantine/core';
import { NextLink } from '@mantine/next';
import { ComponentProps } from 'react';

type TooltipLinkProps = {
  children?: React.ReactNode;
} & ComponentProps<'a'>;

export const TooltipLink = ({ children, href }: TooltipLinkProps) => {
  if (!href) {
    return <>{children}</>;
  }
  return (
    <Tooltip label={href} color="blue" withArrow>
      <NextLink
        legacyBehavior
        href={href}
        style={{
          display: 'inline-block',
          paddingRight: '0.25rem',
          paddingLeft: '0.25rem',
          fontWeight: 'bold',
          color: '#0066c0',
        }}
      >
        {children ?? ''}
      </NextLink>
    </Tooltip>
  );
};

propsとして<a>タグの持つpropsを受け取ることができます。
その中からhrefchildrenを取り出し、MantineのUIを生かした独自のコンポーネントにすることができました。

nyatintenyatinte

カスタムマークダウンの定義

前回は簡単な例を紹介しました。

では、枠線で囲まれた部分はどのように実装しているのでしょうか?

HTMLには<linkcard><warningcard>のようなタグはありません…

結論から言うと、テキストの内容に応じて条件分岐することで実装しています。
枠線部分のコードを見てみましょう

md
https://shottr.cc/

::warning
2022/11/03 現在、MacOS のみの対応となっています
::

これはhtmlで出力されると、細かい部分は省きますが

html
<p>https://shottr.cc/</p>
<p>::warning 2022/11/03 現在、MacOS のみの対応となっています ::</p>

のように<p>タグとして出力されます。

components/MDXCustomProvider.tsx
const components = {
  code: CodeBlock,
  a: TooltipLink,
  hr: SpaceDivider,
  h2: HeadingBox,
  p: MDXCustomText,
}

のように<p>タグ をMDXCustomTextというコンポーネントとして出力できるようにします

components/uiParts/text/MDXCustomText.tsx
import { Box } from '@mantine/core';
import { ComponentProps } from 'react';

import { AlertCard } from '../card/AlertCard';
import { LinkCard } from '../card/LinkCard';

type MDXCustomTextProps = {
  children?: React.ReactNode;
} & Omit<ComponentProps<'p'>, 'children'>;

export const MDXCustomText = ({ children }: MDXCustomTextProps) => {
  if (!children) {
    return <br />;
  }
  if (typeof children !== 'string') {
    return <>{children}</>;
  }
  const url = children.match(/https?:\/\/[\w/:%#\$&\?\(\)~\.=\+\-]+/g);
  if (url) {
    return (
      <Box style={{ padding: '2rem' }}>
        <LinkCard url={url[0]} />
      </Box>
    );
  }

  if (children.startsWith('::warning')) {
    return (
      <AlertCard>
        {children.replace('::warning', '').replace('::', '')}
      </AlertCard>
    );
  }
  return <>{children}</>;
};

もし、テキストがURLならLinkCardを、::warningで始まるならAlertCardというコンポーネントを返すようにしました。
このようにすることでカスタムマークダウンだったり、リンクカードを表現できるようになります。

以下にMDXProviderで使用したコンポーネントを載せておきます
特にLinkCardCodeBlockは独自の処理があり、面倒な部分もあるので是非参考にしてください。

CodeBlock

mantineから追加で[prism](https://mantine.dev/others/prism/)をインストールします。

yarn add @mantine/prism
import { Prism } from '@mantine/prism';
import type { Language } from 'prism-react-renderer';
import type { ComponentProps } from 'react';

type CodeBlockProps = {
  children?: string;
} & ComponentProps<'code'>;
export const CodeBlock = ({ children, className }: CodeBlockProps) => {
  const language = (className?.replace(/language-/, '') ?? 'bash') as Language;
  return (
    <>
      <Prism
        colorScheme="dark"
        withLineNumbers
        language={language}
        copyLabel="クリップボードにコピー"
        copiedLabel="コピーしました!"
      >
        {children ?? ''}
      </Prism>
    </>
  );
};

ポイントとしては

md
```tsx
const = ...
のようにマークダウンをかくと
```html:html
<code classname="languege-tsx"></code>

のように出力されるので
const language = (className?.replace(/language-/, '') ?? 'bash') as Language;のように言語名を受け取ることでシンタックスハイライトを追加できるようにしました。

LinkCard

react-link-previewというものを利用します

yarn add @dhaiwat10/react-link-preview
components/uiParts/card/LinkCard.tsx
import { LinkPreview } from '@dhaiwat10/react-link-preview';
import NoImage from 'images/noImage.png';

import { TooltipLink } from '../Link/TooltipLink';

type MetaData = {
  title: string | null;
  description: string | null;
  image: string | null;
  siteName: string | null;
  hostname: string | null;
};
const fetcher = async (url: string) => {
  const LINK_PRECIEW_SERVER_API =
    'https://js-linkpreview.herokuapp.com/api/link-preview/?url=';
  const CONFIG = {
    headers: {
      Accept: 'application/json',
    },
  };
  const res = await fetch(LINK_PRECIEW_SERVER_API + url, CONFIG);
  const data = await res.json();
  console.log(data);
  const {
    title,
    description,
    domain: siteName,
    img: image,
    requestUrl: hostname,
  } = data.data;
  return { title, description, image, siteName, hostname } as MetaData;
};

type LinkCardProps = {
  url: string;
};
export const LinkCard = ({ url }: LinkCardProps) => {
  return (
    <LinkPreview
      url={url}
      fetcher={fetcher}
      width="100%"
      imageHeight="200px"
      fallbackImageSrc={NoImage.src}
      fallback={<TooltipLink href={url}>{url}</TooltipLink>}
    />
  );
};

ポイントとしてはデータフェッチです。詳細は省きますが、nextjsでは直リンクではうまくfetchできないことがあります。
その対策として、LINK_PRECIEW_SERVER_APIで定義したURLにリクエストを送ることでいい感じに動いてくれます。
https://github.com/Nick-007/link-preview
今回は使用していないですが、↑のようなものも使えると思います。
ここらへんの話は自分でもあまり理解していないので、「もっとこうした方がいいよ」があればぜひ教えていただきたいです。

HeadingBox
components/uiParts/heading/HeadingBox.tsx
import { Title, TitleProps } from '@mantine/core';
import { ReactNode } from 'react';

type HeadingBoxProps = {
  children: ReactNode;
} & Omit<TitleProps, 'children'>;

export const HeadingBox = ({ children, ...props }: HeadingBoxProps) => {
  return (
    <Title
      order={2}
      style={{
        display: 'box',
        color: '#494949',
        backgroundColor: '#f4f4f4',
        borderLeft: 'solid 5px #7db4e6',
        borderBottom: 'solid 3px #d7d7d7',
        marginTop: '2rem',
        marginBottom: '1rem',
        padding: '0.5rem 1rem',
      }}
      {...props}
    >
      {children}
    </Title>
  );
};
SpaceDivider
components/uiParts/divider/spaceDivider.tsx
import { Divider, DividerProps } from '@mantine/core';

type SpaceDividerProps = DividerProps;
export const SpaceDivider = (props: SpaceDividerProps) => {
  return (
    <Divider
      style={{
        padding: '1rem 0',
      }}
      {...props}
    />
  );
};
Tooltiplink

1つ前の記事
をご覧ください!