strapiでブログを作る④(フロントエンド編)

strapiでブログを作る④(フロントエンド編)

nuxtjsを使ってstrapiのフロント部分の作り方を説明します。

公開日:2024年6月4日 更新日:2024年9月4日
programming

まずはnuxtをインストールする

以下はproject名をprotagramとしていますので、こちらはよしなに変更してください。

npx nuxi@latest init protagram

Need to install the following packages:
[email protected]
Ok to proceed? (y) 

✔ Which package manager would you like to use?
npm
◐ Installing dependencies...                                                                                             9:04:07

> postinstall
> nuxt prepare

✔ Types generated in .nuxt                                                                                              9:04:47

added 826 packages, and audited 828 packages in 40s

166 packages are looking for funding
  run `npm fund` for details

found 0 vulnerabilities
✔ Installation completed.                                                                                               9:04:47

✔ Initialize git repository?
No
                                                                                                                         9:10:03
✨ Nuxt project has been created with the v3 template. Next steps:
 › cd protagram                                                                                                          9:10:03
 › Start development server with npm run dev 

次にbackendで作ったstrapiのapiからdataを取得するときに使うpackageをインストールする。

https://strapi.nuxtjs.org/

packageのインストール

まずは以下のパッケージをインストールして各々の設定をしていく

  • tailwindcss
  • @tailwindcss/typography
  • @nuxtjs/strapi
  • markdown-it
  • @types/markdown-it
  • vue3-carousel

※一括でダウンロードしようとしたら、エラーでたので1つづつインストールする。

tailwindcss

https://tailwindcss.com/docs/guides/nuxtjs

説明不要だと思いますがclassをつけるだけでスタイリング出来るcss framework

tailwindのドキュメントのNuxtにinstallする方法の通りに実行していきます。

@tailwindcss/typography

https://github.com/tailwindlabs/tailwindcss-typography

これはproseというclassをつけるとその配下のhタグとかpタグとかを記事を表示するときに使うタグにいい感じのstyleを当ててくれるやつ。

daisyui

https://daisyui.com/docs/install/

tailwindcssを入れても簡単なボタンをスタイリングするにしても複数のclassを追加しなくてはいけない。

それもめんどくさいのでdaisyuiを使うことでボタンならclass="btn" だけで済むので楽

markdown-it

https://github.com/markdown-it/markdown-it

マークダウンをHTMLに変換するためのパッケージ

@types/markdown-itはmarkdown-itの型

vue3-carousel

https://ismail9k.github.io/vue3-carousel/getting-started.html

これはstrapiが記事を書く際のブロックでsliderがあるのだがそこで追加した画像をカルーセルで表示する際に使う。

別にこのpackageじゃなくてもカルーセルが実現出来るpackageなら何でもいい。

@nuxtjs/strapi

https://strapi.nuxtjs.org/setup

前回作ったstrapi(backend)から渡されたapiを受け取る際に使うpackage

別に直接axiosなどで受け取ってもいいのだがこちらのpackageを使った方が簡単

最後に

npm install
npm run dev

を実行することで立ち上げる事が出来る。

型の取得方法

各content事に手で型を書いていってもいいのだがそれだと結構めんどくさい。

そこでstrapi(backend)でgenerateした型を流用することにした。

まずstrapi(backend)で生成された型が定義されているファイルはcontentTypes.d.tscomponents.d.tsなのでそれをコピーしてくるコードを書いたファイルを作る。

copyType.cjsというファイルを作って以下のコードをコピーする。

パスの部分はよしなに書き換えてください。

const fs = require("fs");
const path = require("path");

const files = [
  {
    src: path.join(
      __dirname,
      "../backend/types/generated/contentTypes.d.ts"
    ),
    dest: path.join(__dirname, `./types/contentTypes.d.ts`),
  },
  {
    src: path.join(
      __dirname,
      "../backend/types/generated/components.d.ts"
    ),
    dest: path.join(__dirname, `./types/components.d.ts`),
  },
];

function copyFile({ src, dest }) {
  const destinationDir = path.dirname(dest);

  // Check if source file exists
  if (!fs.existsSync(src)) {
    console.error(`Source file does not exist: ${src}`);
    process.exit(1);
  }

  // Ensure destination directory exists or create it
  if (!fs.existsSync(destinationDir)) {
    fs.mkdirSync(destinationDir, { recursive: true });
  }

  // Read the source file, modify its content and write to the destination file
  const content = fs.readFileSync(src, "utf8");

  fs.writeFile(dest, content, (err) => {
    if (err) {
      console.error(`Error writing to destination file: ${err}`);
      process.exit(1);
    } else {
      console.log(`File ${src} copied and modified successfully!`);
    }
  });
}

files.forEach((file) => copyFile(file));

次にpackage.jsonに以下を追加してください。

"copytypes": "node copyTypes.js"

これを追加することで

npm run copytypes

を実行するとバックエンドの最新の型定義をコピーする事ができます。

スキーマなどの更新があった際にはコピーするのを忘れないようにしましょう。

次にstrapiの型をinstallします。

npm install @strapi/strapi

次に先ほどファイルをコピーしたディレクトリと同じ./src/types にtypes.tsというファイルを作り以下をコピペします。

import type { Attribute, Common, Utils } from "@strapi/strapi";

type IDProperty = { id: number };

type InvalidKeys<TSchemaUID extends Common.UID.Schema> = Utils.Object.KeysBy<
  Attribute.GetAll<TSchemaUID>,
  Attribute.Private | Attribute.Password
>;

export type GetValues<TSchemaUID extends Common.UID.Schema> = {
  [TKey in Attribute.GetOptionalKeys<TSchemaUID>]?: Attribute.Get<
    TSchemaUID,
    TKey
  > extends infer TAttribute extends Attribute.Attribute
    ? GetValue<TAttribute>
    : never;
} & {
  [TKey in Attribute.GetRequiredKeys<TSchemaUID>]-?: Attribute.Get<
    TSchemaUID,
    TKey
  > extends infer TAttribute extends Attribute.Attribute
    ? GetValue<TAttribute>
    : never;
} extends infer TValues
  ? // Remove invalid keys (private, password)
    Omit<TValues, InvalidKeys<TSchemaUID>>
  : never;

type RelationValue<TAttribute extends Attribute.Attribute> =
  TAttribute extends Attribute.Relation<
    infer _TOrigin,
    infer TRelationKind,
    infer TTarget
  >
    ? Utils.Expression.MatchFirst<
        [
          [
            Utils.Expression.Extends<
              TRelationKind,
              Attribute.RelationKind.WithTarget
            >,
            TRelationKind extends `${string}ToMany`
              ? Omit<APIResponseCollection<TTarget>, "meta">
              : APIResponse<TTarget> | null
          ]
        ],
        `TODO: handle other relation kind (${TRelationKind})`
      >
    : never;

type ComponentValue<TAttribute extends Attribute.Attribute> =
  TAttribute extends Attribute.Component<infer TComponentUID, infer TRepeatable>
    ? IDProperty &
        Utils.Expression.If<
          TRepeatable,
          GetValues<TComponentUID>[],
          GetValues<TComponentUID> | null
        >
    : never;

type DynamicZoneValue<TAttribute extends Attribute.Attribute> =
  TAttribute extends Attribute.DynamicZone<infer TComponentUIDs>
    ? Array<
        Utils.Array.Values<TComponentUIDs> extends infer TComponentUID
          ? TComponentUID extends Common.UID.Component
            ? { __component: TComponentUID } & IDProperty &
                GetValues<TComponentUID>
            : never
          : never
      >
    : never;

type MediaValue<TAttribute extends Attribute.Attribute> =
  TAttribute extends Attribute.Media<infer _TKind, infer TMultiple>
    ? Utils.Expression.If<
        TMultiple,
        APIResponseCollection<"plugin::upload.file">,
        APIResponse<"plugin::upload.file"> | null
      >
    : never;

export type GetValue<TAttribute extends Attribute.Attribute> =
  Utils.Expression.If<
    Utils.Expression.IsNotNever<TAttribute>,
    Utils.Expression.MatchFirst<
      [
        // Relation
        [
          Utils.Expression.Extends<TAttribute, Attribute.OfType<"relation">>,
          RelationValue<TAttribute>
        ],
        // DynamicZone
        [
          Utils.Expression.Extends<TAttribute, Attribute.OfType<"dynamiczone">>,
          DynamicZoneValue<TAttribute>
        ],
        // Component
        [
          Utils.Expression.Extends<TAttribute, Attribute.OfType<"component">>,
          ComponentValue<TAttribute>
        ],
        // Media
        [
          Utils.Expression.Extends<TAttribute, Attribute.OfType<"media">>,
          MediaValue<TAttribute>
        ],
        // Fallback
        // If none of the above attribute type, fallback to the original Attribute.GetValue (while making sure it's an attribute)
        [Utils.Expression.True, Attribute.GetValue<TAttribute, unknown>]
      ],
      unknown
    >,
    unknown
  >;

export interface APIResponseData<TContentTypeUID extends Common.UID.ContentType>
  extends IDProperty {
  attributes: GetValues<TContentTypeUID>;
}

export interface APIResponseCollectionMetadata {
  pagination: {
    page: number;
    pageSize: number;
    pageCount: number;
    total: number;
  };
}

export interface APIResponse<TContentTypeUID extends Common.UID.ContentType> {
  data: APIResponseData<TContentTypeUID>;
}

export interface APIResponseCollection<
  TContentTypeUID extends Common.UID.ContentType
> {
  data: APIResponseData<TContentTypeUID>[];
  meta: APIResponseCollectionMetadata;
}

これでbackendからコピーした型定義を使うことが出来るようになります。

articleをfetchする処理を例にarticleの型を定義する方法が以下になります。

import type { APIResponseCollection, APIResponseData } from "@/types/types";

type Article = APIResponseData<"api::article.article">;

const fetchArticleBySlug = ()  => async (slug: string): Promise<Article> => {
    const { find } = useStrapi();
    const response = await find<APIResponseCollection<"api::article.article">>("articles", {
        filters: { slug: slug },
        populate: "deep",
        });
        return response.data[0] as unknown as Article;
};

設計

今回はstrapiの管理画面で作った各contentをcomposablesから取得するようにした。

composablesの使い方としてここでfetchのロジックまで書くのはよろしくないかもしれないですが、一旦このままにします。

例としてaboutは以下のような感じです。

./composables/useAbout.ts

import type { Ref } from "vue";

import type { APIResponseCollection, APIResponseData } from "@/types/types";

type About = APIResponseData<"api::about.about">['attributes'] | undefined;

const fetchAbout = () => async (): Promise<About> => {
    const { findOne } = useStrapi();
    const response = await findOne<APIResponseCollection<"api::about.about">>("about", {
        populate: "deep",
        });
        return response.data.attributes as unknown as About;
};

export const useAbout = () => {
    const about = useState<About>("about_state", () => undefined);
    return {
        about: readonly(about),
        fetchAbout: fetchAbout(),

    };
};

これを使う時は以下のような感じです。

<template>
  <div v-if="about">
    <Heading
      v-if="about.title"
      :title="about.title"
      :description="about.description"
    />

    <Blocks v-if="about.blocks" :blocks="about.blocks" />
  </div>
</template>

<script setup lang="ts">
const { fetchAbout } = useAbout();

const about = await fetchAbout();

useHead({
  title: about?.title,
  meta: [
    { name: "description", content: about?.description },
    { name: "og:title", content: about?.title },
    { name: "og:description", content: about?.description },
  ],
});
</script>

Blocksについて

記事を書くときなどに使われているRich text(Blocks)はstrapiの特徴的な部分でもあると思うのでここだけどのように表示しているかコードを掲載しようと思います。

デフォルトだとブロックの種類が

  • media
  • quote
  • rech text
  • slider

の4つあるので、以下のようにif分で分岐して各ブロックを表示しています。

<template>
  <div v-for="block in blocks">
    <div
      v-if="block.__component === 'shared.rich-text'"
      class="prose max-w-4xl mx-auto py-8 px-3"
    >
      <div v-html="mdRenderer.render(block.body)" />
    </div>
    <div v-else-if="block.__component === 'shared.media'" class="py-8">
      <img
        :src="getStrapiMedia(block.file.data.attributes.url)"
        class="w-screen"
      />
    </div>
    <div v-else-if="block.__component === 'shared.quote'" class="px-3 py-6">
      <blockquote
        class="container max-w-xl border-l-4 border-neutral-700 py-2 pl-6 text-neutral-700"
      >
        <p class="text-5xl font-medium italic">{{ block.body }}</p>
        <cite class="mt-4 block font-bold uppercase not-italic">
          {{ block.title }}
        </cite>
      </blockquote>
    </div>
    <div v-else-if="block.__component === 'shared.slider'">
      <MediaSlider :files="block.files.data" />
    </div>
    <div v-else>{{ block }}</div>
  </div>
</template>

<script setup lang="ts">
import md from "markdown-it";

const mdRenderer = md();

const props = defineProps({
  blocks: {
    type: Array as PropType<any[]>,
    required: true,
  },
});
</script>

バックエンドから持ってきた型の中にblockの型定義もあるようなのですが、使い方がわからずanyとしてしまいました。

nuxtjs/strapiの使い方

nuxtjs/strapiではgraphQLでfetchすることもできますか今回はuseStrapiでfetchしています。

slugを元に記事を取得する場合は以下のようにします。

import type { APIResponseCollection, APIResponseData } from "@/types/types";

type Article = APIResponseData<"api::article.article">;

const fetchArticleBySlug = ()  => async (slug: string): Promise<Article> => {
    const { find } = useStrapi();
    const response = await find<APIResponseCollection<"api::article.article">>("articles", {
        filters: { slug: slug },
        populate: "deep",
        });
        return response.data[0] as unknown as Article;
};

useStrapiのfindを使うこと取得することができます。

説明しなくてもわかると思いますが、filters: { slug: slug }, で該当のslugに当てはまる記事だけ取ってくるようにして、populate: "deep", で階層構造にないっている記事のオブジェクトを全てを取得出来るようになります。確かこれを書かないと2階層くらいまでしか取得されないはずです。

※後からドキュメントを見返したところfindOneというメソッドも用意されていたのでそちらを使ったほうが良かったかも。(https://strapi.nuxtjs.org/usage#findone)

カテゴリーで絞って取得する場合は以下の通りです。

filtersのところやsortのところは好きな値に書き換えて大丈夫です。

const fetchArticlesByCategorySlug = () => async (Categoryslug: string): Promise<Article[]> => {
    const { find } = useStrapi();
    const response = await find<APIResponseCollection<"api::article.article">>("articles", {
        filters: { category: { slug: Categoryslug } },
        populate: "deep",
        sort: ['createdAt:desc'],
        });
        return response.data as unknown as Article[];
};

まとめ

strapiは意外と多くの情報があるので、調べたら大体分かりました。

型のところだけちょっと無理矢理感あるので、もっといい方法があればなと思いました。

後、取得したデータにattributesとかdataという無駄な階層が含まれてしまっているのも結構使いにくかったです。

articleに紐付いているcategoryのnameを取得するときの例: article.attributes.category.data.attributes.name

strapiのプラグインを使うと消せるっぽいけど初めから消しておいてほしいと思いました。

次回デプロイ方法について説明します。

参考サイト

https://strapi.io/blog/improve-your-frontend-experience-with-strapi-types-and-type-script https://strapi.io/blog/how-to-build-a-photo-sharing-app-with-nuxt-3-graph-ql-cloudinary-postgres-and-strapi https://strapi.io/blog/build-a-blog-with-nuxt-vue-js-strapi-and-apollo https://chrisgrime.medium.com/build-a-strapi-blog-with-nuxt-3-f3836ee10bf5