Open20

microCMS + next.jsでブログ作成

Hiroki KamedaHiroki Kameda

こちらの内容を使って個人ブログを作成する。
https://blog.microcms.io/microcms-next-jamstack-blog/

現状のスキルレベル

  • Next.jsを多少触っている
  • 基本的にフロントエンドではなくコーディングのスキルがメイン
  • APIを使ったり、機能開発をしたことはほぼ無し

モチベーション

フロントエンドの学習をしてスキルアップしていく

Github

https://github.com/kmdhrk/hirokikameda-blog

Hiroki KamedaHiroki Kameda
export const getStaticPaths = async() =>{
  // dataにエンドポイントblogの情報を取得する
  const data = await client.get({endpoint: "blog"});

  // pathsにdataから取り出したidをURLとして代入する
  const paths = data.contents.map((content) => `/blog/${content.id}`);

  // fallbackをfalseにするとpathsで返したURL以外404に
  return {paths, fallback: false};
}
Hiroki KamedaHiroki Kameda

getStaticPropsの引用

contextパラメータは次のキーを含むオブジェクトです:

paramsはページが動的ルートを利用するためのルートパラメータを持ちます。たとえば、ページ名が [id].js >である時、paramsは { id: ...} のように見えます。詳細は 動的ルーティングのドキュメントをご覧ください。>後に説明する getStaticPathsと一緒に使う必要があります。
ページがプレビューモードになっている時は preview が true になり、そうでない場合は false になります。>プレビューモードのドキュメントをご覧ください。
previewDataは、setPreviewDataによって設定されたプレビューデータを含みます。プレビューモードのドキュメント をご覧ください。

挙動的にはこれ

// 既にcontextというオブジェクトが存在している
export const getStaticProps = async(context) => {
  // ページidをidに代入
  const id = context.params.id;
  
 // エンドポイント"blog"のページIDが"id"のdataを取得 
  const data = await client.get({endpoint:"blog", contentId: id});

  return{
    props:{
      blog:data,
    },
  }
}

getStaticPropsが少し理解できてきた

Hiroki KamedaHiroki Kameda

スタイルは追加せずに一旦完了
約2hぐらいで完成。
日本語で丁寧に説明してあってとてもわかり易かった。

残りやりたいこと

  • スタイル追加
  • Typescriptに変更
  • ページネーション追加
  • プレビュー環境を整える
  • tailwind追加
  • yarnに統一
Hiroki KamedaHiroki Kameda

{ props: posts } を返すことで、Blog コンポーネントは
ビルド時にpostsを prop として受け取ります。

ここで受け取った値はpropとして使用できるようになる

Hiroki KamedaHiroki Kameda

Typescriptの導入

公式
https://nextjs.org/docs/basic-features/typescript

  1. tsconfig.jsonを作成

  2. パッケージ導入

yarn add --dev typescript @types/react @types/node
  1. yarn buildをすると初期設定が入る
{
  "compilerOptions": {
    "target": "es5",
    "lib": [
      "dom",
      "dom.iterable",
      "esnext"
    ],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": false,
    "forceConsistentCasingInFileNames": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "preserve"
  },
  "include": [
    "next-env.d.ts",
    "**/*.ts",
    "**/*.tsx"
  ],
  "exclude": [
    "node_modules"
  ]
}
  1. .jsを.tsxにリネーム

  2. とりあえずエラー削除していく
    anyでお茶を濁したけど合っているかわからいない

blog/[id].tsx
export const getStaticPaths: GetStaticPaths = async () => {
  const data: any = await client.get({ endpoint: "blog" });
  const paths = data.contents.map((content) => `/blog/${content.id}`);
  console.log(data)
  return { paths, fallback: false };
};

export const getStaticProps: GetStaticProps = async (context) => {
  const id: any = context.params.id;
  const data = await client.get({ endpoint: "blog", contentId: id});

  return {
    props: {
      blog: data,
    },
  };
};
index.tsx
export const getStaticProps: GetStaticProps  = async () => {
  const data: any = await client.get({ endpoint: "blog" });
  return {
    props: {
      blog: data.contents,
    },
  };
};


Hiroki KamedaHiroki Kameda

プレビュー環境

https://blog.microcms.io/nextjs-preview-mode/

エラーが発生

export default async (req: NextApiRequest, res: NextApiResponse) => {
  if (!req.query.id) {
    return res.status(404).end();
  }
  const id = req.query.id;
  const draftKey = req.query.draftkey;

  const content = await fetch(
    `https://webdock.microcms.io/api/v1/blog/${id}?fields=id&draftKey=${draftKey}`,
    { headers: { 'X-API-KEY': process.env.API_KEY || '' } }
  )
  .then(res => res.json()).catch(error => null);

  if (!content) {
    return res.status(401).json({ message: 'Invalid slug' });
  }

  res.setPreviewData({
    slug: content.id,
    draftKey: req.query.draftKey,
  });
  res.writeHead(307, { Location: `/${content.id}` });
  res.end('Preview mode enabled');
};

検証
contentがundefineになっている
→ fetchがうまくいっていない
→ draftKeyの取得ができていない
→ タイポしてました。。。

これで一日オワタ
microCMSサイトの記述だとディレクトリがpage配下になってしまうので、/blog/[id].tsxにプレビューを表示させるように変更

  res.writeHead(307, { Location: `/blog/${content.id}` });

プレビューが表示されるのは、page/[id].tsxのディレクトリなのでプレビュー専用のファイルを作成する。
page/blog/[id].tsxをコピペし、
pathsを書き換え

page/[id].tsx
export const getStaticPaths: GetStaticPaths = async () => {
  const data: any = await client.get({ endpoint: "blog" });
  // /blog/${content.id}のblogを削除
  const paths = data.contents.map((content) => `/${content.id}`);
  return { paths, fallback: true };
};

記述かぶっているのでリファクタリングしたい。
ダイナミックルートで解決できそうかも。

プレビュー自体はできているので一旦後回し

draftKeyの型エラー

こちらの記事で修正できた。
https://zenn.dev/thiragi/scraps/cb502de7f6866d

APIの挙動も調べた↓

Hiroki KamedaHiroki Kameda

スタイルの追加

CSSの選定は、Next.jsで推奨されているTailwindとCSS Mosulesで記述することにする
ちょっと古いけれどめちゃくちゃ参考になった
https://qiita.com/jagaapple/items/7f74fc32c69f5b731159

Sassの追加

$ yarn add sass

scssファイルも使えます。

TOPページのスタイリング大体終了
ナビゲーションつけたいなー

day.jsの追加

公開日をフォーマットするためにday.jsを追加する

yarn add dayjs
Hiroki KamedaHiroki Kameda

シンタックスハイライト追加

参考
https://qiita.com/cawauchi/items/ff6489b17800c5676908

yarn add highlight.js
yarn add cheerio
import cheerio from "cheerio";
import hljs from "highlight.js";
import "highlight.js/styles/github-dark.css";

export default function Blogid({ content, highlightedBody }: contentProps) {
  return (
    <>
      <Header />
      <main className={blogStyles.post}>
        <div className="max-w-3xl p-11 mx-auto bg-white rounded-xl shadow-md">
          <h1>{content.title}</h1>
          <div className="mt-2">
            <span className="text-gray-600">
              {dayjs(content.publishedAt).format("YYYY.MM.DD")}
            </span>
            <span className="ml-5 text-sm text-gray-600 underline">
              {content.category && `${content.category.name}`}
            </span>
          </div>
          <div className="mt-12">
            {content.eyecatch ? (
              <Image
                src={content.eyecatch.url}
                width={content.eyecatch.width}
                height={content.eyecatch.height}
              />
            ) : (
              <Image src="/noimage.png" alt="No Image" />
            )}
          </div>
          <div dangerouslySetInnerHTML={{ __html: highlightedBody }}></div>
        </div>
      </main>
    </>
  );
}

export const getStaticProps: GetStaticProps = async ({
  params,
  previewData,
}) => {
  const id = params?.id;
  const isDraft = (item: any): item is { draftKey: string } =>
    !!(item?.draftKey && typeof item.draftKey === "string");
  const draftKey = isDraft(previewData);
  const content = await fetch(
    `https://webdock.microcms.io/api/v1/blog/${id}${
      draftKey !== undefined ? `?draftKey=${draftKey}` : ""
    }`,
    { headers: { "X-API-KEY": process.env.API_KEY || "" } }
  ).then((res) => res.json());

  const $ = cheerio.load(content.body);
  $("pre code").each((_, elm) => {
    const result = hljs.highlightAuto($(elm).text());
    $(elm).html(result.value);
    $(elm).addClass("hljs");
  });

  return {
    props: {
      content,
      highlightedBody:$.html()
    },
  };
};

Hiroki KamedaHiroki Kameda

Google Analyticsの導入

https://panda-program.com/posts/nextjs-google-analytics
多分Head内にScriptを置くとエラーが出るので
Scriptは直接_app.tsxに書く

_app.tsx
import "../styles/global.scss";
import "tailwindcss/tailwind.css";
import { useRouter } from "next/router";
import React, { useEffect } from "react";
import { existsGaId, pageview, GA_ID } from "../libs/gtag";
import Script from "next/script";

function MyApp({ Component, pageProps }) {
  const router = useRouter();

  useEffect(() => {
    if (!existsGaId) {
      return;
    }

    const handleRouteChange = (path) => {
      pageview(path);
    };

    router.events.on("routeChangeComplete", handleRouteChange);

    return () => {
      router.events.off("routeChangeComplete", handleRouteChange);
    };
  }, [router.events]);
  return (
    <>
        {GA_ID !== undefined && (
          <>
            <Script
              src={`https://www.googletagmanager.com/gtag/js?id=${GA_ID}`}
              onLoad={() => {
                const script = document.createElement("script");
                script.innerHTML = `
                window.dataLayer = window.dataLayer || [];
                function gtag(){dataLayer.push(arguments);}
                gtag('js', new Date());
                gtag('config', '${GA_ID}', {
                  page_path: window.location.pathname,
                });`;
                document.body.appendChild(script);
              }}
            />
          </>
        )}
        <Script
          strategy="beforeInteractive"
          src="https://cdnjs.cloudflare.com/ajax/libs/lazysizes/5.3.2/lazysizes.min.js"
          integrity="sha512-q583ppKrCRc7N5O0n2nzUiJ+suUv7Et1JGels4bXOaMFQcamPk9HjdUknZuuFjBNs7tsMuadge5k9RzdmO+1GQ=="
          crossOrigin="anonymous"
        />

      <Component {...pageProps} />
    </>
  );
}

export default MyApp;

Hiroki KamedaHiroki Kameda

OGPの追加

以下のブログを参考にそのまま記載
https://qiita.com/TK-C/items/cd34d0f6d4b001053443

seo.tsx
import Head from "next/head";

interface MetaData {
  pageTitle?: string;
  pageDescription?: string;
  pagePath?: string;
  pageImg?: string;
  pageImgWidth?: number;
  pageImgHeight?: number;
}

export default function Seo({
  pageTitle,
  pageDescription,
  pagePath,
  pageImg,
  pageImgWidth,
  pageImgHeight,
}: MetaData) {
  const defaultTitle = "Hiroki Kameda's Blog";
  const defaultDescription = "demo";

  const title = pageTitle ? `${pageTitle} | ${defaultTitle}` : defaultTitle;
  const description = pageDescription ? pageDescription : defaultDescription;
  const url = pagePath;
  const imgUrl = pageImg;
  const imgWidth = pageImgWidth ? pageImgWidth : 1280;
  const imgHeight = pageImgHeight ? pageImgHeight : 640;

  return (
    <Head>
      <title>{title}</title>
      <meta name="viewport" content="width=device-width,initial-scale=1.0" />
      <meta name="description" content={description} />
      <meta property="og:url" content={url} />
      <meta property="og:title" content={title} />
      <meta property="og:site_name" content={title} />
      <meta property="og:description" content={description} />
      <meta property="og:type" content="website" />
      <meta property="og:image" content={imgUrl} />
      <meta property="og:image:width" content={String(imgWidth)} />
      <meta property="og:image:height" content={String(imgHeight)} />
      <meta name="twitter:card" content="summary_large_image" />
      <link rel="canonical" href={url} />
    </Head>
  );
}

ブログURLを追加

pages/blog/[id].tsx
export default function Blogid({ content, highlightedBody, toc }: BlogProps) {
  
  const router = useRouter();
  const pagePath = `https://micro-cms-blog-nu.vercel.app${router.asPath}`;
  
  return (
    <>
      <Seo
        pageTitle={content.title}
        pagePath={pagePath}
        pageDescription={content.description}
        pageImg={content.eyecatch.url}
        pageImgHeight={content.eyecatch.height}
        pageImgWidth={content.eyecatch.width}
      />
      <Header />
//省略

https://zenn.dev/k_neko3/articles/893c2409f405b0