discourse 插件开发

栏目: Ruby · 发布时间: 5年前

内容简介:开发discourse插件, 依赖的知识: ES6/SCSS/Ember.js/Rails/handlebars.Ember是前端MVVM框架, 支持数据双向绑定/虚拟DOM, 模板引擎使用handlebars, 依赖jQuery(处理DOM兼容性操作), 遵循依赖: ruby, postgres, redis 需要提前安装好.

开发discourse插件, 依赖的知识: ES6/SCSS/Ember.js/Rails/handlebars.

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

discourse 本地环境搭建

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

  • macOS 安装 Ruby 2.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 up -d -f db.yml # 启动 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
bundle install; # 安装依赖
bundle exec 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('test@test.com'); 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 = git@github.com/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/slick@1.8.0/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) { // [可选] 设定当前路由的渲染逻辑
        // render: 渲染一个模板到另一个模板的某个位置(位置 在模板里用 {{outlet}} 表示)
        this.render('test', { // [可选] 待渲染的模板名称 (templates/test.hbs)
          into: 'application', // [可选] 在哪个模板插入当前模板 (在 templates/application.hbs 的{{outlet}}位置插入)
          outlet: 'modal', // [可选] 指定outlet名称, 改变插入模板的位置 (在 application.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'}}>
    {{d-button class=(if isActive 'active' 'none')}}
    {{d-button classNameBindings="isActive:active:none"}}
  • 表单元素

    • <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>
    {{link-to 'search' (query-params page=1)}} TEST {{/link-to}}
  • {{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}}
  • 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的现成组件比较少, 而且有很多不太维护了, 可能需要自己写.

参考


以上所述就是小编给大家介绍的《discourse 插件开发》,希望对大家有所帮助,如果大家有任何疑问请给我留言,小编会及时回复大家的。在此也非常感谢大家对 码农网 的支持!

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

Hacking Growth

Hacking Growth

Sean Ellis、Morgan Brown / Crown Business / 2017-4-25 / USD 29.00

The definitive playbook by the pioneers of Growth Hacking, one of the hottest business methodologies in Silicon Valley and beyond. It seems hard to believe today, but there was a time when Airbnb w......一起来看看 《Hacking Growth》 这本书的介绍吧!

JSON 在线解析
JSON 在线解析

在线 JSON 格式化工具

RGB转16进制工具
RGB转16进制工具

RGB HEX 互转工具

RGB HSV 转换
RGB HSV 转换

RGB HSV 互转工具