nextjs + mantine + mdx で自作ブログを作る
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
みたいなのがあるのでこれを使えばめちゃくちゃ早く公開できる
nextjsを導入する
yarn create next-app --typescript
✔ What is your project named? … frontend
色々インストールされるので待つ
こちらの記事を参考に色々追加する
eslint + prettier
yarn add --save-dev eslint prettier eslint-config-prettier eslint-plugin-unused-imports eslint-plugin-simple-import-sort eslint-plugin-testing-library
{
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
}
}
{
"semi": true,
"singleQuote": true,
"tabWidth": 2,
"useTabs": false
}
{
"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も変更する
そもそもbaseUrIとは何?という人は↑の記事が参考になります
{
"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"]
}
ここで一区切り
Mantineの導入
UIライブラリとしてMantineを試してみます
ChakraUIも候補に上がりましたが、MantineUIのHooksやコンポーネントの豊富さからスピーディーな開発が可能だと考えたのが採用理由です
公式のnext用のgetting startedを参考に進めていきます
プラグインは特に追加せず、必要になったら追加していきます
ここで注意なのがfrontend
フォルダ上で行うこと
yarn add @mantine/core @mantine/hooks @mantine/next @emotion/server @emotion/react
セットアップのために/pages
配下を一旦まっさらにしてしまいましょう。ついでに/styles
も消してしまいます。
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>
);
}
}
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
については以下の記事が参考になるかと思います
あとは適当にページを作ってyarn dev
します
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の導入が完了しました
eslintが効かない
せっかくunused-imports
とかのプラグインを入れたのに効かない
と思っていたら思いっきりディレクトリをミスっていました
修正としてはworkspace
の.eslintrc.json
と.prettierrc
をfrontend
に移行します
├── 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
こんな感じ
{}
{
"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が機能しますね
ブログの外観を作る
Mantineを触りつつ、外観を先に作ってしまいます
MantineUIから良さげなやつを引っ張ってきて使ってしまいましょう
今回はmdxというものを使ってブログを書いていきます。
イメージとしてはマークダウンの中でcomponentが使えるといった感じです
せっかく自分でブログを作るならちょっと面白いcomponentなんかを使って個性を出したい!ということでこれを採用します
他にもreact-markdown
やmarkdown-it
などでmarkdown形式のブログを書くことができるのでそこはお好みで。
上記の記事に従って色々設定していきます
yarn add @next/mdx @mdx-js/loader @mdx-js/react
- /** @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ファイルに関する拡張機能をインストールします
インストール後、歯車マークを押してadd to .devcontainer.json
をクリックすることで.devcontainer
に自動で書き込みしてくれます
ここまで設定したら適当に1つページを作ってみましょう
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 にアクセスします
うまくいけばこんな感じでページが表示できると思います
mdxをもう少し触ってみる
Frontmatterの利用
frontmatter は YAML のようなキーと値の組み合わせで、ページに関するデータを保存するために使用できます。gray-matterのようにMDXコンテンツにfrontmatterを追加するソリューションはたくさんありますが、@next/mdxはデフォルトではfrontmatterをサポートしていません。
@next/mdxでページのメタデータにアクセスするには、.mdxファイル内からmetaオブジェクトをエクスポートすることができます。
とのことです。実際に触るとイメージがつくかと思います。
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>
);
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
対応するタグを任意のcomponentにすることができます
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を発見
これを参考にちょっと書いてみる
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[],
},
};
}
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>
);
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>
);
↑何もexportされていないとどうなるか
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>
);
};
ブラウザで見てみる
ちゃんとpublish
がtrue
のものだけが表示されています
また、ArticleCard
で編集したことにより、クリックでページ遷移ができるようになりました
コマンドで楽々テンプレート作成
記事を表示する土台ができましたが、毎回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);
設定も少し編集します
{
"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"]
}
...
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
+ "ca": "ts-node ./src/bin/createArticle.ts"
...
},
ca
はcreate article
の略です。お好きに命名してください
ここまでできたらテストしてみます
yarn ca test 作成テスト
$ ts-node ./src/bin/createArticle.ts test 作成テスト
id: test
title: 作成テスト
Done in 4.83s.
export const meta = {
id: 'test',
title: '2022-10-31 21:38:50,
// image: '',
published: false,
};
# 作成テスト
export default ({ children }) => (
<ArticleLayout meta={meta}>{children}</ArticleLayout>
);
このようにファイルが作成されていれば成功です
あとは記事を書いてpublished
をtrue
にすれば公開完了です
これで簡単に記事を書き始められますね!
画像を扱えるようにする
記事のトップ画像を変更できるようにします。
export type article = {
id: string;
title: string;
date: string;
- image?: string;
+ image?: StaticImageData | string;
published: boolean;
};
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
で渡せばトップ画像を追加できます
ソート機能の追加
前の記事でしれっとソートロジックを組んでおいたのでボタンを追加していい感じに表示したいと思います。
Mantineでアイコンボタンを表現するために
tablericonというのを使います
yarn add tabler-icons-react
ドキュメントを参考にボタンを作ります
......
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>
);
};
......
簡単にソートが実装できました
ページネーションを実装する
記事の数が増えてくると、記事一覧ページが縦に長くなって見づらくなってしまいます。
それを防ぐためにページネーション機能を実装します。
まずはサンプルとなるデータをいくつか作成してしまいましょう
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();
"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でページネーションについてのコンポーネントが提供されているので使います
もしくは
を使えばカスタムコンポーネントを作成できます+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;
...
愚直な手法ではありますが、ページネーションが実装できました
もしもっといい方法があれば教えてほしいです
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)
です
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に変換されるとき、マークダウンの記法に対応したタグとして出力されます
例えば
# hello world!
は
<h1>hello world!</h1>
となります
他にも色々な対応があります。詳しくは以下の記事が参考になります
この#
をh1
に変換するのを自分で定義できるのがMDXProvider
になります
...
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
というコンポーネントとして出力されます。
コンポーネント自体はこのようになっています
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を受け取ることができます。
その中からhref
とchildren
を取り出し、MantineのUIを生かした独自のコンポーネントにすることができました。
カスタムマークダウンの定義
前回は簡単な例を紹介しました。
では、枠線で囲まれた部分はどのように実装しているのでしょうか?
HTMLには<linkcard>
や<warningcard>
のようなタグはありません…
結論から言うと、テキストの内容に応じて条件分岐することで実装しています。
枠線部分のコードを見てみましょう
https://shottr.cc/
::warning
2022/11/03 現在、MacOS のみの対応となっています
::
これはhtmlで出力されると、細かい部分は省きますが
<p>https://shottr.cc/</p>
<p>::warning 2022/11/03 現在、MacOS のみの対応となっています ::</p>
のように<p>
タグとして出力されます。
const components = {
code: CodeBlock,
a: TooltipLink,
hr: SpaceDivider,
h2: HeadingBox,
p: MDXCustomText,
}
のように<p>
タグ をMDXCustomText
というコンポーネントとして出力できるようにします
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
で使用したコンポーネントを載せておきます
特にLinkCard
やCodeBlock
は独自の処理があり、面倒な部分もあるので是非参考にしてください。
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>
</>
);
};
ポイントとしては
```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
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にリクエストを送ることでいい感じに動いてくれます。
今回は使用していないですが、↑のようなものも使えると思います。
ここらへんの話は自分でもあまり理解していないので、「もっとこうした方がいいよ」があればぜひ教えていただきたいです。
HeadingBox
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
import { Divider, DividerProps } from '@mantine/core';
type SpaceDividerProps = DividerProps;
export const SpaceDivider = (props: SpaceDividerProps) => {
return (
<Divider
style={{
padding: '1rem 0',
}}
{...props}
/>
);
};
Tooltiplink
1つ前の記事
をご覧ください!