专注于WEB前端开发, 追求更好的用户体验, 更好的开发体验 [长沙前端QQ群:234746733]

discourse 插件开发

开发discourse插件, 依赖的知识: ES6/SCSS/Ember.js/Rails/handlebars.
Ember是前端MVVM框架, 支持数据双向绑定/虚拟DOM, 模板引擎使用handlebars, 依赖jQuery(处理DOM兼容性操作), 遵循约定优于配置原则(类似Rails).

discourse 本地环境搭建

依赖: ruby, postgres, redis 需要提前安装好.

  • macOS 安装Ruby2.6

    brew install rbenv; rbenv init; rbenv install 2.6.2; # discourse依赖 Ruby 2.5+
  • (推荐) 用docker搭建本地 postgres, redis

    # code db.yml
    version: '3.1'
    services:
      redis:
        container_name: redis
        image: redis:alpine
        ports:
          - "6379:6379"
      postgres:
        container_name: postgres
        image: postgres:9-alpine
        ports:
          - "5432:5432"
        environment:
          - POSTGRES_USER=postgres
          - POSTGRES_PASSWORD=postgres
    # docker-compose -f db.yml up -d # 启动 postgresql/redis
    # docker restart postgres redis # 重启 postgresql/redis
git clone https://github.com/discourse/discourse.git;
# git checkout tags/v2.2.4; # 指定某个稳定的版本
code config/database.yml # 设定 postgres 数据库信息
development: # 设定正确用户名/密码
  username: name
  password: pass
  host: localhost
  host_names: # 本地论坛域名(不添加: 影响 hot reload)
    - leon.lvh.me
bundle install; # 安装依赖
rake db:create db:migrate; # 创建数据库
# rails r "SiteSetting.min_password_length=8;SiteSetting.min_admin_password_length=8;" # 设定密码最少8位
rake admin:create # 创建用户, 输入Email/password/是否管理员
# rails r "u=User.find_by_email('[email protected]'); u.password='11112222'; u.save!;" # 修改用户密码
rails s -p 8000 # 启动论坛, 访问 localhost:8000

创建plugin

例子: 创建名为 DiscourseTest 的插件

rails g plugin DiscourseTest # 创建插件, 自动创建以下文件:
# plugins/discourse-test/README.md
# plugins/discourse-test/LICENSE # 默认MIT
# plugins/discourse-test/plugin.rb # 插件后端入口
# plugins/discourse-test/assets/stylesheets/common/discourse-test.scss
# plugins/discourse-test/assets/javascripts/initializers/discourse-test.es6 # 插件前端入口
# plugins/discourse-test/config/settings.yml # 插件配置项
# plugins/discourse-test/config/locales/client.en.yml # 扩展i18n语言包
# plugins/discourse-test/config/locales/server.en.yml

# 插件生成器有bug, 生成的js文件名 要手动修改:
# discourse-test.es6 => discourse-test.js.es6

# 方便 vscode 开发:
code .gitignore # 增加3行:
/jsapp/
/adminjs/
!/plugins/discourse-test # 插件路径

code .gitmodules # 增加(方便vscode 管理多个插件):
[submodule "plugins/discourse-test"]
    path = plugins/discourse-test
  url = [email protected]/username/discourse-test.git

重启discourse后, 管理员登录论坛, 可以在 /admin/plugins 看到已安装的插件: DiscourseTest.
开发模式: 修改scss页面会自动更新, 修改js文件需刷新页面.

plugin API (前端)

前端 app/assets/javascripts/discourse/lib/plugin-api.js.es6 的一些主要方法.
在插件 assets/javascripts/initializers/discourse-test.js.es6initialize 内使用.

  • api.getCurrentUser(); 获取当前用户信息
  • api.replaceIcon(source, destination); 替换Discourse icon
  • api.modifyClass(resolverName, changes, opts); 覆盖或扩展类的方法(controller/component/model)
  • api.modifyClassStatic(resolverName, changes, opts); 覆盖或扩展类中的静态方法
  • api.reopenWidget(name, args); 扩展或覆盖方法
  • api.decorateWidget('widgetName:LOCATION', helper => {}); 在widget前/后 添加内容, LOCATION: beforeafter
    还可以指定Widget里面的 applyDecorators(this, "footerLinks", ...) 部分, 比如, 在后面追加菜单: api.decorateWidget('hamburger-menu:footerLinks', ()=>({ href: '#', rawLabel: 'Test' }));
  • api.createWidget(name, args); 创建 Widget
  • api.changeWidgetSetting(widgetName, settingName, newValue); 如果Widget有settings, 改变settings值
  • api.addNavigationBarItem({}); 导航栏navigation-bar添加菜单 (帖子列表Categories后面)
  • api.addUserMenuGlyph(); 在用户菜单中添加图标(右上角用户头像的展开菜单)
  • api.onPageChange((url, title) => {}); 每次页面变更时触发 (比如用于统计)
  • api.decorateCooked($elem => $elem.css({ backgroundColor: '#000' })); 帖子渲染后, 使用jQuery修改帖子内容
  • api.onToolbarCreate(toolbar => { toolbar.addButton(); }); 给编辑器的工具栏添加新按钮
  • api.onAppEvent(name, () => {}); 侦听 appEvents.trigger() 事件
  • api.attachWidgetAction('header', actionName, fn); 给widget里增加action, 通过this.sendWidgetAction(actionName)触发

plugin API (后端)

后端 lib/plugin/instance.rb 的一些主要方法, 在插件内的 plugin.rb 内使用:

  • register_asset "stylesheets/common/discourse-test.scss" 注册资源文件(支持scss/css/js/es6文件), 主要针对样式/lib库
    assets/javascripts/initializers 内的js文件会自动引入, 无需注册.
  • enabled_site_setting :discourse_test_enabled 把插件内config/settings.yml的值, 默认存到site_settings表中(使用多站点模式时必须)
    后台修改插件的setting时, 修改也会保存到site_settings表, 不启用多站点, 可以省略.
  • register_svg_icon "fab-youtube" if respond_to?(:register_svg_icon) # 注册FontAwesome图标(svg-icons/fontawesome/内并未包含所有图标)
  • register_html_builder('server:xxx'), 会在 .erb 里 <%= build_plugin_html 'server:xxx' %> 位置插入HTML, 比如:

    register_html_builder('server:before-head-close') do # <%= build_plugin_html 'server:before-head-close' %>
      '<script src="//cdn.jsdelivr.net/gh/kenwheeler/[email protected]/slick/slick.min.js"></script>'
    end
  • extend_content_security_policy, 扩展引入外部资源策略CSP (论坛默认防XSS, 禁止了外部资源), 比如:

    extend_content_security_policy( # 允许加载cdnjs/jsdelivr的资源
    script_src: ['cdnjs.cloudflare.com', 'cdn.jsdelivr.net'],
    )
  • after_initialize do 实例化后执行的逻辑 (比如: 添加路由等操作)

Routes

  • 定义路由

    // assets/javascripts/discourse/*-route-map.js.es6
    this.route('forum', { path: '/forum' }); // 读取 routes/forum.js.es6, 文件不存在, 页面会空白
    this.route('discovery', function() {
      this.route('forum', { path: '/forum' }); // 读取 routes/discovery-forum.js.es6
    });
    // assets/javascripts/discourse/routes/routeName.js.es6
    // 不设定 templateName 或 renderTemplate (默认用当前路由对应的template: templates/routeName.hbs)
    export default Discourse.Route.extend({
      // templateName: 'test', // [可选] 指定模板: templates/test.hbs
      renderTemplate(controller, model) { // [可选] 设定当前路由的渲染逻辑
        this.render('box'); // 渲染模板 box.hbs
        this.render('test', { // [可选] 待渲染的模板名称 (templates/test.hbs)
          into: 'box', // [可选] 在哪个模板插入当前模板 (默认: 在 templates/box.hbs 的{{outlet}}位置插入)
          outlet: 'modal', // [可选] 指定outlet名称, 改变插入模板的位置 (在 box.hbs 的{{outlet "modal"}}位置插入)
          controller: 'controllerName', // [可选] 指定模板的controller名称 (controllers/controllerName.js.es6)
          // model: model // [可选] 给 controller 设定model (controller里: this.get('model'))
        });
      },
      model() { // 初始化数据: 获得model 给controller 使用
        return ajax('/about.json').then(json => json);
      },
      setupController(controller, model) { // 给controller设置属性 (如省略: 默认给controller设置: `model`等于model()方法的返回值)
        controller.setProperties({ model });
      },
      resetController(controller, isExiting, transition) { // model改变或离开路由时, 修改属性
        if (isExiting) controller.setProperties({ page: 1 });
      }
      actions: { // 定义方法
        willTransition(transition) { // [自带事件] 离开当前路由
          // transition.abort(); // 终止路由的转换
        },
        didTransition() { // [自带事件] 路由完成转换(在beforeModel,model,afterModel,setupController后执行)
          // return true; // 冒泡: 触发父路由的willTransition方法
        },
        alert(msg) { // 定义事件给模板使用: <a {{action "alert" "test"}}>test</a>
          alert(msg);
        }
      }
    });
  • Methods/Properties:

    • queryParams: query的变量可直接在 hbs 模板使用

      • refreshModel: true // queryParams值改变, 触发: model() 方法
    • titleToken() // 设定页面的 title
    • beforeModel() 在model方法前执行
    • afterModel() 在model方法后执行
    • redirect() 页面跳转逻辑
    • activate/deactivate 进入路由/退出路由时执行
  • 路由跳转: this.transitionTo('posts', { queryParams: { page: 1 } }) or this.replaceWith('discovery.latest')
  • console.log(Discourse.__container__.lookup('router:main').currentRouteName) 打印当前路由
  • console.log(Discourse.__container__.lookup('router:main')._routerMicrolib.recognizer.names) 打印所有路由
  • 延伸: Creating Routes in Discourse and Showing Data

Controllers

  • 定义控制器 (controller已经被弱化, 功能大都可以在route实现)

    // assets/javascripts/discourse/controllers/*.js.es6
    export default Ember.Controller.extend({
      queryParams: ['page'], // 定义查询参数
      page: 1, // 设置 queryParams 默认值
      application: Ember.inject.controller('application'), // 定义属性给自己/模板使用
      path: Ember.computed.alias('application.currentPath'),
      init() { // 控制器初始化
        this.set('list', []);
      },
      actions: { // 定义事件给模板使用
        loadMore () {
          const p = this.get('page') + 1;
          this.set('page', p); // 更新queryParams, 触发路由的model()更新数据
        }
      }
    });
  • 引用其他控制器: this.controllerFor('user'), controllers/*.js.es6 文件必须存在
  • 路由跳转: this.transitionToRoute('posts', { queryParams: { page: 1 } })

Models

  • 定义模型 (插件开发一般用不到)
// app/assets/javascripts/discourse/models/test.js.es6
import { ajax } from "discourse/lib/ajax";
const Test = Discourse.Model.extend({});
Test.reopenClass({ // 覆盖或扩展类
  findAll() {
    return ajax('/about.json').then(json => json);
  },
});
export default Test;
// 使用:
// import Test from "discourse/plugins/DiscourseTest/discourse/models/test";
// Test.findAll().then(json => console.warn({ json })); // 取数据
  • store 数据仓库 (已经加载的数据自动缓存), router/controller/component/widget 里都可以使用 this.store

    // find: 从store中获取id为1的数据 (第1个参数是model类名, 第2个参数对象的id值)
    this.store.find('test', 1); // request: /tests/1
    this.store.findAll('test'); // request: /tests

    需要 plugin.rb 创建 /tests 的顶级路由, 和对应的 controller.

Templates

  • assets/javascripts/discourse/templates/**.hbs
  • {{#if xx }} Y {{else if isTest}} test {{else}} N {/if}} if...else
  • {{#unless readOnly}} ... {{/unless}} unless
  • {{#each items as |item index|}} {{item.name}} {{/each}} 循环
  • 调用 route/controller 里定义的action

    • <a onclick={{action "save"}}> 点击调用controller的action, 可简写成<a {{action "save"}}>
    • <a {{route-action "save" model}}></a> 点击调用route的action (传参 model)
    • <a {{action "save" (hash test=true)}}> 用hash helper传递 键值对 到 action
    • <a {{action 'save' bubbles=false preventDefault=false}}> 阻止冒泡: bubbles=false, 取消 阻止默认行为: preventDefault=false
  • attr属性绑定

    • <div class="{{if isActive 'active' 'none'}}> 动态class
    • {{d-button class=(if isActive 'active' 'none')}} 组件动态class
    • {{d-button classNameBindings="isActive:active:none"}} 组件动态class(这种方式比较少了)
  • 表单元素

    • <input onchange={{action "changeValue"}} /> HTML元素
    • {{input type="checkbox" change=(action "changeValue")}} 自带组件
    • {{textarea type="text" focus-out=(action "changeValue")}} 自带组件 onfocusout
  • 超链接:

    • <a href={{link}} target="_blank"> TEST </a> HTML元素
    • {{link-to 'search' (query-params page=1)}} TEST {{/link-to}} 自带组件: 链接到search路由, 传递queryParams: page=1
  • {{partial 'test'}} 输出 controller 指定的 templates/test.hbs
  • console.log(Ember.TEMPLATES) 打印所有的templates
  • {{log model}} 可以打印模板的变量

Helpers

  • assets/javascripts/helpers/*.js.es6 会被自动加载
  • 方式1 import { registerUnbound } from "discourse-common/lib/helpers"; registerUnbound('eq', (param1, param2) => {});
  • 方式2 import { registerHelper } from "discourse-common/lib/helpers"; registerHelper('eq', (params, hash) => {});
  • 方式3 Writing Helpers

    // assets/javascripts/helpers/custom-field-validation.js.es6
    function validation([type, title, key], hash) { // params, hash
      console.warn({ type, title, key }, hash);
    }
    export default Ember.Helper.helper(validation);
    // {{custom-field-validation value.type value.title value.key test=true}}
  • 方式2/3, 参数支持 es6解构 ([type, title, key], { test }), (推荐用1/2方式, 遵循discourse的约定)
  • .hbs文件里使用 {{helper-name arg1 arg2}}
  • *注: hbs`...` 方法里不能调用helper, 会提示: not defined

Components

  • components包含2个部分: js定义行为, hbs 定义UI.
  • 定义组件: 例子 login-button, 点击显示登录界面

    // assets/javascripts/discourse/components/login-button.js.es6
    // .hbs里使用: {{#login-button class="btn"}} Login {{/login-button}}
    export default Ember.Component.extend({
      tagName: 'a',
      classNames: ['loginBtn'],
      click() {
        const application = Ember.getOwner(this).lookup('route:application');
        application.send('showLogin');
      },
      // actions: {}
    });
    {{!-- assets/javascripts/discourse/templates/components/login-button.hbs --}}
    {{yield}}
  • 指定 某个hbs模板(init时)

    • this.set('layoutName', 'components/my-test') (Ember < 2.2)
    • this.set('layout', api.container.lookup('template:components/my-test')); (Ember >= 2.2)
  • Adding Ember Components to Discourse, 插件修改Components
  • Ember Component
  • Methods/Properties: init, didReceiveAttrs(收到attrs), willRender, didInsertElement, didRender

    • buildArgs 传递 props到 widgets 的 attrs
  • 表单组件

    <!-- input props: placeholder, maxlength, readonly, checked, disabled -->
    {{input type="text" value=value}}
    {{textarea value=name cols="80" rows="6"}}
    <!-- dropdown/checkbox(初始values必须为数组) -->
    {{multi-select allowAny=false maximum=10 content=types values=values
      valueAttribute="id" nameProperty="name"
      minimum=1}}
    <!-- select/radio -->
    {{combo-box value=value content=options none="plugins.placeholder_select"
      valueAttribute="id" nameProperty="name" onSelect=(action "statusChanged")
      isDisabled=readOnly}}
    <!-- radio-button -->
    {{radio-button name="upload" id="local" value="local" selection=selection}}
    {{radio-button name="upload" id="remote" value="remote" selection=selection}}
    <!-- admin: string array(自由创建数组内容) -->
    {{value-list values=images inputType="array" addKey="plugins.add_options"}}
    <!-- markdown editor -->
    {{d-editor value='markdown'}}
    <!-- ace-editor -->
    {{ace-editor mode="html_ruby" content=buffered.content}}
    <!-- 类似 input type="text" -->
    {{text-field value="slug" placeholderKey="category.slug_placeholder" maxlength="255"}}
    <!-- button -->
    {{d-button action=(action "addValue") actionParam=value icon="plus" translatedLabel='Add' class="add-value-btn btn-small"}}
    <!-- loading -->
    {{conditional-loading-spinner condition=loading}}
    <!-- 同时添加多个 key value 多个\n切分 -->
    {{secret-value-list values="firstKey|FirstValue"}}

Widgets

  • Widget是一个带有html() 函数的类,它生成渲染自身的虚拟dom (类似简化版的 Component).
  • 定义Widget

    // assets/javascripts/discourse/widgets/menus.js.es6
    import { createWidget } from "discourse/widgets/widget";
    import { h } from "virtual-dom";
    createWidget('menus', {
      tagName: 'div.menus',
      html(attrs, state) { // 输出要渲染的html内容
        return [
          h('div.list', { className: 'box' }, [ // div.list.box
            h('a', 'menu1'),
            h('a', 'menu2'),
          ])
        ];
      },
      click() { // widget被点击
      },
      clickOutside() { // 点击widget外部
      },
      // 除了click, 还有 drag/keyUp/keyDown
    });
  • hbs里引入Widget: {{mount-widget widget="widget-name" args=(hash param1=123)}}引入
  • Widget里引入Widget: this.attach(name, attrs, props);
  • 插件里引入Widget helper.attach(name, attrs, { model: post });
  • this.sendWidgetAction(actionName) 调当前Widget或上级Widget的 action
  • this.queueRerender() 重新渲染自己(Components/Widgets)
  • 输出富文本HTML内容: RawHtml({ html: `<span>${html}</span>` })h('i.iconfont', { innerHTML: $html })

Connectors

  • connector 由 hbs和js 组成, 会渲染到 {{plugin-outlet name="FolderName"}} 语法指定的位置
  • 创建connector (在模板文件里的 {{plugin-outlet name="above-site-header"}} 位置渲染)

    <!-- assets/javascripts/discourse/templates/connectors/above-site-header/topbar.hbs -->
    <h4>{{title}}</h4>
    // assets/javascripts/discourse/templates/connectors/above-site-header/topbar.js.es6
    export default {
      setupComponent(_, ctx) {
        ctx.set('title', 'ABOVE-SITE-HEADER');
      },
      shouldRender(_, ctx) {
        return true; // show: true, hide: false
      },
      // actions: {},
    };
  • Plugin Outlets for Ember 2.10
  • {{~raw-plugin-outlet name="topic-list-after-title"}} 会渲染 connectors/topic-list-after-title/*.raw.hbs 的内容

Settings

  • config/settings.yml 设置后台的插件配置项, 可以参考 site_settings.yml 里面的说明

    • How to add settings to your Discourse theme
    • default: 默认值
    • client: true 变量给JavaScript使用
    • type: 字段类型, 比如 upload, list(多选), enum(单选)
    • list_type: compact (list 一行展示)
    • allow_any: false (list 限制 不能添加自定义内容)
    • refresh: 后台修改配置, 前端取变量时 会自动更新 (不需要reload页面)
  • 增加FontAwesome图标: Admin/Settings - 搜索 svg_icon_subset
  • 强制静态文件https: Admin/Settings - 勾选 `force https

Emberjs/Handlebars

  • Ember-Teach教程目录
  • Ember.run.next(() => {}); // setTimeout (run.later 1ms)
  • Ember.run.scheduleOnce('afterRender', () => {}); // 所有渲染任务完成后(DOM树更新后, 在最终插入之前)
  • afterRender比run.next的优势: 元素呈现到屏幕之前执行, 防止渲染后闪烁灯问题. 而run.next依赖setTimeout, 具有不确定性.
  • Em.A([]), Em.Object.create({}) 创建的Array/Object 可以使用Ember的方法/属性
  • Array 变量 要 pushObject 才会触发 propertyChange
  • Ember.typeOf() // 变量类型

Examples

  • 路由跳转: redirect /categories to /latest

    api.modifyClass('route:discovery.categories', {
      redirect() {
        this.replaceWith('discovery.latest')
      }
    });
  • 修改默认的首页

    • assets/javascripts/discourse/homepage-route-map.js.es6

      export default function () {
        this.route('homepage', { path: '/home' });
      }
    • assets/javascripts/discourse/routes/homepage.js.es6

      export default Discourse.Route.extend({});
    • assets/javascripts/discourse/templates/homepage.hbs

      <h1>Hi</h1>
    • assets/javascripts/initializers/discourse-test.js.es6

      import { setDefaultHomepage } from "discourse/lib/utilities";
      setDefaultHomepage('home');
    • (可选) app/controllers/discourse-test/homepage_controller.rb

      class HomepageController < ApplicationController
      end
    • (可选) config/routes.rb

      Discourse::Application.routes.append do
        get '/home' => 'homepage#index' # homepage_controller.rb
      end

Faqs

  • rm -rf ./tmp # 如发现修改code不生效, 可尝试 删除缓存并重启
  • rm -rf ~/Library/Logs/DiagnosticReports/* 删除 crash log
  • rm -rf ./log/development.log 删除 dev log
  • scss 里的颜色 尽量使用 dark-light-diff 方法, 因为论坛有 light/dark 模式切换
  • 隐藏开发环境的 mini-profiler(左上角浮动栏), 快捷键alt+p
  • htmlSafe:

    • Components里: Ember.String.htmlSafe(), ${}.htmlSafe(); (deprecated: new Handlebars.SafeString())
    • Widgets里插入HTML: RawHtml({ html: `<span>${html}</span>` })h('i.iconfont', { innerHTML: '' }) (不支持 SafeString)
    • 不用htmlSafe, 在.hbs里, 可以使用 {{{variable}}} 三个括号显示HTML字符串
    • XSS: 用户输入的内容不使用htmlSafe; 需要拼接可以用 escapeExpression()转义 (import { escapeExpression } from "discourse/lib/utilities";)
  • 引用插件内的静态图片: public/images/logo.png => <img src="/plugins/DiscourseTest/images/logo.png">
  • 按钮点击无效: 如果console没报错, 检查是否是Chrome插件影响的. 比如: Enable Copy
  • Modal

    • bootbox.confirm, bootbox.alert, bootbox.prompt, bootbox.dialog, dialog.modal('hide')
    • import showModal from "discourse/lib/show-modal"; render template
  • icons

  • Badges 勋章设置
    Show badge on the public badges page: 在 /badges 显示本勋章
  • 设定全局变量给所有模板使用:

    • Discourse.Site.currentProp('appView', true); hbs: {{#if site.appView}}
      或: this.site.setProperties({ appView: true });
    • Discourse.SiteSettings.appView = true; hbs: {{#if siteSettings.appView}}
  • console.log(Discourse.Site.current()) 打印 this.site
  • console.log(Discourse.SiteSettings) 打印 siteSettings
  • 暂不能用...object(扩展运算符)传递多个attrs, Splat redux with es6-ish syntax
  • 坑的地方:

    • discourse插件文档是论坛帖子, 查找/筛选很麻烦, 需结合Google/GitHub/论坛 综合筛选.
    • discourse/ember都有很多过时的内容和示例.
    • Ember的现成组件比较少, 而且有很多不太维护了, 可能需要自己写.

参考

/ 分类: 开发,实践 / TrackBackhttps://xhl.me/archives/discourse-plugin-development/trackback标签: none

添加新评论 »