サイトマップを HTML に変換する

Nuxt.js
公開日:

@nuxtjs/sitemap モジュールでは、sitemap.xslUrl プロパティを指定することで XSL ファイルを使うことができます。

これを使うと、sitemap.xml をブラウザで開いた時に HTML 形式で表示させることができます。

XSL は Extensible Stylesheet Language の略で、XSLT や XPath などからなるスタイルシート言語です。

XSLT は XML を異なる構造の XML や HTML に変換するもので、XPath は CSS セレクタのように XML の特定の部分を指定するものです。

ウェブブラウザは XML ファイルに XSL ファイルを使う旨の記述があると、その XSL ファイルを使って変換された XML や HTML を表示します。HTML と同じように、XSL の解釈もブラウザ側の XSL プロセッサーに委ねられます。

基本的にブラウザは XSLT 1.0 しか解釈しません。XSLT 2.0 や 3.0 は勧告されているものの、これらをネイティブにサポートする主なブラウザはありません。

正直今から習得する必要はまるでない言語ですが、プログラミングっぽいこともできて面白い部分もあるので、興味があれば触れてみてください。

サイトマップに XSL を適用する

// nuxt.config.js
export default {
  modules: [
    '@nuxtjs/sitemap',
  ],
  sitemap: {
    // hostname とかは割愛
    xslUrl: 'sitemap.xsl',
    xmlNs: 'xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"', // 元々の長ったらしい名前空間宣言を上書き
  },
}

これで出力される sitemap.xml に sitemap.xsl ファイルを使う旨の記述が追加されます。改行して見やすくしていますが、実際は 1 行で出力されます。

<!-- dist/sitemap.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<?xml-stylesheet type="text/xsl" href="sitemap.xsl"?><!-- ← これ -->
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
  <url>
    <loc>https://jamblog-beryl.vercel.app/</loc>
  </url>
</urlset>

sitemap.xsl は、sitemap.xml と同階層になるように、static フォルダ直下に用意します。XSL も XML で記述します。

<!-- static/sitemap.xsl -->
<?xml version="1.0" encoding="UTF-8"?>
<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
  <xsl:template match="/">
  </xsl:template>
</xsl:stylesheet>

ここで静的サイト生成してから dist を提供させます。

$ npx nuxt generate && npx nuxt start

いちいち static/sitemap.xsl を変更しては静的サイト生成していたのでは日が暮れてしまうので、dist/sitemap.xsl を直接変更していきます。

いい感じになったら static/sitemap.xsl にコピペするのをお忘れなく。

/sitemap.xml(/sitemap.xsl ではない)にアクセスすると、真っ白なものが表示されるかと思います。まだこの段階では DOCTYPE 宣言すら出力していないので、ページですらありません。

まずは HTML として認識されるようにするため、コードを追加します。

<!-- dist/sitemap.xsl -->
<?xml version="1.0" encoding="UTF-8"?>
<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
  <!-- <!DOCTYPE html SYSTEM "about:legacy-compat"> を出力 -->
  <xsl:output method="html" version="5.0" encoding="UTF-8" doctype-system="about:legacy-compat" />
  <xsl:template match="/">
    <html lang="ja">
      <head>
        <meta charset="utf-8" /><!-- 飽く迄 XML なので空タグを閉じねばならない -->
        <title>sitemap</title>
      </head>
      <body>
        <h1>サイトマップ</h1>
      </body>
    </html>
  </xsl:template>
</xsl:stylesheet>

XSL は古いので、<!DOCTYPE html> を出力できません。そのような場合は、代わりに <!DOCTYPE html SYSTEM "about:legacy-compat"> を出力します。

<xsl:template match="/"> はルート(/)にマッチするテンプレートなので、一度だけ表示されます。

サイトマップをリストで表示する

サイトマップはルート要素として <urlset> があり、その子として <url> がある構成ですので、先程のテンプレートを「ルートの <urlset>」にマッチするように変更してみます。

<xsl:template match="/urlset">
  <!-- 省略 -->
</xsl:template>

すると、サイトマップにある URL 文字列が吐き出されてしまいます。おまけに先程設定した <title><h1> なども消えてしまっています。どうも <xsl:output> を設定した状態で、マッチするテンプレートがなかった時にこのような表示になってしまうようです。

実は、<urlset><url>, <loc> などのサイトマップ用の要素を使うときは、名前空間を指定してやる必要があります。<xsl:stylesheet> の開始タグを変更します。

<!-- サイトマップ用の要素を「sm」名前空間で使う -->
<xsl:stylesheet
  version="1.0"
  xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
  xmlns:sm="http://www.sitemaps.org/schemas/sitemap/0.9"
  exclude-result-prefixes="sm"
>

これで、サイトマップ用の要素の前に sm: を付けることで、その要素を指定することができるようになりました。

<xsl:template match="/sm:urlset">
  <!-- 省略 -->
</xsl:template>

次に、<url> 要素用のテンプレートを作ります。先程のテンプレートでは「ルートの <urlset>」を指定しましたが、<url> はルート要素ではないので、単に sm:url とします。

子の <loc> のデータを出力したいので、<xsl:value-of> を使って書き出します。

<xsl:template match="/sm:urlset">
  <!-- 省略 -->
</xsl:template>
<xsl:template match="sm:url">
  <li>
    <a><xsl:value-of select="sm:loc" /></a>
  </li>
</xsl:template>

これでリスト表示されると思われるかも知れませんが、表示されているテンプレートから呼び出されないと、描画されません。

sm:urlset の方のテンプレートを変更します。

<xsl:template match="/sm:urlset">
  <html lang="ja">
    <!-- 省略 -->
    <body>
      <h1>サイトマップ</h1>
      <ul>
        <xsl:apply-templates />
      </ul>
    </body>
  </html>
</xsl:template>

<xsl:apply-templates /> は、現在の要素(=ルートの <urlset>)の子要素にマッチするテンプレートを適用します。

ここでは <urlset> の子である <url> にマッチするテンプレートが定義されているので、その数だけリストタグが出力されます。

明示的に <xsl:apply-templates select="sm:url" /> と書くこともできます。

データを使った属性を設定する

sm:url のテンプレートでは、URL が書き出されているものの、href 属性がないためリンクになっていません。

XSL で属性を設定するには、属性を設定する要素の子要素として <xsl:attribute name="属性名"> を使います。このタグの中身が属性値として設定されます。

<xsl:template match="sm:url">
  <li class="url"><!-- 固定値の属性は普通に書く -->
    <a>
      <xsl:attribute name="href"><!-- 属性名を指定する -->
        <xsl:value-of select="sm:loc" /><!-- 中身が属性値となる -->
      </xsl:attribute>
      <xsl:value-of select="sm:loc" /><!-- こちらはただの文字列 -->
    </a>
  </li>
</xsl:template>

条件分岐する

このブログでは、記事の場合のみ <lastmod> を書き出しています。<xsl:if> を使うと、データがあれば表示するといったことができます。

<xsl:template match="sm:url">
  <li class="url">
    <a>
      <!-- 省略 -->
    </a>
    <xsl:if test="sm:lastmod">
      <p>
        <xsl:text>更新日時:</xsl:text><!-- タグなしだとホワイトスペースが生まれる -->
        <time>
          <xsl:attribute name="datetime">
            <xsl:value-of select="sm:lastmod" />
          </xsl:attribute>
          <xsl:value-of select="sm:lastmod" />
        </time>
      </p>
    </xsl:if>
  </li>
</xsl:template>

日付を処理する関数みたいなものを作る

このブログでは、<lastmod> の値は協定世界時(UTC) のタイムゾーンに基づいているので、日本標準時(JST)に合わせるには 9 時間進める必要があります。

ここでは、引数として <lastmod> の値を与えると、Y年n月j日 H:i の形式でフォーマットする関数(テンプレート)を作ってみます。

<xsl:template> に match 属性を指定せず、name 属性を指定します。引数は <xsl:param> で定義します。

<xsl:template name="formatDate">
  <xsl:param name="date" select="'1970-01-01T00:00:00.000Z'" /><!-- select 属性値はデフォルト値になる -->
  <xsl:value-of select="$date" /><!-- $引数名 で引数の値にアクセスできる。値を書き出すことが return みたいなもの -->
</xsl:template>

<xsl:call-template name="テンプレート名"> で任意の場所に呼び出せます。引数を渡さなかった場合は、デフォルトの 1970-01-01T00:00:00.000Z が表示されます。

<xsl:template match="sm:url">
  <li class="url">
    <!-- 省略 -->
    <xsl:if test="sm:lastmod">
      <p>
        <xsl:text>更新日時:</xsl:text>
        <time>
          <xsl:attribute name="datetime">
            <xsl:value-of select="sm:lastmod" />
          </xsl:attribute>
          <xsl:call-template name="formatDate">
            <xsl:with-param name="date" select="sm:lastmod" /><!-- 引数を渡す -->
          </xsl:call-template>
        </time>
      </p>
    </xsl:if>
  </li>
</xsl:template>

$date を処理して日付をフォーマットします。

<xsl:variable> を使って、$date から得た年月日などを変数に入れます。<xsl:param> と似ていますが、こちらは他から上書きされることはありません。変数も $変数名 でアクセスできます。

<xsl:template name="formatDate">
  <xsl:param name="date" select="'1970-01-01T00:00:00.000Z'" />

  <!-- $date の 1 文字目から 4 文字分切り出した文字列を変数 $yr に入れる -->
  <xsl:variable name="yr" select="substring($date, 1, 4)" />

  <!-- $date の 6 文字目から 2 文字分切り出して、数値に変換したものを変数 $mo に入れる -->
  <!-- 01-12 ではなく 1-12 になってほしいので、数値に変換している -->
  <xsl:variable name="mo" select="number(substring($date, 6, 2))" />
  <xsl:variable name="d" select="number(substring($date, 9, 2))" /><!-- 日は 1-31 -->

  <!-- 時に 9 を足したものを変数 $hr に入れる(+ で数値に変換されている)-->
  <xsl:variable name="hr" select="substring($date, 12, 2) + 9" />
  <xsl:variable name="min" select="substring($date, 15, 2)" /><!-- 分は 00-59 -->
</xsl:template>

ここから、$hr の値が 24 以上なら日付を 1 日進めるようにして、さもなくば単にフォーマットして値を返します。

先程の <xsl:if> を使いたいところですが、なんと elseelse if にあたるものがありません。この場合は <xsl:choose>, <xsl:when>, <xsl:otherwise> を使います。

<xsl:template name="formatDate">
  <!-- 省略 -->
  <xsl:variable name="hr" select="substring($date, 12, 2) + 9" />
  <xsl:variable name="min" select="substring($date, 15, 2)" />
  <xsl:choose>
    <xsl:when test="$hr >= 24">
      <!-- 日付を 1 日進めてフォーマットする処理 -->
    </xsl:when>
    <xsl:otherwise>
      <xsl:value-of select="concat($yr, '年', $mo, '月', $d, '日 ', $hr, ':', $min)" />
    </xsl:otherwise>
  </xsl:choose>
</xsl:template>

条件文では馴染みのある大なりイコールや小なりが使えますが、小なり(<)系はエスケープする必要があります。以下のような感じになり見づらいです。

(一般的な &lt にしたかったのですが入稿できず。#60lt と読み替えてください)

<xsl:when test="23 &#60; $hr" /><!-- 小なり -->
<xsl:when test="24 &#60;= $hr" /><!-- 小なりイコール -->
<xsl:when test="$hr > 23" /><!-- 大なり -->
<xsl:when test="$hr >= 24" /><!-- 大なりイコール -->
<xsl:when test="$hr = 9" /><!-- 等しい(==, === ではない) -->
<xsl:when test="$hr != 9" /><!-- 等しくない -->

日付を 1 日進める場合、年が変わる場合と月が変わる場合があります。前者は12月31日かどうかを判断すれば済みますが、後者は少し厄介です。

後者には、以下のような場合があります。

  • 12月以外 の 31日
  • 4, 6, 9, 11月 の 30日
  • 2月29日
  • 閏年でない2月28日

日本では、閏年に関する法令があるらしく、神武天皇即位紀元年数(紀元前 660 年 を紀元として数える=皇紀年数)を 4 で割り切れる年を閏年とするが、皇紀年数から 660 を引いてから 100 で割って、更にその商が 4 で割り切れない年は平年とするとしています。

ややこしいですが、要は西暦での年数が 4 の倍数であり、かつ 100 の倍数ではない、または 400 の倍数である年が閏年であるということです。

これらを <xsl:when> に落とし込んで、それぞれからフォーマットした日付を書き出します。「かつ」は and、「または」は or、論理否定は not()、剰余は mod です。

<xsl:template name="formatDate">
  <!-- 省略 -->
  <xsl:choose>
    <xsl:when test="$hr >= 24">
      <xsl:variable name="time" select="concat($hr - 24, ':', $min)" />
      <xsl:choose>
        <xsl:when test="$d = 31 and $mo = 12">
          <xsl:value-of select="concat($yr + 1, '年1月1日 ', $time)" />
        </xsl:when>
        <xsl:when test="$d = 31 or ($d = 30 and ($mo = 4 or $mo = 6 or $mo = 9 or $mo = 11))">
          <xsl:value-of select="concat($yr, '年', $mo + 1, '月1日 ', $time)" />
        </xsl:when>
        <xsl:when test="$d = 29 and $mo = 2">
          <xsl:value-of select="concat($yr, '年3月1日 ', $time)" />
        </xsl:when>
        <xsl:when test="$d = 28 and $mo = 2 and not($yr mod 4 = 0 and $yr mod 100 != 0 or $yr mod 400 = 0)">
          <xsl:value-of select="concat($yr, '年3月1日 ', $time)" />
        </xsl:when>
        <xsl:otherwise><!-- 上記に当てはまらなければ単純に日を加算する -->
          <xsl:value-of select="concat($yr, '年', $mo, '月', $d + 1, '日 ', $time)" />
        </xsl:otherwise>
      </xsl:choose>
    </xsl:when>
    <!-- 省略 -->
  </xsl:choose>
</xsl:template>

全体のコードは /sitemap.xsl で確認することができます。

また、このブログの /sitemap.xml では、XPath を使って書き出す要素をフィルタしています。こちらも XSL のソースを見てもらえれば、何となく分かるのではないかと思います。