シングルページアプリにDiscourseのコメントを埋め込む

シングルページアプリケーション(SPA)で [1] を実装しようとすると、サーバーサイドレンダリング(SSR)ページを提供しない場合、最終的に問題が発生します。詳細は [2] を参照してください。

そこで、少し試行錯誤した結果、以下の方法を提案したいと思います。例は Vue.js ですが、他のフレームワーク/ライブラリにも簡単に適応できます。

: ここでは、Discourse コメントセクションを埋め込む場所を「ブログ記事」という用語で表します。もちろん、これはサイトの個々のページを意味する場合もあります。

1. [1] の問題点

1.1. javascripts/embed.js はクライアントサイドレンダリングコンテンツでは機能しない

[1] で HTML に挿入するように指示されている <script>...</script> スニペットは、ここでアプローチしている実装では使用しません。Discourse インスタンスが提供する javascripts/embed.js の一部を、SPA 内の関数として利用します。

1.2. Discourse はクライアントサイドレンダリングコンテンツをスクレイピングできない

Discourse は、各ブログ記事に対して自動的にトピックを作成し、タイトルとコンテンツを決定するために元の URL(ブログ記事の)にアクセスしようとします。これは SPA では失敗します。Discourse は、JavaScript が有効になっていない場合の非 JavaScript 部分、つまり「申し訳ありませんが、このサイトは JavaScript が有効になっていないと正しく機能しません。続行するには有効にしてください。」というメッセージを取得するためです。

必要なデータを提供し、トピックを作成するために、RSS ポーリングプラグインを利用します。

2. 実装

2.1 RSS ポーリングと RSS/Atom フィード

サイトに、RSS ポーリングプラグイン用の RSS または Atom フィードを提供するエンドポイントを作成します。このエンドポイントは、静的な XML 形式のファイルでも、XML 形式のコンテンツを提供するサーバーサイド関数でも構いません。例:

URL: https://mysite.com/blog.atom

コンテンツ:

<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">

  <title>My Site Blog Posts</title>
  <link href="https://mysite.com/blog/"/>
  <updated>2022-07-03T09:02:48.721Z</updated>
  <id>urn:uuid:790c1857-b968-49cc-9fbd-bf7afe3552c2</id>

  <entry>
    <title>An Article about Technology</title>
    <author>
      <name>Your Name Here</name>
    </author>
    <link href="https://mysite.com/blog/an-article-about-technology"/>
    <id>urn:uuid:f6cc13e4-d2eb-4385-af28-c867a94f48dc</id>
    <published>2022-07-03T00:00:00Z</published>
    <updated>2022-07-03T00:00:00Z</updated>
    <summary>Let's discuss some technology in this article.</summary>
  </entry>

</feed>

Discourse で RSS ポーリングプラグインを [3] (Discourse ホスト) または [4] (セルフホスト) に従ってインストールします。

Admin → Settings → Plugins で RSS ポーリングの推奨設定:

Key Value
rss polling enabled true
rss polling frequency 10 (つまり 10 分)

Admin → Plugins → RSS Polling で新しいフィードを追加します。

[3] に従って設定します。

Key Value
URL https://mysite.com/blog.atom
Category Filter <this is optional>
Author <define an author of the automatically generated topics>
Category <define the category/ies where the automatically generated topics will be posted>
Tags <this is optional>

2.2 SPA ルーター設定

Discourse は、URL パスの最後の部分を個々のブログ記事の識別子として使用します。

例:

https://mysite.com/blog/an-article-about-technology
https://mysite.com/blog/another-article-about-cats

SPA ルーターを適切に設定し、個々のブログ記事が個々の URL に対応するようにします。

また、Discourse は個々のブログ記事へのリンクを提供するため、クリックしたときにサイトが実際の記事を表示するのは良い UX です。

2.3 記事コンポーネント

前述のように、[1]<script></script> を使用したアプローチは使用できません。そのため、iframe と javascripts/embed.js の一部の関数をコンポーネントに実装します。

Article.vue

編集する必要がある項目:

Item Description
#YOUR-DISCOURSE-URL# Discourse インスタンスの URL、例: discourse.mysite.com
#YOUR-SITE-URL# サイトの URL、例: mysite.com。ブログ記事のパスが /blog/ でない場合は %2Fblog%2F も含まれる可能性があります。
<template>
  <div id="article">

    <!-- your formatted article here -->

    <iframe
      v-if="slug"
      v-bind:src="`https://#YOUR-DISCOURSE-URL#/embed/comments?embed_url=https%3A%2F%2F#YOUR-SITE-URL#%2Fblog%2F${slug}%2F`"
      id="discourse-embed-frame"
      width="100%"
      v-bind:height="`${iframeHeight}px`"
      frameborder="0"
      scrolling="no"
      referrerpolicy="no-referrer-when-downgrade"
    />
  </div>
</template>

<script>
export default {
  data: () => ({
    slug: null,     // ブログ記事のスラッグ、例: ルートが "https://mysite.com/blog/an-article-about-technology" の場合の "an-article-about-technology"
    iframeHeight: 0 // Discourse が正確な iframe の高さを通知します(receiveMessage メソッドを参照)
  }),

  methods: {
    // iframe 通信
    receiveMessage(event) {
      if (!event) {
        return;
      }
      if (!(event.origin || "").includes("#YOUR-DISCOURSE-URL#")) {
        return;
      }

      if (event.data) {
        if (event.data.type === "discourse-resize" && event.data.height) {
          this.iframeHeight = +event.data.height;
        }

        if (event.data.type === "discourse-scroll" && event.data.top) {
          // iframe のオフセットを検索
          const destY = this.findPosY(this.$refs["discourse-embed-frame"]) + event.data.top;
          window.scrollTo(0, destY);
        }
      }
    },

    // http://amendsoft-javascript.blogspot.ca/2010/04/find-x-and-y-coordinate-of-html-control.html より引用
    findPosY(obj) {
      var top = 0;
      if (obj.offsetParent) {
        while (1) {
          top += obj.offsetTop;
          if (!obj.offsetParent) break;
          obj = obj.offsetParent;
        }
      } else if (obj.y) {
        top += obj.y;
      }
      return top;
    }
  },

  async created() {
    this.slug = this.$router.currentRoute.path.split("/")[2];
  },

  mounted() {
    window.addEventListener("message", this.receiveMessage);
  },
  beforeDestroy() {
    window.removeEventListener("message", this.receiveMessage);
  }
}
</script>

2.4 Discourse 埋め込み設定

RSS/Atom フィードのポーリングとサイトでの実装が完了したら、Discourse インスタンスでの埋め込み設定を行います。

Admin → Customize → Embedding に移動し、ホストを追加します。

Key Value
Allowed Hosts サイトの基本 URL、例: “mysite.com
Class Name スタイリング用のオプションのクラス名
Path Allowlist 例: “/blog/.*”
Post to Category RSS ポーリングの Category で設定したものと同じカテゴリ

3. 最終的な注意点

RSS/Atom フィードのポーリングと Discourse 自体が、個々のブログ記事のトピックが存在しない場合に新しいトピックを作成します。RSS/Atom フィードのポーリングが先に行われること(つまり、トピックが作成されるまで待ってからブログ記事をサイトで表示すること)を確認してください。

理由: Discourse はタイトルと概要をスクレイピングできないため、トピックは mysite.com となり、概要は We are sorry, but this site does not work without javascript. になります。:wink:

何らかの理由で Discourse が先に実行された場合は、トピックを削除し、RSS/Atom フィードが機能するまで待ってください。

Cheers
– MK2k

「いいね!」 2