Nuxt3とmicroCMSで作るブログ ③記事詳細ページの作成

Nuxt3とmicroCMSで作るブログシリーズの3番目の記事です。
前回に記事一覧ページを作成したので、今回は記事詳細ページを作っていきます。

記事詳細APIの作成

前回に引き続き記事詳細もサーバサイドから取得していきます。
server/apiにpostDetail.tsを作成し、以下のようにします。

/* client/server/api/postDetail.ts */
import type { IncomingMessage, ServerResponse } from 'http'
import client from './client'
import { Post } from './types'
import * as url from "url";

export default async (req: IncomingMessage, res: ServerResponse) => {
    const params = url.parse(req.url as string, true).query;
    const slug = params.slug
    const data = client.getListDetail<Post>({
        endpoint: 'post',
        contentId: String(slug),
    })

    return data
}


まずここの部分で動的に記事のID,つまりslugを取得するようにしています。

const params = url.parse(req.url as string, true).query;
const slug = params.slug


例えばクライアント側でhttp://localhost:3000/my-pretty-slugにアクセスしたら、my-pretty-slugというパラメータを付与して、APIを呼びます。
サーバー側ではurlをパースして、辞書型に変換したのちにslugを取り出しているという感じです。

記事詳細ページの作成

次にpages内に[slug]ディレクトリを作り、index.vueを作成します。

<!-- client/pages/[slug]/index.vue -->
<script setup lang="ts">

const route = useRoute();
const slug: string | string[] = route.params.slug;

const { data: article } = await useFetch(`/api/postDetail`, {
  params: { slug: slug },
});

</script>

<template>
  <div class="main">
    <span class="published">{{ $formatDate(article.publishedAt) }}</span>
    <span v-for="(tag, i) in article.tag" :key="tag.id" class="tag">{{ tag.name }}
      <span v-if="i !== article.tag.length - 1" style="margin:0 2px;">
        /
      </span>
    </span>
    <h1 class="title">{{ article.title }}</h1>
    <div class="markdown-body" v-html="article.text" />
  </div>
</template>

<style scoped>
.published {
  font-size: 1.4rem;
  color: #888;
  margin-right: 20px;
}

.title {
  margin-top: 10px;
  margin-bottom: 30px;
  font-size: 2.4rem;
  color: #0d1a3c;
  line-height: 1.6;
}

.tag {
  font-size: 1.4rem;
  color: 888;
  opacity: 0.7;
  letter-spacing: 1px;
  margin-right: 1rem;
}
</style>


Nuxt3では[slug]/index.vueという風な階層にすることで、動的ルーティングができます。
このあたりは公式ページが詳しいです。
https://v3.nuxtjs.org/guide/directory-structure/pages/

クライアント側ではここの部分でslugを取り出して、パラメーターを付与してサーバーに投げます。

const route = useRoute();
const slug: string | string[] = route.params.slug;

const { data: article } = await useFetch(`/api/postDetail`, {
  params: { slug: String(slug) },
});


そうして、サーバー側からAPIリクエストを行い、記事が返却される、という流れです。

記事のmainクラスの詳細部分のスタイルはグローバルに適用させました。

/* client/assets/style.css */
...
/* トップページ以外で使用するメインレイアウト */
.main {
  position: relative;
  width: 720px;
  margin: 0 auto 0;
  padding: 112px 0;
  color: #0d1a3c;
}

/* 記事詳細のマークダウン */
.markdown-body * {
  margin-top: 0;
  margin-bottom: 2rem;
  line-height: 1.9;
  font-size: 1.6rem;
  font-weight: 500;
}

.markdown-body strong {
  background-color: yellow;
}

.markdown-body img {
  display: block;
  max-width: 100%;
  margin-top: 20px;
  margin-bottom: 0px;
  height: auto;
  border: solid 1px #ccc;
}

@media (max-width: 1024px) {
  .markdown-body img {
    max-width: 100%;
  }
}

.markdown-body p code {
  background-color: #eee;
  color: #333;
  padding: 0.2em 0.4em;
  font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace;
  margin-left: 0.5rem;
  margin-right: 0.5rem;
}

.markdown-body pre {
  line-height: 1.2;
  background-color: #1D1F21;
  padding: 2% 4%;
  overflow-x: scroll;
}

.markdown-body pre * {
  margin-bottom: 0;
  line-height: 1.4;
  font-weight: lighter;
}

.markdown-body blockquote {
  padding: 15px;
  border-left: 5px solid #ccc;
  border-radius: 2px;
}

.markdown-body h1 {
  font-size: 24px;
  border-bottom: 1px solid #ccc;
  font-weight: bold;
  margin-top: 20px;
  margin-bottom: 10px;
}

.markdown-body h2,
.markdown-body h3 {
  font-size: 20px;
  margin-top: 30px;
  margin-bottom: 10px;
  font-weight: bold;
}

.markdown-body h2 {
  border-bottom: 1px solid #ccc;
}

.markdown-body .cp_embed_wrapper {
  margin-top: 20px;
  margin-bottom: 20px;
}

.markdown-body a {
  color: #1266f1;
}

.markdown-body a:hover {
  opacity: .5;
}

.markdown-body ul,
.markdown-body ol {
  padding-left: 1.5em;
  margin: 1rem 0;
  line-height: 1.7;
}

.markdown-body ul {
  list-style-type: disc;
}

.markdown-body ol {
  list-style-type: decimal;
}

.markdown-body ul li,
.markdown-body ol li {
  margin-bottom: 1rem;
}


ここまでで、このように表示がされます。


コードのハイライトを行う

次にせっかくリッチエディタで書いているので、コードのシンタックスハイライトを行っていきましょう。

参考
サーバサイドでシンタックスハイライトを行う

参考記事と同様にcheerioとhighlight.jsを使っていきます。

npm install cheerio
npm install highlight.js


<!-- client/pages/[slug]/index.vue -->
<script setup lang="ts">
import cheerio from 'cheerio'; //追加
import hljs from 'highlight.js'//追加
import 'highlight.js/styles/hybrid.css'//追加

const route = useRoute();
const slug: string | string[] = route.params.slug;

const { data: article } = await useFetch(`/api/postDetail`, {
  params: { slug: String(slug) },
});

// hljsクラスをつける
const $ = cheerio.load(article.value.text);
$('pre code').each((_, elm) => {
  const result = hljs.highlightAuto($(elm).text());
  $(elm).html(result.value);
  $(elm).addClass('hljs');
});
const body = $.html()

</script>

<template>
  <div class="main">
    <span class="published">{{ $formatDate(article.publishedAt) }}</span>
    <span v-for="(tag, i) in article.tag" :key="tag.id" class="tag">{{ tag.name }}
      <span v-if="i !== article.tag.length - 1" style="margin:0 2px;">
        /
      </span>
    </span>
    <h1 class="title">{{ article.title }}</h1>
    <!-- 変更 -->
    <div class="markdown-body" v-html="body"></div>
  </div>
</template>


script内でuseFetchで取得したデータを取り出す際はarticle.value.textのようにvalueを付ける決まりになっているようです。
ちょっとはまったポイントでした。



次回はトップページのページング処理を行っていきます。

Transitionを効かせる

Nuxt3ではデフォルトでTransition用のクラスが付与されるようです。
なので、style.cssに追記するだけでTransitionが効きます。

/* client/assets/style.css */

...

/* Transition */
.page-enter-from {
  opacity: 0;
  transform: translateY(-10px);
}

.page-enter-active,
.page-leave-active {
  transition: all 0.3s;
}

.page-enter,
.page-leave-to {
  opacity: 0;
}
...




TOPページ