Open10

Nuxt3(SSG) + prismic + bunで無料ブログ作成(知識・準備編)

yunayuna

Nuxt3(frontend framework)とprismic(headless CMS)の無料枠、bun(javascript runtime)で、
無料ブログ作成をするメモです。

私の開発環境はlinux mint(ubuntu系)ですが、
最近bunがwindows対応したので(v1.1〜)、基本windows環境にも対応しているはずです。
ブログサイトは、SSG(事前に静的ファイルに固め、S3やnetlifyなどにデプロイして使います)です。

過去の記事など見ながら進めて見たのですが
prismicも進化が進み、古めの情報が多かったりしてうまく動かなかったので、
2024/4/24時点で、きちんと動作するまでの検証内容をメモしておきます。

▼各種公式

headless CMS prismic
https://prismic.io/

frontend framework
https://nuxt.com/

Bun — A fast all-in-one JavaScript runtime
https://bun.sh/

▼参考にした記事
https://zenn.dev/mogami/articles/prismic_jamstack
https://zenn.dev/sterashima78/scraps/24184233423f4b
https://qiita.com/eggpogg_/items/113e376a971421409acb

prismicの無料枠

1ユーザー、100GB、400万API call/月 は大丈夫なので、個人レベルや閲覧の少ないサイトは収まると思います。

yunayuna

事前準備

bun のインストール

省略(bun公式を参照ください)

prismic-cliのインストール

後々色々と使うことがあるので、先に入れておく

bun install -g prismic-cli

prismicのアカウント作成

signupページから、github or email でアカウント作成できます。
https://prismic.io/dashboard/signup?redirectUri=%2Fdashboard

作成完了すると、ダッシュボード画面が表示されます

yunayuna

prismicでの設定(リポジトリの作成)

ダッシュボードのCreate a new repository にて、
Nuxtを選択します。

プロジェクトのスタイルは、今回はMinimal Starterを選択(Selectをクリック)

リポジトリ名(プロジェクト名のようなもの)、表示名(必須ではない)を入力して
Freeプランのまま「Create Project」で進めます

これでリポジトリ(プロジェクトのようなもの)完成

yunayuna

prismic.io の構成について

前提として、
prismicのWebサイトを構成する「Page」「Slice」「Field」を事前に知っておくと理解しやすいです。

・Field:Webページを構成する最も小さい部品
以下のように、リッチテキストや画像など、最小構成のコンポーネントが用意されています
▼Fieldの作成画面

・Slice: Fieldを組み合わせて作ったかたまり
Fieldの組み合わせで、少し大きな構成のコンポーネント、Sliceを作ります。
Sliceは"Non-Repeatable Zone"と、"Repeatable Zone"の2つで構成されています。
▼Sliceの構築画面

・Page: Pageが持つ項目StaticZone)と、Sliceの組み合わせで、Pageを作ります
Pageは、Static zoneとSlice zoneの2つで構成されています。

Static zone:UIDやTitlleなど、Pageに属する項目を設定する場所
Slice zone:作成済みのSliceを複数準備しておく場所

Slice zoneで指定したSliceの内、どれを実際に使うかは、データを入れるタイミングで取捨選択・並べ替えることができるので、
必要になりそうな部品は、この段階で全て用意しておくと良いです。

▼Pageの構築画面

・作成したslice部品を、Webの部品としてHTMLコーディングします。

・Pageに対して、実データを登録する
例:上記で作成したPageやSliceの各項目、フィールドに、実際のデータを入力します

・自動的に入力したデータが読み込まれ、Webページが表示されます
例:ブログ用のsliceを埋め込んだページに、入力したデータが表示され、ブログとして閲覧できる

yunayuna

ローカルに、prismicベースのNuxtプロジェクトを生成

prismic側の作業

prismicの管理画面で、作成したリポジトリのページのトップ画面を表示します。
https://<リポジトリ名>.prismic.io/documents/working?l=en-us

ここで、
「Get started with Slice Machine」ボタンをクリックします。

すると、ローカルでprismicの各種ツールをインストールするコマンドが表示されます。

ここではnpxが表示されていますが、bunxを利用します。

ローカル側の作業

プロジェクト用のディレクトリを用意し、移動

mkdir ~/Project/sample120
cd ~/Project/sample120

上記、prismicに表示されているコマンド
npx @slicemachine/init@latest --repository sample120 --starter nuxt-starter-prismic-minimal

npxをbunxに変えて、実行します。

$ bunx @slicemachine/init@latest --repository sample120 --starter nuxt-starter-prismic-minimal

 Slice Machine  → Initializing
✔ Starter copied

ℹ We collect telemetry data to improve user experience.
  Learn more: https://prismic.dev/slice-machine/telemetry

✔ Detected framework Nuxt 3 and package manager npm
✔ Began core dependencies installation with npm ... (running in background)
✔ Selected repository sample120 (flag repository used)
✔ Installed core dependencies with npm
✔ Updated Slice Machine configuration and loaded adapter

Logging in to Prismic...
press any key to open the broweser to login..

ログイン中...というところでストップするので、Enterを入力すると、
デフォルトブラウザが立ち上がり、prismicのログイン画面に遷移します。
※braveブラウザを使っていてエラーになる事が有りました。
その場合はシールドをOFFにすることでログインできました。

✔ Logged in as rkp37980@romog.com
✔ Synced data with Prismic
✔ Initialized project (patched package.json scripts)
✔ Initialized adapter

 Slice Machine  → Initialization successful!

Continue with next steps in Slice Machine.

? Run Slice Machine (npm run slicemachine)? › (Y/n)

最後に、slice machineを実行するか?と聞かれます。
npmコマンドが表示されていますが、今回はbunを使うので、"n"をタイプして、キャンセルします。

 Slice Machine  → Initialization successful!

Continue with next steps in Slice Machine.

リポジトリ名sample120でディレクトリが作成され、Nuxt3のファイル群に、prismic用のディレクトリ・ファイルが追加された状態で展開されます。

├── customtypes (prismic用ディレクトリ)
├── documents (prismic用ディレクトリ)
├── LICENSE
├── node_modules
├── nuxt.config.ts
├── package-lock.json
├── package.json
├── pages
├── prismicio-types.d.ts (prismic用ファイル)
├── public
├── README.md
├── server
├── slicemachine.config.json (prismic用ファイル)
├── slices (prismic用ディレクトリ)
└── tsconfig.json

nuxt.config.tsにprismicの記述が追記されています。
prismicのコンテンツを使いたいページは、下記 routesにパスを追記する必要が有ります。
(ここに記述がないパスでは、prismicのデータが取得できないので、都度追記が必要です)

nuxt.config.ts
    modules: [
        "@nuxtjs/prismic",
    ],
    prismic: {
        endpoint: 'sample120',
        preview: '/api/preview',
        clientConfig: {
            routes: [
                {
                  type: 'page',
                  path: '/:uid',
                },
                {
                  type: 'page',
                  uid: 'home',
                  path: '/',
                },
              ]
          },
    },
yunayuna

prismicのslice machineを起動する

slice machineとは?

GUI上で、Webサイトの部品となるsliceの定義を構築し、
prismicサーバーに設定を保存すると同時にNuxt用のコンポーネントを生成できる機能で、
ローカルで起動します。

slice machineの起動

slice machineは、すでに生成したNuxtプロジェクトに組み込まれています。

Nuxtプロジェクトのディレクトリ生成が完了したら、
ディレクトリに移動して、以下コマンドでslice machineを起動します。(デフォルト:ポート9999で起動されます)
※ファイル生成時に、package.jsonのscriptsに、prismicのslicemachine実行用のコマンドが記述されています

$ bun run slicemachine
 start-slicemachine
 Slice Machine v1.26.0  → Running at http://localhost:9999

ブラウザで http://localhost:9999 を開きます

今回、minimal starterでプロジェクト生成したので、
すでに最小限の構成でPage、Sliceが生成されてます。

Sliceの一覧

作成済みのSlice:RichTextの詳細。
"Non-Repeatable Zone"にContentという一部品で構成されています。

これに対応するNuxtのコンポーネント

ファイルの中身(※このファイルは、slice machineでの更新と連動して自動作成されますが、そのあとで細かいcssの指定などカスタマイズして使います。)
公式のサンプルを見ると、slice側ではデザインの指定ができないので、一つのsliceの中で複数のvaliationを作っておき、ここでデザインによる差別化をするという使い方もしていました。

slices/RichText/index.vue
<script setup lang="ts">
import { type Content } from '@prismicio/client'

// The array passed to \`getSliceComponentProps\` is purely optional.
// Consider it as a visual hint for you when templating your slice.
defineProps(getSliceComponentProps<Content.RichTextSlice>(
  ['slice', 'index', 'slices', 'context']
));
</script>

<template>
  <section>
    <PrismicRichText
      :field="slice.primary.content"
      class="richtext"
    />
  </section>
</template>

<style scoped>
section:deep(.richtext) {
  max-width: 600px;
  margin: 6em auto;
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica,
    Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
}

section:deep(.richtext .codespan) {
  font-family: monospace;
}
</style>

Pageの一覧
すでに「Page」という名前のPageが1つ作成されています。

中身を確認します。
Static Zoneと、Slice Zoneの2つの枠があり、

Static Zoneに:UIDとTitleというフィールド
Slice Zoneに:slice(RichText)が1つ
登録されています。

Pageに対応したコード

Typescriptの型定義ファイル

(※このファイルも自動作成されます。手動で更新することはなさそう)

定義されたNamespace

prismicio-types.d.ts
  namespace Content {
    export type {
      PageDocument,
      PageDocumentData,
      PageDocumentDataSlicesSlice,

実装部分

prismicio-types.d.ts
type PageDocumentDataSlicesSlice = RichTextSlice;

/**
 * Content for Page documents
 */
interface PageDocumentData {
  /**
   * Title field in *Page*
   *
   * - **Field Type**: Title
   * - **Placeholder**: *None*
   * - **API ID Path**: page.title
   * - **Tab**: Main
   * - **Documentation**: https://prismic.io/docs/field#rich-text-title
   */
  title: prismic.TitleField;

  /**
   * Slice Zone field in *Page*
   *
   * - **Field Type**: Slice Zone
   * - **Placeholder**: *None*
   * - **API ID Path**: page.slices[]
   * - **Tab**: Main
   * - **Documentation**: https://prismic.io/docs/field#slices
   */
  slices: prismic.SliceZone<PageDocumentDataSlicesSlice>;
}

/**
 * Page document from Prismic
 *
 * - **API ID**: `page`
 * - **Repeatable**: `true`
 * - **Documentation**: https://prismic.io/docs/custom-types
 *
 * @typeParam Lang - Language API ID of the document.
 */
export type PageDocument<Lang extends string = string> =
  prismic.PrismicDocumentWithUID<Simplify<PageDocumentData>, "page", Lang>;

各Pageの定義ファイル

Pageという名称のPage構成(static zoneのfieldと、slice zoneの構成)を定義したファイル

index.json
{
  "id": "page",
  "label": "Page",
  "format": "page",
  "repeatable": true,
  "status": true,
  "json": {
    "Main": {
      "uid": { "type": "UID", "config": { "label": "UID", "placeholder": "" } },
      "title": {
        "type": "StructuredText",
        "config": {
          "label": "Title",
          "placeholder": "",
          "allowTargetBlank": true,
          "single": "heading1"
        }
      },
      "slices": {
        "type": "Slices",
        "fieldset": "Slice Zone",
        "config": { "choices": { "rich_text": { "type": "SharedSlice" } } }
      }
    }
  }
}
yunayuna

prismic管理画面側を確認

定義したSliceやPage(Webサイトの構造データ)は、オンライン側(prismic管理ページ)にも同期していますが、
構造に応じたコンテンツ(データセット)は、ローカルで持つ必要がないので
オンラインのみで保持する情報となります。

ダッシュボードにアクセスすると、
作成したリポジトリが表示されています。
https://prismic.io/dashboard

作成した「サンプルプロジェクト」をクリックすると、
「Documents」のリストが表示されます。

Documentとは、作成したPageの型を使って、実際のデータをセットした生きたWebページのコンテンツ。
つまり、Pageが箱であるのに対して、Documentは箱に合わせて準備したデータセットにあたります。

Documentsは、Work(稼働中), Planned(稼働予定), Archive(クローズしたもの)とステータス別にリスト確認できるようになっています。

Minimal Startartで作成したので、デフォルトですでにDocumentが登録されているのが確認できます。
こちらの中身を見ていきます。

Pageで定義してある2つのstatic値、
・UID(文字列)
・Title()
の値と、
1つのSlice(RichText・Default)のデータが登録されています。

ここまでで、最も基本的な構造について、一通り紹介しました。

yunayuna

Nuxtのプロジェクトページ内で、prismicのコンテンツを使う

ここまでで生成されたprismicのコンポーネントを、
Nuxt内で実際に使われている箇所をチェックしていきます。

nuxtでは、pagesディレクトリの下の構造が、そのままWebサイトのURLに反映される形でルーティングされます。
※ファイル名が[変数名].vue だと、URLの該当箇所に任意の値を使うことができ、その値をファイル内で受け取って表示内容を動的に切り替えることができます。(詳細は公式 https://nuxt.com/docs/getting-started/routing)

2つのページがデフォルトで生成されていますので、中身を確認。
デフォルトで、登録されていたdocument(データセット)の"home"というUIDを指定して、
page情報を取得。
そこから<header title>タグで使いたいTitle名を取り出したり、
slize zoneで定義されたコンポーネントを<SliceZone> を使って配置しています。

index.vue
<script setup lang="ts">
import { components } from '~/slices'

const prismic = usePrismic()
const { data: page } = useAsyncData('index', () =>
  prismic.client.getByUID('page', 'home')
)

useHead({
  title: prismic.asText(page.value?.data.title)
})
</script>


<template>
  <SliceZone
    wrapper="main"
    :slices="page?.data.slices ?? []"
    :components="components"
  />
</template>

上記と同じ内容を表示していますが、
"home"というdocument IDを、ベタ書きではなく、URLから受け取ってセットしています。

[uid].vue
<script setup lang="ts">
import { components } from '~/slices'

const prismic = usePrismic()
const route = useRoute()
const { data: page } = useAsyncData(route.params.uid as string, () =>
  prismic.client.getByUID('page', route.params.uid as string)
)

useHead({
  title: prismic.asText(page.value?.data.title)
})
</script>


<template>
  <SliceZone
    wrapper="main"
    :slices="page?.data.slices ?? []"
    :components="components"
  />
</template>

プロジェクトディレクトリで、開発モードでnuxtを実行しますが、
前に記述した「slice machine」の実行(bun run slicemachine)は、
package.jsonで一緒に実行されるようにセットされていますので、slice machineが単体で起動している場合は一度停止しておきます。
(純粋にnuxtだけ起動する場合は、 bun run nuxt:devです)

package.json
  "scripts": {
    "dev": "concurrently \"npm:nuxt:dev\" \"npm:slicemachine\" --names \"nuxt,slicemachine\" --prefix-colors green,magenta",
    "nuxt:dev": "nuxt dev",
    "slicemachine": "start-slicemachine",

nuxt devモードで起動

$ bun run dev

$ concurrently "npm:nuxt:dev" "npm:slicemachine" --names "nuxt,slicemachine" --prefix-colors green,magenta
[slicemachine] 
[slicemachine] > nuxt-starter-prismic-minimal@0.0.0 slicemachine
[slicemachine] > start-slicemachine
[slicemachine] 
[nuxt] 
[nuxt] > nuxt-starter-prismic-minimal@0.0.0 nuxt:dev
[nuxt] > nuxt dev
[nuxt] 
[nuxt] Nuxt 3.11.1 with Nitro 2.9.5
[nuxt] 
[nuxt]   ➜ Local:    http://localhost:3000/
[nuxt]   ➜ Network:  use --host to expose
[nuxt] 
[nuxt] [nuxt:prismic] ℹ Using default preview page, available at /api/preview
[nuxt]   ➜ DevTools: press Shift + Alt + D in the browser (v1.1.4)
[nuxt] 
[slicemachine] 
[slicemachine]  Slice Machine v1.26.0  → Running at http://localhost:9999
[slicemachine] 
[nuxt] ℹ Vite server warmed up in 1914ms
[nuxt] ℹ ✨ new dependencies optimized: @slicemachine/adapter-nuxt/simulator, @unhead/vue, @prismicio/client
[nuxt] ℹ ✨ optimized dependencies changed. reloading
[nuxt] ℹ Vite client warmed up in 2374ms
[nuxt] [nitro] ✔ Nuxt Nitro server built in 722 ms

http://localhost:3000 似アクセスして、ページをチェック

実際のページ(Title部分)

実際のページ(画面)

prismic管理画面で見た、documentの構成

yunayuna

Page,Slice以外の部品 "Custum types"

まだ扱っていなかった部品として、
Page,Sliceの間に、Custum typesというものがある。

これは、Pageのように、複数のStaticフィールド,Sliceで成り立つ構造を定義するものだが、
データ構造を取り出して直接Nuxtのファイル内で扱うデータセットや、
小さいコンポーネントを格納するのに使うイメージ。
(Sliceを使わずStaticフィールドの固まりを定義するだけでも良い)

・ナビゲーションメニューに表示するメニュー構成
・サイトのタイトル文字列
など、データとして保管しておきたい情報を格納する箱として定義したり、
いろんなページで利用する、データ付きの小さいコンポーネントを格納するような感じ。

slice machineで、Custum typesを定義

LabelとLink先のURLを保持する"Group"を用意し、その中にlavelとlinkフィールドを保持する

prismic管理画面側の設定

prismic管理画面上では、Groupフィールドは、自動的にRepeatable fieldとして展開されます。
ここでは、例として3つのメニュー構造でデータ登録しています。

Nuxtのコード上の設定

typescriptの型を定義する prismicio-types.d.tsに、自動的に追加されることと、
以下のようにcustumtypesディレクトリに要素が自動的に連携・追加されます。

custumtypes/navigation/index.json
{
  "id": "navigation",
  "label": "Navigation",
  "format": "custom",
  "repeatable": false,
  "status": true,
  "json": {
    "Main": {
      "links": {
        "type": "Group",
        "config": {
          "label": "Links",
          "fields": {
            "label": {
              "type": "StructuredText",
              "config": {
                "label": "Label",
                "placeholder": "Optional - Label for the link",
                "allowTargetBlank": false,
                "single": "heading3"
              }
            },
            "link": {
              "type": "Link",
              "config": {
                "label": "Link",
                "placeholder": "Link for navigation item",
                "select": null
              }
            }
          }
        }
      }
    }
  }
}

ただし、上記、自動で生成されるファイルだけでは使えなくて、
自分でcomposableにcustumTypesを扱うための関数を準備する必要が有ります。

composables/useNavigation.ts
export const useNavigation = () => {
  const prismic = usePrismic()
  return useAsyncData('$navigation', () => prismic.client.getSingle('navigation')).data
}

ページ内の記述

components/Header.vue
<script setup lang="ts">
const navigation = useNavigation()
</script>

<template>
  <Bounded
    as="header"
    y-padding="sm"
  >
    <div
      class="flex flex-wrap items-baseline justify-between gap-x-6 gap-y-3 leading-none"
    >
      <nav>
        <ul
          class="flex flex-wrap gap-6 md:gap-10"
        >
          <li
            v-for="item in navigation?.data.links"
            :key="$prismic.asText(item.label) || ''"
            class="font-semibold tracking-tight text-slate-800"
          >
            <PrismicLink
              :field="item.link"
            >
              {{
                $prismic.asText(item.label)
              }}
            </PrismicLink>
          </li>
        </ul>
      </nav>
    </div>
  </Bounded>
</template>