레거시 “위젯” 렌더링 시스템에서 벗어나 현대적인 Glimmer 컴포넌트로 대체하고 있습니다.
최근 Glimmer 컴포넌트를 사용하여 게시물 스트림을 현대화했습니다. 이 가이드에서는 기존 위젯 기반 시스템에서 새로운 Glimmer 구현으로 플러그인과 테마를 마이그레이션하는 방법을 안내합니다.
걱정하지 마세요—이 마이그레이션은 처음에는 복잡해 보일 수 있지만 실제로는 훨씬 더 직관적입니다. 새로운 시스템은 기존 위젯 시스템보다 더 직관적이고 강력하도록 설계되었으며, 이 가이드가 이 과정을 도와줄 것입니다.
타임라인
이는 변경될 수 있는 예상 일정입니다
2025년 2분기:
핵심 구현 완료
공식 플러그인과 테마 컴포넌트 업그레이드 시작
Meta에서 활성화됨
업그레이드 권장 사항 게시(이 가이드)
2025년 3분기:
공식 플러그인과 테마 컴포넌트 업그레이드 완료
glimmer_post_stream_mode기본값을auto로 설정하고 콘솔 비추천(deprecation) 메시지 활성화
관리자 경고 배너와 함께 비추천 메시지 활성화(2025년 7월 예정)- 서드파티 플러그인과 테마 업데이트 필요
2025년 4분기:
새 게시물 스트림 기본 활성화
기능 플래그 설정 및 레거시 코드 제거
나에게 어떤 의미가 있는가?
플러그인이나 테마 중 게시물 스트림을 커스터마이징하기 위해 ‘위젯’ API를 사용하는 것이 있다면, 새로운 버전에서 작동하도록 업데이트해야 합니다.
새 게시물 스트림을 어떻게 시험해 볼 수 있나?
새 게시물 스트림을 시험해 보려면 사이트 설정에서 glimmer_post_stream_mode 설정을 auto로 변경하기만 하면 됩니다. 호환되지 않는 플러그인이나 테마가 없는 경우 새 게시물 스트림이 활성화됩니다.
glimmer_post_stream_mode가 auto로 설정되면 Discourse는 호환되지 않는 플러그인과 테마를 자동으로 감지하며, 발견되면 브라우저 콘솔에 해당 플러그인이나 테마가 업데이트되어야 하는지 정확히 식별하는 유용한 경고 메시지와 관련 코드를 찾기 위한 스택 트레이스가 표시됩니다.
이 메시지는 플러그인이나 테마의 어떤 부분이 새로운 Glimmer 게시물 스트림과 호환되도록 업데이트되어야 하는지 정확히 파악하는 데 도움이 됩니다.
새 게시물 스트림 사용 중 문제가 발생하더라도 걱정하지 마세요. 당분간은 설정을 disabled로 변경하여 기존 시스템으로 돌아가실 수 있습니다. 호환되지 않는 확장 기능이 설치되어 있더라도 시험해 보고 싶다면, 관리자 권한으로 옵션을 enabled로 설정하여 새 게시물 스트림을 강제할 수 있습니다. 단, 사용 중인 커스터마이징에 따라 사이트가 올바르게 작동하지 않을 수 있으므로 주의하여 사용하세요.
커스텀 플러그인이나 테마가 설치되어 있습니다. 업데이트가 필요한가요?
다음 중 하나 이상의 커스터마이징을 수행하는 경우 플러그인이나 테마를 업데이트해야 합니다:
-
다음 위젯에
decorateWidget,changeWidgetSetting,reopenWidget또는attachWidgetAction을 사용하는 경우:actions-summaryavatar-flairembedded-postexpand-hiddenexpand-post-buttonfilter-jump-to-postfilter-show-allpost-articlepost-avatar-user-infopost-avatarpost-bodypost-contentspost-datepost-edits-indicatorpost-email-indicatorpost-gappost-group-requestpost-linkspost-locked-indicatorpost-meta-datapost-noticepost-placeholderpost-streampostposter-nameposter-name-titleposts-filtered-noticereply-to-tabselect-posttopic-post-visited-line
-
다음 API 메서드 중 하나를 사용하는 경우:
addPostTransformCallbackincludePostAttributes
위의 커스터마이징 중 하나를 사용하는 확장 기능이 있는 경우, 토픽 페이지를 방문할 때 업그레이드해야 하는 플러그인이나 컴포넌트를 식별하는 콘솔 경고가 표시됩니다.
비추천 ID는 다음과 같습니다:
discourse.post-stream-widget-overrides
인스턴스에서 테마를 하나 이상 사용하는 경우, 경고가 활성 플러그인과 현재 사용 중인 테마 및 테마-컴포넌트에 대해서만 표시되므로 모든 테마를 확인해야 합니다.
대체 방안은 무엇인가?
새로운 Glimmer 게시물 스트림은 게시물의 표시 방식을 커스터마이징하는 여러 가지 방법을 제공합니다:
- 플러그인 아울렛(Plugin Outlets): 게시물 스트림의 특정 지점에 콘텐츠를 추가하는 데 사용됩니다.
- 트랜스포머(Transformers): 항목을 커스터마이징하거나 데이터 구조를 수정하거나 컴포넌트의 동작을 변경하는 데 사용됩니다.
includePostAttributes를 addTrackedPostProperties로 대체
플러그인이 includePostAttributes를 사용하여 게시물 모델에 속성을 추가하는 경우, 대신 addTrackedPostProperties를 사용하도록 업데이트해야 합니다.
이전:
api.includePostAttributes('can_accept_answer', 'accepted_answer', 'topic_accepted_answer');
이후:
api.addTrackedPostProperties('can_accept_answer', 'accepted_answer', 'topic_accepted_answer');
addTrackedPostProperties 함수는 게시물 업데이트를 위해 속성을 추적 대상으로 표시합니다. 플러그인이 게시물에 속성을 추가하고 렌더링 중에 이를 사용하는 경우 중요합니다. 이렇게 하면 이러한 속성이 변경될 때 UI가 자동으로 업데이트됩니다.
일반적인 마이그레이션 패턴
플러그인 아울렛 사용
플러그인 아울렛은 게시물 스트림의 특정 지점에 콘텐츠를 추가하는 주요 도구입니다. 이는 커스텀 콘텐츠를 삽입할 수 있는 지정된 장소라고 생각할 수 있습니다.
이것은 위젯 데코레이션에서 벗어나기 위한 핵심입니다.
Glimmer 게시물 스트림은 자주 커스터마이징되는 콘텐츠를 아울렛으로 감쌉니다. renderBeforeWrapperOutlet과 renderAfterWrapperOutlet 플러그인 API 함수를 사용하여 그 앞이나 뒤에 콘텐츠를 삽입하세요.
1. 위젯 데코레이션을 플러그인 아울렛으로 대체
가장 일반적인 커스터마이징은 게시물에 콘텐츠를 추가하는 것입니다. 위젯 시스템에서는 decorateWidget을 사용했습니다. Glimmer에서는 대신 플러그인 아울렛을 사용합니다.
이전:
// 플러그인의 초기화 프로그램의 일부
import { withPluginApi } from "discourse/lib/plugin-api";
// ... 다른 import
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);
});
}
};
이후:
// 플러그인의 .gjs 초기화 프로그램의 일부
import Component from "@glimmer/component";
import { withPluginApi } from "discourse/lib/plugin-api";
import SolvedAcceptedAnswer from "../components/solved-accepted-answer";
// ... 다른 import
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를 사용합니다.
이전:
// 플러그인의 초기화 프로그램의 일부
import { withPluginApi } from "discourse/lib/plugin-api";
// ... 다른 import
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);
});
}
};
이후:
// 플러그인의 .gjs 초기화 프로그램의 일부
import Component from "@glimmer/component";
import { withPluginApi } from "discourse/lib/plugin-api";
// ... 다른 import
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";
// ... 다른 import
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";
// ... 다른 import
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. 게시물의 cooked 콘텐츠 앞이나 뒤에 콘텐츠 삽입
플러그인이 게시물 텍스트 뒤에 콘텐츠를 추가하는 경우, post-content-cooked-html 아울렛과 함께 renderAfterWrapperOutlet API를 사용합니다.
이전:
// 플러그인의 초기화 프로그램의 일부
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);
});
}
};
이후:
// 플러그인의 초기화 프로그램의 일부 (.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);
// ... 다른 커스터마이징
});
}
},
};
실제 사례
다음은 공식 플러그인에서 가져온 실제 마이그레이션 풀 리퀘스트 링크입니다. 이는 각 유형의 커스터마이징을 어떻게 업데이트했는지 보여줍니다:
- discourse-ai
- discourse-assign
- discourse-cakeday
- discourse-post-voting
- discourse-reactions
- discourse-shared-edits
- discourse-solved
- discourse-topic-voting
- discourse-user-notes
- discourse-activity-pub
문제 해결
새 게시물 스트림 활성화 후 사이트가 깨져 보입니다
glimmer_post_stream_mode를disabled로 되돌립니다- 콘솔에서 구체적인 오류 메시지를 확인합니다
- 다시 시도하기 전에 문제 있는 플러그인/테마를 업데이트합니다
경고가 표시되지 않지만 커스터마이징이 작동하지 않습니다
- 커스터마이징이 위에 나열된 위젯을 대상으로 하는지 확인합니다
- 커스터마이징이 보이는 토픽이 있는 토픽 페이지에서 테스트하고 있는지 확인합니다
도움이 필요하신가요?
버그를 찾았거나 도입한 새 API를 사용하여 커스터마이징을 달성할 수 없는 경우, 아래로 알려주세요.