🐙

githubリポジトリのマークダウンファイルを取得しnext.jsで表示する

2020/10/05に公開5

日頃はこのzennで初心者ながらに記事を書かせてもらっています。これを自分のウェブサイトにも同期させられたら手間が省けるし何よりzennと自分のウェブサイトで記事が異なるといった煩雑さがなくなるなと思いました。

zennはgithubのリポジトリで記事を管理できるのでgithubからマークダウンファイルを取得出来れば自分のウェブページにも表示できます。以下で説明するのはその方法です。これがベストな方法かは解りませんが方法の一つとして読んでいただければと思います。

まずはcurlでAPIを叩いてみる

GithubAPIを使うのは初めてだったのでまずはcurlコマンドで簡単に叩いてみます。
まずは単純に

$ curl https://api.github.com/users/bkc-tomi
{
  "login": "bkc-tomi",
  以下略
}

これだとプライベートリポジトリなどは取得できません。zennのリポジトリはプライベートにしているのでプライベートリポジトリも取得できるようにアクセストークンを発行して実行します。やり方はいくつかあるようですが、私はPersonal access tokenを発行するやり方をとります。

githubのページで
Settings -> Developer settings -> Personal access tokens
でアクセストークンを発行してください。

$ curl -H "Authorization: token **************************" https://api.github.com/user/repos
多すぎたので実行結果略

上ではプライベートも含めて全てのリポジトリデータを取得しています。多すぎたので実行結果は省略しています。

Next.jsで叩く

次にNext.jsでデータを取得してみます。Next.jsのgetStaticPropsでデータを取得してコンポーネントに埋め込みます。

export const getStaticProps: GetStaticProps = async() => {
    const fetchData = await fetch("https://api.github.com/repos/bkc-tomi/zenn-content/contents/articles/",
    {
        headers: { "Authorization": "token *" }
    })
    .then(res => {
        return res.json();
    })
    .catch(err => {
        console.log(err);
    })
    const blog = fetchData;
    return {
        props: {
            myBlogs:      blog,
        }
    }
}

fetchAPIで取得してプロップスに渡します。アクセストークンが必要なのでheadersも忘れないようにしましょう。

0: {name: ".keep", path: "articles/.keep", 
1: {name: "2020-09-27-go-1.md", path: "articles/2020-09-27-go-1.md", 
2: {name: "2020-09-27-go-2.md", path: "articles/2020-09-27-go-2.md", 
3: {name: "2020-09-28-go-3.md", path: "articles/2020-09-28-go-3.md", 
4: {name: "2020-09-29-go-4.md", path: "articles/2020-09-29-go-4.md", 
5: {name: "2020-09-29-go-5.md", path: "articles/2020-09-29-go-5.md", 
6: {name: "2020-09-30-go-6.md", path: "articles/2020-09-30-go-6.md", 
7: {name: "2020-09-30-go-env.md", path: "articles/2020-09-30-go-env.md", 
8: {name: "2020-09-30-go-web1.md", path: "articles/2020-09-30-go-web1.md", 
9: {name: "2020-09-30-go-web2.md", path: "articles/2020-09-30-go-web2.md", 
10: {name: "2020-10-01-go-7.md", path: "articles/2020-10-01-go-7.md", 
11: {name: "2020-10-02-go-web3.md", path: "articles

ここではarticleディレクトリのファイル一覧を取得しています。これを使って個々のファイルのデータを以下のように取得します。

export const getStaticProps: GetStaticProps = async() => {
    const datas = [];

    const list = await fetch("https://api.github.com/repos/bkc-tomi/zenn-content/contents/articles/",
    {
        headers: { "Authorization": "token *" }
    })
    .then(res => {
        return res.json();
    })
    .catch(err => {
        console.log(err);
    });
    // ①
    if (list) {
        await Promise.all(list.map(async(li: any) => {
            const data = await fetch("https://api.github.com/repos/bkc-tomi/zenn-content/contents/articles/" + li.name,
            {
                headers: { "Authorization": "token *" }
            })
            .then(res => {
                return res.json();
            })
            .catch(err => {
                console.log(err);
            });
            datas.push(data);
        }));
    }

ここでは①先ほど取得した一覧のファイル名を使用してさらに個々のファイルを取得しています。そして取得したデータをdatas配列の中に格納していっています。ここで取得されるデータは以下のような構造をしています。

{name: "2020-09-28-go-3.md", path: "articles/2020-09-28-go-3.md", sha: "3757c8654fa712e8d66143760703080582c54963", size: 9548, url: "https://api.github.com/r, …}
content: "-略"
download_url: "https://raw.githubusercontent.com/
encoding: "base64"
git_url: "https://api.github.com/
html_url: "https://github.com/
name: "2020-09-28-go-3.md"
path: "articles/2020-09-28-go-3.md"
sha: "3757c8654fa712e8d66143760703080582c54963"
size: 9548
type: "file"
url: "https://api.github.com/repos/n"
_links: {self: "https://api.github.com

ここで、ファイルの中身(content)はbase64というタイプにエンコードされていてそのままだと文字化けして表示されます。これをデコードしてutf-8に戻すために以下の操作をします。

datas.map((data, index) => {
    // const contents = decodeURIComponent(escape(atob(data.content)));
    const buffer = new Buffer(data.content, 'base64');
    const markdown = buffer.toString("utf-8");
    datas[index].content = markdown;
});

atob()関数でbase64をデコードしますがこの関数は正確にはwindow.atob()なのでブラウザ上でしか動作しません。なのでブラウザ上ならコメントアウトしている記述でデコードしますが、getStaticPropsはサーバ上で実行されるのでBuffer関数を使用しマークダウンに直します。

コンポーネントに埋め込む

コンポーネントへの埋め込みはreact-markdownというライブラリを使います。とりあえず記事の一つを出力してみましょう。

import { GetStaticProps } from 'next';
import Layout from "../components/layout";
import ReactMarkdown from 'react-markdown';

function Blog(datas) {
    return (
        <Layout>
            <ReactMarkdown source={ datas[1].content } />
        </Layout>
    );
}

ReactMarkdownはマークダウンを解析してhtmlに変換します。これを実行すると以下のようになります。

実行結果

見た目をもっと整える必要はありますが、これでgithubからマークダウンファイルを取ってくることができます。

gray-matterを使う

実行結果を見て---で囲まれた記事の設定が表示されているのは機密情報ではないとはいえ不格好です。ここで思い出したのですが、next.jsのチュートリアルでブログを作った時もマークダウンファイルを扱いました。その時どのようにしてマークダウンをhtmlにしたか調べるとgray-matterというライブラリを使用していました。

gray-matter

これはマークダウンを分析してhtmlに変換してくれます。先ほどの---で囲まれた記事の設定情報も分析してオブジェクトにしてくれるので、扱いやすくなります。なのでこれを使って以下のようにコードを書き直しました。

import { useState } from "react";
import { GetStaticProps } from 'next';
import Link from "next/link";
import Layout from "../components/layout";
import { SideBar } from "../components/sideBar";
import matter from "gray-matter";
import ReactMarkdown from "react-markdown";
import Styles from "../styles/template.module.css";

function Blog({ 
    datas, keys
}) {
    const [key, setKey] = useState(keys[0]);

    const handleKey = (event) => {
        setKey(event.target.innerHTML);
    }
    return (
        <Layout>
            <div id="top" className={ Styles.container }>
                <SideBar>
                    <h3>記事一覧</h3>
                    {
                        keys.map((key, id) => {
                        return (
                            <p
                                key={ id }
                                className={ Styles.btn }
                                onClick={ handleKey }
                            >{ key }</p>
                        )
                        })
                    }
                </SideBar>
                <div className={ Styles.article }>
                    <div className={ Styles.innerArticle } >
                        <h1 >Blog</h1>
                        <br></br>
                        <br></br>
                        <h1>{ key }</h1>
                        <ReactMarkdown source={ datas[key] } />
                        <Link href="#top"><a
                            style={{
                                display: "inline-block",
                                paddingTop: "2rem",
                            }}
                        >Topに戻る</a></Link>
                    </div>
                </div>
            </div>
        </Layout>
    );
}

export const getStaticProps: GetStaticProps = async() => {
    const datas = {};
    const keys = [];
    const lists = await fetch("https://api.github.com/repos/bkc-tomi/zenn-content/contents/articles/",
    {
        headers: { "Authorization": "token " + process.env.GITHUB_PERSONAL_TOKEN }
    })
    .then(res => {
        return res.json();
    })
    .catch(err => {
        console.log(err);
    });

    if (lists) {
        await Promise.all(lists.map(async(li: any) => {
            if (li.name != ".keep") {
                const data = await fetch("https://api.github.com/repos/bkc-tomi/zenn-content/contents/articles/" + li.name,
                {
                    headers: { "Authorization": "token " + process.env.GITHUB_PERSONAL_TOKEN }
                })
                .then(res => {
                    return res.json();
                })
                .catch(err => {
                    console.log(err);
                });
                const buffer = new Buffer(data.content, 'base64');
                const content = buffer.toString("utf-8");
                const mdObj = matter(content);
                if (mdObj.data.published) {
                    datas[mdObj.data.title] = mdObj.content;
                    keys.push(mdObj.data.title);
                }
            }
        }));
    }
    keys.sort();
    return {
        props: {
            datas: datas,
            keys: keys,
        }
    }
}
 
export default Blog;

今回重要なのはgetStaticPropsの部分です。ここでは取得したデータを連想配列datasとkeysの二つに分けて返します。keysは記事の内容を保持している連想配列から一つを指定するときに使用します。
fetch関数でデータを取得し、取得したデータの内コンテンツの部分をBuffer関数でutf-8形式に変換します。さらにmatterで分析しhtmlデータに変換します。
本来はいろいろな情報が取得できていますが、使用するのはファイル内部のコンテンツとタイトルだけなのでだけなのでそれぞれdatasとkeysに代入していき、コンポーネントに渡します。
これを実行すると以下のようになります。

実行結果2

これで以上になります。

Discussion

大学生だった.大学生だった.

この記事を参考にNext.jsでブログを作ってる者です。記事について質問があるのですが const [key, setKey] = useState(keys[0]); このkeyには記事のタイトルが格納されるのでしょうか?

Tomiaki MatsumuraTomiaki Matsumura

ありがとうございます。返答が遅くなってしまい申し訳ありませんでした。

おっしゃる通り、keyには記事のタイトルが入ります。また、記事の本文は、記事のタイトルをキーにした連想配列にしているので

const handleKey = (event) => {
        setKey(event.target.innerHTML);
}

でタイトルの記事を呼び出す形になっています。

大学生だった.大学生だった.

あともう一つすいません。zennに投稿するmdファイルだと日付を管理出来ないないのですが、記事の並び替え等はどのように行なっているのでしょうか?

Tomiaki MatsumuraTomiaki Matsumura

私はこのようにマークダウンのファイル名に日付を入れるようにして、対応しています。
スクリーンショット

ただ今試して見たら以下のように日付の情報を加えてもzennの方で問題なく動作するので、それを利用して並べ替えるのもありかもしれません。

---
title: "自分のウェブサイトにブログカードを実装してみた"
emoji: "🎫"
type: "tech" # tech: 技術記事 / idea: アイデア
topics: ["nextjs", "blog", "vercel"]
date: 20210322
published: true
---
if (mdObj.data.published) {
    const key = {
        date: mdObj.data.date,
        title: mdObj.data.title
    }
    keys.push(key);
}

keys.sort((a, b) => {
    if (a.date > b.date) return -1;
    if (a.date < b.date) return 1;
    return 0;
})