Next.jsmicroCMS

Next.js, microCMS, Chakra UIで作るブログ ③マークダウンの記事詳細ページ

公開日:2022-07-18 更新日:2023-06-12

Next.js, microCMS, Chakra-UIで作るブログシリーズの3つ目の記事です。

前回は、記事一覧をトップページに表示させました。

今回はリンク先の記事詳細ページの作成を行っていきます。

Next.jsのルーティングについて

記事詳細のリンクですが、下記のように設定していました。

<Link href={`/post/${post.id}`}>

順番が前後したような気がしますが、このようなURL構成にしたい場合、pages配下にpostディレクトリを作り、その中に[slug].tsxというファイル作ります。

もしくはpages→[slug]ディレクトリ→index.tsxと続けます。

いずれにせよ、こうすることでリクエストを投げる際に必要なパラメータをparams.slugとして取り出せるようになります。

ルーティングの詳細について

https://nextjs-ja-translation-docs.vercel.app/docs/routing/introduction

記事詳細ファイルの作成

上のルーティングルールに倣って、postディレクトリ→[slug].tsxを作成します。

/* src/pages/post/[slug].tsx */
import { client } from "libs/client";
import type { Post } from "types/blog";
import type { GetStaticPaths, GetStaticProps, } from "next";
import {
    Box,
    Container,
    Divider,
    Heading,
    Stack,
} from "@chakra-ui/react";
import React from "react";
import { Header } from "components/Header";
import { DateTime } from "components/DateTime";

type Props = {
    post: Post
}

export default function Article({ post }: Props) {
    return (
        <Box>
            <Header />
            <Container as="main" maxW="container.md" marginTop="4" marginBottom="16">
                <Stack spacing="8">
                    <Heading as="h1" fontSize="4xl" lineHeight={1.6}>
                        {post.title}
                    </Heading>
                    <DateTime datetime={post.publishedAt} />
                </Stack>
                <Divider marginY="8" />
                {/* 記事本文 */}
                <div>{post.text}</div>
            </Container >
        </Box >
    )
}

/* 記事詳細の静的ファイルの作成 */
export const getStaticPaths: GetStaticPaths = async () => {
    // limitがデフォルトで10なので、記事数が10以上になると古い記事が生成されない
    // そのため、一旦totalCountを取得して、limitに指定してリクエストを投げる。
    const data = await client.getList<Post>({ endpoint: "post", queries: { fields: 'id' } });
    const totalCount = data.totalCount
    const allData = await client.getList<Post>({ endpoint: "post", queries: { limit: totalCount } });
    const paths = allData.contents.map((content) => `/post/${content.id}`);
    return { paths, fallback: false };
};

// パラメーターから記事詳細データを取得
export const getStaticProps: GetStaticProps<Props, { slug: string }> = async ({ params }) => {
    if (!params) throw new Error("Error Slug Not Found");
    const slug = params.slug;
    const data = await client.getListDetail<Post>({ endpoint: "post", contentId: slug });
    return {
        props: {
            post: data,
        },
    };
};

getStaticPathsという関数が新しく出てきました。

Next.jsではルート内のファイル内に記述するのが一般的みたいです。

getStaticPaths must be used with getStaticProps. You cannot use it with getServerSideProps.

The getStaticPaths API reference covers all parameters and props that can be used with getStaticPaths

https://nextjs.org/docs/basic-features/data-fetching/get-static-paths

こちらでやることとしては、静的ファイルのパスルートを全て作って渡してあげるようなイメージでしょうか。

microCMSのリクエストはデフォルトで記事が10件しか取得されません。

そのため、一旦記事数を取得してから、再度全件取得のクエリを投げる、という処理を行うようにしました。

ここまでで一旦記事を表示させてみます。

このようにmicroCMSのリッチエディタフィールドはhtmlタグ付きでレスポンスが返ってきます。

マークダウン対応させる方法

Reactで用意されているdangerouslySetInnerHTMLを使用するのが簡易的な方法でしょうか。

以下のようにします。

export default function Article({ post }: Props) {
    return (
        <Box>
                ...省略
                {/* 記事本文  変更*/}
                <div
                    dangerouslySetInnerHTML={{
                        __html: `${post.text}`,
                    }}
                />
        </Box >
    )
}

後はHTMLタグごとにスタイルを振っていけば見た目を整えることができます。

ただし、今回はChakra UIを使用しているので、個別にスタイルを振るのを避けて、統一を図りたいところです。

なので、少し手順が増えますが、Chakra UIスタイルに変換するコンポーネントを作成していきます。

マークダウン変換テンプレートの作成

まず、html-react-parserを使用するので、yarn addします。

yarn add html-react-parser

次にcomponents内にMakdownTemplate.tsxを作成します。

/* src/components/MakdownTemplate.tsx */
import {
    BoxProps,
    Box,
    Text,
    UnorderedList,
    OrderedList,
    Heading,
    ListItem,
} from "@chakra-ui/react";
import parse, { domToReact, HTMLReactParserOptions } from 'html-react-parser';

type MarkdownTemplateProps = {
    source: string;
} & BoxProps;

const h1 = {
    props: {
        mt: "24px",
        mb: "16px",
        lineHeight: "1.25",
        fontWeight: "600",
        pb: ".3em",
        fontSize: "2em",
        borderBottom: "1px solid #E7ECF2",
    },
};

const h2 = {
    props: {
        mt: "24px",
        mb: "16px",
        lineHeight: "1.25",
        fontWeight: "600",
        pb: ".3em",
        fontSize: "1.5em",
        borderBottom: "1px solid #E7ECF2",
    },
};

const h3 = {
    props: {
        mt: "24px",
        mb: "16px",
        lineHeight: "1.25",
        fontWeight: "600",
        fontSize: "1.25em",
    },
};

const p = {
    props: {
        lineHeight: "1.8",
        mb: "10px",
        fontSize: "18px",
        color: "##000",
    },
};

const ul = {
    props: {
        my: "1",
        lineHeight: "2",
        pl: "1em"
    },
};

const ol = {
    props: {
        my: "1",
        lineHeight: "2",
        pl: "1em"
    }
};

const li = {
    props: {
        fontSize: "18px"
    },
};

const options: HTMLReactParserOptions = {
    replace: (domNode: any) => {
        if (domNode.type === "tag") {
            if (domNode.name === "h1") {
                return (
                    <Heading as="h1" {...h1.props}>
                        {domToReact(domNode.children, options)}
                    </Heading>
                );
            }
            if (domNode.name === "h2") {
                return (
                    <Heading as="h2" {...h2.props}>
                        {domToReact(domNode.children, options)}
                    </Heading>
                );
            }
            if (domNode.name === "h3") {
                return (
                    <Text as="h3" {...h3.props}>
                        {domToReact(domNode.children, options)}
                    </Text>
                );
            }
            if (domNode.name === "ul") {
                return (
                    <UnorderedList {...ul.props}>
                        {domToReact(domNode.children, options)}
                    </UnorderedList>
                );
            }
            if (domNode.name === 'ol') {
                return (
                    <OrderedList {...ol.props}>
                        {domToReact(domNode.children, options)}
                    </OrderedList>
                )
            }
            if (domNode.name === "li") {
                return (
                    <ListItem {...li.props}>
                        {domToReact(domNode.children, options)}
                    </ListItem>
                )
            }            
            if (domNode.name === "p") {
                return (
                    <Text {...p.props}>{domToReact(domNode.children, options)}</Text>
                );
            }           
        }
    }
}

export const MarkdownTemplate = (props: MarkdownTemplateProps) => {
    return <Box {...props}>{parse(props.source, options)}</Box>;
};

react-html-parserのparse関数で、tagに応じて、Chakra UIコンポーネントを返している、というような処理になります。

あとは[slug].tsxで読み込みます。

/* src/pages/post/[slug].tsx */
...省略
// 追加
import { MarkdownTemplate } from 'components/MarkdownTemplate'
...省略
export default function Article({ post }: Props) {
    return (
        <Box>
            <Header />
            <Container as="main" maxW="container.md" marginTop="4" marginBottom="16">
                ...省略
                {/* 記事本文  変更*/}
                <MarkdownTemplate source={post.text} />
            </Container >
        </Box >
    )
}

このような見た目になるでしょう。

その他ablockquoteimgなんかも使いそうなので、追加をします。

/* src/components/MakdownTemplate.tsx */
import {
    BoxProps,
    Box,
    Text,
    UnorderedList,
    OrderedList,
    Heading,
    ListItem,
    // 追加
    Link,
    Image,
} from "@chakra-ui/react";
import parse, { domToReact, HTMLReactParserOptions } from 'html-react-parser';

type MarkdownTemplateProps = {
    source: string;
} & BoxProps;

...省略

// 追加
const blockquote = {
    props: {
        color: "gray.500",
        my: "1em",
        pl: "2em",
        borderLeft: '2px',
        borderColor: 'gray.500',
        fontSize: "18px",
        lineHeight: "1.8",
    }
}


const a = {
    props: {
        isExternal: true,
        textDecoration: "none",
        color: "#3182CE",
        _hover: {
            textDecoration: "none",
            color: "#4593e6",
        },
    },
};


const img = {
    props: {
        border: "1px",
        borderColor: "gray.300"
    }
}



const options: HTMLReactParserOptions = {
    replace: (domNode: any) => {
        if (domNode.type === "tag") {
            ...省略
            // 追加
            if (domNode.name === 'blockquote') {
                return (
                    <Box as="blockquote" {...blockquote.props}>
                        {domToReact(domNode.children, options)}
                    </Box>
                )
            }
            if (domNode.name === "a") {
                return (
                    <Link {...a.props} href={domNode.attribs.href}>
                        {domToReact(domNode.children, options)}
                    </Link>
                );
            }
            if (domNode.name === 'img') {
                return (
                    <Image {...img.props} src={domNode.attribs.src} />
                )
            }
        }
    }
}

試しに柴田聡子さんの公式HPからアルバムの紹介を引用してみました。

ソースコードをハイライトする

次にソースコードをハイライトする処理を追記していきましょう。

今回はhighlight.jsを使用します。

yarn add highlight.js

highlight.jsですが、タグのclassにhljsを付けるだけで、良い感じにハイライトしてくれます。

注意点は通常の<code>タグとソースコードの<pre><code></code></pre>と出力されるタグを区別することです。

microCMSのリッチエディター上で書く時のこういうやつですね。

念頭において追記していきます。

/* src/components/MakdownTemplate.tsx */
import {
    ...省略
    // 追加
    Code as ChakraCode
} from "@chakra-ui/react";

// 追加
import highlight from 'highlight.js';
import 'highlight.js/styles/hybrid.css'

...省略

// 追加 通常の<code>のスタイル
const code = {
    props: {
        fontSize: 'md',
        px: "0.2em",
        mx: "0.2rem",
    },
}

// 追加 ソースコードのスタイル
const preCode = {
    props: {
        fontSize: "18px",
    }
}

// 追加
const languageSubset = ['js', 'html', 'css', 'xml', 'typescript', 'python'];

const options: HTMLReactParserOptions = {
    replace: (domNode: any) => {
        if (domNode.type === "tag") {
            ...省略
            if (domNode.name === 'code') {
                // 通常のcodeタグとpre→codeタグを区別する
                if (domNode.parent.name === 'pre') {
                    const highlightCode = highlight.highlightAuto(
                        domToReact(domNode.children) as string,
                        languageSubset,
                    ).value;
                    return (
                        <Box as="code" className="hljs" {...preCode.props}>
                            {parse(highlightCode)}
                        </Box>
                    );
                } else {
                    return (
                        <ChakraCode {...code.props}>
                            {domToReact(domNode.children, options)}
                        </ChakraCode>
                    )
                }
            }
        }
    }
}

ソースコードは<pre><code>...と出力されるので、親のタグの名前がpreかどうかで処理を分岐させています。

ここの部分ですね

if (domNode.name === 'code') {
    // 通常のcodeタグとpre→codeタグを区別する
    if (domNode.parent.name === 'pre') {
    ...        
    } else {
        ...
    }
}

こうすることで通常のコードとソースコードで別々のスタイルを適用することができます。

ブログ上ではこのように表示されます。

おわりに

次回は、ページングの処理を加えていこうと思います。

ソースコード

長くなったので、コードを全文載せておきます。

/* src/components/MakdownTemplate.tsx */
import {
    BoxProps,
    Box,
    Text,
    UnorderedList,
    OrderedList,
    Heading,
    ListItem,
    Link,
    Image,
    Code as ChakraCode
} from "@chakra-ui/react";

import parse, { domToReact, HTMLReactParserOptions } from 'html-react-parser';
import highlight from 'highlight.js';
import 'highlight.js/styles/hybrid.css'

type MarkdownTemplateProps = {
    source: string;
} & BoxProps;

const h1 = {
    props: {
        mt: "24px",
        mb: "16px",
        lineHeight: "1.25",
        fontWeight: "600",
        pb: ".3em",
        fontSize: "2em",
        borderBottom: "1px solid #E7ECF2",
    },
};

const h2 = {
    props: {
        mt: "24px",
        mb: "16px",
        lineHeight: "1.25",
        fontWeight: "600",
        pb: ".3em",
        fontSize: "1.5em",
        borderBottom: "1px solid #E7ECF2",
    },
};

const h3 = {
    props: {
        mt: "24px",
        mb: "16px",
        lineHeight: "1.25",
        fontWeight: "600",
        fontSize: "1.25em",
    },
};

const p = {
    props: {
        lineheight: "1.8",
        mb: "10px",
        fontSize: "18px",
        color: "##000",
    },
};


const ul = {
    props: {
        my: "1",
        lineHeight: "2",
        pl: "1em"
    },
};

const ol = {
    props: {
        my: "1",
        lineHeight: "2",
        pl: "1em"
    }
};

const li = {
    props: {
        fontSize: "18px"
    },
};

const blockquote = {
    props: {
        color: "gray.500",
        my: "1em",
        pl: "2em",
        borderLeft: '2px',
        borderColor: 'gray.500',
        fontSize: "18px",
        lineHeight: "1.8",
    }
}

const a = {
    props: {
        isExternal: true,
        textDecoration: "none",
        color: "#3182CE",
        _hover: {
            textDecoration: "none",
            color: "#4593e6",
        },
    },
};

const img = {
    props: {
        border: "1px",
        borderColor: "gray.300"
    }
}

const code = {
    props: {
        fontSize: 'md',
        px: "0.2em",
        mx: "0.2rem",
    },
}

const preCode = {
    props: {
        fontSize: "18px",
    }
}

const languageSubset = ['js', 'html', 'css', 'xml', 'typescript', 'python'];

const options: HTMLReactParserOptions = {
    replace: (domNode: any) => {
        if (domNode.type === "tag") {
            if (domNode.name === "h1") {
                return (
                    <Heading as="h1" {...h1.props}>
                        {domToReact(domNode.children, options)}
                    </Heading>
                );
            }
            if (domNode.name === "h2") {
                return (
                    <Heading as="h2" {...h2.props}>
                        {domToReact(domNode.children, options)}
                    </Heading>
                );
            }
            if (domNode.name === "h3") {
                return (
                    <Text as="h3" {...h3.props}>
                        {domToReact(domNode.children, options)}
                    </Text>
                );
            }
            if (domNode.name === "ul") {
                return (
                    <UnorderedList {...ul.props}>
                        {domToReact(domNode.children, options)}
                    </UnorderedList>
                );
            }
            if (domNode.name === 'ol') {
                return (
                    <OrderedList {...ol.props}>
                        {domToReact(domNode.children, options)}
                    </OrderedList>
                )
            }
            if (domNode.name === "li") {
                return (
                    <ListItem {...li.props}>
                        {domToReact(domNode.children, options)}
                    </ListItem>
                )
            }
            if (domNode.name === "p") {
                return (
                    <Text {...p.props}>{domToReact(domNode.children, options)}</Text>
                );
            }
            if (domNode.name === 'blockquote') {
                return (
                    <Box {...blockquote.props}>
                        {domToReact(domNode.children, options)}
                    </Box>
                )
            }
            if (domNode.name === "a") {
                return (
                    <Link {...a.props} href={domNode.attribs.href}>
                        {domToReact(domNode.children, options)}
                    </Link>
                );
            }
            if (domNode.name === 'img') {
                return (
                    <Image {...img.props} src={domNode.attribs.src} />
                )
            }
            if (domNode.name === 'code') {
                // 通常のcodeタグとpre→codeタグを区別する
                if (domNode.parent.name === 'pre') {
                    const highlightCode = highlight.highlightAuto(
                        domToReact(domNode.children) as string,
                        languageSubset,
                    ).value;
                    return (
                        <Box as="code" className="hljs" {...preCode.props}>
                            {parse(highlightCode)}
                        </Box>
                    );
                } else {
                    return (
                        <ChakraCode {...code.props}>
                            {domToReact(domNode.children, options)}
                        </ChakraCode>
                    )
                }
            }
        }
    }
}

export const MarkdownTemplate = (props: MarkdownTemplateProps) => {
    return <Box {...props}>{parse(props.source, options)}</Box>;
};
Twitter Share