nuxt-jsonld と schema-dts を利用して型安全で宣言的な JSON-LD を定義する

JSON-LD、結構書くの面倒ですよね。細かな仕様を都度 https://schema.org/ に見に行ったり、正しく実装できているかをリッチリザルトテスト(旧構造化データテストツール)で確認したり。

実装面でも SPA の root の外の操作になるので、結構自前で書くのはやりたくないものだったりします。

そんなときに、 nuxt-jsonld + schema-dts を使うと便利ですよ。というのをせっかくなので紹介しようかなと。

このブログも @nuxt/content 製だったりするのですが、私が Web サイトや Web アプリケーションを作成する時は、個人業務問わずほぼ Nuxt.js になっているので、例によって例のごとくそれ前提で話します。

google/schema-dts について

https://github.com/google/schema-dts

schema-dts は、 Google が開発・メンテナンスしている JSON-LD 形式での Schema.org の型定義です。

Generics を使った柔軟な定義が可能な型定義専用のパッケージで、実際構造化データを検索結果に利用している Google 自身が開発していることもあって、実用にあたっての正しいデータ構造を書くのに役立ちます。あといちいち Schema.org 調べなくても補完で key 名が思い出せます。

schema-dts は WithContext<T> の形で Schema が定義できるようになっており、例えば記事ページの構造化データを用意する場合は、 WithContext<Article> とすることで、記事ページのために必要なデータを一通り網羅することができます。

頻出の設定としては Person や Organization、Article や BreadCrumbList あたり。BreadCrumbList なんかはどのページでも定義しておいて損がない情報な割には最後の要素だけ記法が違うことがあったりするので、地味に便利だったりします。

基本的に何のライブラリを使う場合も、あるいは使わない場合も、 schema-dts は導入しておいて損はないかと思います。

schema-dts-gen は利用することがないのでちょっと使用感はわかりません。

nuxt-jsonld

https://github.com/ymmooot/nuxt-jsonld

nuxt-jsonld は、有志によって開発されている、 Nuxt.js で JSON-LD を宣言的に扱うことができるモジュールです。これは Vue Component の this 定義を拡張し、 data や methods, computed と同じレイヤーに jsonld() {} を生やすことで、それをルーティングに沿って <head> 内に出力してくれるパッケージとなっています。

基本的にオプションなどはほとんどないので、 README に沿って以下のような Plugin を定義するだけで OK です。

// plugins/jsonld.js
import Vue from 'vue';
import NuxtJsonld from 'nuxt-jsonld';

// you can set the indentation
Vue.use(NuxtJsonld, {
  space: process.env.NODE_ENV === 'production' ? 0 : 2, // default: 2
});

ちなみに、私は他のプラグインとの統一の意味でも、 TS ファイルとしてこうすることが多いです。

// ~/src/plugins/json-ld.ts
import Vue from 'vue'
import NuxtJsonld from 'nuxt-jsonld'
import { Plugin } from '@nuxt/types'

const JSONLDPlugin: Plugin = () => {
  Vue.use(NuxtJsonld, {
    space: process.env.NODE_ENV === 'production' ? 0 : 2
  })
}

export default JSONLDPlugin

その上で pages/**/*.vuejsonld() { } で Object を return してやると、こんな感じで定義が head に出力されます。

Image from Gyazo

ただ、この nuxt-jsonld 自体には型定義が存在せず、 () => object | null となっているので、微妙に定義として心もとないという課題があります。

// https://github.com/ymmooot/nuxt-jsonld/blob/master/src/index.ts#L6-L11

declare module 'vue/types/options' {
  interface ComponentOptions<V extends VueT> {
    jsonld?: () => object | null;
    head?: MetaInfo | (() => MetaInfo);
  }
}

実際は WithContext<T> | WithContext<T>[] | null が利用可能であるはず。

そこでこのあたりは schema-dts で補強してやると、丁度よい塩梅となります。

組み合わせての利用例

組み合わせてこの点をカバーする手法自体は ComponentOptions を書き換えて jsonldjsonld<T> とするなどいくつか手段はあるのですが、 JSON-LD の場合はページによって要求される構造がかなり違ったりするほか、特定のページでしか必要ないことも多くあるので、ページごとに型を定義してしまうでも良い気がします。

実際にこのブログは /entry/_slug.vue に対してパンくずリストと記事の構造化データを付与したかったので、以下のように定義しています。

import { defineComponent } from '@vue/composition-api'
import { Entry } from '~/types/struct'
import { WithContext, Article, BreadcrumbList } from 'schema-dts'

export default defineComponent({
  jsonld(): [WithContext<Article>, WithContext<BreadcrumbList>] {
    const entry = this.entry as Entry
    const url = `https://d.potato4d.me${entry.path}`
    const title = `${entry.title} | potato4d D(iary)`
    const imageUrl = 'https://d.potato4d.me/opengraph.png'
    const createdAt = entry.publishedAt
    return [
      {
        '@context': 'https://schema.org',
        '@type': 'Article',
        headline: title,
        description: entry.description,
        url,
        mainEntityOfPage: {
          '@type': 'WebPage',
          '@id': url,
        },
        image: [imageUrl],
        author: {
          '@type': 'Person',
          name: 'potato4d / Takuma HANATANI',
          url: `https://potato4d.me`,
        },
        publisher: {
          '@type': 'Person',
          name: 'potato4d / Takuma HANATANI',
          url: `https://potato4d.me`,
        },
        datePublished: createdAt,
        dateModified: createdAt,
      },
      {
        '@context': 'https://schema.org',
        '@type': 'BreadcrumbList',
        itemListElement: [
          {
            '@type': 'ListItem',
            position: 1,
            name: 'トップページ',
            item: 'https://d.potato4d.me/',
          },
          {
            '@type': 'ListItem',
            position: 2,
            name: entry.title,
            item: url,
          },
        ],
      },
    ]
  },
})

こんな感じにしておくだけで、構造化データの実装ミスに気づけて結構便利だったり。

勿論最終的にはリッチリザルトテストを通すことを推奨しますが、開発時に効率的に定義することや、追加や変更があったときにリリースして検索結果に反映されてしまう前にいち早く気づけるという点で、普通にプログラミングをしているとき以上に型の恩恵を強く受けられる気がします。

SEO 系はこういうのあんまりやってる人が多くないので導入したときの DX の良さに感動しがち。