예정된 게시글 메뉴 변경 - 테마와 플러그인 준비 방법

Discourse 코드베이스를 지속적으로 개선하기 위한 노력의 일환으로, 레거시 “위젯” 렌더링 시스템의 사용을 제거하고 Glimmer 컴포넌트로 대체하고 있습니다.

최근에 게시물 메뉴를 현대화했으며, 이제 glimmer_post_menu_mode 설정을 통해 Discourse에서 사용할 수 있습니다.

이 설정은 세 가지 값을 허용합니다:

  • disabled: 레거시 “위젯” 시스템 사용
  • auto: 현재 플러그인과 테마의 호환성을 감지합니다. 호환되지 않는 항목이 있으면 레거시 시스템을 사용하고, 그렇지 않으면 새 메뉴를 사용합니다.
  • enabled: 새 메뉴를 사용합니다. 호환되지 않는 플러그인이나 테마가 있는 경우 사이트가 손상될 수 있습니다.

공식 플러그인은 이미 새 메뉴와 호환되도록 업데이트되었지만, 여전히 새 메뉴와 호환되지 않는 서드파티 플러그인, 테마 또는 테마 컴포넌트가 있는 경우 업그레이드가 필요합니다.

브라우저 콘솔에 호환성 문제의 원인을 식별하는 경고가 출력됩니다.

:timer_clock: 롤아웃 타임라인

이는 변경될 수 있는 대략적인 추정치입니다

2024년 4분기:

  • :white_check_mark: 코어 구현 완료
  • :white_check_mark: 공식 플러그인 업데이트
  • :white_check_mark: Meta에서 활성화
  • :white_check_mark: glimmer_post_menu_mode 기본값이 auto로 설정; 콘솔 비권장(deprecation) 메시지 활성화
  • :white_check_mark: 업그레이드 조언 게시

2025년 1분기:

  • :white_check_mark: 서드파티 플러그인과 테마 업데이트 필요
  • :white_check_mark: 비권장 메시지 시작, 남은 문제에 대해 관리자 경고 배너 트리거
  • :white_check_mark: 새 게시물 메뉴 기본 활성화

2025년 2분기

  • :white_check_mark: 4월 1일 - 기능 플래그 설정 및 레거시 코드 제거

:eyes: 나에게 어떤 의미가 되나요?

플러그인이나 테마에서 게시물 메뉴를 커스터마이징하기 위해 ‘위젯’ API를 사용하는 경우, 새 버전과 호환되도록 업데이트해야 합니다.

:person_tipping_hand: 새 게시물 메뉴를 어떻게 시험해 볼 수 있나요?

최신 버전의 Discourse에서는 호환되지 않는 플러그인이나 테마가 없으면 새 게시물 메뉴가 활성화됩니다.

호환되지 않는 확장 기능이 설치된 경우, 관리자로 enabled로 설정을 변경하여 새 메뉴 사용을 강제할 수 있습니다. 설치된 커스터마이징에 따라 사이트가 손상될 수 있으므로 신중하게 사용하세요.

이 자동 시스템이 예상대로 작동하지 않는 드문 경우, 위의 설정을 사용하여 이 '자동 기능 플래그’를 일시적으로 오버라이드할 수 있습니다. 이 작업이 필요한 경우 이 주제에서 알려주세요.

:technologist: 플러그인과 테마를 업데이트해야 하나요?\n

아래와 같은 커스터마이징을 수행하는 플러그인이나 테마가 있는 경우 업데이트가 필요합니다:

  • 다음 위젯에서 decorateWidget, changeWidgetSetting, reopenWidget 또는 attachWidgetAction을 사용하는 경우:

    • post-menu
    • post-user-tip-shim
    • small-user-list
  • 다음 API 메서드 중 하나를 사용하는 경우:

    • addPostMenuButton
    • removePostMenuButton
    • replacePostMenuButton

:bulb: 위의 커스터마이징 중 하나를 수행하는 확장 기능이 있는 경우, 주제 페이지에 액세스할 때 업그레이드가 필요한 플러그인 또는 컴포넌트를 식별하는 경고가 콘솔에 출력됩니다.

비권장 ID는: discourse.post-menu-widget-overrides

:warning: 인스턴스에서 여러 테마를 사용하는 경우, 경고는 활성 플러그인과 현재 사용 중인 테마 및 테마 컴포넌트에 대해서만 출력되므로 모든 테마를 확인하시기 바랍니다.

대체 방안은 무엇인가요?

게시물 메뉴를 커스터마이징하기 위한 새로운 API로 값 변환기 post-menu-buttons를 도입했습니다.

이 값 변환기는 버튼의 추가, 교체, 제거 또는 재배열을 허용하는 DAG 객체를 제공합니다. 또한 메뉴와 연관된 게시물, 표시되는 게시물의 상태, 항목의 더 쉬운 배치에 사용할 수 있는 버튼 키와 같은 컨텍스트 정보를 제공합니다.

DAG API는 .add 및 .replace와 같이 새 버튼 정의가 필요한 경우 Ember 컴포넌트를 수신하도록 기대합니다.

각 커스터마이징은 다르지만, 가장 일반적인 사용 사례에 대한 지침은 다음과 같습니다:

addPostMenuButton

변경 전:

withPluginApi("1.34.0", (api) => {
  api.addPostMenuButton("solved", (attrs) => {
    if (attrs.can_accept_answer) {
      const isOp = currentUser?.id === attrs.topicCreatedById;
      return {
        action: "acceptAnswer",
        icon: "far-check-square",
        className: "unaccepted",
        title: "solved.accept_answer",
        label: isOp ? "solved.solution" : null,
        position: attrs.topic_accepted_answer ? "second-last-hidden" : "first",
      };
    }
  });
});

변경 후:

아래 예제는 Ember의 Template Tag Format (gjs)를 사용합니다.

// components/solved-accept-answer-button.gjs
import Component from "@glimmer/component";
import { action } from "@ember/object";
import { inject as service } from "@ember/service";
import DButton from "discourse/components/d-button";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";

export default class SolvedAcceptAnswerButton extends Component {
  // 버튼이 즉시 표시될지, '더 보기' 버튼 뒤에 숨겨질지 나타냅니다
  static hidden(args) { 
    return args.post.topic_accepted_answer;
  }

  ...

  <template>
    <DButton
      class="post-action-menu__solved-unaccepted unaccepted"
      ...attributes
      @action={{this.acceptAnswer}}
      @icon="far-check-square"
      @label={{if this.showLabel "solved.solution"}}
      @title="solved.accept_answer"
    />
  </template>
}

// initializer.js
import SolvedAcceptAnswerButton from "../components/solved-accept-answer-button";

...
withPluginApi("1.34.0", (api) => {
  api.registerValueTransformer(
    "post-menu-buttons",
    ({
      value: dag, 
      context: {
        post,
        firstButtonKey, // 첫 번째 버튼의 키
        secondLastHiddenButtonKey, // 두 번째로 마지막 숨겨진 버튼의 키
        lastHiddenButtonKey, // 마지막 숨겨진 버튼의 키
      },
    }) => {
        dag.add(
          "solved",
          SolvedAcceptAnswerButton,
          post.topic_accepted_answer
            ? {
                before: lastHiddenButtonKey,
                after: secondLastHiddenButtonKey,
              }
            : {
                before: [
                  "assign", // assign 플러그인이 추가한 버튼
                  firstButtonKey,
                ],
              }
        );
    }
  );
});

:bulb: 버튼 스타일링

위의 예제와 같이 컴포넌트에 ...attributes를 포함하는 것이 좋습니다.

컴포넌트 DButton 또는 DMenu의 사용과 결합하면 보일러플레이트 클래스를 처리하여 버튼이 게시물 메뉴의 다른 버튼과 동일한 형식을 따르도록 보장합니다.

추가적인 형식은 사용자 정의 클래스를 사용하여 지정할 수 있습니다.

replacePostMenuButton

  • 변경 전:
withPluginApi("1.34.0", (api) => {
  api.replacePostMenuButton("like", {
    name: "discourse-reactions-actions",
    buildAttrs: (widget) => {
      return { post: widget.findAncestorModel() };
    },
    shouldRender: (widget) => {
      const post = widget.findAncestorModel();
      return post && !post.deleted_at;
    },
  });
});
  • 변경 후:
import ReactionsActionButton from "../components/discourse-reactions-actions-button";

...

withPluginApi("1.34.0", (api) => {
  api.registerValueTransformer(
    "post-menu-buttons",
    ({ value: dag, context: { buttonKeys } }) => {
      // ReactionsActionButton은 새로운 버튼 컴포넌트입니다
      dag.replace(buttonKeys.LIKE, ReactionsActionButton);
    }
  );
});

removePostMenuButton

  • 변경 전:
withPluginApi("1.34.0", (api) => {
  api.removePostMenuButton('like', (attrs, state, siteSettings, settings, currentUser) => {
    if (attrs.post_number === 1) {
      return true;
    }
  });
});
  • 변경 후:
withPluginApi("1.34.0", (api) => {
  api.registerValueTransformer(
    "post-menu-buttons",
    ({ value: dag, context: { post, buttonKeys } }) => {
      if (post.post_number === 1) {
        dag.delete(buttonKeys.LIKE);
      }
    }
  );
});

:sos: 다른 커스터마이징은 어떻게 하나요?

도입한 새 API를 사용하여 커스터마이징을 달성할 수 없는 경우, 토론을 위해 새로운 개발 주제를 생성하여 알려주세요.

:sparkles: 저는 플러그인/테마 개발자입니다. 전환 기간 동안 구버전과 새 게시물 메뉴를 모두 지원하는 테마/플러그인을 어떻게 업데이트하나요?

플러그인에서 구버전과 새 버전의 게시물 메뉴를 모두 지원하기 위해 아래 패턴을 사용했습니다:

function customizePostMenu(api) {
  const transformerRegistered = api.registerValueTransformer(
    "post-menu-buttons",
    ({ value: dag, context }) => {
      // 새 게시물 메뉴 커스터마이징
      ...
    }
  );

  const silencedKey =
    transformerRegistered && "discourse.post-menu-widget-overrides";

  withSilencedDeprecations(silencedKey, () => customizeWidgetPostMenu(api));
}

function customizeWidgetPostMenu(api) {
  // 여기 레거시 "위젯" 코드 커스터마이징
  ...
}

export default {
  name: "my-plugin",

  initialize(container) {
    withPluginApi("1.34.0", customizePostMenu);
  }
};

:star: 더 많은 예제

새 API 사용법에 대한 예제는 공식 플러그인을 확인해 보세요:

Glimmer Post 메뉴의 기본값을 enabled로 변경했습니다.

Discourse 인스턴스가 업그레이드되면, 새로운 API로 업데이트되지 않은 기존 커스터마이징이 적용되지 않게 됩니다.

일단, 나머지 커스터마이징 업데이트를 진행하는 동안 관리자는 설정을 disabled로 되돌릴 수 있습니다.

레거시 코드는 2분기 초에 제거됩니다.

완전한 컴포넌트 API를 사용할 수 있는 유연성을 높이 평가합니다. 전반적으로 glimmer 컴포넌트의 문법을 좋아하며, 코드베이스 내 복잡도를 줄이는 데 이점이 있을 수 있다는 점도 이해합니다.

그러나 기본적인 사용 사례(버튼을 추가하고 아이콘을 부여하는 것)에서는 구 API 방식이 객관적으로 더 간결하고 이해하기 쉬웠습니다(수입(import)이 적고, 작업이 적으며, 노출되는 API 범위가 작음). 구 API 방식에 대한 지속적인 지원을 제공할 수 없는 이유가 있을까요? 구 API 방식을 편의 함수(convenience functions)로 사용하고, 내부 구현은 새로운 glimmer 컴포넌트를 통해 수행한다면, 해당 API 방식에서도 버전 호환성 검사를 수행할 수 있을 것입니다.

이렇게 하면 해당 방식을 사용하는 사용자들에게 훨씬 덜 파괴적(disruptive)일 뿐만 아니라, 플러그인 생태계 내에서 조건부 로직으로 인한 코드 폭발을 줄일 수 있습니다.

기존 위젯에 대해 제가 가진 가장 큰 불만은 문서화가 부족하다는 점입니다. 이 게시글처럼 그 제거를 알리는 글은, 해당 특정 방식이 존재하며 어떻게 사용하는지에 대해 제가 본 가장 명확한 문서 중 하나입니다. 사실 저는 구 API를 어떻게 사용하는지 알아보기 위해 여기 왔습니다.

변환기(transformer)가 문자열 리터럴로 한 곳에서 등록되는 방식을 좋아합니다. 이는 문서화(및 플러그인 개발) 업무를 훨씬 쉽게 만들어준다고 생각합니다.

위젯의 경우, 모두 createWidget 메서드에 의해 등록되며, 이는 createWidgetFrom를 호출합니다. 제가 보는 문제점은 _registry가 API로 보호되는 파일 스코프 전역 변수인데, 이 API가 반복(iteration)을 허용하지 않는다는 것입니다. 위젯 레지스트리에 반복 함수만 제공받을 수 있다면, 현재 등록된 위젯을 실시간으로 발견할 수 있을 것입니다. "브라우저 콘솔에서 이 자바스크립트 한 줄을 실행하여 API를 호출하고 레지스트리를 나열하세요"라고 문서화하면 됩니다. 그러면 변환기 레지스트리가 제공하는 것과 매우 유사한 유틸리티를 얻을 수 있을 것입니다.

플러그인 개발에 도움이 될 또 다른 점은, 컴포넌트/위젯에 의해 렌더링되는 루트 DOM 요소에 어떤 컴포넌트/위젯을 보고 있는지 알려주는 속성을 추가하는 것입니다. 예를 들어 "data-widget=foo"와 같은 형태입니다. 이는 디버그 기능이 될 수도 있고, 기본적으로 활성화될 수도 있습니다. OSS(오픈소스)이므로, 불명확성을 통한 보안(security through obscurity)을 달성하는 것이 아닙니다.

glimmer 컴포넌트로의 전환을 환영합니다. 하지만 시간이 걸릴 것이며, 그 사이 사람들이 작업해야 할 위젯이 많습니다. 따라서 위에서 언급한 대로 위젯의 가시성을 개선하면 모두에게 전이 기간(transition period)이 더 쉬워질 것이라고 생각합니다.

구 API 방식에 대해서는… 누군가 자바스크립트 API에 상세한 주석을 추가하는 데 공을 들인 것 같습니다 하지만, 이를 위한 문서 사이트는 생성된 적이 없습니다. 왜 그런가요?

만약 동의해 주신다면, 위젯 레지스트리를 반복하는 기능에 대한 풀 리퀘스트를 제출하는 것을 기꺼이 하겠습니다.

또한, 새로운 기능만 구현하려고 한다면 플러그인 호환성을 어떤 Discourse API 버전에 고정해야 할까요? 모든 예제에서 withPluginApi("1.34.0", ...)를 사용하고 계신데, 이는 더 오래된 버전이고 이 변경 사항이 적용된 시기를 대표하지 않는 것 같습니다. 하지만 확인 부탁드립니다. 감사합니다!

올바른 버전입니다. 변경 로그를 여기서 확인하실 수 있습니다:

https://github.com/discourse/discourse/blob/main/docs/CHANGELOG-JAVASCRIPT-PLUGIN-API.md?plain=1#L64-L67

또한, 이 기능이 도움이 될 수 있습니다: Pinning plugin and theme versions for older Discourse installs (d-compat branches)

위젯 제거 작업은 이미 몇 년간 진행되어 왔으며, 향후 몇 달 내에 이를 완료할 예정입니다. 따라서 그때까지는 기본 시스템에 대한 변경 사항을 도입하지 않을 것으로 생각합니다.

Ember Inspector를 사용해 보셨나요? 제가 설명한 문제를 해결해 줄 수 있을 것 같으며, 현재 DOM 요소를 렌더링하지 않는 Ember 컴포넌트까지 표시해 줍니다.

매우 최근부터 이 버전 번호는 더 이상 필수적이지 않습니다. 다음과 같이 작성할 수 있습니다.

export default apiInitializer((api) => {

또는

withPluginApi((api) => {

이 변경 사항에 대한 문서 업데이트도 곧 진행될 예정입니다. 현재 버전 호환성 관리에 대한 최신 권장 방식은 @Arkshine님이 언급하신 .discourse-compatibility 파일입니다:

정말 좋은 변화네요! 매번 그 번호를 업데이트하는 걸 잊어버리곤 하거든요 :rofl:.

@david @Arkshine 와, 정말 멋진 게시물들이네요. 정말 도움이 됩니다!

한 가지 더 질문이 있습니다. api.registerValueTransformer를 호출할 때 공유되는 "context"에 대해인데요, 나에게 어떤 context가 전달될지 어떻게 알 수 있을까요? 단순히 context를 console.log로 출력하는 방법도 있겠지만, 사전에 어떤 값들이 사용 가능한지 알 수 있다면 좋겠습니다.

현재 제가 작성하고 있는 플러그인에서는 토픽 작성자에게 특별한 모더레이션 권한을 부여하고 있습니다. 이를 위해 “현재 토픽 작성자”, “현재 사용자”, 그리고 “로그인된 사용자의 그룹 소속” 정보를 알아야 합니다.

아마도 이 구체적인 예제가 제 질문에 대한 맥락을 이해하는 데 도움이 될 것입니다.

수정:

궁금해하시는 분들을 위해, 제 최종 코드는 다음과 같습니다:

const currentUser = api.getCurrentUser()
const validGroups = currentUser.groups.filter(g => ['admins', 'staff', 'moderators'].includes(g.name))

// 현재 사용자가 게시물 작성자이거나, 권한이 있는 그룹에 속해 있는 경우
if (post?.topic?.details?.created_by?.id === currentUser.id || validGroups.length > 0) {
  // 해당 기능에 대한 접근 권한을 부여
  ...
}

현재 이에 대한 중앙 문서가 없습니다. 앱의 일부 영역에는 예: 토픽 목록과 같이 context 인수를 나열한 자체 문서가 있습니다. 하지만 그 외에는 코어 코드베이스에서 applyTransformer 호출을 검색하거나 console.log를 사용하는 것이 가장 좋은 방법입니다.

변환기(transformer)에 대한 일반 문서는 다음에서 확인할 수 있습니다: Using Transformers to customize client-side values and behavior

“applyTransformer” 검색 결과 0건. 잘못된 곳을 보고 있는 건가요?

"api.registerValueTransformer"를 검색하면 몇 가지 유용한 예제를 찾을 수 있습니다. 하지만 물론 해당 예제들은 반환되는 컨텍스트에 대한 포괄적인 문서를 제공하지는 않습니다. 그 특정 예제에 유용했던 것들만 보여줄 뿐이죠.

제 특정 예제에서 context의 console.log를 확인해 보니, post는 반환되지만 user는 반환되지 않는 것을 알 수 있습니다. 그렇다면 컨텍스트에 포함되지 않은 다른 애플리케이션 상태에 접근하는 방법은 무엇인가요?

이전에는 api.decorateWidget 컨텍스트 내에서 helper.getModel()이나 helper.currentUser를 호출할 수 있었다는 점을 이해하고 있습니다. 유사한 결과를 얻을 수 있는 현재 방법이 있을 거라고 가정합니다.

여기서 주신 모든 도움에 감사드립니다.

오, 내 질문에 스스로 답을 한 것 같습니다. 이 예시는 api.getCurrentUser()의 사용법을 보여줍니다. 즉, 해당 API 부분은 사실상 변경되지 않았으며 여전히 glimmer 패러다임과 호환됩니다.

applyValueTransformer 또는 applyBehaviorTransformer를 의미한 것으로 보입니다. 해당 함수들은 다음 파일에서 찾을 수 있습니다: https://github.com/discourse/discourse/blob/main/app/assets/javascripts/discourse/app/lib/transformer.js

레거시 게시물 메뉴 코드가 이제 제거되었습니다. 테마 및 플러그인을 업데이트해 주신 모든 분들께 감사드립니다 :rocket: