开发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.es6
的 initialize
内使用.
- 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:
before
或after
还可以指定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 } })
orthis.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). // 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 logrm -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";
)
- Components里: Ember.String.htmlSafe(),
- 引用插件内的静态图片:
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
- Font Awesome 5 and SVG icons
- 自定义icon: (plugin support for custom icons)(https://github.com/discourse/discourse/commit/47cbfb1)
如果未生效, 尝试rm -rf ./tmp
再启动论坛 - api.replaceIcon('far-eye', 'ak-icon-eye'); 替换默认的icon
- 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.siteconsole.log(Discourse.SiteSettings)
打印 siteSettings- 暂不能用...object(扩展运算符)传递多个attrs, Splat redux with es6-ish syntax
坑的地方:
- discourse插件文档是论坛帖子, 查找/筛选很麻烦, 需结合Google/GitHub/论坛 综合筛选.
- discourse/ember都有很多过时的内容和示例.
- Ember的现成组件比较少, 而且有很多不太维护了, 可能需要自己写.
参考
- Beginner’s Guide to Creating Discourse Plugins
- Install Plugins
- Discourse后端接口文档 方便理解discourse的字段结构
- Developer’s guide to Discourse Themes
- How to create a Discourse plugin
plugins 参考:
- 自带的插件, ruby 钩子, js api
- discourse-plugin-template 路由和样式
- discourse-solved, 官方插件, 有Settings配置 (需要: Discourse 2.3.0.beta5 or above)
- discourse-events, 帖子设定时间范围
- Babble 在线聊天插件
- discourse-translator 翻译帖子
- discourse-adplugin 投放广告
- discourse-layouts 侧边栏中显示小部件或广告