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;
}
...