今後のポストストリーム変更 - テーマとプラグインの準備方法

レガシーな「ウィジェット」レンダリングシステムから脱却し、モダンな Glimmer コンポーネントに置き換える作業を進めています。

最近、Glimmer コンポーネントを使用して投稿ストリームを近代化しました。このガイドでは、プラグインやテーマを古いウィジェットベースのシステムから新しい Glimmer 実装へ移行させる手順を解説します。

ご安心ください。この移行は、一見思われるほど複雑ではありません。新しいシステムは、古いウィジェットシステムよりも直感的で強力になるよう設計されており、このガイドが移行プロセスをサポートします。

タイムライン

これらは変更される可能性がある見積もりです

2025 年第 2 四半期:

  • :white_check_mark: コア実装完了
  • :white_check_mark: 公式プラグインとテーマコンポーネントのアップグレード開始
  • :white_check_mark: Meta 上で有効化
  • :white_check_mark: アップグレードに関するアドバイス(このガイド)の公開

2025 年第 3 四半期:

  • :white_check_mark: 公式プラグインとテーマコンポーネントのアップグレード完了
  • :white_check_mark: glimmer_post_stream_mode のデフォルトを auto に設定し、コンソールに非推奨メッセージを表示するように設定
  • :white_check_mark: 管理者警告バナー付きで非推奨メッセージを有効化(2025 年 7 月予定)
  • 第三者のプラグインやテーマの更新が必要

2025 年第 4 四半期:

  • :white_check_mark: 新しい投稿ストリームをデフォルトで有効化
  • :white_check_mark: 機能フラグ設定とレガシーコードの削除

私にとって这意味着什么?

プラグインやテーマのいずれかが投稿ストリームのカスタマイズに「ウィジェット」API を使用している場合、新しいバージョンに対応するように更新する必要があります。

新しい投稿ストリームを試すには?

新しい投稿ストリームを試すには、サイト設定で glimmer_post_stream_mode 設定を auto に変更するだけです。互換性のないプラグインやテーマがない場合、これで新しい投稿ストリームが有効になります。

glimmer_post_stream_modeauto に設定されている場合、Discourse は互換性のないプラグインやテーマを自動的に検出します。検出された場合、ブラウザのコンソールに、更新が必要なプラグインやテーマを特定し、関連するコードの場所を特定するのに役立つスタックトレースを含む役立つ警告メッセージが表示されます。

これらのメッセージにより、新しい Glimmer 投稿ストリームと互換性を持たせるために、プラグインやテーマのどの部分を更新する必要があるかを正確に特定できます。

新しい投稿ストリームの使用中に問題が発生してもご安心ください。現時点では、設定を disabled に戻して古いシステムに戻すことができます。互換性のない拡張機能がインストールされているが、それでも試したい場合は、管理者としてオプションを enabled に設定して、新しい投稿ストリームを強制できます。ただし、これは慎重に使用してください。カスタマイズの内容によっては、サイトが正しく機能しない可能性があります。

カスタムプラグインやテーマをインストールしています。更新する必要がありますか?

以下のいずれかのカスタマイズを実行している場合、プラグインやテーマを更新する必要があります。

  • 以下のウィジェットで decorateWidgetchangeWidgetSettingreopenWidget、または attachWidgetAction を使用する:

    • actions-summary
    • avatar-flair
    • embedded-post
    • expand-hidden
    • expand-post-button
    • filter-jump-to-post
    • filter-show-all
    • post-article
    • post-avatar-user-info
    • post-avatar
    • post-body
    • post-contents
    • post-date
    • post-edits-indicator
    • post-email-indicator
    • post-gap
    • post-group-request
    • post-links
    • post-locked-indicator
    • post-meta-data
    • post-notice
    • post-placeholder
    • post-stream
    • post
    • poster-name
    • poster-name-title
    • posts-filtered-notice
    • reply-to-tab
    • select-post
    • topic-post-visited-line
  • 以下の API メソッドのいずれかを使用する:

    • addPostTransformCallback
    • includePostAttributes

:light_bulb: 上記のカスタマイズを使用する拡張機能がある場合、トピックページにアクセスすると、アップグレードが必要なプラグインやコンポーネントを特定する警告がコンソールに表示されます。

非推奨 ID は: discourse.post-stream-widget-overrides

:warning: インスタンスで複数のテーマを使用している場合は、警告はアクティブなプラグインと現在使用中のテーマおよびテーマコンポーネントに対してのみ表示されるため、すべてのテーマを確認してください。

代替手段は何か?

新しい Glimmer 投稿ストリームでは、投稿の表示方法をカスタマイズするためのいくつかの方法が用意されています。

  1. プラグインアウトレット: 投稿ストリームの特定のポイントにコンテンツを追加するためのもの。
  2. トランスフォーマー: アイテムのカスタマイズ、データ構造の変更、コンポーネントの動作の変更のためのもの。

includePostAttributesaddTrackedPostProperties に置き換える

プラグインが includePostAttributes を使用して投稿モデルにプロパティを追加している場合は、addTrackedPostProperties を使用するように更新する必要があります。

Before:

api.includePostAttributes('can_accept_answer', 'accepted_answer', 'topic_accepted_answer');

After:

api.addTrackedPostProperties('can_accept_answer', 'accepted_answer', 'topic_accepted_answer');

addTrackedPostProperties 関数は、プロパティを投稿更新の追跡対象としてマークします。プラグインが投稿にプロパティを追加し、レンダリング中にそれらを使用する場合は重要です。これにより、これらのプロパティが変更されたときに UI が自動的に更新されます。

一般的な移行パターン

プラグインアウトレットの使用方法

プラグインアウトレットは、投稿ストリームの特定のポイントにコンテンツを追加するための主要なツールです。カスタムコンテンツを注入できる指定された場所と考えてください。

ウィジェット装飾からの移行の鍵となります。

Glimmer 投稿ストリームは、頻繁にカスタマイズされるコンテンツをアウトレットでラップしています。renderBeforeWrapperOutletrenderAfterWrapperOutlet のプラグイン API 関数を使用して、それらの前後にコンテンツを挿入します。

1. ウィジェット装飾をプラグインアウトレットに置き換える

最も一般的なカスタマイズは、投稿にコンテンツを追加することです。ウィジェットシステムでは decorateWidget を使用していましたが、Glimmer ではプラグインアウトレットを使用します。

Before:

// プラグインのイニシャライザーの一部

import { withPluginApi } from "discourse/lib/plugin-api";
// ... その他のインポート

function customizeWidgetPost(api) {
  api.decorateWidget("post-contents:after-cooked", (helper) => {
    const post = helper.getModel();
    if (post.post_number === 1 && post.topic.accepted_answer) {
      return helper.attach("solved-accepted-answer", { post });
    }
  });
}

export default {
  name: "extend-for-solved-button",
  initialize() {
    withPluginApi((api) => {
      // ... その他のカスタマイズ
      customizeWidgetPost(api);
    });
  }
};

After:

// プラグインの .gjs イニシャライザーの一部

import Component from "@glimmer/component";
import { withPluginApi } from "discourse/lib/plugin-api";
import SolvedAcceptedAnswer from "../components/solved-accepted-answer";
// ... その他のインポート

function customizePost(api) {
  api.renderAfterWrapperOutlet(
    "post-content-cooked-html",
    class extends Component {
      static shouldRender(args) {
        return args.post?.post_number === 1 && args.post?.topic?.accepted_answer;
      }

      <template>
        <SolvedAcceptedAnswer 
          @post={{@post}} 
        />
      </template>
    }
  );
}

export default {
  name: "extend-for-solved-button",
  initialize() {
    withPluginApi((api) => {
      // ... その他のカスタマイズ
      customizePost(api);
    });
  }
};

2. 投稿者名の後にコンテンツを追加する

プラグインが投稿者名の後にコンテンツを追加している場合、post-meta-data-poster-name アウトレットを使用して renderAfterWrapperOutlet API を使用します。

Before:

// プラグインのイニシャライザーの一部

import { withPluginApi } from "discourse/lib/plugin-api";
// ... その他のインポート

function customizeWidgetPost(api) {
  api.decorateWidget(`poster-name:after`, (dec) => {
    if (!isGPTBot(dec.attrs.user)) {
      return;
    }

    return dec.widget.attach("persona-flair", {
      personaName: dec.model?.topic?.ai_persona_name,
    });
  });
}

export default {
  name: "ai-bot-replies",
  initialize() {
    withPluginApi((api) => {
      // ... その他のカスタマイズ
      customizeWidgetPost(api);
    });
  }
};

After:

// プラグインの .gjs イニシャライザーの一部

import Component from "@glimmer/component";
import { withPluginApi } from "discourse/lib/plugin-api";
// ... その他のインポート

function customizePost(api) {
  api.renderAfterWrapperOutlet(
    "post-meta-data-poster-name", 
    class extends Component {
      static shouldRender(args) {
        return isGPTBot(args.post?.user);
      }

      <template>
        <span class="persona-flair">{{@post.topic.ai_persona_name}}</span>
      </template>
    }
  );
}

export default {
  name: "ai-bot-replies",
  initialize() {
    withPluginApi((api) => {
      // ... その他のカスタマイズ
      customizePost(api);
    });
  }
};

3. 投稿コンテンツの前にコンテンツを追加する

// テーマの .gjs イニシャライザーの一部

import Component from "@glimmer/component";
import { withPluginApi } from "discourse/lib/plugin-api";
// ... その他のインポート

function customizePost(api) {
  api.renderBeforeWrapperOutlet(
    "post-article",
    class extends Component {
      static shouldRender(args) {
        return args.post?.topic?.pinned;
      }

      <template>
        <div class="pinned-post-notice">
          This is a pinned topic
        </div>
      </template>
    }
  );
}

export default {
  name: "pinned-topic-notice",
  initialize() {
    withPluginApi((api) => {
      // ... その他のカスタマイズ
      customizePost(api);
    });
  }
};

4. 投稿コンテンツの後にコンテンツを追加する

// テーマの .gjs イニシャライザーの一部

import Component from "@glimmer/component";
import { withPluginApi } from "discourse/lib/plugin-api";
// ... その他のインポート

function customizePost(api) {
  api.renderAfterWrapperOutlet(
    "post-article",
    class extends Component {
      static shouldRender(args) {
        return args.post?.wiki;
      }

      // 実際のコンポーネントでは、以下のようなテンプレートを使用します:
      <template>
        <div class="wiki-post-notice">
          This post is a wiki
        </div>
      </template>
    }
  );
}

export default {
  name: "wiki-post-notice",
  initialize() {
    withPluginApi((api) => {
      customizePost(api);
      // ... その他のカスタマイズ
    });
  }
};

トランスフォーマーの使用方法

トランスフォーマーは、Discourse コンポーネントをカスタマイズするための強力な手段です。コンポーネント全体をオーバーライドすることなく、データやコンポーネントの動作を変更できます。

投稿ストリームのカスタマイズに関連する最も重要な値トランスフォーマーをいくつか紹介します。

トランスフォーマー名 説明 コンテキスト
post-class 主な投稿要素に適用される CSS クラスをカスタマイズします。 { post }
post-meta-data-infos 投稿に表示されるメタデータコンポーネントのリストをカスタマイズします。これにより、投稿日や編集インジケーターなどのアイテムの追加、削除、並べ替えが可能になります。 { post, metaDataInfoKeys }
post-meta-data-poster-name-suppress-similar-name ユーザー名がユーザー名と類似している場合に、フルネームの表示を抑制するかどうかを決定します。抑制するには true を返します。 { post, name }
post-notice-component 投稿通知のレンダリングに使用されるコンポーネントをカスタマイズまたは置き換えます。 { post, type }
post-show-topic-map 最初の投稿でのトピックマップコンポーネントの表示を制御します。 { post, isPM, isRegular, showWithoutReplies }
post-small-action-class 小さなアクション投稿にカスタム CSS クラスを追加します。 { post, actionCode }
post-small-action-custom-component 標準の小さなアクション投稿をカスタム Glimmer コンポーネントに置き換えます。 { post, actionCode }
post-small-action-icon 小さなアクション投稿に使用されるアイコンをカスタマイズします。 { post, actionCode }
poster-name-class 投稿者の名前のコンテナにカスタム CSS クラスを追加します。 { user }

1. 投稿にカスタムクラスを追加する

// プラグインのイニシャライザーの一部
import { withPluginApi } from "discourse/lib/plugin-api";

function customizePostClasses(api) {
  api.registerValueTransformer(
    "post-class",
    ({ value, context }) => {
      const { post } = context;

      // 特定のユーザーからの投稿にカスタムクラスを追加
      if (post.user_id === 1) {
        return [...value, "special-user-post"];
      }

      return value;
    }
  );
}

export default {
  name: "custom-post-classes",
  initialize() {
    withPluginApi((api) => {
      // ... その他のカスタマイズ
      customizePostClasses(api);
    });
  }
};

2. カスタム投稿メタデータの追加

post-meta-data-infos トランスフォーマーを使用すると、投稿メタデータセクションにカスタムコンポーネントを追加できます。

// プラグインのイニシャライザーの一部
import { withPluginApi } from "discourse/lib/plugin-api";

function customizePostMetadata(api) {
  // メタデータセクションで使用されるコンポーネントを定義
  // コンポーネントはトランスフォーマーのコールバックの外で作成する必要があります。
  // そうしないとメモリ問題を引き起こす可能性があります。
  const CustomMetadataComponent = <template>...</template>;

  api.registerValueTransformer(
    "post-meta-data-infos",
    ({ value: metadata, context: { post, metaDataInfoKeys } }) => {
      // 特定の投稿に対してのみコンポーネントを追加
      if (post.some_custom_property) {
        metadata.add(
          "custom-metadata-key",
          CustomMetadataComponent,
          {
            // 日付の前に配置
            before: metaDataInfoKeys.DATE,
            // 返信タブの後に配置
            after: metaDataInfoKeys.REPLY_TO_TAB,
          }
        );
      }
    }
  );
}

export default {
  name: "custom-post-metadata",
  initialize() {
    withPluginApi((api) => {
      // ... その他のカスタマイズ
      customizePostMetadata(api);
    });
  }
};

以下は、discourse-activity-pub プラグインからの実際の例です。

// discourse-activity-pub プラグインのイニシャライザーの一部
import { withPluginApi } from "discourse/lib/plugin-api";
import ActivityPubPostStatus from "../components/activity-pub-post-status";
import {
  activityPubPostStatus,
  showStatusToUser,
} from "../lib/activity-pub-utilities";

function customizePost(api, container) {
  const currentUser = api.getCurrentUser();

  const PostMetadataActivityPubStatus = <template>
    <div class="post-info activity-pub">
      <ActivityPubPostStatus @post={{@post}} />
    </div>
  </template>;

  api.registerValueTransformer(
    "post-meta-data-infos",
    ({ value: metadata, context: { post, metaDataInfoKeys } }) => {
      const site = container.lookup("service:site");
      const siteSettings = container.lookup("service:site-settings");

      if (
        site.activity_pub_enabled &&
        post.activity_pub_enabled &&
        post.post_number !== 1 &&
        showStatusToUser(currentUser, siteSettings)
      ) {
        const status = activityPubPostStatus(post);

        if (status) {
          metadata.add(
            "activity-pub-indicator",
            PostMetadataActivityPubStatus,
            {
              before: metaDataInfoKeys.DATE,
              after: metaDataInfoKeys.REPLY_TO_TAB,
            }
          );
        }
      }
    }
  );
}

export default {
  name: "activity-pub",
  initialize(container) {
    withPluginApi((api) => {
      customizePost(api, container);
      // ... その他のカスタマイズ
    });
  }
};

5. 投稿の調理済みコンテンツの前後にコンテンツを挿入する

プラグインが投稿のテキストの後にコンテンツを追加している場合、post-content-cooked-html アウトレットを使用して renderAfterWrapperOutlet API を使用します。

Before:

// プラグインのイニシャライザーの一部
import { withPluginApi } from "discourse/lib/plugin-api";

function customizeCooked(api) {
  api.decorateWidget("post-contents:after-cooked", (helper) => {
    const post = helper.getModel();
    if (post.wiki) {
      const banner = document.createElement("div");
      banner.classList.add("wiki-footer");
      banner.textContent = "This post is a wiki";
      element.prepend(banner);
    }
  });
}

export default {
  name: "wiki-footer",
  initialize() {
    withPluginApi((api) => {
      // ... その他のカスタマイズ
      customizeCooked(api);
    });
  }
};

After:

// プラグインのイニシャライザーの一部 (.gjs)
import Component from "@glimmer/component";
import { withPluginApi } from "discourse/lib/plugin-api";

// プラグインアウトレットで使用されるコンポーネントを定義
class WikiBanner extends Component {
  static shouldRender(args) {
    return args.post.wiki;
  }

  <template>
    <div class="wiki-footer">This post is a wiki</div>
  </template>
}

function customizePost(api) {
  // renderBeforeWrapperOutlet を使用して投稿コンテンツの前にコンテンツを追加
  api.renderAfterWrapperOutlet(
    "post-content-cooked-html",
    WikiBanner
  );
}

export default {
  name: "wiki-footer",
  initialize() {
    withPluginApi((api) => {
      customizePost(api);
      // ... その他のカスタマイズ
    });
  }
};

移行中の新旧両システムのサポート

プラグインまたはテーマの作成者として、移行中に両方のシステムをサポートし、拡張機能が古い投稿ストリームと新しい投稿ストリーの両方で動作するようにしたい場合があります。

多くの公式プラグインで使用されているパターンは以下の通りです。

// solved-button.js
import Component from "@glimmer/component";
import { withSilencedDeprecations } from "discourse/lib/deprecated";
import { withPluginApi } from "discourse/lib/plugin-api";
import RenderGlimmer from "discourse/widgets/render-glimmer";
import SolvedAcceptedAnswer from "../components/solved-accepted-answer";

function customizePost(api) {
  // glimmer 投稿ストリームのカスタマイズ
  api.renderAfterWrapperOutlet(
    "post-content-cooked-html",
    class extends Component {
      static shouldRender(args) {
        return args.post?.post_number === 1 && args.post?.topic?.accepted_answer;
      }

      <template>
        <SolvedAcceptedAnswer 
          @post={{@post}} 
          @decoratorState={{@decoratorState}} 
        />
      </template>
    }
  );

  // ...

  // 非推奨警告を抑制しながら古いウィジェットコードをラップ
  withSilencedDeprecations("discourse.post-stream-widget-overrides", () =>
    customizeWidgetPost(api)
  );
}

// 古いウィジェットコード
function customizeWidgetPost(api) {
  api.decorateWidget("post-contents:after-cooked", (helper) => {
    let post = helper.getModel();

    if (helper.attrs.post_number === 1 && post?.topic?.accepted_answer) {
      // RenderGlimmer を使用してウィジェットシステムで Glimmer コンポーネントをレンダリング
      return new RenderGlimmer(
        helper.widget,
        "div",
        <template><SolvedAcceptedAnswer @post={{@data.post}} /></template>
        null,
        { post }
      );
    }
  });
}

export default {
  name: "extend-for-solved-button",
  initialize(container) {
    const siteSettings = container.lookup("service:site-settings");

    if (siteSettings.solved_enabled) {
      withPluginApi((api) => {
        customizePost(api);
        // ... その他のカスタマイズ
      });
    }
  },
};

実世界の例

公式プラグインからの実際の移行プルリクエストへのリンクを以下に示します。これらは、各タイプのカスタマイズをどのように更新したかを示しています。

トラブルシューティング

新しい投稿ストリームを有効化した後、サイトが破損して見える

  • glimmer_post_stream_modedisabled に戻す
  • コンソールで特定のエラーメッセージを確認する
  • 問題のあるプラグイン/テーマを更新してから再試行する

警告が表示されないが、カスタマイズが機能しない

  • カスタマイズが上記のウィジェットをターゲットにしているか確認する
  • カスタマイズが表示されるトピックがあるトピックページでテストしているか確認する

サポートが必要ですか?

バグを発見した場合、または新しく導入した API を使用してカスタマイズを実現できない場合は、以下までご連絡ください。

Glimmer Post Stream が Meta で有効になりました。

問題を発見した場合は、以下に投稿してください。

コンポーネントのアップグレード中に、奇妙なコードに遭遇しました。

まず、@user={{@post}} には少し困惑しています。これはタイプミスでしょうか?

次に、なぜ post-avatar-flair という名前の PluginOutlet と UserAvatarFlair が別々の要素になっているのでしょうか?また、なぜ post-avatar-flair は、近くにある他のアウトレットのようなラッパーではないのでしょうか?

動作しているので、タイプミスではないと思います。おそらくこの場所では、post オブジェクトに @user 引数に UserAvatarFlair コンポーネントが期待するすべての属性が含まれているのでしょうか?奇妙に見えることには同意します!

PRの説明に基づくと、それらは他の同様の「アバターフレア」outlet との一貫性のために行われたようです(これらは「ラッパープラグイン outlet」が登場する前に導入された可能性が高いです)。

Glimmer Post Stream をデフォルトで有効にするプルリクエストをマージしました。

次回のアップデート後、互換性のあるサイトでは新しい Post Stream が自動的に使用されます。互換性のない拡張機能を使用しているサイトは、古いバージョンにフォールバックし、ブラウザコンソールに警告が表示されます。

現時点では、glimmer_post_stream_modedisabled に設定することで、古い Post Stream の使用を強制できます。

問題が発生した場合は、以下から報告してください。

このコードが原因で警告が表示され、このスレッドにリンクされているようです。

api.changeWidgetSetting('post-avatar', 'size', '120');

新しいシステムでは、これをどのように更新すればよいでしょうか?

api.registerValueTransformer("post-avatar-size", () => {
    return "120";
});

ありがとうございます。

これで動作すると思います。そのコードを入力し、グリマーモードのすべてのフォース設定を一時的にオンにしてみたところ、アバターのサイズと投稿リストがサイトで正常に機能しているように見えました。テスト後、オーバーライドはまだ推奨されていないようなのでオフにしました。しかし、私のサイトはこれで切り替えの準備ができたはずです。

@Boostさん、こんにちは。:smiley:

オーバーライドはまだ推奨されていないとのことですが、どういう意味でしょうか?サイトの準備ができていれば、自動的にGlimmer Post Streamに切り替わります。

Discourse のインストールを次に更新する際には、互換性のないカスタマイズがまだあるサイトでも、Glimmer Post Stream がデフォルトで有効になります。

実際、すべてのウィジェットレンダリングシステムが無効になったため、ウィジェットベースのカスタマイズはレンダリングされなくなります。

現時点では、互換性のないサイトでは、管理者は次の設定値を変更することで、古い動作を再度有効にすることができます。

  • glimmer_post_stream_mode
  • deactivate_widgets_rendering

ウィジェット投稿ストリームを再度有効にするには、両方の設定を変更する必要があります。

これは、約 1 か月後に予定されている Discourse のコードベースから古いコードを削除する前の最終段階です。その後、ウィジェットを再度有効にすることはできなくなります。

実験設定のオプションは、使用しないように明確に警告しています。

指定されたユーザーグループの「自動」モードで新しい「glimmer」投稿ストリーム実装を有効にします。この実装は現在開発中であり、本番環境での使用は意図されていません。実装が最終決定され、発表されるまで、これに対してテーマ/プラグインを開発しないでください。

したがって、「本番環境での使用は意図されていません。これに対してテーマ/プラグインを開発しないでください」と述べているのは、かなり強い言葉です。

これが Glimmer post stream mode auto groups オプションの説明です。

よく気がつきました。これは見落としていました。

Glimmer Post Stream がデフォルトになり、ウィジェット版はレガシーとみなされ、すでに削除が予定されています。設定の説明を更新します。

ウィジェットの投稿ストリームを削除するプルリクエストがマージされました。