Discourse 开发者文档
Discourse 开发者文档。内容保持上游原样。
- Developer Guides
- Discourse Developer Docs
- index
- Introduction to Discourse Development
- Developing Discourse using a Dev Container
- Developing Discourse on GitHub Codespaces
- Install Discourse for development using Docker
- Install Discourse on macOS for development
- Install Discourse on Ubuntu or Debian for Development
- Install Discourse on Windows for development
- Install plugins in your non-Docker development environment
- Set up a local Discourse Development Environment?
- Set up a multisite development environment
- Set up Discourse for development on Fedora Linux
- Add Ember Components to Discourse
- The UI kit of shared Discourse components, helpers, and modifiers
- Automatically lint and format code before commits
- Write acceptance tests and component tests for Ember code in Discourse
- How to run Discourse core, plugin and theme QUnit test suites
- Pinning plugin and theme versions for older Discourse installs (d-compat branches)
- Ember object ownership (getOwner, service injections, etc.)
- Using Google's 'tachometer' to measure JS performance changes in Discourse
- Setup Continuous Integration using GitHub Actions
- Developer's guide to Markdown extensions
- Converting modals from legacy controllers to new DModal component API
- Using the DModal API to render Modal windows (aka popups/dialogs) in Discourse
- Using the JS API
- Using Plugin Outlet Connectors from a Theme or Plugin
- Using modifyClass to change core behavior
- Creating Routes in Discourse and Showing Data
- Adding a new 'managed' authentication method to Discourse
- Preventing accidental serialization of ActiveRecord models
- (not recommended) Overriding Discourse templates from a Theme or Plugin
- Overview
- Write end-to-end system specs for the Discourse user interface
- Discourse toolkit to render forms.
- AppEvents Triggers Reference
- Using Transformers to customize client-side values and behavior
- Customizing the topic list
- Guidelines for CSS classes using BEM
- JavaScript type hinting & validation (typescript)
- Designing for Different Devices (Touch & Hover)
- Designing for Responsive Widths (Breakpoints, Viewport & Containers)
- Drag, resize, and gesture primitives
- Bundle splitting for plugins
- Private modules and the `-internals` convention
- Developing Discourse Plugins - Part 1 - Create a basic plugin
- Developing Discourse Plugins - Part 2 - Connect to a plugin outlet
- Developing Discourse Plugins - Part 3 - Add custom site settings
- Developing Discourse Plugins - Part 4 - Setup git
- Developing Discourse Plugins - Part 5 - Add an admin interface
- Developing Discourse Plugins - Part 6 - Add acceptance tests
- Developing Discourse Plugins - Part 7 - Publish your plugin
- Add a new locale from plugin
- Add a new provider to discourse-chat-integration
- Repackaging a markdown-it extension as a Discourse plugin
- Structuring a plugin for Rails autoloading
- Developing Discourse Themes & Theme Components
- Theme Developer Quick Reference Guide
- Designer's Guide to getting started with themes in Discourse
- Get started with Theme Creator and the Theme CLI
- Install the Discourse Theme CLI console app to help you build themes
- Structure of themes and theme components
- Split up theme Javascript into multiple files
- Split up theme SCSS into multiple files
- Add settings to your Discourse theme
- Objects type for theme setting
- Migrate Discourse theme settings
- Adding metadata and screenshots to a Theme
- Use Discourse Core Variables in your Theme
- Add localizable strings to themes and theme components
- End-to-end system testing for themes and theme components
- Minimizing Maintenance on Theme Customizations
- Change font sizes in your themes
- Include assets (e.g. images, fonts) in themes and components
- Replace Discourse's default SVG icons with custom icons in a theme
- Update themes and plugins to support automatic dark mode
- Theme modifiers: A brief introduction
- Insert text or images anywhere on your site using CSS pseudo-elements
- Create and share a font theme component
- Install a new language for Highlight.JS via a theme component
- Add custom content that only appears on your homepage
- Message Format support for localization
- Change icons globally
- Alternative icons
- Change the style of a wiki post
- Create and share a color scheme
- Use the `navigation_menu` query parameter to preview the sidebar or other options
- Customize posts' contents with your own styles
- Adding preview screenshots to Discourse themes
- Controlling site settings with themes
- Things to consider before deciding to white-label the Discourse Mobile apps
- Create custom Automations
- Discourse Public Data Dump
- Overview
- Using the AI bot via the Discourse API
- Theme Developer Tutorial: 1. Introduction
- Theme Developer Tutorial: 2. Creating a remote theme
- Theme Developer Tutorial: 3. CSS in Themes
- Theme Developer Tutorial: 4. Using Outlets to insert and replace content
- Theme Developer Tutorial: 5. Building and using components
- Theme Developer Tutorial: 6. Using the JS API
- Theme Developer Tutorial: 7. Wrapping up
- ADMIN-QUICK-START-GUIDE
- AUTHORS
- Install Discourse on a Cloud Server
- INSTALL-email
- INSTALL
- List of Discourse Plugins
- Discourse Security
- Discourse Developer Testing Guide
- Contributor Covenant Code of Conduct
Developer Guides
Discourse Developer Docs
[!TIP] If you just want to browse the existing docs, head over to https://meta.discourse.org/c/documentation/10
This directory contains the source files for Discourse's developer documentation. Changes to the main branch are automatically published to https://meta.discourse.org/c/documentation/10
This was originally a separate repository at discourse/discourse-developer-docs, which has since been archived and merged into core.
Overview
This directory contains a script (./sync_docs) which takes the markdown files under docs/ and uses the Discourse API to mirror them to a Discourse category.
The same script can be used to provide a live preview of docs in your own non-production Discourse environment.
Directory Structure
Each doc is represented by a markdown file under docs/**/*.md. Directory/file names are only visible in the git repository, and are not synchronised to Discourse.
When synchronizing, directories/files are sorted lexicographically, so we can use two-digit numbers to define the order in which sections/topics will appear in the Discourse sidebar.
assets/ contains images which can be referenced by the docs.
Sections
Each section directory must have an index.md file which defines the section title in the YAML frontmatter. For example
---
title: My Section Title
---
Other content in the file is ignored.
Doc Files
Each doc markdown file must define frontmatter with three keys:
-
title: Used as the Discourse topic title -
short_title: Displayed in the sidebar -
id: A unique ID for the document. This is used to create a Discourse 'external_id' to associate a topic with the markdown file.Changing this ID will cause the associated topic to be deleted and recreated. Avoid unless absolutely necessary.
The doc content should be included below the frontmatter. It can include any markdown/bbcode supported by Discourse.
Example:
---
title: How to Develop themes for Discourse
short_title: Develop themes
id: theme-dev
---
How to develop themes:
1. Read the docs
2. Profit
Images
Images (e.g. screenshots) for docs can be stored in the /assets directory. To reference them from the markdown, use the standard markdown image syntax, with a path like /assets/my-image.png. For example:

The sync tool will automatically upload the images to Discourse, and replace the path with a discourse-specific upload:// path. A mapping of repo-path to discourse-path will be persisted in an HTML comment at the end of the doc's topic.
When a doc stops using an image, delete it from /assets. CI runs ./check_unused_assets to catch any that are left behind.
Avoid hotlinking images from other sources. If you do, Discourse may download them and update the topic content. This will cause the docs sync tool to detect a diff, and update the topic unnecessarily.
Links between docs
Link to other docs using a relative path to their markdown file, so the link works when browsing the repository. For example:
See the [DModal API](12-dmodal-api.md) or [theme settings](../05-themes-components/09-theme-settings.md).
The sync tool rewrites these to the corresponding Discourse topic. Heading fragments (#some-heading) are dropped, becasue Discourse anchors are generated differently. Relative links to files which aren't docs will fail validation.
Contributing
When working on substantial changes, you may like to set up a staging environment to preview your changes.
-
Prepare a Discourse instance (could be a local development environment, or a production site)
- Create a category for the docs
- Install discourse-doc-categories, and configure fully by adding the category & assigning an index topic
- Enable DiscoTOC for the category
- Ensure data-explorer is installed and enabled
-
Obtain an API key. This should be 'global' key, associated with an admin user account
-
Create a data explorer query with this content:
-- [params] -- int :category_id = 1 WITH index_topic_id AS ( SELECT value::int as index_topic_id FROM category_custom_fields WHERE category_id = :category_id AND name='doc_category_index_topic' ) SELECT t.id as t_id, p.id as first_p_id, t.external_id as external_id, title, raw, t.deleted_at, CASE WHEN index_topic_id=t.id THEN true ELSE false END as is_index_topic FROM topics t JOIN posts p on p.post_number = 1 AND p.topic_id = t.id JOIN index_topic_id ON true JOIN categories c ON c.id = t.category_id WHERE category_id = :category_id AND index_topic_id IS NOT NULL -- Prevents this query being used for non-doc categories AND (c.topic_id = index_topic_id OR c.topic_id != t.id) -- Skip category description topic, unless its also the indexand note the query's id number from the URL in your browser
-
Run the script in 'watch' mode:
bundle install DOCS_CATEGORY_ID=123 \ DOCS_DATA_EXPLORER_QUERY_ID=123 \ DOCS_TARGET="https://forum.example.com" \ DOCS_API_KEY=abc \ bundle exec ./sync_docs --watchFor debugging, the script accepts a
-vflag. -
Once the script has completed the first run, all the topics should be visible in Discourse. Any edits to markdown files will be synced instantaneously to Discourse.
index
Documentation about extending Discourse with plugins, themes, and theme components, working with the core code, and using the Discourse API.
Introduction to Discourse Development
Discourse is a modern and highly-customizable platform for building communities. This documentation is intended for anyone who wants to build a customization for Discourse, or work on Discourse itself.
Architecture
Discourse Core has a Ruby on Rails backend and an Ember JS frontend. This is the shared platform upon which Discourse communities are built.
Discourse Themes can be used to customize the frontend user experience using CSS and Javascript. Each theme has its own git repository, and community admins can generally install and manage them via the Discourse admin panel, even on shared hosting platforms.
Theme Components are themes which are intended for use alongside other Theme Components, as part of an overall Theme. From a development point of view, Theme Components and Themes are almost identical.
Discourse Plugins can customize the frontend in similar ways to themes. They can also customize the backend Ruby on Rails application, which allows them to introduce more extensive features. Plugins can only be installed by the server administrator, so their use may be restricted on shared hosting platforms.
Getting Started
To work on Discourse Core, Themes or Plugins, you'll need a community to develop against.
To develop Themes or Theme Components, you can use the discourse_theme CLI against our public 'Theme Creator', your own production site, or a dedicated development environment. Once you have a development environment, check out the Theme Beginners Guide.
To develop plugins or core, you'll need to set up a dedicated development environment. Once that's set up, check out the Plugin Beginners Guide.
For general information about developing Discourse core, Themes and Plugins, check out the Code Internals section.
Developing Discourse using a Dev Container
Dev Containers is an open standard for configuring a development environment inside a container. This almost entirely eliminates the need to install/configure Discourse-specific tools/dependencies on your local machine, and makes it very easy to keep up-to-date as Discourse evolves over time.
Dev Containers can be used in a number of different IDEs, or directly using their reference CLI. This guide will describe the setup process for VSCode.
Getting started
-
Download and install VSCode
-
Install the Dev Containers extension in VSCode
-
Clone the Discourse repository onto your machine
git clone https://github.com/discourse/discourse -
In VSCode, use
File->Open Folder, then choose the Discourse directory -
Open the folder in its Dev Container. This can be done via the popup prompt, or by opening the command palette (Cmd/Ctrl + Shift + P) and searching for "Open folder in container…"
-
If this is your first time launching a container, you will be prompted to install and start Docker Desktop. Once complete, go back to VSCode re-run "Open folder in container…"
-
Wait for the container to download and start. When it's done, the README will appear, and you'll see the Discourse filesystem in the sidebar.
-
Run the default build task using Cmd/Ctrl + Shift + B.
This will install dependencies, migrate the database, and start the server. It'll take a few minutes, especially on the lower-end machines. You'll see "Build successful" in the terminal when it's done.
-
Run the
dev/admin/createtask. Open the command palette and search for "Tasks: Run Tasks". It will present a menu of tasks; selectdev/admin/createoff of that list. You'll be prompted to enter an email address and a password for your admin user. -
Visit
http://localhost:3000in your browser to see your new Discourse instance -
All done! You can now make changes to Discourse's source code and see them reflected in the preview.
Running tests
The first time you run tests, you'll need to install testing dependencies, including Playwright and the discourse_test DB. You can run the deps/testing task to install those. Open the command palette, select "Tasks: Run Tasks". It will present a menu of tasks; select deps/testing off of that list.
Once the test dependencies are installed, you can run bin/lint, bin/qunit, or the system specs
Applying config/container updates
Every so often, the devcontainer config and the associated container image will be updated. VSCode should prompt you to "rebuild" to apply the changes. Alternatively, you can run "Dev Containers: Rebuild Container" from the VSCode command palette. The working directory, and your Redis/Postgres data will be preserved across rebuilds.
If you'd like to start from scratch with fresh database, you'll need to delete the discourse-pg and discourse-redis docker volumes. This can be done from the "Remote Explorer" tab of the VSCode sidebar.
Discourse's sample vscode .vscode/settings.json and .vscode/tasks.json will be copied when you first boot the codespace. From that point forward, if you want to use the latest sample config, you'll need to manually copy .vscode/settings.json.sample to .vscode/settings.json.
References
Developing Discourse on GitHub Codespaces
GitHub Codespaces is a very fast way to get started with Discourse development. Their free tier is good for 30 hours per month of a 4-core machine.
Getting Started
The VSCode environment will automatically be configured with our recommended settings and extensions, including automatic linting and formatting.
To minimize usage, make sure to run "Codespaces: Stop Current Codespace" from the command palette (Ctrl + Shift + P or Cmd + Shift + P) when you're finished. If you forget to do this, the Codespace should be shut down automatically after your account's configured idle time (default 30 mins). But, there are some situations where the codespace will not be detected as idle, so it's best to stop it deliberately.
Running tests
The first time you run tests, you'll need to install testing dependencies, including Playwright and the discourse_test DB. You can run the deps/testing task to install those. Open the command palette, select "Tasks: Run Tasks". It will present a menu of tasks; select deps/testing off of that list.
Once the test dependencies are installed, you can run bin/lint, bin/qunit, or the system specs
Tips
-
You can launch a codespace from specific branches/PRs - just visit it, and press ,
-
You can manage all your codespaces at https://github.com/codespaces/
-
Discourse's sample vscode
.vscode/settings.jsonand.vscode/tasks.jsonwill be copied when you first boot the codespace. From that point forward, if you want to use the latest sample config, you'll need to manually copy.vscode/settings.json.sampleto.vscode/settings.json.
Install Discourse for development using Docker
Developing using Docker
Since Discourse runs in Docker, you should be able to run Discourse directly from your source directory using a Discourse development container.
:white_check_mark: Pros: No need to install any system dependencies, no configuration needed at all for setting up a development environment quickly.
:x: Cons: Will be slightly slower than the native dev environment on Ubuntu, and much slower than a native install on MacOS.
See also https://meta.discourse.org/t/developing-discourse-using-a-dev-container/336366
Step 1: Install Docker
Ubuntu
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add -
sudo add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable"
sudo apt-get update
sudo apt-get install -y docker-ce
19.10
sudo apt-get install docker.io
sudo usermod -a -G docker $USER
sudo reboot
Windows WSL: Ubuntu
You can run the above commands within WSL but you must have all the files inside WSL file system. E.g. it works just fine if it's inside ~/discourse but it fails if it's placed inside /mnt/c/discourse.
MacOS
:warning: The Discourse development docker image is only available for x86_64 architectures. M1 Macs are capable of starting the image using architecture emulation, but Discourse is unlikely to boot due to the lack of inotify support in QEMU.
Instead, you should use https://meta.discourse.org/t/beginners-guide-to-install-discourse-on-macos-for-development/15772
Option 1: Download a packaged .dmg from the Docker store
Option 2: brew install docker
Step 2: Start Container
Clone Discourse repository to your local device.
git clone https://github.com/discourse/discourse.git
cd discourse
(from your source root)
d/boot_dev --init
# wait while:
# - dependencies are installed,
# - the database is migrated, and
# - an admin user is created (you'll need to interact with this)
# In one terminal:
d/rails s
# And in a separate terminal
d/dev --only ember
...then open a browser on http://localhost:3000 and voila!, you should see Discourse.
Plugin Symlinks
The Docker development flow supports symlinks under the plugins/ directory, with the following caveat:
Whenever a new plugin symlink is created, the Docker container must be restarted with:
d/shutdown_dev; d/boot_dev
Notes:
-
To test emails, run MailHog :
d/mailhog -
If there are missing gems, run:
d/bundle install -
If a db migration is needed:
d/rake db:migrate RAILS_ENV=development -
When you're done, you can choose to kill the Docker container with:
d/shutdown_dev -
Data is persisted between invocations of the container in your source root
tmp/postgresdirectory. If for any reason you want to reset your database run:sudo rm -fr data -
If you see errors like "permission denied while trying to connect to Docker", Run:
run `sudo usermod -aG docker ${USER}` sudo service docker restart -
If you wish to globally expose the ports from the container to the network (default off) use:
d/boot_dev -p -
The Dockerfile comes from discourse/discourse_docker on GitHub, in particular image/discourse_dev.
Running Tests
d/rake autospec
To run specific plugin tests, you can also do something like this:
d/rake plugin:spec["discourse-follow"]
Or even something like this to be even more specific:
my-machine:~/discourse$ d/shell
discourse@discourse:/src$ RAILS_ENV=test /src/bin/rspec plugins/discourse-follow/spec/lib/updater_spec.rb:37
Install Discourse on macOS for development
:warning: This guide covers installation instructions for a macOS development environment, for production guides see: https://meta.discourse.org/t/how-to-install-discourse-in-production/142537
So you want to set up Discourse on macOS to hack on and develop with?
We'll assume that you don't have Ruby/Rails/Postgres/Redis installed on your Mac. Let's begin :rocket: !
Install Discourse Dependencies
You will need the following packages on your system:
- Git
- rbenv or asdf
- ruby-build
- Ruby (latest stable)
- Rails
- PostgreSQL
- SQLite
- Redis
- Node.js
- pnpm
- libvips
- MailHog**
- ImageMagick**
** optional
restart your terminal
Now that we have installed Discourse dependencies, let's move on to install Discourse itself.
Restart your Terminal
Exit your shell and restarting it ensures that the paths to the installed packages are correctly picked up by the Terminal.
Clone Discourse
Clone the Discourse repository in ~/discourse folder:
git clone https://github.com/discourse/discourse.git ~/discourse
~ indicates home folder, so Discourse source code will be available in your home folder.
Bootstrap Discourse
Switch to your Discourse folder:
cd ~/discourse
Install the needed gems
bundle install
Install the JS dependencies
pnpm install
Next, run these commands to set up your local Discourse instance:
bundle exec rake db:create
bundle exec rake db:migrate
RAILS_ENV=test bundle exec rake db:create db:migrate
Start rails + Ember servers, you have two options here.
Option 1: using two separate Terminal tabs/windows, run Rails and the frontend bundler separately via
bundle exec rails server
and
bin/dev --only ember
Option 2: using only one Terminal tab/window:
bin/dev # runs Pitchfork and the frontend bundler together
:tada: You should now be able to navigate to http://localhost:3000 to see your local Discourse installation. (Note that the first load can take up to a minute as the server is warmed up.)
You can also try running the specs:
bundle exec rake autospec
All (or almost all) the tests should pass.
Create New Admin
To create a new admin, run the following command:
RAILS_ENV=development bundle exec rake admin:create
Follow the prompts to create an admin account.
Configure Mail
Run MailHog:
mailhog
Congratulations! You are now the admin of your own Discourse installation!
Happy hacking! And to get started with that, see Beginner’s Guide to Creating Discourse Plugins.
Install Discourse on Ubuntu or Debian for Development
:warning: This guide covers installation instructions in a development environment. For a production guide see: https://meta.discourse.org/t/how-to-install-discourse-in-production/142537
So you want to set up Discourse on Ubuntu or Debian to hack on and develop with?
We'll assume that you work locally and don't have Ruby/Rails/Postgres/Redis installed on your Ubuntu or Debian system. Let's begin!
Requirements
We suggest having at least 4 GB RAM and 2 CPU cores.
Current compatibility:
| OS | Compatibility |
|---|---|
| Debian 11 | ✅ |
| Crostini (Linux on ChromeOS) | ✅ |
| Ubuntu 22.04 or later | ✅ |
Install Discourse Dependencies
You will need the following packages on your system:
- Git
- rbenv or asdf
- ruby-build
- Ruby (latest stable)
- Rails
- PostgreSQL
- SQLite
- Redis
- Node.js
- pnpm
- libvips
- MailHog**
- ImageMagick**
** optional
Now that we have installed Discourse dependencies, let's move on to install Discourse itself.
Clone Discourse
Clone the Discourse repository in ~/discourse folder:
git clone https://github.com/discourse/discourse.git ~/discourse
~ indicates home folder, so Discourse source code will be available in your home folder.
Setup Database
Create role with the same name as your Linux system username:
cd /tmp && sudo -u postgres createuser -s "$USER"
[/details]
Bootstrap Discourse
Switch to your Discourse folder:
cd ~/discourse
Install the needed gems
source ~/.bashrc
bundle install
Install the JS dependencies
pnpm install
Now that you have successfully installed gems, run these commands:
bin/rails db:create
bin/rails db:migrate
RAILS_ENV=test bin/rails db:create db:migrate
Start rails and the frontend bundler:
bin/dev
If the images are not appearing, use this command instead: (you can also specify an IP if you are working on a remote server)
DISCOURSE_HOSTNAME=localhost UNICORN_LISTENER=localhost:3000 bin/dev
You should now be able to navigate to http://localhost:3000 to see your local Discourse installation.
Create New Admin
To create a new admin, run the following command:
bin/rails admin:create
Follow the prompts, and a new admin account will be created.
Configure Mail
Run MailHog:
mailhog
Congratulations! You are now the admin of your own Discourse installation!
Happy hacking! And to get started with that, see Beginner’s Guide to Creating Discourse Plugins.
Last Reviewed by @blake on [date=2023-04-03 timezone="America/Boise"]
Install Discourse on Windows for development
:information_source: This tutorial has been tested on Windows 10 and 11.
To set up a development environment for Discourse on Windows, you can do it using Windows Subsystem for Linux feature.
This setup requires the WSL 2 installation. It is only available in Windows 10 builds 18917 or higher. We’ll assume that you already installed Windows Subsystem for Linux 2 (Ubuntu) on your Windows 10 system. WARNING: Install Ubuntu 18.04, and not 20.04 since some installations will fail on 20.04. For more information see June 30th, 2020 notes at the bottom of this post.
Let’s begin!
Installing Discourse
- Initially follow the steps from the topic Beginners Guide to Install Discourse on Ubuntu for Development until the step Clone Discourse.
[quote="Arpit Jalan, post:1, topic:14727, username:techAPJ"]
Install Discourse Dependencies
You will need the following packages on your system:
- Git
- rbenv or asdf
- ruby-build
- Ruby (latest stable)
- Rails
- PostgreSQL
- SQLite
- Redis
- Node.js
- pnpm
- libvips
- MailHog**
- ImageMagick**
** optional
Now that we have installed Discourse dependencies, let’s move on to install Discourse itself. [/quote]
-
Before setting up the database you have to start PostgreSQL service & Redis server manually using following commands
sudo service postgresql start redis-server --daemonize yes -
Then go through all the remaining steps of the Ubuntu guide.
[quote="Arpit Jalan, post:1, topic:14727, username:techAPJ"]
Clone Discourse
Clone the Discourse repository in ~/discourse folder:
git clone https://github.com/discourse/discourse.git ~/discourse
~ indicates home folder, so Discourse source code will be available in your home folder.
Setup Database
Create role with the same name as your ubuntu system username:
sudo -u postgres createuser -s "$USER"
Bootstrap Discourse
Switch to your Discourse folder:
cd ~/discourse
Install the needed gems
source ~/.bashrc
bundle install
Now that you have successfully installed gems, run these commands:
bundle exec rake db:create
bundle exec rake db:migrate
RAILS_ENV=test bundle exec rake db:create db:migrate
Try running the specs:
bundle exec rake autospec
All the tests should pass.
Start rails server:
bundle exec rails server
You should now be able to connect with your Discourse app on http://localhost:3000 - try it out!
In a separate terminal instance, navigate to your discourse folder (cd ~/discourse) and run:
bin/dev --only ember
This starts the frontend bundler; the app remains served from http://localhost:3000. [/quote]
Creating a Command to Start Discourse
Now your development environment is almost ready. The only problem is every time when you open Ubuntu on Windows you have to start the PostgreSQL service & Redis server manually. Don't worry we can have a workaround for this by creating a custom command :wink:
cd ~
Create a new file using the command nano start-discourse and paste the content below then save and exit.
#!/bin/bash
# to start PostgreSQL
sudo service postgresql start
# to start Redis server
redis-server --daemonize yes
Now modify the CHMOD using below command
chmod +x start-discourse
And copy the file to your bin folder
sudo cp start-discourse /usr/bin/
It's done. Now, whenever you open the Ubuntu bash just run the command below and start developing :+1:
start-discourse
Alternatively, if you are using Windows 10 enterprise, pro, or education edition then you can create a Linux virtual machine in hyper-v to set up the Discourse dev environment.
Notes About Windows Environment
[details=As of June 30, 2020:] [quote="Andrea Habib, post:56, topic:75149, username:AndreaHabib"] With the introduction of Windows 10 build 2004 and WSL 2 and many of you might run into some issues since Discourse now requires WSL 2 to actually run. For starters: If you haven’t already, you would want to update your system to Windows 2004 (19041) Note: Don’t install using Windows Update in the system settings as you might get errors mid-installation. Also make sure you are on Windows 1903 (18362) or 1909 (18363), you can search winver for that.
- Go to: Download Windows 10
- Click the Update Now button under Windows 10 May 2020 Update to download the update assistant and let it install and finish
- If you get any errors, then download the media creation tool, run it, and create a bootable flash drive (at least 8GB flash) and use it to install Windows.
When you are done with the Windows installation, you will need to manually install WSL 2 But first, we need to make sure that 2 features are enabled:
- Virtual Machine Platform which can be enabled directly from your Windows by going to Programs and Features and then enabling Optional Features in Turn Windows features on or off
- The other is Virtualization which can be only enabled from your BIOS. However this option can vary from one motherboard to the other. eg: on AMD it’s called SVM. So please look up your specific motherboard.
Now that you have both features enabled:
- Install Ubuntu 18.04, and not 20.04 since some installations will fail on 20.04.
- Go here and update Linux Kernel: Link in replies
- Go to this link and follow the steps: Install Windows Subsystem for Linux (WSL) on Windows 10 | Microsoft Docs
- Go to powershell (run as administrator) and run:
wsl --set-version Ubuntu-18.04 2orwsl --set-version Ubuntu 2, whichever distro shows up inwsl -l -v - Conversion takes time, a few minutes
- Run
wsl -l -vto make sure that Ubuntu is running on WSL 2
Now you can run Ubuntu-18.04 or Windows Terminal with Ubuntu and start installing the dependencies and tools
Note: If you are running into an error installing one of the gems during bundle install: Make sure you are on Ubuntu-18.04 and not 20.04
However, if you already have postgreSQL 12 and running on port 5432, you might run into some issues, since the dependencies install postgreSQL 10, which might increment the port to 5433. To fix that:
- Open your folder in a text editor (VS Code for example)
- Go to
config/database.yml - Under development and under adapter, add a new line and then add: port: 5433
- Make sure you do the same for test and profile.
If the above doesn’t work, make sure to check if PostgreSQL is running and at which port via pg_isready. Then follow the same instructions above, setting the port equal to the one the database is running on.
FOR THE LINUX KERNEL: Updating the WSL 2 Linux kernel | Microsoft Docs [/quote]
[quote="Ricky Chon, post:61, topic:75149, username:RickyC0626"]
A solution that does not require explicit port configuration in database.yml
Some errors during database creation and migration:
Invalid Redis connection to a specific port / connection refusal
bundle exec rake db:create
rake aborted!
Redis::CannotConnectError: Error connecting to Redis on localhost:6379 (Errno::ECONNREFUSED)
...
Caused by:
Errno::ECONNREFUSED: Connection refused - connect(2) for 127.0.0.1:6379
...
Caused by:
IO::EINPROGRESSWaitWritable: Operation now in progress - connect(2) would block
...
Tasks: TOP => db:create => db:load_config => environment
(See full trace by running task with --trace)
From this error we can see that the port Redis uses is 6379, and we can fix this by starting the Redis server via that port.
By default, redis-server --daemonize yes should work, but if not, use:
redis-server --daemonize yes --port 6379
Check status of Redis instance:
redis-cli
127.0.0.1:6379> ping
PONG
Invalid connection to psql port
bundle exec rake db:migrate
PG::ConnectionBad: could not connect to server: No such file or directory
Is the server running locally and accepting
connections on Unix domain socket "/var/run/postgresql/.s.PGSQL.5432"?
From this error we can see that PostgreSQL is having trouble connecting to port 5432. Usually starting the service should work
sudo service postgresql start
If this still doesn’t work and the error persists, try checking to see which port the service is listening from, and if necessary, change the port to the one you want in postgresql.conf:
pg_isready
/var/run/postgresql:5432 - no response
or
/var/run/postgresql:5432 - accepting connections
The Result
Once we have done the above or executed the start-discourse command, the two instances should run on their default/specified ports. To check their status via the default Windows cmd terminal, we can run:
netstat -anop tcp
which will show something like this:
Proto Local Address Foreign Address State PID
TCP 127.0.0.1:5432 0.0.0.0:0 LISTENING 17768
TCP 127.0.0.1:6379 0.0.0.0:0 LISTENING 17768
We have now confirmed that both our postgresql and redis-server instances are running.
We can also check the status of the instances on Ubuntu or WSL, with the following commands:
lsof -i
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
mailcatch 3244 rickyc0626 7u IPv4 36127 0t0 TCP localhost:1025 (LISTEN)
mailcatch 3244 rickyc0626 8u IPv4 36128 0t0 TCP *:socks (LISTEN)
redis-ser 3287 rickyc0626 6u IPv6 29352 0t0 TCP *:6379 (LISTEN)
redis-ser 3287 rickyc0626 7u IPv6 29353 0t0 TCP *:6379 (LISTEN)
sudo ss -plunt | grep postgres
tcp LISTEN 0 128 127.0.0.1:5432 0.0.0.0:* users:(("postgres",pid=3070,fd=7))
pg_lsclusters
Ver Cluster Port Status Owner Data directory Log file
10 main 5432 online postgres /var/lib/postgresql/10/main /var/log/postgresql/postgresql-10-main.log
From here, these commands should work with no major issues, without needing to modify the database.yml file:
bundle exec rake db:create
bundle exec rake db:migrate
RAILS_ENV=test bundle exec rake db:create db:migrate
Any further issues that show up can be addressed in the future. [/quote]
[/details]
[details=As of July 1, 2020]
[quote="Ricky Chon, post:61, topic:75149, username:RickyC0626"]
A solution that does not require explicit port configuration in database.yml
Some errors during database creation and migration:
Invalid Redis connection to a specific port / connection refusal
bundle exec rake db:create
rake aborted!
Redis::CannotConnectError: Error connecting to Redis on localhost:6379 (Errno::ECONNREFUSED)
...
Caused by:
Errno::ECONNREFUSED: Connection refused - connect(2) for 127.0.0.1:6379
...
Caused by:
IO::EINPROGRESSWaitWritable: Operation now in progress - connect(2) would block
...
Tasks: TOP => db:create => db:load_config => environment
(See full trace by running task with --trace)
From this error we can see that the port Redis uses is 6379, and we can fix this by starting the Redis server via that port.
By default, redis-server --daemonize yes should work, but if not, use:
redis-server --daemonize yes --port 6379
Check status of Redis instance:
redis-cli
127.0.0.1:6379> ping
PONG
Invalid connection to psql port
bundle exec rake db:migrate
PG::ConnectionBad: could not connect to server: No such file or directory
Is the server running locally and accepting
connections on Unix domain socket "/var/run/postgresql/.s.PGSQL.5432"?
From this error we can see that PostgreSQL is having trouble connecting to port 5432. Usually starting the service should work
sudo service postgresql start
If this still doesn’t work and the error persists, try checking to see which port the service is listening from, and if necessary, change the port to the one you want in postgresql.conf:
pg_isready
/var/run/postgresql:5432 - no response
or
/var/run/postgresql:5432 - accepting connections
The Result
Once we have done the above or executed the start-discourse command, the two instances should run on their default/specified ports. To check their status via the default Windows cmd terminal, we can run:
netstat -anop tcp
which will show something like this:
Proto Local Address Foreign Address State PID
TCP 127.0.0.1:5432 0.0.0.0:0 LISTENING 17768
TCP 127.0.0.1:6379 0.0.0.0:0 LISTENING 17768
We have now confirmed that both our postgresql and redis-server instances are running.
We can also check the status of the instances on Ubuntu or WSL, with the following commands:
lsof -i
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
mailcatch 3244 rickyc0626 7u IPv4 36127 0t0 TCP localhost:1025 (LISTEN)
mailcatch 3244 rickyc0626 8u IPv4 36128 0t0 TCP *:socks (LISTEN)
redis-ser 3287 rickyc0626 6u IPv6 29352 0t0 TCP *:6379 (LISTEN)
redis-ser 3287 rickyc0626 7u IPv6 29353 0t0 TCP *:6379 (LISTEN)
sudo ss -plunt | grep postgres
tcp LISTEN 0 128 127.0.0.1:5432 0.0.0.0:* users:(("postgres",pid=3070,fd=7))
pg_lsclusters
Ver Cluster Port Status Owner Data directory Log file
10 main 5432 online postgres /var/lib/postgresql/10/main /var/log/postgresql/postgresql-10-main.log
From here, these commands should work with no major issues, without needing to modify the database.yml file:
bundle exec rake db:create
bundle exec rake db:migrate
RAILS_ENV=test bundle exec rake db:create db:migrate
Any further issues that show up can be addressed in the future. [/quote]
[/details]
Last Reviewed by @SaraDev on [date=2022-06-15 time=19:00:00 timezone="America/Los_Angeles"]
Install plugins in your non-Docker development environment
(This guide only covers non-docker dev install. For Docker dev :whale: see https://meta.discourse.org/t/beginners-guide-to-install-discourse-for-development-using-docker/102009?u=merefield)
If you've followed the instructions to set up your local discourse, you can install a plugin locally:
-
Stop your local server if it's running.
-
Download the plugin repo and save it your
/pluginsfolder. Alternatively, you can use a symlink. -
If the plugin includes migrations (you'll see them in
db/migrate), run them with:LOAD_PLUGINS=1 bundle exec rake db:migratefrom discourse root, not the plugin folder. -
Re-start the server.
-
If the plugin has settings, you can edit them by going to
http://localhost:3000/admin/pluginsand clicking on "Settings" next to its name.
If you'd like to install a plugin in production, follow this guide.
Set up a local Discourse Development Environment?
So you want to set up Discourse locally to hack on and develop with?
This root guide links all the #dev-install guides for the various systems to setup Discourse locally on. If you want to add a guide to this list, be sure to tag it dev-install before editing this post to include it here.
(If you want to install Discourse for production use, see our install guide)
In no particular order, here are the install guides for setting Discourse up on your local environment:
- Beginners Guide to Install Discourse on Ubuntu for Development
- Beginners Guide to Install Discourse on macOS for Development
- Beginners Guide to Install Discourse on Windows 10 for Development
- Beginners Guide to Install Discourse for Development using Docker
- Setting up Discourse for development on Fedora
Set up a multisite development environment
It's possible to run discourse in multisite mode in development. A multisite server uses a different DB and hostname for each site that it serves. This is useful if you are developing a feature or a plugin that should be multisite compatible.
All you need to do is add an appropriate config/multisite.yml file. i.e:
---
alternate:
adapter: postgresql
database: discourse_alternate
host_names:
- alternate.localhost
To run rake tasks against these extra sites, simply add the RAILS_DB environment variable with the name of the site that you are targeting:
RAILS_DB=alternate rake db:create
RAILS_DB=alternate rake db:migrate
Some rake tasks are special in that, when RAILS_DB isn't specified in development, they run across all sites:
rake db:create
rake db:migrate
In general, however, running rake tasks without RAILS_DB set will target the default site.
To access the site, start the dev server:
bin/dev
You may now be able to view the your new site at http://alternate.localhost:3000, but if you cannot, you may need to add alternate.localhost to your /etc/hosts file or equivalent.
Set up Discourse for development on Fedora Linux
This guide has been tested against a fresh install of Fedora 31 and 33, but may work on older versions that also use dnf as the package management tool. This is not an official guide but may be useful for other developers using Fedora. This is largely based on the Ubuntu development guide, with changes for the different packages for dnf. The assumption is that you do not have any of the packages installed already, although most will be skipped by the tooling if it is already installed.
If you're looking to install Discourse for a production environment, prefer the docker install instructions on github.
Install required system and development packages
sudo dnf update
sudo dnf install -y "@development-tools" git rpm-build zlib-devel ruby-devel readline-devel libpq-devel ImageMagick vips sqlite sqlite-devel nodejs npm curl gcc g++ bzip2 openssl-devel libyaml-devel libffi-devel zlib-devel gdbm-devel ncurses-devel optipng pngquant jpegoptim gifsicle oxipng
Install required npm packages
sudo npm install -g svgo pnpm
Install and setup postgres
sudo dnf install postgresql-server postgresql-contrib
sudo postgresql-setup --initdb --unit postgresql
sudo systemctl enable postgresql
sudo systemctl start postgresql
sudo -u postgres -i createuser -s $USER
Install and setup redis
sudo dnf install redis
sudo systemctl enable redis
sudo systemctl start redis
Installing rbenv, ruby-build, and ruby
git clone https://github.com/rbenv/rbenv.git ~/.rbenv
cd ~/.rbenv && src/configure && make -C src
~/.rbenv/bin/rbenv init
printf 'export PATH="$HOME/.rbenv/bin:$PATH"\n' >> ~/.bashrc
printf 'eval "$(rbenv init - --no-rehash)"\n' >> ~/.bashrc
source ~/.bashrc
git clone https://github.com/rbenv/ruby-build.git "$(rbenv root)"/plugins/ruby-build
# confirm the install is correct
curl -fsSL https://github.com/rbenv/rbenv-installer/raw/main/bin/rbenv-doctor | bash
rbenv install 2.7.1
rbenv global 2.7.1
rbenv rehash
Install Ruby dependencies
gem update --system
gem install bundler mailcatcher rails
Clone Discourse code
git clone https://github.com/discourse/discourse.git ~/discourse
cd ~/discourse
Install Discourse dependencies
bundle install
pnpm install
Create the required databases and load the schema
bundle exec rake db:create db:migrate
RAILS_ENV=test bundle exec rake db:create db:migrate
Test installation by running the tests
bundle exec rake autospec
Run the application
bundle exec rails server
You should now be able to see the Discourse setup page at http://localhost:3000.
For further setup, see the existing official install guides.
Add Ember Components to Discourse
In the previous tutorial I showed how to configure both the server and the client side parts of Discourse to respond to a request.
We now recommend you to read the Ember component documentation: https://guides.emberjs.com/release/components/introducing-components/
Before writing a component, check the UI kit of shared components, helpers, and modifiers; most common controls already exist there.
[details="Old tutorial"] In this tutorial, I’m going to create a new Ember Component as a way to wrap third party Javascript. This is going to be similar to a YouTube video I made a while back, which you may find informative, only this time it’s specific to Discourse and how we lay out files in our project.
Why Components?
Handlebars is quite a simple tempting language. It’s just regular HTML along with some dynamic parts. This is simple to learn and great for productivity, but not so great for code re-use. If you’re developing a large application like Discourse, you’ll find that you want to re-use some of the same things over and over.
Components are Ember’s solution to this problem. Let’s create a component that will display our snack in a nicer way.
Creating a new Component
Components need to have a dash in their name. I’m going to choose fancy-snack as the name for this one. Let’s create our template:
app/assets/javascripts/admin/templates/components/fancy-snack.hbs
<div class="fancy-snack-title">
<h1>{{snack.name}}</h1>
</div>
<div class="fancy-snack-description">
<p>{{snack.description}}</p>
</div>
Now, to use our component, we will replace our existing admin/snack template with this:
app/assets/javascripts/admin/templates/snack.hbs
{{fancy-snack snack=model}}
We can now re-use our fancy-snack component in any other template, just passing in the model as required.
Adding Custom Javascript Code
Besides re-usability, Components in Ember are great for safely adding custom Javascript, jQuery and other external code. It gives you control of when the component is inserted into the page, and when it is removed. To do this, we define an Ember.Component with some code:
app/assets/javascripts/admin/components/fancy-snack.js
export default Ember.Component.extend({
didInsertElement() {
this._super();
this.$().animate({ backgroundColor: "yellow" }, 2000);
},
willDestroyElement() {
this._super();
this.$().stop();
},
});
If you add the above code and refresh the page, you’ll see that our snack has an animation of a slowly fading yellow background.
Let’s explain what’s going on here:
-
When the component is rendered on the page it will call
didInsertElement -
The first line of
didInsertElement(andwillDestroyElement) isthis._super()which is necessary because we’re subclassing Ember.Component. -
The animation is done using jQuery’s animate function.
-
Finally, the animation is cancelled in the
willDestroyElementhook, which is called when the component is removed from the page.
You might wonder why we care about willDestroyElement at all; the reason is in a long lived Javascript application like Discourse it’s important to clean up after ourselves, lest we leak memory or leave things running. In this case we stop the animation, which tells any jQuery timers that they needn’t fire any more as the component is no longer visible on the page.
[/details]
Where to go from here
The final tutorial in this series covers automated testing.
The UI kit of shared Discourse components, helpers, and modifiers
ui-kit is the layer of reusable, domain-free building blocks that core, plugins, and themes compose their interfaces from. It lives under frontend/discourse/app/ui-kit/ and ships three kinds of primitive:
Everything in it is prefixed with d- (D in PascalCase) and is considered public API for plugins and themes. This page explains when to reach for it, what is in it, and how to add to it. Each primitive's own JSDoc/TSDoc is the reference for its arguments; the interactive styleguide shows most of them rendered.
Importing
Import from the discourse/ui-kit namespace:
import { on } from "@ember/modifier";
import DButton from "discourse/ui-kit/d-button";
import dIcon from "discourse/ui-kit/helpers/d-icon";
import dAutoFocus from "discourse/ui-kit/modifiers/d-auto-focus";
<template>
<label class="rename-form__label">{{dIcon "pencil"}} {{@label}}</label>
<input
class="rename-form__input"
value={{@name}}
{{dAutoFocus selectText=true}}
{{on "input" @onInput}}
/>
<DButton
class="btn-primary"
@action={{@onSave}}
@label="save"
@icon="check"
@isLoading={{@isSaving}}
/>
</template>
discourse/components/*, discourse/helpers/*, and discourse/modifiers/* are still where domain-specific, non-shareable components live; only the reusable primitives were moved into the kit. The old paths of the moved ones (discourse/components/d-button, discourse/helpers/d-icon, and so on) still resolve through the shims in frontend/discourse/app/ui-kit-shims.js, so existing plugins keep working, but new code should import them from discourse/ui-kit/... directly.
Do not import anything from a -internals directory, wherever it appears (discourse/ui-kit/-internals/..., discourse/lib/-internals/..., and so on). Those modules are private implementation details of the primitives that wrap them: they are not part of the public API and can be renamed, reshaped, or removed without notice or a deprecation cycle. If a primitive does not expose what you need, extend the primitive rather than reaching past it.
When to use it
Before writing a component, check whether the kit already covers the need:
- Browse the
ui-kit/directory (component, helper, and modifier names are descriptive) and the styleguide sections. - Prefer composing existing primitives over adding a variant. A
d-buttonwith@iconand aclassattribute is better than a new button component; ad-empty-statewith a custom@ctaLabelis better than a bespoke empty message. - If the need is a form, use FormKit rather than assembling inputs by hand; FormKit's controls are themselves built on ui-kit primitives.
- If the need is anchored or hover-triggered UI (tooltips, menus, popovers), use float-kit rather than positioning things yourself. It ships both components (
DTooltip,DMenu,DPopover,DToast, and their headless variants, imported fromdiscourse/float-kit/components/...) and thetooltip,menu, andtoastsservices for showing the same things programmatically. See Menus, Tooltips, and Toasts.
Reach for a new component in frontend/discourse/app/components/ when the thing you are building knows about Discourse domain concepts (a topic, a post, a category, a user's notification level). Add to ui-kit/ only when the primitive is domain-free and useful to more than one consumer; see Adding a primitive below.
What is in it
The groups below are a map, not an exhaustive list. Run ls frontend/discourse/app/ui-kit for the current inventory, and read the JSDoc/TSDoc of each file for its arguments and blocks.
Actions
| Primitive | Use it for | Styleguide |
|---|---|---|
DButton |
Every clickable action. Handles labels, icons, @isLoading, @disabled, and ARIA. |
Buttons |
DComboButton |
A primary action paired with a dropdown of secondary actions. | Combo button |
DCopyButton |
Copy a value to the clipboard with feedback. | |
DPageActionButton |
Actions in a page header. | |
DBadgeButton, DToggleSwitch, DTogglePasswordMask |
Toggles and badge-shaped actions. | |
DShortcut |
Render a keyboard shortcut in the platform's notation. | Shortcut |
Inputs
Form-level composition belongs to FormKit. These are the underlying controls, useful when a single control is needed outside a form.
| Primitive | Use it for | Styleguide |
|---|---|---|
DNativeSelect |
A native <select> with Discourse styling. |
Dropdowns |
DMultiSelect |
Choose several values with search. | Multi select |
DTextField, DTextarea, DExpandingTextArea, DPasswordField, DRadioButton |
Single controls. | Forms |
DDateInput, DDatePicker, DTimeInput, DDateTimeInput, DDateTimeInputRange, DFutureDateInput, DRelativeTimePicker, DTimeShortcutPicker, DCalendarDateTimeInput |
Dates, times, and ranges. | Date/time inputs |
DOtp, DSecondFactorInput |
One-time codes. | OTP |
DFilterInput, DFilterControls |
Filter a list. | |
DIconGridPicker, DPickFilesButton |
Specialised pickers. | |
DCharCounter, DInputTip, DPopupInputTip |
Feedback attached to an input. | Char counter |
DAccessControl, DAccessControlField |
Edit access control lists. | |
DEditor |
The composer's markdown editor. |
Page chrome and layout
| Primitive | Use it for | Styleguide |
|---|---|---|
DPageHeader, DPageSubheader |
Titles, breadcrumbs, tabs, and actions of an admin or settings page. | |
DBreadcrumbsContainer, DBreadcrumbsItem |
Breadcrumb trails registered from routes. | Breadcrumbs |
DNavItem, DNavigationItem, DHorizontalOverflowNav |
Navigation bars; the overflow nav is built on DOverflowControls. |
Navigation bar |
DOverflowControls |
Scrollable strips with edge fades, chevrons on whichever edges can still scroll, press-and-hold, and a way to reveal one item. | Overflow controls |
DTabs |
Tab panels switched in place, with the strip, keyboard cursor, and ARIA pairing built in. | Tabs |
DResponsiveTable, DTableHeaderToggle |
Tables that stay usable on narrow viewports, with sortable column headers. | |
DVirtualList |
Long lists that render only the rows in view. | Virtual list |
DReorderableList, DReorderableListGroup |
Lists the user can reorder by drag, a move menu, or the keyboard. The group lets a row move between lists. | Reorderable list |
DStatTiles |
Rows of headline numbers. | |
DTapTileGrid, DTapTile |
Grids of tappable choices. | |
DPostAccordion, DPostAccordionItem |
Collapsible sections. | |
DResizeHandles, DResizeSeparator |
User-resizable regions. | Drag and drop |
DSaveControls |
The save button and "saved" feedback of a settings form. |
Loading, empty, and feedback states
| Primitive | Use it for |
|---|---|
DSkeleton |
Placeholder shapes while content loads. Prefer a skeleton that mirrors the eventual layout over a spinner. |
DConditionalLoadingSpinner, DConditionalLoadingSection |
Show a spinner (@condition) or a dimmed, labelled section (@isLoading) in place of content while it loads. |
DAsyncContent |
Render content that is loaded asynchronously. Give it a promise, a TrackedAsyncData, or a function that fetches the data via @asyncData, and fill the loading, content, empty, and error blocks; it reloads when @context changes, so there is no need to hand-roll loading flags and error handling. |
DEmptyState |
A titled, illustrated "nothing here" message with an optional call to action. See Empty state. |
DFlashMessage |
Inline success, error, and warning banners. |
DLoadMore |
Infinite scrolling. |
Content rendering
| Primitive | Use it for |
|---|---|
DCookText |
Render raw markdown as cooked HTML. |
DDecoratedHtml |
Render HTML and apply the decorators registered through the plugin API to it. |
DHighlightedCode |
Syntax-highlighted code. |
DHtmlWithLinks, DCustomHtml |
Trusted HTML fragments. |
DInterpolatedTranslation, DCountI18n |
Translations whose placeholders are components or counts. |
DRelativeDate |
A live-updating relative timestamp. |
DCdnImg, DLightDarkImg |
Images that follow the CDN and the colour scheme. |
DAnimatedEmoji |
An emoji animated where an animation exists, static otherwise. |
Users, avatars, and categories
DUserAvatar, DUserAvatarFlair, DAvatarFlair, DUserLink, DUserInfo, DUserStat, DUserStatusMessage, DSmallUserList, DBadgeCard, plus the d-avatar, d-bound-avatar, d-category-badge, d-category-link, d-discourse-tag, and d-topic-link helpers. See Categories.
Overlays
DModal and DModalCancel render modals; see the DModal API. DDropdownMenu renders the content of a menu opened through the menu service. DConditionalInElement renders its block into @element elsewhere in the DOM, or in place when @inline is set.
Helpers
d-icon, d-icon-or-image, d-emoji, d-replace-emoji, d-format-date, d-format-duration, d-number, d-age-with-tooltip, d-dasherize, d-dir-span, d-unique-id, d-base-path, d-loading-spinner, d-element (a typed wrapper for a chosen tag, so attributes and modifiers are checked against the right element), and d-concat-class (join class names, dropping falsy ones).
Modifiers
| Modifier | Use it for |
|---|---|
d-auto-focus |
Focus an element when it renders. |
d-trap-tab |
Keep keyboard focus inside a dialog. |
d-tab-to-sibling |
Make Tab move focus between sibling elements. |
d-roving-focus |
Arrow-key navigation over a group of items (a toolbar, listbox, menu, or tree) with a single tab stop, via roving tabindex or aria-activedescendant. See Roving focus. |
d-close-on-click-outside |
Dismiss an overlay when the user clicks elsewhere. |
d-on-resize, d-observe-intersection, d-scroll-into-view |
React to layout and visibility. |
d-scroll-edges |
Stamp a scroll container's overflow and edge state as data attributes, for a strip that brings its own chrome. Pair it with the scroll-strip stylesheet mixin, which paints the edge fades from those attributes. |
d-autocomplete |
Attach an autocomplete popup to a text input. |
d-swipe, d-pointer-drag, d-resize-edge, d-drag-dwell, d-drag-and-drop-* |
Gestures. See Drag, resize, and gesture primitives. |
Adding a primitive
A primitive belongs in ui-kit/ when it is domain-free (it does not know what a topic or a post is) and either already has two consumers or is an obvious platform capability (a focus trap, a skeleton, a keyboard-shortcut renderer). Otherwise keep it next to its single consumer in app/components/.
When you add one:
- Name it
d-<name>and put it atfrontend/discourse/app/ui-kit/d-<name>.gts(orhelpers/d-<name>.js,modifiers/d-<name>.ts). See Splitting a large primitive when one file is not enough. - Write it in TypeScript with a
Signature(Args,Blocks,Element) and TSDoc on the class and every public argument. The kit is platform-level code, so accurate types matter more here than elsewhere;d-shortcut.gts,d-skeleton.gts,d-resize-separator.gts, andmodifiers/d-drag-dwell.tsare good models. See Types. - Forward
...attributesto the element consumers will want to target, and declare that element in theElementtype so the type checker allows attributes and modifiers on it. - Use BEM class names rooted at the component name (older primitives predate this rule; new ones must follow it),
d-<name>,d-<name>__part, and a standalone.--modifier, and put the stylesheet atapp/assets/stylesheets/common/ui-kit/d-<name>.scss, registered in_index.scss. Use colour and spacing tokens only; no hard-coded colours. See CSS guidelines. - Prefer named blocks over boolean arguments when the consumer supplies content, and yield a small API object when the consumer needs to call back into the component.
- Take strings already translated. A primitive receives display text as a plain
stringargument (@label,@title) that the consumer has already passed throughi18n(); it never resolves translation keys itself. The kit does not know which locale file a consumer's key lives in, and a key-taking argument forces a parallel@translatedXargument for every string.d-resize-separator.gtsdocuments itslabelthis way.DButton's@label-as-key plus@translatedLabelpair predates this rule; do not copy it. - Gate development-only warnings and assertions on
DEBUGfrom@glimmer/env. It is a build-time constant, so the gated code and its messages are stripped from production bundles;d-resize-handles.gtsandmodifiers/d-roving-focus/diagnostics.tsdo this. - Do not mention plugins or specific libraries in its docs or comments; describe the mechanism instead.
Splitting a large primitive
A primitive whose behaviour outgrows one file keeps one public entry module and moves everything else into a sibling directory named after it. Consumers import only the entry; nothing outside the directory imports from -internals/.
ui-kit/
d-widget.gts # the public component; the only import path
d-widget/
README.md # what each collaborator owns and why
types.ts # every interface, incl. the ones the entry re-exports
-internals/
constants.ts # values shared by more than one collaborator
engine/ # the headless logic: state, algebra, resolution
coordinators/ # non-rendering classes the component constructs once
parts/ # rendering subcomponents that own part of the public DOM
modifiers/ # element-attached behaviour private to this primitive
modifiers/
d-gesture.ts # a modifier entry follows the same shape
d-gesture/
types.ts
keyboard.ts, strategies/, …
Use the buckets that apply; a two-file split needs only types.ts. What goes where:
types.tsholds every interface, including the ones the entry re-exports as public API. It is the documentation home for the argument reference, so the entry'sArgsblock can point at it.-internals/engine/is the headless layer: state, filtering, the move or selection algebra, value resolution. It has no DOM and no component reference, which is what makes it unit-testable on its own.-internals/coordinators/are plain classes the component constructs once and configures downward with thunks (() => this.args.x); they never hold a reference back to the component. Announcers, menu coordinators, and load-feedback timers live here.-internals/parts/are the rendering subcomponents. They receive the stable helper objects (the engine, a presenter) plus per-slot inputs, and render the elements of the primitive's public DOM, the ones its stylesheet and tests target. They add no wrapper beyond that contract.-internals/constants.tsexists so two collaborators that must agree on a value (a menu identifier, a selector) import it rather than restating it.README.mdexplains the split: what each collaborator owns, the invariants that cross files, and the design decisions a future reader would otherwise undo.
Blast radius and backward compatibility
The kit exists to build all of Discourse's UI: core, every plugin, and every theme render through it. A change here therefore has a far larger blast radius than a change to a single feature, and code you cannot see (third-party plugins and themes) depends on the public surface exactly as it is today.
Before changing anything public facing (an argument, a block, a yielded API object, a class name, DOM structure, or a helper's or modifier's signature), work out:
- Who can be relying on it. Search core, the in-repo plugins, and the wider plugin and theme ecosystem for the argument or class name; assume any exported name or documented argument has consumers you cannot see.
- Whether the change is backward compatible. Adding an optional argument or block is; renaming, removing, changing a default, changing the element
...attributesland on, or restructuring the DOM that stylesheets target is not. - Whether a deprecation cycle is required. A breaking change to a public surface ships behind a deprecation first: keep the old path working, emit a deprecation with an id and a
sinceversion, document the replacement, and remove it only after the cycle. Do not skip this because the old behaviour looks unused.
For anything new (a feature, an argument, a component, a modifier), the bar is exhaustive testing rather than a happy-path check: every argument and block, keyboard and pointer input, the states a consumer can put it in, error paths, and the accessibility contract. A primitive that ships with a gap ships that gap to every surface that adopts it.
Testing a primitive
- Rendering tests go in
frontend/discourse/tests/integration/ui-kit/d-<name>-test.gjs, next to the other 40-odd kit tests. Assert through the public API and observable DOM, not internal state. Cover every argument and block, every input method the primitive answers to, and the states it can be driven into; the drag-and-drop family infrontend/discourse/tests/integration/ui-kit/modifiers/shows the expected depth (modifier tests live in thatmodifiers/subdirectory, helper tests inhelpers/). - Type tests for
.gtsprimitives go infrontend/discourse/type-tests/ui-kit/d-<name>-test.gts, using@glint-expect-error(andexpect-typewhere a value type matters) to pin down both what compiles and what must not. - Module-level state (registries, callbacks) needs a reset export that
frontend/discourse/tests/helpers/qunit-helpers.jscalls fromtestCleanup(), asd-decorated-htmlandd-editordo. - System specs for behaviour a component test cannot reach (real drag negotiation, portaled overlays, scroll containers) go in the styleguide plugin when the primitive has no core consumer to exercise it.
Styleguide section
Every primitive is browsable at /styleguide/<category>/<section>, and the section is part of the deliverable, not an afterthought. Add or extend one under plugins/styleguide/assets/javascripts/discourse/components/sections/ and register it in plugins/styleguide/assets/javascripts/discourse/lib/styleguide.js.
A section should be designed, with multiple examples that each isolate one capability. sections/molecules/drag-and-drop.gjs is the model: it groups its examples with StyleguideGroups, and each Example carries a title, a @kind, a description of what the example demonstrates, a @tryThis prompt telling the reader what to do, an optional @note on the subtlety it exposes, and the source via @code so the reader can copy it. Aim for one example per argument or behaviour worth understanding (types, positions, disabled state, nesting, custom preview, and so on), rather than a single kitchen-sink demo.
DMultiSelect footer actions
DMultiSelect accepts an optional footer block below the search results, including when
there are no matching results. The block receives the menu API; await menu.close() before
opening another dialog so focus moves to the new dialog. Without a footer block, no footer
or divider is rendered. Set @matchTriggerWidth={{true}} when a caller needs the menu to
match the trigger width; this is opt-in and independent of the footer.
<DMultiSelect
@matchTriggerWidth={{true}}
@loadFn={{this.loadItems}}
@selection={{this.selection}}
@onChange={{this.selectItems}}
>
<:selection as |item|>{{item.name}}</:selection>
<:result as |item|>{{item.name}}</:result>
<:footer as |menu|>
<DButton @action={{fn this.createItem menu}} @label="create" @icon="plus" />
</:footer>
</DMultiSelect>
Related guides
- FormKit for forms.
- DModal API for modals.
- CSS guidelines for BEM and tokens.
- Designing for devices and responsive widths.
- Drag, resize, and gesture primitives for the modifiers that handle input.
- Types for the Glint and TypeScript conventions the kit follows.
- Private modules for where
-internalscode goes.
Automatically lint and format code before commits
Discourse uses lefthook for git hooks, and bin/lint as the main CLI entry point for running the same checks manually.
If you are working in a local clone, install the hooks once:
pnpm install
pnpm lefthook install
After that, staged files will be checked automatically on git commit.
The main command: bin/lint
Use bin/lint when you want to run the repo's configured linters yourself instead of waiting for the pre-commit hook.
Common examples:
bin/lint
bin/lint path/to/file.rb path/to/file.gjs
bin/lint --recent
bin/lint --staged
bin/lint --unstaged
bin/lint --wip
bin/lint --fix path/to/file.rb
bin/lint --fix --recent
bin/lint --fix
What each mode does
bin/lint: lint all supported files in the repositorybin/lint path/to/file ...: lint only the given filesbin/lint --recent: lint files changed in the last 50 commits, plus untracked filesbin/lint --staged: lint only staged filesbin/lint --unstaged: lint only unstaged filesbin/lint --wip: lint staged files, unstaged files, and files changed sincemainbin/lint --fix ...: run the auto-fixers for the selected filesbin/lint --fix: run all available auto-fixers across the repositorybin/lint --verbose: print the underlying lefthook commands
When you pass explicit files, bin/lint filters them to supported lintable file types before invoking lefthook.
:information_source: Markdown files are only linted under
docs/developer-guides/. Runningbin/lint path/to/doc.mdon any other Markdown file will report that nothing was linted.
What gets linted
The exact configuration lives in lefthook.yml. At the time of writing, bin/lint covers:
Ruby
**/*.{rb,rake,thor}- Ruby scripts under
bin/**/* Gemfile
Checks:
rubocopsyntax_tree(stree check)
JavaScript, GJS, CSS, and SCSS formatting
app/assets/stylesheets/**/*.{css,scss}frontend/**/*.{js,gjs,scss,css,cjs,mjs}- matching plugin and theme asset files
Checks:
prettier/pprettier
JavaScript and GJS linting
frontend/**/*.{js,gjs}- matching plugin and theme JS files
Checks:
eslint(witheslint-plugin-ember'stemplate-*rules covering the template portion of.gjsfiles)
SCSS linting
app/assets/stylesheets/**/*.scss- matching plugin and theme SCSS files
Checks:
stylelint
YAML and locale checks
**/*.{yaml,yml}exceptconfig/database.yml**/{client,server}.en.yml
Checks:
yaml-lintscript/i18n_lint.rb
Type checking
When you run bin/lint with no file arguments, the full-repo lint also runs:
pnpm lint:types
This is the Glint/TypeScript-style check for Discourse's type information. It runs in TypeScript's strict mode and also generates the published declarations.
:information_source:
bin/lint path/to/fileand the pre-commit hook do not run the full type check. Use plainbin/lintwhen you want the complete repo-wide lint pass.
What can be auto-fixed
bin/lint --fix can automatically fix a lot of issues, but not all of them.
Auto-fix is configured for:
prettier --writeeslint --fixstylelint --fixrubocop -Asyntax_tree(stree write)
In practice, that means --fix can reformat and rewrite:
- Ruby
- JavaScript / GJS
- CSS / SCSS
These checks are not auto-fixed by bin/lint --fix:
- YAML syntax validation
- i18n linting for
client.en.yml/server.en.yml - Glint/type checking
Relationship to git hooks
The pre-commit hook uses the same lefthook configuration as bin/lint, but it runs only against staged files.
That means:
- a commit can fail because staged files do not pass linting
bin/lint --stagedis the closest manual equivalent to the pre-commit hookbin/lint --fix --stagedis a good way to repair exactly what you are about to commit
Practical workflow
For day-to-day development, these are the most useful commands:
# Before committing a couple of changed files
bin/lint --fix path/to/file1.rb path/to/file2.gjs
# Check exactly what the pre-commit hook will check
bin/lint --staged
# Clean up all current in-progress work
bin/lint --fix --wip
# Run the full repo lint suite, including type checks
bin/lint
Write acceptance tests and component tests for Ember code in Discourse
Automated tests are a great way to protect your code against future regressions. Many people are familiar with how to do this in our Rails codebase with rspec, but the Javascript side can be somewhat of an enigma to some.
Fortunately, it’s pretty easy these days to add basic tests to your Ember code!
Component Tests
In the previous tutorial in this series we added a component called fancy-snack to display our snack with a fading background. Let’s write a test for it. Create the following file:
test/javascripts/components/snack-test.js
import componentTest from "helpers/component-test";
moduleForComponent("fancy-snack", { integration: true });
componentTest("test the rendering", {
template: "{{fancy-snack snack=testSnack}}",
setup() {
this.set("testSnack", {
name: "Potato Chips",
description: "Now with extra trans fat!",
});
},
test(assert) {
assert.equal(this.$(".fancy-snack-title h1").text(), "Potato Chips");
assert.equal(
this.$(".fancy-snack-description p").text(),
"Now with extra trans fat!"
);
},
});
To run the test, open your browser on your development server to /qunit?module=component%3Afancy-snack. Your browser will then perform the component tests and output something like “2 assertions of 2 passed, 0 failed.”
Note that while on the /qunit page you can run other tests. You can simply select a new test from the Module dropdown box at the top of the screen.
Let’s step through the test to understand how it works.
The template line tells Ember how we’d like to insert our component. It’s the exact same markup you’d use to place the component in a handlebars template so it should be familiar:
template: '{{fancy-snack snack=testSnack}}’,
Note that it is passing testSnack through as the snack parameter. That is defined in the setup() method:
setup() {
this.set('testSnack', {
name: 'Potato Chips',
description: 'Now with extra trans fat!'
});
},
I’ve just put in some dummy data. That’s all we need to do to have Ember render the component. Finally, we have a couple of assertions in the test() method:
test(assert) {
assert.equal(this.$('.fancy-snack-title h1').text(), 'Potato Chips');
assert.equal(this.$('.fancy-snack-description p').text(), 'Now with extra trans fat!');
}
If you use this.$() you get access to a jQuery selector in your template. The assertions here use that selector to grab the value of the snack’s title and snack’s description and compare them with what we expect. If the values match then the assertions will pass and our test is all working.
It’s worth noting that you don’t need to test every little thing in a component like this. You should use some discretion and try to figure out what things in your code are likely to break or cause confusion to other developers down the road. If you test too many things in your template, it will mean it’s a pain for someone else in the future to change it. Just start small, testing the most obvious things, and in time you’ll get the hang of it.
Acceptance Tests
Acceptance tests are often easier to write, and can be more powerful than component tests as they test your application the same way a user would in their browser. I often start with acceptance tests, and then if I am making a complicated component I’ll add tests for it too.
Here’s how we can write an acceptance test that will visit our /admin/snack route and confirm that the snack was rendered:
test/javascripts/acceptance/snack-test.js
import { acceptance } from "helpers/qunit-helpers";
acceptance("Snack");
test("Visit Page", function (assert) {
visit("/admin/snack");
andThen(() => {
assert.ok(exists(".fancy-snack-title"), "the snack title is present");
});
});
The test() in this case almost reads like English! The first command says visit the URL of /admin/snack. After that, there is an andThen() method. This method is necessary to make sure that all the background work is finished before the tests continue. Since Javascript and Ember code is asynchronous, we need to make sure Ember is done everything it needs to do before our assertions are executed. Finally, it tests to see if the .fancy-snack-title element is present.
However, if you run this test by visiting /qunit?module=Acceptance%3A%20Snack you’ll find that the test will fail, due to an AJAX error.
If you recall, our code includes both a Rails side and a Javascript side which performed an AJAX request to get its data. The acceptance test ran the Javascript side, but it didn’t know what to do to get its data from Rails.
To fix this, we need to add a fake response, using the excellent pretender library. Open up the test/javascripts/helpers/create-pretender.js file and look for the line that says:
this.get("/admin/plugins", () => response({ plugins: [] }));
Right below it, add a line to return a fake snack object for our acceptance test to work with:
this.get("/admin/snack.json", () => {
return response({ name: "snack name", description: "snack description" });
});
You can read the above code as "for any request to /admin/snack.json, respond with the following response."
If you refresh the URL /qunit?module=Acceptance%3A%20Snack, your acceptance test should retrieve its data via pretender and the tests should pass.
Where to go from here
You might try building out a small feature, and adding tests to make sure it works. You could even try using TDD by creating your tests before you write any code on the front end. Depending on what you’re working on and your personal preferences, you might find this a more enjoyable way to go about this. Good luck and happy coding :)
How to run Discourse core, plugin and theme QUnit test suites
Discourse has extensive frontend tests for core, plugins and themes. Once you have a functioning local development environment, those tests can be run locally in a number of different ways.
Running tests in the browser
Core / Plugins: Visit /tests in your development environment
Themes: Visit /theme-qunit in your development (or production) environment, then choose the theme
In general, when working on core tests you should enable "Skip Plugins", and when you're working on plugins you should choose the specific plugin from the dropdown list (which will automatically 'Skip Core'). The core test suite is not expected to pass when plugins are enabled (because plugins often deliberately change core's behavior)
:information_source: Unfortunately, at the time of writing, memory leaks in our tests mean that trying to running the entire suite in one browser tends to hit the browser memory limit. To run the entire suite across multiple browsers in parallel, check out the CLI examples below.
Running tests on the CLI
Core
From the root directory of Discourse, use bin/qunit (make sure you have run pnpm install since you last pulled in upstream changes). It wraps Ember Exam for randomization and parallelisation, and by default runs against the running Rails server and existing JS assets. Run bin/qunit --help for the full list of options.
Here are some useful examples:
# Run a single test file, or every test in a directory:
bin/qunit frontend/discourse/tests/integration/components/bookmark-test.gjs
bin/qunit frontend/discourse/tests/integration/components
# Run the entire core suite across 5 headless Chrome instances in parallel:
bin/qunit --parallel 5
# Run all tests whose "module: test name" contains a substring (case-insensitive):
bin/qunit --filter "Integration | Component | bookmark"
# Slash-wrap the value to match with a regular expression, e.g. for alternation:
bin/qunit --filter "/bookmark|reaction/i"
# Spin up an isolated server and run in a visible browser for easier debugging:
bin/qunit --standalone --no-headless
:information_source:
--filteris forwarded to QUnit: a plain value matches as a case-insensitive substring, while a slash-wrapped value like/foo/imatches as a regular expression. Include theiflag for case-insensitive regex matching.
Plugins
From the root directory of Discourse:
bin/rake "plugin:qunit" # Run all plugin qunit tests
bin/rake "plugin:qunit[discourse-chat-integration]" # Run a single plugin's qunit tests
Themes
From the root directory of Discourse:
bin/rake "themes:qunit[url,<theme_url>]"
bin/rake "themes:qunit[name,<theme_name>]"
bin/rake "themes:qunit[id,<theme_id>]"
Pinning plugin and theme versions for older Discourse installs (d-compat branches)
📖 Background
Theme and plugin developers generally want to target the latest release of Discourse, without worrying about backwards compatibility. But sites running older Discourse releases still need a version of the theme/plugin that works for them.
To bridge that gap, Discourse can be told to check out an older 'pinned' version of the theme/plugin. There are two mechanisms for this, checked in order:
d-compat/<YYYY>.<M>git branches in the theme/plugin repository (the primary method — recommended for all new pins).- A
.discourse-compatibilityYAML file in the root of the repository (the original mechanism, still supported as a fallback).
If both exist, the branch wins.
🌿 The d-compat/<YYYY>.<M> branch system
Discourse releases use date-based versions like 2025.5, 2025.6, etc. When Discourse updates a plugin or theme from git, it asks the repository: "do you have a branch named d-compat/<YYYY>.<M> matching my version?" (e.g. d-compat/2025.5 for Discourse 2025.5.x). If so, Discourse checks out the tip of that branch instead of main.
The lookup only runs when the local checkout is on the repo's default branch. If you've intentionally pinned to a different branch, the d-compat logic is skipped and your pin is respected.
To support an older Discourse version with this system:
- Create a branch named
d-compat/<YYYY>.<M>from a commit that's known to work on that version (e.g.git checkout -b d-compat/2025.5 <commit>). - Push it to
origin. You may want to protect the branch from accidental deletion. - Land any backport commits onto that branch. Discourse instances on
2025.5.xwill pick them up automatically on the next update; instances on newer Discourse will keep tracking the default branch.
You don't need to touch .discourse-compatibility at all when using branches.
⚙️ Automated branch creation (create-d-compat-branch.yml)
In practice you rarely need to create these branches by hand. The default theme and plugin skeletons include a d-compat-branch.yml workflow which runs daily, checks for new versions of Discourse core, and pushes matching d-compat/<YYYY>.<M> branches as needed.
If your repository was created from an older copy of the skeletons, just copy the d-compat-branch.yml file into your .github/workflows directory to get it working.
:git_merged: Backporting a fix to a d-compat branch
When you've landed a fix on the default branch that also needs to reach sites on an older Discourse release:
-
Branch off the target d-compat branch and cherry-pick the fix:
git fetch origin git checkout -b backport/my-fix-2025.5 origin/d-compat/2025.5 git cherry-pick <commit-sha> git push -u origin backport/my-fix-2025.5 -
Open a PR with
d-compat/2025.5as the base branch (notmain). Get it reviewed and merged the same way you would any other PR. -
Repeat for each older
d-compat/<YYYY>.<M>branch that needs the fix.
Sites on 2025.5.x will pick up the merged commit on their next update.
[details="Legacy fallback: the .discourse-compatibility file"]
If no matching d-compat branch exists, Discourse falls back to a YAML .discourse-compatibility file in the repo root, mapping Discourse versions to git refs of your plugin/theme:
< 3.2.0.beta2-dev: abcde
Discourse picks the lowest entry that matches the running core version, so anyone on < 3.2.0.beta2-dev checks out commit abcde. Use < (or the legacy <=, the default when no operator is given) to specify the version bound. Reach for this only if the branch-based system can't express what you need.
[/details]
Ember object ownership (getOwner, service injections, etc.)
:warning: Importing getOwner from
discourse-common/lib/get-owneris deprecated.
To access e.g. services there a couple of methods at your disposal.
-
In components/controllers/routes you should use service injections
import { service } from "@ember/service"; export default class Something extends Component { @service router; -
In cases where a service can be unavailable (i.e. it comes from an optional plugin) there's a
optionalServiceinjectionimport optionalService from "discourse/lib/optional-service"; export default class Something extends Component { @optionalService categoryBannerPresence; -
In API initializers you have the access to
api.containerapiInitializer((api) => { const router = api.container.lookup("service:router"); -
And for a direct replacement of an existing code you can use
import { getOwner } from "@ember/application"; -
…or if you still need the fallback shim (in a non-component/controller/route/widget context) use
import { getOwnerWithFallback } from "discourse/lib/get-owner";
Using Google's 'tachometer' to measure JS performance changes in Discourse
When working on client-side work in Discourse core/plugins/themes, it's important to consider the performance impact. Google's 'Tachometer' project provides a statistically rigorous benchmarking tool which we can use to definitively measure the effect of changes.
https://github.com/google/tachometer
Essentially, this tool takes a list of URLs and loads them in a 'round-robin' fashion. For each page load, it takes some performance measurement. After hundreds/thousands of iterations, it produces a comparison table.
The beauty of this 'round-robin' approach is that it helps to reduce the impact of external factors on measurements.
Step 1: Add performance.measure()
The approach here will vary based on what you're testing. But fundamentally: you need to introduce a performance.measure() value for Tachometer to read.
If you want to render the time it takes for Discourse to boot and render, you can use the built-in "discourse-init-to-paint" measurement. For anything else, you can introduce your own performance.measure and use that.
You can check it's working using the performance tab in your browser dev tools:

If you're trying to measure an activity which requires user interaction (e.g. opening a menu), you could achieve this by adding something like this in an initializer to click the button 1 second after the page is loaded:
setTimeout(() => document.querySelector(".my-button").click(), 1000);
Step 2: Identify URLs for testing
Firstly, make sure that you are building Ember assets in production mode. This can be achieved by launching the server with EMBER_ENV=production.
To obtain two different URLs, there are two main approaches:
If your change is small enough to be easily feature-flagged, then you could add logic to toggle it based on a URL query parameter. Then your two urls could be
http://localhost:3000?flag=before
http://localhost:3000?flag=after
If the change is too large for that, then you could create a git worktree and launch a second copy of rails from it on another port:
git worktree add ../discourse-after my-branch
cd ../discourse-after
bundle install
pnpm install
EMBER_ENV=production UNICORN_PORT=3001 bin/dev
And then your two URLs would be
http://localhost:3000
http://localhost:3001
If you take this approach, make sure that both copies of the app have the performance telemetry you introduced in step 1 of this guide
Step 3: Configure Tachometer
This is my bench.json file, which will take 300 samples of each target:
{
"timeout": 5,
"sampleSize": 300,
"benchmarks": [
{
"measurement": {
"mode": "performance",
"entryName": "discourse-init-to-paint"
},
"expand": [
{
"url": "http://localhost:3000",
"name": "before"
},
{
"url": "http://localhost:3001",
"name": "after"
}
]
}
]
}
Step 4: Run benchmark
To reduce noise, stop any unrelated activities on your workstation, and then start the benchmark with a command like:
pnpx tachometer --config ./bench.json
When complete, you should see a comparison of the before/after performance.

Caveats
As with any experiments like this, it's worth considering the limitations. For example:
-
Performance differences on your development workstation may not map directly onto other browsers/devices
-
The boot process of Discourse in development mode is not exactly the same as it is in production. When making structural changes (e.g. framework updates) this may be important
-
Performance often varies based on application state (e.g. the number of topics being rendered), so your results may not be exactly reproducible in other environments
Setup Continuous Integration using GitHub Actions
To build a robust extension for Discourse, it can be wise to include Continuous Integration (CI) into your plugin or theme-component. This will help to detect errors early on and lessen the chances of bugs in your code.
Setting up a CI workflow using GitHub Actions to automate builds and testing is an approach the Discourse team uses on all our components, and we recommend you do the same.
:gear: Setting it up
To add automated workflows for GitHub actions to detect, you need to create a .github/workflows folder in the root directory of your repository.
Inside the workflows folder you can define a set of automations that GitHub actions will need to run. For instance, these could be .yml files for linting and tests.
We've created template workflows for both plugins and theme components which you can make use of. These connect to our 'reusable workflow' definitions here.
In the template's skeleton repository, on GitHub you can click the Use this template button to create a plugin/theme component repository based on the template.
Alternatively, if you already have a project you'd like to add the workflows to, simply copy the relevant workflow into your repository's .github/workflows/ folder:
:electric_plug: Plugins: discourse-plugin.yml
:jigsaw: Themes and Theme Components: discourse-theme.yml
:point_up: These templates are locked to a specific major version of our reusable workflows. Small improvements we make to the workflows will automatically take effect in your theme/plugin. For breaking changes (e.g. introducing a new linter), we will bump the major version of the reusable workflows, and you will need to update your workflow to point to the new version
:tada: Voila! You're all setup! Simply, create a commit or a PR to your repository and GitHub actions will auto-detect the workflows and begin running the jobs.
GitHub actions will show a breakdown of each test and after running it will indicate either a :white_check_mark: or :x: depending on if the test passed or failed.
If a test failed, clicking on the details will give you a some information on what failed which may give you clues on what's wrong with your code and what needs to be fixed.
[details="See example"]
[/details]
:white_check_mark: Add your own tests
For plugin and components tests to work effectively, its important that you write tests for your plugin or theme component.
For details on how to write front-end tests with EmberJS see:
- https://meta.discourse.org/t/write-ember-acceptance-and-component-tests-for-discourse/49167
- https://guides.emberjs.com/release/testing/
For more details on writing test RSpec tests with Rails see:
:bulb: Examples
For your benefit, we've picked out a couple examples of plugins and theme components that have some robust testing integrated:
| Plugin / Component | Client Side Tests | Server Side Tests |
|---|---|---|
| Assign | :link: View Folder | :link: View Folder |
| Calendar | :link: View Folder | :link: View Folder |
| Reactions | :link: View Folder | :link: View Folder |
| Right Sidebar Blocks | :link: View Folder | |
| Tag Icons | :link: View Folder | |
| Table Builder | :link: View Folder |
Developer's guide to Markdown extensions
Discourse uses a Markdown engine called Markdown-it.
Here are some dev notes that will help you either fix bugs in core or create your new plugins.
The Basics
Discourse only contains a few helpers on top of the engine, so the vast majority of learning that needs to be done, is understanding Markdown It.
The docs directory contains the current documentation.
I strongly recommend reading:
-
The architecture document to understand at a top level how the engine works.
-
Development for basic development guidelines
-
API documentation for a very detailed reference
-
And finally, the source code which is very well documented and clear.
While I develop extensions for the engine I usually open up a second editor looking at existing rules. The engine consists of a long list of rules and each rule is in a dedicated file that is reasonably easy to follow.
If I am working on an inline rule I will think of what existing inline rule works more or less like it and base my work on it.
Keep in mind, you can sometimes get away with just changing a renderer to get desired functionality which is usually much easier.
How to structure an extension?
When the markdown engine initializes it searches through all the modules.
If any module is called /discourse-markdown\/|markdown-it\// (meaning it lives in a discourse-markdown or markdown-it directory) it will be a candidate for initialization.
If the module exports a method called setup it will be called by the engine during initialization.
The setup protocol
/my-plugins/assets/javascripts/discourse-markdown/awesome-extension.js
export function setup(helper) {
// ... your code goes here
}
A setup method gets access to a helper object it can use for initialization. This contains the following methods and vars:
-
bool markdownIt: this property is set totruewhen the new engine is in use. For proper backwards compatibility you want to check it. -
registerOptions(cb(opts, siteSettings, state)): the provided function is called before the markdown engine is initialized, you can use it to determine if to enable or disable the engine. -
allowList([spec, ...]): this method is used to allowlist HTML with our sanitizer. -
registerPlugin(func(md)): this method is used to register a Markdown It plugin.
Putting it all together
function amazingMarkdownItInline(state, silent) {
// standard markdown it inline extension goes here.
return false;
}
export function setup(helper) {
if(!helper.markdownIt) { return; }
helper.registerOptions((opts,siteSettings)=>{
opts.features.['my_extension'] = !!siteSettings.my_extension_enabled;
});
helper.allowList(['span.amazing', 'div.amazing']);
helper.registerPlugin(md=>{
md.inline.push('amazing', amazingMarkdownItInline);
});
}
Discourse specific extensions
Generating hashtag references in Ruby
Use HashtagAutocompleteService#hashtags_for to build references for generated Markdown:
HashtagAutocompleteService.new(user.guardian).hashtags_for("tag", ["bug", "support"]).join(" ")
If a visible category has the slug bug, the result is #bug::tag #support.
Pass names already filtered for the viewer's visibility. Order and case are
preserved, collisions are checked against higher-priority types, and a name the
hashtag rule cannot match falls back to plain text.
BBCode
Discourse contains 2 rulers you can use for custom BBCode tags. An inline and block level ruler.
Inline bbcode rules are ones that live in an inline paragraph like [b]bold[/b]
Block level rules apply to multiple lines of text like:
[poll]
- option 1
- options 2
[/poll]
md.inline.bbcode.ruler holds a list of inline rules that are applied in order.
md.block.bbcode.ruler holds a list of block level rules
There are many examples for inline rules at: bbcode-inline.js
Quotes and polls are good examples of bbcode block rules.
Inline BBCode rules
Inline BBCode rules are an object containing information about how to handle a tag.
For example:
md.inline.bbcode.ruler.push("underline", {
tag: "u",
wrap: "span.bbcode-u",
});
Will cause
test [u]test[/u]
To be converted to:
test <span class="bbcode-u">test</span>
Inline rules can either wrap or replace text. When wrapping you can also pass in a function to gain extra flexibility.
md.inline.bbcode.ruler.push("url", {
tag: "url",
wrap: function (startToken, endToken, tagInfo, content) {
const url = (tagInfo.attrs["_default"] || content).trim();
if (simpleUrlRegex.test(url)) {
startToken.type = "link_open";
startToken.tag = "a";
startToken.attrs = [
["href", url],
["data-bbcode", "true"],
];
startToken.content = "";
startToken.nesting = 1;
endToken.type = "link_close";
endToken.tag = "a";
endToken.content = "";
endToken.nesting = -1;
} else {
// just strip the bbcode tag
endToken.content = "";
startToken.content = "";
// edge case, we don't want this detected as a onebox if auto linked
// this ensures it is not stripped
startToken.type = "html_inline";
}
return false;
},
});
The wrapping function provides access to:
-
The tagInfo, which is a dictionary of key/values specified via bbcode.
[test=testing]->{_default: "testing"}[test a=1]->{a: "1"} -
The token starting the inline
-
The token finishing the inline
-
The content of the bbcode inline
Using this information you can handle all sort of wrapping needs.
Occasionally you may want to replace the entire BBCode block, for that you can use replace
md.inline.bbcode.ruler.push("code", {
tag: "code",
replace: function (state, tagInfo, content) {
let token;
token = state.push("code_inline", "code", 0);
token.content = content;
return true;
},
});
In this case we are replacing an entire [code]code block[code] with a single code_inline token.
Block BBCode rules
Block bbcode rules allow you to replace an entire block. The block APIs are the same for simple cases:
md.block.bbcode.ruler.push("happy", {
tag: "happy",
wrap: "div.happy",
});
[happy]
hello
[/happy]
will become
<div class="happy">hello</div>
The function wrapper has a slightly different API cause there are not wrapping tokens.
md.block.bbcode.ruler.push("money", {
tag: "money",
wrap: function (token, tagInfo) {
token.attrs = [["data-money", tagInfo.attrs["_default"]]];
return true;
},
});
[money=100]
**test**
[/money]
Will become
<div data-money="100">
<b>test</b>
</div>
You can gain full control over block rendering with before and after rule, this allows you to do stuff like double nest a tag and so on.
md.block.bbcode.ruler.push("ddiv", {
tag: "ddiv",
before: function (state, tagInfo) {
state.push("div_open", "div", 1);
state.push("div_open", "div", 1);
},
after: function (state) {
state.push("div_close", "div", -1);
state.push("div_close", "div", -1);
},
});
[ddiv]
test
[/ddiv]
will become
<div>
<div>test</div>
</div>
Handling text replacements
Discourse ships with an extra special core rule for applying regular expressions to text.
md.core.textPostProcess.ruler
To use:
md.core.textPostProcess.ruler.push("onlyfastcars", {
matcher: /(car)|(bus)/, //regex flags are NOT supported
onMatch: function (buffer, matches, state) {
let token = new state.Token("text", "", 0);
token.content = "fast " + matches[0];
buffer.push(token);
},
});
I like cars and buses
Will become
<p>I like fast cars and fast buses</p>
Converting modals from legacy controllers to new DModal component API
:information_source: If you're implementing a new Modal, check out the main docs here. This topic describes how to migrate an existing controller-based Modal to the new Component-based API.
In the past, Discourse used an Ember-Controller-based API for rendering modals. To invoke the modal, you would pass a string with the name of the controller to showModal(). Under the covers, this made use of Ember's Route#renderTemplate API, which is deprecated in Ember 3.x and will be removed in Ember 4.x.
To allow Discourse to upgrade to Ember 4.x and beyond, we've introduced a new component-based API for modals. This new API embraces Ember's 'declarative' design patterns, and aims to provide clean DDAU (data down actions up) semantics.
Step 1: Move Files
Move the controller JS file and the template file to the /components/modal directory. This makes them a 'colocated component' which can be imported just like any other JS module.
Step 2: Update the JS file
Then, update the component JS definition to extend from @ember/component instead of @ember/controller [^1]. Remove the ModalFunctionality mixin and update any uses of its functions according to the table below:
[^1]: Classic Ember Components are recommended in this guide because they provided the easiest migration path from Ember Controllers. But for simple modals, or if you're happy to spend some time refactoring, modern Glimmer components are the better choice.
| Before | After |
|---|---|
flash() and clearFlash() |
Create a flash property in your component and pass it to the @flash argument of <DModal>. By default the alert will be styled with the alert class which is a copy of the 'error' class, but it can be overridden using the @flashType argument. |
showModal() |
Import the showModal function from discourse/lib/show-modal |
closeModal action |
Invoke the closeModal argument which is automatically passed into your component |
Old-style Modal Controllers would live 'forever', which meant we had to manually cleanup state. With the new Component-based API, the component will be created and destroyed when the modal is shown/hidden. In many cases that means your old lifecycle hooks are no longer required.
If you still need some lifecycle-based logic, use this table:
| Before | After |
|---|---|
onShow() |
Use standard Ember component lifecycle (init() or Ember modifier) |
afterRender |
Use standard Ember component lifecycle (init() or Ember modifier) |
beforeClose() |
create a wrapper around the @closeModal argument which is passed into your component. Pass a reference to your close wrapper into DModal like <DModal @closeModal={{this.myCloseModalWrapper}}> |
onClose() |
Use standard Ember component lifecycle (willDestroy() or Ember modifier) |
Step 3: Update the Template
Replace the <DModalBody> wrapper with <DModal>. Add some new attributes:
- Pass through the new
@closeModalargument - Add an explicit class. To match the old behavior, take your controller filename and add
-modal.
For example, if your modal controller was called close-topic.js, the new <DModal> invocation would look something like this:
<DModal @closeModal={{@closeModal}} class="close-topic-modal">
If the DModalBody invocation includes any other arguments, update them based on the table below:
| Before | After |
|---|---|
@title="title_key" |
@title={{i18n "title_key"}} |
@rawTitle="translated title" |
@title="translated title" |
@subtitle="subtitle_key" |
@subtitle={{i18n "subtitle_key"}} |
@rawSubtitle="translated subtitle" |
@subtitle="translated subtitle" |
@class |
@bodyClass |
@modalClass |
Use angle-bracket syntax with regular html attribute: <DModal class="blah"> |
@titleAriaElementId |
Use angle-bracket syntax with regular html attribute: <DModal aria-labelledby="blah"> |
@dismissable, @submitOnEnter, @headerClass |
Unchanged |
If there was any footer content rendered after the old <DModalBody> component, use the new <:footer> named block to introduce it inside <DModal>. When using any named blocks, the body content should be wrapped in <:body></:body>. For example:
<DModal @closeModal={{@closeModal}}>
<:body>
Hello world, this is the content of the modal
</:body>
<:footer>
This is the footer content. A `.modal-footer` wrapper will be added
automatically
</:footer>
</DModal>
Step 4: Update the showModal call sites
Previously, modals would be rendered using the showModal API, which would take a string (the controller name) and a number of opts. It would return an instance of the controller which could be manipulated:
import showModal from "discourse/lib/show-modal";
export default class extends Component {
showMyModal() {
const controller = showModal("my-modal", {
title: "My Modal Title",
modalClass: "my-modal-class",
model: { topic: this.topic },
});
controller.set("updateTopic", this.updateTopic);
});
}
To render new component-based Modals you should inject the 'modal' service (or access it using something like getOwner(this).lookup("service:modal")) and call the show() function.
show() takes a reference to the new Component class as the first argument. The only opt still supported is 'model', which can be used to pass all data/actions required for your Modal.
No reference to the component instance will be returned. Instead, show() returns a promise which will resolve when the modal is closed. The promise will resolve with any data which was passed to @closeModal.
import MyModal from "discourse/components/my-modal";
import { service } from "@ember/service";
export default class extends Component {
@service modal;
showMyModal() {
this.modal.show(MyModal, {
model: { topic: this.topic, updateTopic: this.updateTopic },
});
});
}
Alternatively, migrate to the declarative API described in the main DModal documentation.
The functionality of the old options can be replicated as follows:
Old showModal opt |
Solution |
|---|---|
admin |
n/a for component - remove it |
templateName |
n/a for components - remove it |
title |
move to <DModal @title={{i18n "blah"}}> |
titleTranslated |
move to <DModal @title="blah">. This could be computed based on data from model if needed |
modalClass |
move to <DModal class="blah"> |
titleAriaElementId |
move to <DModal aria-labelledby="blah"> |
panels |
Use the <:headerBelowTitle> named block to implement tabs in your component (example) |
model |
unchanged |
Step 5: Tests
Any tests should largely remain the same. The most common issue are:
-
Modals no longer have a default class based on their name. Classes must be specified explicitly in the template (see beginning of Step 3)
-
The
d-modalwrapper no longer persists in the DOM when the modal is closed. To check all modals are closed, use a check likeassert.dom('.d-modal').doesNotExist()
Profit!
Your modal should now work as it did before. To take further advantage of the new API, you may want to consider replacing showModal calls with a declarative strategy, and converting your Modal to be a Glimmer component.
Examples
Here are some example commits which demonstrate converting some of Discourse core's modals to the new API:
-
https://github.com/discourse/discourse/pull/22154
-
https://github.com/discourse/discourse/pull/22164
Using the DModal API to render Modal windows (aka popups/dialogs) in Discourse
Discourse 3.1.0.beta6 ships with a brand new <DModal> component-based API. DModal is part of the UI kit and is imported from discourse/ui-kit/d-modal.
:information_source: This supersedes the old controller-based API, which is now deprecated. If you have existing modals using the old APIs, check out the migration guide here.
Rendering a Modal
Modals are rendered by including the <DModal> component in a handlebars template. If you don't already have a suitable template, check out https://meta.discourse.org/t/using-plugin-outlet-connectors-from-a-theme-or-plugin/32727.
A simple modal would look something like this:
<DButton
@translatedLabel="Show Modal"
@action={{fn (mut this.modalIsVisible) true}}
/>
{{#if this.modalIsVisible}}
<DModal @title="My Modal" @closeModal={{fn (mut this.modalIsVisible) false}}>
Hello world, this is some content in a modal
</DModal>
{{/if}}
:information_source: The
muthelper is used here as a hbs-only way to set a value. You could also setmodalIsVisibleusing any other standard Ember method.
This example will create a simple Modal like this:

Wrapping in a component
Before introducing any more complexity, it's usually best to wrap up your new Modal in its own Component definition. Let's move the <DModal> stuff inside a new <MyModal /> component
// components/my-modal.gjs
<template>
<DModal @title="My Modal" @closeModal={{@closeModal}}>
Hello world, this is some content in a modal
</DModal>
</template>
Upgrading this .gjs file to a class-based component will allow you to introduce more complex logic and state.
To make use of the new component, update the call site to reference it, making sure to pass in a @closeModal argument.
<DButton
@translatedLabel="Show Modal"
@action={{fn (mut this.modalIsVisible) true}}
/>
{{#if this.modalIsVisible}}
<MyModal @closeModal={{fn (mut this.modalIsVisible) false}} />
{{/if}}
Adding a footer
Many modals have some kind of call-to-action. In Discourse these tend to be located at the bottom of the modal. To make this possible, DModal has a number of 'named blocks' which can have content rendered inside them. Here's the example updated to include two buttons in the footer, one of which is our standard DModalCancel button
<DModal @title="My Modal" @closeModal={{@closeModal}}>
<:body>
Hello world, this is some content in a modal
</:body>
<:footer>
<DButton class="btn-primary" @translatedLabel="Submit" />
<DModalCancel @close={{@closeModal}} />
</:footer>
</DModal>

Rendering a modal from a non-hbs context
Ideally, <DModal> instances should be rendered from within an Ember template using the declarative technique demonstrated above. If that's not feasible for your use case, it can be done by injecting the modal service and calling modal.show().
Make sure you've wrapped up your modal in its own component as described above. Then, trigger the modal by passing a reference of your component class to showModal:
import MyModal from "discourse/components/my-modal";
// (inject the modal service in the relevant place)
// Add this call whenever you want to open the modal.
// A `@closeModal` argument will be passed to your component automatically.
this.modal.show(MyModal);
// Optionally, pass a '`model`' parameter. Passed as `@model` to your component.
// This can include data, and also actions/callbacks for your Modal to use.
this.modal.show(MyModal, {
model: { topic: this.topic, someAction: this.someAction },
});
// `modal.show()` returns a promise, so you can wait for it to be closed
// It will resolve with the data passed to the `@closeModal` action
const result = await this.modal.show(MyModal);
More customizability!
<DModal> has a number of named blocks and arguments.
Arguments
| Arg | Purpose |
|---|---|
@closeModal |
Required for dismiss UI to appear at all. |
@title |
Renders <h1 id="discourse-modal-title">; wires aria-labelledby. |
@subtitle |
Small text below the title. |
@flash / @flashType |
Inline alert at top of modal (DFlashMessage). |
@hideHeader, @hideFooter |
Hide whole regions. |
@headerClass, @bodyClass |
Extra class on header/body wrappers. |
@dismissable |
Default true when @closeModal set. Disables Esc / backdrop click / X. |
@autofocus |
Default true. Auto-focuses first focusable element via dTrapTab. |
@submitOnEnter |
Default true. Enter clicks .d-modal__footer .btn-primary unless focus is in a form / textarea / select-kit. |
@beforeClose |
async ({ initiatedBy }) => boolean. Return false to cancel close (e.g. dirty-form confirm). |
@hidden |
Pauses keyboard handling; used when a nested modal is on top. |
@tagName |
"div" (default) or "form". Use "form" for forms so native submit works. |
Blocks
| Block | Position | When to use |
|---|---|---|
default / :body |
Main content area | Default area |
:aboveHeader |
Very top, before header | Rarely needed; for content that must sit above the title bar (e.g. a banner). |
:headerAboveTitle |
Inside header, before title | Present but unused. Rarely needed. |
:belowModalTitle |
Inside .d-modal__title, after the <h1> |
Excellent position for supplementary meta info. |
:headerBelowTitle |
Inside header, after title block | Tabs, sub-nav, or search input that's part of the header. |
:headerPrimaryAction |
Right side of header on mobile only | Replaces the X close button with a primary action (e.g. "Save"). Also auto-renders a "Cancel" button on the left and adds .--has-primary-action to the header. |
:belowHeader |
Between header and body | Persistent sub-header content (e.g.searchar) that's outside the scrollable body, so sticky display. |
:aboveFooter |
Between body and footer | Suppressed when @hideFooter is set. Use for content tied to the footer but outside it. Also rare. |
:footer |
Bottom action bar | Primary + secondary buttons. The first .btn-primary here is what Enter triggers. |
:belowFooter |
After the footer | Rarely needed; ignores @hideFooter. Useful for status text outside the bordered footer area. |
Sources: the interactive styleguide for arguments, and the d-modal template implementation for named blocks.
CSS
Use the .d-modal classes as an achor to override core, and avoid the legacy `.modal selector.
4 modifiers available:
- .
--largesets max width to 800px (desktop-only) - .
--maxsets max width to 90vw (desktop-only) - .
has-searchsets fixed height (80vh): intended for modals with search/filter system to avoid height change based on result length (desktop-only) .--stackedsets footer buttons to stacking (mobile-only)
Using the JS API
Discourse's JavaScript API allows themes and plugins to make extensive customizations to the user experience. The simplest way to use it is to create a new theme from the admin panel, click "Edit Code", and then head to the JS tab.
For file-based themes, the API can be used by creating a file in the api-initializers directory. For theme's that's {theme}/javascripts/api-initializers/init-theme.gjs, and for plugins, it's {plugin}/assets/javascripts/discourse/api-initializers/init-plugin.js. The content should be:
import { apiInitializer } from "discourse/lib/api";
export default apiInitializer((api) => {
// Your code here
});
All the available APIs are listed in the plugin-api.gjs source code in Discourse core, along with a short description and examples.
For a full tuturial, including examples of JS API usage, check out:
https://meta.discourse.org/t/theme-developer-tutorial-1-introduction/357796
Using Plugin Outlet Connectors from a Theme or Plugin
Discourse includes hundreds of Plugin Outlets which can be used to inject new content or replace existing contend in the Discourse UI. 'Outlet arguments' are made available so that content can be customized based on the context.
Choosing an outlet
To find the name of a plugin outlet, search Discourse core for "<PluginOutlet", or use the plugin outlet locations theme component. (e.g. topic-above-posts).
Wrapper outlets
Some outlets in core look like <PluginOutlet @name="foo" />. These allow you to inject new content. Other outlets will 'wrap' an existing core implementation like this
<PluginOutlet @name="foo">
core implementation
</PluginOutlet>
Defining a connector for this kind of 'wrapper' outlet will replace the core implementation. Only one active theme/plugin can contribute a connector for a wrapper plugin outlet.
For wrapper plugin outlets, you can render the original core implementation using the {{yield}} keyword. This can be helpful if you only want to replace the core implementation under certain conditions, or if you would like to wrap it in something.
Defining the connector
Once you've chosen an outlet, decide on a name for your connector. This needs to be unique across all themes / plugins installed on a given community. e.g. brand-official-topics
In your theme / plugin, define a new .gjs connector with a path formatted like this:
:art:
{theme}/javascripts/discourse/connectors/{outlet-name}/{connector-name}.gjs:electric_plug:
{plugin}/assets/javascripts/discourse/connectors/{outlet-name}/{connector-name}.gjs
The content of these files will be rendered as an Ember Component. For general information on Ember and the .gjs format, check out the Ember guides.
For our hypothetical "brand official topics" connector, the file might look like
<template>
<div class="alert alert-info">
This topic was created by a member of the
<a href="https://discourse.org/team">Discourse Team</a>
</div>
</template>
[quote]
[details=ℹ️ Legacy wrapper elements]
In the past, connectors were authored using .hbs files. When these files are used, the plugin outlet may automatically introduce a wrapper element. The element type is defined by @connectorTagName on the <PluginOutlet />.
Modern .gjs-based connectors have full control of their DOM. No automatic wrapper element will be introduced.
[/details]
[/quote]
Using outlet arguments
Plugin Outlets provide information about the surrounding context via @outletArgs. The arguments passed to each outlet vary. An easy way to view the arguments is to add this to your template:
{{log @outletArgs}}
This will log the arguments to your browser's developer console. They will appear as a Proxy object - to explore the list of arguments, expand the [[Target]] of the proxy.
In our topic-above-posts example, the rendered topic is available under @outletArgs.model. So we can add the username of the team member like this:
<template>
<div class="alert alert-info">
This topic was created by
{{@outletArgs.model.details.created_by.username}}
(a member of the
<a href="https://discourse.org/team">Discourse Team</a>)
</div>
</template>
[quote]
[details=ℹ️ Legacy ways to access arguments]
In many Plugin Outlets, by default it is possible to access arguments using {{argName}} or {{this.argName}}. For now, this still works in existing outlets.
New plugin outlets (with @defaultGlimmer={{true}}) render connectors as 'template only glimmer components', which do not have a this context. Eventually, existing Plugin Outlets will also be migrated to this pattern. The @outletArgs technique is best because it will work consistently in both classic and glimmer plugin outlets.
[/details]
[/quote]
Adding more complex logic
Sometimes, a simple template is not enough. To add Javascript logic to your connector, upgrade your .gjs file to export a class-based component. This functions just the same as any other component definition, and can include service injections.
In our topic-above-posts example, we may want to render the user differently based on the 'prioritize username in ux' site setting. The .gjs file might look something like this:
.../connectors/topic-above-posts/brand-official-topic.gjs:
import Component from "@glimmer/component";
import { service } from "@ember/service";
export default class BrandOfficialTopics extends Component {
@service siteSettings;
get displayName() {
const user = this.args.outletArgs.model.details.created_by;
if (this.siteSettings.prioritize_username_in_ux) {
return user.username;
} else {
return user.name;
}
}
<template>
<div class="alert alert-info">
This topic was created by
{{this.displayName}}
(a member of the
<a href="https://discourse.org/team">Discourse Team</a>)
</div>
</template>
}
[quote]
[details=ℹ️ Legacy ways to define complex logic]
In older versions of Discourse, connectors were defined as a .hbs template plus an adjacent .js file, and it wasn't possible to export a custom component definition. Instead, you could export an object with setupComponent(args, component) and teardownComponent(component) functions. These older techniques are not officially deprecated yet, but we recommend switching to a single .gjs file with a class-based component going forwards.
[/details]
[/quote]
Conditional rendering
If you only want your content to be rendered under certain conditions, it's often enough to wrap your template with a handlebars {{#if}} block. If that's not enough, you may want to use the shouldRender hook to control whether your connector template is rendered at all.
Firstly, ensure you have a class-based .gjs connector as described above. Then, add a static shouldRender() function. Extending our example:
import Component from "@glimmer/component";
export default class BrandOfficialTopics extends Component {
static shouldRender(outletArgs, helper) {
const firstPost = outletArgs.model.postStream.posts[0];
return firstPost.primary_group_name === "team";
}
// ... (any other logic)
<template>
{{! ... }}
</template>
}
Now the connector will only be rendered when the first post of the topic was created by a team member.
shouldRender is evaluated in a Glimmer autotracking context. Future changes to any referenced properties (e.g. outletArgs) will cause the function to be re-evaluated.
[quote]
[details=ℹ️ Legacy shouldRender implementations]
Autotracking: Before Discourse 3.1, shouldRender would only be evaluated during initial render. Changes to referenced properties would not cause the function to be re-evaluated.
Non-class syntax: For now, defining a shouldRender function in a plain (non-class) javascript object is still supported, but we recommend moving towards a class-based or templateOnly-based syntax going forward.
[/details]
[/quote]
Introducing new outlets
If you need an outlet that doesn't yet exist, please feel free to make a pull request, or open a topic in #dev.
Using modifyClass to change core behavior
For advanced themes and plugins, Discourse offers the modifyClass system. This allows you to extend and override functionality in many of core's javascript classes.
When to use modifyClass
modifyClass should be a last resort, when your customization cannot be made via Discourse's more stable customization APIs (e.g. plugin-api methods, plugin outlets, transformers).
Core's code can change at any time. And therefore, customizations made via modifyClass could break at any time. When using this API, you should ensure that you have controls in place to catch those issues before they reach a production site. For example, you could add automated tests to the theme/plugin, or you could use a staging site to test incoming Discourse updates against your theme/plugin.
Basic Usage
api.modifyClass can be used to modify the functions and properties of any class which is accessible via the Ember resolver. That includes Discourse's routes, controllers, services and components.
modifyClass takes two arguments:
-
resolverName(string) - construct this by using the type (e.g. component/controller/etc.), followed by a colon, followed by the (dasherized) filename name of the class. For example:component:d-button,component:modal/login,controller:user,route:application, etc. -
callback(function) - a function which receives the existing class definition, and then returns an extended version.
For example, to modify the click() action on d-button:
api.modifyClass(
"component:d-button",
(Superclass) =>
class extends Superclass {
@action
click() {
console.log("button was clicked");
super.click();
}
}
);
The class extends ... syntax mimics that of JS child classes. In general, any syntax/features supported by child classes can be applied here. That includes super, static properties/functions, and more.
However, there are some limitations. The modifyClass system only detects changes to the class's JS prototype. Practically, that means:
-
introducing or modifying a
constructor()is not supportedapi.modifyClass( "component:foo", (Superclass) => class extends Superclass { constructor() { // This is not supported. The constructor will be ignored } } ); -
introducing or modifying class fields is not supported (although some decorated class fields, like
@trackedcan be used)api.modifyClass( "component:foo", (Superclass) => class extends Superclass { someField = "foo"; // NOT SUPPORTED - do not copy @tracked someOtherField = "foo"; // This is ok } ); -
simple class fields on the original implementation cannot be overridden in any way (although, as above,
@trackedfields can be overridden by another@trackedfield)// Core code: class Foo extends Component { // This core field cannot be overridden someField = "original"; // This core tracked field can be overridden by including // `@tracked someTrackedField =` in the modifyClass call @tracked someTrackedField = "original"; }
If you find yourself wanting to do these things, then your use-case may be better satisfied by making a PR to introduce new APIs in core (e.g. plugin outlets, transformers, or bespoke APIs).
Upgrading Legacy Syntax
In the past, modifyClass was called using an object-literal syntax like this:
// Outdated syntax - do not use
api.modifyClass("component:some-component", {
someFunction() {
const original = this._super();
return original + " some change";
}
pluginId: "some-unique-id"
});
This syntax is no longer recommended, and has known bugs (e.g. overriding getters or @actions). Any code using this syntax should be updated to use the native-class syntax described above. In general, conversion can be done by:
- removing
pluginId- this is no longer required - Update to the modern native-class syntax described above
- Test your changes
Troubleshooting
Class already initialized
When using modifyClass in an initializer, you may see this warning in the console:
Attempted to modify "{name}", but it was already initialized earlier in the boot process
In theme/plugin development, there are two ways this error is normally introduced:
-
Adding a
lookup()caused the errorIf you
lookup()a singleton too early in the boot process, it will cause any latermodifyClasscalls to fail. In this situation, you should try to move the lookup to happen later. For example, you would change something like this:// Lookup service in initializer, then use it at runtime (bad!) export default apiInitializer((api) => { const composerService = api.container.lookup("service:composer"); api.composerBeforeSave(async () => { composerService.doSomething(); }); });To this:
// 'Just in time' lookup of service (good!) export default apiInitializer((api) => { api.composerBeforeSave(async () => { const composerService = api.container.lookup("service:composer"); composerService.doSomething(); }); }); -
Adding a new
modifyClasscaused the errorIf the error is introduced by your theme/plugin adding a
modifyClasscall, then you'll need to move it earlier in the boot process. This commonly happens when overriding methods on services (e.g. topicTrackingState), and on models which are initialized early in the app boot process (e.g. amodel:useris initialized forservice:current-user).Moving the modifyClass call earlier in the boot process normally means moving the call to a
pre-initializer, and configuring it to run before Discourse's 'inject-discourse-objects' initializer. For example:// (plugin)/assets/javascripts/discourse/pre-initializers/extend-user-for-my-plugin.js // or // (theme)/javascripts/discourse/pre-initializers/extend-user-for-my-plugin.js import { withPluginApi } from "discourse/lib/plugin-api"; export default { name: "extend-user-for-my-plugin", before: "inject-discourse-objects", initializeWithApi(api) { api.modifyClass("model:user", (Superclass) => class extends Superclass { myNewUserFunction() { return "hello world"; }, }); }, initialize() { withPluginApi(this.initializeWithApi); }, };This modification of the user model should now work without printing a warning, and the new method will be available on the currentUser object.
Creating Routes in Discourse and Showing Data
Over time Discourse has grown in complexity and it can be daunting for beginners to understand how data gets all the way from the back end Ruby on Rails application to the Ember.js application in front.
This tutorial is meant to show the full lifecycle of a request in Discourse and explain the steps necessary if you want to build a new page with its own URL in our application.
URLs First
I always prefer to start thinking of features in terms of the URLs to access them. For example let’s say we want to build an admin feature that showed the last snack I ate while working on Discourse. A suitable URL for that would be /admin/snack
In this case:
-
Visiting
/admin/snackin your browser should show the snack using the “full stack”, in other words the Ember application will be loaded up and it would request the data it needs to display the snack. -
Visiting
/admin/snack.jsonshould return the JSON data for the snack itself.
The Server Side (Ruby on Rails)
Let’s start by creating a new controller for the snack.
app/controllers/admin/snack_controller.rb
class Admin::SnackController < Admin::AdminController
def index
render json: { name: "donut", description: "delicious!" }
end
end
In this case we inherit from Admin::AdminController to gain all the security checks to make sure the user viewing the controller is an administrator. We just have one more thing to do to before we can access our controller, and that’s to add a line to config/routes.rb:
Find the block that looks like this:
namespace :admin, constraints: StaffConstraint.new do
# lots of stuff
end
And add this line inside it:
get 'snack' => 'snack#index'
Once you’re done, you should be able to visit /admin/snack.json in your browser and you’ll see JSON for the snack! Our snack API seems to be working :candy:
Of course, as you build your feature to add more complexity you likely wouldn’t just return hardcoded JSON from a controller like this, you’d query the database and return it that way.
The Client Side (Ember.js)
If you open up your browser and visit /admin/snack (without the .json) you’ll see that Discourse says “Oops! That page doesn’t exist.” — that’s because there’s nothing in our front end Ember application to respond to the route. Let’s add a .gjs template to show our snack:
frontend/discourse/admin/templates/snack.gjs
<template>
<h1>{{@controller.model.name}}</h1>
<hr />
<p>{{@controller.model.description}}</p>
</template>
And, like on the Rails API side we need to wire up the route. Open the file frontend/discourse/admin/routes/admin-route-map.js and look for the export default function() method. Add the following line:
this.route("snack");
We have one final thing left to do in Ember land, and that’s to have the Ember application perform an AJAX request to fetch our JSON from the server. Let’s create one last file. This will be an Ember Route. Its model() function will be called when the route is entered, so we’ll make our ajax call in there:
frontend/discourse/admin/routes/admin-snack.js
import { ajax } from "discourse/lib/ajax";
import DiscourseRoute from "discourse/routes/discourse";
export default class AdminSnackRoute extends DiscourseRoute {
model() {
return ajax("/admin/snack.json");
}
}
Now, you can open your browser to /admin/snack and you should see the details of the snack rendered in the page!
Summary
-
Opening your browser to
/admin/snackboots up the Ember application -
The Ember application router says
snackshould be the route -
The Ember route for
snackmakes an AJAX request to/admin/snack.json -
The Rails application router says that should be the
admin_snack controller -
The
admin_snack_controllerreturns JSON -
The Ember application gets the JSON and renders the
.gjstemplate
Where to go from here
I've written a follow up tutorial on how to add an Ember Component to Discourse.
Adding a new 'managed' authentication method to Discourse
Continuing from https://meta.discourse.org/t/future-social-authentication-improvements/94691...
We are now in the process of moving all 'associated account' information into a single database table. This will help to significantly reduce duplicated logic, and allow quicker development in the future. For example, migrating our core twitter logic to the new system reduced the number of lines of code from 136 to just 24 :tada:.
This post isn't designed to be a step-by-step instruction manual for adding a new authentication provider, but it will aim to provide an overview, pointing to the relevant source code where necessary.
Implementing an authenticator
Each authenticator must implement a subclass of Auth::Authenticator. To use the new shared logic, the authenticator can instead extend Auth::ManagedAuthenticator. An example of a bare-bones implementation can be found in the core Facebook authenticator:
https://github.com/discourse/discourse/blob/main/lib/auth/facebook_authenticator.rb
name and register_middleware must be overridden by implementing classes, along with enable_setting — the boolean site setting an admin uses to turn the provider on.
An authenticator should also declare required_settings: the site settings that must have a value before an authentication can succeed. The base class uses them for configured?, and enabled? is enable_setting && configured?, so a provider with missing credentials is never advertised on the login page and its /auth/<name> route stays closed — otherwise clicking the button strands the user on the provider's own error page with no way back. Declaring required_settings also lets AuthProviderCredentialsValidator refuse to enable the provider in the first place; wire it up with validator: "AuthProviderCredentialsValidator" on the enable setting.
def enable_setting
:enable_google_oauth2_logins
end
def required_settings
%i[google_oauth2_client_id google_oauth2_client_secret]
end
An authenticator that overrides enabled? directly opts out of both gates.
:information_source: Aside: for multisite compatibility, it is important that any site-specific information is supplied to omniauth in a
setuplambda, rather than being fixed at the time of definition. See all core authenticators for examples of this.
All logic to link external accounts to Discourse accounts is handled by Auth::ManagedAuthenticator. This relies on the omniauth provider returning data in the format defined in their documentation. If any manipulation of this data is required, Authenticators can override the after_authenticate method, and manipulate the auth_token as required. For example, the core Twitter authenticator removes all the extra information from the token:
https://github.com/discourse/discourse/blob/b46b6e72d1906ca31e29855bda71f3498c8e203f/lib/auth/twitter_authenticator.rb#L10-L14
Data is stored in the user_associated_accounts database table. provider_uid, info, credentials and extra are all taken directly from the data returned by omniauth.
Provider images are downloaded from info.image on authentication and retained in each account's avatar_upload_id. They remain separate from the user's uploaded picture and selectable presets. The avatar picker offers cached images from enabled providers under the existing avatar permissions.
UserAvatar owns avatar imports, selection, refreshes, and cleanup. Authentication schedules provider retrieval through UserAvatar.retrieve_for_associated_account; download jobs and importers use UserAvatar.import_url_for_user, passing associated_account_id for provider images. User convenience methods and account/upload lifecycle callbacks delegate avatar changes to UserAvatar; permissions remain in Guardian.
user_avatars.selected_user_associated_account_id records an explicitly selected provider. Subsequent downloads update the displayed avatar only while that provider remains selected. New accounts initially select their provider when no avatar was assigned; auth_overrides_avatar continues to enforce provider selection. Existing avatars retain their appearance and are not assigned a provider automatically. Existing linked accounts populate their provider choices on their next login.
Choosing another avatar clears provider selection. Disconnecting an account preserves its currently displayed image as a local snapshot and preserves any separate uploaded picture. Failed downloads retain the previous image. Queued downloads are discarded if the account was disconnected, moved to another user, or now supplies a different image URL.
Gravatar selection still uses upload-ID matching. A custom or preset picture identical to the cached Gravatar can therefore follow subsequent Gravatar updates.
https://github.com/discourse/discourse/blob/b46b6e72d1906ca31e29855bda71f3498c8e203f/app/models/user_associated_account.rb#L13-L24
Once an Authenticator class has been defined, it needs to be registered. This must happen early in the application's lifecycle, and can not happen within a plugin's after_initialize method. The minimum registration can simply contain a reference to the authenticator. In a plugin, registration can be done using the auth_provider function. For example:
auth_provider authenticator: OpenIDConnectAuthenticator.new()
In core, registration takes place in discourse.rb. A full list of possible AuthProvider options can be found here. Text content can be defined using these options, but it is better to provide localisable strings in client.en.yml following the standard keys. For example:
https://github.com/discourse/discourse-openid-connect/blob/88fdf7b5ab624aba7c207e403665e0393334794a/config/locales/client.en.yml#L1-L7
[details=Additional ManagedAuthenticator notes by @fantasticfears]
ManagedAuthenticator in details
You might need to work on something special for authentication. And you would like to know more about ManagedAuthenticator. Basically, it has several operations, options, and controls how the data will be used.
Discourse manages user information with two controllers. Users::OmniauthCallbacksController manages the payload once OAuth2 authentication is done. after_authenticate is called here. can_connect_existing_user? is also used here.
There are some private methods you can read to understand how different data fields work.
if authenticator.can_connect_existing_user? && current_user
@auth_result = authenticator.after_authenticate(auth, existing_account: current_user)
else
@auth_result = authenticator.after_authenticate(auth)
end
UsersController has revoke_account which uses can_revoke? and revoke. But for the revoke method to work remotely, you need to build your own implementation.
UserAuthenticator is a service class helping authenticate (verifying email confirmation or OAuth2 path) users. after_create_account is called here.
The core logic remains at after_authenticate with Auth::Result data class. We follow data structure here. extra_data will be passed to after_create_account for creating related records.
result.extra_data = {
provider: auth_token[:provider],
uid: auth_token[:uid],
info: auth_token[:info],
extra: auth_token[:extra],
credentials: auth_token[:credentials]
}
It will try to match and connects to an existing account.
You might wonder why automatic account creation is possible but there is no User.create. This is done in UsersController#create.
authentication = UserAuthenticator.new(user, session)
The user is a fresh instance will be populated by session data which is prepared by the auth provider. Trust me, it's just magic. [/details]
Migration to the new system
To provide a seamless switch to the new system, data should be migrated from the old storage location. For core authentication providers, this may be dedicated tables. For plugins, this may be plugin_store_rows, or oauth2_user_infos. The minimum data required in a user_associated_accounts row is provider_name, provider_uid and user_id. For an example migration see:
https://github.com/discourse/discourse/blob/main/db/migrate/20181207141900_migrate_twitter_user_info.rb
Once the ManagedAuthenticator system has been released to the stable branch with v2.2.0, we will begin migrating official authentication plugins. At this point, a plugin_store_row migration example will be added here.
Preventing accidental serialization of ActiveRecord models
We've introduced a patch to prevent the accidental serialization of ActiveRecord models without specifying the fields to be serialized. This change ensures that we control which fields are included, avoiding potential issues with incomplete or excessive data being exposed.
By default, rendering an ActiveRecord model as JSON includes all attributes, which may not be desirable in many cases. To enforce better practices, we need to specify which fields should be serialized.
Usage Examples
Incorrect Usage:
def show
@user = User.first
render json: @user
end
In development and tests, this will result in:
ActiveRecordSerializationSafety::BlockedSerializationError:
Serializing ActiveRecord models (User) without specifying fields is not allowed.
Use a Serializer, or pass the :only option to #serializable_hash. More info: https://meta.discourse.org/t/-/314495
./lib/freedom_patches/active_record_disable_serialization.rb:15:in `serializable_hash'
Correct Usage:
- Using a Serializer
class UserSerializer < ApplicationSerializer
attributes :id, :email
end
def show
@user = User.first
render json: @user, serializer: UserSerializer
end
- Using the
:onlyoption
def show
@user = User.first
render json: @user.as_json(only: [:id, :email])
end
(not recommended) Overriding Discourse templates from a Theme or Plugin
Ideally, when customizing Discourse via themes/plugins, you should use CSS, the JavaScript Plugin API, or plugin outlets. If none of these work for your use-case, feel free to open a PR to Discourse core or start a #dev topic here on Meta. We're always happy to discuss adding new outlets/APIs to make customization easier.
If you've exhausted all other options, you may need to resort to template overrides. This technique allows you to override the entire template of any Ember Component or Route from your theme/plugin.
:rotating_light: This is not a recommended way of customizing Discourse. Day-to-day changes in Discourse core will conflict with your template override eventually, potentially causing catastrophic errors when rendering the forum.
If you decide to take this approach, make sure you have sufficient automated testing and QA processes to detect regressions. If you distribute a theme/plugin with template overrides, please ensure forum admins are aware of the stability risks your theme/plugin carries.
:rotating_light: :rotating_light: :rotating_light: October 2023 Update: For new features, Discourse is increasingly moving towards using components authored using Ember's
.gjsfile format. Templates for these components are defined inline, and cannot be overridden by themes/plugins.Going forward, all template customizations should be done using Plugin Outlets
[details=I understand this will break in the near future, show me the docs anyway]
Overriding Component Templates
To override an Ember Component template (i.e. anything under components/* in Discourse core), you should create an identically-named .hbs in your theme/plugin. For example, to override the template for the badge-button component in Discourse core, you would create a template file in your theme/plugin at this location:
:art: {theme}/javascripts/discourse/templates/components/badge-button.hbs
:electric_plug: {plugin}/assets/javascripts/discourse/templates/components/badge-button.hbs
The override must always be nested inside the /templates directory, even if the core component has a 'colocated' template.
Overriding Route Templates
Overriding route templates (i.e. all the non-component templates under templates/*) works in the same way as components. Create an identically named template in your theme/plugin. For example, to override discovery.hbs in core, you would create a file like
:art: {theme}/javascripts/discourse/templates/discovery.hbs
:electric_plug: {plugin}/assets/javascripts/discourse/templates/discovery.hbs
Interaction between multiple themes / plugins
If multiple installed themes/plugins override the same template, the 'winner' is the one with the lowest-numbered ranking in this list:
- Theme overrides (highest theme 'id' wins)
- Plugin overrides (latest alphabetical plugin name wins)
- Core
This precedence also means that you can override plugin templates from themes. Technically you can also override theme templates from other themes, and plugin templates from other plugins, but the behavior can be surprising because of the dependence on plugin-name and theme-id.
How does this work?
Discourse assembles and prioritises templates in the DiscourseTemplateMap class. For colocated component templates, that information is used during app initialization to replace the core template associations. For all other templates, the map is used by the resolver at runtime to fetch the correct template. [/details]
Overview
A service is a small object that encompasses business logic for a given action. From outside, it should be seen as a sort of black box. You provide it with parameters, it runs (including all the side effects it can trigger), and then it returns a result object describing the outcome of the action.
You can think of a service as a conductor of an orchestra, it organizes how things are done and not necessarily does them by itself.
A service object has a functional flow control using steps and while it holds no state between executions, during an execution all state between steps is maintained in its context object. Everything that happens for a business action will be done in the service, let it be by using specialized steps, custom methods or delegating things to other objects.
Our service concepts are heavily inspired by Trailblazer 2.0, dry-transaction and to a lesser extent interactor.
Why?
The most common place where to use a service object is in a controller action. A controller action is usually a point of entry for dedicated business logic, but it’s not always clear where to put that logic. Of course, it can stay in the controller action, that’s what a lot of Rails apps do, but then it tends to grow and repeat the same processes over and over. Sometimes part of that logic can be extracted into models, and that’s fine, but it can lead to what’s called fat models where a model starts handling way more things than it should.
That’s a typical case where a service object comes in handy: you describe what the business logic is step by step (fetching models, checking permissions, and so on) and then you can take action depending on the outcome in a very simple way. All the error handling is done for you inside the service, and you can match its outcome in a very deterministic way. Input parameters are validated using a dedicated object called a contract and when a step fails, the service stops. That means that if a step runs, every previous step was successful.
It doesn’t mean every situation should be handled by a service. A service encapsulates business logic for a given action, and is encouraged to rely on specialized objects. So it won’t replace models or libraries, for example. As said before, it really shines when used in a controller action, but it can be used anywhere. A background job is another good example.
Another benefit immediately available with services, is that since all the logic related to a business action is encapsulated in it, you can call it from anywhere (console, SDK, controllers, specs, etc.) and you’ll always get the same outcome.
It’s also quite easy to understand what’s happening in a service at a glance, since all the steps are listed sequentially. There’s also a matching system to handle the possible outcomes in a deterministic way, here again you can understand at a glance what a controller will do, for example.
Getting started
Here’s a simplified service to update a user’s username which demonstrates most available steps:
class User::UpdateUsername
include Service::Base
params do
attribute :id, :integer
attribute :username, :string
validates :id, presence: true
validates :username, presence: true, format: { with: /\A[a-zA-Z0-9]+\z/ }
end
model :user
policy :can_update_username
transaction do
step :update
step :log
end
private
def fetch_user(params:)
User.find_by(id: params.id)
end
def can_update_username(guardian:, user:)
guardian.can_edit_username?(user)
end
def update(params:, user:)
user.update!(username: params.username)
end
def log(guardian:, user:)
StaffActionLogger.new(guardian.user).log_username_change(
user,
user.username_before_last_save,
user.username,
)
end
end
And here is an example on how you could call such a service:
User::UpdateUsername.call(params: {id: 1, username: "username"}, guardian: Discourse.system_user.guardian)
Without knowing how services work, you can probably guess what’s happening here. Let’s dive in.
Steps
What’s a step?
This is the basic unit of a service. There is a generic one (step) and specialized ones (params, model, etc.), and they’re all steps.
Steps are defined in the order they will be called. Each step will call a corresponding method and, depending on its return value, will continue or halt the execution of the service. Most steps rely on returning a value, and not raising an exception (otherwise, you’ll break the execution flow).
The immediate benefit is that error handling is done for you, and you don’t have to implement any specific logic in the service itself. We’ll see later how to handle errors.
⚠️ As said above, a step shouldn’t raise an exception, as this will not be handled automatically for you by the service. If a service raises an exception, it should be treated as a bug. If you need to call a piece of code that might raise an exception under expected usage, then you should use the
trystep to wrap the steps that could raise.
Let’s see what steps are available and how to use them.
step
This is the generic step, you provide a name, and it will run the defined method of the same name. The return value of this step doesn’t impact the execution flow. To mark the service as failed in a generic step, you have to call #fail! explicitly.
model
This specialized step helps to remove some boilerplate when dealing with models. By default, it will execute the method named fetch_<name>. In the above example, you can see we name our model :user and the corresponding method is named fetch_user.
Here, you can fetch (or instantiate) a model as you see fit. If the step returns a falsy value, or an exception is raised, then the execution flow will stop here. If an ActiveRecord model with unsaved changes is returned, the step also fails when the model contains errors or its validations fail. If not, the execution flow will stop.
This step is also compatible with collections: if a collection is fetched but empty, the execution flow will stop.
You can also provide an ActiveRecord relation. The step won’t load records but will determine if the relation will return any records. If there are none, the execution flow will stop.
Sometimes, you need to fetch a model (or a collection of models), but it’s ok if it’s empty. For those cases, you can use the optional: true option allowing the execution flow to continue even if the model returns a falsy value.
policy
This step will execute the method of the same name. You can put arbitrary code here, and the execution flow will stop if the return value is falsy.
Usually, a policy is related to some state on one of the service models and/or to the current user (if any).
params
This is one of the most powerful steps. Its main purpose is to validate the incoming data before feeding it to the models and to the service at a more global level. This is actually ActiveModel validations but applied to the incoming parameters.
This step will run coercions and validations defined in the provided block. If the underlying contract isn’t valid (at least one validation failed), then the execution flow will stop.
transaction
This step is a bit special, as it will wrap any other steps defined in its block inside a SQL transaction.
Any step that fails inside the transaction block will cause a rollback of the transaction.
try
This step will catch exceptions raised by the steps defined in its block. Specific exception classes can be provided if you don’t want to automatically catch all exceptions.
lock
This step uses DistributedMutex under the hood to create a lock around the steps defined in its block. This is useful when race conditions can happen.
options
This step is another special one, as it’s similar to a contract (without the validations part), but for options your service can take. This is useful if you need to change your service behavior depending on certain conditions. Also, that step can’t fail.
only_if
This step will only run the steps defined in its block if the provided condition is true. That’s another step that can’t fail by itself.
each
This step iterates over a collection from the context and runs the steps defined in its block for each item. The item is provided under its singularized name (e.g., each :users provides user:) along with index:. If any nested step fails, iteration stops immediately and the service fails with the failing item and index available in context.
An empty collection is silently skipped.
A persist: option allows values to accumulate across iterations and survive the loop’s variable isolation. This is useful for collecting results during bulk operations.
Steps arguments
Each step is called with the service context. To access a value in it, just provide its key as a keyword argument.
The context object
The only state a service maintains is its context object. This is where each step can put data to be used by other steps. Most of the time, you don’t need to access the context directly, as specialized steps (such as params, model or options) will store the proper data for you. But sometimes it’s necessary (even if uncommon). In those cases, it’s just a matter of using the context like you would with a hash:
def first_step
context[:my_special_key] = "My special value"
end
…
# Then in a later step, you can use it like any other key from the context
def another_step(my_special_key:)
# do something with `my_special_key`
end
Handling service results
Once a service has been called, it will return a result object, to know whether the call was a success. In the case of a failure, the result object can be inspected to know what failed and why.
Each step will store its outcome in the result object, accessible through special keys (like result.model.user for example). While this is nice, this would be tedious to manually check the result object. That’s why there’s a built-in feature allowing to run the service, match step outcomes and act upon results using a custom DSL.
This feature makes the use of a service a breeze. Continuing with our User::UpdateUsername service, this is how we could use it inside a controller (but it can work anywhere, not just in controllers):
def update
User::UpdateUsername.call(service_params) do |result|
on_success { |user:| render(json: success_json.merge(new_username: user.username)) }
on_failure { render(json: failed_json, status: 422) }
on_failed_contract { |contract| render(json: failed_json.merge(errors: contract.errors.full_messages), status: 400) }
on_model_not_found(:user) { raise Discourse::NotFound }
on_failed_policy(:can_update_username) { raise Discourse::InvalidAccess.new }
end
end
Passing a block to .call allows to “match” an outcome, a bit like if we were using pattern matching. There are two generic matchers (on_success and on_failure) and each specialized step has at least one dedicated matcher. The complete detailed list is available in the API section.
💡
on_failureis like a catch-all rule, it will match only if the service fails and no other more specialized matcher matches.
This declarative way helps decoupling what is handled by the caller (here a controller) from what is handled by the service.
Testing
To simplify testing, custom RSpec matchers have been added. It’s also considered a best practice to always follow the same structure. Remember to test the caller class too. If your service is called from a controller, for example, that controller should be tested with a request spec. Following the various outcome blocks will help to know what to test. Here is how we could test our User::UpdateUsername service:
RSpec.describe User::UpdateUsername do
describe described_class::Contract, type: :model do
it { is_expected.to validate_presence_of(:id) }
it { is_expected.to validate_presence_of(:username) }
it do
is_expected.to allow_values("0userName", "USERNAME", "username", "21421341").for(:username)
end
it { is_expected.not_to allow_values("invalid-username").for(:username) }
end
describe ".call" do
subject(:result) { described_class.call(params:, **dependencies) }
fab!(:user)
let(:params) { { username:, id: user_id } }
let(:dependencies) { { guardian: } }
let(:guardian) { user.guardian }
let(:username) { "NewUsername" }
let(:user_id) { user.id }
context "when contract isn’t valid" do
let(:username) { "----" }
it { is_expected.to fail_a_contract }
end
context "when model is not found" do
let(:user_id) { 0 }
it { is_expected.to fail_to_find_a_model(:user) }
end
context "when current user cannot update user's username" do
let(:guardian) { Guardian.new }
it { is_expected.to fail_a_policy(:can_update_username) }
end
context "when everything’s ok" do
it { is_expected.to run_successfully }
it "updates user's username" do
expect { result }.to change { user.reload.username }.to(username)
end
it "logs the action" do
expect { result }.to change { UserHistory.count }.by(1)
end
end
end
end
First, and because a contract is present, we test it using a dedicated describe block. Since a contract uses ActiveModel under the hood, the simplest way to test it is to use Shoulda Matchers.
Then, we use a describe block for the .call method, which is how the service is run. We’re using a context for each possible branching. It’s quite easy as we just have to follow the steps we defined in the service. You can see we’re not testing all the possible values to have the contract fail: that’s because it’s tested extensively above, so here we’re just ensuring the params step is properly called and if a bad value is provided, then it will stop the execution of the service.
For the other steps, if they can fail, then they should have a context using a dedicated matcher.
The run_successfully matcher ensures the service succeeded (result.success? is true) and will provide some debugging information if that’s not the case.
In the event of a matcher failing, it will output details about the result object to help debugging things:
Failures:
1) User::UpdateUsername.call when current user cannot update user's username is expected to fail a policy named 'can_update_username'
Failure/Error: it { is_expected.to fail_a_policy(:can_update_username) }
Expected policy 'can_update_username' (key: 'result.policy.can_update_username') to fail but it succeeded.
Inspecting User::UpdateUsername result object:
[1/6] [params] default (0.1152 ms) ✅
[2/6] [model] user (1.7147 ms) ✅
[3/6] [policy] can_update_username (0.0093 ms) ✅ ⚠️ <= expected to return false but got true instead
[4/6] [transaction] (2.5952 ms)
[5/6] [step] update (2.4038 ms) ✅
[6/6] [step] log (0.0054 ms) ✅
# ./spec/services/update_username_spec.rb:39:in `block (6 levels) in <main>'
# ./spec/rails_helper.rb:497:in `block (2 levels) in <top (required)>'
# /home/discourse/.bundle/gems/ruby/3.3.0/gems/webmock-3.23.1/lib/webmock/rspec.rb:39:in `block (2 levels) in <top (required)>'
Available matchers
fail_a_policy(name)
This matcher expects a policy named name to fail.
fail_a_contract
This matcher expects the service contract to be invalid.
fail_to_find_a_model(name)
This matcher expects a model step named name to not find its model.
fail_with_an_invalid_model(name)
This matcher expects a model step named name to find its model, but that model should be invalid.
fail_with_exception
This matcher expects the try step to have caught an exception. A specific exception class can be provided.
fail_a_step(name)
This matcher expects a step named name to fail.
run_successfully
This matcher expects the service to succeed.
API
Steps
params(name = :default, default_values_from: nil, base_class: Service::ContractBase, from_resource: nil, only: nil, except: nil, &block)
Arguments
- name: the name of the contract, in the case there is more than one. Defaults to
default. - default_values_from: name of a model to use to pre-fill the contract values. This is useful when you want some values of a model to be updated through a contract while applying other default values. A real-world example is available in the
Chat::UpdateChannelservice. - base_class: the base class to use for the contract. This is particularly useful for reusing an existing contract (for example, you could have very similar contracts between a
Createand aUpdateservice). Defaults toService::ContractBase. - from_resource: a resource whose writable attribute names and ActiveModel types are imported into the contract.
- only: writable attribute names to import. Accepts strings or symbols. An empty array imports no attributes.
- except: writable attribute names to exclude from the import. Accepts strings or symbols.
- block: the block containing all the validations, attribute definitions, etc.
This step declares the use of a contract to validate input parameters. Parameters provided to the service will be passed to the contract if their name matches the attributes defined in the contract.
With from_resource, all writable attributes are imported by default. Use only: or except: to select the attributes for an action:
params(from_resource: TopicResource, only: %i[title]) do
attribute :notify, :boolean, default: false
validates :title, presence: true
end
Imports run before the block. Explicit attribute declarations override imported types through normal ActiveModel behavior. A declaration that omits the type preserves the imported type. Selection applies only to imports, so the block can declare additional parameters or declare an excluded attribute explicitly.
Selection options require from_resource: and cannot be combined. Unknown or read-only selection names raise an error when the service is defined. Imports include only names and types.
With default_values_from, nested hashes are merged recursively. Supplied values override model values, including nil, false, and 0. An empty hash keeps the defaults at that level. A supplied array replaces the whole model array; an empty array clears it.
For example, model values { dimensions: { width: 640, height: 480 }, tags: ["draft"] } combined with params { dimensions: { width: 800 }, tags: [] } produce { dimensions: { width: 800, height: 480 }, tags: [] } before contract validation.
String and symbol keys are treated alike at every level. Combining defaults and inputs does not modify their hashes or arrays. Coercion and validation still run on the resulting values.
Under the hood, a class for the contract will be automatically created, allowing easy testing. The default contract will result in Contract, otherwise it will prepend the name used for the contract (for params(:user_avatar), this will give UserAvatarContract).
If the contract is invalid, it will stop the execution of the service. Its result object can be inspected by accessing the result.contract.<name> key of the main result object. The contract result object exposes two keys:
- errors: the errors returned by the contract.
- parameters: the raw parameters provided to the contract before any coercion happens.
options(&block)
Arguments
- block: the block containing the option definitions.
This steps is used to define options that can be provided to the service to change its behavior. The syntax to define an option is the same as the one used for contracts, but it has no validations. A good example can be found in the Chat::CreateMessage service.
This step cannot fail.
model(name = :model, step_name = :"fetch_#{name}", optional: false)
Arguments
- name: the name of the model. Defaults to
model. - step_name: the name of the method to call for this step. For example, when instantiating a new model, we could use
instantiate_model. Defaults tofetch_<model_name>. - optional: if the model is marked as optional, the step won’t fail if the model isn’t found. Defaults to
false.
This step helps to remove some boilerplate when fetching/instantiating models or a collection of models. A model can be pretty much anything (not only ActiveRecord models), being a single object or a collection. The result of the step will be stored in the context as name (so, by default, it would be context[:model]).
The step will fail if the model is nil or empty?, or if an exception is raised. In the case of an ActiveRecord object with unsaved changes, the step also fails if the model already contains errors (a record whose save failed, for example) — those errors are preserved for on_model_errors — or otherwise if running its validations marks it as invalid. Models without pending changes are not revalidated, and non-ActiveRecord objects are never validated.
Its result object can be inspected by accessing the result.model.<name> key of the main result object. The model result object exposes one or two keys:
- invalid: will be
trueif the model has been found but is invalid. - not_found: will be
trueif the model was not found. - exception: the exception that caused the step to fail.
policy(name = :default, class_name: nil)
Arguments
- name: the name of the policy. Defaults to
default. - class_name: a policy class to implement the logic instead of defining the step in the service. Defaults to
nil.
This step declares the use of a policy. A policy is just arbitrary code, and the step will fail if the policy result is falsy.
If you have a rather complex policy, it’s better to use a policy class. It needs to inherit from Service::PolicyBase and implement #call and #reason as using a policy class allows explaining in more details why the policy failed through the use of the #reason method. A complete example can be found in the Chat::DirectMessageChannel::Policy::MaxUsersExcess class used by the Chat::CreateDirectMessageChannel service.
The step will fail if the policy returns a falsy value. Its result object can be inspected by accessing the result.policy.<name> key of the main result object. The policy result object exposes one key:
- reason: the reason why the policy failed if a policy class was used.
transaction(&block)
This step is a bit special as its only purpose is to wrap other steps inside a SQL transaction. It cannot fail by itself but if a step fails inside the transaction block, the transaction will rollback.
try(*exceptions, &block)
Arguments
- exceptions: one or more exception classes to catch. Not providing any class is equivalent to provide
StandardError. - block: a block containing other steps.
This step wraps other steps. If any of the wrapped steps raises an exception, the try step will catch it and fail, which will halt the execution flow.
lock(*keys, &block)
Arguments
- keys: one or more keys to provide the lock with an unique ID.
- block: a block containing other steps.
This step wraps other steps. If the lock can't be acquired for any reason, the execution flow will be halted.
The keys provided are first matched against the params object of the service and, if not found, against the service context. This allows locking on values derived from earlier steps, such as a model fetched from the database. When a resolved value responds to id (e.g. an ActiveRecord model), its id is used automatically to build the lock name.
For example, if the provided key is user_id and its value is 1, then the resulting lock name will be user_id:1, so if the same service is called with 2 as the user_id value, it will not wait on the first lock. Similarly, lock(:topic) after a model :topic step will use the topic's id in the lock name.
only_if(condition_name, &block)
Arguments
- condition_name: the name of the condition to check.
- block: a block containing other steps.
This step wraps other steps. If the condition is not met (meaning the related method returns a falsy value), the steps inside its block will be skipped but the execution flow won’t be halted.
That step is very useful when you have several steps depending on the same condition to be run, as it allows to keep all the error handling provided by steps like model, policy, etc.
each(collection_name, as: nil, persist: nil, &block)
Arguments
- collection_name: the name of the context key holding the collection to iterate over.
- as: override the singularized item name. For example,
each :users, as: :memberprovidesmember:instead ofuser:. - persist: keys that should accumulate across iterations and survive the loop's variable isolation. Accepts a hash where values are lambdas or method symbols used to initialize the key, or an array of symbols for keys without initialization.
- block: a block containing other steps.
This step iterates over a collection and runs the steps defined in its block for each item. Each iteration receives the singularized item name (e.g., user: for each :users) and index: as keyword arguments.
By default, variables set inside the loop are isolated: they don't leak to the outer context. The persist: option allows specific keys to survive the isolation. This is particularly useful for accumulating results during bulk operations.
If any nested step fails (a policy returns falsy, a model is not found, or fail! is called), iteration stops immediately and the service fails. The failing item and index remain available in context for error reporting, and existing matchers (on_failed_policy, on_model_not_found, etc.) work as expected.
An empty collection is silently skipped.
# Fail-fast iteration
each :users do
policy :can_delete
step :destroy
end
# Accumulating results across iterations
each :tag_names, persist: { results: -> { { created: [], failed: [] } } } do
step :create_tag
end
# Custom item name
each :users, as: :target_user do
step :process
end
step(name)
Arguments
- name: the name of the step.
This is a generic step, to execute arbitrary code. A generic step won’t ever fail by itself, no matter what its return value is. If you need to mark a step as failed, you should use the #fail! method.
A generic step has a result object, even if by default it exposes nothing. It can be accessed with the result.step.<name> key of the main result object.
Helper available inside a step
fail!(message)
Arguments
- message: the error message to set on the result object.
This method can be used to mark a generic step as failed. The result object is accessible at result.step.<step_name> and exposes an error key.
The context object
This context object is available inside a step as context or as the return value of a service.
success?
Returns true if the context is set as successful (this is the default).
failure?
Returns true if the context is set as failed.
fail!(context = {})
Arguments
- context: the context to merge into the current one.
Marks the context as failed and raises a Service::Base::Failure exception.
fail(context = {})
Arguments
- context: the context to merge into the current one.
Marks the context as failed without raising an exception.
Calling a service with a block
The block form of .call can be used anywhere (a controller action, a job, another class, etc.). The provided actions will be evaluated in the order they appear, and the execution will stop at the first responder. The only exception to this is on_failure as it will always be executed last.
.call(context = {}, &actions)
Arguments
- context: the initial context to provide to the service. If the service is called from a controller, you can use the
service_paramshelper which will returnparamsand theguardianobject. - actions: the block containing the steps to match on.
Example
MyService.call(**service_params, extra_dependency: my_dependency) do |result|
on_success { |my_model:| do_something(my_model) }
on_failure { handle_generic_failure }
end
If you need to access the result object, it’s available as the first object passed to the main block (see example above). Each outcome block can match keys from the context (exactly as you do when writing step definitions) independently from what object is passed to the block.
For example, it means that with the on_failed_contract matcher, you could access a previously fetched model while using the provided contract as the first argument. It would be done like this:
on_failed_contract { |contract, my_model:| do_something(contract, my_model) }
You could do all this by only using the result object, but it’s a bit nicer this way (and will ensure the key you’re trying to access actually exists).
A required keyword argument (my_model:) will raise if the service didn’t set that key, which is what you want most of the time. For a key that’s only set on some paths (a model fetched by a step that didn’t run, or steps wrapped in an only_if block), give the argument a default value and it will be used as a fallback:
on_failure { |my_model: nil| do_something(my_model) }
on_success
Will execute the provided block if the service succeeds.
on_failure
Will execute the provided block if the service fails.
on_failed_step(name)
Arguments
- name: the name of the step to match.
Will execute the provided block if the step named name fails. It also provides the step result object as the first argument of the block.
on_failed_policy(name = "default")
Arguments
- name: the name of the policy to match. Defaults to
default.
Will execute the provided block if the policy named name fails. It also provides the policy result object as the first argument of the block.
on_failed_contract(name = "default")
Arguments
- name: the name of the contract to match. Defaults to
default.
Will execute the provided block if the contract named name is invalid. It also provides the contract result object as the first argument of the block.
on_model_not_found(name = "model")
Arguments
- name: the name of the model to match. Defaults to
model.
Will execute the provided block if the model named name is not present. It also provides the model result object as the first argument of the block.
on_model_errors(name = "model")
Arguments
- name: the name of the model to match. Defaults to
model.
Will execute the provided block if the model named name contains errors. It also provides the actual model as the first argument of the block.
on_exceptions(*exceptions)
Arguments
- exceptions: zero or more exception classes that can be caught by a
trystep.
Will execute the provided block if a try step failed by catching one of the provided exception classes. If no class is provided, then the block will be executed if a try step caught any exception. It also provides the actual exception as the first argument of the block.
on_lock_not_acquired(*keys)
Arguments
- keys: the keys used by the lock to match.
Will execute the provided block if the lock step failed.
Contracts
The main purpose of a contract is to validate the incoming data before feeding it to the models and to the service at a more global level. The important part being validating user input (typically coming from params in a controller, and services expect to access those parameters through the params key of their context).
A contract is actually an ActiveModel object, so all the API of the latter is available. Anyway, let’s see how to define and use a contract inside a service.
To define a service contract, just call params and open a block:
params do
attribute :id, :integer
attribute :username, :string
validates :id, presence: true
validates :username, presence: true, format: { with: /\A[a-zA-Z0-9]+\z/ }
end
Here, all the API from ActiveModel is available. In this example, we define we want to validate two attributes, id and username with their respective cast type (integer and string).
💡 Use cast types extensively as they’ll provide you with proper objects before any validation happens.
Rails ships with cast types for
big_integer,binary,boolean,date,datetime,decimal,float,immutable_string,integer,stringandtime. Custom cast types can be defined, we ship two:arrayandsymbol.
🙅 Don’t define attributes if you don’t transform them or validate them. The primary purpose of a contract is to validate data, it can also be used to cast or massage data before using it (usually a contract does both).
Then, we define validations, exactly like you would in an ActiveRecord model. Here, we’re checking for id and username not being blank and that username respects an expected format.
Another thing that is available in a contract, since it’s an ActiveModel object, are validation callbacks. If you need to manipulate the attribute values, you can do so by calling before_validation or after_validation. There are examples in the codebase, like in the Chat::CreateCategoryChannel service.
⚠️ Once run by the service, a contract is frozen and you can’t modify its attributes. If you need to do some processing on its values, you can do it directly inside the contract itself.
Some methods have been added to the contract object to make your life a bit easier when dealing with model updates and things like that:
#sliceand#mergeare available.#to_hashhas been implemented, so the contract object will be automatically cast as a hash by Ruby depending on the context. For example, with an ActiveRecord model, you can do this:user.update(**params).
Nested attributes
It’s fairly common to get nested parameters from an endpoint. This usually helps keeping things organized. Contracts support nested hashes and nested arrays. Let’s imagine we have a service that needs to respond to that kind of input:
{
"user": {
"id": 2,
"username": "new username"
},
"page": {
"sort": "asc",
"number": 3
}
}
We can easily define a contract to validate such input:
params do
attribute :user, :hash do
attribute :id, :integer
attribute :username, :string
validates :id, :username, presence: true
end
attribute :page, :hash, default: -> { {} } do
attribute :sort, :symbol, default: :asc
attribute :number, :integer, default: 1
validates :sort, inclusion: { in: %i[asc desc] }
validates :number, numericality: { only_integer: true, greater_than: 0 }
end
validates :user, presence: true
end
For each nested attribute we want to validate, we just have to open a block and inside that block, we can define attributes and validations as usual. Inside that block, we have access to another contract object, so everything we can do in the parent contract is available in the nested one.
There are some things to notice:
- Here, before opening the block, we’re using
:hashto tell our contract to expect another hash. If we had an array of hashes, we would have used:arrayinstead. - A nested attribute is just an attribute, so if you want to validate its presence, you have to add a validation for it (as done for the
userattribute). Otherwise, it will be considered as an optional attribute. - When using default values, like we do for the
pageattribute, make sure to provide a default value for the whole attribute (here, an empty hash) to avoidnilerrors when accessing nested attributes.
After validation, errors will be defined directly on the main contract, as usual. We’re using the same approach Rails does for nested attributes in models. For example, if the username attribute is blank, the error will be available at errors[:"user.username"]. The full message will be User username can't be blank.
Now, let’s imagine our input has arrays of hashes, like this:
{
"tags": [{ "name": "tag1" }, { "name": "tag2" }]
}
A contract to validate that input could look like this:
params do
attribute :tags, :array do
attribute :name, :string
validates :name, presence: true
end
validates :tags, presence: true
end
If there are errors, it will use the index of the array to define them. For example, if the second tag has no name, the error will be available at errors[:"tags[1].name"]. The full message will be Tags[1] name can't be blank.
And of course, you can combine nested hashes and arrays as you see fit.
Reusing contracts
Sometimes, you may want to reuse a contract between different services. For example, you could have a Create and an Update service for the same concept, and both would share most of their contract logic.
First, let’s create a (naive) User::Create service:
class User::Create
include Service::Base
params do
attribute :username, :string
attribute :email, :string
attribute :date_of_birth, :date
validates :username, presence: true, format: { with: /\A[a-zA-Z0-9]+\z/ }
validates :email, :date_of_birth, presence: true
end
policy :can_create_user
model :user, :create_user
…
end
The important part here is the contract checking for a username, an email and a date of birth to allow creating a user.
Now, let’s say we have another service to update a user, and you can update the same attributes. If we were to write a dedicated contract, that could be a bit tedious, as we’d want to be able to only update the username without having to update the email, for example.
We can combine two options of the params step to help us, default_values_from and base_class:
class User::Update
include Service::Base
model :user
params default_values_from: :user, base_class: User::Create::Contract
private
def fetch_user(params:)
User.find_by(id: params.id)
end
end
First, we fetch the user model to update. If params.id wasn’t provided or is invalid, the service will stop here.
Then, we define the params step. Here, we tell the service to use the User::Create::Contract as the base class for the contract. This means all the attributes and validations defined in that contract will be available in this service too. Using the default_values_from: :user option tells the service to pre-fill the contract attributes with the values from the fetched user model. This way, if only the username attribute is provided, the email and date_of_birth attributes will be pre-filled with the current values from the user, thus passing the presence validations.
Parameters
Parameters provided to the service through the params key are accessible like params.my_param (not params[:my_param]). Parameters are available even if they're not processed by a contract.
Policy objects
When a policy starts becoming complex or when you’d like to provide more context on why it can fail, then it’s time to use a policy object instead of a simple policy.
It’s quite easy to create a new policy object, let’s take the can_update_username policy we have in the User::UpdateUsername service and convert it:
class User::Policy::CanUpdateUsername < Service::PolicyBase
delegate :user, to: :context, private: true
def call
guardian.can_edit_username?(user)
end
def reason
# Here we can put more complex logic to dynamically output a reason, this is just an example
I18n.t("cannot_edit_username", username: user.username)
end
end
There are some rules to keep in mind when writing a policy object:
- It must inherit from
Service::PolicyBase. - It must define two methods:
#calland#reason. - The context object is automatically injected in the policy, and is available by calling
#context(like in a service). - The guardian object is also automatically available as
#guardian. - By convention, it should be namespaced under its concept followed by the
Policynamespace: for our current example, it meansUser::Policy::which maps toapp/services/user/policy/on the filesystem.
💡 To keep things short and clear, feel free to use
delegateextensively.
Then, when you want to use it in a service, just write your step like this:
policy :can_update_username, class_name: User::Policy::CanUpdateUsername
Actions
When a step starts becoming too complex, like it has too many branching statements for example, then it’s time to extract all that logic to a dedicated class. That logic could live in a model for instance, but when in doubt, just create a new action.
An action is just a small class that responds to a .call method by convention. What happens inside is up to you. The idea, however, is to execute an action (hence the name) with minimal overhead. It means an action should not validate data, for example. It should be called with valid objects only, thus being able to work with them right away. It also means that an action should not fail. You can think of an action as a bare-bones service without all the bells and whistles. Also, an action can be reused by different services.
Here again, it’s relatively simple to create a new action. Let’s take as an example our log step:
class User::Action::LogUsernameChange < Service::ActionBase
option :actor
option :user
def call
StaffActionLogger.new(actor).log_username_change(
user,
user.username_before_last_save,
user.username,
)
end
end
Of course, this is a very basic example, you can do more complex things in an action. A real-world example can be found in User::Action::TriggerPostAction.
Service::ActionBase comes with Dry::Initializer which provides a nice mini-DSL:
- Use
option :my_argto declare a required keyword argument namedmy_arg. - Use
optional: trueto declare the argument optional (for instance,option :my_arg, optional: true).
You should not need anything more than that to work with an action, but if you want to use some advanced features of dry-initializer (like coercion), just take a look at their docs.
Some rules to keep in mind when writing an action:
- It must inherit from
Service::ActionBase. - It must define one method:
#call. - You should prefer
optionoverparamto define arguments, as it’s a bit more self-documenting on the caller side. It also allows you to use the hash shorthand syntax. - By convention, it should be namespaced under its concept followed by the
Actionnamespace: for our current example, it meansUser::Action::which maps toapp/services/user/action/on the filesystem.
💡 To keep things short and clear, feel free to use
delegateextensively.
Then, when you want to use it in a service, just write your step like this:
def log(guardian:, user:)
User::Action::LogUsernameChange.call(actor: guardian.user, user:)
end
Best practices and guidelines
- Use namespaces for concepts. Most of the time, a model name is a business concept.
- Name services using a verb, describing the action (
CreateUsernotUserCreator). - Don’t repeat the concept name in the service name (
User::Createis easily understandable, no need forUser::CreateUser). - If your service receives parameters, they should be validated through the
paramsstep. - Don’t put too much logic in a step. If logic becomes complex, prefer to use an action instead. It’s better to offload complex logic to an action, as it will simplify the reasoning and the testing.
- Don’t inject models into services. Only dependencies (like a guardian, or input parameters) should be injected. Models are expected to be fetched inside the service and have their error handled by the
modelstep. - Use a policy object when a policy logic becomes relatively complex and/or if you need to expose a custom reason why that policy failed. It could be a dynamic reason or simply because building the reason needs some dedicated logic.
- A good rule of thumb is to extract any logic that becomes relatively complex into dedicated objects. They can be models, PORO, actions, etc. Just don’t try to always pack everything into the service itself.
- Likewise, avoid utility methods in a service. A service should only have step definitions. If some processing is needed, then it can probably be done in a contract, an action or extracted somewhere else.
- If an action is pretty complex (it has a lot of edge cases or several branching statements, for example), test it in isolation instead of testing it directly in the service specs. Then in the service specs, just ensure that action is properly called.
- When defining a method for a step, don’t provide default parameters, the framework won’t allow it. If you need a default value for something, it’s probably best to declare it in a contract.
- Try to follow the steps order when writing your methods or your tests.
Debugging
The main tool to help debugging a service is the steps inspector. However, it’s not a live debugging tool, as it inspects the result object once the service has run.
Steps Inspector
This small tool is very useful to debug the outcome of a service. The Service::StepsInspector class is not meant to be used directly, as there’s a shortcut available directly on any result object.
Call #inspect_steps on a result object, and it will output all the steps of the service with their current state. This is how it looks like for the User::UpdateUsername service we’re using in our examples:
Inspecting User::UpdateUsername result object:
[1/6] [params] default (0.3581 ms) ✅
[2/6] [model] user (68.9291 ms) ✅
[3/6] [policy] can_update_username ❌
(3 more steps not shown as the execution flow was stopped before reaching them)
Here we can see each step is numbered to track the execution order easily. Then the type of the step is outputted, followed by its name and how much time the step took to run. Finally, there’s either a checkmark or a cross, depending on the step outcome.
So, we can see the can_update_username policy failed, and since it’s a simple policy it’s easy to see that the problem lies with the user not having enough permissions (through guardian).
The policy is defined as:
def can_update_username(guardian:, user:)
guardian.can_edit_username?(user)
end
In the case of a more complex result object, like with a contract, the steps inspector provides the error from the failing step.
Let’s say we call our User::UpdateUsername service without providing any parameters. It would then fail at the params step.
The inspector now outputs this:
Inspecting User::UpdateUsername result object:
[1/6] [params] default ❌
(5 more steps not shown as the execution flow was stopped before reaching them)
Why it failed:
#<ActiveModel::Errors [#<ActiveModel::Error attribute=id, type=blank, options={}>, #<ActiveModel::Error attribute=username, type=blank, options={}>, #<ActiveModel::Error attribute=username, type=invalid, options={:value=>nil}>]>
Provided parameters: {"id"=>nil, "username"=>nil}
Here we can see ActiveModel errors, telling us id and username were blank. The provided parameters are also outputted to help debugging.
Here’s a recap of what errors will be outputted for the different steps:
- model: when the model is an
ActiveRecordone, it outputs its validation errors. Otherwise, it outputs the reason why it failed, probably aModel not founderror. - params: outputs the validation errors followed by the provided parameters.
- policy: doesn’t output anything for a simple policy. When a policy object is used, then it outputs its
reason. - try: outputs the exception caught by
try. - lock: outputs the lock wasn’t acquired and provides the lock name.
- step: outputs the message provided to
fail!.
Live debugging
We don’t have a live debugging tool (yet), but it’s not that hard to make sense of what’s happening in a service.
The simplest thing to do is to put a binding.pry statement inside any step you want to inspect. It’s just a method, so you’ll have access to its parameters and to the context object. To inspect it, you can call #to_h on it and see what keys and values it holds.
Remember, if your pry session doesn’t open, it means a step before the one you’re trying to inspect failed.
Write end-to-end system specs for the Discourse user interface
Rails system specs are used to simulate the actions of a real user using the app in a browser. We use the Playwright ruby driver which is becoming an industry standard. The tests run locally and in CI out of the box. Capybara is the test framework used on top of rspec to interact with the web browser, and it sends commands to Playwright.
We currently only support running system specs in Chrome, make sure you have Chrome installed before proceeding. Run pnpm i to ensure Playwright is correctly set up.
Since the Discourse app is an Ember Single Page Application, there are some unique constraints and challenges to writing system specs. It's important to keep in mind that you should always be observing for changes in the DOM in your tests, not manually waiting for things to happen or adding artificial sleep time. Also, the JavaScript build is separate from the Rails server, which means you must be running the frontend bundler when writing system specs.
Running system specs
Any system spec can be run with the bin/rspec FILENAME.rb command. By default the specs are run in a headless version of Chrome, meaning no browser window will open while the spec is running.
:warning: If you do not already have the Discourse rails server running with
bin/dev, you will need to runpnpm --dir=frontend/discourse buildafter every JavaScript change to see these reflected in the headless browser. It is recommended you just keep your local server running while writing system specs.Also, ensure you run rails migrations any time you make modifications to your local database schema.
There are various environment flags that can be used to change how the spec is run.
Commonly Used
PLAYWRIGHT_HEADLESS- Set to0to open a browser while the spec is running. This will allow you to observe what is going on while the browser is being driven by the test harness. Combine with debugging tools and Chrome devtools to help write and debug specs.
Rarely Used
[details="These environment variables aren't often used but provide greater control over Playwright and Capybara"]
CAPYBARA_REMOTE_DRIVER_URL- Allows Capybara to control a remote Chrome browser instead of a local one.PLAYWRIGHT_DISABLE_VERBOSE_JS_LOGS- By default JS logs are verbose, so errors from JS are shown when running system tests, you can disable this by setting it to1.CAPYBARA_SERVER_HOST- The hostname of the server that Playwright Webdriver is running on.CAPYBARA_SERVER_PORT- The port of the server that Playwright Webdriver is running on.CAPYBARA_DEFAULT_MAX_WAIT_TIME- Overrides the default wait time when looking for DOM elements in Capybara. [/details]
Writing system specs
Basics
The bare minimum needed for a system spec is adding type: :system to the top level describe block of the spec. This makes sure RSpec uses Capybara et. al. via rails_helper.
# frozen_string_literal: true
describe "Bookmarking posts and topics", type: :system do
it "allows logged in user to create bookmarks with and without reminders" do
# browser controls and rspec assertions go here
end
end
If you need a logged in user you will need to fabricate one and sign them in. Our convention is to call the variable for the currently logged in user current_user:
# frozen_string_literal: true
describe "Bookmarking posts and topics", type: :system do
fab!(:current_user) { Fabricate(:user) }
before do
sign_in(current_user)
end
it "allows logged in user to create bookmarks with and without reminders" do
# browser controls and rspec assertions go here
end
end
If you want to simulate a mobile device, you need only add mobile: true to the it block:
context "when mobile"
it "allows logged in user to create bookmarks with and without reminders", mobile: true do
# browser controls and rspec assertions go here
end
end
This will change the screen size with Capybara and this.site.isMobileDevice will be true in the Ember app, meaning some components will appear or behave differently.
Best Practices
Many of these will be further expanded throughout this document, but this is a quick reference to come back to.
- Remember that you should never manually sleep or wait for things in system specs, see the Gotchas section below
- Do not store references to elements on the page in variables, they can quickly go "stale" in Playwright. Always
findthem again when you need them - Refactor system spec code into Page Objects once a repetitive pattern is apparent
- RSpec expectations should be used very sparingly in Page Objects and preferably not at all, most expectations should be in the spec file
- Use high specificity CSS classes with BEM, these will aid in finding unique elements when writing system specs
- Make sure you are testing the happy path only, no complicated
contextblocks and branching conditionals - Keep speed in mind, if a system test you have written is running quite slow investigate why and see if there are some things you can improve
- Keep direct execution of JavaScript with things like
page.execute_scriptto a minimum - Use
skiporxitfor tests that are known to be flaky in CI environments - Use Capybara and RSpec matchers effectively to avoid waiting too long or checking for an element too early, see Gotchas section below
Capybara DSL and RSpec Matchers
Capybara has its own DSL which is accessible in every system spec and in every Page Object class, reference for this can be found at https://rubydoc.info/github/teamcapybara/capybara/master#the-dsl . A good cheat sheet can be found at https://devhints.io/capybara .
For example if you are looking for an element on the page you can use find with a CSS class:
expect(find(".my-class")).to have_content(I18n.t("some.key"))
Most Capybara DSL supports passing in an optional wait parameter to override the default time that Capybara waits for an element or selector to be found in the DOM. This should be used rather than using things like sleep. This can be useful in cases where the backend takes a longer time to update the UI. For example:
find(".some-element", wait: 10)
It's good practice to add CSS classes to elements as identifiers that have good specificity so your system specs are not finding other elements on the page by mistake. We use BEM for this.
This DSL is automatically included in all of our Page Objects, see the Page Object section below for more information.
:warning: Gotchas
Capybara Selectors and Waiting
It is critically important to remember what https://rubydoc.info/github/teamcapybara/capybara/master#asynchronous-javascript-ajax-and-friends says . Never use this format of checking for an element or CSS on the page:
expect(page.has_css?(".selector")).to eq(true)
expect(!page.has_css?(".selector")).to eq(true)
Always use these formats instead:
expect(page/page_object).to have_css/have_custom_selector
expect(page/page_object).to have_no_css/have_custom_selector
This is because the latter format uses Capybara’s built in wait functionality whereas the former does not, this is important because we have an SPA with lots of AJAX calls, and we need to wait until a maximum timeout for elements or CSS to appear on the page.
Never manually wait for things to happen in a system spec using ruby's sleep!
Capybara Matcher DSL Negative Slowdowns
The same magic above that allows us to define our own has_X methods have some terrible speed implications when used in the not_to form. For example:
expect(topic_page).not_to have_post_content(post)
This will cause an several seconds of time to be added to the spec. To fix this, we can write a negative DSL matcher directly in our Page Object and that does not take the same hit:
def has_no_post_content?(post)
post_by_number(post).has_no_content?(post.raw)
end
expect(topic_page).to have_no_post_content(post)
Using visit in a SPA
In Capybara, you can use visit to go directly to a page. For example:
page.visit("/t/123")
This works fine in our Ember SPA for the initial navigation and page load. However, if you try to use it when navigating to other pages in specs, keep in mind that visit causes a full page refresh, clearing any UI state. To navigate to other pages it's generally best to click on a link directly:
page.find(".some-link").click
Avoiding direct assertions against database records
Asserting directly against database records within system specs may lead to inconsistent test results if you don't appropriately wait for the backend updates to synchronize with the frontend.
For example, directly checking database states like this in system tests can be problematic:
expect(Topic.count).to eq(2)
This assertion assumes that the backend update has already occurred, ignoring any asynchronous behavior.
To avoid such issues:
-
Observe Changes in the UI System specs should focus on what the user can observe via the user interface. Use Capybara's built-in matchers to wait for DOM updates. For example:
expect(page).to have_selector(".success-message")or
expect(topic_page).to have_topic(topic) -
Wait for Backend Synchronization If necessary, use a helper like
try_until_successto check database state explicitly but sparingly:try_until_success do expect(Topic.count).to eq(1) end
Page Objects
To make querying and inspecting parts of the page easier and reusable in between system specs, we are using the concept of Page Objects. A basic Page Object looks like this:
# frozen_string_literal: true
module PageObjects
module Pages
class Tag < PageObjects::Pages::Base
def visit_tag(tag)
page.visit "/tag/#{tag.name}"
self
end
def tag_info_btn
find("#show-tag-info")
end
def add_synonyms_dropdown
PageObjects::Components::SelectKit.new("#add-synonyms")
end
def search_tags(query)
add_synonyms_dropdown.search(query)
end
def tag_box(tag)
find(".tag-box div[data-tag-name='#{tag}']")
end
end
end
end
Page Objects are responsible for the following:
- Visiting URLs, either directly or by clicking elements
- Finding common elements based on CSS selectors or XPaths
- Performing common actions in the UI (e.g. finding and clicking on a specific button)
- Filling in or otherwise interacting with input elements
We split our Page Objects into 3 classifications -- Page, Component, Modal.
- A "Page" here generally corresponds to an overarching Ember route, e.g. "Topic" for
/t/324345/some-topic, and this contains logic for querying components within the topic such as "Posts". All of these inherit fromPageObjects::Pages::Base. - A "Modal" is any given modal window that opens within the app. All modal page objects inherit from
PageObjects::Modals::Base, which handles open/closed states and clicking outside the modal. - A "Component" is any reusable component in the Ember app and roughly maps to Ember components, though in some cases it may represent a small section of a Page. All of these inherit from
PageObjects::Components::Base.
When using Page Objects inside system specs, you should use let to store instances of them in variables rather than defining them in your specs inline. The start of your spec file may look something like this:
let(:modal) { PageObjects::Modals::Base.new }
let(:composer) { PageObjects::Components::Composer.new }
let(:topic) { PageObjects::Pages::Topic.new }
let(:cdp) { PageObjects::CDP.new }
Then, you can use these variables to interact with the page in a declarative way. For example:
it "bookmarks a post" do
topic_page.visit(topic.id)
topic_page.bookmark_post(post.id)
expect(topic_page.post_by_id(post.id)).to be_bookmarked
end
Generally a good rule of thumb to follow for creating Page Objects is to write your system spec without them first, then extract commonly referenced elements and actions into a Page Object for the corresponding page, component, or modal. Chrome DevTools and other debugging tools below are your friend in this process.
Capybara DSL and RSpec Matchers in Page Objects
All Capybara DSL is accessible in Page Objects because it is included in the base classes.
Every method we define inside Page Objects in the form has_x? magically :sparkles: becomes a custom RSpec matcher that respects Capybara's waiting logic. For example in bookmarks we have these matchers:
def has_post_content?(post)
post_by_number(post).has_content?(post.raw)
end
def has_post_bookmarked?(post)
post_by_number(post).has_css?(".bookmarked")
end
Which are used like so inside system specs:
expect(topic_page).to have_post_content(post)
expect(topic_page).to have_post_bookmarked(post)
The opposite is true as well -- you can define has_no_X? methods and they will do the opposite:
def has_no_d_editor?
page.has_no_css?(D_EDITOR_SELECTOR)
end
Which is used like so in a system spec:
expect(category_page).to have_no_d_editor
Simple "boolean" methods like open?, closed? etc. will be usable as .to be_x RSpec matchers, which you can see the reference for at https://www.rubydoc.info/github/rspec/rspec-expectations/RSpec/Matchers .
def open?
has_css?(".dialog-container")
end
Which is used like so in a system spec:
expect(dialog).to be_open
Discourse Secret Sauce :hamburger:
There are many Discourse-specific things to remember when writing system specs, whether they are commonly used helpers or general knowledge about the app and commonly used components. More may be added to this section over time.
fab!
If you have used let and let! in RSpec before then you will have an inkling of what fab! might do:
fab!will create an instance variable that is stored and used for all the specs, rather than being recreated before each one likeletis. This is very useful for things like a User or Topic that is used multiple times in the spec without modification in acontextblock.fab!can also be used with the name of a model, and it will use the default Fabricator for that model. For examplefab!(:topic)is the equivalent to doingfab!(:topic) { Fabricate(:topic) }
You should use this whenever possible to avoid unnecessary database work.
Spec Helpers
Most of these can be found in SystemHelpers.
sign_in- Directly posts to ourSessionController#becomeendpoint, which only works in the test environment. Used if you need to log in as a user for a test.try_until_success- Wrap an RSpec expectation in this block and it will run every 0.1 seconds until the provided timeout, which by default is the Capybara default max wait time. This is useful for checking the database directly to see if a change has been applied or for various other things where it is tricky to find a DOM element that you can wait for in the UI. Should be used sparingly.wait_for_attribute- Waits for an attribute to equal a value on a specific DOM element. Usestry_until_successto wait for a maximum amount of time.wait_for_animation- Waits for an element to stop moving on the page, and for other animations to settle. Usestry_until_successto wait for a maximum amount of time.resize_window- Temporarily resizes the browser window to a specific width and height.using_browser_timezone- Sets the timezone of the browser to something different to your local timezone using a TZ identifier (e.g.Africa/Algiers)setup_system_test- Run automatically before every system spec, it sets variousSiteSettingvalues that make sense, e.g. disabling "user tips" so they don't get in the way when searching for DOM elements.
Common Components
For common components, we should utilize Page Objects heavily to abstract repeated behavior. Below are some common components used widely throughout the app, what they do, and if possible a link to their Page Object class.
SelectKit
Used for all dropdowns, multiselects, and search fields in Discourse. See PageObjects::Components::SelectKit. You must expand the dropdown, then select an item from it using a name or a value. You may also want to manually collapse it at times in the case of multiselects. Example usage:
tag_chooser = PageObjects::Components::SelectKit.new(".tag-chooser")
tag_chooser.expand
tag_chooser.select_row_by_name(tag2.name)
tag_chooser.collapse
Toasts
Quick messages shown on the screen based on a user action, which can be success, warning, info, or error. See PageObjects::Components::Toasts. Example usage:
expect(toast).to have_success(I18n.t("some.success.message"))
Dialog
Messages shown to the user, which can be informative or require confirmation. See PageObjects::Components::Dialog. Example usage:
expect(dialog).to be_open
dialog.click_yes
expect(dialog).to be_closed
There are many more examples in spec/system/page_objects/components.
Plugins
Plugin system specs work in the same way as core system specs, but must be run from the directory of the discourse core repo.
I18n
When comparing strings stored in our server.en.yml and client.en.yml localization files for expectations in system specs, you should use our I18n library.
If you need to use a string from client.en.yml, which is where the majority of user-facing strings are stored, you will need to prefix it with either js. or admin_js. depending on whether it is from the admin interface or the rest of the interface:
# Admin string from client.en.yml
expect(sidebar).to have_no_section_link(
I18n.t("admin_js.admin.community.sidebar_link.moderation_flags"),
)
# All other strings from client.en.yml
expect(find(".topic-list-header .static-label").text).to eq(
I18n.t("js.filters.new.topics_with_count", count: 3),
)
Caveats
There are some caveats and situations where you don't need to do this. For user-generated or fabricated strings, this is not necessary.
# Fabricated strings
fab!(:topic) { Fabricate(:topic, title: "Best topic ever!")
it "checks the topic title" do
# Using a fabricated string directly
expect(topic_page).to have_title(topic.title)
# Also acceptable
expect(topic_page).to have_title("Best topic ever!")
end
# User-generated strings
it "closes topics with message" do
# Filling in a user-generated string
topic_bulk_actions_modal.fill_in_close_note("None of these are useful")
topic_bulk_actions_modal.click_bulk_topics_confirm
# Check that the topic now has the message, which is user-generated content
visit("/t/#{topic.slug}/#{topic.id}")
expect(topic_page).to have_content("None of these are useful")
end
Finally, some strings are quite big and there's no need to check that the entire contents are on the page and match correctly. In that case, matching part of the string with direct comparison is fine.
Rate Limiting
Our RateLimiter system is disabled by default in specs. However if you need to turn it on to test some rate limiting specifically in system specs (though you should use request specs for this), use RateLimiter.enable.
Advanced Chrome Interaction
In certain cases you will need to use some advanced features of Chrome in your system specs. Some examples are interacting with the clipboard (copy and paste) and network manipulation (simulating slow connections). This is achieved using the Chrome DevTools Protocol, and sometimes with native Capybara functionality.
These advanced interactions are all stored in the PageObjects::CDP class.
Clipboard example:
it "allows copying message transcripts" do
cdp.allow_clipboard
chat_page.visit_channel(channel_1)
channel_page.messages.copy_text(message_1)
expect(cdp.read_clipboard.chomp).to eq(message_1.message)
end
Network example:
it "allows cancelling uploads" do
visit("/new-topic")
expect(composer).to be_opened
file_path_1 = file_from_fixtures("huge.jpg", "images").path
cdp.with_slow_upload do
attach_file(file_path_1) { composer.click_toolbar_button("upload") }
expect(composer).to have_in_progress_uploads
find("#cancel-file-upload").click
expect(composer).to have_no_in_progress_uploads
expect(composer.preview).to have_no_css(".image-wrapper")
end
end
Direct JavaScript Execution
You can also directly run arbitrary JavaScript code in the browser with Capybara though this should be done sparingly, since Ember manages the lifecycle of the app. If you must, you can do it like so:
page.execute_script(<<~JS)
alert("Look ma, JavaScript!");
JS
Debugging
Writing and debugging system specs can be tricky at times, especially when they become "flaky" and start failing in strange ways. These debugging tools help with writing the specs in the first place and figuring out what is wrong.
:traceallows to capture a trace in a zip file which you can load at https://trace.playwright.dev or locally throughpnpm dlx playwright show-trace /path/to/trace.zip
it "shows bar", trace: true do
# code
end
:videoallows to capture a video of your spec
it "shows bar", video: true do
# code
end
pause_test- This helper can be used in your spec to pause execution usingbinding.pryso you can inspect the page and other local spec variables. You can resume execution when done. This can be used withPWDEBUG=1to start the playwright debugger, this is currently limited.debuggerand{{debugger}}- If you are usingPLAYWRIGHT_HEADLESS=0then any JavaScript debug breakpoints will be hit in the browser. The Ember{{debugger}}helper in templates works as well.- Screenshots - Every time a system spec fails Capybara will produce a screenshot, typically in the
$REPO/tmp/capybaradirectory. You can also manually callsave_screenshotinside your spec to do this. save_and_open_page- Use this to dump the current HTML of the page and open it in your browser.PLAYWRIGHT_SLOW_MO_MS=1000allows you to have 1000ms delay on each action, useful to see what's happening in your test.
Getting screenshots from Github Actions
For system test failures in Github Actions Runners, there are screenshots we can download from job artifacts. In the job's summary, artifacts are located at the bottom.
[details="Screenshots on how to get there"]


[/details]
Discourse toolkit to render forms.
Basic Usage
FormKit exposes a single component as its public API: <Form />. All other elements are yielded as contextual components, modifiers, or plain data.
Every form is composed of one or multiple fields, representing the value, validation, and metadata of a control. Each field encapsulates a control, which is the form element the user interacts with to enter data, such as an input or select. The control type is specified via @type on the field. Other utilities, like submit or alert, are also provided.
Here is the most basic example of a form:
import Component from "@glimmer/component";
import { action } from "@ember/object";
import Form from "discourse/components/form";
export default class MyForm extends Component {
@action
handleSubmit(data) {
// do something with data
}
<template>
<Form @onSubmit={{this.handleSubmit}} as |form|>
<form.Field
@name="username"
@title="Username"
@validation="required"
@type="input"
as |field|
>
<field.Control />
</form.Field>
<form.Field @name="age" @title="Age" @type="input-number" as |field|>
<field.Control />
</form.Field>
<form.Submit />
</Form>
</template>
}
Form
Yielded Parameters
form
The Form component yields a form object containing contextual components and helper functions.
Common members include:
Field,Object,Collection,Fieldset,Row,Section,ContainerSubmit,Reset,Button,Alert,ActionsCheckboxGroup,InputGroup,ConditionalContentset(name, value),setProperties(object),addItemToCollection(name, value)
Example
<Form as |form|>
<form.Row as |row|>
<!-- ... -->
</form.Row>
</Form>
transientData
transientData is the form's current draft state. It starts as a clone of @data, updates as the user edits fields, and does not mutate the original @data object.
:information_source: This is useful if you want to have conditionals in your form based on other fields.
Example
<Form as |form transientData|>
<form.Field @name="amount" @title="Amount" @type="input-number" as |field|>
<field.Control />
</form.Field>
{{#if (gt transientData.amount 200)}}
<form.Field @name="confirmed" @title="Confirm" @type="checkbox" as |field|>
<field.Control>I know what I'm doing</field.Control>
</form.Field>
{{/if}}
</Form>
Arguments
@data
Initial state of the form data.
FormKit expects a plain JavaScript object (POJO). Internally it clones that object into draft state, tracks patches, and only mutates its own internal copy.
Keys matching field @names are prepopulated automatically, including nested object and collection paths.
:information_source:
@datais treated as immutable. Edits do not mutate the original object you pass in. If you need to keep some external state in sync while editing, do that explicitly in@onSet.
:warning: A new
@dataobject rebuilds the whole form, discarding focus and unsaved edits. Keep its identity stable while the form is open.
When deriving a form object from a model, build it once in a class field or constructor:
formData = getProperties(this.args.model, "foo", "bar", "baz");
Avoid getters or (hash ...) that recompute while the form is open, e.g. after writing back to the model in @onSet or @onSubmit. If the model itself can change, re-create the owning component per model, e.g. {{#each (array @model) as |model|}}.
Parameter
data(Object): The data object passed to the template.
Example
<Form @data={{hash foo="bar"}} as |form|>
<form.Field @name="foo" @title="Foo" @type="input" as |field|>
<!-- This input will have "bar" as its initial value -->
<field.Control />
</form.Field>
</Form>
@onRegisterApi
Callback called when the form instance is created. It allows the developer to interact with the form through JavaScript.
Parameters
callback(Object): The object containing callback functions.callback.get(Function): Returns the current value for a field name.callback.submit(Function): Function to submit the form.callback.reset(Function): Function to reset the form.callback.set(Function): Function to set a key/value on the form data object.callback.setProperties(Function): Function to set an object on the form data object.callback.addError(Function): Function to add an error programmatically.callback.removeError(Function): Function to remove a single field error.callback.removeErrors(Function): Function to clear all errors.callback.isDirty(boolean): Tracked property exposing the dirty state of the form. It becomestrueafter changes are made and returns tofalseafter reset.
Example
registerAPI({ get, submit, reset, set, addError }) {
// Interact with the form API
get("foo");
submit();
reset();
set("foo", 1);
addError("foo", { title: "Foo", message: "Something went wrong" });
}
<Form @onRegisterApi={{this.registerAPI}} />
@onSubmit
Callback called when the form is submitted and valid.
Parameters
data(Object): The current draft data for the form.
Example
handleSubmit({ username, age }) {
console.log(username, age);
}
<Form @onSubmit={{this.handleSubmit}} as |form|>
<form.Field @name="username" @title="Username" @type="input" as |field|>
<field.Control />
</form.Field>
<form.Field @name="age" @title="Age" @type="input-number" as |field|>
<field.Control />
</form.Field>
<form.Submit />
</Form>
@onReset
Callback called after FormKit rolls the draft data back to its initial state and clears errors.
Parameters
data(Object): The rolled-back draft data.
Example
handleReset(data) {
console.log("reset to", data);
}
<Form @data={{this.formData}} @onReset={{this.handleReset}} as |form|>
<form.Field @name="username" @title="Username" @type="input" as |field|>
<field.Control />
</form.Field>
<form.Reset />
</Form>
@validate
A custom validation callback added directly to the form. This runs once per validation pass, after field-level validation.
Example
@action
myValidation(data, { addError }) {
if (data.foo !== data.bar) {
addError("foo", { title: "Foo", message: "Bar must be equal to Foo" });
}
}
<Form @validate={{this.myValidation}} />
An asynchronous example:
@action
async myValidation(data, { addError }) {
try {
await ajax("/check-username", {
type: "POST",
data: { username: data.username }
});
} catch(e) {
addError("username", { title: "Username", message: "Already taken!" });
}
}
@validateOn
Controls when FormKit should validate.
- Accepted values:
submit(default),change,focusout, andinput.
Example
<Form @validateOn="change" />
@onDirtyCheck
Callback used during route transitions when the form is dirty. Return a truthy value to show the built-in "dirty form" confirmation dialog, or a falsy value to skip it.
Parameters
transition(Transition): The Ember route transition being processed.
Example
@action
onDirtyCheck(transition) {
return transition.to?.name !== "wizard.step";
}
<Form @onDirtyCheck={{this.onDirtyCheck}} />
Field
@name
A field must have a unique name. This name is used to read/write the value on the form data object and is also used for validation.
Names cannot contain . or -. Dots are reserved for nested paths such as profile.location.city.
Example
<form.Field @name="foo" @title="Foo" @type="input" as |field|>
<field.Control />
</form.Field>
@title
A field must have a title. It will be displayed above the control and is also used in validation.
Example
<form.Field @name="foo" @title="Foo" @type="input" as |field|>
<field.Control />
</form.Field>
@type
A field must have a type. This determines which control component is rendered. The available types are:
- Input types:
input(defaults to text),input-text,input-number,input-email,input-password,input-url,input-tel,input-date,input-time,input-datetime-local,input-color,input-month,input-week,input-range,input-search,input-hidden - Other controls:
checkbox,code,calendar,color,composer,custom,emoji,icon,image,menu,password,question,radio-group,select,tag-chooser,textarea,toggle
Example
<form.Field @name="foo" @title="Foo" @type="input" as |field|>
<field.Control />
</form.Field>
@description
The description is optional and will be shown under the title when set.
Example
<form.Field
@name="foo"
@title="Foo"
@description="Bar"
@type="input"
as |field|
>
<field.Control />
</form.Field>
@helpText
The help text is optional and will be shown under the field when set.
Example
<form.Field @name="foo" @title="Foo" @helpText="Baz" @type="input" as |field|>
<field.Control />
</form.Field>
@showTitle
By default, the title will be shown on top of the control. You can choose not to render it by setting this property to false.
Example
<form.Field
@name="foo"
@title="Foo"
@showTitle={{false}}
@type="input"
as |field|
>
<field.Control />
</form.Field>
@disabled
A field can be disabled to prevent any changes to it.
Example
<form.Field
@name="foo"
@title="Foo"
@disabled={{true}}
@type="input"
as |field|
>
<field.Control />
</form.Field>
@tooltip
Allows to display a tooltip next to the field's title. Won't display if title is not shown.
You can pass a string or a <DTooltip /> component.
Example
<Form as |form|>
<form.Field
@name="foo"
@title="Foo"
@type="input"
@tooltip="a nice input"
as |field|
>
<field.Control />
</form.Field>
</Form>
<Form as |form|>
<form.Field
@name="foo"
@title="Foo"
@type="input"
@tooltip={{component DTooltip content="a nice input"}}
as |field|
>
<field.Control />
</form.Field>
</Form>
@validation
Read the dedicated validation section.
@validate
Read the dedicated custom validation section.
@onSet
By default, when changing the value of a field, this value will be set on the form's internal data object. However, you can choose to have full control over this process for a field.
Example
@action
handleFooChange(value, { set, name, parentName, index }) {
set("foo", value + "-bar");
}
<form.Field
@name="foo"
@title="Foo"
@type="input"
@onSet={{this.handleFooChange}}
as |field|
>
<field.Control />
</form.Field>
:information_source: You can use
@onSetto also mutate the initial data object if you need more reactivity for a specific case.
The second argument passed to @onSet contains:
set(name, value): update form draft statename: the field's full nameparentName: the parent path for nested fieldsindex: the current collection index when inside a collection
Example
@action
handleFooChange(value, { set }) {
set("foo", value + "-bar");
this.model.foo = value + "-bar";
}
<Form @data={{this.model}} as |form|>
<form.Field
@name="foo"
@title="Foo"
@type="input"
@onSet={{this.handleFooChange}}
as |field|
>
<field.Control />
</form.Field>
</Form>
Yielded Parameters
The field yields a single field object. The control component determined by @type is available as field.Control.
<form.Field @name="foo" @title="Foo" @type="input" as |field|>
<field.Control />
</form.Field>
field.Control
field.Control is the control component determined by @type. You can pass control-specific attributes directly to it (e.g., @height, @lang, placeholder).
:information_source:
field.Controlis the supported API. Older yielded names such asfield.Inputare deprecated.
field
The yielded field object provides access to the field's data and helpers:
| Name | Description |
|---|---|
Control |
Contextual component for the control set by @type |
id |
ID to be used on the control for accessibility |
errorId |
ID of the field error container |
name |
Full field name, including nested path prefixes |
value |
Current value from draft data |
set |
Function to set the field's value |
Controls
Controls, as we use the term here, refer to the UI widgets that allow a user to enter data. In its most basic form, this would be an input. The control type is specified via @type on the field.
:information_source: You can pass down HTML attributes to the underlying control.
Example
<Form as |form|>
<form.Field
@name="query"
@title="Query"
@type="input"
@description="You should make sure the query doesn't include bots."
as |field|
>
<field.Control placeholder="Foo" />
</form.Field>
</Form>
@format
Controls accept a @format property which can be: small, medium, large, or full.
Form Kit sets defaults for each control, but you can override them using @format:
- small:
100px - medium:
220px - large:
400px - full:
100%
Additionally, the following CSS variables are provided to customize these defaults:
- small:
--form-kit-small-input - medium:
--form-kit-medium-input - large:
--form-kit-large-input
@labelFormat
Overrides the width of the title and description (the label area) independently of @format. Useful when long descriptions need more room than the input itself — e.g. @format="small" with @labelFormat="full" keeps a small input but lets the description span the form. Only emit it when the label area should differ from the field; otherwise both inherit from @format. See @format for the available values.
Checkbox
Renders an <input type="checkbox"> element.
:information_source: When to use a single checkbox There are only 2 options: yes/no. It feels like agreeing to something. Checking the box doesn't save; there is a submit button further down.
Example
<Form as |form|>
<form.Field @name="approved" @title="Approved" @type="checkbox" as |field|>
<field.Control />
</form.Field>
</Form>
Calendar
Renders a datepicker and a time input. On mobile the datepicker will be replaced by a date input.
@includeTime
Displays the time input or not. Defaults to true.
Example
<Form as |form|>
<form.Field @name="start" @title="Start" @type="calendar" as |field|>
<field.Control @includeTime={{false}} />
</form.Field>
</Form>
@expandedDatePickerOnDesktop
Displays date picker expanded on desktop. Defaults to true.
Example
<Form as |form|>
<form.Field @name="start" @title="Start" @type="calendar" as |field|>
<field.Control @expandedDatePickerOnDesktop={{false}} />
</form.Field>
</Form>
Code
Renders an <AceEditor /> component.
@height
Sets the height of the editor in pixels.
@lang
Sets the editor mode.
Example
<Form as |form|>
<form.Field @name="query" @title="Query" @type="code" as |field|>
<field.Control @lang="sql" @height={{400}} />
</form.Field>
</Form>
Color
Renders a color input with optional preset swatches.
Common control arguments:
@colors: array of preset colors@usedColors: array of already-used colors@collapseSwatches: collapse swatches into a menu@collapseSwatchesLabel: label for the collapsed swatches button@allowNamedColors: allow named colors instead of only hex values@fallbackValue: value to restore on blur when left blank
Example
<Form as |form|>
<form.Field @name="color" @title="Color" @type="color" as |field|>
<field.Control
@colors={{array "0088CC" "FFCC00"}}
@usedColors={{array "FFCC00"}}
/>
</form.Field>
</Form>
Composer
Renders a <DEditor /> component.
@height
Sets the height of the composer.
Example
<Form as |form|>
<form.Field @name="message" @title="Message" @type="composer" as |field|>
<field.Control @height={{400}} />
</form.Field>
</Form>
@preview
Controls the display the composer preview. Defaults to false.
Example
<Form as |form|>
<form.Field @name="message" @title="Message" @type="composer" as |field|>
<field.Control @preview={{true}} />
</form.Field>
</Form>
Custom
Renders a wrapper for custom content. This is the right choice when you want to provide your own control markup but still use FormKit field metadata and state.
Example
<Form as |form|>
<form.Field @name="slug" @title="Slug" @type="custom" as |field|>
<field.Control>
<MyCustomControl
id={{field.id}}
@value={{field.value}}
@onChange={{field.set}}
/>
</field.Control>
</form.Field>
</Form>
Emoji
Renders an <EmojiPicker /> component.
@context
Passes the picker context through to <EmojiPicker />.
Example
<Form as |form|>
<form.Field @name="emoji" @title="Emoji" @type="emoji" as |field|>
<field.Control @context="chat" />
</form.Field>
</Form>
Icon
Renders an <IconPicker /> component.
Example
<Form as |form|>
<form.Field @name="icon" @title="Icon" @type="icon" as |field|>
<field.Control />
</form.Field>
</Form>
Image
Renders an <UppyImageUploader /> component.
Common control arguments:
@type: uploader context passed to<UppyImageUploader />@placeholderUrl: optional placeholder image
Upload Handling
By default, the component will set an upload object. It's common to only want the URL and the ID of the upload. To achieve this, you can use the @onSet property on the field:
@action
handleUpload(upload, { set }) {
set("upload_id", upload.id);
set("upload_url", getURL(upload.url));
}
<Form as |form|>
<form.Field
@name="upload"
@title="Upload"
@type="image"
@onSet={{this.handleUpload}}
as |field|
>
<field.Control />
</form.Field>
</Form>
Example
<Form as |form|>
<form.Field @name="upload" @title="Upload" @type="image" as |field|>
<field.Control />
</form.Field>
</Form>
Input
Renders an <input> element.
@type
The input variant is specified as part of the field's @type using the input- prefix. For example, @type="input", @type="input-number", or @type="input-email". @type="input" defaults to text.
Allowed Types
input(defaults to text)input-colorinput-dateinput-datetime-localinput-emailinput-hiddeninput-monthinput-numberinput-passwordinput-rangeinput-searchinput-telinput-textinput-timeinput-urlinput-week
Special Cases
checkboxandradiohave dedicated controls- file uploads should use
@type="image"
Examples
<Form as |form|>
<form.Field @name="email" @title="Email" @type="input" as |field|>
<field.Control />
</form.Field>
<form.Field @name="age" @title="Age" @type="input-number" as |field|>
<field.Control />
</form.Field>
</Form>
@before
Renders text before the input
Examples
<Form as |form|>
<form.Field @name="email" @title="Email" @type="input" as |field|>
<field.Control @before="mailto:" />
</form.Field>
</Form>
@after
Renders text after the input
Examples
<Form as |form|>
<form.Field @name="email" @title="Email" @type="input" as |field|>
<field.Control @after=".com" />
</form.Field>
</Form>
Menu
@selection
The text to show on the trigger.
yielded parameters
Item
Renders a selectable row. Accepts @value, @icon and @action props.
- @value: allows to assign a value to a row.
- @icon: shows an icon at the start of the row.
- @action: override the default action which would set the value of the field with the value of this row.
The content will be yielded.
Divider
Renders a separator.
Container
Renders a div which will have for content the yielded content.
Examples
<Form as |form|>
<form.Field @name="email" @title="Email" @type="menu" as |field|>
<field.Control as |menu|>
<menu.Item @value={{1}} @icon="pencil-alt">Edit</menu.Item>
<menu.Divider />
<menu.Container class="foo">
Bar
</menu.Container>
<menu.Item @action={{this.doSomething}}>Something</menu.Item>
</field.Control>
</form.Field>
</Form>
Password
Renders a password input with a visibility toggle.
:information_source: This is different from
@type="input-password". The dedicatedpasswordcontrol adds the show/hide button.
Example
<Form as |form|>
<form.Field @name="secret" @title="Secret" @type="password" as |field|>
<field.Control />
</form.Field>
</Form>
Question
Renders two inputs of type radio where the first one is a positive answer, the second one a negative answer.
@yesLabel
Allows to customize the positive label.
@noLabel
Allows to customize the negative label.
Examples
<Form as |form|>
<form.Field @name="email" @title="Email" @type="question" as |field|>
<field.Control @yesLabel="Correct" @noLabel="Wrong" />
</form.Field>
</Form>
RadioGroup
Renders a list of radio buttons sharing a common name.
Example
<Form as |form|>
<form.Field @name="foo" @title="Foo" @type="radio-group" as |field|>
<field.Control as |radioGroup|>
<radioGroup.Radio @value="one">One</radioGroup.Radio>
<radioGroup.Radio @value="two">Two</radioGroup.Radio>
<radioGroup.Radio @value="three">Three</radioGroup.Radio>
</field.Control>
</form.Field>
</Form>
Radio yielded parameters
Title
Allows to render a title.
Examples
<Form as |form|>
<form.Field @name="foo" @title="Foo" @type="radio-group" as |field|>
<field.Control as |RadioGroup|>
<RadioGroup.Radio @value="one" as |radio|>
<radio.Title>One title</radio.Title>
</RadioGroup.Radio>
</field.Control>
</form.Field>
</Form>
Description
Allows to render a description.
Examples
<Form as |form|>
<form.Field @name="foo" @title="Foo" @type="radio-group" as |field|>
<field.Control as |RadioGroup|>
<RadioGroup.Radio @value="one" as |radio|>
<radio.Description>One description</radio.Description>
</RadioGroup.Radio>
</field.Control>
</form.Field>
</Form>
Select
Renders a <DNativeSelect /> component.
@includeNone
By default, Select includes a "none" option when the field is blank or when the field is not marked required. Override this with @includeNone.
Example
<Form as |form|>
<form.Field @name="fruits" @title="Fruits" @type="select" as |field|>
<field.Control as |select|>
<select.Option @value="1">Mango</select.Option>
<select.Option @value="2">Apple</select.Option>
<select.Option @value="3">Coconut</select.Option>
</field.Control>
</form.Field>
</Form>
Tag Chooser
Renders a <TagChooser /> component.
Common control arguments:
@allowCreate@categoryId@showAllTags@excludeSynonyms@excludeTagsWithSynonyms@unlimited@placeholder
Example
<Form as |form|>
<form.Field @name="tags" @title="Tags" @type="tag-chooser" as |field|>
<field.Control @categoryId={{1}} @allowCreate={{true}} />
</form.Field>
</Form>
Textarea
Renders a <textarea> element.
@height
Sets the height of the textarea.
Example
<Form as |form|>
<form.Field
@name="description"
@title="Description"
@type="textarea"
as |field|
>
<field.Control @height={{120}} />
</form.Field>
</Form>
@autoResize
Grows the textarea with its content (renders via <ExpandingTextArea>). Bounds can be set via the --expanding-text-area-min-height (default auto) and --expanding-text-area-max-height (default none) custom properties on any ancestor.
Example
<form.Field @name="description" @type="textarea" as |field|>
<field.Control @autoResize={{true}} />
</form.Field>
[data-name="description"] {
--expanding-text-area-min-height: 10rem;
--expanding-text-area-max-height: 50rem;
}
Toggle
Renders a <DToggleSwitch /> component.
:information_source: There are only 2 states: enabled/disabled. It should feel like turning something on. Toggling takes effect immediately, there is no submit button.
Example
<Form as |form|>
<form.Field @name="allowed" @title="Allowed" @type="toggle" as |field|>
<field.Control />
</form.Field>
</Form>
Layout
Form Kit aims to provide good defaults, allowing you to mainly use fields and controls. However, if you need more control, we provide several helpers: Row and Col, Section, Fieldset, Container and Actions.
You can also use utilities like Submit, Reset, Alert, CheckboxGroup, InputGroup, and ConditionalContent.
Actions
Actions is a custom Container designed to wrap your buttons in the footer of your form.
Example
<Form as |form|>
<form.Actions>
<form.Submit />
</form.Actions>
</Form>
Alert
Displays an alert in the form.
@icon
An optional icon to use in the alert.
Example
<form.Alert @icon="info-circle">
Foo
</form.Alert>
@type
Specifies the type of alert. Allowed types: success, error, warning, or info.
Example
<form.Alert @type="warning">
Foo
</form.Alert>
Checkbox Group
CheckboxGroup allows grouping checkboxes together.
Example
<form.CheckboxGroup @title="Preferences" as |group|>
<group.Field @name="editable" @title="Editable" @type="checkbox" as |field|>
<field.Control />
</group.Field>
<group.Field
@name="searchable"
@title="Searchable"
@type="checkbox"
as |field|
>
<field.Control />
</group.Field>
</form.CheckboxGroup>
ConditionalContent
ConditionalContent helps you switch between mutually exclusive blocks of content using a small radio control.
Example
<Form as |form|>
<form.ConditionalContent @activeName="basic" as |conditional|>
<conditional.Conditions as |Condition|>
<Condition @name="basic">Basic</Condition>
<Condition @name="advanced">Advanced</Condition>
</conditional.Conditions>
<conditional.Contents as |Content|>
<Content @name="basic">
<form.Alert>Basic settings</form.Alert>
</Content>
<Content @name="advanced">
<form.Alert @type="warning">Advanced settings</form.Alert>
</Content>
</conditional.Contents>
</form.ConditionalContent>
</Form>
Container
Container allows you to render a block similar to a field without tying it to specific data. It is useful for custom controls.
Common arguments:
@title@subtitle@format@direction
Example
<Form as |form|>
<form.Container @title="Important" @subtitle="This is important">
<!-- Container content here -->
</form.Container>
</Form>
Fieldset
Wraps content in a fieldset.
Example
<form.Fieldset @name="a-fieldset" class="my-fieldset">
Foo
</form.Fieldset>
@title
Displays a title for the fieldset, will use the legend element.
Example
<form.Fieldset @title="A title">
Foo
</form.Fieldset>
@description
Displays a description for the fieldset.
Example
<form.Fieldset @description="A description">
Foo
</form.Fieldset>
@name
Sets the name of the fieldset. This is necessary if you want to use the fieldset test helpers.
Example
<form.Fieldset @name="a-name">
Foo
</form.Fieldset>
Input Group
Input group allows you to group multiple inputs together on one line.
Example
<Form as |form|>
<form.InputGroup as |inputGroup|>
<inputGroup.Field @title="Foo" @name="foo" @type="input" as |field|>
<field.Control />
</inputGroup.Field>
<inputGroup.Field @title="Bar" @name="bar" @type="input" as |field|>
<field.Control />
</inputGroup.Field>
</form.InputGroup>
</Form>
Reset
The Reset component renders a <DButton /> which will reset the form when clicked. It accepts all the same parameters as a standard <DButton />. The label and default action are set by default.
Example
<form.Reset />
To customize the Reset button further, you can pass additional parameters as needed:
Example with Additional Parameters
<form.Reset @translatedLabel="Remove changes" />
Row and Col
Row and Col enable you to utilize a simple grid system (12 columns) within your form.
Example
<Form as |form|>
<form.Row as |row|>
<row.Col @size={{4}}>
<form.Field @name="foo" @title="Foo" @type="input" as |field|>
<field.Control />
</form.Field>
</row.Col>
<row.Col @size={{8}}>
<form.Field @name="bar" @title="Bar" @type="input" as |field|>
<field.Control />
</form.Field>
</row.Col>
</form.Row>
</Form>
Section
Section provides a simple way to create a section with or without a title.
@subtitle
Displays secondary text in the section header.
Example
<Form as |form|>
<form.Section @title="Settings">
<!-- Section content here -->
</form.Section>
</Form>
Submit
The Submit component renders a <DButton /> which will submit the form when clicked. It accepts all the same parameters as a standard <DButton />. The label, default action, and primary style are set by default.
Example
<form.Submit />
To customize the Submit button further, you can pass additional parameters as needed:
Example with Additional Parameters
<form.Submit @translatedLabel="Send" />
Object
The object component lets you work with a nested object in your form.
Example
<Form @data={{hash foo=(hash bar=1 baz=2)}} as |form|>
<form.Object @name="foo" as |object data|>
<object.Field @name="bar" @title="Bar" @type="input" as |field|>
<field.Control />
</object.Field>
<object.Field @name="baz" @title="Baz" @type="input" as |field|>
<field.Control />
</object.Field>
</form.Object>
</Form>
@name
An object must have a unique name. This name is used as a prefix for the underlying fields.
Like field names, object names should not contain . or -.
Example
<form.Object @name="foo" />
Nesting
An object can accept a nested Object or Collection.
Example
<Form @data={{hash foo=(hash bar=(hash baz=1 bol=2))}} as |form|>
<form.Object @name="foo" as |parentObject|>
<parentObject.Object @name="bar" as |childObject data|>
<childObject.Field @name="baz" @title="Baz" @type="input" as |field|>
<field.Control />
</childObject.Field>
</parentObject.Object>
</form.Object>
</Form>
<Form @data={{hash foo=(hash bar=(array 1 2))}} as |form|>
<form.Object @name="foo" as |parentObject|>
<parentObject.Collection @name="bar" as |collection index|>
<collection.Field @title="Baz" @type="input" as |field|>
<field.Control />
</collection.Field>
<form.Button
class={{concat "remove-" index}}
@action={{fn collection.remove index}}
>Remove</form.Button>
</parentObject.Collection>
</form.Object>
</Form>
Collection
The collection component lets you work with arrays in your form.
It yields three values: the collection API, the current index, and the current item.
Example
<Form @data={{hash foo=(array (hash bar=1) (hash bar=2))}} as |form|>
<form.Collection @name="foo" as |collection index item|>
<collection.Field @name="bar" @title="Bar" @type="input" as |field|>
<field.Control placeholder={{concat "item-" index}} />
</collection.Field>
</form.Collection>
</Form>
@name
A collection must have a unique name. This name is used as a prefix for the underlying fields.
For example, if collection has the name "foo", the 2nd field of the collection with the name "bar", will actually have "foo.1.bar" as name.
Like field names, collection names should not contain . or -.
Example
<form.Collection @name="foo" />
@tagName
A collection renders as a <div class="form-kit__collection"> by default. You can alter this behavior with @tagName.
Example
<form.Collection @name="foo" @tagName="tr" />
Primitive array
If the shape of your data is an array of primitives, eg: [1, 2, 3], form-kit is able to handle it. You just have to omit the name on the field in this case, as the name will be auto generated for you with the index.
Example
<Form @data={{hash foo=(array 1 2)}} as |form|>
<form.Collection @name="foo" as |collection|>
<collection.Field @title="Baz" @type="input" as |field|>
<field.Control />
</collection.Field>
</form.Collection>
</Form>
Nesting
A collection can accept a nested Object or Collection.
Example
<Form
@data={{hash foo=(array (hash bar=(hash baz=1)) (hash bar=(hash baz=2)))}}
as |form|
>
<form.Collection @name="foo" as |collection|>
<collection.Object @name="bar" as |object|>
<object.Field @name="baz" @title="Baz" @type="input" as |field|>
<field.Control />
</object.Field>
</collection.Object>
</form.Collection>
</Form>
<Form
@data={{hash
foo=(array (hash bar=(array (hash baz=1))) (hash bar=(array (hash baz=2))))
}}
as |form|
>
<form.Collection @name="foo" as |parent parentIndex|>
<parent.Collection @name="bar" as |child childIndex|>
<child.Field @name="baz" @title="Baz" @type="input" as |field|>
<field.Control />
</child.Field>
</parent.Collection>
</form.Collection>
</Form>
Add an item to the collection
The <Form /> component yielded object has a addItemToCollection function that you can call to add an item to a specific collection.
Example
<Form @data={{hash foo=(array (hash bar=1) (hash bar=2))}} as |form|>
<form.Button @action={{fn form.addItemToCollection "foo" (hash bar=3)}}>
Add
</form.Button>
<form.Collection @name="foo" as |collection index|>
<collection.Field @name="bar" @title="Bar" @type="input" as |field|>
<field.Control placeholder={{concat "item-" index}} />
</collection.Field>
</form.Collection>
</Form>
Remove an item from the collection
The <Collection /> component yielded object has a remove function that you can call to remove an item from this collection, it takes the index as parameter
Example
<Form @data={{hash foo=(array (hash bar=1) (hash bar=2))}} as |form|>
<form.Collection @name="foo" as |collection index|>
<collection.Field @name="bar" @title="Bar" @type="input" as |field|>
<field.Control />
<form.Button @action={{fn collection.remove index}}>
Remove
</form.Button>
</collection.Field>
</form.Collection>
</Form>
Validation
Field accepts a @validation property which allows you to describe the validation rules of the field.
List of Available Rules
Accepted
The value must be "yes", "on", true, 1, or "true". Useful for checkbox inputs — often where you need to validate if someone has accepted terms.
Example
<form.Field
@name="terms"
@title="Terms"
@type="checkbox"
@validation="accepted"
as |field|
>
<field.Control />
</form.Field>
Between
Checks that a numeric value is between a minimum and maximum value.
Example
<form.Field
@name="amount"
@title="Amount"
@type="input-number"
@validation="between:1,10"
as |field|
>
<field.Control />
</form.Field>
dateAfterOrEqual
Checks if a calendar value is after or equal to the specified date. Format must be YYYY-MM-DD.
Example
<form.Field
@name="start"
@title="Start"
@type="calendar"
@validation="dateAfterOrEqual:2022-02-01"
as |field|
>
<field.Control />
</form.Field>
dateBeforeOrEqual
Checks if a calendar value is before or equal to the specified date. Format must be YYYY-MM-DD.
Example
<form.Field
@name="start"
@title="Start"
@type="calendar"
@validation="dateBeforeOrEqual:2022-02-01"
as |field|
>
<field.Control />
</form.Field>
EndsWith
Checks that a string ends with a given suffix.
Example
<form.Field
@name="domain"
@title="Domain"
@type="input"
@validation="endsWith:.com"
as |field|
>
<field.Control />
</form.Field>
Integer
Checks if the value is an integer.
Example
<form.Field
@name="age"
@title="Age"
@type="input-number"
@validation="integer"
as |field|
>
<field.Control />
</form.Field>
Length
Checks that the input's value is over a given length, or between two length values.
Example
<form.Field
@name="username"
@title="Username"
@type="input"
@validation="length:5,16"
as |field|
>
<field.Control />
</form.Field>
Number
Checks if the input is a valid number as evaluated by isNaN().
:information_source: When applicable, prefer to use the number input:
@type="input-number".
Example
<form.Field
@name="amount"
@title="Amount"
@type="input"
@validation="number"
as |field|
>
<field.Control />
</form.Field>
Required
Checks if the input is empty.
required:trim trims leading and trailing whitespace before checking the value.
Example
<form.Field
@name="username"
@title="Username"
@type="input"
@validation="required:trim"
as |field|
>
<field.Control />
</form.Field>
StartsWith
Checks that a string starts with a given prefix.
Example
<form.Field
@name="handle"
@title="Handle"
@type="input"
@validation="startsWith:@"
as |field|
>
<field.Control />
</form.Field>
URL
Checks if the input value appears to be a properly formatted URL including the protocol. This does not check if the URL actually resolves.
Example
<form.Field
@name="endpoint"
@title="Endpoint"
@type="input-url"
@validation="url"
as |field|
>
<field.Control />
</form.Field>
Combining Rules
Rules can be combined using the pipe operator: |.
Example
<form.Field
@name="username"
@title="Username"
@type="input"
@validation="required|length:5,16"
as |field|
>
<field.Control />
</form.Field>
Custom Validation
Custom validators can call preventSubmit to stop the current submission without marking the form invalid. It only affects the current validation pass and must be called again to prevent a later submission attempt. Calling it during a validation pass that was not triggered by submission has no effect.
Field
Field accepts a @validate property which allows you to define a callback function to validate a single field.
Parameters
name(string): The name of the form field being validated.value(unknown): The current field value.context(Object)context.data(Object): Current form draft data.context.type(string): Field control type.context.addError(Function): Adds an error if validation fails.context.preventSubmit(Function): Prevents the current submission without adding an error.
Example
@action
validateUsername(name, value, { data, addError }) {
if (value === data.slug) {
addError(name, {
title: "Username",
message: "Username and slug must differ.",
});
}
}
<form.Field
@name="username"
@title="Username"
@type="input"
@validate={{this.validateUsername}}
as |field|
>
<field.Control />
</form.Field>
Form
Form accepts a @validate property which allows you to define a callback function to validate the full form state once per validation pass.
Parameters
data(Object): The data object containing additional information for validation.handlers(Object): An object containing handler functions.handlers.addError(Function): A function to add an error if validation fails.handlers.removeError(Function): A function to clear an existing error.handlers.preventSubmit(Function): Prevents the current submission without adding an error.
Example
@action
validateForm(data, { addError, removeError }) {
if (data.start && data.end && data.end < data.start) {
addError("end", {
title: "End",
message: "End must be after start.",
});
} else {
removeError("end");
}
}
<Form @validate={{this.validateForm}} as |form|>
<form.Field @name="start" @title="Start" @type="input-date" as |field|>
<field.Control />
</form.Field>
<form.Field @name="end" @title="End" @type="input-date" as |field|>
<field.Control />
</form.Field>
<form.Submit />
</Form>
:information_source: Unknown validation rule names raise at runtime. Keep the rule list above in sync with the implementation when extending FormKit.
Helpers
Helpers are yielded by some blocks, like Form, or provided as parameters to callbacks. They allow you to interact with the form state in a simple and clear way.
set
set allows you to assign a value to a specific field in the form's data.
Parameters
name(string): The name of the field to which the value is to be set.value(number): The value to be set.
Example
set("foo", 1);
Using the set helper yielded by the form:
<Form as |form|>
<DButton @action={{fn form.set "foo" 1}} @translatedLabel="Set foo" />
</Form>
setProperties
setProperties allows you to assign an object to the form's data.
Parameters
data(object): A POJO where each key is going to be set on the form using its value.
Example
setProperties({ foo: 1, bar: 2 });
Using the setProperties helper yielded by the form:
<Form as |form|>
<DButton
@action={{fn form.setProperties (hash foo=1 bar=2)}}
@translatedLabel="Set foo and bar"
/>
</Form>
addError
addError allows you to add an error message to a specific field in the form.
Parameters
name(string): The name of the field that is invalid.error(object): The error's datatitle(string): The title of the error, usually the translated name of the fieldmessage(string): The error message
Example
addError("foo", { title: "Foo", message: "This should be another thing." });
Customize
Plugin Outlets
FormKit works seamlessly with <PluginOutlet />. You can use plugin outlets inside your form to extend its functionality:
<Form as |form|>
<PluginOutlet @name="above-foo-form" @outletArgs={{hash form=form}} />
</Form>
Then, in your connector, you can use the outlet arguments to add custom fields:
<template>
<@outletArgs.form.Field @name="bar" @title="Bar" @type="input" as |field|>
<field.Control />
</@outletArgs.form.Field>
</template>
Styling
All FormKit components propagate attributes, allowing you to set classes and data attributes, for example:
<Form class="my-form" as |form|>
<form.Field
@name="foo"
@title="Foo"
@type="input"
class="my-field"
as |field|
>
<field.Control class="my-control" />
</form.Field>
</Form>
Custom Control
Creating a custom control is straightforward with the properties yielded by form and field:
<Form as |form|>
<form.Field
@name="foo"
@title="Foo"
@type="custom"
class="my-field"
as |field|
>
<field.Control>
<MyCustomControl id={{field.id}} @onChange={{field.set}} />
</field.Control>
</form.Field>
</Form>
Common Values on form
| Name | Description |
|---|---|
set |
Set any field by name, e.g. set("bar", 1) |
setProperties |
Set multiple field values at once |
addItemToCollection |
Append an item to a collection by path |
Common Values on field
| Name | Description |
|---|---|
id |
Input ID |
errorId |
Error container ID |
name |
Full nested field path |
value |
Current field value from draft state |
set |
Set the current field value |
Javascript assertions
Form
The form element assertions are available at assert.form(...).*. By default it will select the first "form" element.
Parameters
target(string | HTMLElement): The form element or selector.
hasErrors()
Asserts that the form error summary contains the given field errors.
Parameters
fields(Object): A map of field names to error messages, e.g.{ username: "Required" }.message(string) [optional]: The description of the test.
Example
assert.form().hasErrors({ username: "Required" }, "the form shows errors");
hasNoErrors()
Asserts that the form error summary is not present.
Parameters
message(string) [optional]: The description of the test.
Example
assert.form().hasNoErrors("the form is valid");
Field
The field element assertions are available at assert.form(...).field(...).*.
Parameters
name(string): The name of the field.
Example
assert.form().field("foo");
hasValue()
Asserts that the value of the field matches the expected text.
Parameters
expected(anything): The expected value.message(string) [optional]: The description of the test.
Example
assert.form().field("foo").hasValue("bar", "user has set the value");
hasNoValue()
Asserts that the field is blank.
Parameters
message(string) [optional]: The description of the test.
Example
assert.form().field("foo").hasNoValue("the field starts blank");
isDisabled()
Asserts that the field is disabled.
Parameters
message(string) [optional]: The description of the test.
Example
assert.form().field("foo").isDisabled("the field is disabled");
isEnabled()
Asserts that the field is enabled.
Parameters
message(string) [optional]: The description of the test.
Example
assert.form().field("foo").isEnabled("the field is enabled");
hasTitle()
Asserts that the field title matches the expected text.
Parameters
expected(anything): The expected value.message(string) [optional]: The description of the test.
Example
assert.form().field("foo").hasTitle("Foo", "it shows the field title");
hasDescription()
Asserts that the field description matches the expected text.
Parameters
expected(anything): The expected value.message(string) [optional]: The description of the test.
Example
assert
.form()
.field("foo")
.hasDescription("Helpful copy", "it shows the description");
hasError()
Asserts that the field has a specific error.
Parameters
error(string): The error message on the field.message(string) [optional]: The description of the test.
Example
assert.form().field("foo").hasError("Required", "it is required");
hasNoErrors()
Asserts that the field has no error.
Parameters
message(string) [optional]: The description of the test.
Example
assert.form().field("foo").hasNoErrors("it is valid");
exists()
Asserts that the field is present.
Parameters
message(string) [optional]: The description of the test.
Example
assert.form().field("foo").exists("it has the foo field");
doesNotExist()
Asserts that the field is not present.
Parameters
message(string) [optional]: The description of the test.
Example
assert.form().field("foo").doesNotExist("it has no foo field");
hasCharCounter()
Asserts that the field has a char counter.
Parameters
current(integer): The current length of the field.max(integer): The max length of the field.message(string) [optional]: The description of the test.
Example
assert.form().field("foo").hasCharCounter(2, 5, "it has updated the counter");
Fieldset
The field element assertions are available at assert.form(...).fieldset(...).*.
Parameters
name(string): The name of the fieldset.
Example
assert.form().fieldset("foo");
hasTitle()
Asserts that the title of the fieldset matches the expected value.
Parameters
expected(anything): The expected value.message(string) [optional]: The description of the test.
Example
assert.form().fieldset("foo").hasTitle("bar", "it has the correct title");
hasDescription()
Asserts that the description of the fieldset matches the expected value.
Parameters
expected(anything): The expected value.message(string) [optional]: The description of the test.
Example
assert
.form()
.fieldset("foo")
.hasDescription("bar", "it has the correct description");
includesText()
Asserts that the fieldset has yielded the expected value.
Parameters
expected(anything): The expected value.message(string) [optional]: The description of the test.
Example
assert.form().fieldset("foo").includesText("bar", "it has the correct text");
exists()
Asserts that the fieldset is present.
Parameters
message(string) [optional]: The description of the test.
Example
assert.form().fieldset("foo").exists("the fieldset is rendered");
doesNotExist()
Asserts that the fieldset is not present.
Parameters
message(string) [optional]: The description of the test.
Example
assert.form().fieldset("foo").doesNotExist("the fieldset is hidden");
Javascript helpers
Form
The FormKit helper allows you to manipulate a form and its fields through a clear and expressive API.
Example
import formKit from "discourse/tests/helpers/form-kit-helper";
test("fill in input", async function (assert) {
await render(
<template>
<Form class="my-form" as |form data|>
<form.Field @name="foo" @title="Foo" @type="input" as |field|>
<field.Control />
</form.Field>
</Form>
</template>
);
const myForm = formKit(".my-form");
});
submit()
Submits the associated form.
Example
formKit().submit();
reset()
Resets the associated form.
Example
formKit().reset();
field()
Returns a field helper for a named field.
Parameters
name(string): The name of the field.
Example
const field = formKit().field("foo");
hasField()
Checks whether a field with the given name exists in the form.
Parameters
name(string): The name of the field.
Example
formKit().hasField("foo");
Field
Parameters
name(string): The name of the field.
value()
Returns the current UI value for supported controls.
Example
formKit().field("foo").value();
options()
Returns the available option values for a @type="select" field.
Example
formKit().field("foo").options();
fillIn()
Can be used on input-like controls such as @type="input", @type="input-text", @type="input-number", @type="password", @type="color", @type="code", @type="textarea", and @type="composer".
Parameters
value(string | integer | undefined): The value to set on the input.
Example
await formKit().field("foo").fillIn("bar");
toggle()
Can be used on @type="checkbox", @type="toggle", or @type="password" fields.
Will toggle the state of the control. In the case of the password control it will actually toggle the visibility of the field.
Example
await formKit().field("foo").toggle();
accept()
Can be used on @type="question" fields.
Example
await formKit().field("foo").accept();
refuse()
Can be used on @type="question" fields.
Example
await formKit().field("foo").refuse();
select()
Can be used on @type="select", @type="menu", @type="icon", @type="radio-group", and @type="color" fields.
Will select the given value.
Parameters
value(string | integer | undefined): The value to select.
Example
await formKit().field("foo").select("bar");
setDay()
Can be used on @type="calendar" fields.
Parameters
day(integer): The day of the month to select.
Example
await formKit().field("start").setDay(15);
setTime()
Can be used on @type="calendar" fields.
Parameters
time(string): The time to set, e.g."14:30".
Example
await formKit().field("start").setTime("14:30");
isDisabled()
Returns whether the field is disabled.
Example
formKit().field("foo").isDisabled();
hasPrefix()
Can be used on @type="color" fields. Returns whether the color input renders its prefix.
Example
formKit().field("color").hasPrefix();
swatches()
Can be used on @type="color" fields. Returns the rendered swatches as { color, isUsed, isDisabled }.
Example
formKit().field("color").swatches();
triggerEvent()
Triggers a DOM event on the field's input element.
Parameters
eventName(string): The event to trigger.options(Object) [optional]: Extra event options.
Example
await formKit().field("foo").triggerEvent("blur");
System specs page object
Form
The FormKit page object component is available to help you write system specs for your forms.
Parameters
target(string): The selector of the form.
Example
form = PageObjects::Components::FormKit.new(".my-form")
submit
Submits the form.
Example
form.submit
reset
Resets the form.
Example
form.reset
has_an_alert?
Checks whether the form renders an alert with the given message.
Example
form.has_an_alert?("message")
expect(form).to have_an_alert("message")
has_field_with_name?
Checks whether a field with the given data-name exists.
Example
form.has_field_with_name?("foo")
has_no_field_with_name?
Checks whether a field with the given data-name is absent.
Example
form.has_no_field_with_name?("foo")
container
Returns a container helper for the named container.
Example
container = form.container("advanced-settings")
container.has_content?("More options")
choose_conditional
Chooses a ConditionalContent branch by radio value.
Example
form.choose_conditional("advanced")
Field
The field helper allows you to interact with a specific field of a form.
Parameters
name(string): The name of the field.
Example
field = form.field("foo")
value
Returns the value of the field.
Example
field.value
expect(field).to have_value("bar")
has_value?
Checks that the field value matches the expected value.
Example
field.has_value?("bar")
checked?
Returns if the control of a checkbox is checked or not.
Example
field.checked?
expect(field).to be_checked
unchecked?
Returns if the control of a checkbox is unchecked or not.
Example
field.unchecked?
expect(field).to be_unchecked
disabled?
Returns if the field is disabled or not.
Example
field.disabled?
expect(field).to be_disabled
enabled?
Returns if the field is enabled or not.
Example
field.enabled?
expect(field).to be_enabled
toggle
Allows toggling a field. Available for @type="checkbox", @type="password", and @type="toggle".
Example
field.toggle
fill_in
Allows filling a field with a given value. Available for @type="input", @type="input-*" variants, @type="password", @type="color", @type="textarea", @type="code", and @type="composer".
Example
field.fill_in("bar")
select
Allows selecting a specified value in a field. Available for @type="select", @type="icon", @type="menu", @type="radio-group", @type="question", tag choosers, and custom multi-select controls.
Example
field.select("bar")
accept
Allows accepting a field. Only available for: @type="question".
Example
field.accept
refuse
Allows refusing a field. Only available for: @type="question".
Example
field.refuse
upload_image
Takes an image path on the filesystem and uploads it for the field. Only available for the @type="image" control.
Example
field.upload_image(image_file_path)
has_errors?
Checks that the field renders the given error messages.
Example
field.has_errors?("Required")
has_no_errors?
Checks that the field has no errors.
Example
field.has_no_errors?
has_selected_names?
Checks the selected names for a @type="tag-chooser" field.
Example
field.has_selected_names?("support", "meta")
AppEvents Triggers Reference
AppEvents
The AppEvent system in Discourse provides a pub/sub mechanism for handling UI updates and component interactions - and these events are triggered via calls of AppEvent.trigger.
This topic consolidates a list of all such event triggers and their arguments, along with line-of-code references to the Discourse source code.
How to figure out what happens on an event trigger
AppEvent is based on the Ember's Evented class, and similarly, events are handled by the on method.
Once the specific AppEvent trigger is identified, you may search in the source code for the corresponding .on method with the event name as the first argument.
This method should have an event handler function passed in as the last argument for executing any necessary logic upon trigger of the event.
Taking the composer:open event, we can search for appEvents.on("composer:open". This could lead us to 1 or more places in the code where the event is handled. Each of these would execute a callback function whenever the event is triggered, for example:
this.appEvents.on("composer:opened", this, this._findMessages);
You would then refer to the definition of the callback function this._findMessages to understand what happens when the composer:opened event is triggered. This callback function can take in arguments passed in from the trigger of the event to be processed within the scope of the function.
List of AppEvent Triggers
ace
ace:resize :link:
No arguments passed to this event.
Detailed List
/frontend/discourse/admin/components/admin-theme-editor.gjs#118 :link:
No arguments passed to this event.
/plugins/discourse-data-explorer/assets/javascripts/discourse/controllers/admin-plugins/explorer/index.js#119 :link:
No arguments passed to this event.
/plugins/discourse-data-explorer/assets/javascripts/discourse/controllers/admin-plugins/explorer/queries/details.js#137 :link:
No arguments passed to this event.
bookmarks
bookmarks:changed :link:
| Position | Argument | Type | Always Present | Description |
|---|---|---|---|---|
| 1 | bookmarkFormData.saveData | property | True | - |
| 2 | this.bookmarkModel.attachedTo | called_function | True | - |
Detailed List
/frontend/discourse/app/components/bookmark-list.gjs#54 :link:
| Position | Argument | Type | Description |
|---|---|---|---|
| 1 | null | null | - |
| 2 | bookmark1.attachedTo | called_function | - |
/frontend/discourse/app/components/bookmark-list.gjs#85 :link:
| Position | Argument | Type | Description |
|---|---|---|---|
| 1 | savedData1 | variable | - |
| 2 | bookmark1.attachedTo | called_function | - |
/frontend/discourse/app/controllers/topic.js#1510 :link:
| Position | Argument | Type | Description |
|---|---|---|---|
| 1 | bookmarkFormData.saveData | property | - |
| 2 | bookmark.attachedTo | called_function | - |
/frontend/discourse/app/lib/topic-bookmark-manager.js#57 :link:
| Position | Argument | Type | Description |
|---|---|---|---|
| 1 | bookmarkFormData.saveData | property | - |
| 2 | this.bookmarkModel.attachedTo | called_function | - |
/frontend/discourse/app/models/post.ts#1248 :link:
| Position | Argument | Type | Description |
|---|---|---|---|
| 1 | data | variable | - |
| 2 | objectArg2 | object | - |
| - | objectArg2.target | string | - |
| - | objectArg2.targetId | property | - |
/frontend/discourse/app/models/post.ts#1268 :link:
| Position | Argument | Type | Description |
|---|---|---|---|
| 1 | null | null | - |
| 2 | objectArg2 | object | - |
| - | objectArg2.target | string | - |
| - | objectArg2.targetId | property | - |
/frontend/discourse/app/models/topic.ts#1289 :link:
| Position | Argument | Type | Description |
|---|---|---|---|
| 1 | null | null | - |
| 2 | bookmark.attachedTo | called_function | - |
/plugins/chat/assets/javascripts/discourse/lib/chat-message-interactor.js#374 :link:
| Position | Argument | Type | Description |
|---|---|---|---|
| 1 | bookmarkFormData.saveData | property | - |
| 2 | bookmark.attachedTo | called_function | - |
/plugins/discourse-data-explorer/assets/javascripts/discourse/controllers/group/reports/show.js#121 :link:
| Position | Argument | Type | Description |
|---|---|---|---|
| 1 | bookmarkFormData.saveData | property | - |
| 2 | bookmark.attachedTo | called_function | - |
calendar
calendar:create-invitee-status :link:
| Position | Argument | Type | Always Present | Description |
|---|---|---|---|---|
| 1 | objectArg1 | object | True | - |
| - | objectArg1.status | variable | True | - |
| - | objectArg1.postId | property | True | - |
calendar:invitee-left-event :link:
| Position | Argument | Type | Always Present | Description |
|---|---|---|---|---|
| 1 | objectArg1 | object | True | - |
| - | objectArg1.invitee | variable | True | - |
| - | objectArg1.postId | property | True | - |
calendar:update-invitee-status :link:
| Position | Argument | Type | Always Present | Description |
|---|---|---|---|---|
| 1 | objectArg1 | object | True | - |
| - | objectArg1.status | variable | True | - |
| - | objectArg1.postId | property | True | - |
card
card:close :link:
No arguments passed to this event.
card:hide :link:
No arguments passed to this event.
card:show :link:
| Position | Argument | Type | Always Present | Description |
|---|---|---|---|---|
| 1 | username | variable | True | - |
| 2 | target | variable | True | - |
| 3 | event | variable | True | - |
chat
chat:message_interaction :link:
| Position | Argument | Type | Always Present | Description |
|---|---|---|---|---|
| 1 | result1.interaction | property | True | - |
chat:modify-selection :link:
| Position | Argument | Type | Always Present | Description |
|---|---|---|---|---|
| 1 | event | variable | True | - |
| 2 | objectArg2 | object | True | - |
| - | objectArg2.type | variable | True | - |
| - | objectArg2.context | property | True | - |
chat:open-insert-link-modal :link:
| Position | Argument | Type | Always Present | Description |
|---|---|---|---|---|
| 1 | event | variable | True | - |
| 2 | objectArg2 | object | True | - |
| - | objectArg2.context | property | True | - |
chat:open-url :link:
| Position | Argument | Type | Always Present | Description |
|---|---|---|---|---|
| 1 | url | variable | True | - |
Detailed List
/plugins/chat/assets/javascripts/discourse/routes/chat.js#66 :link:
| Position | Argument | Type | Description |
|---|---|---|---|
| 1 | url | variable | - |
/plugins/chat/assets/javascripts/discourse/routes/chat.js#71 :link:
| Position | Argument | Type | Description |
|---|---|---|---|
| 1 | url | variable | - |
chat:refresh-channel-members :link:
No arguments passed to this event.
chat:rerender-header :link:
No arguments passed to this event.
chat:toggle-close :link:
| Position | Argument | Type | Always Present | Description |
|---|---|---|---|---|
| 1 | event | variable | False | - |
Detailed List
/plugins/chat/assets/javascripts/discourse/initializers/chat-keyboard-shortcuts.js#117 :link:
| Position | Argument | Type | Description |
|---|---|---|---|
| 1 | event | variable | - |
/plugins/chat/assets/javascripts/discourse/routes/chat.js#76 :link:
No arguments passed to this event.
chat:toggle-expand :link:
| Position | Argument | Type | Always Present | Description |
|---|---|---|---|---|
| 1 | this.chatStateManager.isDrawerExpanded | property | True | - |
Detailed List
/plugins/chat/assets/javascripts/discourse/components/chat-drawer.gjs#163 :link:
| Position | Argument | Type | Description |
|---|---|---|---|
| 1 | this.chatStateManager.isDrawerExpanded | property | - |
/plugins/chat/assets/javascripts/discourse/services/chat.js#437 :link:
| Position | Argument | Type | Description |
|---|---|---|---|
| 1 | this.chatStateManager.isDrawerExpanded | property | - |
composer
composer:add-files :link:
| Position | Argument | Type | Always Present | Description |
|---|---|---|---|---|
| 1 | undefined | undefined | True | - |
| 2 | objectArg2 | object | True | - |
| - | objectArg2.skipPlaceholder | boolean | True | - |
composer:cancel-upload :link:
No arguments passed to this event.
composer:cancelled :link:
No arguments passed to this event.
Detailed List
/frontend/discourse/app/services/composer.js#1654 :link:
No arguments passed to this event.
/frontend/discourse/app/services/composer.js#1669 :link:
No arguments passed to this event.
/frontend/discourse/app/services/composer.js#1686 :link:
No arguments passed to this event.
composer:created-post :link:
| Position | Argument | Type | Always Present | Description |
|---|---|---|---|---|
| 1 | objectArg1 | object | True | - |
| - | objectArg1.postId | property | True | - |
composer:div-resizing :link:
No arguments passed to this event.
composer:edited-post :link:
No arguments passed to this event.
composer:find-similar :link:
No arguments passed to this event.
composer:insert-block :link:
| Position | Argument | Type | Always Present | Description |
|---|---|---|---|---|
| 1 | template.content | property | True | - |
Detailed List
/frontend/discourse/app/controllers/topic.js#535 :link:
| Position | Argument | Type | Description |
|---|---|---|---|
| 1 | quotedText | variable | - |
/frontend/discourse/app/controllers/topic.js#777 :link:
| Position | Argument | Type | Description |
|---|---|---|---|
| 1 | quotedText.trim | called_function | - |
/frontend/discourse/app/lib/lightbox/quote-image.js#89 :link:
| Position | Argument | Type | Description |
|---|---|---|---|
| 1 | quote | variable | - |
/plugins/discourse-templates/assets/javascripts/discourse/services/d-templates.js#103 :link:
| Position | Argument | Type | Description |
|---|---|---|---|
| 1 | template.content | property | - |
composer:insert-text :link:
| Position | Argument | Type | Always Present | Description |
|---|---|---|---|---|
| 1 | document.activeElement.href | property | True | - |
| 2 | objectArg2 | object | True | - |
| - | objectArg2.ensureSpace | boolean | True | - |
composer:open :link:
| Position | Argument | Type | Always Present | Description |
|---|---|---|---|---|
| 1 | objectArg1 | object | True | - |
| - | objectArg1.model | property | True | - |
composer:opened :link:
No arguments passed to this event.
composer:preview-toggled :link:
| Position | Argument | Type | Always Present | Description |
|---|---|---|---|---|
| 1 | this.isPreviewVisible | property | True | - |
composer:replace-text :link:
| Position | Argument | Type | Always Present | Description |
|---|---|---|---|---|
| 1 | match | variable | True | - |
| 2 | replacement | variable | True | - |
composer:reply-reloaded :link:
| Position | Argument | Type | Always Present | Description |
|---|---|---|---|---|
| 1 | this | this | True | - |
Detailed List
/frontend/discourse/app/models/composer.ts#1822 :link:
| Position | Argument | Type | Description |
|---|---|---|---|
| 1 | this | this | - |
/frontend/discourse/app/models/composer.ts#1841 :link:
| Position | Argument | Type | Description |
|---|---|---|---|
| 1 | this | this | - |
composer:resize-ended :link:
No arguments passed to this event.
composer:resize-started :link:
No arguments passed to this event.
composer:resized :link:
No arguments passed to this event.
Detailed List
/frontend/discourse/app/components/composer-body.js#73 :link:
No arguments passed to this event.
/frontend/discourse/app/components/composer-container.gjs#296 :link:
No arguments passed to this event.
composer:saved :link:
No arguments passed to this event.
composer:show-preview :link:
No arguments passed to this event.
composer:toolbar-popup-menu-button-clicked :link:
| Position | Argument | Type | Always Present | Description |
|---|---|---|---|---|
| 1 | menuItem | variable | True | - |
composer:typed-reply :link:
No arguments passed to this event.
this.composerEventPrefix:all-uploads-complete :link:
No arguments passed to this event.
this.composerEventPrefix:apply-surround :link:
| Position | Argument | Type | Always Present | Description |
|---|---|---|---|---|
| 1 | [grid] | string | True | - |
| 2 | [/grid] | string | True | - |
| 3 | grid_surround | string | True | - |
| 4 | objectArg4 | object | True | - |
| - | objectArg4.useBlockMode | boolean | True | - |
this.composerEventPrefix:closed :link:
No arguments passed to this event.
this.composerEventPrefix:replace-text :link:
| Position | Argument | Type | Always Present | Description |
|---|---|---|---|---|
| 1 | matchingPlaceholder1.index1 | property | True | - |
| 2 | replacement1 | variable | True | - |
| 3 | objectArg3 | object | False | - |
| - | objectArg3.regex | variable | False | - |
| - | objectArg3.index | variable | False | - |
Detailed List
/frontend/discourse/app/components/composer-editor.gjs#511 :link:
| Position | Argument | Type | Description |
|---|---|---|---|
| 1 | matchingPlaceholder1.index1 | property | - |
| 2 | replacement1 | variable | - |
| 3 | objectArg3 | object | - |
| - | objectArg3.regex | variable | - |
| - | objectArg3.index | variable | - |
/frontend/discourse/app/components/composer-editor.gjs#537 :link:
| Position | Argument | Type | Description |
|---|---|---|---|
| 1 | match1 | variable | - |
| 2 | replacement1 | variable | - |
/frontend/discourse/app/components/composer-editor.gjs#597 :link:
| Position | Argument | Type | Description |
|---|---|---|---|
| 1 | matchingPlaceholder1.index1 | property | - |
| 2 | string | string | - |
| 3 | objectArg3 | object | - |
| - | objectArg3.regex | variable | - |
| - | objectArg3.index | variable | - |
this.composerEventPrefix:upload-cancelled :link:
| Position | Argument | Type | Always Present | Description |
|---|---|---|---|---|
| 1 | file.id | property | True | - |
this.composerEventPrefix:upload-error :link:
| Position | Argument | Type | Always Present | Description |
|---|---|---|---|---|
| 1 | file | variable | True | - |
this.composerEventPrefix:upload-started :link:
| Position | Argument | Type | Always Present | Description |
|---|---|---|---|---|
| 1 | file.name | property | True | - |
this.composerEventPrefix:upload-success :link:
| Position | Argument | Type | Always Present | Description |
|---|---|---|---|---|
| 1 | file.name | property | True | - |
| 2 | upload | variable | True | - |
this.composerEventPrefix:uploads-aborted :link:
No arguments passed to this event.
Detailed List
/frontend/discourse/app/lib/uppy/composer-upload.js#132 :link:
No arguments passed to this event.
/frontend/discourse/app/lib/uppy/composer-upload.js#181 :link:
No arguments passed to this event.
this.composerEventPrefix:uploads-cancelled :link:
No arguments passed to this event.
this.composerEventPrefix:uploads-preprocessing-complete :link:
No arguments passed to this event.
this.composerEventPrefix:will-close :link:
No arguments passed to this event.
this.composerEventPrefix:will-open :link:
No arguments passed to this event.
composer-messages
composer-messages:close :link:
No arguments passed to this event.
Detailed List
/frontend/discourse/app/services/composer.js#918 :link:
No arguments passed to this event.
/plugins/discourse-templates/assets/javascripts/discourse/services/d-templates.js#70 :link:
No arguments passed to this event.
composer-messages:create :link:
| Position | Argument | Type | Always Present | Description |
|---|---|---|---|---|
| 1 | objectArg1 | object | True | - |
| - | objectArg1.extraClass | string | True | - |
| - | objectArg1.templateName | string | True | - |
| - | objectArg1.body | called_function | True | - |
Detailed List
/frontend/discourse/app/services/composer.js#794 :link:
| Position | Argument | Type | Description |
|---|---|---|---|
| 1 | objectArg1 | object | - |
| - | objectArg1.extraClass | string | - |
| - | objectArg1.templateName | string | - |
| - | objectArg1.body | called_function | - |
/frontend/discourse/app/services/composer.js#804 :link:
| Position | Argument | Type | Description |
|---|---|---|---|
| 1 | objectArg1 | object | - |
| - | objectArg1.extraClass | string | - |
| - | objectArg1.templateName | string | - |
| - | objectArg1.body | called_function | - |
/frontend/discourse/app/services/composer.js#969 :link:
| Position | Argument | Type | Description |
|---|---|---|---|
| 1 | objectArg1 | object | - |
| - | objectArg1.extraClass | string | - |
| - | objectArg1.templateName | string | - |
| - | objectArg1.body | variable | - |
/frontend/discourse/app/services/composer.js#993 :link:
| Position | Argument | Type | Description |
|---|---|---|---|
| 1 | objectArg1 | object | - |
| - | objectArg1.extraClass | string | - |
| - | objectArg1.templateName | string | - |
| - | objectArg1.body | variable | - |
/frontend/discourse/app/services/composer.js#1002 :link:
| Position | Argument | Type | Description |
|---|---|---|---|
| 1 | objectArg1 | object | - |
| - | objectArg1.extraClass | string | - |
| - | objectArg1.templateName | string | - |
| - | objectArg1.body | called_function | - |
/frontend/discourse/app/static/prosemirror/extensions/mention.js#251 :link:
| Position | Argument | Type | Description |
|---|---|---|---|
| 1 | objectArg1 | object | - |
| - | objectArg1.extraClass | string | - |
| - | objectArg1.templateName | string | - |
| - | objectArg1.body | variable | - |
composer-service
composer-service:last-validated-at-cleared :link:
No arguments passed to this event.
composer-service:last-validated-at-updated :link:
| Position | Argument | Type | Always Present | Description |
|---|---|---|---|---|
| 1 | objectArg1 | object | True | - |
| - | objectArg1.model | variable | True | - |
Detailed List
/frontend/discourse/app/services/composer.js#1073 :link:
| Position | Argument | Type | Description |
|---|---|---|---|
| 1 | objectArg1 | object | - |
| - | objectArg1.model | variable | - |
/frontend/discourse/app/services/composer.js#1282 :link:
| Position | Argument | Type | Description |
|---|---|---|---|
| 1 | objectArg1 | object | - |
| - | objectArg1.model | property | - |
count-updated
count-updated:user.username_lower:key :link:
| Position | Argument | Type | Always Present | Description |
|---|---|---|---|---|
| 1 | value | variable | True | - |
cta
cta:shown :link:
No arguments passed to this event.
d-editor
d-editor:preview-click-group-card :link:
| Position | Argument | Type | Always Present | Description |
|---|---|---|---|---|
| 1 | event1.target | property | True | - |
| 2 | event1 | variable | True | - |
d-editor:preview-click-user-card :link:
| Position | Argument | Type | Always Present | Description |
|---|---|---|---|---|
| 1 | event1.target | property | True | - |
| 2 | event1 | variable | True | - |
destroyed-custom-html
destroyed-custom-html:this.name :link:
No arguments passed to this event.
discourse
discourse:focus-changed :link:
| Position | Argument | Type | Always Present | Description |
|---|---|---|---|---|
| 1 | session.hasFocus | property | True | - |
discourse-ai
discourse-ai:bot-header-icon-clicked :link:
No arguments passed to this event.
discourse-ai:bot-pm-created :link:
| Position | Argument | Type | Always Present | Description |
|---|---|---|---|---|
| 1 | objectArg1 | object | True | - |
| - | objectArg1.id | property | True | - |
| - | objectArg1.slug | property | True | - |
| - | objectArg1.title | variable | True | - |
discourse-ai:force-conversations-sidebar :link:
No arguments passed to this event.
discourse-ai:new-conversation-btn-clicked :link:
No arguments passed to this event.
discourse-ai:stop-forcing-conversations-sidebar :link:
No arguments passed to this event.
discourse-reactions
discourse-reactions:reaction-toggled :link:
| Position | Argument | Type | Always Present | Description |
|---|---|---|---|---|
| 1 | objectArg1 | object | True | - |
| - | objectArg1.post | variable | True | - |
| - | objectArg1.reaction | property | True | - |
discourse-solved
discourse-solved:solution-toggled :link:
| Position | Argument | Type | Always Present | Description |
|---|---|---|---|---|
| 1 | post1 | variable | True | - |
Detailed List
/plugins/discourse-solved/assets/javascripts/discourse/components/solved-accept-answer-button.gjs#28 :link:
| Position | Argument | Type | Description |
|---|---|---|---|
| 1 | post1 | variable | - |
/plugins/discourse-solved/assets/javascripts/discourse/components/solved-unaccept-answer-button.gjs#28 :link:
| Position | Argument | Type | Description |
|---|---|---|---|
| 1 | post1 | variable | - |
discourse-templates
discourse-templates:show :link:
| Position | Argument | Type | Always Present | Description |
|---|---|---|---|---|
| 1 | objectArg1 | object | True | - |
| - | objectArg1.onInsertTemplate | variable | True | - |
do-not-disturb
do-not-disturb:changed :link:
| Position | Argument | Type | Always Present | Description |
|---|---|---|---|---|
| 1 | this.do_not_disturb_until | property | True | - |
dom
dom:clean :link:
No arguments passed to this event.
draft
draft:destroyed :link:
| Position | Argument | Type | Always Present | Description |
|---|---|---|---|---|
| 1 | key | variable | True | - |
emoji-picker
emoji-picker:close :link:
No arguments passed to this event.
flag
flag:created :link:
| Position | Argument | Type | Always Present | Description |
|---|---|---|---|---|
| 1 | objectArg1 | object | True | - |
| - | objectArg1.message | property | True | - |
| - | objectArg1.postId | property | True | - |
full-page-search
full-page-search:trigger-search :link:
No arguments passed to this event.
group
group:join :link:
| Position | Argument | Type | Always Present | Description |
|---|---|---|---|---|
| 1 | group1 | variable | True | - |
group:leave :link:
| Position | Argument | Type | Always Present | Description |
|---|---|---|---|---|
| 1 | model1 | variable | True | - |
header
header:hide-topic :link:
No arguments passed to this event.
header:keyboard-trigger :link:
| Position | Argument | Type | Always Present | Description |
|---|---|---|---|---|
| 1 | objectArg1 | object | True | - |
| - | objectArg1.type | string | True | - |
| - | objectArg1.event | variable | False | - |
Detailed List
/frontend/discourse/app/services/keyboard-shortcuts.js#531 :link:
| Position | Argument | Type | Description |
|---|---|---|---|
| 1 | objectArg1 | object | - |
| - | objectArg1.type | string | - |
| - | objectArg1.event | variable | - |
/frontend/discourse/app/services/keyboard-shortcuts.js#540 :link:
| Position | Argument | Type | Description |
|---|---|---|---|
| 1 | objectArg1 | object | - |
| - | objectArg1.type | string | - |
| - | objectArg1.event | variable | - |
/frontend/discourse/app/services/keyboard-shortcuts.js#547 :link:
| Position | Argument | Type | Description |
|---|---|---|---|
| 1 | objectArg1 | object | - |
| - | objectArg1.type | string | - |
| - | objectArg1.event | variable | - |
/frontend/discourse/app/services/search.js#68 :link:
| Position | Argument | Type | Description |
|---|---|---|---|
| 1 | objectArg1 | object | - |
| - | objectArg1.type | string | - |
header:show-topic :link:
| Position | Argument | Type | Always Present | Description |
|---|---|---|---|---|
| 1 | topic1 | variable | True | - |
header:update-topic :link:
| Position | Argument | Type | Always Present | Description |
|---|---|---|---|---|
| 1 | composer.topic | property | True | - |
Detailed List
/frontend/discourse/app/controllers/topic.js#1843 :link:
| Position | Argument | Type | Description |
|---|---|---|---|
| 1 | topic | variable | - |
/frontend/discourse/app/instance-initializers/subscribe-user-notifications.js#168 :link:
| Position | Argument | Type | Description |
|---|---|---|---|
| 1 | null | null | - |
| 2 | 5000 | integer | - |
/frontend/discourse/app/routes/topic.js#428 :link:
| Position | Argument | Type | Description |
|---|---|---|---|
| 1 | model | variable | - |
/frontend/discourse/app/services/composer.js#1203 :link:
| Position | Argument | Type | Description |
|---|---|---|---|
| 1 | composer.topic | property | - |
/plugins/discourse-assign/assets/javascripts/discourse/initializers/extend-for-assigns.js#502 :link:
| Position | Argument | Type | Description |
|---|---|---|---|
| 1 | topic | variable | - |
inserted-custom-html
inserted-custom-html:this.name :link:
No arguments passed to this event.
interface-color
interface-color:changed :link:
| Position | Argument | Type | Always Present | Description |
|---|---|---|---|---|
| 1 | LIGHT_VALUE_FOR_COOKIE | variable | True | - |
Detailed List
/frontend/discourse/app/services/interface-color.js#95 :link:
| Position | Argument | Type | Description |
|---|---|---|---|
| 1 | LIGHT_VALUE_FOR_COOKIE | variable | - |
/frontend/discourse/app/services/interface-color.js#113 :link:
| Position | Argument | Type | Description |
|---|---|---|---|
| 1 | DARK_VALUE_FOR_COOKIE | variable | - |
/frontend/discourse/app/services/interface-color.js#127 :link:
| Position | Argument | Type | Description |
|---|---|---|---|
| 1 | LIGHT_VALUE_FOR_COOKIE | variable | - |
/frontend/discourse/app/services/interface-color.js#136 :link:
| Position | Argument | Type | Description |
|---|---|---|---|
| 1 | DARK_VALUE_FOR_COOKIE | variable | - |
keyboard
keyboard:move-selection :link:
| Position | Argument | Type | Always Present | Description |
|---|---|---|---|---|
| 1 | objectArg1 | object | True | - |
| - | objectArg1.articles | variable | True | - |
| - | objectArg1.selectedArticle | variable | True | - |
notifications
notifications:changed :link:
No arguments passed to this event.
Detailed List
/frontend/discourse/app/instance-initializers/subscribe-user-notifications.js#160 :link:
No arguments passed to this event.
/plugins/chat/assets/javascripts/discourse/services/chat-tracking-state-manager.js#110 :link:
No arguments passed to this event.
page
page:changed :link:
| Position | Argument | Type | Always Present | Description |
|---|---|---|---|---|
| 1 | objectArg1 | object | True | - |
| - | objectArg1.url | variable | True | - |
| - | objectArg1.title | called_function | False | - |
| - | objectArg1.currentRouteName | property | False | - |
| - | objectArg1.replacedOnlyQueryParams | variable | False | - |
Detailed List
/frontend/discourse/app/lib/page-tracker.js#42 :link:
| Position | Argument | Type | Description |
|---|---|---|---|
| 1 | objectArg1 | object | - |
| - | objectArg1.url | variable | - |
| - | objectArg1.title | called_function | - |
| - | objectArg1.currentRouteName | property | - |
| - | objectArg1.replacedOnlyQueryParams | variable | - |
/plugins/discourse-events/test/javascripts/integration/components/upcoming-events-list-test.gjs#37 :link:
| Position | Argument | Type | Description |
|---|---|---|---|
| 1 | objectArg1 | object | - |
| - | objectArg1.url | string | - |
/plugins/discourse-events/test/javascripts/integration/components/upcoming-events-list-test.gjs#53 :link:
| Position | Argument | Type | Description |
|---|---|---|---|
| 1 | objectArg1 | object | - |
| - | objectArg1.url | string | - |
/plugins/discourse-events/test/javascripts/integration/components/upcoming-events-list-test.gjs#93 :link:
| Position | Argument | Type | Description |
|---|---|---|---|
| 1 | objectArg1 | object | - |
| - | objectArg1.url | string | - |
/plugins/discourse-events/test/javascripts/integration/components/upcoming-events-list-test.gjs#124 :link:
| Position | Argument | Type | Description |
|---|---|---|---|
| 1 | objectArg1 | object | - |
| - | objectArg1.url | string | - |
/plugins/discourse-events/test/javascripts/integration/components/upcoming-events-list-test.gjs#141 :link:
| Position | Argument | Type | Description |
|---|---|---|---|
| 1 | objectArg1 | object | - |
| - | objectArg1.url | string | - |
/plugins/discourse-events/test/javascripts/integration/components/upcoming-events-list-test.gjs#153 :link:
| Position | Argument | Type | Description |
|---|---|---|---|
| 1 | objectArg1 | object | - |
| - | objectArg1.url | string | - |
/plugins/discourse-events/test/javascripts/integration/components/upcoming-events-list-test.gjs#172 :link:
| Position | Argument | Type | Description |
|---|---|---|---|
| 1 | objectArg1 | object | - |
| - | objectArg1.url | string | - |
/plugins/discourse-events/test/javascripts/integration/components/upcoming-events-list-test.gjs#201 :link:
| Position | Argument | Type | Description |
|---|---|---|---|
| 1 | objectArg1 | object | - |
| - | objectArg1.url | string | - |
/plugins/discourse-events/test/javascripts/integration/components/upcoming-events-list-test.gjs#218 :link:
| Position | Argument | Type | Description |
|---|---|---|---|
| 1 | objectArg1 | object | - |
| - | objectArg1.url | string | - |
/plugins/discourse-events/test/javascripts/integration/components/upcoming-events-list-test.gjs#239 :link:
| Position | Argument | Type | Description |
|---|---|---|---|
| 1 | objectArg1 | object | - |
| - | objectArg1.url | string | - |
page:compose-reply :link:
| Position | Argument | Type | Always Present | Description |
|---|---|---|---|---|
| 1 | topic | variable | True | - |
page:like-toggled :link:
| Position | Argument | Type | Always Present | Description |
|---|---|---|---|---|
| 1 | post1 | variable | True | - |
| 2 | likeAction1 | variable | True | - |
page:topic-loaded :link:
| Position | Argument | Type | Always Present | Description |
|---|---|---|---|---|
| 1 | topic | variable | True | - |
policy
policy:changed :link:
| Position | Argument | Type | Always Present | Description |
|---|---|---|---|---|
| 1 | objectArg1 | object | True | - |
| - | objectArg1.controller | variable | True | - |
| - | objectArg1.message | variable | True | - |
poll
poll:voted :link:
| Position | Argument | Type | Always Present | Description |
|---|---|---|---|---|
| 1 | poll1 | variable | True | - |
| 2 | this.post | property | True | - |
| 3 | this.vote | property | True | - |
Detailed List
/plugins/poll/assets/javascripts/discourse/components/poll.gjs#190 :link:
| Position | Argument | Type | Description |
|---|---|---|---|
| 1 | poll1 | variable | - |
| 2 | this.post | property | - |
| 3 | this.vote | property | - |
/plugins/poll/assets/javascripts/discourse/components/poll.gjs#448 :link:
| Position | Argument | Type | Description |
|---|---|---|---|
| 1 | poll1 | variable | - |
| 2 | this.post | property | - |
| 3 | this.vote | property | - |
post
post:created :link:
| Position | Argument | Type | Always Present | Description |
|---|---|---|---|---|
| 1 | createdPost | variable | True | - |
post:highlight :link:
| Position | Argument | Type | Always Present | Description |
|---|---|---|---|---|
| 1 | result.payload.post_number | property | True | - |
| 2 | options | variable | False | - |
Detailed List
/frontend/discourse/app/lib/url.js#366 :link:
| Position | Argument | Type | Description |
|---|---|---|---|
| 1 | closest | variable | - |
/frontend/discourse/app/routes/topic/from-params.js#99 :link:
| Position | Argument | Type | Description |
|---|---|---|---|
| 1 | closest | variable | - |
/frontend/discourse/app/services/composer.js#1211 :link:
| Position | Argument | Type | Description |
|---|---|---|---|
| 1 | result.payload.post_number | property | - |
| 2 | options | variable | - |
post:show-revision :link:
| Position | Argument | Type | Always Present | Description |
|---|---|---|---|---|
| 1 | copy.postNumber | property | True | - |
| 2 | copy.revisionNumber | property | True | - |
post-stream
post-stream:filter-replies :link:
| Position | Argument | Type | Always Present | Description |
|---|---|---|---|---|
| 1 | objectArg1 | object | True | - |
| - | objectArg1.topic_id | property | True | - |
| - | objectArg1.post_number | variable | True | - |
| - | objectArg1.post_id | variable | True | - |
post-stream:filter-show-all :link:
| Position | Argument | Type | Always Present | Description |
|---|---|---|---|---|
| 1 | this.args.streamFilters | property | True | - |
post-stream:filter-upwards :link:
| Position | Argument | Type | Always Present | Description |
|---|---|---|---|---|
| 1 | objectArg1 | object | True | - |
| - | objectArg1.topic_id | property | True | - |
| - | objectArg1.post_id | variable | True | - |
post-stream:gap-expanded :link:
| Position | Argument | Type | Always Present | Description |
|---|---|---|---|---|
| 1 | objectArg1 | object | True | - |
| - | objectArg1.post_id | property | True | - |
quote-button
quote-button:edit :link:
No arguments passed to this event.
quote-button:quote :link:
No arguments passed to this event.
reviewablenote
reviewablenote:created :link:
| Position | Argument | Type | Always Present | Description |
|---|---|---|---|---|
| 1 | data1 | variable | True | - |
| 2 | this.args.reviewable | property | True | - |
| 3 | this.formApi | property | True | - |
search
search:search_result_view :link:
| Position | Argument | Type | Always Present | Description |
|---|---|---|---|---|
| 1 | objectArg1 | object | True | - |
| - | objectArg1.page | property | True | - |
search-menu
search-menu:search_menu_opened :link:
No arguments passed to this event.
sidebar-hamburger-dropdown
sidebar-hamburger-dropdown:rendered :link:
No arguments passed to this event.
site-header
site-header:force-refresh :link:
No arguments passed to this event.
this.eventPrefix
this.eventPrefix:insert-text :link:
| Position | Argument | Type | Always Present | Description |
|---|---|---|---|---|
| 1 | markdown | variable | True | - |
Detailed List
/frontend/discourse/app/lib/textarea-text-manipulation.js#447 :link:
| Position | Argument | Type | Description |
|---|---|---|---|
| 1 | table | variable | - |
/frontend/discourse/app/lib/textarea-text-manipulation.js#501 :link:
| Position | Argument | Type | Description |
|---|---|---|---|
| 1 | markdown | variable | - |
topic
topic:created :link:
| Position | Argument | Type | Always Present | Description |
|---|---|---|---|---|
| 1 | createdPost | variable | True | - |
| 2 | this | this | True | - |
topic:current-post-changed :link:
| Position | Argument | Type | Always Present | Description |
|---|---|---|---|---|
| 1 | objectArg1 | object | True | - |
| - | objectArg1.post | variable | True | - |
topic:current-post-scrolled :link:
| Position | Argument | Type | Always Present | Description |
|---|---|---|---|---|
| 1 | objectArg1 | object | True | - |
| - | objectArg1.postIndex | property | True | - |
| - | objectArg1.percent | called_function | True | - |
topic:jump-to-post :link:
| Position | Argument | Type | Always Present | Description |
|---|---|---|---|---|
| 1 | this.get | called_function | True | - |
Detailed List
/frontend/discourse/app/controllers/topic.js#1085 :link:
| Position | Argument | Type | Description |
|---|---|---|---|
| 1 | this.get | called_function | - |
/frontend/discourse/app/controllers/topic.js#1478 :link:
| Position | Argument | Type | Description |
|---|---|---|---|
| 1 | postId | variable | - |
topic:keyboard-trigger :link:
| Position | Argument | Type | Always Present | Description |
|---|---|---|---|---|
| 1 | objectArg1 | object | True | - |
| - | objectArg1.type | string | True | - |
topic:scrolled :link:
| Position | Argument | Type | Always Present | Description |
|---|---|---|---|---|
| 1 | offset | variable | True | - |
topic:timings-sent :link:
| Position | Argument | Type | Always Present | Description |
|---|---|---|---|---|
| 1 | data | variable | True | - |
topic-entrance
topic-entrance:show :link:
| Position | Argument | Type | Always Present | Description |
|---|---|---|---|---|
| 1 | objectArg1 | object | True | - |
| - | objectArg1.topic | variable | True | - |
| - | objectArg1.position | called_function | True | - |
Detailed List
/frontend/discourse/app/components/basic-topic-list.gjs#89 :link:
| Position | Argument | Type | Description |
|---|---|---|---|
| 1 | objectArg1 | object | - |
| - | objectArg1.topic | variable | - |
| - | objectArg1.position | called_function | - |
/frontend/discourse/app/components/featured-topic.gjs#14 :link:
| Position | Argument | Type | Description |
|---|---|---|---|
| 1 | objectArg1 | object | - |
| - | objectArg1.topic | property | - |
| - | objectArg1.position | called_function | - |
/frontend/discourse/app/components/mobile-category-topic.gjs#19 :link:
| Position | Argument | Type | Description |
|---|---|---|---|
| 1 | objectArg1 | object | - |
| - | objectArg1.topic | property | - |
| - | objectArg1.position | called_function | - |
topic-header
topic-header:trigger-this.args.type-card :link:
| Position | Argument | Type | Always Present | Description |
|---|---|---|---|---|
| 1 | this.args.username | property | True | - |
| 2 | e1.target | property | True | - |
| 3 | e1 | variable | True | - |
upload-mixin
upload-mixin:this.config.id:all-uploads-complete :link:
No arguments passed to this event.
upload-mixin:this.config.id:in-progress-uploads :link:
| Position | Argument | Type | Always Present | Description |
|---|---|---|---|---|
| 1 | this.inProgressUploads | property | True | - |
upload-mixin:this.config.id:upload-cancelled :link:
| Position | Argument | Type | Always Present | Description |
|---|---|---|---|---|
| 1 | file.id | property | True | - |
upload-mixin:this.config.id:upload-success :link:
| Position | Argument | Type | Always Present | Description |
|---|---|---|---|---|
| 1 | file.name | property | True | - |
| 2 | completeResponse | variable | True | - |
Detailed List
/frontend/discourse/app/lib/uppy/uppy-upload.js#252 :link:
| Position | Argument | Type | Description |
|---|---|---|---|
| 1 | file.name | property | - |
| 2 | completeResponse | variable | - |
/frontend/discourse/app/lib/uppy/uppy-upload.js#273 :link:
| Position | Argument | Type | Description |
|---|---|---|---|
| 1 | file.name | property | - |
| 2 | upload | variable | - |
upload-mixin:this.config.id:uploads-cancelled :link:
No arguments passed to this event.
user-card
user-card:after-show :link:
| Position | Argument | Type | Always Present | Description |
|---|---|---|---|---|
| 1 | objectArg1 | object | True | - |
| - | objectArg1.user | variable | True | - |
user-card:show :link:
| Position | Argument | Type | Always Present | Description |
|---|---|---|---|---|
| 1 | objectArg1 | object | True | - |
| - | objectArg1.username | variable | True | - |
user-drafts
user-drafts:changed :link:
No arguments passed to this event.
user-menu
user-menu:notification-click :link:
| Position | Argument | Type | Always Present | Description |
|---|---|---|---|---|
| 1 | objectArg1 | object | True | - |
| - | objectArg1.notification | property | True | - |
| - | objectArg1.href | property | True | - |
user-menu:rendered :link:
No arguments passed to this event.
user-menu:tab-click :link:
| Position | Argument | Type | Always Present | Description |
|---|---|---|---|---|
| 1 | tab1.id | property | True | - |
user-reviewable-count
user-reviewable-count:changed :link:
| Position | Argument | Type | Always Present | Description |
|---|---|---|---|---|
| 1 | count | variable | True | - |
user-status
user-status:changed :link:
| Position | Argument | Type | Always Present | Description |
|---|---|---|---|---|
| 1 | data | variable | True | - |
other events
AI_RESULTS_TOGGLED :link:
| Position | Argument | Type | Always Present | Description |
|---|---|---|---|---|
| 1 | objectArg1 | object | True | - |
| - | objectArg1.enabled | boolean | True | - |
Detailed List
/plugins/discourse-ai/assets/javascripts/discourse/components/ai-full-page-search.gjs#67 :link:
| Position | Argument | Type | Description |
|---|---|---|---|
| 1 | objectArg1 | object | - |
| - | objectArg1.enabled | boolean | - |
/plugins/discourse-ai/assets/javascripts/discourse/components/ai-full-page-search.gjs#156 :link:
| Position | Argument | Type | Description |
|---|---|---|---|
| 1 | objectArg1 | object | - |
| - | objectArg1.enabled | undefined | - |
/plugins/discourse-ai/assets/javascripts/discourse/components/ai-full-page-search.gjs#193 :link:
| Position | Argument | Type | Description |
|---|---|---|---|
| 1 | objectArg1 | object | - |
| - | objectArg1.enabled | boolean | - |
click-tracked :link:
| Position | Argument | Type | Always Present | Description |
|---|---|---|---|---|
| 1 | href | variable | True | - |
desktop-notification-opened :link:
| Position | Argument | Type | Always Present | Description |
|---|---|---|---|---|
| 1 | objectArg1 | object | True | - |
| - | objectArg1.url | property | True | - |
keyboard-visibility-change :link:
| Position | Argument | Type | Always Present | Description |
|---|---|---|---|---|
| 1 | keyboardVisible | variable | True | - |
push-notification-opened :link:
| Position | Argument | Type | Always Present | Description |
|---|---|---|---|---|
| 1 | objectArg1 | object | True | - |
| - | objectArg1.url | property | True | - |
REFRESH_USER_SIDEBAR_CATEGORIES_SECTION_COUNTS_APP_EVENT_NAME :link:
No arguments passed to this event.
this.flagCreatedEvent :link:
| Position | Argument | Type | Always Present | Description |
|---|---|---|---|---|
| 1 | flagModal.args.model.flagModel | property | True | - |
| 2 | postAction | variable | True | - |
| 3 | opts | variable | True | - |
Using Transformers to customize client-side values and behavior
Discourse core includes a number of "transformer" hooks which can be used to customize client behavior. These fall into two categories:
-
Value Transformers take an output from Discourse core, and optionally modify it, or replace it with something else
-
Behavior Transformers can replace a particular piece of logic in Discourse core, and optionally call the original implementation.
Each transformer hook is referenced by a name, and multiple plugins/themes can register transformers against the same hook.
Value Transformers
Value transformers can be registered using api.registerValueTransformer:
registerValueTransformer=>boolean
Param Type Description transformerNamestringthe name of the transformer valueCallbackfunction({value, context})callback to be used to transform the value
The valueCallback will be passed two named arguments: the original value, and a context object. It should return the transformed value. Or, if no transformation is required, it should return the original value.
Callbacks can access the provided context for the current transformer hook, or they can reference other data sources. Many value transformers are executed in autotracking contexts, which means that referencing reactive state will cause the transformer to be automatically re-evaluated when that state changes.
For example, to modify the link URL of the logo on small screen devices:
api.registerValueTransformer("home-logo-href", ({ value, context }) => {
const capabilities = api.container.lookup("service:capabilities");
if (!capabilities.viewport.sm) {
// viewport narrower than the `sm` breakpoint
return "/latest";
} else {
return value;
}
});
Behavior Transformers
Behavior transformers are similar, but instead of receiving and returning a value, they wrap some original behavior, and can optionally call the original implementation.
registerBehaviorTransformer=>boolean
Param Type Description transformerNamestringthe name of the transformer behaviorCallbackfunction({next, context})callback to be used to transform or override the behavior
The behaviorCallback is passed two named arguments. next is a function which can optionally be called to run the original behavior. context contains extra information which may be useful.
For example, if you wanted to limit infinite loading to 100 topics:
api.registerBehaviorTransformer(
"discovery-topic-list-load-more",
({ next, context }) => {
const topicList = context.model;
if (topicList.topics.length > 100) {
alert("Not loading any more");
} else {
next();
}
}
);
Multiple registered transformers
If multiple transformers are registered against a single name, then they will be run in order of registration. The input to valueTransformers will be the value returned by the previous transformer. The next() function passed to behaviorTransformers will call the remaining transformers in the chain.
Finding Transformers
At the moment there is no centralized documentation for transformers. However, a list of all core transformers can be found in the code here. You can then search for those names in the core codebase to find where they're called, the context/value, and the expected return type.
Introducing new Transformers
To introduce new transformers, the name first needs to be added to the registry. In core, add it to lib/registry/transformers.ts.
To introduce a new transformer via a theme or plugin, create a pre-initializer and register the name via the relevant API:
// .../discourse/pre-initializers/my-transformer.js
export default {
before: "freeze-valid-transformers",
initialize() {
withPluginApi((api) => {
api.addValueTransformerName("my-value-transform");
api.addBehaviorTransformerName("my-behavior-transform");
});
},
};
To apply the transformer to some value or behavior, import the associated apply function and use it anywhere in your code:
import {
applyValueTransformer,
applyBehaviorTransformer,
} from "discourse/lib/transformer";
const context = { bestForumSoftware: "Discourse" };
// Value Transformer
const originalValue = "Hello";
const transformedValue = applyValueTransformer(
"my-value-transform",
originalValue,
context
);
// Behavior Transformer
applyBehaviorTransformer(
"my-behavior-transformer",
() => {
alert("This is the core implementation");
},
context
);
TypeScript
The transformer pipeline exports ValueTransformer, MutableValueTransformer, AnyValueTransformer, BehaviorTransformer, and DefaultBehaviorTransformer callback types from discourse/lib/transformer. Value callbacks take value and context type parameters: ValueTransformer<T, C> returns T, while MutableValueTransformer<T, C> mutates the value and returns void. registerValueTransformer accepts AnyValueTransformer<T, C>, which allows either kind but still rejects a callback returning something other than T. Behavior callbacks take return and context type parameters. Context types can be interfaces without an index signature. Hook names remain strings so plugins and themes can introduce hooks.
applyValueTransformer and applyMutableValueTransformer infer their result type from the default value. Supply an explicit type argument when a hook can return a broader type than its default, for example applyValueTransformer<string | null>("custom-label", null). Registration and application must agree on a hook's value, context, and return types; the string registry does not enforce a shared contract between separate calls.
Behavior callbacks receive a shallow copy of the context. next() and applyBehaviorTransformer can return void when middleware stops the chain or calls next() after the queue is exhausted. Promise results pass through unchanged, and async middleware that awaits next() resolves to the awaited result or undefined; BehaviorTransformerResult<R> describes the full result type.
Customizing the topic list
The topic-list is one of the most-used and most-customized user-interfaces in Discourse. There are many different tools available to theme and plugin developers to achieve this customization, each with their own advantages, disadvantages and maintenance considerations.
CSS-based Customization
The safest and most common customization method is CSS. As with any other Discourse customization: the more complex your changes, the more maintenance is likely to be required in your theme or plugin.
General guidance on CSS customization can be found in the Designer's Guide.
Adding, removing and rearranging columns
On desktop, the topic list is rendered as a table. On mobile, each topic row uses a separate single-cell layout, so topic-list-columns only affects the desktop table.
The Plugin API can be used to customize the desktop columns via the topic-list-columns Value Transformer. This is the most surgical way to add, remove, replace, or reorder columns without taking ownership of the full row markup.
topic-list-columns is a mutable transformer. Core builds a DAG of columns, passes that mutable object through registered transformers, and resolves it afterwards. In practice, that means you should mutate the columns object directly.
The transformer callback currently receives:
context.listContextcontext.categorycontext.filter
Current core column keys are:
bulk-selectwhen bulk select is enabledtopicposterswhen poster avatars are enabledreplieslikesorop-likesdepending on the current sort orderviewsactivity
Each column value can define:
header: the component rendered in the table header for that columnitem: the component rendered in each desktop row for that column
The DAG instance supports these methods:
add(key, value, position?)delete(key)replace(key, value, position?)reposition(key, position)
When you call replace(), core preserves the existing header or item if your replacement only provides one of them.
Header cell components currently receive these arguments:
| Argument | Description |
|---|---|
@sortable |
Whether the column should behave as a sortable header |
@activeOrder |
The currently active sort order |
@changeSort |
Callback used to change sorting |
@ascending |
Whether the active sort is ascending |
@category |
The current category, when available |
@name |
The translation key or label used by the header |
@bulkSelectEnabled |
Whether bulk select mode is active |
@showBulkToggle |
Whether the bulk-select toggle button should be shown |
@canBulkSelect |
Whether bulk select is available |
@canDoBulkActions |
Whether bulk actions are currently available |
@bulkSelectHelper |
The bulk-select helper object |
Item cell components currently receive these arguments:
| Argument | Description |
|---|---|
@topic |
The current topic |
@bulkSelectEnabled |
Whether bulk select mode is active |
@onBulkSelectToggle |
Callback used by the bulk-select checkbox |
@isSelected |
Whether the topic is currently selected |
@showTopicPostBadges |
Whether unread/new badges should be shown |
@hideCategory |
Whether category markup should be hidden |
@tagsForUser |
The current tag visibility context |
@expandPinned |
Whether the pinned excerpt should be shown |
Current source for this API lives in frontend/discourse/app/components/topic-list/list.gjs, frontend/discourse/app/components/topic-list/header.gjs, and frontend/discourse/app/components/topic-list/item.gjs.
This example adds, removes and repositions columns in the desktop topic list:
// .../discourse/api-initializers/my-topic-list-customization.gjs
import { apiInitializer } from "discourse/lib/api";
const StaffHeaderCell = <template>
<th>Staff?</th>
</template>;
const StaffItemCell = <template>
<td>{{if @topic.creator.staff "✅"}}</td>
</template>;
export default apiInitializer((api) => {
api.registerValueTransformer(
"topic-list-columns",
({ value: columns, context }) => {
// Remove the core column which shows poster avatars:
columns.delete("posters");
// Swap the "replies" and "views" columns:
columns.reposition("views", { before: "replies" });
if (context.category?.slug === "announcements") {
columns.add(
"created-by-staff",
{
header: StaffHeaderCell,
item: StaffItemCell,
},
{ before: "activity" }
);
}
}
);
});
Introducing content via Plugin Outlets
Prefer Plugin Outlets when you want to inject content without taking ownership of the whole row structure.
Some of the most useful topic-list outlets are:
| Outlet | Purpose | Outlet args |
|---|---|---|
before-topic-list-body / after-topic-list-body |
Add markup before or after the table body | topics, selected, bulkSelectEnabled, lastVisitedTopic, discoveryList, hideCategory |
after-topic-list-item |
Insert an extra <tr> after each row |
topic, index |
topic-list-item |
Wrap or replace the entire row | topic, bulkSelectEnabled, onBulkSelectToggle, isSelected, hideCategory, tagsForUser, showTopicPostBadges, navigateToTopic |
topic-list-topic-cell-link-top-line |
Extend the desktop title/status line | topic, tagsForUser |
topic-list-topic-cell-link-bottom-line |
Extend the desktop category/tag/meta line | topic, tagsForUser |
topic-list-item-mobile-avatar / topic-list-item-mobile-bumped-at |
Mobile-only customizations | topic |
topic-list-before-reply-count / topic-list-before-view-count / topic-list-before-relative-date |
Add content near the count/date cells | topic |
topic-list-heading-bottom |
Add content inside sortable header cells | name, bulkSelectEnabled |
There are more outlets throughout frontend/discourse/app/components/topic-list/, especially in item/topic-cell.gjs, item/replies-cell.gjs, item/views-cell.gjs, and item/activity-cell.gjs.
Replacing the entire topic-list-item
If you want to completely replace the core row implementation, use the topic-list-item wrapper outlet. This should only be done when your design differs so much from core that you do not want future core changes to affect it, and you do not need compatibility with other themes or plugins.
Core intentionally does not pass @columns into this wrapper outlet. A full row replacement means taking ownership of the row structure yourself.
If using this strategy, you should take extra care to ensure that your code is well tested, and you should make sure that your theme/plugin users are aware of the caveats.
Other tweaks via Transformers and Theme Modifiers
A number of Transformers allow small, targeted changes to the topic-list implementation:
topic-list-columns(mutable context:{ listContext, category, filter }) - mutate the desktop column DAGtopic-list-class(context:{ topics, listContext }) - return classes to add to the topic list<table>topic-list-item-class(context:{ topic, index, listContext }) - return classes to add to each topic rowtopic-list-item-style(context:{ topic, index, listContext }) - return an array ofhtmlSafeCSS declarations to be joined into the row's inlinestyletopic-list-item-expand-pinned(context:{ topic, mobileView }) - control whether the topic excerpt is displayedtopic-list-item-mobile-layout(context:{ topic, listContext }) - choose whether a row uses the mobile layouttopic-list-header-sortable-column- override whether header cells are sortable, for example based on the current category
There is also a behavior transformer for row clicks:
topic-list-item-click(context:{ event, topic, listContext, navigateToTopic }) - wrap or override the default click behavior. Register this withapi.registerBehaviorTransformer(...), notapi.registerValueTransformer(...).
For example, topic-list-item-style must return htmlSafe strings:
// .../discourse/api-initializers/topic-list-style.gjs
import { htmlSafe } from "@ember/template";
import { apiInitializer } from "discourse/lib/api";
export default apiInitializer((api) => {
api.registerValueTransformer(
"topic-list-item-style",
({ value, context }) => {
if (context.topic.pinned) {
value.push(htmlSafe("outline: 2px solid var(--primary-low);"));
}
return value;
}
);
});
Some Theme Modifiers are also relevant to the topic list because they change which data is serialized:
serialize_topic_excerpts(boolean, defaultfalse) - always include excerpts when serializing topic liststopic_thumbnail_sizes(array) - request additional resolutions in the topic thumbnail setserialize_topic_op_likes_data(boolean) - include first-post like data such asop_like_count,op_can_like,op_liked, andfirst_post_idserialize_topic_is_hot(boolean) - includeis_hoton topic list items
Guidelines for CSS classes using BEM
When writing CSS classes for Discourse components, themes, or plugins, we follow a modified variant of Block Element Modifier (BEM). Following these guidelines will make it much less likely for CSS conflicts to occur, since BEM helps a lot with specificity. Themes and components have an easier time of overriding Discourse core styles, and the more descriptive class names make it easy at a glance to see where CSS classes should be applied in core.
The guidelines
This is what the Discourse engineering and design teams have aligned on using. It’s mainly BEM, with some influence from SMACSS and others.
Syntax
It is generally a good idea to make a distinct block level CSS class per reusable Ember component, which you can then attach element CSS classes and modifiers to.
-
You use default BEM for block and element in the format
.block__elementfor example.header__itemor.admin-new-feature-item__screenshot.- A block is a standalone component. Blocks can be used within blocks.
- An element is a part of a block that can not be used outside of that block's context, for example an inner element.
- A modifier you can use mainly for changing the appearance, if different than the default. This can be created standalone, because this can sometimes be re-used and because this reduces pure BEM verbosity by a lot.
- For example,
d-buttonis the block which has some default styling, and--cancelis a modifier to make it look different
- For example,
-
These special prefixes can be used to signify that the piece of UI in question is currently styled a certain way because of a state or condition:
is-foo, for exampleis-open(for modals)has-foo, for examplehas-errors(for forms)
Examples of syntax
Visually nesting is recommended because this keeps all relevant elements attached to the block and can be easily collapsed.
.block {
// block styling
&__element {
//element styling
&.--modifier { // direct modifier on element }
.--modifier & { // indirect modifier on parent element}
}
}
For modifiers, they can either be applied directly to an element like so:
<button class="d-button --modifier"></button>
Or in an alternate use case, imagine we want to style all of these elements inside the main block, when there is an error:
<div class="block --error">
<input class="block__input"/>
<label class="block__label">
<p class="block__text">…</p>
</div>
Instead of placing a modifier on each element separately, we can just place it on the block level and use it indirectly via the syntax with & at the end. It makes little difference in the CSS file, but it keeps the DOM cleaner by not repeating modifiers.
A great real world example of our CSS classes in use in Discourse is within the chat plugin, in the loading skeleton component.
See also
- Designing for Different Devices — adapting styles to viewport size, touch vs. hover, and other device characteristics.
JavaScript type hinting & validation (typescript)
Discourse ships type information for its JavaScript code. This can provide inline documentation, autocomplete, and other useful IDE features. It can also be used for some basic type validation, using the @ts-check directive.
Much of this will be automatically consumed by IDEs with TypeScript/JavaScript support. But for functionality in .gjs files, you'll need some specific configuration and/or IDE plugins.
Writing TypeScript
Core, themes and plugins can be authored directly in TypeScript. Use a .ts extension for plain modules, or .gts for Glimmer components with a <template> tag. Type syntax is stripped at build time, so no separate compilation step is required. Linting (@discourse/lint-configs) and type-checking (pnpm lint:types) both understand these files.
Strict checking
pnpm lint:types runs in TypeScript's strict mode, and TypeScript in core and in the bundled plugins and themes must pass it. Plugins and themes outside this repository extend the same config, so they are checked under strict too. To opt out, set "strict": false in the compilerOptions of the plugin's or theme's own tsconfig.json.
JavaScript files are not type-checked, but the published @discourse/types declarations for them are generated under strict. A field initialized to null is declared with the type null, so give public values like this a JSDoc type such as /** @type {number | null} */.
Type tests
Types whose meaning a runtime test can't capture (a generic's resolved type, an overload pick, a return type derived from arguments) can be asserted at compile time with expect-type, in .ts/.gts files under frontend/discourse/type-tests/ (e.g. type-tests/truth-helpers/). They are checked by pnpm lint:types but kept out of the test and production bundles.
Typing model fields added by plugins
Each core model declares the fields core sends in an interface named after it, such as TopicFields, PostFields, UserFields or AdminUserFields. The model's type extends that interface. A plugin that adds fields, on the server with add_to_serializer or on the client with api.addModelField or api.addTrackedTopicProperties, can declare them by augmenting that interface.
The store has two registries that work the same way. ModelRegistry maps a store type name to its model, so store.find and store.createRecord return that model. ModelCreateAttrsRegistry maps a type name to the attributes createRecord accepts, for models whose inputs differ from their fields.
// plugins/my-plugin/assets/javascripts/discourse/model-types.ts
import type Sticker from "./models/sticker.js";
declare module "discourse/models/topic" {
interface TopicFields {
/** Sent only when the topic has stickers. */
sticker_count?: number;
}
}
declare module "discourse/services/store" {
interface ModelRegistry {
sticker: Sticker;
}
}
With this file in place, topic.sticker_count is a number | undefined, and store.createRecord("sticker") returns a Sticker.
- Type each field from what the server sends. Make it optional unless every payload that builds the model includes it, and add
| nullwhen the serializer can returnnil. A field added with aninclude_conditionis optional. - Declare only the fields the plugin adds. Redeclaring a core field with a different type is reported as an error.
- A model the plugin defines follows the same pattern as core: export a
<Model>Fieldsinterface, merge it into the class withexport default interface <Model> extends <Model>Fields {}, and register the class inModelRegistry. Core disables the@typescript-eslint/no-empty-object-typeand@typescript-eslint/no-unsafe-declaration-mergingrules on the merge lines. - The file must be a module, so keep at least one
import typeor anexport {};. Without one, thedeclare moduleblocks replace the core modules instead of extending them. Avoid a bareimport "discourse/models/topic";for this, because it stays in the bundle as a runtime import. - Use a
.tsfile, not.d.ts. Type checking skips declaration files, so a field that clashes with the type core declares is only reported from a.tsfile. The file compiles to an empty module. - Registry keys and lookup names are matched after the resolver's model name normalization. For example,
postStream,post_streamandpost-streammatch the same key. The exportedNormalizeModelNametype describes this normalization. Keys that normalize to the same name produce a union of their registered types. Names without a matching registration and dynamic strings fall back toRestModel. Only the model class lookup is normalized. The identity map and__typeretain the raw string, and request URLs are still derived from that raw string through the adapter.
Usage
-
CLI: Run
pnpm lint:types -
VSCode: Install the TypeScript 7 and Glint v2 extensions. Glint 1.4.0 and newer hands
.gts/.gjsfiles to TypeScript's own language server. Both are part of our recommended config, so you may already have it. If anything isn't working, you may need to trigger "Restart extension host" from VSCode's command palette, or restart the IDE. -
JetBrains (RubyMine, WebStorm, Intellij, etc.): Install the EmberExperimental plugin.
Troubleshooting
Ensure that you've run pnpm install recently
Enabling for a theme or plugin
Official themes/plugins, and the official skeletons, are all wired up for types. To enable it for your own plugin/theme, pull in the latest changes from the relevant skeleton (package.json, tsconfig.json)
Bundled plugins and themes
Plugins and themes in this repository depend on @discourse/types through the pnpm workspace and reference the core project from their tsconfig.json. pnpm lint:types builds core first, so a core type change is checked against every bundled plugin in the same run, with no release or version bump in between.
In the editor, the TypeScript 7 language server resolves core imports straight to core source. Editors still on TypeScript 6 read the declarations that the last pnpm lint:types emitted, so run it, or keep pnpm types:watch running, after changing core types.
External plugins and themes consume the published @discourse/types package. It is published from main by the publish-types workflow.
Relative imports
Relative imports name the file with its extension: ./foo.ts, ./foo.js, ./foo.gts, ./foo.gjs. TypeScript 7 only resolves a .gts or .gjs module when the specifier carries the extension, and the same rule is applied to every extension so that the type checker, the build and ESLint agree. ESLint reports a missing extension and adds it with --fix.
Enable checking for a file
.ts and .gts files are always type-checked. For .js / .gjs files, type-checking is opt-in: add /** @ts-check */ at the top. For some examples, search Discourse core for @ts-check.
Limitations
We do not provide any guarantees about the accuracy of the types - they're provided on a best-effort basis. PRs to improve the type documentation in core are welcome.
Known Issues
-
Autocomplete inside
<template>tags requires complete syntax. For example, if you start typing:<DBuThis will not autocomplete to DButton, because the template syntax cannot be parsed. The workaround is to close the brackets, and then go back to the variable you'd like to autocomplete:
<DBu />Upstream issue here
Designing for Different Devices (Touch & Hover)
This document outlines the APIs used to adapt Discourse's user interface for different devices.
Touch & Hover
Some devices only have touchscreens, some only have a traditional mouse pointer, and some have both. Importantly, touchscreen users cannot "hover" over elements. Therefore, interfaces should be designed to work entirely without hover states, with hover-specific enhancements added for devices that support them.
There are several ways to detect touch/hover capability via CSS and JavaScript. For consistency, we recommend using Discourse's helpers instead of those CSS/JS APIs directly.
For CSS, you can target the .discourse-touch and .discourse-no-touch classes, which are added to the <html> element. These are determined based on the (any-pointer: coarse) media query.
For example:
html.discourse-touch {
// SCSS rules here will apply to devices with a touch screen,
// including mobiles/tablets and laptops/desktops with touch screens.
}
html.discourse-no-touch {
// SCSS rules here will apply to devices with no touch screen.
}
This information is also available in Ember components via the capabilities service:
import Component from "@glimmer/component";
import { service } from "@ember/service";
class MyComponent extends Component {
@service capabilities;
<template>
{{#if this.capabilities.touch}}
This text will be displayed for devices with a touch screen
{{/if}}
{{#unless this.capabilities.touch}}
This text will be displayed for devices with no touch screen
{{/unless}}
</template>
}
Legacy Mobile / Desktop Modes
Historically, Discourse shipped two completely different layouts and stylesheets for "mobile" and "desktop" views, based on the browser's user-agent. Developers would target these modes by putting CSS in specific mobile/desktop directories, by using the .mobile-view/.desktop-view HTML classes, and the site.mobileView boolean in JavaScript.
These techniques are now considered deprecated and should be replaced with the viewport and capability-based strategies discussed in the next document. For backwards-compatibility, legacy desktop/mobile CSS is used when the viewport is larger/smaller than the sm threshold.
See also
- Designing for Responsive Widths — breakpoints, viewport size, and container queries.
- Guidelines for CSS classes using BEM — CSS class naming conventions.
Designing for Responsive Widths (Breakpoints, Viewport & Containers)
This document outlines the APIs used to adapt Discourse's user interface to different widths — both the browser viewport, and individual containers on the page.
We design "mobile first" and then add customizations for larger sizes as needed. The breakpoints we use are:
| Breakpoint | Size | Pixels (at 16px body font size) |
|---|---|---|
| xs | 20rem | 320px |
| sm | 40rem | 640px |
| md | 48rem | 768px |
| lg | 64rem | 1024px |
| xl | 80rem | 1280px |
| 2xl | 96rem | 1536px |
Viewport Size
The most common way to use these breakpoints is based on the browser's viewport size. To use these in an SCSS file, add @use "lib/viewport"; at the top of the file, then use one of the available mixins:
@use "lib/viewport";
@include viewport.from(lg) {
// SCSS rules here will be applied to
// devices larger than the lg breakpoint
}
@include viewport.until(sm) {
// SCSS rules here will be applied to
// devices smaller than the sm breakpoint
}
@include viewport.between(sm, md) {
// SCSS rules here will be applied to
// devices with a size between the sm
// and md breakpoints
}
In general, SCSS is the recommended way to handle layout differences based on viewport size. For advanced cases, the same breakpoints can be accessed in Ember components via the capabilities service. For example:
import Component from "@glimmer/component";
import { service } from "@ember/service";
class MyComponent extends Component {
@service capabilities;
<template>
{{#if this.capabilities.viewport.lg}}
This text will be displayed for devices larger than the lg breakpoint
{{/if}}
{{#unless this.capabilities.viewport.sm}}
This text will be displayed for devices smaller than the sm breakpoint
{{/unless}}
</template>
}
These properties are reactive, and Ember will automatically re-render the relevant parts of the template as the browser is resized.
For a real example, see app/assets/stylesheets/common/base/directory.scss, which uses viewport.until(sm)/viewport.until(md) throughout to adapt the user directory table's layout at narrower viewport widths.
Container Queries
Sometimes a component's layout should respond to the size of its own container, rather than the size of the whole browser viewport — for example, a card that's rendered both full-width and inside a narrow sidebar. For these cases, use lib/container, which shares the same breakpoint scale and mixin API as lib/viewport.
Container queries only work against an ancestor element that has been explicitly opted in with container-type. Set that (and, optionally, a container-name if you need to target a specific ancestor) on the element whose size you want to query against:
.my-component {
container-type: inline-size;
container-name: my-component;
}
Then, add @use "lib/container"; at the top of the file, and use the mixins the same way as lib/viewport:
@use "lib/container";
.my-component {
container-type: inline-size;
container-name: my-component;
@include container.until(sm, my-component) {
// SCSS rules here will be applied when
// .my-component itself is narrower than
// the sm breakpoint
}
}
The $name argument is optional. Omit it if there's only one queryable container in scope; pass it when you need to target a specific named container (for example, if the rule lives on a descendant, rather than the container element itself).
For a real example, see .db-whos-posting in app/assets/stylesheets/admin/dashboard/engagement.scss, which sets container-type: inline-size; container-name: db-whos-posting; and uses container.until(sm, db-whos-posting) to adapt its own layout independently of the viewport.
See also
- Designing for Different Devices (Touch & Hover) — detecting touch/hover capability, and legacy mobile/desktop modes.
- Guidelines for CSS classes using BEM — CSS class naming conventions.
Drag, resize, and gesture primitives
ui-kit ships the input-driven gesture primitives: dragging a payload onto a
target, resizing a region, and reading a swipe or a pointer drag. This page is
the usage reference. Each primitive's own TSDoc carries the fine print for every
argument; what follows is how to put them together.
Basic usage
A drag needs a source that carries a payload and a target that accepts its type.
import { hash } from "@ember/helper";
import dDragAndDropSource from "discourse/ui-kit/modifiers/d-drag-and-drop-source";
import dDragAndDropTarget from "discourse/ui-kit/modifiers/d-drag-and-drop-target";
<template>
<div {{dDragAndDropSource type="card" data=(hash id=@card.id)}}>
{{@card.title}}
</div>
<div {{dDragAndDropTarget accepts="card" onDrop=@onDrop}}>
Drop a card here
</div>
</template>
@action
onDrop({ source, position }) {
// source.data is what the source attached; position is "before" or "after"
this.file(source.data.id, position);
}
The type string is the whole matching vocabulary. A target engages only for
sources whose type is in its accepts, and everything else drags straight
over it.
Picking a primitive
| I want to… | Use |
|---|---|
| Move something onto a target and transfer a payload | dDragAndDropSource + dDragAndDropTarget |
| Take page content the browser started dragging, with no source | dDragAndDropTarget with adopts |
| Take files, HTML, URLs, or text from outside the window | dDragAndDropExternalTarget |
| Upload the files somebody dropped | the upload pipeline, not the modifier above |
| React to a drag without becoming a drop target | dDragAndDropMonitor |
| Scroll a container while a drag hovers near its edge | dDragAndDropAutoScroll |
| Open or reveal something once a drag has hovered it a moment | dDragDwell |
| Render from the in-flight drag | the dragAndDrop service |
| Press, move, and track a value continuously | dPointerDrag |
| Put an accessible handle between two regions | DResizeSeparator |
| Resize on one axis with your own element and semantics | dResizeEdge |
| Resize a box from its edges and corners | DResizeHandles |
| Detect a directional flick | dSwipe |
| Know that an element changed size | dOnResize, which is not a gesture |
dDragAndDropSource
Marks an element draggable and attaches the payload.
Arguments
| Argument | Type | Purpose |
|---|---|---|
type |
string, required |
The discriminator targets filter on. Overwrites any type in the payload. |
data |
object |
Static payload, read back as source.data. |
getInitialData |
() => object |
Dynamic payload, called once just before dragstart. |
dragPreview |
Element or render function |
What the drag image shows. Defaults to the source element. |
dragPreviewOffset |
{x: string, y: string} |
CSS lengths pushing a rendered preview clear of the pointer. |
effectAllowed |
DataTransfer["effectAllowed"] |
What the drag permits. Defaults to "move". |
dragHandle |
Element |
An element inside this one that a drag must start from. |
disabled |
boolean |
Detaches the registration. A drag in flight still finishes. |
canDrag |
(feedback) => boolean |
Returning false blocks the drag from starting. |
onDragStart |
(event) => void |
Fires once the drag is confirmed. |
onDragEnd |
(event) => void |
Fires at the end of every drag, landed or abandoned. |
onDrop |
(event) => void |
Fires only for a drag that ended on a target. |
The element carries data-drag-source while registered and --dragging for the
duration of the drag. Style and assert against those, never against
draggable: that attribute sits on whichever element was registered, which is
the handle when there is one.
Dragging by a handle
Without a handle the whole element starts a drag, so text inside it cannot be selected and a touch press meant to scroll starts a drag instead. Pass the handle element itself, captured with a modifier, not a selector.
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { modifier } from "ember-modifier";
import dDragAndDropSource from "discourse/ui-kit/modifiers/d-drag-and-drop-source";
export default class Row extends Component {
@tracked gripElement;
captureGrip = modifier((element) => {
this.gripElement = element;
return () => (this.gripElement = undefined);
});
<template>
<li {{dDragAndDropSource type="row" dragHandle=this.gripElement}}>
<span {{this.captureGrip}} aria-hidden="true"></span>
{{@item.label}}
</li>
</template>
}
The row stays the drag body: it keeps data-drag-source, it is what a target
reports as source.element, and it is what the default preview photographs.
Custom previews
An Element preview is photographed in place. A render function mounts a fresh
preview into an isolated offscreen container, so nothing around the source
bleeds into the drag image, and may return a cleanup function.
dragPreview = ({ container, element }) => {
const node = element.cloneNode(true);
container.append(node);
return () => node.remove();
};
dDragAndDropTarget
Accepts element drags and reports where the drop would land.
Arguments
| Argument | Type | Purpose |
|---|---|---|
accepts |
string | string[] |
Which source types engage the target. Omit to accept any. |
adopts |
NativeDragAdoption | NativeDragAdoption[] |
Also take browser-started page content. See below. |
acceptsSelf |
boolean |
false refuses a drop whose dragged element is this element. |
position |
"before" | "after" | "inside" |
A fixed position, which wins over midpoint math. |
axis |
"vertical" | "horizontal" |
Which midpoint is measured. Defaults to "vertical". |
indicator |
boolean |
false suppresses the indicator class. |
canDrop |
(feedback) => boolean |
Gate asked while hovering. false defers to an ancestor. |
dropEffect |
DropEffect or (feedback) => DropEffect |
The cursor feedback the browser shows. |
getData |
() => object |
Metadata attached to the drag's record of this target. |
getIsSticky |
() => boolean |
Whether the target stays current after the pointer leaves. |
onDragEnter |
(event) => void |
This target became the deepest accepted target. |
onDrag |
(event) => void |
Throttled, while it stays the deepest accepted target. |
onDragLeave |
(event) => void |
It stopped being the deepest accepted target. |
onDrop |
(event) => void |
The drag was released here. |
A registered target carries data-drop-target.
Positions and indicator classes
A target resolves before, after or inside and paints one class while a
compatible drag is over it:
| Position | Vertical axis | Horizontal axis |
|---|---|---|
before |
--drag-above |
--drag-left |
after |
--drag-below |
--drag-right |
inside |
--drag-inside |
--drag-inside |
Positions are logical, not physical. In a right-to-left row, before still
means the reading-order start, and the class flips to --drag-right so the
indicator stays under the pointer. Consumers need no special case.
Nesting
Only the deepest accepted target receives lifecycle callbacks, so nested targets never handle one drop twice. The cost is that an ancestor stops indicating the moment a descendant claims the drag. When a container has to stay highlighted while its descendants activate in turn, render from the service instead and leave drop handling to the descendants.
A descendant that answers false from canDrop is not accepted, so the drop
falls through to the nearest ancestor that does accept it. That is how a target
hands back a region it does not own, such as an edge band that belongs to the
enclosing container. Eligibility is sampled while the drag hovers and is not
asked again at the release, so the answer a target last gave is the one that
counts.
Adopting browser-started drags
The browser starts drags on ordinary page content, such as a link or an image, which registers no source and could not. An adoption lets a target take those too. It names the kind of drag and supplies a predicate:
export const WEB_LINK = {
type: "web-link",
match: ({ element }) => Boolean(element.closest("a[href]")),
};
<div {{dDragAndDropTarget adopts=WEB_LINK onDrop=this.addLink}}></div>
From there it is an ordinary drag: the same callbacks, the same positions, the
same indicator. The adoption's type becomes source.type, so a monitor or
auto-scroll filters on it like any other type, and the native payload arrives on
source.native with the same reader API an external target hands you. Its
items list is always empty, because the handles go inert when dragstart ends.
Three rules are not guessable from the signature:
- Adoption is resolved once for the page, not per target. At
dragstartthe first live adoption whose predicate matches names the drag, and every target listing that name accepts it. Two adoptions sharing a name must therefore share a predicate. Declare one as a module constant and offer it from each target. adoptswithoutacceptsrefuses every registered source. A target that named what it wants throughadoptsis not also asking for everything else on the page.- It covers only what the browser started on this page. To take the same
content dragged in from outside the window, pair the target with
dDragAndDropExternalTargeton the same element.
Adoption deliberately leaves several drags alone: a registered source keeps its
own lifecycle, an element somebody else made draggable keeps its own drag, files
go to an external target, and a text selection is never adopted, including one
held inside an input or textarea. A predicate that throws is reported and
refuses, rather than deciding for the adoptions after it.
dDragAndDropExternalTarget
Accepts payloads dragged in from another window or another application. It never sees a drag that began on this page.
Arguments
| Argument | Type | Purpose |
|---|---|---|
accepts |
"files" | "html" | "text" | "urls" or array |
Which kinds engage the target. Omit to accept any. |
position |
"before" | "after" | "inside" |
Opts into resolving a position at all. |
axis |
"vertical" | "horizontal" |
Same, from the pointer against the midpoint. |
Without position or axis the target is one destination rather than a slot:
callbacks receive position: null and the indicator is the single
--drag-over-external class. A registered external target carries
data-drop-target-external.
<div
{{dDragAndDropExternalTarget
accepts=(array "urls" "text")
onDrop=this.acceptLink
}}
></div>
The payload exposes types, containsFiles(), getFiles(), containsHTML(),
getHTML(), containsText(), getText(), containsURLs() and getURLs().
items and getFiles() are only populated at the drop.
This is not the upload path. A file dropped here does not enter upload validation, progress, retries, or the upload record. Reach for it only when the consumer genuinely wants the raw payload.
dDragAndDropMonitor
Reacts to an element drag happening anywhere, without becoming a drop target.
| Argument | Type | Purpose |
|---|---|---|
types |
string | string[] |
Which drag types to watch. Omit to watch all. |
onDragStart |
(event) => void |
A watched drag began. |
onDrag |
(event) => void |
Throttled, while it is in flight. |
onDrop |
(event) => void |
It was released. |
The element only anchors the modifier's lifecycle; a monitor is global.
dDragAndDropAutoScroll
Scrolls a container while a drag hovers near its edge.
| Argument | Type | Purpose |
|---|---|---|
types |
string | string[] |
Element drag types that engage it. |
externalKinds |
"files" | "html" | "text" | "urls" or array |
External kinds that engage it. |
axis |
"vertical" | "horizontal" | "all" |
Which direction to scroll. |
target |
"element" | "window" |
Scroll this element or the window. |
Element drags and external drags arrive through different adapters, so an
observer that watches both names both: types for the element side,
externalKinds for the external side. accepts stays a drop-taking word.
dDragDwell
Fires a callback once a drag has hovered its element for a delay — a folder opening under a file held over it — without making the element a drop target. It watches drags the way a monitor does, for both element drags and, when asked, external ones, and tests its own rectangle against the pointer. It never joins the drop-target hierarchy: no indicator, no positions, and it composes freely with a real target on the same element.
| Argument | Type | Purpose |
|---|---|---|
types |
string | string[] |
Element drag types to watch. Omit to watch all. |
externalKinds |
external kinds or array | External kinds to watch. Omitting it, or naming no kinds, refuses external drags. |
delay |
boolean | number |
Hover before the dwell fires: true (default) the standard 500 ms, false immediate, or milliseconds. |
leaveDelay |
boolean | number |
Grace before a fired dwell is undone on leave: true (default) mirrors the entry delay, false immediate, or milliseconds. |
canDwell |
(feedback) => boolean |
Gates candidacy; shares a drop target's canDrop feedback shape. |
acceptsSelf |
boolean |
Whether the element's own drag may dwell. Defaults to true. |
onDwell |
(event) => void |
The drag hovered long enough. |
onDwellEnd |
(event) => void |
The candidacy ended; the place to undo what onDwell did. |
<div
{{dDragDwell
types="card"
canDwell=this.canDwell
onDwell=this.open
onDwellEnd=this.close
}}
>
onDwellEnd carries reason ("left" or "drag-ended"), fired (whether
onDwell ran for this candidacy), and droppedHere (whether the drop landed
on a drop target on the element or on one of its descendants — always false
when that subtree registers no target at all) — together the undo predicate: close
again when fired and not droppedHere, and a drop into the element keeps
it open.
The gate runs when a candidacy would start, on every frame while it is
pending, and once more as the dwell fires — but not after: a dwell that opens
a section falsifies its own "only when closed" gate, so after firing only the
rectangle and the types/externalKinds filters decide when the candidacy
ends. canDwell refuses only
on a literal false, after types/externalKinds filtering, exactly like a
target's canDrop — the two may share one function.
Adoption stays the co-located target's job: the dwell has no adopts, and an
adopted browser drag answers to its adoption's declared type in types.
Two limits worth knowing: the rectangle test does not see occlusion — the
dwell answers to the pointer being inside the element's box, not to the box
being visible under the pointer — and an element drag that leaves the window
freezes its last position, so a pending dwell can fire while the pointer is
outside; the drag's eventual end delivers the onDwellEnd that undoes it.
createDragDwell, the engine underneath
The modifier wraps createDragDwell, a plain factory re-exported from the
same module, for a consumer driving a dwell directly when it is coordinate-driven
rather than element-bound: one tracker across many discovered elements, or a
sub-rect rule such as a center-third band.
| Option | Type | Purpose |
|---|---|---|
destroyable |
object |
Bounds the dwell's lifetime; usually the owning component. |
delay |
number |
Milliseconds of hovering before the dwell fires; defaults to 500. |
identity |
(target) => unknown |
Keys candidacy; defaults to the target itself. |
onDwell |
(target) => void |
Fires once, with the latest same-identity target. |
The returned tracker has two members: update(target) reports the current
candidate (null for none), and reset() cancels and clears, ready for the
next drag.
Semantics worth knowing:
- One fire per uninterrupted candidacy. Reporting the same identity again never
pushes the deadline out and never re-fires an already-fired dwell. A new
identity restarts the full delay, and
nullclears, so the same identity can dwell again after leaving and returning. - Silence is not
null. A consumer that stops callingupdate— a monitor frame without input, a pointer holding still — leaves the pending dwell running. Only an explicitnullorreset()cancels it. - A drop or teardown ends the drag without a leave, so wire
reset()into the drop and drag-end paths explicitly. The destructor registered ondestroyablecovers teardown. - When the consumer builds a fresh descriptor per report, pass
identityto name the stable part, or every report reads as a new candidate and the dwell never fires.
The dragAndDrop service
The service holds the in-flight drag as tracked state, so a surface can render from it without wiring a callback.
| Member | Purpose |
|---|---|
currentDrag |
The in-flight element drag, or null. |
currentExternalDrag |
The in-flight external drag, or null. |
isDragging |
Whether either is in flight. |
accepts(types) |
Whether the element drag's type is in types. |
acceptsExternal(kinds) |
Whether the external drag carries one of kinds. |
currentDrag carries type, data, element, and native for an adopted
drag.
<div class={{if (this.dragAndDrop.accepts "row") "is-drop-zone"}}></div>
Use the service to read and the monitor to respond. Rendering from monitor callbacks means keeping state the service already owns.
Note that accepts() and acceptsExternal() treat an empty or missing filter
as matching nothing, because a caller that has not decided should not light up
for every drag. A target's accepts reads the other way: omitting it accepts
everything.
dPointerDrag
Press, move, and release on one element, for a value that tracks the pointer. It transfers nothing and has no targets.
| Argument | Type | Purpose |
|---|---|---|
onDragStart |
(event, info) => boolean | void |
The gesture began. Returning false refuses it. |
onDrag |
(event, info) => void |
The pointer moved. |
onDragEnd |
(event, info) => void |
The gesture finished. |
onDragCancel |
(event, info) => void |
It was interrupted rather than finished. |
threshold |
number |
Pixels of movement before the gesture starts. |
draggingClass |
string |
Class applied to the element while dragging. |
bodyClass |
string |
Class applied to document.body while dragging. |
cancelCommits |
boolean |
Whether a cancel commits the last value. |
stopPropagation |
boolean |
Whether to stop the pointer events propagating. |
touchAction |
TouchActionToken |
The touch-action to apply for the gesture. |
Always handle onDragCancel. A gesture interrupted by the browser otherwise
leaves whatever onDragStart opened still open.
DResizeSeparator
The default for resizing between two regions. dResizeEdge underneath keeps the
value math, the bounds and the keyboard operation; the separator adds the
accessibility contract on top: role="separator", one tab stop, an
aria-orientation, and live aria-valuenow updates.
| Argument | Type | Purpose |
|---|---|---|
label |
string, required |
What the separator is called, already translated. |
axis |
"vertical" | "horizontal" |
"vertical" resizes height. Defaults to "vertical". |
side |
"start" | "end" |
The edge the handle sits on, naming the edge opposite the one that moves. |
measure |
Element or (separator) => Element |
The box being resized. Given this, it measures itself. |
value |
number or () => number |
The current size, when it is not the measured extent. |
min / max |
number or () => number |
Bounds. Override the measurement. |
onResizeStart |
() => void |
The gesture began. |
onResize |
(size) => void |
Throughout the gesture. Preview here. |
onResizeEnd |
(size) => void |
Once at the end. Commit here. |
label is required because a focusable role="separator" with no accessible
name is announced as a bare "splitter", which says nothing about what it
resizes.
<DResizeSeparator
@label={{i18n "sidebar.resize"}}
@measure={{this.panelElement}}
@onResizeEnd={{this.persistWidth}}
@axis="horizontal"
/>
onResizeEnd also fires if the separator is destroyed mid-gesture, so anything
opened at the start still gets closed.
dResizeEdge
The modifier underneath DResizeSeparator. Use it directly only when the
element and its separator semantics are already yours to provide.
| Argument | Type | Purpose |
|---|---|---|
value |
number | null or a function, required |
The current size. |
min / max |
number or a function, required |
Bounds. |
axis |
"vertical" | "horizontal" |
Which dimension is resized. |
side |
"start" | "end" |
Which edge the handle sits on. |
bodyClass |
string |
Class applied to document.body while resizing. |
onResizeStart |
() => void |
The gesture began. |
onResize |
(size, meta) => void |
Throughout the gesture. |
onResizeEnd |
(size, meta) => void |
Once at the end. |
DResizeHandles
Renders the edges and corners of a two-dimensional box resize and reports pointer geometry for the consumer to interpret.
| Argument | Type | Purpose |
|---|---|---|
directions |
BoxDirection[] |
Which of n, ne, e, se, s, sw, w, nw to render. |
handles |
DResizeHandleDescriptor[] |
Explicit handle descriptors with their payloads. |
handleClass / draggingClass |
string |
Classes for the handles and the active gesture. |
measure |
MeasureTarget |
The box the geometry is measured against. |
threshold |
number |
Pixels of movement before a gesture starts. |
cancelCommits |
boolean |
Whether a cancel commits the last value. |
stopPropagation |
boolean |
Whether to stop the pointer events propagating. |
onResizeStart / onResize / onResizeEnd / onResizeCancel |
(payload, dragInfo) => void |
The gesture lifecycle. |
@action
onResize(direction, { delta }) {
if (direction.includes("e")) {
this.width = Math.max(MIN, this.startWidth + delta.x);
}
if (direction.includes("s")) {
this.height = Math.max(MIN, this.startHeight + delta.y);
}
}
Do not give a two-dimensional box resize role="separator". It has no single
aria-valuenow to report, so the role would describe a control that does not
exist.
dSwipe
Reports a discrete directional flick with velocity, for touch surfaces that open or dismiss something.
| Argument | Type | Purpose |
|---|---|---|
onDidStartSwipe |
(state, event) => void |
The gesture began. |
onDidSwipe |
(state) => void |
It progressed. |
onDidEndSwipe |
(state) => void |
It completed. |
onDidCancelSwipe |
(detail) => void |
It was interrupted. |
enabled |
boolean |
Whether the gesture is active. |
lockBody |
boolean |
Whether to lock body scrolling during it. |
Use dPointerDrag instead when a value must track the pointer continuously.
dSwipe answers "which way did they flick", not "where is the pointer now".
dOnResize is not a gesture
dOnResize wraps ResizeObserver: it reports that an element's size changed,
and nobody resizes anything with it. The similar name is the trap. To let
someone change a size, use one of the resize primitives above.
Accessibility
A drag is not a keyboard interaction. Every reorder surface has to pair the
drag with a keyboard path, and every resize between regions should use the
keyboard support already in DResizeSeparator or dResizeEdge.
For a list the user puts in order, use DReorderableList. It pairs the
drag with a move menu and keyboard shortcuts, and it announces each completed
move. See the UI kit guide.
Announce the outcome, not the movement. A completed reorder reports the
item's new visible position once through a11y.announce(). A no-op announces
nothing, and moving the pointer across drop indicators announces nothing.
Do not add aria-dropeffect or aria-grabbed. Both are deprecated and
neither makes a drag operable.
A drag handle is decorative and stays outside the tab order, because a keyboard cannot operate it. The operable controls are the keyboard path beside it.
Keep the two paths' scopes honest. They may legitimately differ: arrows may move within one labeled list while a drag crosses between lists. The keyboard path should follow the list the user is stepping through rather than silently crossing a semantic boundary.
A dwell only shortcuts a pointer drag. Whatever dDragDwell reveals
must stay reachable without it: the surface keeps its ordinary affordance — a
disclosure control, a keyboard path — and the dwell merely saves the pointer a
detour mid-drag.
Testing
Synthetic mouse events do not drive a native drag; they stall. Use the helpers.
In JavaScript tests, from
discourse/tests/helpers/ui-kit/drag-and-drop-helper:
| Helper | Drives |
|---|---|
simulateDrag(source, target, opts) |
A registered source onto a target. |
simulateUnsourcedDrag(source, target, opts) |
Browser-started page content onto a target. |
simulateExternalDrag(target, opts) |
A payload from outside the window. |
externalDragOver / dragOver / startDrag |
A drag left hovering, without dropping. |
dragEvent / dragEventNow |
One event, with or without a frame after it. |
centerOf / textTransfer / fileTransfer |
Coordinates and payloads. |
Every synthetic drag event must carry finite clientX and clientY, which is
what centerOf is for.
A dwell needs no clock control: its delay collapses under test (see
discourse/lib/later), so hover with startDrag and dragOver, then
await settled() for the fire. Edges that must land before the collapsed
delay ride synchronous drop-target hierarchy changes via dragEventNow, which
is why a dwell test co-locates a drop target on the dwell element.
In system specs, use SystemHelpers#drag_and_drop.
dDraggable is deprecated
dDraggable raises discourse.ui-kit.d-draggable when instantiated. Use
dPointerDrag. It is not a drop-in: rename didStartDrag, dragMove and
didEndDrag to onDragStart, onDrag and onDragEnd, and add onDragCancel
so an interrupted gesture still finishes.
Bundle splitting for plugins
By default, every javascript module in a plugin is loaded when Discourse boots. This guide describes the staticModules system, which allows code to be automatically loaded on-demand, thereby improving initial boot times.
This system is currently available to plugins. Theme support will come later
Enable static modules
Add a frontend section to your plugin's about.json:
{
"frontend": {
"staticModules": true
}
}
Two things happen:
Your components, helpers, modifiers and lib code stop being loaded automatically. Instead, they are only loaded when referenced by an import in other code.
Your routes, controllers & templates move into a separate bundle which loads on demand.
Group your routes
With staticModules on, all the routes in your plugin will go into one bundle. That is usually what you want.
To split further, name a bundle on a route. The route and everything under it go in that bundle:
export default function () {
this.route("chat", { bundleName: "chat" }, function () {
this.route("channel", { path: "/c/:title/:id" });
});
this.route("chat-settings", { bundleName: "chat-settings" });
}
Here chat and chat.channel load together, and chat-settings loads separately.
If a map mounts on another route and all of its routes belong in the same bundle, name it once on the map instead:
export default {
resource: "admin.adminPlugins.show",
bundleName: "workflows-admin",
map() {
this.route("workflows", { path: "workflows" });
this.route("workflows-variables", { path: "variables" });
},
};
Manually loading code on-demand
Bundling is automatic for routes only. Anything imported from an initializer or service is loaded eagerly.
When something is large and rarely used (e.g. a modal that opens on a click), you should import it dynamically instead.
export default async function showNewMessageModal(modal) {
const { default: ChatModalNewMessage } =
await import("../components/chat/modal/new-message");
modal.show(ChatModalNewMessage);
}
The path must be a literal, so that the build can find it. To pull in a group of modules with one import, re-export them from a single module and import that.
When lazy-loading components, it normally makes sense to use the DAsyncContent wrapper from ui-kit. For example:
export default class LazyLoader extends Component {
get complexComponent() {
return import("./complex-component");
}
<template>
<DAsyncContent @asyncData={{this.complexComponent}}>
<:content as |componentModule|>
<componentModule.default />
</:content>
</DAsyncContent>
</template>
}
DAsyncContent registers its own test waiter. If your plugin uses qunit tests and you call import() yourself, wrap the promise in waitForPromise so that tests wait for it to load:
import { waitForPromise } from "@ember/test-waiters";
await waitForPromise(import("./my-module"));
Sharing code with other themes and plugins
Enabling staticModules puts a boundary around your plugin. If another plugin or theme needs to import one of your modules, list it under exports in about.json:
{
"frontend": {
"staticModules": true,
"exports": {
"discourse/components/chat-channel": "discourse/components/chat-channel",
"discourse/models/chat-channel": "discourse/models/chat-channel"
}
}
}
The key is the name other plugins import by, and the value is the module behind it. Keeping the two apart means you can move a module without breaking anyone, and you can export the same module under more than one name. Other plugins reach these as discourse/plugins/<your-plugin>/<key>.
Adding a module here will cause it to be eagerly loaded when the application boots, and therefore carries a performance cost. Consider providing an asynchronous-friendly entrypoint for any shared components/libs, so that most code is only loaded on the first use.
Route map syntax
Route maps are statically analyzed during the build, so it's important for them to use simple syntax. Route maps must only include this.route calls, with literal string names and options.
This means no conditionals, no loops, and no values read from settings, services, or imports. If the build cannot parse your map, your plugin will fail to compile.
Compatibility
When using staticModules, you should avoid using these legacy features:
-
templateName/controllerNameoverrides in routes -
Injecting controllers or routes via
@controlleror.lookup() -
Loading components via
.lookup() -
requirejs.entries,require(),define() -
Loading your plugin's modules from other plugins (except those listed in
exports)
Verification
Generated bundles are listed in app/assets/generated/<your-plugin>/manifest.json after a build. Discourse will preload the right bundle when a user lands directly on your page.
Private modules and the `-internals` convention
What -internals means
A folder named -internals holds code that is not public API. It may be renamed, reshaped or deleted without notice or a deprecation cycle. Plugins and themes must never depend on it.
Only the folder's owner imports from it. Everything else, including core code that uses a primitive, imports the owner's public entry instead. The UI kit guide states the same rule for the kit's primitives. Nothing lints this, so the folder name is the only signal.
Where it goes
Put -internals immediately inside the unit that owns the concept. Ownership decides placement, not who imports it.
One named owner. A component or module owns the concept, so its internals sit inside it, and only that owner imports them:
ui-kit/d-reorderable-list/-internals/ owned by DReorderableList
lib/blocks/-internals/ owned by the blocks module
The UI kit guide's "Splitting a large primitive" section describes how a primitive lays out its own -internals folder.
No single owner. Several members share the mechanism, so it goes in the -internals of the smallest namespace that contains every consumer, under a folder named for the mechanism. The owner is then that namespace, and any member of it may import the folder:
ui-kit/-internals/cursor/ shared by dRovingFocus and DReorderableList,
both ui-kit members
lib/-internals/drag-and-drop/ shared by ui-kit modifiers and a service, so no
namespace narrower than the app contains them
lib/ is the app-wide root, which is why a mechanism whose consumers span several namespaces lands there. Reach for it only when nothing narrower fits. A mechanism used only within ui-kit belongs to ui-kit.
Choosing
- Does one component or module own the concept? Then
<owner>/-internals/. - Otherwise, list every consumer and take the smallest namespace containing all of them. Then
<namespace>/-internals/<mechanism>/. - If that namespace is the app itself, it is
lib/-internals/<mechanism>/.
Do not nest -internals under a namespace-scoped lib/. A namespace lib/ (as in form-kit/lib/) holds flat, supported helpers. -internals already says the code is private, so the extra level adds nothing.
Splitting a shared mechanism out
When a second consumer appears for something inside one owner's -internals, move the shared part up rather than importing across. Keep the move behaviour-neutral and let the existing suite prove it. ItemScope and the stepping helpers were lifted out of ui-kit/modifiers/d-roving-focus/ into ui-kit/-internals/cursor/ this way, with the modifier's own tests as the gate.
Developing Discourse Plugins - Part 1 - Create a basic plugin
Building a plugin in Discourse can be really simple, once you learn a couple of quirks. The goal of this post is to create a skeleton plugin and introduce you to the basics.
Your development environment
Make sure you have a development environment of Discourse running on your computer. I recommend you use the appropriate setup guide and come back when you're done.
plugin.rb
:tada: Use https://github.com/discourse/discourse-plugin-skeleton to create a complete discourse plugin skeleton in your plugins directory :tada:
The skeleton is now bundled in discourse core,
rake plugin:create[plugin-name]will create a plugin using the skeleton
When Discourse starts up, it looks in the plugins directory for subdirectories containing a plugin.rb file. The plugin.rb file has two purposes: it is the manifest for your plugin with the required information about your plugin including: its name, contact information and a description. The second purpose is to initialize any ruby code necessary to run your plugin.
In our case, we won't be adding any ruby code but we still need the plugin.rb. Let's create the directory basic-plugin with the file plugin.rb inside it, with the following contents:
basic-plugin/plugin.rb
# name: basic-plugin
# about: A super simple plugin to demonstrate how plugins work
# version: 0.0.1
# authors: Awesome Plugin Developer
# url: https://github.com/yourusername/basic-plugin
Once you've created this file, you should restart your local server and the plugin should be loaded.
An important Gotcha!
If you're used to regular rails development you might notice that plugins aren't quite as nice when it comes to reloading. In general, when you make changes to your plugin, you should Ctrl+c the server to stop it running, then run it again using bin/dev.
My changes weren't picked up! :warning:
Sometimes the cache isn't cleared fully, especially when you create new files or delete old files. To get around this issue, remove your tmp folder and start rails again. On a mac you can do it in one command: rm -rf tmp; bin/dev.
Checking that your plugin was loaded
Once you've restarted your local server, visit the url /admin/plugins (make sure you're logged in as an admin account first, as only admins can see the plugin registry).
If everything worked, you should see your plugin in the list:

Congratulations, you just created your first plugin!
Let's add some Javascript
Right now your plugin doesn't do anything. Let's add a javascript file that will pop up an alert box when discourse loads. This will be super annoying to any user and is not recommended as an actual plugin, but will show how to insert Javascript into our running application.
Create the following file:
plugins/basic-plugin/assets/javascripts/discourse/initializers/alert.js
export default {
name: "alert",
initialize() {
alert("alert boxes are annoying!");
},
};
Now if you restart your local server, you should see "alert boxes are annoying!" appear on the screen. (If you did not, see the "My Changes weren't picked up" heading above).
Let's step through how this worked:
-
Javascript files placed in
assets/javascripts/discourse/initializersare executed automatically when the Discourse application loads up. -
This particular file
exports one object, which has anameand aninitializefunction. -
The
namehas to be unique, so I just called italert. -
The
initialize()function is called when the application loads. In our case, all it does is execute ouralert()code.
You're now an official Discourse plugin developer!
More in the series
Part 1: This topic Part 2: Plugin Outlets Part 3: Site Settings Part 4: git setup Part 5: Admin interfaces Part 6: Acceptance tests Part 7: Publish your plugin
Developing Discourse Plugins - Part 2 - Connect to a plugin outlet
Previous tutorial: https://meta.discourse.org/t/developing-discourse-plugins-part-1-create-a-basic-plugin/30515
Getting Started: Templates
Discourse's client application is written using the Ember.js Javascript framework. Ember uses Templates to generate HTML. There's a great introduction to the templating language at that link, so definitely read it thoroughly.
The Problem: Adding elements to the Discourse User Interface
Many plugins need to add and extend the Discourse web interface. We provide a mechanism to do this called plugin outlets in handlebars templates.
If you browse the Discourse templates, you'll often see the following markup:
<PluginOutlet @name="edit-topic" />
This is declaring a plugin outlet called "edit-topic". It's an extension point in the template that plugin authors can leverage to add their own markup.
:exclamation: If you want to see some of the places where plugin outlets exist, you can run the following command in a POSIX-compliant shell:
git grep -A 1 "<PluginOutlet" -- "*.gjs"
You can also display the plugin outlets on a Discourse site by turning on the Discourse Developer Toolbar. Just type enableDevTools() in the browser console on a Discourse forum and click the plug icon that appears on the left side of the page.
Connecting to a Plugin Outlet
Once you've found the plugin outlet you want to add to, you have to write a connector for it. A connector is a .gjs component whose filename includes connectors/<outlet name> in its path.
For example, if the Discourse template has:
<PluginOutlet @name="evil-trout" />
Then any .gjs files you create in the connectors/evil-trout directory
will automatically be appended. So if you created the file:
plugins/hello/assets/javascripts/discourse/connectors/evil-trout/hello.gjs
With the contents:
<template>
<b>Hello World</b>
</template>
Discourse would insert <b>Hello World</b> at that point in the template.
Note that we called the file hello.gjs -- The filename (as opposed to the directory name) does not matter, but it must be unique across every plugin. It's useful to name it something descriptive of what you are extending it to do. This will make debugging easier in the future.
Troubleshooting
- Double check the name of the connector and make sure it matches the plugin name perfectly.
More information
https://meta.discourse.org/t/using-plugin-outlet-connectors-from-a-theme-or-plugin/32727
More in the series
Part 1: Plugin Basics Part 2: This topic Part 3: Site Settings Part 4: git setup Part 5: Admin interfaces Part 6: Acceptance tests Part 7: Publish your plugin
Developing Discourse Plugins - Part 3 - Add custom site settings
Previous tutorial: https://meta.discourse.org/t/developing-discourse-plugins-part-2-connect-to-a-plugin-outlet/31001
Site Settings
If you visit /admin/site_settings on a Discourse you have administrator capabilities on, you'll see a list of configuration settings. Out of the box, we provide what we think are the best settings for a Discourse install, but we also understand that people want to tweak their installations to make their forum just the way they want it.
Chances are, unless your plugin is very simple, you'll want to add settings that the users of your plugin can change and use to configure functionality. Fortunately, this is quite easy to do!
config/settings.yml
The first thing you'll need to do is create config/settings.yml within your plugin folder. This file will outline all the settings your plugin will need. Here's an example file:
plugins:
awesomeness_enabled:
default: true
client: true
awesomeness_max_volume:
default: 10
client: true
The file needs to be in YAML format. YAML can be quite picky so if Discourse is having trouble loading your settings I suggest you try validating your YAML with a tool like YAMLint.
I'll explain the example file in detail. The top level is plugins and that tells Discourse that we want these settings to appear under "Plugins" in the site settings.
After that, there are two settings declared, awesomeness_enabled and awesomeness_max_volume. Discourse reasons the type of the settings from the default, so awesomeness_enabled is a boolean and awesomeness_max_volume is a number.
The client: true is important to understand. Discourse is made up of two major applications, the server side API written in Ruby on Rails, and the client side application written in Ember.js. By default, we don't expose settings to the Ember.js client app unless you add client: true. We do this because some settings are private like API keys and should not be sent to end users. Also, if we sent every setting to the client that could be a lot for end users to download!
In our example case, we want both of those settings to be accessible in the JavaScript world as well as the server side world.
An important second step
Before you can use your newly added site settings, you need to add translations for them. Since Discourse supports many languages, any text you add will have to support being translated into other languages.
Let's create the translations for our settings in English:
config/locales/server.en.yml
en:
site_settings:
awesomeness_enabled: "Is this plugin awesome?"
awesomeness_max_volume: "What is the maximum volume possible?"
The labels we added in that file will be displayed in the admin section. It's a good idea to be clear as possible as to what the setting accomplishes.
Declaring the setting as the 'enabled setting'
Now that we have our site setting, we should tell Discourse that it's the one that turns our features on and off.
Open your plugin.rb file and add the following line below the metadata comments:
enabled_site_setting :awesomeness_enabled
Make sure to start all of your other settings with "awesomeness_" in order for the settings button at /admin/plugins to work correctly.
Accessing your new Settings
First, you'll need to restart your development server to have the settings take effect. Once you do that, the settings should be available to your server and client side code.
We automatically inject the site settings into most JavaScript objects, so if you are declaring a Component, Controller, Route, View or Model you should be able to access the site setting by simply using this.siteSettings.awesomeness_enabled. In most handlebars templates you should also be able to say {{siteSettings.awesomeness_enabled}} and the setting value will be displayed.
We haven't covered much Ruby stuff in this series yet, but if you want to access the site settings in the Ruby application you can do so via: SiteSetting.awesomeness_enabled
Now go forth and add custom settings to your plugins!
More in the series
Part 1: Plugin Basics Part 2: Plugin Outlets Part 3: This topic Part 4: git setup Part 5: Admin interfaces Part 6: Acceptance tests Part 7: Publish your plugin
Developing Discourse Plugins - Part 4 - Setup git
Previous tutorial: https://meta.discourse.org/t/developing-discourse-plugins-part-3-add-custom-site-settings/31115
Now that your plugin is getting more sophisticated, it's time to get more sophisticated about how you develop it.
We suggest that you use git as version control for your plugin. We also recommend that you use github to share your plugin code with others.
Creating your Git Repo
Once you've created your Github account, visit this url to create a new repository. You can call it anything you want, but generally something that starts with discourse- is good. Make sure the repository is public. Here's how my screen looked:

Creating your local working folder
At this point I create a local directory on my computer to hold the plugin. I usually put mine in ~/code but you can put it anywhere you like on your computer:
mkdir -p ~/code/discourse-plugin-test
cd ~/code/discourse-plugin-test
Now let's follow the instructions from github to initialize the repo with a README:
echo "# discourse-plugin-test" >> README.md
git init
git add README.md
git commit -m "first commit"
git remote add origin git@github.com:eviltrout/discourse-plugin-test.git
git push -u origin main
Finally, create a plugin.rb file for your plugin as explained in part 1. For this example I just created a dummy one:
plugin.rb
# name: discourse-plugin-test
# about: Shows how to set up Git
# version: 0.0.1
# authors: Robin Ward
Creating a symlink
Because you followed our developer guide you should have a copy of discourse checked out on your computer somewhere. I checked mine out to ~/code/discourse but again you could have put it anywhere and this should still work if you adjust the following code accordingly:
cd ~/code/discourse/plugins
ln -s ~/code/discourse-plugin-test .
The above code created a symbolic link between your discourse code and your plugin folder. Restart your development server and you should find your plugin is working!
The beauty of this setup is you can just check your plugin into github and not worry about the discourse codebase it lives inside. Your changes will be isolated to the plugin itself. If you need to edit discourse's code you still can, but git will track the changes separately!
I recommend using one editor window for your plugin codebase and one for Discourse itself. It is easier when you think of them as two different things.
More in the series
Part 1: Plugin Basics Part 2: Plugin Outlets Part 3: Site Settings Part 4: This topic Part 5: Admin interfaces Part 6: Acceptance tests Part 7: Publish your plugin
Developing Discourse Plugins - Part 5 - Add an admin interface
Previous tutorial: https://meta.discourse.org/t/developing-discourse-plugins-part-4-setup-git/31272
Sometimes site settings aren't enough of an admin interface for your plugin to work the way you want. For example, if you install the discourse-akismet plugin, you might have noticed that it adds a navigation item to the admin plugins section in of your Discourse:

In this tutorial we'll show you how to add an admin interface for your plugin. I'm going to call my plugin purple-tentacle, in honor of one of my favorite computer games. Seriously, I really love that game!
Setting up the Admin Route
Let's start by adding a plugin.rb like we've done in previous parts of the tutorial.
plugin.rb
# name: purple-tentacle
# about: A sample plugin showing how to add a plugin route
# version: 0.1
# authors: Robin Ward
# url: https://github.com/discourse/purple-tentacle
add_admin_route 'purple_tentacle.title', 'purple-tentacle'
Discourse::Application.routes.append do
get '/admin/plugins/purple-tentacle' => 'admin/plugins#index', constraints: StaffConstraint.new
end
The add_admin_route line tells Discourse that this plugin will need a link on the /admin/plugins page. Its title will be purple_tentacle.title from our i18n translations file and it will link to the purple-tentacle route.
The lines below that set up the server side mapping of routes for our plugin. One assumption Discourse makes is that almost every route on the front end has a server side route that provides data. For this example plugin we actually don't need any data from the back end, but we need to tell Discourse to serve up something in case the user visits /admin/plugins/purple-tentacle directly. This line just tells it: 'hey if the user visits that URL directly on the server side, serve the default plugins content!'
(If this is confusing don't worry too much, we'll come back to it in a future tutorial when we handle server side actions.)
Next, we'll add a template that will be displayed when the user visits the /admin/plugins/purple-tentacle path. It will just be a button that shows an animated gif of purple tentacle when the user clicks a button:
assets/javascripts/discourse/templates/admin/plugins-purple-tentacle.gjs
import DButton from "discourse/components/d-button";
<template>
{{#if @controller.tentacleVisible}}
<div class="tentacle">
<img src="https://eviltrout.com/images/tentacle.gif" />
</div>
{{/if}}
<div class="buttons">
<DButton
@label="purple_tentacle.show"
@action={{@controller.showTentacle}}
@icon="eye"
@id="show-tentacle"
/>
</div>
</template>
If you've learned the basics of handlebars the template should be pretty simple to understand. The <DButton /> is a component in Discourse we use for showing a button with a label and icon.
To wire up our new template we need to create a route map:
assets/javascripts/discourse/purple-tentacle-route-map.js
export default {
resource: "admin.adminPlugins",
path: "/plugins",
map() {
this.route("purple-tentacle");
},
};
A route map is something we added to discourse to make it so that plugins could add routes to the ember application. The syntax within map() is very similar to Ember's router. In this case our route map is very simple, it just declares one route called purple-tentacle under /admin/plugins.
Finally, let's add our translation strings:
config/locales/client.en.yml
en:
js:
purple_tentacle:
title: "Purple Tentacle"
show: "Show Purple Tentacle"
If you restart your development server, you should be able to visit /admin/plugins and you'll see our link! If you click it, you'll see the button to show our purple tentacle:

Unfortunately, when you click the button, nothing happens :(
If you look at your developer console, you should see an error that provides a clue to why this is:
Uncaught Error: Nothing handled the action 'showTentacle'`
Ah yes, the reason is in our template we are depending on a couple of things:
- That when the user clicks the button,
showTentaclewill be called on the controller. showTentacleshould set the propertytentacleVisibletotrueso that the image shows up.
If you haven't read the Ember Guides on Controllers now is a good time to do so, because we'll implement a controller for our purple-tentacle template that will handle this logic.
Create the following file:
assets/javascripts/discourse/controllers/admin-plugins-purple-tentacle.js
import Controller from "@ember/controller";
import { action } from "@ember/object";
import { tracked } from "@glimmer/tracking";
export default class AdminPluginsPurpleTentacleController extends Controller {
@tracked tentacleVisible = false;
@action
showTentacle() {
this.tentacleVisible = true;
}
}
And now when we refresh our page, clicking the button shows our animated character!

I'll leave it as an extra exercise to the reader to add a button that hides the tentacle when clicked :smile:
If you are having trouble getting your version of this plugin working, I've pushed it to github.
More in the series
Part 1: Plugin Basics Part 2: Plugin Outlets Part 3: Site Settings Part 4: git setup Part 5: This topic Part 6: Acceptance tests Part 7: Publish your plugin
Developing Discourse Plugins - Part 6 - Add acceptance tests
Previous tutorial: https://meta.discourse.org/t/developing-discourse-plugins-part-5-add-an-admin-interface/31761
Did you know that Discourse has two large test suites for its code base? On the server side, our Ruby code has a test suite that uses rspec. For the browser application, we have a qunit suite that has ember-testing included.
Assuming you have a development environment set up, if you visit the http://localhost:3000/tests URL you will start running the JavaScript test suite in your browser. One fun aspect is that you can see it testing the application in a miniature window in the bottom right corner:

The Discourse application is built with a lot of tests that will begin running when you visit the /tests URL. So it may be helpful to filter your tests by the plugin you are working on. You can do that in the interface by clicking Plugin dropdown and selecting your plugin:

Adding an Acceptance Test in your Plugin
First, make sure you have the latest version of Discourse checked out. Being able to run Acceptance tests from plugins is a relatively new feature, and if you don't check out the latest version your tests won't show up.
For this article I am going to write an acceptance test for the purple-tentacle plugin that we authored in part 5 of this series.
Adding an acceptance test is as easy as adding one file to your plugin. Create the following:
test/javascripts/acceptance/purple-tentacle-test.js
import { acceptance, exists } from "discourse/tests/helpers/qunit-helpers";
import { click, visit } from "@ember/test-helpers";
import { test } from "qunit";
acceptance("Purple Tentacle", function (needs) {
needs.settings({ purple_tentacle_enabled: true });
needs.user();
test("Purple tentacle button works", async function (assert) {
await visit("/admin/plugins/purple-tentacle");
assert.ok(exists("#show-tentacle"), "it shows the purple tentacle button");
assert.ok(!exists(".tentacle"), "the tentacle is not shown yet");
await click("#show-tentacle");
assert.ok(exists(".tentacle"), "the tentacle wants to rule the world!");
});
});
I tried to write the test in a way that is clear, but it might be a little confusing if you've never written an acceptance test before. I highly recommend that you read the Ember docs on acceptance testing as they have a lot of great information.
In each test we write, we need to assert something. In our test, we want to make a few assertions to check whether the tentacle is hidden initially and then shown only after clicking the button.
We want to define a set of actions to be taken before an assertion is made. To do that we use the await keyword. By using that keyword, we wait for the execution of each asynchronous helper to finish first.
Our first action of importance is: await visit("/admin/plugins/purple-tentacle");. This tells our test to navigate to that URL in our application. That URL was the one that displays the tentacle.
After visiting the page where the purple tentacle button appears, we want to check if we can see the button on the page exists and that the tentacle image doesn't exist yet.
That is done by the following assertions:
assert.ok(exists("#show-tentacle"), "it shows the purple tentacle button");
assert.ok(!exists(".tentacle"), "the tentacle is not shown yet");
P.S. the previous version of the purple-tentacle plugin didn't have the #show-tentacle element id in the handlebars template. Check out the latest version to follow along!
Once those tests pass it's time to test the interaction.
The next command is await click('#show-tentacle'); which tells our testing framework that we want to click the button and show the tentacle.
After we simulate a click on the button, we can check whether the tentacle appears by asserting:
assert.ok(exists(".tentacle"), "the tentacle wants to rule the world!");
Not too bad is it? You can try the test yourself by visiting http://localhost:3000/tests?qunit_single_plugin=purple-tentacle&qunit_skip_core=1 on your development machine. You should very quickly see the purple tentacle appear and all tests will pass.
If you want to run the plugin qunit tests on the command line using PhantomJS, you can run
rake plugin:qunit['purple-tentacle']
(where purple-tentacle is the folder name of your plugin)
Debugging your tests
As you write your plugins, your tests can help you identify issues in your plugin. When you're developing your tests or if you make changes to your plugin's code, the tests may fail. To help understand why, Ember has some nice helpers: pauseTest() and resumeTest().
To make use of them, add await pauseTest() within your test code where you would like the test to pause. Now, when you run your test in the browser, the test will automatically pause at the point you added `pauseTest(). This will give you a chance to inspect the page or view any errors to help debug for issues.
Where to go from here
I hate to sound like a broken record but the Ember documentation on testing is excellent. You might also want to see how Discourse tests various functionality by browsing the tests in our javascript tests directory. We have quite a few examples in there you can learn from.
Happy testing!
More in the series
Part 1: Plugin Basics Part 2: Plugin Outlets Part 3: Site Settings Part 4: git setup Part 5: Admin interfaces Part 6: This topic Part 7: Publish your plugin
Developing Discourse Plugins - Part 7 - Publish your plugin
Previous tutorial: https://meta.discourse.org/t/developing-discourse-plugins-part-6-add-acceptance-tests/32619
You've created your plugin, you've uploaded it to GitHub and you've added tests. Great! Problem is, no one else knows about it.
Documenting your plugin
All plugins require good documentation. Users need to know what the plugin does, how to install it, important settings/configuration changes needed, and how to use it. Plugins should be documented in two different locations: the README.md file within your git repo, and a dedicated topic in the #customization:plugin category.
GitHub Documentation
The README.md file on GitHub is important as it's shown be default when a user visits your repo. At minimum, your readme should include the plugin's title and a short description. A more complete readme will also include installation instructions, a more detailed description, basic usage instructions, license, and (if applicable) screenshots. If additional instructions are included in your plugin's Meta topic, be sure to include a link to the topic.
Example of a minimally documented plugin: Discourse Data Explorer Examples of plugins with more complete documentation: Discourse Solved, Discourse OAuth2 Basic, and Discourse Ads.
[details="Sample plugin README template"]
Be sure to update text surrounded by <> with your plugin specifics.
## <Plugin Title>
<Plugin Description>
## Installation
Follow the [plugin installation guide](https://meta.discourse.org/t/install-a-plugin/19157).
## How to use
<Explain how to enable the plugin, necessary configuration steps, and how to use it>
## Screenshots
<Include screenshots if necessary to demonstrate your plugin's usage>
## Read More
<Include a link to your Meta topic if more information is detailed there>
## License
<Note your code license, most Discourse plugins use MIT>
[/details]
Meta Documentation
Where GitHub documentation tends to be short and "to the point", Meta documentation is where you get to share the full details of your plugin, and convince users why they should use it. At minimum, your Meta topic should include a short description of the plugin, and a link to the plugin's repo on GitHub (so users can install it). More complete documentation will also include a detailed description include example use cases, detailed usage instructions, documentation of all settings and configuration options, and screenshots. Screenshots are key as users want to see what your plugin does, not just read about it. A plugin adding an auth provider likely needs only 1 screenshot, where a plugin that adds a new interface, or makes major changes should have more quite a few.
Meta documentation tends to be more personal than GitHub, each plugin author has their own documentation style. Whatever style you choose, ensure it is clear, easy to read, and organized. Use headers as appropriate, annotate screenshots to explain complex interactions, and be sure to be consistent in your formatting. Avoid the temptation to write a single giant paragraph.
Updating your documentation
After you write your initial documentation, it's important to keep it up to date.
Added a new feature to the plugin? Be sure to save some time to document it. Answered the same question multiple times? Consider adding a FAQ section to your Meta topic. Known issue that is complicated to fix? Detail it in your topic so users know what to expect.
Supporting your plugin
After you publish your plugin and it's documentation, site admins who find it useful may install it on their site and start to use it. This usage requires ongoing support by the plugin developer. You'll want to answer questions that site admins have, helping them to use your plugin. Something that made perfect sense to you may be unclear to others, so you'll want to update the code and/or documentation to clarify it. You may receive feature requests, which you'll have to decide whether or not to add.
Lastly, Discourse itself is under constant development. While your plugin may work perfectly today, tomorrow something might change that breaks it. Be sure to stay up to date on Discourse development so that you can update your plugin to support the latest version of Discourse when a change affects your plugin.
More in the series
Part 1: Plugin Basics Part 2: Plugin Outlets Part 3: Site Settings Part 4: git setup Part 5: Admin interfaces Part 6: Acceptance tests Part 7: This topic
Add a new locale from plugin
Usually the best way to add a new language to Discourse is by creating a pull request as described in "https://meta.discourse.org/t/how-to-add-a-new-language/14970".
The following guide is for you, if you want to use a language that can't be added to core right now.
Adding a locale that extends an existing locale
Let's say you want to add a Spanish locale for Mexico (es_MX). Discourse already ships a Spanish locale (es), so this is quite easy.
Your plugin's directory structure should look like this:
custom-locales
├── assets
│ └── locales
│ └── es_MX.js.erb
├── config
│ └── locales
│ ├── client.es_MX.yml
│ └── server.es_MX.yml
└── plugin.rb
plugin.rb
You add the new locale by calling register_locale.
-
The first parameter is required and must be the language code.
-
name(the English name of the locale) andnativeName(the name shown in the user interface) can be omitted, if the language code exists innames.yml. -
fallbackLocaleis the language code that should be used as fall back for missing translations and pluralization rules.
# name: custom-locales
# about: An example plugin for adding new locales.
# version: 1.0
register_locale("es_MX", name: "Spanish (Mexico)", nativeName: "Español (México)", fallbackLocale: "es")
<locale>.js.erb
The content of assets/locales/es_MX.js.erb is quite simple -- make sure you replace the locale code within that file with the one you need.
//= require locales/i18n
<%= JsLocaleHelper.output_locale(:es_MX) %>
Translation files in config/locales
The files config/locales/client.es_MX.yml and config/locales/server.es_MX.yml contain the translations you want to use. You don't need to provide all translations needed by Discourse. The fallbackLocale and English will be used in case of missing translations.
Adding a new locale
Adding a completely new locale, that shouldn't fall back to an existing locale, is a little bit more work.
Your plugin's directory structure should look like this:
custom-locales
├── assets
│ └── locales
│ └── foo.js.erb
├── config
│ └── locales
│ ├── client.foo.yml
│ └── server.foo.yml
├── lib
│ └── javascripts
│ └── locale
│ ├── message_format
│ │ └── foo.js
│ └── moment_js
│ └── foo.js
│ └── moment_js_timezones
│ └── foo.js
└── plugin.rb
plugin.rb
You add the new locale by calling register_locale.
-
The first parameter is required and must be the language code.
-
name(the English name of the locale) andnativeName(the name shown in the user interface) can be omitted, if the language code exists innames.yml, otherwise you should set them too. -
pluraldescribes the language's pluralization rules. Take a look atplurals.rbfor inspiration. Also, you can omit this parameter ifplurals.rbalready contains your language code.
# name: custom-locales
# about: An example plugin for adding new locales.
# version: 1.0
register_locale(
"foo",
name: "Foo",
nativeName: "Foo Bar",
plural: {
keys: [:one, :other],
rule: lambda { |n| n == 1 ? :one : :other }
}
)
<locale>.js.erb
The content of assets/locales/foo.js.erb is quite simple -- make sure you replace the locale code within that file with the one you need.
//= require locales/i18n
<%= JsLocaleHelper.output_locale(:foo) %>
Translation files in config/locales
The files config/locales/client.foo.yml and config/locales/server.foo.yml contain the translations you want to use. You don't need to provide all translations needed by Discourse. The English translations will be used in case of missing translations.
FAQ
Discourse doesn't load my locale. What is wrong?
Make sure that the plugin is enabled, that you registered the locale correctly and that all required files exist and have the correct language code in the filename. Discourse doesn't load locales when it detects missing files.
Add a new provider to discourse-chat-integration
discourse-chat-integration abstracts away the boilerplate for integrating Discourse with external chatroom systems. There are three features which a provider implementation can support: Notifications, Slash Commands and Transcripts.
There are two ways you can add a provider: in your own plugin, or by submitting a pull request to discourse-chat-integration. This post will detail the latter, but most of the information will work in either scenario.
This post gives a general overview of how things work, but the best way to understand it is to read the code for existing providers. I have tried to document unusual bits with comments, and am happy to answer questions in this topic :slight_smile:
Adding Notification Support :bell:
- In the
Providerfolder, create a new folder with the name of your chatroom system: e.g.hipchat - Create a new ruby file, following the format
hipchat_provider.rb - Within that, define a new module inside
DiscourseChat::Provider. The name of the module must end inProviderfor it to be loaded correctly. Your module must define three constants:PROVIDER_NAME: A string used to reference your provider internally. It shouldn't contain any whitespace. It will likely be the same as the folder namePROVIDER_ENABLED_SETTING: A symbol referencing a site setting used to enable/disable this provider. Make sure you define it in the plugin'ssettings.ymlfile.CHANNEL_PARAMETERS: An array of hashes defining what data your provider needs about each channel. This might be a URL, a username, or some kind of 'Channel ID'. Each hash should specify the parameterskey: the key you will use to reference the data laterregex: the regular expression used to validate the user-provided value. This is checked both on the client and the server. For example, to only allow non-whitespace characters, you could use^\S+$unique: (optional) set this to true to stop users creating more than one channel with the same value for this parameterhidden: (optional) set this to true to hide this parameter from the list of channels. It will always be shown in the "Edit Channel" modal.
[quote] :information_source: If your channel parameters are not 'human-readable', you may want to add a "Name" parameter, for the user to enter a more useful channel identifier for their reference. See the hipchat provider for an example of this. [/quote]
- Your module must also define the function
self.trigger_notification(post, channel). Inside this function you should write code to actually send the notification to your chat system. This will vary based on the provider, but will generally consist of sending aRESTfulrequest to their API. Try looking at the implementations of existing providers to help you. - Make sure to handle errors returned by your provider's API. You should raise a
DiscourseChat::ProviderError, and optionally specify a (client side) translation key which gives information about the error. This will be displayed in the admin user interface against the current channel. You can specify additional objects in theinfohash, which will be included in the site's logs. - To make sure Discourse loads your ruby file, you should add a
require_relativeline to the bottom of provider.rb.
You should end up with something that looks like this:
module DiscourseChat::Provider::HipchatProvider
# This should be unique, and without whitespace
PROVIDER_NAME = "hipchat".freeze
# Make sure the referenced setting has been added to settings.yml as a boolean
PROVIDER_ENABLED_SETTING = :chat_integration_hipchat_enabled
CHANNEL_PARAMETERS = [
{ key: "name", regex: '^\S+' }, # Must not start with a space
{ key: "webhook_url", regex: 'hipchat\.com', unique: true, hidden: true }, # Must contain hipchat.com
{ key: "color", regex: '^(yellow|green|red|purple|gray|random)$' } # Must be one of these colors
]
def self.trigger_notification(post, channel)
# Access the user-defined channel parameters like this
webhook_url = channel.data['webhook_url']
color = channel.data['color']
# The "post" object can be used to get the information to send
title = post.topic.title
link_url = post.full_url
# Post.excerpt has a number of options you can use to format nicely before sending to your chat system
# Most of the time, you'll want remap_emojis to convert discourse emojis to unicode
excerpt = post.excerpt(400, text_entities: true, strip_links: true, remap_emoji: true)
# Make an API request to your API provider
# This might be useful: http://www.rubyinside.com/nethttp-cheat-sheet-2940.html
# Parse the response to your API request, and raise any errors using the DiscourseChat::ProviderError
error_key = 'chat_integration.provider.hipchat.invalid_color'
raise ::DiscourseChat::ProviderError.new info: {error_key: error_key}
end
end
Translation keys
In client.en.yml, you should specify a title for your provider, titles & help information for any parameters, and any error keys that you're using when raising a DiscourseChat::ProviderError. For example:
en:
js:
chat_integration:
provider:
hipchat:
title: "HipChat"
param:
name:
title: "Name"
help: "A name to describe the channel. It is not used for the connection to HipChat."
webhook_url:
title: "Webhook URL"
help: "The webhook URL created in your HipChat integration"
color:
title: "Color"
help: "The color of the message in HipChat. Must be one of yellow,green,red,purple,gray,random"
errors:
invalid_color: "The API rejected the color you selected"
You should make sure to also provide translations for any site settings you have created. There is no need to define any server-side translations, unless you're using them in your self.trigger_notification implementation
Adding Slash Command Support :speech_balloon:
So, you've got notifications working but you want to be able to control the rules from inside your chat system. The common way to do that is using "slash commands". Different chat systems implement them slightly differently, but the general idea is that you have keywords for "actions", and can then supply parameters afterwards. For example, in Slack we have
/discourse watch support tag:help
In order to do this you need to find out what method your chat system provides for communicating using Slash commands. Most providers have the ability to "register" a slash command, and then enter a URL which will receive a POST request whenever your slash command is invoked.
discourse-chat-integration provides a few systems to help you with this. To register a new URL on the forum under /chat-integration/, you should create a new file in your provider's folder with a name like telegram_command_controller.rb.
module DiscourseChat::Provider::TelegramProvider
class TelegramCommandController < DiscourseChat::Provider::HookController
requires_provider ::DiscourseChat::Provider::TelegramProvider::PROVIDER_NAME
before_filter :telegram_token_valid?, only: :command
skip_before_filter :check_xhr,
:preload_json,
:verify_authenticity_token,
:redirect_to_login_if_required,
only: :command
def command
# Work out which channel the commands are coming from
chat_id = params['message']['chat']['id']
provider = DiscourseChat::Provider::TelegramProvider::PROVIDER_NAME
channel = DiscourseChat::Channel.with_provider(provider).with_data_value('chat_id', chat_id).first
if channel.exists?
# This is something like "watch support tag:hello"
text = params['message']['text']
# Split each parameter into its own item
tokens = message['text'].split(" ")
# Use the helper method to process the command
response = ::DiscourseChat::Helper.process_command(channel, tokens)
# You can call methods from your provider module to send
# a response back
DiscourseChat::Provider::TelegramProvider.sendMessage(message)
end
# Always give telegram a success message, otherwise we'll stop receiving webhooks
data = {
success: true
}
render json: data
end
def telegram_token_valid?
params.require(:token)
if SiteSetting.chat_integration_telegram_secret.blank? ||
SiteSetting.chat_integration_telegram_secret != params[:token]
raise Discourse::InvalidAccess.new
end
end
end
class TelegramEngine < ::Rails::Engine
engine_name DiscourseChat::PLUGIN_NAME + "-telegram"
isolate_namespace DiscourseChat::Provider::TelegramProvider
end
TelegramEngine.routes.draw do
post "command/:token" => "telegram_command#command"
end
end
Some notable things:
- Your controller should inherit from
DiscourseChat::Provider::HookController. This makes sure it is disabled when the plugin is disabled - You need to verify the authenticity of the request somehow. This will vary per-provider, but a
before_filternormally works well. - The
skip_before_filteritems are required for your endpoint to work when called by your provider. - You should define a
Rails::Engineinside your provider's module. It should end withEngine, and will be automatically loaded under the URL/chat-integration/<provider name> - There is a helper method
DiscourseChat::Helper.process_command(channel, tokens)which deals with the actual logic of creating/editing rules. You simply pass it the channel object and an array of strings
Language Strings
To use the Helper.process_command method, you need to define these language strings for your provider in server.en.yml:
mattermost:
status:
header: |
*Rules for this channel*
(if multiple rules match a post, the topmost rule is executed)
no_rules: "There are no rules set up for this channel. Run `/discourse help` for instructions."
rule_string: "*%{index})* *%{filter}* posts in *%{category}*"
rule_string_tags_suffix: " with tags: *%{tags}*"
parse_error: "Sorry, I didn't understand that. Run `/discourse help` for instructions."
create:
created: "Rule created successfully"
updated: "Rule updated successfully"
error: "Sorry, an error occured while creating that rule."
delete:
success: "Rule deleted successfully"
error: "Sorry, an error occured while deleting that rule. Run `/discourse status` for a list of rules."
not_found:
tag: "The *%{name}* tag cannot be found."
category: "The *%{name}* category cannot be found. Available categories: *%{list}*"
help: |
*New rule:* `/discourse [watch|follow|mute] [category] [tag:name]`
(you must specify a rule type and at least one category or tag)
- *watch* – notify this channel for new topics and new replies
- *follow* – notify this channel for new topics
- *mute* – block notifications to this channel
*Remove rule:* `/discourse remove [rule number]`
(`[rule number]` can be found by running `/discourse status`)
*List rules:* `/discourse status`
*Help:* `/discourse help`
Adding Transcript Posting Support :scroll:
Posting transcripts is the hardest part of implementing a provider for discourse-chat-integration. Very little logic is available for sharing between providers, because of huge differences in provider APIs.
As more providers are implemented, it may be possible to abstract behavior so that it can be shared. PRs for such abstraction would be welcome alongside transcript support for more providers.
There is one helper method, which handles storing the transcript on the server ready for the user to write a draft. The DiscourseChat::Helper.save_transcript(text) method takes a string (containing the body of a post), and returns a secret string. The transcript will be stored on the server for 1 hour.
https://github.com/discourse/discourse-chat-integration/blob/4f9ad4efefa0270e00a24e711dd65ff1928b82cf/app/helpers/helper.rb#L192-L198
To let the user access the transcript, you should give them a link in the format
link = "#{Discourse.base_url}/chat-transcript/#{secret}"
The best way to go about implementing transcript support for a new provider is to look at the Slack implementation:
https://github.com/discourse/discourse/blob/main/plugins/discourse-chat-integration/lib/discourse_chat_integration/provider/slack/slack_transcript.rb
Repackaging a markdown-it extension as a Discourse plugin
markdown.it the CommonMark engine used by Discourse has a wide array of plugins
Header anchors, definition lists, smart-arrows and the list goes on and on.
First a warning
:warning: CommonMark is meant to be… Common. The further you move away from the spec the less Common your Markdown becomes. It can make it harder to port to other solutions and, if not careful, cause internal inconsistencies in parsing. Before repackaging anything be sure to answer the question "do I really want to repackage this?"
I just finished repackaging https://meta.discourse.org/t/discourse-footnote/84533 and have some lessons to share about how to do this right.
Steps for the lazy
If you are lazy and just want to get started the easiest way is just to fork footnotes and swap files and var names. I was pretty careful to make sure it follows best practices so you should have solid example.
Opening moves, a minimal repackage
From what I can tell the majority of markdown.it plugins are shipped as vanilla js files. In many cases plugins are simply shipped as a single js file, like this: markdown-it-mark.js.
Ideally you want to leave the original intact, that means you can simply copy an updated version of the file into your plugin without needing to mess with the existing plugin.
The first problem you will hit is that you have to teach your plugin to load this JavaScript on the server cause the Markdown engine runs on the server as well. To do though you can simply copy the file as is into assets/javascripts/vendor/original-plugin.js then in your plugin.rb file you would add:
# this teaches our markdown engine to load your vanilla js file
register_asset "javascripts/vendor/original-plugin.js", :vendored_pretty_text
Once you have the actual body of the plugin included you need to teach our pipeline how to load and initialize it:
Create a file called assets/javascripts/lib/discourse-markdown/your-extension.js
This file will be auto loaded cause it ends with .js AND in the discourse-markdown directory.
A simple example can be:
export function setup(helper) {
// this allows you only to load your extension if a site setting is enabled
helper.registerOptions((opts, siteSettings) => {
opts.features["your-extension"] = !!siteSettings.enable_my_plugin;
});
// whitelist any attributes that you need to support,
// otherwise our sanitizer will strip them
helper.whiteList(["div.amazingness"]);
// you can also do fancy stuff like this
helper.whiteList({
custom(tag, name, value) {
if ((tag === "a" || tag === "li") && name === "id") {
return !!value.match(/^fn(ref)?\d+$/);
}
},
});
// finally this is the magic that you would use to register the extension in
// our pipeline. whateverGlobal is the name of global the plugin exposes
// it takes in a single (md) var that is then used to amend the pipeline
helper.registerPlugin(window.whateverGlobal);
}
Always be testing
Discourse's bin/rake autospec is plugin aware :innocent:
This means that when you add the file spec/pretty_text_spec.rb every time you save it the plugin test file will run.
I use this extensively cause it makes work so much faster.
Say you added a plugin that changes every number in a post to 8 circle, you can call it discourse-magic-8-ball.
Here is how I would structure the tests:
require "rails_helper"
describe PrettyText do
it "can be disabled" do
SiteSetting.enable_magic_8_ball = false
markdown = <<~MD
1 thing
MD
html = <<~HTML
<p>1 thing</p>
HTML
cooked = PrettyText.cook markdown.strip
expect(cooked).to eq(html.strip)
end
it "supports magic 8 ball" do
markdown = <<~MD
1 thing
MD
html = <<~HTML
<p>8 circle thing</p>
HTML
cooked = PrettyText.cook markdown.strip
expect(cooked).to eq(html.strip)
end
end
You may need to "decorate posts"
In some cases plugins work best when they add extra "dynamic" features to your posts. Examples of that are the poll plugin or the footnotes plugin that adds a "..." that dynamically shows a tooltip.
if you need to "decorate" posts add assets/javascripts/api-initializers/your-initializer.js
import { apiInitializer } from "discourse/lib/api";
export default apiInitializer((api) => {
const siteSettings = api.container.lookup("service:site-settings");
if (!siteSettings.enable_magic_8_ball) {
return;
}
api.decorateCookedElement((elem) => {
// your amazing magic goes here
});
});
You may need to "post process" the posts
In some cases you may need to "post process" posts, the markdown rendering engine, by-design is not aware of certain information like, for example post_id. In some cases you may want server side access to the post and "cooked" html, this can let you do things like trigger background jobs, synchronized custom fields or "correct" auto generated HTML.
For footnotes I needed a distinct id for each footnote, which meant I needed access to post_id, so I was forced to make changes to the HTML in the post processor (which runs in sidekiq)
To hook in you would add the following to your plugin.rb file:
DiscourseEvent.on(:before_post_process_cooked) do |doc, post|
doc.css("a.always-bing").each do |a|
# this should always go to bing
a["href"] = "https://bing.com"
end
end
You may need some custom CSS
If you want to ship custom css, be sure to register the file in plugin.rb
Add your css to assets/stylesheets/magic.scss and then run
register_asset "stylesheets/magic.scss"
Remember that we "auto-reload" changes so you can amend your plugin CSS and see changes on the fly in development.
Good luck with your repackaging adventures :four_leaf_clover:
Structuring a plugin for Rails autoloading
Many plugins include lots of class definitions inside plugin.rb, or use require_relative to load ruby files. That works, but it comes with some disadvantages:
- No auto-reloading of changes in development. Any changes require a full server restart
- Getting the
requirecalls in the right order can be painful - If they are
require'd outside theafter_initializeblock, then other autoloaded classes/modules may not be available
There is a solution! Plugins can lean on the standard Rails autoloading system. For new plugins, everything you need is defined in the plugin-skeleton. This topic describes how to adapt an existing plugin and extend the configuration.
1. Define a module and a Rails::Engine for your plugin
In plugin.rb, define a module for your plugin with a unique PLUGIN_NAME, and add a require_relative line to load the engine file we're about to create.
# name: my-plugin-name
# ...
module ::MyPluginModule
PLUGIN_NAME = "my-plugin-name"
end
require_relative "lib/my_plugin_module/engine"
Now create {plugin}/lib/my_plugin_module/engine.rb:
module ::MyPluginModule
class Engine < ::Rails::Engine
engine_name PLUGIN_NAME
isolate_namespace MyPluginModule
end
end
Important things to note:
-
In plugin.rb, you must include
::before your module name to define it in the root namespace (otherwise, it would be defined underPlugin::Instance) -
require_relative "lib/.../engine"must be in the root of theplugin.rbfile, not inside anafter_initializeblock -
Putting the engine in its own file under
lib/is important. Defining it directly in theplugin.rbfile will not work. (Rails uses the presence of alib/directory to determine the root of the engine) -
The file path should include the module name, according to the Zeitwerk rules
-
The
engine_nameis used as the prefix for rake tasks and any routes defined by the engine (:link: rails docs) -
isolate_namespacehelps to prevent things leaking between core and the plugin (:link: Rails docs)
2. Define ruby files in the correct directory structure
The engine will now autoload all files in {plugin}/app/{type}/*. For example, we can define a controller
{plugin}/app/controllers/my_plugin_module/examples_controller.rb
module ::MyPluginModule
class ExamplesController < ::ApplicationController
requires_plugin PLUGIN_NAME
def index
render json: { hello: "world" }
end
end
end
This will now be autoloaded whenever anything in Rails tries to access ::MyPluginModule::MyController. To test things, try accessing that class from the rails console.
For autoloading to work correctly, file paths must match the full module/class hierarchy according to the rules defined by Zeitwerk.
3. Defining routes on the plugin's engine
Create a {plugin}/config/routes.rb file
MyPluginModule::Engine.routes.draw do
get "/examples" => "examples#index"
# define routes here
end
Discourse::Application.routes.draw do
mount ::MyPluginModule::Engine, at: "my-plugin"
end
This file will be automatically loaded by the engine, and changes will take effect without a server restart. In this case, the controller action would be available at /my-plugin/examples.json.
4. Adding more autoloaded paths
Sometimes you may like to introduce additional directories of autoloadable Ruby files. The most common example is the lib/ directory in a plugin.
Modify your engine definition to append lib/ to the engine's autoload paths:
class Engine < ::Rails::Engine
engine_name PLUGIN_NAME
isolate_namespace MyPluginModule
config.autoload_paths << File.join(config.root, "lib")
end
Now you can define a lib module like
{plugin}/lib/my_plugin_module/some_lib_module.rb
module ::MyPluginModule::SomeLibModule
end
And now any references to ::MyPluginModule::SomeLibModule will automatically load the module from this file.
5. Profit!
All these files will now be automatically loaded without any deliberate require calls. Changes will be automatically picked up by rails and reloaded in-place with no server restart.
Developing Discourse Themes & Theme Components
Discourse Themes and Theme Components can be used to customize the look, feel and functionality of Discourse's frontend. This section of the developer guides aims to provide all the reference materials you need to develop simple themes for a single site, right up to complex open-source theme components.
This introduction aims to provide a map of all the tools and APIs for theme development. If you prefer a step-by-step tutorial for theme development, jump straight to:
https://meta.discourse.org/t/theme-developer-tutorial-1-introduction/357796
Themes vs. Theme Components
Discourse Themes can be used to customize the frontend user experience using CSS and JavaScript. Each theme has its own git repository, and community admins can generally install and manage them via the Discourse admin panel, even on shared hosting platforms.
Theme Components are themes which are intended for use alongside other Theme Components, as part of an overall Theme. From a development point of view, Theme Components and Themes are almost identical. In these guides, the phrase "Theme" and "Theme Component" are used interchangeably.
Prerequisites
Firstly, make sure you understand how to use existing themes and theme components in Discourse. Using ready-made themes is the quickest and safest way to customize your community. If you need more, then it's time to consider writing your own theme.
As part of Discourse's overall architecture, Discourse Themes are built using standard HTML, CSS, JavaScript technologies, and make use of Ember concepts for more advanced UIs. These reference guides assume a base-level understanding of these technologies, and link out to external references where possible.
Discourse is a fast-moving project, and as such any custom theme will require maintenance over time. Make sure you consider this as part of your planning & development processes.
Getting Started
Frontend Customization
More!
Check out the rest of the Developer Guides !
Theme Developer Quick Reference Guide
As themes grow more powerful, there's more to remember about how they work. We have loads of detailed documentation under #theme-guides, but if you just need something to jog your memory, this guide may help.
General Resources
:scroll: Beginner's guide :scroll: Designer's guide :scroll: Developer's guide :paintbrush: Theme Creator :desktop_computer: Theme CLI :notebook_with_decorative_cover: Theme Directory :jigsaw: Component Directory :wrench: Theme Modifiers :wrench: Themeable site settings
File/Folder Structure read more
about.json
settings.yml
common/, desktop/, mobile/
{common|desktop|mobile}.scss
head_tag.html
header.html
after_header.html
body_tag.html
footer.html
embedded.scss (common only)
locales/
en.yml
...
assets/
(arbitrarily named files, referenced in about.json)
stylesheets/
(arbitrarily named files, can be imported from each other, and common/desktop/mobile.scss)
javascripts/
(arbitrarily named files. Supports .js, .gjs and .hbs)
about.json structure info, available metadata
{
"name": "My Theme",
"component": false,
"license_url": null,
"about_url": null,
"authors": null,
"theme_version": null,
"minimum_discourse_version": null,
"maximum_discourse_version": null,
"assets": {
"variable-name": "assets/my-asset.jpg"
},
"color_schemes": {
"My Color Scheme": {
"primary": "222222"
}
}
}
SCSS
:link: Available CSS Variables
Javascript read more
// {theme}/javascripts/api-initializers/init-theme.gjs
import { apiInitializer } from "discourse/lib/api";
export default apiInitializer((api) => {
// Your code here
});
Settings read more
settings.yml:
fruit:
default: apples|oranges
type: list
description: # Old method. It's better to define these in the locale files (see below)
en: English Description
fr: Description Française
Access from JavaScript:
console.log(settings.fruit);
Access from gjs templates:
<template>{{settings.fruit}}</template>
Access from scss:
html {
font-size: #{$global-font-size}px;
background: $site-background;
}
Themeable site settings read more
about.json:
"theme_site_settings": {
"enable_welcome_banner": false
}
Access from JavaScript:
@service siteSettings;
this.siteSettings.enable_welcome_banner;
Access from gjs templates:
<template>{{this.siteSettings.enable_welcome_banner}}</template>
Translations read more
locales/en.yml
en:
my_translation_key: "I love themes"
theme_metadata: # These are used in the admin panel. They are not made available to your js/hbs files
description: This theme lets you do amazing things on your Discourse
settings:
fruit: A description of the whitelisted_fruits setting
Access from JavaScript:
import { i18n } from "discourse-i18n";
i18n(themePrefix("my_translation_key"));
Access from gjs templates:
import { i18n } from "discourse-i18n";
<template>
{{i18n (themePrefix "my_translation_key")}}
<DButton @label={{theme-prefix "my_translation_key"}} />
</template>
Designer's Guide to getting started with themes in Discourse
So you're interested in in designing your own theme for Discourse? You've arrived at the right topic :smile:
This guide will be more focused on the SCSS/CSS aspects of working with themes in Discourse. If you are also knowledgeable in JS/EmberJs/Handlebars you can go even deeper by looking into this guide.
https://meta.discourse.org/t/developer-s-guide-to-discourse-themes/93648
I will describe to you my personal method's of designing and theming in Discourse. As with most things, there are LOTS of ways to go about implementing your own designs. I enjoy using the inspector tools heavily when creating themes, and will show you a couple times how I do that in this post.
Getting Setup for Theming
Please read through Beginners Guide to Using Discourse Themes, as well as the Structure of Themes... before continuing. A deep knowledge isn't necessary at this point, but these articles will give you a little more familiarity before beginning.
In order to best work with theming in Discourse, I suggest getting setup with the following to offer yourself the quickest and most streamlined design process. These steps will enable you to see your changes as you make them, without having to 'save' and refresh from a discourse site admin panel.
It is totally possible to work through this guide using the admin console (provided you have admin level access to a discourse forum.)
- Install Discourse Theme CLI and read through that topic for an understanding of what it can do.
- Retrieve an API Key from https://discourse.theme-creator.io/
- Sign in with your Meta Account
- Click on My Themes
- Click on the API Key
- In the popup modal, click Generate API Key & copy the key generated for you (we will use this in a bit)
Running the Discourse Theme CLI
With the Discourse Theme CLI installed, and your API Key ready, open your preferred text editor or terminal window and change your working directory to where you would like your theme folder to be setup.
Once there, run the following command discourse_theme new your_theme_name fill out the prompts like so:
-
What would you like to call your theme? Choose your theme name
-
Would you like to start watching this theme? Yes
-
What is the root URL of your Discourse Site?
https://discourse.theme-creator.io/ -
Would you like this site name stored in...? Yes
-
What is your API Key? enter the API key you retrieved from theme creator
-
Would you like this API key stored...? Yes
-
Choose Create and Sync with a new theme when prompted
-
Choose Do Nothing when prompted about child theme components
If everything worked correctly, you should now be able to navigate to My Themes on the https://discourse.theme-creator.io/ and see your new theme in the themes list on the left.
To view these changes in real time, click on your theme name, then at the bottom of the info area, click on Preview
The Theme CLI is also now watching for any changes in the newly created directory, and will save, as well as update the theme on theme-creator with every change.
First Steps
The Discourse Theme CLI has created a theme scaffolding for us inside of the folder name we specified in the command we ran earlier. A lot of files are generated that we will not be using, so we will go ahead and delete everything but the following:
common/common.scss
desktop/desktop.scss
mobile/mobile.scss
about.json
Inside of the directory, go ahead and run rm -rf .git as well to remove git version tracking, it will not be needed for this guide.
Your theme directory should now look like this:

It is worth noting that the styles we add to these files will render in their respective use case. Styles in common.scss will be applied to desktop + mobile, while styles in desktop.scss will only be applied to desktop browsing, and those in mobile.scss will only apply to mobile views.
Hello World (in color)
Discourse uses SCSS for its styling, so in order to best utilize styles, you may want to familiarize yourself with SASS, but if not, you will still be able to follow along with this guide.
Ok, now to get what to what we've all been waiting for... THEMING!
Right now, our about.json currently does not have any color_schemes defined, so paste in the following code into that section then save.
{
"name": "my theme",
"about_url": null,
"license_url": null,
"assets": {},
"color_schemes": {
"Default": {
"primary": "222222",
"secondary": "ffffff",
"tertiary": "0088cc",
"quaternary": "e45735",
"header_background": "ffffff",
"header_primary": "333333",
"highlight": "ffff4d",
"danger": "e45735",
"success": "009900",
"love": "fa6c8d"
}
}
}
If you have your browser open, you wouldn't have seen any changes take effect, because this is the default color scheme used when no scheme is present.
Theme Overview
In order to have something to actually implement in this guide, I will walk you through on creating a simple theme based on this color palette.

Changing BG Color + Primary Text Color
Let's do something very simple. We will go ahead and change the "Secondary" value of our current color scheme. Let's change it to "secondary": "EEF4F7" (this changes the background color.) Let's also change the "primary" value to "203243".
With just that line, we have already changed the look and feel of our forum. A lot of customization can be done by just editing the color's in the color scheme alone.
Color Scheme Use
All of the following keys are defined in the about.json file under the corresponding color scheme name. These descriptions are a good reference to help you understand what the main purpose of each variable name is for:
| Color | Description |
|---|---|
| primary | Most text, icons, and borders |
| secondary | The main background color, and text color of some buttons |
| tertiary | Links, some buttons, notifications, and accent color |
| quaternary | Navigation links |
| header_background | Background color of the site's header |
| header_primary | Text and icons in the site's header |
| highlight | The background color of highlighted elements on the page, such as posts and topics |
| danger | Highlight color for actions like deleting posts and topics |
| success | Used to indicate an action was successful |
| love | The like button's color |
Each of these variables are available to us to use inside of our SCSS files like so.
body {
background-color: var(--primary);
}
Other versions of each color are also created for us to use as well. Things like var(--primary-medium) , or var(--primary-very-low) can be used to get different tones of the same color.
Lets change the other colors in our "Default" color scheme to match this:
"Default": {
"primary": "203243",
"secondary": "EEF4F7",
"tertiary": "416376",
"quaternary": "5E99B9",
"header_background": "FaFaFa",
"header_primary": "EEF4F7",
"highlight": "86BDDB",
"danger": "8F393E",
"success": "70DB82",
"love": "FC94CB"
}
:flashlight: You can see all of the available variables for use in your SCSS files if you click on the Style Guide link while previewing your theme on theme creator, then clicking on Colors in the left hand menu.
The Styleguide is a very helpful section to look at when you are creating a custom theme. Each Atom will show you how certain elements of Discourse will look with your styles applied.

Going Deeper
With the previous section under our belt, I think it's time for us to go a bit deeper as to what can be done in Discourse with only SCSS. (Hint: A LOT!)
Styling the Header
You will notice that our previous changes to the color scheme have left something to be desired with our header. The icons are barely visible!

The Discourse Header includes a container (with a background color) to hold a site logo, as well as the nav icons to the right. All of these can be customized.
The target class to customize the header is .d-header.
In our common/common.scss file, let's add the following:
.d-header {
box-shadow: none;
border-bottom: 1px solid var(--primary-low-mid);
height: 5em;
}
This will remove the default box-shadow on the header, give it a little more height, as well as set a border-bottom to give us some separation.
For the icons -- Inside of the .d-header SCSS brackets, lets add in this nested code.
.d-header {
// ...previous code
.d-icon {
color: var(--primary-low-mid);
}
}
This is looking good, but a keen eye will notice the increased header height has given us less room in between itself and the rest of the Discourse forum elements!

The spacing between the main area and the header is controller by the #main-outlet target. Lets increase this spacing just a bit by adding the following to the bottom of your common/common.scss file.
#main-outlet {
padding-top: 6.5em;
}
Navigation Container

The leftmost area is the category/tag filter dropdowns, followed by the navigation links, ended with the New Topic button.
Category / Tag Dropdown
Let's make some changes to this area. To do so, add the following to your common.scss file.
.navigation-container {
.select-kit.combo-box {
.select-kit-header {
border-radius: 0.9em;
background-color: var(--header_background);
}
}
}
Here we target the .select-kit-header to give them each an identical border-radius, as well as a lighter background color.
Clicking on either of these, opens up a drop down menu.

Currently, it is also has hard corners, so lets add some styles to round these off as well as change the background color to be the same as the header.
.navigation-container {
.select-kit.combo-box {
// ...previous code
&.category-drop,
&.tag-drop {
.select-kit-body {
border-radius: 0.9em;
background-color: var(--header_background);
.select-kit-collection {
background-color: var(--header_background);
border-top-left-radius: 0px;
border-top-right-radius: 0px;
}
}
}
}
}
This results in the following look...

If you look closely, you can see our changes have left a small border visible at the top right of the search area.
Lets fix this by looking in our browser's inspector. This is always a super helpful tool to learn what classes/IDs we need to target in order to apply styles correctly.
With the dropdown menu visible, right click on the search area and 'Inspect' the element in your browser.

We can see that this input is located inside of a div with a class of select-kit-filter.
If we look at the rules being applied to this selector, we can see that it currently has a border-top and bottom, as well as some padding applied. We want to only change the border-top styling.
Add the following code nested in the .select-kit-body scss from earlier.
.select-kit.combo-box.category-drop,
.select-kit.combo-box.tag-drop {
.select-kit-body {
// ...previous code
.select-kit-filter {
border-top: 0px;
}
}
}
With that, our code to style the navigation container should look like so.
.navigation-container {
// Category + Tag Drop Down
.select-kit.combo-box {
.select-kit-header {
border-radius: 0.9em;
background-color: var(--header_background);
}
&.category-drop,
&.tag-drop {
.select-kit-body {
border-radius: 0.9em;
background-color: var(--header_background);
.select-kit-collection {
background-color: var(--header_background);
border-top-left-radius: 0px;
border-top-right-radius: 0px;
}
.select-kit-filter {
border-top: 0px;
}
}
}
}
}
Navigation Links
Let's add some styles to get these navigation links looking similar to this:

Let's use our inspector again to discover what we should target here.

We can see that our navigation elements are inside of a UL with a class of "nav nav-pills ..."
Back in our common.scss file, under the previous section, but still nested inside of navigation-container lets add the following:
.nav-pills {
& > li a {
&.active {
color: var(--tertiary);
background-color: var(--secondary);
border-bottom: 4px solid var(--tertiary);
}
}
}
This change will target only our links with a class of active that are children of nav-pills. This change should get our active link looking like so:

This is fine, but I would like the bottom border to only extend as far as the text. To do this, above the &.active { line, lets add the following, which will affect all A links inside of the navigation <li> tags.
// ...other code
.nav-pills {
& > li a {
padding: 0;
margin-right: 20px;
color: var(--tertiary-high);
border-bottom: 4px solid transparent;
&.active {
// ...more code
}
}
}
Now, we need to style the "hover" effect to be the same as the "active" effect.
Under our previous &.active lets add
&:hover {
color: var(--tertiary);
background-color: var(--secondary);
border-bottom: 4px solid var(--primary);
}
// Nav Pills
.nav-pills {
& > li a {
padding: 0;
margin-right: 20px;
color: var(--tertiary-high);
border-bottom: 4px solid transparent;
&.active {
color: var(--tertiary);
background-color: var(--secondary);
border-bottom: 4px solid var(--tertiary);
}
&:hover {
color: var(--tertiary);
background-color: var(--secondary);
border-bottom: 4px solid var(--primary);
}
}
}
Buttons
I would like to change most of the buttons on this theme to be rounded with some custom styling. This will change the + New Topic button, as well as other buttons throughout the site.
At the bottom of our common.scss file, lets add the following:
.btn {
background-color: var(--header_background);
color: var(--primary);
border-radius: 1.2em;
border: 1px solid var(--primary-low-mid);
.d-icon {
color: var(--primary);
}
&:hover {
background-color: var(--quaternary-low);
color: var(--primary);
.d-icon {
color: var(--primary);
}
}
&.btn-default,
&.btn-primary {
padding: 10px 12px;
}
}
This will get our buttons looking like this:

Go ahead and click through to a Topic on your site preview, then hit the reply button on a topic reply, or from the reply button at the bottom of the topic stream. You will see that our button styling has affected some things we may not have had in mind.

I do not want these text-editing buttons to be affected by my previous styling. This requires a bit of more complex SASS/CSS, but we can get our code to :not() affect these buttons. :wink:
Lets add this line of code, in front of our current .btn target. This will tell our styles to only apply to buttons who are not children of the .d-editor-button-bar.
:not(.d-editor-button-bar) > .btn
Ok that worked great… but wait! Now there is this strange rebel doing it's own thing.

Inspecting this in the browser, I can see that this button has a class of .select-kit-header because upon clicking this gear, more options will show up.

:flashlight: I can not stress how important using your browser's inspector tools are when creating Discourse Themes. They are your best friend to ride with you on this journey.
Now that we know we DON'T want to target this button, let's add some more :not() functionality to our code.
:not(.d-editor-button-bar) >
.btn:not(.single-select-header)
This will select all buttons that are NOT children of the .d-editor-button-bar and do not have the class .single-select-header. I know this is a little confusing, but inside Discourse, there are many moving parts, so sometimes the styling needs to be very specific for it to affect elements correctly.
I have also noticed that our current styling affects the modal close button awkwardly. Clicking on anything that pops open a modal would allow you to see this, or even easier, we can navigate to the modal section of the Style Guide.

To fix this, I will add another target to our code.
:not(.d-editor-button-bar) >
.btn:not(.single-select-header):not(.modal-close)
Moving on...
I see one more button that doesn't seem to have been affected by our code. It is the Tracking button located at the very bottom of a topic post stream.

I will add the following line, after a comma, to our current .btn code.
:not(.d-editor-button-bar) >
.btn:not(.single-select-header):not(.modal-close),
.topic-notifications-button > .select-kit > .btn
This will target the button that appears in this section correctly, and for now, we are finished with styling the top area of our forum.
:flashlight: Feel free to tweak any of the parameters in your own css. The more you play around with these styles and see what and how they affect the html, the more you will learn!
Where to go from here
This guide was meant to scratch the surface of how you can customize your own theme for Discourse. I hope that you now have more insight into how to target areas of the app for your own customizations.
Remember A LOT of things can be customized with only using SCSS. If you would like to go even deeper with your development, I would recommend reading the articles linked at the top of this post.
Feel free to ask any questions and I will gladly try and help you, or point you in the right direction.
Get started with Theme Creator and the Theme CLI
This topic will walk you through how to use the Theme CLI with our Theme Creator site to develop a theme and preview your changes on a live Discourse site.
-
Sign up for an account here on Meta if you haven't already
-
Install the Theme CLI via the instructions here
-
Create a new theme on Theme Creator by:
- Visiting https://discourse.theme-creator.io/my/themes
- Clicking Install, and selecting the "Create New" option.
- Giving your theme a unique name (you can ignore the color scheme for now).
-
Click advanced, then edit locally, and retrieve API key.
Copy the API key that is generated.

-
Go back to your command line and type
discourse_theme download example-folder, where example-folder is where your theme will be stored locally. Follow the prompts.-
Enter
https://discourse.theme-creator.ioas the root URL of your Discourse site. -
Enter your API key from the previous step when prompted.
-
Continue following the prompts and select the theme you created in step 4.
-
Enter yes when asked if you'd like to start watching your theme.
-
-
Now your local theme folder is being watched for changes, which will be automatically uploaded to Theme Creator. You can open a preview of your theme by clicking preview in your theme's settings on Theme Creator.

-
To stop watching for changes, hit ctrl + c in your command line window. To start watching for changes again type
discourse_theme watch example-theme.
:tada: You now have a local theme directory you can edit and see your changes live!
For an in-depth look at how themes are structured and what you can do, check out our https://meta.discourse.org/t/developer-s-guide-to-discourse-themes/93648/1
Install the Discourse Theme CLI console app to help you build themes
The
Discourse Theme CLI is a ruby gem that allows you to use your editor of choice when developing Discourse themes and theme components. As you save files the CLI will update the remote theme or component and changes to it will appear live!
Installing
To play with it, make sure you have Ruby 2.5 or up installed.

If you are on Windows, you have 2 options:
Option 1: Windows Subsystem for Linux.
Windows 10 has access to a full Linux environment, you can use it to install ruby simply with sudo apt-get install ruby, this will give you Ruby 2.3.
Options 2: Older Windows
Older versions of Windows have no access to WSL, you can easily install Ruby with Ruby Installer, go for the recommended version and default settings for the install.

Mac OS version 10.13.3 ship with Ruby 2.3 out of the box, nothing special is needed. If you are running an earlier version of Mac OS consider using rvm, rbenv or homebrew to install a recent ruby.
Once Ruby 2.2 or later is running, open a terminal or command shell and run:
gem install discourse_theme
Once installed, to learn more about it:
discourse_theme
Upgrading
gem update discourse_theme
Features
The CLI provides 3 main functions:
discourse_theme new
You can use it to quickly create a new theme with discourse_theme new YOUR_DIR_NAME
discourse_theme watch
You can use it to monitor a theme and synchronize with a discourse site (with live refresh) using discourse_theme watch YOUR_DIR_NAME
What this means is that you can use your own editor to edit you theme and site will magically :unicorn: update with the changes!
discourse_theme download
You can download an existing theme from Discourse using discourse_theme download YOUR_DIR_NAME. You will then be given the option to start "watching" straight away!
Credentials
You will need to generate an API Key. Go to the admin area and generate a key there.
- :exclamation: Select a “User Level” of
Single Userwhen generating the key, notAll Users. - :exclamation: Make sure to check
Global Keyor you will receive 403 forbidden errors.
Credentials are (optionally) stored at ~/.discourse_theme. API keys are stored per-site, and the URL/theme_id for each directory is also tracked. If you ever need to change your settings, just add --reset to any command and you will be prompted for all values again.
Testimonials
"This tool is truly a GEM!" @awole20 "This is very very good." @awesomerobot "It’s working :) And it’s pretty dosh garn cool. Nice!" @angus "OMG. It's unbelievable." @pfaffman
Structure of themes and theme components
Discourse supports native themes that can be sourced from a .tar.gz archive or from a remote git repository including private repositories.

An example theme is at: https://github.com/SamSaffron/discourse-simple-theme

The git repository will be checked for updates (once a day), or by using the Check for Updates button. When changes are detected the Check for Updates button will change to the Update to Latest.

To create a theme you need to follow a specific file structure. These are the files you may include:
about.json (required)
common/common.scss
common/header.html
common/after_header.html
common/footer.html
common/head_tag.html
common/body_tag.html
common/embedded.scss
desktop/desktop.scss
desktop/header.html
desktop/after_header.html
desktop/footer.html
desktop/head_tag.html
desktop/body_tag.html
mobile/mobile.scss
mobile/header.html
mobile/after_header.html
mobile/footer.html
mobile/head_tag.html
mobile/body_tag.html
locales/en.yml
locales/{locale}.yml
stylesheets/{anything}/{anything}/{anything}.scss
javascripts/{anything}.js
javascripts/{anything}.gjs
javascripts/{anything}.hbs
assets/{asset_filename}
settings.yml
Any of these files :arrow_up: are optional, so you only need to create the ones you need.
For those looking to split theme SCSS into multiple files, that's now possible.
https://meta.discourse.org/t/splitting-up-theme-scss-into-multiple-files/115126
For those looking to split up theme into multiple JS files, just add the JS files you want into the javascripts directory.
The about.json file structure is below, and you can see more information about it at https://meta.discourse.org/t/adding-metadata-to-a-theme/119205:
{
"name": "My Theme",
"component": false,
"license_url": null,
"about_url": null,
"authors": null,
"theme_version": null,
"minimum_discourse_version": null,
"maximum_discourse_version": null,
"assets": {
"variable-name": "assets/my-asset.jpg"
},
"color_schemes": {
"My Color Scheme": {
"primary": "222222"
}
},
"screenshots": ["screenshots/light.jpeg", "screenshots/dark.jpeg"]
}
Instructions on how to add settings to your theme available here: https://meta.discourse.org/t/how-to-add-settings-to-your-discourse-theme/82557?u=osama.
To tell Discourse that you are going to add a theme component and not a full theme just add the line "component": true to the about.json file
The file structure matches the theme custom CSS / HTML.
Further reading
Check out the other articles with the #themes::tag tag.
:information_source: See also:
- https://meta.discourse.org/t/developer-s-guide-to-discourse-themes/93648
- https://meta.discourse.org/t/discourse-theme-cli-console-app-to-help-you-build-themes/82950
- https://meta.discourse.org/t/include-images-and-fonts-in-themes/62459?source_topic_id=60848
Last Reviewed by @SaraDev on [date=2022-08-15 time=14:00 timezone="America/Los_Angeles"]
Split up theme Javascript into multiple files
Complex theme javascript can be split into multiple files, to keep things nicely organised.
To use this functionality, simply add files to the /javascripts folder in your theme directory. These files can not be edited from the Discourse UI, so you must use the Theme CLI or source the theme from git.
Javascript files are treated exactly the same as they are in core/plugins, so you should follow the same file/folder structure. Theme files are loaded after core/plugins, so if the filenames match, the theme version will take precedence.
As an example, you can now accomplish https://meta.discourse.org/t/adding-to-plugin-outlets-using-a-theme/32727 by adding a single file to your theme:
/javascripts/my-theme/connectors/discovery-list-container-top/add-header-message.gjs
import Component from "@glimmer/component"; import { service } from
"@ember/service"; export default class HeaderMessage extends Component {
@service currentUser;
<template>
Welcome
{{this.currentUser.username}}
</template>
}
To use the JS API, create an initializer:
/javascripts/discourse/api-initializers/init-theme.gjs
import { apiInitializer } from "discourse/lib/api";
export default apiInitializer((api) => {
// Your code here
});
If you need a totally different .js asset (e.g. for a web worker), check out this topic.
Split up theme SCSS into multiple files
Themes and theme components are becoming steadily more powerful, and developers are getting more and more ambitious. To make things easier for developers, themes can now split their SCSS into multiple files.
https://github.com/discourse/discourse/commit/268d4d4c828a0d9e3ca6b0b5b623db85eb32b9f3
When creating a new theme with the theme CLI, or sharing a theme on github, simply create a new folder called scss. Fill it with your .scss files, following any folder structure, and all the files will be available for you to import in the common / desktop / mobile SCSS sections of your theme.
For example, if you want to have a common variable available to both mobile and desktop SCSS, you could do something like this:
scss/my-folder/variables.scss
$favourite-color = red;
desktop/desktop.scss
@import "my-folder/variables";
body {
background-color: $favourite-color;
}
mobile/mobile.scss
@import "my-folder/variables";
body {
color: $favourite-color;
}
This feature was added in v2.3.0.beta8, so do not use these features quite yet if you need to maintain backwards compatibility with older versions of Discourse. You can use the minimum_discourse_version parameter of about.json to ensure your component doesn't get used on an earlier version.
Add settings to your Discourse theme
Discourse has the ability for themes to have "settings" that can be added by theme developers to allow site owners to customize themes through UI without having to change any line of code and worry about losing their changes with future updates for the theme.
Themes can also alter certain themeable site settings, for more information on that, see the Themeable site settings topic.
:heavy_plus_sign: Adding settings to your theme
Adding settings to your theme is a bit different from adding CSS and JS code, that is there is no way to do it via the UI.
The way to add settings is to create a repository for your theme, and in the root folder of your repository create a new settings.yaml (or settings.yml) file. In this file you'll use the YAML language to define your theme settings.
:loudspeaker: Note: You may find it helpful to make use of the Theme CLI, which tremendously simplifies the development process.
Now if you're familiar with plugin development, this shouldn't be a new thing to you - it mostly works the same way as adding site settings to your plugin. Just dump some valid YAML in your settings file and you'll be good to go.
A valid theme setting must have a name and default value, that's the bare minimum and it looks like this:
simple_setting: true
As you can probably tell, that will create a setting with the name simple_setting and it'll have true as its default value.
Similarly, you can add something like this:
site_name: My Forums
max_avatars: 7
And you'll have two more settings, site_name which will be a string setting with "My Forums" as the default value, and max_avatars as an integer setting with default value of 7.

You can access your settings in your JS code like this: settings.your_setting_key.
So until this point we've covered the simplest way to define settings. In the next section we'll dive a bit deeper into the various types of settings and how you can use them.
:symbols: Supported types
There are 9 types of settings:
integerfloatstringbool(for boolean)listenumobjects(replacement forjson_schema)upload(for images)icon(for a single icon from the Discourse icon set)
And you can specify type by adding a type attribute to your setting like this:
float_setting:
type: float
default: 3.14
I should say that you don't always have to explicitly set a type attribute because Discourse is smart enough to work out the setting type from the setting's default value. So you can reduce the above example to this:
float_setting:
default: 3.14
That said, you need to set a type attribute when working with list, enum and icon settings, otherwise Discourse will not recognize them correctly.
List Setting:
whitelisted_fruits:
default: apples|oranges
type: list
Enum Setting:
favorite_fruit:
default: orange
type: enum
choices:
- apple
- banana

In case the difference between list and enum settings is not clear to you: enum settings allow your theme users to select only one value from a set of values defined by you (see the choices attribute).
On the other hand, list settings allow your users to create their own list (i.e. an array) of values. They can add to or remove from the setting's default list of values. You can set the default list of values for the setting by joining the values with a vertical bar | character. See the list setting in the example above.
You can see a real-world use case for list settings here: https://meta.discourse.org/t/linkify-words-in-post-theme-component/82193?u=osama.
:loudspeaker: Note: Pay attention to indentation when working with YAML because YAML is very picky about spaces and will throw a syntax error if your code indentation is incorrect.
Icon Setting:
banner_icon:
default: bullhorn
type: icon
Icon settings give site owners a searchable icon picker, and the value is the icon name. Discourse adds the selected icon to the sprite sheet, so you can render it in your theme without registering it separately.
objects type
The objects setting type is a special type that allows you to accomplish advanced settings with custom structure and validations. We have a separate documentation for this type.
:capital_abcd: Setting description and localizations
You can add description text to your theme setting and it'll be shown as a label directly under the setting. To do that simply add a description attribute to your setting like so:
whitelisted_fruits:
default: apples|oranges
type: list
description: "This text will be displayed under this setting and it explains what the setting does!"
And you'll get this:

Multiple languages support
If you know more than one language, and you'd like to add support for those languages to your theme, then you can totally do that provided that Discourse supports said languages.
First of all, make sure the language you want to support is in this list:
[details="Languages list"]
| Code | Name | |||
|---|---|---|---|---|
| ar | اللغة العربية | |||
| bs_BA | bosanski jezik | |||
| ca | català | |||
| cs | čeština | |||
| da | dansk | |||
| de | Deutsch | |||
| el | ελληνικά | |||
| en | English | |||
| es | Español | |||
| et | eesti | |||
| fa_IR | فارسی | |||
| fi | suomi | |||
| fr | Français | |||
| gl | galego | |||
| he | עברית | |||
| id | Indonesian | |||
| it | Italiano | |||
| ja | 日本語 | |||
| ko | 한국어 | |||
| lv | latviešu valoda | |||
| nb_NO | Norsk bokmål | |||
| nl | Nederlands | |||
| pl_PL | język polski | |||
| pt | Português | |||
| pt_BR | Português (BR) | |||
| ro | limba română | |||
| ru | Русский | |||
| sk | slovenčina | |||
| sq | Shqip | |||
| sr | српски језик | |||
| sv | svenska | |||
| te | తెలుగు | |||
| th | ไทย | |||
| tr_TR | Türkçe | |||
| uk | українська мова | |||
| ur | اردو | |||
| vi | Việt Nam | |||
| zh_CN | 中文 | |||
| zh_TW | 中文 (TW) |
[/details]
(If you can't see your language in the list then you might want to take a look at https://meta.discourse.org/t/how-to-add-a-new-language/14970?u=osama)
Then you'll need find your language code from the above list and use the language code as a key under the description attribute and translation as a value for the key like so:
whitelisted_fruits:
default: apples|oranges
type: list
description:
en: English text
ar: نص باللغة العربية
fr: Texte français
And now you have support for 3 languages: English, Arabic and French.
Additional setting attributes and options
Min and max attributes
Sometimes you may need to specify limits that a setting value can't exceed to prevent your users from accidentally breaking the theme or possibly the whole site.
To specify limits, simply add a min or max or both attributes to your setting like so:
integer_setting:
default: 10
min: 5
max: 100
You can specify limits to integer, float and string type settings. For integer and float settings, the value of the setting itself is checked against the limits. And for string settings, the length of the value is checked against the specified limits.
If your user tries to enter a value that's not within the allowed range, they'll see an error telling them what the min and max values are.
Access to settings in your JS/CSS/Handlebars
Theme settings are made available globally as a settings variable in theme JavaScript files. For example:
// {theme}/javascripts/discourse/api-initializers/init-theme.gjs
import { apiInitializer } from "discourse/lib/api";
export default apiInitializer((api) => {
console.log("settings are", settings);
});
This settings object is also usable as normal within .gjs <template> tags.
Setting CSS variables
In CSS, you'll get a variable created for every setting of your theme and each variable will have the same name as the setting it represents.
So if you had a float setting called global_font_size and a string setting called site_background, you could do something like this in your theme CSS:
html {
font-size: #{$global-font-size}px;
background: $site-background;
}
Resolving group membership
Theme components sometimes need to show or hide a feature based on whether the current user is in a configured group. Avoid checking currentUser.groups for this because it only includes groups that are visible to the user, and it can miss hidden groups.
For group-backed list settings, add resolve_group_membership: true to resolve the check server-side:
copy_button_allowed_groups:
default: "1|3"
type: list
list_type: group
resolve_group_membership: true
This option is only valid when the setting has type: list and list_type: group. When it is enabled, the frontend settings object does not include the original group list. Instead, Discourse adds a boolean with the same setting name prefixed by user_in_:
// {theme}/javascripts/discourse/api-initializers/init-theme.gjs
import { apiInitializer } from "discourse/lib/api";
export default apiInitializer((api) => {
if (!settings.user_in_copy_button_allowed_groups) {
return;
}
// User is in at least one selected group.
});
The generated boolean also works with automatic groups such as logged_in_users and anonymous_users. Object theme settings can use the same option on type: groups properties. See objects type for theme settings for details.
:link: Related Topics
- https://meta.discourse.org/t/developer-s-guide-to-discourse-themes/93648
Objects type for theme setting
We are introducing a new type: objects to the supported types for theme settings which can be used to replace the existing json_schema type which we intend to deprecate soon.
Defining an objects type theme setting
To create an objects type theme setting, first define a top level key just like any theme setting which will be used as the setting's name.
links: ...
Next add the type, default and schema keywords to the setting.
links:
type: objects
default: []
schema: ...
type: objects indicates that this will be an objects type setting while the default: [] annotation sets the default value of the setting to an empty array. Note that the default value can also be set to an array of objects which we will demonstrate once the schema has been defined.
To define the schema, first define the name of the schema like so:
links:
type: objects
default: []
schema:
name: link
Next, we will add the properties keyword to the schema which will allow us to define and validate how each object should look like.
links:
type: objects
default: []
schema:
name: link
properties:
name: ...
In the example above, we are stating that the link object has a name property. To define the type of data that is expected, each property needs to define the type keyword.
links:
type: objects
default: []
schema:
name: link
properties:
name:
type: string
The above schema definition states that the link object has a name property of type string which means that only string values will be accepted for the property. Currently the following types are supported:
string: Value of property is stored as a string.integer: Value of property is stored as an integer.float: Value of property is stored as a float.boolean: Value of property istrueorfalse.upload: Value of property is the attachment URLenum: Value of property must be one of the values defined in thechoiceskeyword.links: type: objects default: [] schema: name: link properties: name: type: enum choices: - name 1 - name 2 - name 3categories: Value of property is an array of valid category ids.groups: Value of property is an array of valid group ids.tags: Value of property is an array of valid tag names.icon: Value of property is the name of a single icon from the Discourse icon set. Selected icons are automatically added to the sprite sheet, so they can be rendered without being registered separately.
With the schema defined, the default value of the setting can now be set by defining a array in yaml like so:
links:
type: objects
default:
- name: link 1
title: link 1 title
- name: link 2
title: link 2 title
schema:
name: link
properties:
name:
type: string
title:
type: string
Required properties
All properties defined are optional by default. To mark a property as required, simply annotate the property with required: true. A property can also be marked as optional by annotating the property with required: false.
links:
type: objects
default: []
schema:
name: link
properties:
name:
type: string
required: true
title:
type: string
required: false
Blank string, datetime and icon values and empty categories, groups and tags lists count as missing: required properties reject them and optional properties skip their validations. false counts as set.
Custom Validations
For certain property types, there are built in support for custom validations which can be declared by annotating the property with the validations keyword.
links:
type: objects
default: []
schema:
name: link
properties:
name:
type: string
required: true
validations:
min_length: 1
max_length: 2048
url: true
Validations for string types
min_length: Minimum length of the property. Value of the keyword has to be an integer.max_length: Maximum length of the property Value of the keyword has to be an integer.url: Validates that the property is a valid URL. Value of the keyword can betrue/false.
Validations for integer and float types
min: Minimum value of the property. Value of the keyword has to be an integer.max: Maximum value of the property. Value of the keyword has to be an integer.
Validations for tags, groups and categories types
min: Minimum number of records for the property. Value of the keyword has to be an integer.max: Maximum number of records for the property. Value of the keyword has to be an integer.
Resolving group membership
Object settings can resolve type: groups properties to a boolean for the current user. This is useful when theme code only needs to know whether the current user is in one of the configured groups, because currentUser.groups only includes groups that are visible to the user.
Add resolve_group_membership: true to the groups property:
menu_sections:
type: objects
default:
- name: section 1
groups:
- 1
- 3
schema:
name: menu section
properties:
name:
type: string
groups:
type: groups
resolve_group_membership: true
The admin UI and stored setting value still use the original groups array. In the frontend runtime settings object, Discourse removes the group IDs from each object and adds a boolean with the same property name prefixed by user_in_:
for (const section of settings.menu_sections) {
if (section.user_in_groups) {
// User is in at least one selected group for this section.
}
}
This option is only valid on object schema properties with type: groups. It also works on nested object schemas and with automatic groups such as logged_in_users and anonymous_users.
Nested objects structure
An object can also have a property which contains an array of objects. In order to create a nested objects structure, a property can also be annotated with type: objects and the associated schema definition.
sections:
type: objects
default:
- name: section 1
links:
- name: link 1
url: /some/url
- name: link 2
url: /some/other/url
schema:
name: section
properties:
name:
type: string
required: true
links:
type: objects
schema:
name: link
properties:
name:
type: string
url:
type: string
Setting description and localization
To add a description for the setting in the en locale, create a file locales/en.yml with the following format given the following objects type theme setting.
sections:
type: objects
default:
- name: section 1
links:
- name: link 1
url: /some/url
- name: link 2
url: /some/other/url
schema:
name: section
properties:
name:
type: string
required: true
links:
type: objects
schema:
name: link
properties:
name:
type: string
url:
type: string
en:
theme_metadata:
settings:
sections:
description: This is a description for the sections theme setting
schema:
properties:
name:
label: Name
description: The description for the property
links:
name:
label: Name
description: The description for the property
url:
label: URL
description: The description for the property
Migrate Discourse theme settings
Introduction
As of Discourse version 3.2.0.beta3, Discourse themes/components can leverage a migrations feature to evolve and alter their settings seamlessly. This feature ensures that updates to themes do not disrupt existing installations by handling changes to settings in a controlled manner.
When to Use Migrations
Common scenarios where migrations are particularly useful:
- Changing the type of a setting (e.g., from a comma-separated string to a list).
- Renaming a setting.
- Modifying the structure or format of the data stored in a setting.
In these scenarios, if the setting type or name is changed in the settings.yml file without an accompanying migration, existing installations where the setting has been changed from the default value, they will lose the change they've made to the setting and potentially break when the theme is updated.
To ensure smooth transition when updating a theme setting, theme developers should ship a migration that instructs Discourse core how to migrate existing state that conforms to the old version of the settings.yml to the new version.
Migration Files
Migrations are JavaScript files located in the migrations/settings directory of the theme, following the naming convention XXXX-migration-name.js. The XXXX prefix is a version number, starting from 0001 and incrementing sequentially, which dictates the order of execution.
The name should be a concise description of the migration's purpose, limited to alphanumeric characters, hyphens, and under 150 characters in length. Migrations are executed in ascending order based on the numerical value of their versions.
We recommend that you start with 0001 for the first migration and 0002 for the second migration etc. Note that if a migration's version is below 1000, then the version must be padded with leading zeros to make it 4 digits long since migration filename must start with 4 digits. For the same reason, it's not currently possible to have more than 9999 migrations in a theme, but we may change that in the future.
Migration Function
Standard JavaScript features such as classes, functions, arrays, maps etc. are all available for migrations to use. The only expectation from Discourse core is that each migration file must export a default function that serves as the entry point. This function receives a Map object representing modified theme settings and returns a Map object reflecting the desired final state of all settings.
Examples
Rename a theme setting:
// filename: 0001-rename-old-setting.js
export default function migrate(settings) {
if (settings.has("old_setting_name")) {
settings.set("new_setting_name", settings.get("old_setting_name"));
settings.delete("old_setting_name");
}
return settings;
}
This migration should be accompanied with the setting rename in the settings.yml file.
Convert a comma-separated string setting to a proper list:
// filename: 0001-convert-string-setting-to-list.js
export default function migrate(settings) {
if (settings.has("list_setting")) {
const list = settings.get("list_setting").split(",");
settings.set("list_setting", list.join("|"));
}
return settings;
}
Similar to the previous example, this migration should be accompanied with changing the setting type from string to list in the settings.yml file.
Rename a choice of an enum setting:
// filename: 0001-rename-enum-choice.js
export default function migrate(settings) {
if (settings.get("enum_setting") === "old_option") {
settings.set("enum_setting", "new_option");
}
return settings;
}
Add a new item to a list setting:
// filename: 0001-add-item-to-list.js
export default function migrate(settings) {
if (settings.has("list_setting")) {
const list = settings.get("list_setting").split("|");
list.push("new_item");
settings.set("list_setting", list.join("|"));
} else {
settings.set("list_setting", "new_item");
}
return settings;
}
Execution and Error Handling
Migrations run automatically during theme installation and updates. They execute only once; if a migration is altered after successful execution, it will not run again. If a migration fails, the update process stops, and an error message of what went wrong is provided.
If a migration has a bug that results in corrupt state for a setting, then the correct way to fix the problem is to create a new migration that corrects the corrupt state instead of modifying the original migration.
Adding metadata and screenshots to a Theme
You can add various pieces of metadata to a theme. Some are stored in the about.json file, and some are stored in the locale files.
about.json :link: file format info
name (string, required): The default name for the theme when installed. This can be changed by admins after the theme is installed.
component (boolean, default false): whether the theme should be treated as a component.
license_url (string, optional): a URL for a license file. A link to this will be displayed in the admin interface. Most themes use this to link to their license file on GitHub.
about_url (string, optional): a URL which contains more information about the theme. A link to this will be displayed in the admin interface. Most themes use this to link to their Meta topic.
theme_version (string, optional): An arbitrary string to describe the version of the theme. Displayed in the admin interface.
screenshots (array, optional): Up to two screenshot paths which will be used in various places of the UI to display screenshots of the theme. See below for more detail on restrictions.
minimum_discourse_version (string, optional): the earliest discourse version which this theme is compatible with. If it does not match, the theme will be auto-disabled. Should be in the format 2.4.0.beta1.
maximum_discourse_version (string, optional): the latest discourse version which this theme is compatible with. If it does not match, the theme will be auto-disabled. Should be in the format 2.4.0.beta1.
locale files (e.g. en.yml) :link: file format info
theme_metadata.description: A localised description of the theme. Displayed in the admin interface.
theme_metadata.settings.setting_name: A localised description of setting_name, displayed below the theme setting in the admin panel.
Screenshots
Themes and components can define a maximum of two screenshots in the screenshots key of about.json, which will look something like this:
"screenshots": ["screenshots/light.webp", "screenshots/dark.webp"]
Keep these restrictions in mind:
- Screenshots must be in a
screenshotsfolder in the theme’s GitHub repo - Max file size:
1 MB - Max resolution:
3840×2160(4K) - Recommended ratio:
16:9 - Allowed formats:
.webp,.png,.jpeg,.jpg
We also follow a convention to include both light and dark screenshots.
Use Discourse Core Variables in your Theme
Discourse is incredibly customizable!
The goal of this topic is to show you how make use of all the amazing options that are available to you as a theme developer. You know... so that you don't have to reinvent the wheel :smile:
Variables? What variables?
Variables cover a large number of things, from font sizes to colors to z-index values. You can use and override the vast majority of variables in your theme editor.
Colors
There are many colors available for you to use or override in your theme. You can see the basic set of colors in the admin UI, under Customize > Colors. Here is a screenshot of the default color scheme:
These colors are available as CSS custom properties that you can use and override via this syntax:
var(--primary)
var(--secondary)
var(--tertiary)
var(--quaternary)
var(--header_background)
var(--header_primary)
var(--highlight)
var(--danger)
var(--success)
var(--love)
:warning: The use of SCSS color variables (like
$primary, $secondary` and so on) is deprecated. Discourse core uses CSS custom properties since August 2020 and we can recommend all existing themes switch SCSS variables to CSS custom properties. For complex color manipulations, see this guide.
So how can I use them?
Start by creating a new theme or if you are using a git-based theme, create a new theme component and add it to your theme.
Let's say I want to set the <body> background to match the highlight color of the current color scheme. Well, I can do that like so:
body {
background: var(--highlight);
}
And the color would be pulled automatically from the current color scheme, like so:

(This background color is not recommended :sweat_smile:)
Now, let's say the current color scheme is not set to "Light scheme" for the current theme. Instead, let's say it's set to the default "Dark" scheme, the same code would yield a different result because the variable is different.

You can see how the <body> background follows the current highlight variable whatever it may be for the current color scheme.
That covers the basic color variables. Discourse also uses derivative colors that can be used or overriden. You can see the full list of colors from the current color scheme in your browser inspector, here is a screenshot of the default theme:

Notice that you can inspect these colors on any Discourse instance, no need to be authenticated. On your own Discourse instance, you can (and should) also use the styleguide plugin, which is now included in core. Enable it and head to /styleguide, and you see all of the Discourse UI elements, including colors:

(That's the Grey Ember color scheme above.)
Ok, on to more advanced topics:
Advanced Variables
Take a look at this file in the Discourse repo:
https://github.com/discourse/discourse/blob/main/app/assets/stylesheets/common/foundation/variables.scss
This is where most of the variables are defined. Just like colors, these variables are magically available for you to use in theme stylesheets.
I will try to break down the content of the file below:
Widths
$small-width: 800px !default;
$medium-width: 995px !default;
$large-width: 1110px !default;
these are very handy if you want your theme to match native Discourse behavior, especially in media queries for example take a look at the relevant parts of the pre-compiled scss for .user-info:
.user-info {
&.medium {
flex: 0 0 32%;
margin: 0 2% 4vh 0;
@media screen and (max-width: $small-width) {
flex: 0 0 48%;
margin-right: 0;
}
}
}
Notice how the media query is fed a variable to determine a cut-off point for small screens and apply different styles.
Fonts
--base-font-size: 0.938em; // eq. to 15px
--base-font-size-larger: 1.063em; // eq. to 17px
--base-font-size-largest: 1.118em; // eq. to 19px
// Font-size definitions, multiplier ^ (step / interval)
--font-up-6: 2.296em;
--font-up-5: 2em;
--font-up-4: 1.7511em;
--font-up-3: 1.5157em;
--font-up-2: 1.3195em;
--font-up-1: 1.1487em; // 2^(1/5)
--font-0: 1em;
--font-down-1: 0.8706em; // 2^(-1/5)
--font-down-2: 0.7579em; // Smallest size we use based on the 1em base
--font-down-3: 0.6599em;
--font-down-4: 0.5745em;
--font-down-5: 0.5em;
--font-down-6: 0.4355em;
This is also pretty straightforward, Discourse has an awesome font-scaling system and so you should make use of it.
The units used are in em and so would be relative to --base-font-size which is used on the <html> element.
So:
.btn {
font-size: var(--font-up-6);
}
gets compiled into:
(--base-font-size _ --font-up-6) or (14px _ 2.296) = 32.14px
Or this:
Line heights
--line-height-small: 1;
--line-height-medium: 1.2; // Headings or large text
--line-height-large: 1.4; // Normal or small text
These variables can be used just like the previous example. Do note that they are unitless values. Here's what that means:
[quote="awesomerobot, post:10, topic:75396"] That’s why it’s recommended that unitless line-height values are used, because your line-height will always be based on the font-size. With 14px font-size and line-height of 1, your line height will be 14px. Update the font-size to 16px and your line-height will be 16px. Unitless line-heights serve as multipliers of the font-size, not values. [/quote]
Z-index values
$z-layers: (
"max": 9999,
"fullscreen": 1700,
"modal": (
"tooltip": 1600,
"popover": 1500,
"dropdown": 1400,
"content": 1300,
"overlay": 1200,
),
"mobile-composer": 1100,
"header": 1000,
"tooltip": 600,
"composer": (
"popover": 500,
"content": 400,
),
"dropdown": 300,
"usercard": 200,
"timeline": 100,
"base": 1,
);
These can be used as discussed here:
[quote="awesomerobot, post:1, topic:78236"] You have basic values like “dropdown” and “fullscreen” but we can also nest some values for more complex stacking like within modals or the composer. The nested values don’t need to be sequential as I’ve set them (due to stacking contexts), but it makes the hierarchy clearer and works as you’d expect.
You call these values in our core CSS like this: [/quote]
div {
z-index: z("header");
}
"or when nested, like this:"
div {
z-index: z("modal", "dropdown");
}
"You can also do basic math as you’d expect:"
div {
z-index: z("base") + 1;
}
The code-blocks above are part of the quote but I moved them out for formatting
Now here's an example:
Put a box above the header:
.zbox {
position: fixed;
top: 0;
left: 0;
height: 200px;
width: 200px;
background: red;
z-index: z("header") + 1;
}
Put a box below the header:
.zbox {
position: fixed;
top: 0;
left: 0;
height: 200px;
width: 200px;
background: red;
z-index: z("header") - 1;
}
Box shadows
$box-shadow: (
"modal": 0 8px 60px rgba(0, 0, 0, 0.6),
"composer": 0 -1px 40px rgba(0, 0, 0, 0.12),
"menu-panel": 0 6px 14px rgba(0, 0, 0, 0.15),
"card": 0 4px 14px rgba(0, 0, 0, 0.15),
"dropdown": 0 2px 3px 0 rgba(0, 0, 0, 0.2),
"header": 0 2px 4px -1px rgba(0, 0, 0, 0.25),
"kbd": (
0 2px 0 rgba(0, 0, 0, 0.2),
0 0 0 1px dark-light-choose(#fff, #000) inset,
),
"focus": 0 0 6px 0 $tertiary,
"focus-danger": 0 0 6px 0 $danger,
);
As you can sort of see, most of the work has been done for you when it comes to variables, and you only need to utilize what's already there in most cases.
The use for box shadows looks like this:
.d-header {
box-shadow: shadow("focus");
}
This should make the header use the focus box-shadow. And if we check to confirm:
This post is a wiki so edit as needed :wink:
Add localizable strings to themes and theme components
For those looking to add custom languages and translations to a Discourse theme or theme component, they can now include localised strings, which are made available for use in UI components. Translations are stored in the same format as core/plugin translations, and can be used in almost the same way.
Themes can supply translation files in a format like /locales/{locale}.yml. These files should be valid YAML, with a single top level key equal to the locale being defined. These can be defined using the discourse_theme CLI, importing a .tar.gz, installing from a GIT repository, or via the editor on theme-creator.discourse.org.
An example locale file might look like
en:
theme_metadata:
description: "This is a description for my theme"
settings:
theme_setting_name: "This is a description for the setting `theme_setting_name`"
another_theme_setting_name:
description: "This is a description for the setting `another_theme_setting_name`"
sidebar:
welcome: "Welcome"
back: "back,"
welcome_subhead: "We're glad you're here!"
likes_header: "Share the Love"
badges_header: "Your Top Badges"
full_profile: "View your full profile"
Administrators can override individual keys on a per-theme basis in the /admin/customize/themes user interface. Fallback is handled in the same way as core, so it is ok to have incomplete translations for non-english languages will make use of the english keys.
Administrators can also manage these translations in Appearance → Site texts. Select a theme or component to search its locale-file strings, edit a translation in the selected language, or revert an override to the locale-file default. The selection is linkable using /admin/customize/site_texts?theme_id=THEME_ID. Site texts identifies these entries as js.theme_translations.THEME_ID.KEY; edits use the same theme translation overrides as the theme editor.

In the background, these translations are stored alongside the core translations, under a theme-specific namespace. For example:
theme_translation.{theme_id}.sidebar.welcome
You should never hardcode the theme_id in your theme code. To dynamically build the translation key, use the themePrefix helper:
import { i18n } from "discourse-i18n";
import { themePrefix } from "virtual:theme";
// In JS code:
const result = i18n(themePrefix("my_translation_key"));
console.log("From Javascript", result);
// In a template tag:
<template>{{i18n (themePrefix "blah")}}</template>
For a complete example of using translations in a theme, check out @awesomerobot's Fakebook theme: https://github.com/awesomerobot/Fakebook
End-to-end system testing for themes and theme components
Writing automated tests for themes is an important part of the theme development process which can help ensure that the features being introduced by a theme continues to work well overtime with core Discourse features.
Currently, Discourse supports two ways of writing regression tests for themes. The first mainly follows EmberJS's way and only involves testing the client side code. The second way is to write Rails system tests which allows you to test both the server side code and client side code at the same time. This document will focus on writing Rails system tests for themes and is what we recommend theme authors focus on when writing tests for their themes as well.
Rails System tests for themes
Under the hood, Discourse uses the RSpec and Capybara testing frameworks to run Rails system tests. Basic knowledge about RSpec and Capybara is required to get started and we recommend reading through the following links first before you get started:
- https://github.com/rspec/rspec-core#basic-structure
- https://github.com/teamcapybara/capybara#the-dsl
Guidelines and tips for writing theme system tests
These are some guidelines to follow when writing system tests:
-
System tests are expected to be located in the
spec/systemdirectory in the theme's directory. -
Each file in the
spec/systemdirectory is expected to follow the<description_of_system_test>_spec.rbformat. -
The top level RSpec
describeblock needs thesystem: truemetadata to be present. Example:RSpec.describe "Testing A Theme or Theme Component", system: true do it "displays the component" do ... end end -
upload_themeandupload_theme_componenthelper methods are available and needs to be called before the tests are ran. Example:RSpec.describe "Testing A Theme or Theme Component", system: true do let!(:theme) do upload_theme end # or `upload_theme_component` if your theme is a component # # let!(:theme_component) do # upload_theme_component # end it "displays the component" do ... end end -
A theme's setting can be changed in the system test by calling the
update_settingmethod on thethemeobject and then saving the theme.Example:
RSpec.describe "Testing A Theme", system: true do let!(:theme) do upload_theme end it "does not display the theme when `should_render` theme setting is false" do theme.update_setting(:should_render, false) theme.save! expect(page).not_to have_css("#some-identifier") end end -
Discourse uses the fabrication gem which allows us to easily setup the test data which we need for each test. The full list of fabricators available in Discourse core can be used in the theme's system test as well.
Example:
RSpec.describe "Testing A Theme", system: true do let!(:theme) do upload_theme end it "displays the theme" do user = Fabricate(:user) category = Fabricate(:category) topic = Fabricate(:topic) topic_2 = Fabricate(:topic, title: "This is topic number 2") ... end end -
Use the
sign_inhelper method to test against different user profiles.Example:
RSpec.describe "Testing A Theme", system: true do let!(:theme) do upload_theme end it "does not display the theme for a regular user" do user = Fabricate(:user) sign_in(user) ... end it "displays the theme for a staff user" do admin = Fabricate(:admin) sign_in(admin) ... end end -
Sometimes you'll want to make querying and inspecting parts of the page easier and more reusable for your system tests. To do that you can use the concept of PageObjects, which you'll see done often in core.
Example:
# frozen_string_literal: true module PageObjects module Components class MyCustomComponent < PageObjects::Components::Base COMPONENT_SELECTOR = ".my-custom-component" def click_action_button find("#{COMPONENT_SELECTOR} .action-button").click end def has_content?(content) has_css?("#{COMPONENT_SELECTOR} .content", text: content) end end end endand you can then use it by importing it with Ruby's built-in
require_relativeat the top of your system test file.require_relative "page_objects/components/my_custom_component"
Running theme system tests
Theme system tests can be run using the discourse_theme CLI rubygem and can be installed with these instructions.
Once the discourse_theme CLI has been installed, you can run all the system tests in your theme directory by running the following command:
discourse_theme rspec .
On the first run of the rspec command for a new theme, you will be prompted on whether you would like to run the system tests using a local Discourse development environment or a Docker container which will have the development environment configured for you. Unless you are a seasoned Discourse plugin or theme developer, we recommend selecting n and run the tests using a Docker container since everything will just work out of the box.
The discourse_theme rspec command also supports running a single spec directory, file and file with line numbers.
discourse_theme rspec /path/to/theme/spec/system
discourse_theme rspec /path/to/theme/spec/system/my_system_spec.rb
discourse_theme rspec /path/to/theme/spec/system/my_system_spec.rb:12
Headful mode
By default, the theme system tests are ran using Google Chrome in the headless mode. This is a mode where the browser does not render anything on screen allowing test runs to complete faster. However, it is often useful to be able to see what the system test you have written is doing by using Google Chrome in the headful mode. You can enable this mode by passing the --headful option to the discourse_theme rspec command.
discourse_theme rspec . --headful
The above command will run the system tests in headful mode where the running of the tests can be seen visually.
You can also pause the execution of the test in your test case by using the pause_test helper method, allowing you to inspect the current state of the application in the browser.
Example:
RSpec.describe "Testing A Theme", system: true do
let!(:theme) do
upload_theme
end
it "displays the theme" do
visit("/")
click("#some-button")
pause_test
# ...
end
end
Minimizing Maintenance on Theme Customizations
Discourse is highly customizable, enabling you to modify almost any aspect of its appearance through themes.
To maintain compatibility with ongoing Discourse updates and new features, all themes require occasional maintenance. The frequency of maintenance depends on the customization complexity and type. You can minimize maintenance efforts for your theme by following these guidelines:
- Check for official themes or theme components that match your desired functionality. These are updated alongside Discourse. These can also serve as examples of how to approach your own customizations.
- Replace interface text using the admin → customize → text feature by searching for the specific text and updating it there.
- Theme CSS is additive, allowing you to override default styles without editing them directly. This approach improves CSS maintainability and minimizes conflicts with updates.
- Use a version control system like Git with GitHub, GitLab, or Bitbucket for tracking changes. While the HTML and CSS editor at admin → customize → themes is convenient for minor adjustments, version control systems can make it easier to track and troubleshoot more complex changes.
- For advanced customizations, create independent modules for new functionality and integrate them through plugin outlets. Discourse uses Ember.js, so building Ember components is ideal. This method isolates custom functionality, reduces maintenance, and helps avoid conflicts with Discourse updates.
Overriding default Discourse JavaScript and HTML templates within a theme should be a last resort, as these changes are more likely to be incompatible with Discourse updates, can be difficult to troubleshoot, and are more prone to errors resulting in downtime.
For more information on using and building Discourse themes, feel free to ask questions on our Meta community, and take a look through our theming guides:
- Beginner’s Guide to using Discourse Themes
- Designer’s Guide to Discourse Themes
- Developer’s guide to Discourse Themes
- How to enable Safe Mode to troubleshoot issues with themes and plugins
- Structure of themes and theme components
- Create and share a font theme component
- How to create and share a color scheme
- How to use Discourse core variables in your theme
- How to add settings to your Discourse theme
- Theme Creator, create and show themes without installing Discourse!
Change font sizes in your themes
The simplest way to change the font size of your entire community would be to override the default value on the HTML element in your theme's CSS, like this:
html {
font-size: 17px; /* default is 16px */
}
Because all of our font-size values within <HTML> are defined with em units, increasing the font-size on <HTML> will proportionately increase the font-size of all other elements (ems are relative units).
Discourse also comes with user-selectable text size options that can be changed in each users preferences (/my/preferences/interface), by default these are:
Smallest: 13px
Smaller: 14px
Normal: 16px (default)
Larger: 18px
Largest: 20px
When you change the font-size of <HTML> as demonstrated above, you're only changing the Normal value. So if you want the user text-size settings to continue working in your theme you should also increase the font-size for the other options. If you wanted to increase the font-size of all options by 1px, that would look like this:
:root {
--base-font-size-smallest: 14px;
--base-font-size-smaller: 15px;
--base-font-size: 17px;
--base-font-size-larger: 19px;
--base-font-size-largest: 21px;
}
Changing font-size of individual components
You might not want to increase the global font-size of your community, and just change the font-size of a specific component, like the header or posts. If you're familiar with CSS, you can target individual elements as expected.
For example, to increase the font-size of all the content within a post you can do this:
.topic-post {
font-size: 1.2em;
}
If you wanted to change the text size of the post content, but not the usernames and other metadata you need to be a little more specific (right click on an element and use your browser's inspector if you need to figure out which element to target)...
.topic-post .contents {
font-size: 1.2em;
}
Note that in the above examples I'm using em units. You can use px values here, but the benefit of ems is that they're relational.
If you used pixel units in the above examples, those font sizes would stay the same even if a user changed the text size setting in their preferences. A static value like 16px is always 16px. But when you use a value like 1.2em, it acts as a multiplier... so if someone chooses a larger text size in their settings the font-size will always scale up to be 1.2x larger than the base setting.
Utilizing Discourse's font-scaling variables
In Discourse's default styles we rely on a set of font scaling variables. You can also use these variables in your themes:
:root {
--font-up-6: 2.296em;
--font-up-5: 2em;
--font-up-4: 1.7511em;
--font-up-3: 1.5157em;
--font-up-2: 1.3195em;
--font-up-1: 1.1487em;
--font-0: 1em;
--font-down-1: 0.8706em;
--font-down-2: 0.7579em;
--font-down-3: 0.6599em;
--font-down-4: 0.5745em;
--font-down-5: 0.5em;
--font-down-6: 0.4355em;
}
This system ensures we're using a limited set of font sizes that scale based on the global size set on html (and saves you from doing math when nesting em units). If an element is set to --font-up-3, we know that it will be 1.5x larger than --font-0, no matter what the specific px value is.
If you feel a bit lost, it might help to visualize these variables like a ladder. If you have an element with font-size: var(--font-up-3) and needed a child of that element to be the equivalent to --font-0, you would need to go 3 steps down the ladder to get there (so you'd use --font-down-3).
Here it is in action:
.topic-post {
font-size: var(--font-up-3); // 3 steps up
.topic-meta-data {
font-size: var(
--font-down-3
); // 3 steps back down; equivalent to --font-0 (1em)
}
}
Include assets (e.g. images, fonts) in themes and components
Themes and theme components allow you to handle uploaded assets such as images and fonts. You can control what assets your theme accepts using the site setting: theme authorized extensions
Including assets in themes and components
For remote themes
Remote themes include:
- Themes and components installed from the Popular list of themes
- Themes and components installed using the From a git repository method
To upload assets to a remote theme or theme component, see https://meta.discourse.org/t/create-and-share-a-font-theme-component/62462 for a detailed example.
Uploading assets to local themes and components
Local themes include:
- The default Light and Dark themes that ship with every Discourse instance
- Themes and components created using the + Create New installation method
- Themes and components that were uploaded from your local computer using the From your device installation method.
To upload assets to a local theme or theme component, navigate to your theme or component and select + Add in the Uploads section:

In the Add Upload modal, choose a file and enter a SCSS variable name to use with with your CSS, javascript, and/or handlebars customizations

Once uploaded, the asset will pop up in the Uploads list:

:warning: Important Note: Do not add uploads to themes and components through the admin interface if the component was installed remotely from a git repository. Updates to the component will clear out any uploads that were not included in the git repository.
How to make use of the assets
SCSS
@font-face {
font-family: Amazing;
src: url($Comic-Sans) format("woff2");
}
body {
font-family: Amazing;
font-size: 15px;
}
.d-header {
background-image: url($texture);
}
:informationsource: _When using
ttforotffonts, be sure to use a format ofopentype.
Javascript
// {theme}/javascripts/discourse/api-initializers/init-theme.gjs
import { apiInitializer } from "discourse/lib/api";
export default apiInitializer((api) => {
console.log(settings.theme_uploads.balloons);
});
HTML
You can't directly add a theme asset to vanilla HTML (like in the header or after header sections of the customize admin panel). You have to use a handlebars template or the plugin API (more about those in https://meta.discourse.org/t/developer-s-guide-to-discourse-themes/93648).
One workaround is to load your asset as a background image using SCSS (like the example above). So in the header section of your theme you might add:
<div class="my-custom-div">
<a href="/"></a>
</div>
and then in your CSS:
.my-custom-div a {
height: 50px;
width: 100px;
background-image: url($asset-name);
}
Accessing theme uploads as settings
You may also treat theme_uploads as settings in JavaScript (related commit).
This allows themes and components access to theme assets.
Inside theme js you can now get the URL for an asset with:
settings.theme_uploads.name
Additional Information
- https://meta.discourse.org/t/create-and-share-a-font-theme-component/62462
Replace Discourse's default SVG icons with custom icons in a theme
You can replace a Discourse's default SVG icons individually or as a whole with your own custom SVG and override them within a theme or theme component.
Step 1 - Create an SVG Spritesheet
To get started, you must create an SVG Spritesheet. This can contain anything from a single additional custom SVG icon up to an entire replacement set of hundreds.
The spritesheet should be saved as an SVG file. In principle, you are nesting the <svg> tag contents from the original SVG icon file into <symbol> tags and giving them a nice identifier.
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" style="display: none;">
<symbol id="my-theme-icon-1">
<!--
Code inside the <svg> tag from the source SVG icon file
this is typically everything between the <svg> tags
(but not the SVG tag itself, that's replaced by <symbol> above)
You can transfer any attributes (i.e. ViewBox="0 0 0 0") to the <symbol> tag
-->
</symbol>
<symbol id="my-theme-icon-2">
<!-- SVG code here. Add more <symbol> blocks as needed.
-->
</symbol>
</svg>
-
Be sure to add a custom ID to each symbol in the spritesheet. It's probably helpful for your sanity to prefix your IDs with your theme name
my-theme-icon. -
To have the icon color to be dynamic like the existing icons, set the fill to
currentColorrather than a hardcoded color (like #333) -
To scale or correctly centre your icon, utilise a
viewBoxattribute on the<symbol>tag. See https://css-tricks.com/scale-svg/#:~:text=The%20viewBox%20is%20an%20attribute,%2C%20y%2C%20width%2C%20height.&text=Likewise%2C%20the%20height%20is%20the,to%20fill%20the%20available%20height for more information. -
Be on the lookout for style collisions within your SVGs. For example, SVGs will often have an inline style like
.st0{fill:#FF0000;}defined. If you have multiple SVGs using the same classes this can cause issues (to fix these issues, edit the classes to be unique to each icon). -
If you have many icons, there are ways to automate this. https://www.npmjs.com/package/svg-sprite-generator is a simple command line tool for combining SVGs into a spritesheet.
Example - single custom icon spritesheet
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" style="display: none;">
<symbol id="bat-icon" viewBox="6 6 36 36">
<path
fill="currentColor"
d="M24,18.2c0.7,0,0.9,0.2,0.9,0.2l0.4-1.7c0,0,0.4,1.5,0.4,2.8c0.2,1.1,2.2,0.4,3.9,0C31.4,19.1,32,16,32,16h16c0,0-9.4,3.5-7,10c0,0-14.8-2-17,7l0,0c-2.2-9-17-7-17-7c2.4-6.5-7-10-7-10h16c0,0,0.6,3.1,2.3,3.5c1.7,0.4,3.9,1.1,3.9,0c0.2-1.1,0.4-2.8,0.4-2.8l0.4,1.7C23.1,18.4,23.4,18.2,24,18.2L24,18.2L24,18.2z"
/>
</symbol>
</svg>
Step 2 - Add the spritesheet to your theme
Once your spritesheet is built, you need to add the SVG file to your component/theme. This is easy via the UI, or you can hard code it into a component/theme.
:information_source: Once it is uploaded to any installed component/theme, it is available throughout your instance using the ID in the
<symbol>tag.
Via the UI
Go to the Uploads section of the theme/component settings and add your sprite file with a SCSS var name of icons-sprite:
Hardcode into a Theme / Component
Add the spritesheet file to the Theme's /assets folder. Then update your assets.json file in the root folder.
For an SVG sprite called my-icons.svg, your about.json should include this:
"assets": {
"icons-sprite": "/assets/my-icons.svg"
}
Step 3 (optional) - Overriding default icons
Now that your spritesheet is set, you can tell Discourse to replace icons. This is how you do it from an api-initializer:
// {theme}/javascripts/discourse/api-initializers/init-theme.gjs
import { apiInitializer } from "discourse/lib/api";
export default apiInitializer((api) => {
api.replaceIcon("bars", "my-theme-icon-bars");
api.replaceIcon("link", "my-theme-icon-link");
// etc.
});
The first ID, bars, is the default icon ID in Discourse and the second is the ID of your replacement icon. The easiest way to find an ID of one of our icons is to inspect the icon in your browser.
Here the icon name follows the d-icon- prefix. So in this example it's d-unliked
![]()
Most of our icons follow the icon names from https://fontawesome.com/, but there are exceptions (which is why checking the ID in your inspector is the most reliable method). You can see all the exceptions in the const REPLACEMENTS block here on github.
That's it. You can now style Discourse with your own custom icons!
Update themes and plugins to support automatic dark mode
Previously, all colors in Discourse were stored as SCSS variables. To support automatic dark mode color scheme switching, we have converted these colors in core to custom CSS properties. You can easily see the full list in the inspector now:

Themes and plugins need to switch all the $color SCSS variables used in stylesheets to the --color CSS property equivalents. In most cases, this is a simple find and replace task:
- background-color: $primary-very-low;
+ background-color: var(--primary-very-low);
But there are some cases where a theme or a plugin is using a more complex variation of a color, for example, when darkening or lightening using SCSS color functions. These cases require a more complex refactoring, and for this we have added the capacity to extend color definitions in themes and plugins.
In plugins
This commit in the discourse-encrypt plugin is a good and simple example of such a refactor. It moves a mix($color1, $color2) SCSS declaration into a separate file and stores it as a CSS custom property. Then the new file is registered as a :color_definitions asset which ensures that the newly declared color property is included in the color definitions stylesheet.
In themes
In themes, you can do the same thing by declaring CSS custom properties in the common/color_definitions.scss stylesheet. You can look at this commit in the graceful theme for an example.
Some additional notes
- when using transparent colors via the
rgba($color, 0.5)function, SCSS accepts HEX and RGB colors in the first parameter, whereas CSS custom properties only accept an RGB color. That is why we have introduced thehexToRGB()helper and some properties with the--rgbsuffix in the color definitions. An example:
// color_definitions.scss
:root {
--primary: #{$primary};
--primary-rgb: #{hexToRGB($primary)};
}
// other stylesheet
.element {
background-color: rgba(var(--primary-rgb), 0.05);
}
- note that in the snippet above, the SCSS variable is interpolated when passed to a custom property. That is a requirement in SCSS, see https://sass-lang.com/documentation/style-rules/declarations for more details.
- the CSS
var()declaration can fallback to a second value if the first one is not available, as in, when writingvar(--color1, red), CSS will fallback to the red color if the--color1property is not found. In plugins, we use the SCSS color variables as fallbacks to ensure compatibility with previous versions of Discourse. So the earlier example, would look like this with a fallback:
- background-color: $primary-very-low;
+ background-color: var(--primary-very-low, $primary-very-low);
Theme modifiers: A brief introduction
As themes become more ambitious, we've been looking for ways to allow them to manipulate core server-side behavior. While they will never be given the same level as control as plugins, we can provide some predefined hooks for themes to manipulate.
Introducing: theme modifiers :partying_face:
They are specified using the modifiers key in your theme's about.json file.
For a 100% up-to-date list of modifiers, check the database schema at the bottom of theme_modifier_set.rb, but here's a quick summary of what we have so far:
-
serialize_topic_excerptsboolean (default false) - always include excerpts when serializing topic lists -
csp_extensionsstring array - add directives to the CSP. Works the same as the old "extend_content_security_policy" theme-setting method. But remember, simple<script src="">tags are allowed automatically. -
svg_iconsstring array - a list of icons which should be included in the icon subset -
topic thumbnailsarray of dimensions - request additional resolutions in the topic thumbnail set. Note that they are generated asynchronously, so you must fall-back to the original image if your requested size is not provided. More information available in the commit message -
serialize_post_user_badgesstring array - a list of badge names (matching entries in the badges table) to serialize alongside post data. When configured, the system includes the specified user badges with each post for client-side rendering.
One theme making heavy use of these new hooks is https://meta.discourse.org/t/topic-list-thumbnails-theme-component/150602?u=david - check out the code to see how it works.
Setting-dependent modifiers
Theme modifiers can also be configured to pull their value from a theme setting, allowing site operators to override modifier behavior without editing the theme's code. To make a modifier depend on a setting, use this syntax in your about.json:
{
"modifiers": {
"modifier_name": {
"type": "setting",
"value": "setting_name"
}
}
}
For example, if you have a theme setting called show_excerpts and want it to control the serialize_topic_excerpts modifier:
In settings.yml:
show_excerpts:
default: false
In about.json:
{
"modifiers": {
"serialize_topic_excerpts": {
"type": "setting",
"value": "show_excerpts"
}
}
}
When the show_excerpts setting is changed, the modifier value will automatically update to match. This provides flexibility for site operators to customize theme behavior through the admin UI.
Insert text or images anywhere on your site using CSS pseudo-elements
So, you want to insert some text or image in your Discourse site.
Regarding the text in most cases it is sufficient to modify it from /admin/customize/site_texts.
Sometimes, however, it is our desire to add a sentence between two distinct blocks, rather than modifying one of the existing elements.
Let's see how to do it using the property :before and :after in CSS3.
Basic steps
-
In order to work we will need to use the developer console present on the main browsers. To open it, just press F12.

-
Select the element where,
beforeorafter, you want to add text.
As you can see, every time the mouse passes on an element it will be highlighted and the corresponding class will be automatically selected on HTML and CSS on the right. Before editing the Discourse stylesheet, do a live test, so after choosing the element on which to operate just click the :heavy_plus_sign: icon. These will add a new rule to the CSS that we can modify according to our needs.
-
Start to edit. Add the suffix
:beforeor:afterto the class, and add a text using thecontentproperty.Adding an image is a bit more complicated if you do not know the CSS, but it is good practice to follow a similar pattern:
background-image: url("url-goes-here"); background-repeat: no-repeat; background-size: your-value; content: ""; width: your-value; height: your-value; display: inline-block;Before:

And after:

Remember that the text will appear wherever the specific class you have selected is used. Sometimes you need to specify on which element you want the new text to appear, adding the parent element to the CSS.
-
Customize it. Knowing a bit about CSS it is easy to customize the style of the text as you want.

.fancy-title::after { content: "ANOTHER TEXT " "\f072"; color: violet; font-family: Fontawesome; background: linear-gradient(to right, #7ce5df 27%, #f1da36 100%); font-size: 18px; padding: 2px 4px; border: 1px solid; } -
Once satisfied, add your changes to the CSS of your site by creating a theme component.
We proceed with some practical examples:
-
Topic Title
For some sites it may be useful to add an image, a banner or a personalized advertisement before or after the title or each post.

#topic-title::before { background-image: url(your-URL-here); background-repeat: no-repeat; background-size: 750px 335px; width: 750px; height: 335px; display: inline-block; content: ""; } #topic-title::after { background-image: url(your-URL-here); background-repeat: no-repeat; background-size: 800px 295px; width: 800px; height: 295px; display: inline-block; content: ""; } -
Topic Body

.topic-body.clearfix::before { background-image: url(your-URL-here); max-height: 2.8571em; width: 690px; height: 184px; background-size: auto 2.8571em; background-repeat: no-repeat; margin-left: 11px; margin-bottom: 0.25em; }Or after:

Just change
.topic-body.clearfix::beforeto.topic-body.clearfix::after. In the same way, it is possible to add a plain textbeforeorafter:.topic-body.clearfix::before { content: "DISCOURSE ROCKS!"; color: red; font-weight: bold; padding-left: 11px; }

-
Post Buttons


.nav.post-controls .actions::before { color: red; content: "Hello from Discourse"; } -
Timeline

.topic-timeline::before { color: red; content: "Hello World"; } .topic-timeline::after { color: red; content: "Hello again"; } .timeline-scroller-content::before { color: violet; content: "Hey,"; } .timeline-scroller-content::after { color: violet; content: "It's me again!"; } .timeline-container .topic-timeline .start-date::before { color: goldenrod; content: "Start Date "; } timeline-container .topic-timeline .start-date::after { color: goldenrod; content: " \f060"; font-family: Fontawesome; } .widget-link.now-date::before { content: "\f061 "; color: burlywood; font-family: Fontawesome; } .widget-link.now-date::after { color: burlywood; content: " Now Date"; } -
Footer Buttons

#topic-footer-buttons::before { content: "THESE ARE FOOTER BUTTONS"; color: indianred; border: 2px solid; padding: 3px; } #topic-footer-buttons::after { color: indianred; content: "CONTENT AFTER GO HERE"; border: 2px solid; }In the latter case, it should be noted that the
:aftercontent is inserted after a text. If you do not need special customizations, it is advisable to change the original text via/admin/customize/site_textsinstead of editing the CSS. -
Suggested Topics

#suggested-topics::before { content: ""; background-image: url(https://d11a6trkgmumsb.cloudfront.net/original/3X/1/0/101f03af29f12ea30e1226eb96a02c3ed2f6d2ef.png); width: 690px; height: 184px; background-size: 690px 184px; background-repeat: no-repeat; display: inline-block; } #suggested-topics::after { content: ""; background-image: url(https://d11a6trkgmumsb.cloudfront.net/original/3X/1/0/101f03af29f12ea30e1226eb96a02c3ed2f6d2ef.png); width: 690px; height: 184px; background-size: 690px 184px; background-repeat: no-repeat; display: inline-block; } -
Topic Actions

.widget-button.btn-flat.share.no-text.btn-icon::after { content: " Share"; } .widget-button.btn-flat.toggle-like.like.no-text.btn-icon::after { content: " Like"; }
Ref: https://meta.discourse.org/t/insert-text-disclaimer-anywhere-in-discourse/99009
Note: The pseudo-elements generated by
::beforeand::afterare contained by the element's formatting box, and thus don't apply to replaced elements such as<img>, or to<br>elements. Source: https://developer.mozilla.org/en-US/docs/Web/CSS/::after
In other words, this won't work for "self closed" elements that can't have child elements. It is intuitive to think the pseudo-elements are like:
{::before is here}<tag>text content</tag>{::after is here}
when in reality it is
<tag>{::before is here}text content{::after is here}</tag>
Create and share a font theme component
Discourse supports importing themes containing assets from a remote repository.
This allows theme authors to share fonts and images.
Create a new git repository with the font
mkdir discourse-roboto-theme
cd discourse-roboto-theme
git init .
vim about.json
For about.json add a skeleton config file
{
"name": "Roboto theme component",
"about_url": "",
"license_url": "",
"assets": {
"roboto": "assets/roboto.woff2"
}
}
Add a LICENSE file, I usually use MIT
vim LICENSE
Download the font
http://localfont.com/ is a handy site to get fonts
mkdir assets
cp ~/Downloads/roboto.woff2 roboto.woff2
Add CSS that consumes the theme
mkdir common
cd common
Create a file called common.scss with
@font-face {
font-family: Roboto;
src: url($roboto) format("woff2");
}
body {
font-family: Roboto;
}
Push changes to GitHub
Check in all your changes:
git add LICENSE
git add about.json
git add assets/roboto.woff2
git add common/common.scss
git commit -am "first commit"
Create an account on GitHub.com and then create a new repository.
(Optional) create a topic on Discourse as a home to discuss your colors
Ideally you would create a topic in the #customization:theme category with some screenshots of your color scheme. You will use this as your about_url
Fill in the missing information in your about.json file
At the end of the process your about.json file will look something like:
{
"name": "Roboto theme component",
"about_url": "https://github.com/SamSaffron/discourse-roboto-theme",
"license_url": "https://github.com/SamSaffron/discourse-roboto-theme/blob/master/LICENSE",
"assets": {
"roboto": "assets/roboto.woff2"
}
}
Check in the change and push to GitHub
git commit -am "added more details"
git push
Test your font component
- In the
admin/customize/themescreen import your theme from GitHub

:confetti_ball:
You can now easily share fonts!

See also:
https://meta.discourse.org/t/how-to-develop-custom-themes/60848
Install a new language for Highlight.JS via a theme component
I've just added a hook to our HighlightJS code so now you can use the plugin API to add a custom language for Highlight.JS.
Example (using the beancount language definition):
const beancountLang = function(e){var c="[A-Z][A-Za-z0-9-]*",a="[0-9]{4}[-|/][0-9]{2}[-|/][0-9]{2}",b="(balance|commodity|custom|document|event|note|open|pad|price|query)",t={cN:"literal",b:/([\-|\+]?)([\d]+[\.]?[\d]*)/,r:0},n={cN:"string",b:'"',e:'"',r:0,c:[e.BE]},s={cN:"name",b:"\\{",e:"\\}",c:[{cN:"literal",b:a},t,n,{cN:"subst",b:"[A-Z][A-Z0-9'._-]{0,22}[A-Z0-9]"}]};return{aliases:["beancount","bean","ledger"],c:[{cN:"built_in",b:"^(include|option|plugin|popmeta|poptag|pushmeta|pushtag)",r:0},{b:"^"+a+"\\s+"+b,rB:!0,r:10,c:[{cN:"type",b:a,e:/\s+/,eE:!0},{cN:"keyword",b:b}]},{b:"^"+a+"\\s+.\\s",rB:!0,r:10,c:[{cN:"type",b:a,e:"\\s+",eE:!0},{cN:"variable",b:".",endsParent:!0}]},e.C(";","$"),{cN:"meta",b:/^\s{2,}[a-z][A-Za-z0-9\-_]+:/},s,{cN:"name",b:"@"},{cN:"type",b:c+":",r:0,c:[{cN:"subst",b:c+"(:"+c+")?"}]},{cN:"section",b:/^\*\s+?.*/},{cN:"link",b:/\^[A-Za-z0-9\-_\/.]+/},{cN:["emphasis"],b:/#[A-Za-z0-9\-_\/.]+/},n,t]}}
api.registerHighlightJSLanguage("beancount", beancountLang);
Official theme components which use this API:
-
https://github.com/discourse/discourse-highlightjs-glimmer
-
https://github.com/discourse/discourse-highlightjs-structured-text
Add custom content that only appears on your homepage
A very common situation you'll find yourself in as a theme developer is the need to create content that only shows on the homepage of your community.
You might add some HTML to the "After Header" section of your theme, which will then appear on every page. You can jump through some hoops in CSS to hide this everywhere except the homepage… but instead let's use a Discourse theme to create a component with content that is only visible on your homepage.
If you're unfamiliar with Discourse themes check out https://meta.discourse.org/t/beginners-guide-to-using-discourse-themes/91966 and https://meta.discourse.org/t/structure-of-themes-and-theme-components/60848
In your Discourse theme you'll need to setup the following directory structure:
:file_folder: javascripts/discourse/components/
:file_folder: javascripts/discourse/connectors/
From here we're going to create an Ember component. You can find more about Ember components from their documentation: https://guides.emberjs.com/release/
But for now this will be a simple component, written as a single .gjs file containing both the logic and the template.
:page_facing_up: javascripts/discourse/components/custom-homepage-content.gjs
import Component from "@glimmer/component";
import { service } from "@ember/service";
import { defaultHomepage } from "discourse/lib/utilities";
export default class CustomHomepageContent extends Component {
@service router;
get isHomepage() {
const { currentRouteName } = this.router;
return currentRouteName === `discovery.${defaultHomepage()}`;
}
<template>
{{#if this.isHomepage}}
<h1>This is my homepage HTML content</h1>
{{/if}}
</template>
}
This creates a isHomepage getter, which checks the router service for the currentRouteName — if the route name matches your homepage (as dictated by site settings) then it will return true. The template inside <template>...</template> checks that getter and only displays your content when it's true. You can add any HTML you want between the {{#if}} blocks.
Now that our component is created, we need to add it to Discourse somewhere. For this step you'll need to decide which plugin outlet to utilize. These are areas throughout Discourse where we've added a little code for developers to hook into. You can search Discourse for these on Github, or browse for them using the https://meta.discourse.org/t/plugin-outlet-locations-theme-component/100673/1/.
For custom homepages, above-main-container is a common choice, so let's use that.
We need to create our connector file in the correct directory:
:page_facing_up: javascripts/discourse/connectors/above-main-container/custom-homepage-connector.gjs
import CustomHomepageContent from "../../components/custom-homepage-content";
<template><CustomHomepageContent /></template>
:point_up: and that's all, it just needs a single line calling for your component :tada:

Message Format support for localization
In Discourse all Message Format strings have a key that ends with "_MF". The Crowdin Editor has a preview that allows you to experiment with various values which is quite helpful for checking your translations.

Important: You need to modify the plural forms of your translation if your language uses forms other than "one" and "other". You can add or remove forms within a { FOO, plural, ... } block in order to make it work for your languages. The preview in the Crowdin Editor will show you a "Syntax error" in the preview when there's a problem with your translation.
The available plural forms are:
- zero
- one
- two
- few
- many
- other (required — general plural form — also used for languages with no distinction between singular and plural forms)
Sometimes you might see an empty =0 {} block in an English source string which means that the variable can't have a value of 0.
Guidelines for Developers
Message Format strings are currently only available only for client-side translations. They are useful if your string contains more than one number or many variables which would lead to a high amount of permutations.
-
The keys of Message Format strings need to end with "_MF"
-
Use the following format for numbers (
#will be replaced with the number):{ variable_name, plural, one: {# singular text} other: {# plural text} } -
Use the following format for choices:
{ variable_name, select, foo: {This is foo} bar: {This is bar} baz: {This is baz} } -
This is how you use it in JavaScript:
I18n.messageFormat("key_MF", { variable1: "foo", variable2: 42, }); -
:loudspeaker: Recommendation: If possible, use complex arguments as the outermost structure of a message, and write full sentences in their sub-messages. If you have nested select and plural arguments, place the select arguments (with their fixed sets of choices) on the outside and nest the plural arguments (hopefully at most one) inside. See https://unicode-org.github.io/icu/userguide/format_parse/messages/
-
Make them readable -- see existing examples in client.en.yml
❌ Bad
There {currentTopics, plural, one {is <strong>#</strong> topic} other {are <strong>#</strong> topics}}. Visitors need more to read and reply to – we recommend at least { requiredTopics, plural, one {<strong>#</strong> topic} other {<strong>#</strong> topics}}. Only staff can see this message.💚 Good
There { currentTopics, plural, one {is <strong>#</strong> topic} other {are <strong>#</strong> topics} }. Visitors need more to read and reply to – we recommend at least { requiredTopics, plural, one {<strong>#</strong> topic} other {<strong>#</strong> topics} }. Only staff can see this message.
Tools and further information
-
https://format-message.github.io/icu-message-format-for-translators/editor.html allows you to test a Message Format string in case there's a problem with the Crowdin Editor. Please disable the "Parse simple xml/html tags" option, otherwise you might see an error message.
-
https://format-message.github.io/icu-message-format-for-translators/
Change icons globally
This is an easy way to change a Discourse icon globally.
-
Right click on the icon you want to change and select "Inspect element" or "Inspect" (depends on the browser)
-
Find the icon name

-
Search a new icon here https://fontawesome.com/icons?d=gallery, e.g. external-link-alt
-
Customize and add the code in your
admin > customize > themes > edit code -> JStab
// {theme}/javascripts/discourse/api-initializers/init-theme.gjs
import { apiInitializer } from "discourse/lib/api";
export default apiInitializer((api) => {
api.replaceIcon("link", "external-link-tab");
});
- Icons that are not used by default from Discourse must be added in the site setting
svg icon subsetthen force refresh your browser to see the changes applied. Result:
All the "link" icons will be replaced by "external-link-tab".
If an icon is used for multiple elements in other pages, such as badges, the icon will also be replaced there.
Exceptions
Note that there is already a theme component that allow you to change the Like icon. I'm using this case as example
The "heart" icon, used to give Like, is hardcoded with other names ('d-liked' and 'd-unliked') and should be treated differently than other icons, so to change the :heart: icon with :+1: icon:
api.replaceIcon("d-liked", "thumbs-up");
api.replaceIcon("d-unliked", "thumbs-o-up");
but on the badge page the icon is still "heart":
so to change it on that page we add:
api.replaceIcon("heart", "thumbs-up");
![]()
Another example:
api.replaceIcon("d-watching", "eye");
changes the watching icon:
[details="See here other exceptions that cover the tracking status, expand/collapse, notifications and likes of course."]
const REPLACEMENTS = {
"d-tracking": "bell",
"d-muted": "discourse-bell-slash",
"d-regular": "far-bell",
"d-watching": "discourse-bell-exclamation",
"d-watching-first": "discourse-bell-one",
"d-drop-expanded": "caret-down",
"d-drop-collapsed": "caret-right",
"d-unliked": "far-heart",
"d-liked": "heart",
"d-post-share": "link",
"d-topic-share": "link",
"notification.mentioned": "at",
"notification.group_mentioned": "users",
"notification.quoted": "quote-right",
"notification.replied": "reply",
"notification.posted": "reply",
"notification.edited": "pencil-alt",
"notification.bookmark_reminder": "discourse-bookmark-clock",
"notification.liked": "heart",
"notification.liked_2": "heart",
"notification.liked_many": "heart",
"notification.liked_consolidated": "heart",
"notification.private_message": "far-envelope",
"notification.invited_to_private_message": "far-envelope",
"notification.invited_to_topic": "hand-point-right",
"notification.invitee_accepted": "user",
"notification.moved_post": "sign-out-alt",
"notification.linked": "link",
"notification.granted_badge": "certificate",
"notification.topic_reminder": "far-clock",
"notification.watching_first_post": "discourse-bell-one",
"notification.group_message_summary": "users",
"notification.post_approved": "check",
"notification.membership_request_accepted": "user-plus",
"notification.membership_request_consolidated": "users",
"notification.reaction": "bell",
"notification.votes_released": "plus",
"notification.chat_quoted": "quote-right",
};
Ref: discourse/icon-library.js at main · discourse/discourse (github.com) [/details]
Feel free to create other themes component and share it in our #customization:theme-component category!
Alternative icons
By default, Discourse bundles icons from the excellent FontAwesome library. But there are a lot of free-to-use icon libraries out there. This topic is the home of alternative icon sets. It lists some theme components that you can try now, but it also describes how you can contribute to have Discourse support other icon sets.
Alternative icon sets
If you are interested in trying out a different icon set, here is a list of theme components that you can try out:
- https://github.com/discourse/discourse-feather-icons
- https://github.com/discourse/discourse-heroicons
- https://github.com/discourse/discourse-heroicons-outline
- https://github.com/discourse/discourse-phosphor-duotone-icons
- https://github.com/discourse/discourse-unicons
- https://github.com/discourse/discourse-material-design-icons-filled
- https://github.com/discourse/discourse-material-design-icons-outlined
(See How to install a theme or theme component for help getting one of these repos in your Discourse site.)
These components are a work in progress. You can use them, but note that not every single icon from core will have an appropriate replacement. (Contributions are welcome, though, see below.)
Some screenshots:
![]()
Feather icons (using the WCAG Dark color scheme)
![]()
Heroicons (using the WCAG Light color scheme)
![]()
Unicons (using the Grey Amber color scheme)
![]()
Phosphor Duotone (using the WCAG Light color scheme)
Contributing
Theme developers and designers, this section is for you.
The components listed above are generated using the discourse-alt-icons utility repository, which streamlines replacing icons in Discourse core with icons from other open source icon sets. The heart of the repository is a build script that generates a theme component from a JSON file of icon name mappings.
Contributions are welcome and encouraged. Discourse uses many icons from FontAwesome and finding matches from other icon sets is a fun task, but it is time-consuming. If you want to help, you can pull the discourse-alt-icons and follow the steps in the readme to add matches for icons that don't yet have them.
Supporting other icon sets
Please use the replies below to propose adding support for another icon set. Or, even better, with a little bit of work you can send a pull request to the discourse-alt-icons repository. Note, that only icon sets with very permissive licenses (i.e. must allow modification, distribution, private use) will be considered.
Change the style of a wiki post
A quick guide to change the style of your wiki posts:

Change the background color:
In your /admin/customize/themes add this CSS under Common > CSS
.wiki .topic-body .cooked {
background-color: #dcffdc; /*light green*/
}
Change text size
If you want you can also change the font used or the size of the text, even the color
.wiki .topic-body .cooked p {
font-size: 18px;
color: green;
}

Add text
You can add a text to make clear to users that what they are watching is a wiki post
.post-info.edits .wiki::before {
content: "EDIT THIS WIKI POST";
color: green;
background-color: #d2e2d2;
margin-right: 3px;
font-weight: bold;
border: 1px solid green;
padding: 3px;
}

Create and share a color scheme
Discourse now supports importing color schemes from remote repository. Here I will demonstrate how you would go about doing this.
Navigate to colors and add a color scheme.
Head to /admin/customize/colors on your site and create a color scheme.

Tips:
-
I used a chrome plugin (color picker) to select colors from an existing image of a palette on the web.
-
If you create a theme, assign the color scheme to the theme and preview it, changes will be reflected live.
Create a new git repository with the color scheme
mkdir my-awesome-scheme
cd my-awesome-scheme
git init .
vim about.json
For about.json add a skeleton config file
{
"name": "My awesome color schemes",
"about_url": "",
"license_url": "",
"color_schemes": {}
}
Add a LICENSE file, I usually use MIT
vim LICENSE
Push changes to GitHub
Check in all your changes:
git add LICENSE
git add about.json
git commit -am "first commit"
Create an account on GitHub.com and then create a new repository.
(Optional) create a topic on Discourse as a home to discuss your colors
Ideally you would create a topic in the #customization:theme category with some screenshots of your color scheme. You will use this as your about_url
Fill in the missing information in your about.json file
At the end of the process your about.json file will look something like:
{
"name": "Solarized",
"about_url": "https://github.com/SamSaffron/discourse-solarized",
"license_url": "https://github.com/SamSaffron/discourse-solarized/blob/master/LICENSE",
"color_schemes": {
"Solarized Light": {
"primary": "586E75",
"secondary": "EEE8D5",
"tertiary": "268BD2",
"quaternary": "CB4B16",
"header_background": "002B36",
"header_primary": "93A1A1",
"highlight": "B58900",
"danger": "CB4B16",
"success": "859900",
"love": "DC322F"
}
}
}
Check in the change and push to GitHub
git commit -am "added more details"
git push
Test your color scheme is correct
- Delete your local color scheme
- In the
admin/customize/themescreen import your theme from GitHub

- Visit
admin/customize/colorsand ensure your color scheme looks correct.
:confetti_ball:
You can now easily share your color scheme with others!
See also:
https://meta.discourse.org/t/how-to-develop-custom-themes/60848
Last Reviewed by @SaraDev on [date=2022-06-02 time=18:00:00 timezone="America/Los_Angeles"]
Use the `navigation_menu` query parameter to preview the sidebar or other options
If you are working on a theme for an existing site and want to preview how it will look with the sidebar or other options for the navigation menu site setting, you can use the ?navigation_menu query parameter.
This feature is designed to help theme developers who want their theme to support all modes equally well, or for those who are working on their own site's theme to prepare for migration from one configuration to another.
To preview a site with different options, append the desired query parameter to the URL of a page:
For example, you can try it out here on meta:
- https://meta.discourse.org?navigation_menu=sidebar
- https://meta.discourse.org?navigation_menu=header_dropdown
- https://meta.discourse.org?navigation_menu=legacy
Customize posts' contents with your own styles
Requirements
:information_source: To be able to use these tips and tricks, you need to be an administrator of either a self-hosted Discourse instance or a Discourse-hosted plan higher than Basic.
Introduction
Discourse supports several methods to format and customize a post's contents. You can find the list here: https://meta.discourse.org/t/supported-formatting-in-posts-markdown-bbcode-and-html/239348?u=canapin
But sometimes, you'll want something more specific, for example, a link that looks like a button.

This is the kind of modification we'll learn here.
The logic
I'll briefly explain the logic behind but you can go to the next step and jump into a practical example :)
Discourse allows any HTML attribute starting with data- in a post's content.
Those are the attributes we'll target with CSS to customize our content.
I'll call them data- attributes in this tutorial :)
One way to create elements with these attributes is a BBcode-like tag: [wrap], to which we'll add a value of our choice. Here we choose "button" (that could be anything else, even the name of your dog :dog:):
[wrap=button]some text[/wrap]
This will output an HTML element having the following attribute: data-wrap="button".
First example: a pink background
Let's start with a practical example. We'll create text with a pink background.
As a block element
In your post, on an empty line, write:
[wrap=pink]pink text[/wrap]

It will create a div element having the attribute data-wrap="pink".
Then, add the following CSS to your theme. Go to Admin panel -> Customize -> Themes -> your theme -> Edit CSS/HTML -> CSS.
Put the following CSS code inside:
[data-wrap="pink"] {
background: pink;
}
Then click the Save button.

Go back to your post, and see the result:

Yes, it is already beautiful :cherry_blossom:
You'll notice that the background covers the whole post width. Because our wrap is the only element on its line, it outputs a block element. You can learn more about the difference between blocks and inline HTML elements here: https://www.w3schools.com/html/html_blocks.asp.
If you want your pink background on multiple lines (still as a block), you'll need both your [wrap] tags having no other content or text on the same line:
[wrap=pink]
pink text
pink text
pink text
pink text
[/wrap]
This will look like this:

As an inline element
Now, let's add some text before the [wrap], or after, or both :smile:. For example:
Here is some [wrap=pink]pink text[/wrap] and it's awesome ✨
Here's the result:

If text or other elements are on the same line as one of your [wrap] tags, it will output an inline element.
Second example: a link with a button's appearance.
Fiddling with the [wrap] tag can sometimes lead to unwanted results for various reasons, one being that it can be a block or an inline element depending on the context.
So, we'll describe two different methods that achieve the same result, but you'll be able to pick the one that suits you the most :v:
An inline button link with [wrap]
The syntax to create a link using markdown is: [some text](https://some-link.etc).
To customize the text and make it appear like a button, we'll insert the wrap inside the square brackets. Here's an example:
This [[wrap=button]nice link[/wrap]](https://discourse.org/) is a blue button 🐳 !
We won't comment on what this code outputs. You know that because you wrote [wrap=button], you'll have to target [data-wrap="button"] in your CSS.
So, let's go, let's add some fancy CSS to make it pretty! :sparkles:
[data-wrap="button"] {
display: inline-block;
padding: 0.5em 1em;
background: DodgerBlue;
color: White;
}
We won't detail the CSS rules here. There are many CSS resources on the Internet, so if you want to do more specific modifications, you'll have to learn about it first. :slight_smile:
The result :magic_wand: :

That looks good, right?
An inline button link with regular HTML content
Since Discourse accepts HTML code, we can decide not to use the [wrap] tags and use HTML with a data- attribute. In this example, we'll use the regular Markdown syntax for the link and surround it with <span> tags.
:information_source: We can't directly use a link <a> tag because it's an exception and won't allow any data- attribute.
Write:
This <span data-button>[link](https://discourse.org/)</span> is a green button 🐸 !
It will output a link inside a <span> tag having a data-button attribute, which means the CSS will be a bit more complicated. We will have to target both data-button and the link:
[data-button] {
display: inline-block;
padding: 0.5em 1em;
background: ForestGreen;
a {
color: White;
}
}
And here's the result!

To go further
A customized list using [wrap]
I'll give a single example without explanation by customizing a list in which each element will be prepended with a cat emoji:
Text:
[wrap=cat]
- Felix
- Garfield
- Nat's cat
[/wrap]
CSS:
[data-wrap="cat"] ul {
list-style: none;
li:before {
content: "🐈";
margin-right: 0.25em;
}
}
Result:

Using your own theme's colors variables
If you allow users to use different themes or colors, your modifications may not look good for each one, especially if they have choices between light and dark color schemes.
A good practice is using Discourse's color variables instead of "hardcoded" colors such as red, #FF0000 or rgb(255,0,0).
Here's an example in which the button's background color will use the primary color of the current palette, and the text will use the secondary color:
Text:
This [[wrap=button]nice link[/wrap]](https://discourse.org/) is a button 🌈 !
CSS:
[data-wrap="button"] {
display: inline-block;
padding: 0.5em 1em;
background: var(--primary);
color: var(--secondary);
}
Here's how it will look for a user using the Solarized Light color scheme:

And if they use the Solarized Dark color scheme:

Conclusion
You now have the basics to create custom elements using the [wrap] element and the data- attributes.
To make more advanced customizations, learning CSS is primordial. You'll find many tutorials on the Internet.
The following Discourse's guide can also be of some help: https://meta.discourse.org/t/make-css-changes-on-your-site/168101?u=canapin.
Using the developer's tools of your Internet Browser will also easily show you the list of your Discourse's color variables and what each looks like:

:raised_hand_with_fingers_splayed: Feel free to suggest any modification for this guide!
Adding preview screenshots to Discourse themes
:bookmark: This guide explains how to add preview screenshots to your Discourse theme to showcase how it looks in both light and dark modes.
:person_raising_hand: Required user: Theme developers
Adding preview screenshots to your Discourse theme helps users quickly understand how your theme looks before they install it. This guide will walk you through adding light and dark mode screenshots to your theme.
Creating the screenshots folder
To add preview screenshots to your theme:
- Create a folder called
screenshotsin the root directory of your theme - Add two image files to this folder:
light.webp- Shows your theme in light modedark.webp- Shows your theme in dark mode
Setting up the image files
Your screenshot images must meet these requirements:
- Ratio: 16:9 aspect ratio
- Dimensions: 2560 × 1440 pixels (recommended)
- File size: Under 1MB per image
- Format: WebP, PNG, JPEG
:information_source: Using the recommended dimensions ensures your screenshots look crisp on high-resolution displays while maintaining reasonable file sizes.
Updating the about.json file
After creating your screenshots, you need to update your theme’s about.json file to include references to these images:
- Open your theme’s
about.jsonfile - Add the
screenshotsarray to the JSON object:
"screenshots": ["screenshots/light.webp", "screenshots/dark.webp"]
Best practices for theme screenshots
Capturing your theme
When taking screenshots of your theme:
- Show the main interface components that make your theme unique.
- Include examples of your theme’s color palette and typography.
- Ensure both screenshots show the same view for easy comparison.
- Use realistic content rather than placeholder text where possible.
Image optimization
Keep your images under the 1MB size limit by:
- Using WebP format, as it combines the advantages of JPEG and PNG, offering more efficient compression than both.
- For PNG format, using optimization tools like TinyPNG or ImageOptim.
- Removing unnecessary metadata from image files.
- Considering slightly reduced dimensions if needed while maintaining the 16:9 ratio.
Common issues and solutions
Images not appearing in theme preview
Issue: Screenshots don’t appear when viewing the theme in the admin panel.
Solution: Verify that:
- Your file names in
about.jsonexactly match the actual file names (these are case-sensitive). - The images are properly uploaded to the theme’s repository in the
screenshotsfolder.
File size too large
Issue: Image files exceed the 1MB limit.
Solution:
- Use image optimization tools to reduce file size.
- Reduce image dimensions while maintaining the 16:9 ratio.
- Consider using a more efficient compression method.
FAQs
Q: Do I need both light and dark screenshots? A: It is not required, but it's good practice to include both light and dark screenshots. If you only include one, the same image will be displayed for both light and dark schemes. If no image is provided at all, a fallback will be displayed.
Q: Can I use a different image format than PNG? A: PNG is recommended for its balance of quality and size. While other formats might work, PNG is the standard for Discourse theme screenshots.
Q: How do I take good screenshots of my theme? A: Use a full-screen browser window at 2560×1440 resolution with developer tools closed. Set your theme to light mode for one screenshot and dark mode for the other.
Additional resources
- https://meta.discourse.org/t/beginners-guide-to-using-discourse-themes/91966
- https://meta.discourse.org/t/developer-s-guide-to-discourse-themes/93648
Controlling site settings with themes
Themeable site settings allow a theme (not components) to override a small subset of core site settings, which generally control parts of the UI and other minor functionality. This allows themes to have a greater control over the full site experience.
:heavy_plus_sign: Adding themeable site settings
All themeable site settings are defined in the core config/site_settings.yml file. Any setting with themeable: true will be available to themes.
To override the default site setting value when the theme is installed, you can add this section in your theme's about.json file:
{
"theme_site_settings": {
"enable_welcome_banner": true
}
}
Any future updates to this value in your theme will not change the saved database value. This is to prevent themes from overriding site settings that the site admin has already changed.
:symbols: Supported types
The types for themeable site settings are identical to the core site setting types. You do not need to define the type in about.json; just make sure the value is a valid one based on the site setting type.
:wrench: Using themeable site settings
The core siteSettings service in JS is used to access the values of themeable site settings. You can access them like this:
@service siteSettings;
this.siteSettings.enable_welcome_banner;
This will also work in .gjs templates. Generally, you will not need to access these values in your theme, since the point of changing these in the theme is to change the behaviour of the core UI itself.
:capital_abcd: Setting description and localizations
The core site setting description is used when showing themeable site settings to site admins on the theme config page, and also on the /admin/config/themeable-site-settings overview page.
:link: Related Topics
- https://meta.discourse.org/t/developer-s-guide-to-discourse-themes/93648
Things to consider before deciding to white-label the Discourse Mobile apps
Since we released the iOS and Android apps a number of people have asked expressed interest in white labeling the Discourse app.
:information_source: We do not offer a white labeling service and would only consider it for enterprise customers as an added bundle. If this is something you're interested in, please discuss with us privately.
Beware of the costs
White-labeling Discourse is a complex task and involves a significant amount of long term maintenance across Android and iOS.
It is rare for it to be needed given Discourse has invested heavily in PWA support which allows you to have a shortcut on the home screen and push notifications on Android.
If you have a very large budget and must have your app searchable in various app stores or are looking to integrate into a larger mobile app this may be an approach you can take.
Should you decide to proceed
If you decide to proceed with an effort to white label the apps on your own, please be aware of the following:
-
Your apps must not be confused with the official Discourse apps. Avoid the word Discourse when white labeling.
-
The code for DiscourseMobile is open source and lives at: https://github.com/discourse/DiscourseMobile it is licensed under the very permissive MIT license
-
Access to our "Push Notification" server is restricted to our customers. If you white-label you would need to setup Discourse to push notifications to a server of your choice using
allowed user api push urlsand implement a push notification receiver that re-publishes to Apple and Android play stores.
We always welcome contributions to the Discourse app, if you have ideas and want to improve it and make it more white label friendly, let us know.
Create custom Automations
:information_source: This is a draft, and may need some extra work.
Vocabulary
- trigger: represents the name of the trigger, eg:
user_added_to_group - triggerable: represents the code logic associated to a trigger, eg:
triggers/user_added_to_group_.rb - script: represents the name of the script, eg:
send_pms - scriptable: represents the code logic associated to a script, eg:
scripts/send_pms.rb
Plugin API
add_automation_scriptable(name, &block)
add_automation_triggerable(name, &block)
Scriptable API
field
field :name, component: lets you add a customisable value in your automation's UI.
List of valid components:
# foo must be unique and represents the name of your field.
field :foo, component: :text # generates a text input
field :foo, component: :list # generates a multi select text input where users can fill values
field :foo, component: :choices, extra: { content: [ {id: 1, name: 'your.own.i18n.key.path' } ] } # generates a combo-box with a custom content
field :foo, component: :boolean # generate a checkbox input
field :foo, component: :category # generates a category-chooser
field :foo, component: :group # generates a group-chooser
field :foo, component: :date_time # generates a date time picker
field :foo, component: :tags # generates a tag-chooser
field :foo, component: :user # generates a user-chooser
field :foo, component: :pms # allows to create one or more PM templates
field :foo, component: :categories # allows to select zero or more categories
field :foo, component: :key-value # allows to create key-value pairs
field :foo, component: :message # allows to compose a PM with replaceable variables
field :foo, component: :trustlevel # allows to select one or more trust levels
triggerables and triggerable!
# Lets you define the list of triggerables allowed for a script
triggerables %i[recurring]
# Lets you force a triggerable for your script and also lets you force some state on fields
field :recurring, component: :boolean
triggerable! :recurring, state: { foo: false }
placeholders
# Lets you mark a key as replaceable in texts using the placeholder syntax `%%sender%%`
placeholder :sender
Note that it's the responsibility of the script to provide values for placeholders and to apply the replacement using input = utils.apply_placeholders(input, { sender: 'bob' })
script
This is the heart of an automation and where all the logic happens.
# context is sent when the automation is triggered, and can differ a lot between triggers
script do |context, fields, automation|
end
Localization
Each field you will use will depend on i18n keys and will be namespaced to their trigger/script.
For example a scriptable with this content:
field :post_created_edited, component: :category
Will require the following keys in client.en.yml:
en:
js:
discourse_automation:
scriptables:
post_created_edited:
fields:
restricted_category:
label: Category
description: Optional, allows to limit trigger execution to this category
Note that description is optional here.
Discourse Public Data Dump
Given the advent of AI and the need for large datasets on local development machines, we have pulled together a quick pattern for getting a "workable" copy of all public (visible by anon) data from a Discourse forum.
Keeping the documentation up to date at:
https://github.com/SamSaffron/discourse_public_import/blob/main/README.md
https://github.com/SamSaffron/discourse_public_import/
Why you care?
- You want a local database with LOTS of topics
- You don't want ANY personal data on your system
This is still in a very rough shape, but it is workable for initial experiments and gives you a very populous local setup.
Overview
The Discourse AI plugin ships a Ruby CLI under plugins/discourse-ai/evals that exercises AI features against YAML definitions and records results. Use it to benchmark prompts, compare model outputs, and regression-test AI behaviors without touching the app database.
Core concepts (what users need to know)
- Eval case: A YAML definition under
evals/cases/<group>/<id>.ymlthat pairs inputs (args) with an expected outcome. Evals can check exact strings, regexes, or expected tool calls. - Feature: The Discourse AI behavior under test, identified as
module:feature_name(for example,summarization:topic_summaries).--list-featuresshows the valid keys. - Agent prompt: The system prompt wrapped around the LLM call. Runs default to the built-in prompt unless you pass
--agent-keysto load alternate prompts fromevals/agents/*.yml. Add multiple keys to compare prompts in one run. - Judge: A rubric embedded in some evals that requires a second LLM to grade outputs. Think of it as an automated reviewer: it reads the model output and scores it against the criteria. If an eval defines
judge, you must supply or accept the default judge model (--judge, defaultgpt-4o). Without a judge, outputs are matched directly against the expected value. - Comparison modes:
--compare agents(one model, many agent prompts) or--compare llms(one agent prompt, many models). The judge picks a winner and reports ratings; non-comparison runs just report pass/fail. - Datasets: Instead of YAML cases, pass
--dataset path.csv --feature module:featureto build cases from CSV rows (contentandexpected_outputcolumns required). - Logs: Every run writes plain text and structured traces to
plugins/discourse-ai/evals/log/with timestamps and agent keys. Use them to inspect failures, skipped models, and judge decisions.
Prerequisites
- Have a working Discourse development environment with the Discourse AI plugin present. The runner loads
config/environment(defaulting to the repository root orDISCOURSE_PATHif set). - LLMs are defined in
plugins/discourse-ai/config/eval-llms.yml; copy it toeval-llms.local.ymlto override entries locally. Each entry expects anapi_key_env(or inlineapi_key), so export the matching environment variables before running, for example:OPENAI_API_KEY=...ANTHROPIC_API_KEY=...GEMINI_API_KEY=...
- From the repository root, change into
plugins/discourse-ai/evalsand runbundle exec ruby ./run --helpto confirm the CLI is wired up. Ifevals/casesis missing it will be cloned automatically fromdiscourse/discourse-ai-evals.
Discover available inputs
bundle exec ruby ./run --listlists all eval ids fromevals/cases/*/*.yml.bundle exec ruby ./run --list-featuresprints feature keys grouped by module (format:module:feature).bundle exec ruby ./run --list-modelsshows LLM configs that can be hydrated fromeval-llms.yml/.local.yml.bundle exec ruby ./run --list-agentslists agent keys defined underevals/agents/*.ymlplus the built-indefault.
Run evals
-
Run a single eval against specific models:
OPENAI_API_KEY=... bundle exec ruby ./run --eval simple_summarization --models gpt-4o-mini -
Run every eval for a feature (or the whole suite) against multiple models:
bundle exec ruby ./run --feature summarization:topic_summaries --models gpt-4o-mini,claude-3-5-sonnet-latestOmitting
--modelshydrates every configured LLM. Models that cannot hydrate (missing API keys, etc.) are skipped with a log message. -
Some evals define a
judgeblock. When any selected eval requires judging, the runner defaults to--judge gpt-4ounless you pass--judge name. Invalid or missing judge configs cause the CLI to exit before running.
Agent prompts and comparison modes
-
Supply custom prompts with
--agent-keys key1,key2. Keys resolve to YAML files inevals/agents; each needskey(optional, defaults to the filename),system_prompt, and an optionaldescription. -
Minimal agent prompt example (
evals/agents/topic_summary_eval.yml):key: topic_summary_eval description: Variant tuned for eval comparisons system_prompt: | Summarize the topic in 2–4 sentences. Keep the original language and avoid new facts. -
--compare agentsruns one model against multiple agents. The built-indefaultagent is automatically prepended so you can compare YAML prompts against stock behavior, and at least two agents are required. -
--compare llmsruns one agent prompt (default unless overridden) across multiple models and asks the judge to score them side by side. -
Non-comparison runs accept a single agent prompt; pass one
--agent-keysvalue or rely on the default prompt.
Dataset-driven runs
-
Generate eval cases from a CSV instead of YAML by passing
--dataset path/to/file.csv --feature module:feature. The CSV must includecontentandexpected_outputcolumns; each row becomes its own eval id (dataset-<filename>-<row>). -
Minimal CSV example:
content,expected_output "This is spam!!! Buy now!",true "Genuine question about hosting",false -
Example:
bundle exec ruby ./run --dataset evals/cases/spam/spam_eval_dataset.csv --feature spam:inspect_posts --models gpt-4o-mini
Writing eval cases
- Store cases under
evals/cases/<group>/<name>.yml. Each file must declareid,name,description, andfeature(themodule:featurekey registered with the plugin). - Provide inputs under
args. Keys ending in_path(orpath) are expanded relative to the YAML directory so you can reference fixture files. For multi-case files,argscan contain arrays (for example,cases:) that runners iterate over. - Expected results can be declared with one of:
expected_output: exact string matchexpected_output_regex: treated as a multiline regular expressionexpected_tool_call: expected tool invocation payload
- Set
vision: truefor evals that require a vision-capable model. Include ajudgesection (pass_rating,criteria, and optionallabel) to have outputs scored by a judge LLM.
Results and logs
-
CLI output shows pass/fail per model and prints expected vs actual details on failures. Comparison runs also stream the judge’s winner and ratings.
-
Example pass/fail snippet:
gpt-4o-mini: Passed 🟢 claude-3-5-sonnet-latest: Failed 🔴 ---- Expected ---- true ---- Actual ---- false -
Comparison winner snippet:
Comparing agents for topic-summary Winner: topic_summary_eval Reason: Captured key details and stayed concise. - default: 7/10 — missed concrete use case - topic_summary_eval: 9/10 — mentioned service dogs and tone was neutral -
Each run writes plain logs and structured traces to
plugins/discourse-ai/evals/log/(timestamped.logand.jsonfiles). The JSON files are formatted for ui.perfetto.dev to inspect the structured steps. -
On completion the runner echoes the log paths; use them to audit skipped models, judge decisions, and raw outputs when iterating on prompts or features.
Common features (what to try first)
summarization:topic_summaries: Summarize a conversation.spam:inspect_posts: Spam/ham classification.translation:topic_title_translator: Translate topic titles while preserving tone/formatting.ai_helper:rewrite: Prompt the AI helper for rewrites.tool_calls:tool_calls_with_no_toolandtool_calls:tool_call_chains: Validate structured tool call behavior.
Using the AI bot via the Discourse API
Overview
Discourse AI exposes an admin/API endpoint for streaming an AI Agent reply over a raw chunked HTTP response.
- Protocol: Raw chunked HTTP transfer encoding (NOT Server-Sent Events).
- Implementation: Hijacks the Rack socket to stream newline-separated JSON objects.
- Side Effects: This is not just a completion API; it creates real Discourse Private Message (PM) posts.
Endpoint Details
- Location:
plugins/discourse-ai/app/controllers/discourse_ai/admin/ai_agents_controller.rb:175-272 - Route:
POST /admin/plugins/discourse-ai/ai-agents/stream-reply.json - API Key Scope:
ai:stream_completion(registered inplugins/discourse-ai/lib/ai_bot/entry_point.rb:283-286)
Request Headers
POST /admin/plugins/discourse-ai/ai-agents/stream-reply.json
Api-Key: <your_api_key>
Api-Username: <your_username>
Content-Type: application/json
Request Body Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
agent_id |
Integer | Optional* | Identifier for the agent. |
agent_name |
String | Optional* | Alternative identifier for the agent. |
query |
String | Yes | The user's prompt/question. |
username |
String | Required if user_unique_id is omitted |
Used incase the chat PM needs to be associated with an existing user. |
user_unique_id |
String | Required if username is omitted |
Identifies the end-user. Creates/reuses a staged user keyed by custom field ai-stream-conversation-unique-id. |
preferred_username |
String | Optional* | Username for the user (if user_unique_id is not used). |
topic_id |
Integer | Optional | Continue an existing PM conversation. |
custom_instructions |
String | Optional | Appended into the agent prompt context. |
Note: You must identify the end-user by either username (existing Discourse user) or user_unique_id.
Response Format
The server returns a 200 OK response with Transfer-Encoding: chunked. The stream consists of newline-separated JSON objects.
HTTP Headers
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
Transfer-Encoding: chunked
Cache-Control: no-cache, no-store, must-revalidate
Connection: close
X-Accel-Buffering: no
X-Content-Type-Options: nosniff
Stream Payload Structure
- Context Chunk: Provides metadata (topic ID, bot user ID, agent ID).
- Partial Chunks: Contains the streamed text fragments.
Example Stream:
{"topic_id":42,"bot_user_id":7,"agent_id":123}
{"partial":"Hello"}
{"partial":" there"}
The client should concatenate partial fields to build the final answer.
Workflow & Side Effects
The endpoint performs the following actions during execution:
- Creates a user post with the raw
query. - Streams the AI answer via the chunked response.
- Creates the final AI reply post with the accumulated answer.
- For new PMs, may auto-title the topic.
Custom Client-Executed Tools
You can provide tool definitions to allow the model to call external tools. The server pauses the stream when a tool is called, allowing the client to execute the tool and resume.
1. Initial Request with Tools
Include custom_tools in the request body:
{
"agent_id": 123,
"query": "What's the weather?",
"user_unique_id": "external-user-42",
"custom_tools": [
{
"name": "client_weather",
"description": "Gets weather from the client runtime",
"parameters": [
{
"name": "city",
"description": "City to fetch weather for",
"type": "string",
"required": true
}
]
}
]
}
2. Tool Call Event
If the model calls a tool, the stream emits a tool_calls event and stops:
{
"event": "tool_calls",
"tool_calls": [
{
"id": "tool_1",
"name": "client_weather",
"parameters": { "city": "Austin" }
}
],
"resume_token": "..."
}
The server persists conversation state in Redis at this point.
3. Resume with Tool Results
The client executes the tool and resumes the stream:
{
"resume_token": "...",
"tool_results": [
{
"tool_call_id": "tool_1",
"content": { "temperature_c": 23 }
}
]
}
The server reloads the saved prompt state, inserts the tool result, continues generation, and streams more partial chunks.
Tool Limits
- Max custom tools: 20
- Max tool results: 20
- Max custom tool definition size: 10,000 bytes
- Max tool result content size: 100 KB
- Resume TTL: 15 minutes
- Max resume rounds: 10
Implementation References
- Controller:
plugins/discourse-ai/app/controllers/discourse_ai/admin/ai_agents_controller.rb - Streamer:
plugins/discourse-ai/lib/ai_bot/response_http_streamer.rb - Custom Tools Session:
plugins/discourse-ai/lib/ai_bot/stream_reply_custom_tools_session.rb
Test Examples
See plugins/discourse-ai/spec/requests/admin/ai_agents_controller_spec.rb for comprehensive examples:
- New streamed conversation: Lines 1248-1356
- Custom tools + resume token: Lines 1358-1448
- Parallel tool calls: Lines 1467-1590
Sample implementation
[details=Ruby Script]
require 'net/http'
require 'json'
require 'uri'
# Configuration
DISCOURSE_URL = '<your site URL>'
API_KEY = '<your API key>'
USERNAME = '<your username>'
AGENT_ID = -1 # Or use agent_name
QUERY = "Hello, how are you today?"
USER_UNIQUE_ID ='leave empty if want the PM to be sent to the USERNAME user'
# Helper to create the URI
uri = URI("#{DISCOURSE_URL}/admin/plugins/discourse-ai/ai-agents/stream-reply.json")
# Create the HTTP request
request = Net::HTTP::Post.new(uri)
request['Api-Key'] = API_KEY
request['Api-Username'] = USERNAME
request['Content-Type'] = 'application/json'
# Prepare the request body
body = {
agent_id: AGENT_ID,
query: QUERY,
## uncomment the below line if you want to use an existing user for the conversation. Also, `username` takes precedence over `user_unique_id` if passed.
# username: USERNAME,
## use the below field along with `user_unique_id` in order to create a new staged user. When using this, skip passing the `username`.
# preferred_username: USER_UNIQUE_ID
}
body[:user_unique_id] = USER_UNIQUE_ID unless USER_UNIQUE_ID.empty?
body = body.to_json
request.body = body
http = Net::HTTP.new(uri.hostname, uri.port)
http.use_ssl = (uri.scheme == 'https')
http.request(request) do |response|
# Check if the response is successful
if response.code == '200'
puts "Stream started successfully."
puts "Response headers: #{response.to_hash}"
puts "Streaming content:"
# Read the chunked response
response.read_body do |chunk|
# The response is newline-separated JSON objects
chunk.each_line do |line|
line = line.strip
next if line.empty?
begin
json = JSON.parse(line)
if json['topic_id']
puts "--- Context Received ---"
puts "Topic ID: #{json['topic_id']}"
puts "Bot User ID: #{json['bot_user_id']}"
puts "Agent ID: #{json['agent_id']}"
elsif json['partial']
# Stream the partial content
print json['partial']
elsif json['event'] == 'tool_calls'
puts "--- Tool Call Received ---"
puts JSON.pretty_generate(json)
# Here you would handle tool execution and resume
# For this example, we just print it
end
# puts "<new word>"
rescue JSON::ParserError => e
puts "Error parsing JSON: #{e.message}"
puts "Raw line: #{line}"
end
end
end
puts "--- Stream Finished ---"
else
puts "Error: #{response.code} #{response.message}"
puts "Response body: #{response.body}"
end
end
[/details]
Notes
- If you want the conversation to be in the name of a new staged user, pass the
unique_user_idandpreferred_usernameas the new desired username and skip theusernamefield. - If you want to converse using an existing user, pass that user's
usernameand skip theunique_user_idandpreferred_usernamefields.
Theme Developer Tutorial: 1. Introduction
This tutorial will teach you how to create a Discourse Theme or Theme Component from the ground up. While this topic assumes no previous experience working on Discourse themes, it does assume some prior experience using HTML, CSS and JavaScript. It'll also help if you know your way around GitHub.
What are Discourse themes?
A theme or theme component is a set of files packaged together designed to either modify Discourse visually or to add new features.
Themes
In general, themes are not supposed to be compatible with each other because they are essentially different standalone designs. You can have multiple themes installed, but you can't use two of them at the same time.
Theme Components
Theme components are geared towards customising one aspect of Discourse. Because of their narrowed focus, theme components are almost always compatible with each other. This means that you can have multiple theme components running at the same time under any theme.
Technical Differences
Technically, themes and theme components are implemented in very similar ways, so this tutorial applies to both. You can convert from themes to theme-components, and vice-versa, very easily.
Remote and local themes
Discourse themes and theme components can either be Local or Remote. Local themes are created & stored on a single Discourse installation, and work well for simple community-specific customizations.
Once your theme becomes more complex, or if you want to share it with other communities, it's beneficial to make it a Remote Theme. These are stored on GitHub (or another Git hosting system), and can be installed on any community using the repository URL.
All the themes in the #customization:theme and #customization:theme-component categories are remote themes.
Getting started with a local theme
Let's kick things off by creating a new local theme! If you have your own Discourse community or a local development environment, then log in as an administrator and visit the Appearance -> Themes and components section of the admin interface, or go directly to /admin/config/customize/themes
If you don't have your own community, then you can log into the public "Theme Creator" community, visit your profile page, and then choose the "Themes" tab.
From there, click the "Install" button, and then choose "Create New" in the popup. Enter a name for your theme, then hit "Create". Once you see your new theme, use the "Edit Code" button.
Editing Code
The code editor has a number of tabs to add code to your theme. When you open a tab, a short description will tell you what it's for. The most commonly-used ones are:
- CSS
<head>- Before Header
- After Header
- Footer
- JS
The "show advanced" toggle can be used to show a few more tabs. Some of those are fairly niche, so we won't go into them for this tutorial. You may also notice the "Mobile" and "Desktop" groups. Those exist for historical reasons, but for new development we recommend implementing everything under "Common", and using CSS breakpoints for any device-specific tweaks.
Hello World
Let's start with a basic local theme. We're going to add a big "Hello World!" banner under the Discourse header. In the "After Header" tab, paste this HTML code:
<div class="hello-world-banner">
<h2 class="hello-world">Hello World!</h2>
</div>
And in the CSS tab, add this:
.hello-world-banner {
height: 300px;
width: 100%;
background: red;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 1em;
}
.hello-world {
font-size: 8em;
color: white;
}
Now hit the save button, then click "Preview". You should see something like:
Congratulations! You just created your first Discourse theme! :tada:
Rendering into an Outlet
"Plugin Outlets" are one of the main ways to customize Discourse. These allow you to add content in thousands of places throughout the user interface. We'll explore these in more detail later, but for now let's render some dynamic content into the discovery-list-container-top outlet.
To do this, visit the JS tab of your theme. The code in this tab is a Discourse "API Initializer", and a boilerplate implementation should be filled in for you. To render some content into an outlet, we can use api.renderInOutlet, and Ember's <template> syntax.
Inside the apiInitializer, replace // your code here with this:
const currentUser = api.getCurrentUser();
api.renderInOutlet(
"discovery-list-container-top",
<template>
<div class="custom-welcome-banner">
{{#if currentUser}}
Welcome back @{{currentUser.username}}
{{else}}
Welcome to our community
{{/if}}
</div>
</template>
);
Then jump back to the CSS tab, and add this new rule:
.custom-welcome-banner {
background: green;
color: white;
text-align: center;
padding: 10px;
}
Now save and refresh the preview. You should see a new dynamic welcome banner just above the topic list! Existing users will be welcomed by their username, and new visitors will see a generic message. Nicely done!
There's a lot going on this example: the JS API, Plugin Outlets, Ember's <template> tag, and the handlebars template syntax inside that. Don't worry, we'll continue to explore these concepts in more detail throughout the rest of the tutorial.
Next up, we'll demonstrate how to turn this into a "Remote Theme", and take advantage of the extra features they unlock. Let's go!
Theme Developer Tutorial: 2. Creating a remote theme
In the last chapter, we created a basic theme via the Discourse admin interface. This works well enough for very simple themes. But as soon as you want to do something more complex, or share a theme more widely, you'll need to create a Remote Theme.
Install the discourse_theme CLI
The first step towards developing a remote theme is to install the discourse_theme CLI on your computer. This tool is used to generate the initial scaffold for a theme, and can then synchronize it in real-time to a local Discourse instance, a "production" Discourse community, or the public Theme Creator.
Follow these instructions to install discourse_theme, then come back here to continue the tutorial.
Create and sync a new theme
In your local terminal, navigate to a directory where you want to store your new theme. Run discourse_theme new my-first-theme, then follow the prompts to fill in the theme's metadata.
When setup is done, you'll see ✅ Done!, and you'll be prompted to start "watching" the theme. Answer yes, and then work through the watching setup.
For the root URL, enter the base URL of the Discourse site you'd like to sync the theme to. For a local development environment, use something like http://localhost:3000. For a production site, use something like https://meta.discourse.org. Or for Theme Creator, use https://discourse.theme-creator.io.
Now you'll need to generate an API key. For a local development environment or production forum, visit the "API Keys" section of the admin panel, choose "Add API key", and create one associated with your user account, and a "Global" scope. Then paste it into the discourse_theme CLI.
If you're using theme creator, visit the themes section of your user profile, click the "API Key" button in the bottom left, generate, then paste into the discourse_theme CLI.
Finally, choose "Create and Sync with new theme". You should see a success message, and some handy links to preview and manage the theme.
Right now this is just an empty theme, so let's add the customizations we created in chapter one of the tutorial to this new theme.
Introducing custom code
Now it's time to start writing some code! Open up the my-first-theme directory in your favorite text editor, then find the file inside javascripts/discourse/api-initializers. Open up the auto-generated .gjs file and replace the content with the example from the last chapter:
import { apiInitializer } from "discourse/lib/api";
export default apiInitializer((api) => {
const currentUser = api.getCurrentUser();
api.renderInOutlet(
"discovery-list-container-top",
<template>
<div class="custom-welcome-banner">
{{#if currentUser}}
Welcome back @{{currentUser.username}}
{{else}}
Welcome to our community
{{/if}}
</div>
</template>
);
});
Now open up common/common.scss, and paste in:
.custom-welcome-banner {
background: green;
color: white;
text-align: center;
padding: 10px;
}
Assuming discourse_theme is still running in your terminal, these changes will have been synced up to your Discourse site instantly, and reflected in the theme preview. Try tweaking some of the text in the files and watch the result change in the preview.
To start up discourse_theme's syncing again without going through the full "new" flow, run discourse_theme watch . from inside your theme's directory. Add --reset if you need to change your forum URL or API key.
Understanding the file structure
As you've probably realized, many of the files in the local directory are a 1:1 match to the tabs in the admin panel from chapter 1. For example, common/common.scss matches the CSS tab, common/after_header.html matches the After Header tab, and files under javascripts/discourse/api-initializers/*.gjs are each treated the same as the JS tab.
Along with these, you may have noticed a bunch more placeholder directories & files which have been automatically generated by discourse_theme. We'll explore a few of these in more detail later in the tutorial, but you can always find a list of all available files here.
For now, take a look at the about.json file. This contains the most important metadata for your Remote Theme, including its name, description, author info, and various URLs. This information will be shown to any users of your theme, so it's important to keep it up-to-date.
Uploading to GitHub
So far you've created a local directory and synced it up to a single forum via discourse_theme. To version-control your theme and make it available for others to install via the Discourse UI, you'll need to push the directory to an internet-accessible Git repository. We'll be using GitHub for this tutorial, but any git-hosting platform should work.
First, let's prepare the local directory. Navigate to the theme in your terminal, then run
git add .
git commit -m "My first commit"
Or if you prefer, perform the commit from your favorite Git user interface.
Then set up the remote repository by visiting github.com/new. Enter a name & description for your repository. Leave the template selection empty, and skip the README, gitignore and license options. Create the repository.
Then follow GitHub's instructions to "push an existing repository from the command line". Once complete, you should see all your theme files in the GitHub UI. The repository URL can now be used to install the theme on any Discourse forum, and any updates you push to GitHub will be made available to forum admins.
Now that we've got the mechanics out the way, we'll be exploring a bunch of ways that themes can be used to customize Discourse. Join us in the next chapter.
Theme Developer Tutorial: 3. CSS in Themes
Technically, Discourse uses SCSS to author its stylesheets. However, we're increasingly moving towards native CSS features as they mature, so the vast majority of themes won't need to use SCSS features or syntaxes. We follow a variant of BEM for our CSS, you should be familiar with this when writing theme CSS.
This chapter will focus on Discourse-specific subjects, so if you don't already have a passing familiarity with CSS, take some time to learn about it from https://developer.mozilla.org/en-US/docs/Learn_web_development/Core/Styling_basics.
Authoring theme CSS
As we touched on in the last chapter, the main entrypoint for theme CSS is the common/common.scss file. For many themes, that's all you'll need.
You can also use desktop/desktop.scss and mobile/mobile.scss, although we're increasingly moving away from these separate files and towards breakpoint-based styling in common.scss.
But for more complex situations, you can put additional scss in files like /stylesheets/my-styles.scss, and import from common.scss like @import "my-styles";
Using variables
Discourse makes extensive use of CSS variables for colors, font sizes, and other things which need to be shared throughout the stylesheets. You can find a full list of the color variables here, font variables here. Or alternatively, open your browser dev tools, select the <html> element, and scroll through all the available variables.
Let's make use of this knowledge by updating our theme to use the theme colors for the banner! Open up the common.scss file, and update the color properties to use variables:
.custom-welcome-banner {
background: var(--quaternary);
color: var(--secondary);
text-align: center;
padding: 10px;
}
discourse_theme will sync this change up to your site instantly, and the change should appear in your browser.
Great! Now your banner's colors will match the site color scheme, and automatically adjust based on light/dark modes.
For more information about the variables available, check out this document
Finding CSS selectors to style
The number of elements and classes in Discourse can feel quite overwhelming from a re-styling standpoint. The key to having a maintainable theme is to keep your changes as small as possible, and match the selectors used in Discourse core's stylesheets.
For example, let's assume you want to style all the buttons in Discourse. One approach would be to use DevTools and try to find every variation of every button and style it. But a better approach would be to see how core is styling buttons, and base your approach on that.
To explore re-styling Discourse in more detail, check out the Designer's guide to Discourse themes
Or if you're ready to explore more ways to add/change content in Discourse, let's go to the next chapter
Theme Developer Tutorial: 4. Using Outlets to insert and replace content
Discourse uses the Ember JS Framework for its user interface. On top of Ember, Discourse provides a number of APIs to allow themes to customize the user interface. The most commonly-used API is Plugin Outlets.
These outlets are positioned throughout Discourse core, and allow themes to render Ember Components inside them. Those components are given access to some contextual information called "outlet args".
Some outlets also "wrap" part of the core user interface, which allows themes to add their own wrapper HTML around that part of core, or even replace it entirely! These are called "Wrapper Outlets".
Choosing an outlet
In the earlier chapter of this tutorial, you used api.renderInOutlet to render a component into the discovery-list-container-top outlet. But how do we know what outlets are available, where they're located, and what outlet args are available?
Enter: the discourse developer toolbar. If you're running Discourse in a local development environment, you may have spotted this floating on the left-hand side of the screen. If you're developing a theme against a production Discourse environment, open the browser developer console and run enableDevTools() to make it appear.
To see the location of all outlets on the current page, click the 🔌 icon in the developer toolbar. You should now see a bunch of green & blue placeholders throughout the application. Green placeholders are for simple outlets where you can render new content. Blue placeholders appear at the beginning and end of each "wrapper outlet".
When you mouseover an outlet, a tooltip will show the available outletArgs, and a button to write each argument to the browser developer console to explore in more detail.
Rendering a simple component
Let's explore the renderInOutlet API in more detail.
api.renderInOutlet(outletName, component);
The first argument is a string representing the name of the outlet you want to target. The second argument is an Ember Component class. Ember provides a few different options for authoring components, but for new developments in Discourse we recommend "Glimmer Components", authored using the "template tag" format (i.e. .gjs files).
The simplest component is a "template only component", and can be authored like this:
const MyComponent = <template>Hello World</template>;
api.renderInOutlet("some-outlet", MyComponent);
// Or on one line
api.renderInOutlet("some-outlet", <template>Hello World</template>);
Inside the <template> you can use handlebars syntax to render simple HTML, dynamic content, and other components. We'll cover a few things here, but The Ember Guides are the best place to learn more about the syntax and everything that's possible.
To access JavaScript variables from inside the template, you can wrap your variable name in double curly brackets. That's the technique we used in the earlier chapters:
const currentUser = api.getCurrentUser();
api.renderInOutlet(
"discovery-list-container-top",
<template>
<div class="custom-welcome-banner">
{{#if currentUser}}
Welcome back @{{currentUser.username}}
{{else}}
Welcome to our community
{{/if}}
</div>
</template>
);
To access contextual arguments passed to Ember Components, you can use the syntax {{@someArgument}}. In the case of outlets, all the contextual "outlet arguments" are made available via the @outletArgs object.
If you use the developer tools to find the discovery-list-container-top outlet, you'll see there are two contextual outletArgs available: category and tag. Let's use the category information to add the category name to our welcome banner:
api.renderInOutlet(
"discovery-list-container-top",
<template>
<div class="custom-welcome-banner">
{{#if currentUser}}
Welcome back @{{currentUser.username}}.
{{else}}
Welcome to our community.
{{/if}}
You're viewing
{{#if @outletArgs.category}}
"{{@outletArgs.category.name}}" topics.
{{else}}
all topics.
{{/if}}
</div>
</template>
);
Save that change, and check the preview in your browser. When you navigate between categories, the banner will update accordingly.
This is a great example of Ember's "autotracking" system. All we have to do is reference the .category property, and Ember will automatically take care of re-rendering that part of the HTML whenever the property changes. Magic!
Class-based Components
For more advanced use-cases, we need a place to store state and JS logic inside a component definition. That can be done using a class-based Glimmer Component. Class based components can also inject application-wide Services to get global information like currentUser. A class-based version of our welcome banner would look like:
import Component from "@glimmer/component";
import { service } from "@ember/service";
class CustomWelcomeBanner extends Component {
@service currentUser;
<template>
<div class="custom-welcome-banner">
{{#if this.currentUser}}
Welcome back @{{this.currentUser.username}}.
{{else}}
Welcome to our community.
{{/if}}
</div>
</template>
}
You can put this code anywhere that you can access from the initializer. That could mean simply adding it to the top of the file. But in general, once you reach the complexity of a class-based component, it's best to put it in its own file, export it, and then import from the initializer.
So, let's go ahead and create a new file: javascripts/discourse/components/custom-welcome-banner.gjs, and include this new component as the default export:
import Component from "@glimmer/component";
import { service } from "@ember/service";
export default class CustomWelcomeBanner extends Component {
@service currentUser;
<template>
<div class="custom-welcome-banner">
{{#if this.currentUser}}
Welcome back @{{this.currentUser.username}}.
{{else}}
Welcome to our community.
{{/if}}
</div>
</template>
}
Then, back in the initializer, we can import it using a "relative import" at the top of the file, and then pass it through to the renderInOutlet function. The entire initializer should now look like:
import { apiInitializer } from "discourse/lib/api";
import CustomWelcomeBanner from "../components/custom-welcome-banner";
export default apiInitializer((api) => {
api.renderInOutlet("discovery-list-container-top", CustomWelcomeBanner);
});
Wrapper Outlets
We won't explore them in this tutorial, but Wrapper Outlets work almost exactly the same as normal outlets. The only difference is that your component will replace any core content inside the wrapper. For example, rendering into the "home-logo-contents" outlet would replace the site logo with your own component.
If you want to re-render the wrapped core implementation inside your component, you can use Ember's {{yield}} keyword.
Conclusion
Now we know how to create and insert content across the whole of Discourse, we'll explore some more advanced concepts you can use in your components. See the next chapter
Theme Developer Tutorial: 5. Building and using components
In the last chapter, we explored how you can render Glimmer Components into plugin outlets, and moved our CustomWelcomeBanner component into its own file. In this chapter, we'll extend the functionality of the banner to demonstrate some more techniques for authoring components, including re-using other components from Discourse Core.
Adding JS logic
In previous chapters, we've used variables and if statements to render dynamic content in handlebars templates. For more complex logic, we can use regular JavaScript and then reference it from the template.
Taking our existing CustomWelcomeBanner component, we can learn more about the currentUser object by adding {{log this.currentUser}} into the template. Then check your browser developer console to explore the object. You'll see a "groups" key available, which lists all the groups the user is part of.
Let's display a list of the groups in the welcome banner. While it's technically possible to do that entirely in handlebars, it'll be much easier to do it in JavaScript. So, let's create a JS getter which returns a comma-separated list of the group names, and also skips the automatic 'trust level' groups. Then, we can reference that getter from the template, just like any other property:
// (existing imports omitted)
export default class CustomWelcomeBanner extends Component {
@service currentUser;
get commaSeparatedGroups() {
return this.currentUser?.groups
.reject((group) => group.name.startsWith("trust_level_"))
.map((group) => group.name)
.join(", ");
}
<template>
<div class="custom-welcome-banner">
{{#if this.username}}
Welcome back @{{this.username}}. You're a member of
{{this.commaSeparatedGroups}}.
{{else}}
Welcome to our community.
{{/if}}
</div>
</template>
}
Using core components
Themes can import and use any components from Discourse core. The majority of them can be found in this directory of the core repository. Some of these are very specific to certain use-cases in core, and probably won't be useful for plugins. But others are more generic, and are designed to be reused from anywhere.
The most-used component is DButton. As the name suggests, that can be used to render an HTML <button>, but with a little extra Discourse flair. To import it, add this to the top of your custom-welcome-banner.gjs file:
import DButton from "discourse/components/d-button";
Let's swap out our welcome message with something a little more interactive, so we can try out the button:
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
import DButton from "discourse/components/d-button";
export default class CustomWelcomeBanner extends Component {
@tracked counter = 0;
@action
incrementCounter() {
this.counter++;
}
<template>
<div class="custom-welcome-banner">
Count:
{{this.counter}}
<DButton
@icon="plus"
@translatedLabel="Increment"
@action={{this.incrementCounter}}
/>
</div>
</template>
}
Lots going on here! Let's break it down:
-
We created a new
counterfield in the component, so we can keep track of an integer. -
We imported the
@trackeddecorator and applied it to thecounterfield. This allows Ember's autotracking system to automatically re-render relevant parts of the DOM when the field value is changed -
We created a new function in the component which will increment the counter when called
-
We imported the
@actiondecorator and applied it to theincrementCounter()function. This makes the function usable from a template context. [^1] -
We imported the
DButtoncomponent, and added it to the template in a similar way to a regular HTML element. We passed three arguments to it:@icon,@translatedLabeland@action.
[^1]: technically: it creates a 'bound' version of the function for each component instance, and ensures the function runs in the context of an ember runloop
Once you've made those changes and saved, you should see the counter interface in the banner. Every time you click the button, the number will go up.
Now we know how to use other components from our theme! To learn more about all of this, we recommend you check out the Ember guides, and read through code from other Discourse themes, plugins, or core.
Next up we'll take a look at some of the other JS-based customization which Discourse offers.
Theme Developer Tutorial: 6. Using the JS API
In the last couple of chapters, we've explored how to use the JavaScript API to render content into outlets. renderInOutlet is the most commonly-used API, but there are a ton more! In this chapter we'll try out a few of them, and show you how to discover more.
Common API methods
getCurrentUser()
api.getCurrentUser() will return information about the current user, or null if nobody is logged in. This can be used for all sorts of things, including per-group logic, or rendering a user's username into the UI.
For group membership checks backed by theme settings, use the resolve_group_membership option in your theme's settings.yml file instead of checking currentUser.groups. This avoids leaking or missing hidden group memberships.
import { apiInitializer } from "discourse/lib/api";
export default apiInitializer((api) => {
console.log("Current user is", api.getCurrentUser());
});
headerIcons
api.headerIcons will allow you to add, remove and re-arrange icons in the header. For example, to add a new icon before the search icon, you'd do something like
import DButton from "discourse/components/d-button";
import { apiInitializer } from "discourse/lib/api";
export default apiInitializer((api) => {
const sayHello = () => {
alert("Hello, world!");
};
api.headerIcons.add(
"my-unique-icon-name",
<template>
<li>
<DButton
@action={{sayHello}}
@icon="wand-magic"
class="icon btn-flat"
/>
</li>
</template>,
{ before: "search" }
);
});
replaceIcon()
api.replaceIcon(source, destination);
With this method, you can easily replace any Discourse icon with another. For example, we have a theme component that replaces the heart icon for like with a thumbs-up icon
decorateCookedElement()
api.decorateCookedElement() allows you to customize the rendered content of Discourse posts. This can be used for anything from simple formatting changes, all the way up to advanced integrated UIs like the built-in 'poll' plugin.
The API should be passed a callback function which will be run for every post when it's rendered to the screen. The first argument to the callback will be the post's root HTML element, and the second will be a helper.
A simple example which appends content to every post would look like:
api.decorateCookedElement((element, helper) => {
const myNewParagraph = document.createElement("p");
myNewParagraph.textContent = "Hello, this is appended to every post!";
element.appendChild(myNewParagraph);
});
Or for a more advanced UI, you can render a glimmer component into a post. For example, to render the counter component we authored earlier into every post, you could do something like this:
import { apiInitializer } from "discourse/lib/api";
import CustomWelcomeBanner from "../components/custom-welcome-banner";
export default apiInitializer((api) => {
api.decorateCookedElement((element, helper) => {
const counterWrapper = helper.renderGlimmer(
"div.my-counter",
CustomWelcomeBanner
);
element.appendChild(counterWrapper);
});
});
helper.getPost() will return the current post, and can be used to build conditional logic into these decorateCookedElement callbacks. console.log the post to see what's available.
registerValueTransformer()
api.registerValueTransformer allows you to inject logic into predefined parts of the Discourse JavaScript application. For example, you can add a "home-logo-href" transformer to link the logo to example.com:
api.registerValueTransformer("home-logo-href", () => "https://example.com");
For more information on Transformers, check out the dedicated guide
Finding more JS API methods
All the available APIs are listed in the plugin-api.gjs source code in Discourse core, along with a short description and examples.
That's it for this chapter, and almost the end of the tutorial. Let's wrap things up in the conclusion.
Theme Developer Tutorial: 7. Wrapping up
This tutorial has been a whistle-stop tour through the process of creating a Discourse theme and experimenting with a few of the most common APIs. Of course, there's plenty more to learn, so let's map out some more places for you to explore.
Existing themes & theme component
One of the best resources when learning about theme development is to read through the code of existing themes. Check out the directories in #customization:theme and #customization:theme-component, or jump straight to our all-the-themes meta-repository for even more example code!
Sharing your themes
Once you have a theme or theme component you're happy with, and it's published on GitHub, it's time to share it! Make a new topic in #customization:theme or #customization:theme-component and we'll review/publish it.
How to ask for help
If you're stuck on something that relates to themes, feel free to create a post in the #dev category. Be sure to include as much information as you can and anything you've tried. The more effort you put into your question, the more likely you'll get an answer. If you can include code snippets, or link out to a repository on GitHub, that would be great!
Other resources
-
https://meta.discourse.org/t/designers-guide-to-getting-started-with-themes-in-discourse/152002
-
https://meta.discourse.org/t/how-to-add-settings-to-your-discourse-theme/82557
-
https://meta.discourse.org/t/adding-localizable-strings-to-themes-and-theme-components/109867
-
https://meta.discourse.org/t/write-end-to-end-system-specs-for-the-discourse-user-interface/325937
-
https://meta.discourse.org/t/minimizing-maintenance-on-theme-customizations/261388
ADMIN-QUICK-START-GUIDE
Welcome to your new community, and thank you for choosing Discourse!
📫 Test your email configuration
Email is required for new account signups and notifications.
→ Send a test email.
→ Email didn’t arrive? Read our guide on email providers.
🤝 Invite your team
Collaborating with others?
→ Send invites to your team members.
:speech_balloon: Start some conversations
Give your early members a place to start interacting.
→ Add some topics with conversation starters, frequently asked questions, and other content.
→ Discuss ideas and plans for your community with your team.
🧑🎨 Make it your own
Help your new members feel right at home when they first arrive.
→ Personalize your welcome topic and fill out your about page.
→ Customize your site appearance.
→ Set up different login methods for your members.
👋 Invite your first members
Welcome a few people in - the more, the merrier!
→ Send out invites or send people the link to your community.
:books: Learn more about Discourse
Support resources:
- Ask our AI-powered assistant anything about Discourse.
- Browse the complete Discourse documentation.
Community resources:
- Join the Discourse community.
- Take inspiration from other Discourse communities.
- Read the Discourse blog.
AUTHORS
⚠ This document only reflects the team at the time of the original release in February 2013. For a more detailed and up to date list of the many individuals that contributed to the design and development of Discourse since then, please refer to the official Discourse website. For a list of people who have contributed to the codebase, see GitHub's list of contributors.
The Core Discourse Team
- Jeff Atwood - Co-Founder, PM
- Robin Ward - Co-Founder, Developer
- Sam Saffron - Co-Founder, Developer
- Neil Lalonde - Developer
Special Thanks To
- Nick Sahler - Intern
- Ryan Mudryk - CSS and HTML design implementation
- Don Petersen - Developer, installation scripts
Install Discourse on a Cloud Server
To install and self-host Discourse, follow the steps below. But if you'd rather skip the setup, maintenance, and server management, our official Discourse hosting plans (Free, Pro, Business or Enterprise) take care of everything for you.
If you prefer to self-host but need help setting it up we have partners who can help. Click here to explore self-hosting set-up options.
Set up Discourse in about 10 minutes with zero knowledge of Rails or Linux shell. Works on any Docker-compatible cloud provider or local server.
Contents
- Quick Start
- Requirements
- Installation
- The Setup Wizard
- After Installation
- Advanced Configuration
- Troubleshooting
- Optional Features
Quick Start
Run this single command on a fresh Ubuntu/Debian server:
wget -qO- https://raw.githubusercontent.com/discourse/discourse_docker/main/install-discourse | sudo bash
That's it! The installer will:
- Install Docker and git automatically (if not present)
- Download the Discourse Docker configuration
- Launch an interactive setup wizard
Don't have a domain name? No problem! The installer offers free subdomains like
yoursite.discourse.diy— no purchase required.Don't want to set up email? Skip SMTP and users can log in via Discourse ID with social logins (Google, Facebook, Apple, GitHub).
Requirements
Server Specifications
| Requirement | Minimum | Recommended |
|---|---|---|
| RAM | 1 GB (with swap) | 2 GB+ |
| CPU | 1 core | 2+ cores |
| Disk | 10 GB | 20 GB+ |
| OS | 64-bit Linux | Ubuntu LTS |
Auto-scaling: The installer automatically tunes
UNICORN_WORKERSanddb_shared_buffersbased on your server's RAM and CPU.
Supported Cloud Providers
The installer works on any cloud provider with Docker support:
- DigitalOcean
- AWS EC2
- Google Cloud Platform
- Linode / Akamai
- Vultr
- Hetzner
- Any VPS with root SSH access
Installation
Step 1: Choose Your Domain
You have two options:
| Option | Best For | Setup |
|---|---|---|
Free subdomain (mysite.discourse.diy) |
Testing, learning, hobby projects | Claim at id.discourse.com/my/subdomain |
Your own domain (forum.example.com) |
Production, custom branding | Purchase from a registrar, configure DNS A record |
Free Subdomain
Visit id.discourse.com/my/subdomain anytime to claim your subdomain. You can do this before or during installation:
- Log in with your Discourse account (or create one)
- Choose your desired subdomain name (e.g.,
mysite) - When ready to install, click "Generate Code" to get a 6-digit verification code (valid for 10 minutes)
How it works: The installer verifies your code with Discourse ID, then automatically creates a DNS A record pointing
yoursite.discourse.diyto your server's IP. No manual DNS configuration required!
Your Own Domain
- Purchase a domain from a registrar like Namecheap
- Create a DNS A record pointing your subdomain (e.g.,
forum.example.com) to your server's IP address - Wait for DNS propagation (can take up to 48 hours, usually much faster)
Step 2: Create and Access Your Server
-
Create a cloud server on any provider (e.g., a DigitalOcean droplet with Ubuntu)
-
SSH into your server:
ssh root@your-server-ip
Step 3: Run the Installer
wget -qO- https://raw.githubusercontent.com/discourse/discourse_docker/main/install-discourse | sudo bash
The wizard will guide you through:
- Admin email(s) — Enter the email addresses for admin accounts
- Domain selection:
- Select No for free subdomain → enter your subdomain and verification code
- Select Yes for your own domain → enter your hostname (e.g.,
forum.example.com)
- SMTP configuration — Skip to use Discourse ID login, or configure your email provider
After confirming your settings, the installer builds your Discourse (~5-10 minutes).
Note: SSL certificates via Let's Encrypt are provisioned automatically — no additional configuration required.
The Setup Wizard
SMTP is Optional
Unlike previous versions, you can skip email configuration during initial setup. When you skip SMTP, the installer automatically enables Login via Discourse ID, which provides:
- Email-based login — Users sign in through id.discourse.com (no SMTP needed)
- Social logins — Google, Facebook, Apple, and GitHub authentication built-in
- Centralized identity — Users can use the same account across multiple Discourse communities
- Web push notifications — Users still receive real-time notifications on all browsers and PWA-enabled devices
This is perfect for:
- Getting started quickly without email provider setup
- Communities that prefer social login over email registration
- Mobile-first communities using the PWA experience
Note: If you want traditional email features (email digests, mailing list mode, or email replies), you can configure SMTP later via Admin → Email or by re-running the setup wizard.
Automatic Features
The wizard handles these automatically:
- Public IP detection — Finds your server's IP using multiple services
- Resource scaling — Sets optimal
UNICORN_WORKERSanddb_shared_buffersbased on your hardware - Port validation — Checks that ports 80 and 443 are available
- DNS verification — Confirms your domain resolves to this server
- Swap creation — Offers to create swap space on low-memory servers
After Installation
Access Your Forum
Once the build completes (5-10 minutes), your forum will be available at:
https://yoursite.discourse.diy(free subdomain)https://forum.example.com(your own domain)
Register Your Admin Account
- Visit your forum URL
- Click Sign Up or Log In
- If using Discourse ID login: sign in with Google, Facebook, Apple, GitHub, or email via id.discourse.com
- If using SMTP: use one of the admin emails you entered during setup and check your inbox
- Complete the in-app setup wizard that appears after registration
Tip: Make sure you register with one of the admin emails you entered during installation to get admin privileges.
Post-Install Security
We strongly recommend enabling automatic security updates:
# Ubuntu/Debian - enable unattended upgrades
dpkg-reconfigure -plow unattended-upgrades
# Install fail2ban for brute-force protection
apt install fail2ban
Upgrading Discourse
Via web UI (recommended):
Visit https://your-forum/admin/upgrade and click Upgrade
Via command line:
cd /var/discourse
./launcher rebuild app
Discourse will email you when new versions are available. Stay current for the latest features and security fixes.
Advanced Configuration
Manual Configuration
For advanced users, edit the configuration file directly:
nano /var/discourse/containers/app.yml
cd /var/discourse
./launcher rebuild app
MaxMind Geolocation
For IP-based geolocation features (country detection, etc.), register for a free MaxMind GeoLite2 license key and add it to your app.yml:
env:
DISCOURSE_MAXMIND_LICENSE_KEY: "your_license_key_here"
Launcher Commands
The launcher script in /var/discourse provides these commands:
Usage: launcher COMMAND CONFIG [--skip-prereqs] [--docker-args STRING]
Commands:
start Start/initialize a container
stop Stop a running container
restart Restart a container
destroy Stop and remove a container
enter Open a shell inside the container
logs View Docker logs for a container
bootstrap Bootstrap a container from template
rebuild Rebuild a container (destroy old, bootstrap, start new)
cleanup Remove stopped containers (> 24 hours old)
Troubleshooting
Ports 80/443 Already in Use
# Check what's using the ports
lsof -i :80
lsof -i :443
# Stop conflicting services
systemctl stop nginx # or apache2
systemctl disable nginx
DNS Verification Fails
- Ensure your A record points to the correct server IP
- DNS propagation can take up to 48 hours
- Use
--skip-connection-testto bypass verification temporarily
Free Subdomain Issues
- Ensure you're using the same Discourse ID account that claimed the subdomain
- Verification codes expire after 10 minutes — generate a new one if expired
- Each subdomain can only be claimed by one user
Build or Bootstrap Errors
# View container logs
cd /var/discourse
./launcher logs app
# Enter container for debugging
./launcher enter app
# Check Rails production logs
cat /var/discourse/shared/standalone/log/rails/production.log
Low Memory Errors
The installer will offer to create swap space. You can also create it manually:
# Create 2GB swap
fallocate -l 2G /swapfile
chmod 600 /swapfile
mkswap /swapfile
swapon /swapfile
echo '/swapfile swap swap defaults 0 0' >> /etc/fstab
Email Not Working
If you configured SMTP but emails aren't sending, see the Email Troubleshooting Guide.
Optional Features
After installation, you can enable additional features:
Authentication
- Google OAuth2
- GitHub Login
- Facebook Login
- Twitter/X Login
- Single Sign-On (SSO)
- More auth plugins on meta.discourse.org
Email & Notifications
- Reply via Email
- Recommended Email Providers — If you skipped SMTP during setup
Operations
Extending Discourse
- Install Plugins
- Import from Other Platforms — vBulletin, phpBB, Vanilla, etc.
- Embed in WordPress
- Embed Comments on Static Sites
Help us improve this guide! Feel free to ask questions on meta.discourse.org or submit a pull request.
Still finding setup too complex? Let us host Discourse for you — we handle everything.
INSTALL-email
Recommended Email Providers for Discourse
We strongly recommend using a dedicated email service. Email server setup and maintenance is very difficult even for experienced system administrators, and getting any part of the complex required email setup wrong means your email won't be delivered, or worse, delivered erratically.
The following are template configurations for email service providers known to work with Discourse.
The pricing information is included as a courtesy, and may be out of date. Discourse does not control the pricing for external services, be sure to check with the email provider for up to date pricing information.
Please note that in any email provider, you must verify and use the subdomain, e.g. discourse.example.com. If you verify the domain only, e.g. example.com, mail will not be configured correctly.
Enter these values when prompted by ./discourse-setup per the install guide. To change the current email service, run ./discourse-setup as well (this will bring the forum offline for a few minutes while it gets rebuilt).
Brevo, previously SendInBlue, GDPR — 300 email per day free
SMTP server address? smtp-relay.brevo.com
SMTP user name? [SMTP credentials in [SMTP & API page](https://app.brevo.com/settings/keys/smtp)]
SMTP password? [SMTP credentials in [SMTP & API page](https://app.brevo.com/settings/keys/smtp)]
Port: 587
Mailgun — 50k emails/month on a 30 day trial OR 10k emails/month with no trial
SMTP server address? smtp.mailgun.org
SMTP user name? [SMTP credentials for your domain under domains tab]
SMTP password? [SMTP credentials for your domain under domains tab]
SendGrid — 40k emails on a 30 day trial
SMTP server address? smtp.sendgrid.net
SMTP user name? apikey
SMTP password? [SendGrid API Key]
We recommend creating an API Key instead of using your SendGrid username and password.
Mailjet — 6k emails/month (200 max/day)
Go to My Account page and click on the "SMTP and SEND API Settings" link to generate a secret key and get the SMTP server address.
SMTP server address? [Mailjet SMTP server address]
SMTP username? [Mailjet API key]
SMTP password? [Mailjet secret key]
Elastic Email
SMTP server address? smtp.elasticemail.com
SMTP user name? [Your registered email address]
SMTP password? [Elastic Email API Key]
SMTP port? 2525
NOTE: By default, Elastic Email will add an additional UNSUBSCRIBE link at the bottom of each sent email. You need to work with them to disable that link, so that Discourse users can manage their subscription through Discourse.
Bounce Handling
When using a third party email service, you will need to enable VERP, or activate their webhooks in order to handle bouncing emails. Full details here.
For Amazon SES bounce notifications delivered through SNS:
- Subscribe
https://<your-discourse-host>/webhooks/awsto the bounce notification topic. - Add the exact topic ARN to the
aws_sns_topic_arn_allowlistsite setting. - Enable Include original email headers for bounce notifications on the SES sending identity. Discourse uses the original
Message-IDand bounced recipient to match a sent email. The SES-assigned message ID alone cannot identify the email in Discourse, so notifications without the original Message-ID are ignored.
See the AWS notification contents documentation for the distinction between the original and SES-assigned message IDs.
INSTALL
To install and self-host Discourse, follow the steps below. But if you'd rather skip the setup, maintenance, and server management, our official Discourse hosting takes care of everything for you.
👉 Learn more about Discourse hosting
How Do I Install Discourse?
:bell: The only officially supported installs of Discourse are Docker based. You must have SSH access to a 64-bit Linux server with Docker support. We regret that we cannot support any other methods of installation including cpanel, plesk, webmin, etc.
Simple 10 minute basic install: Beginner Docker install guide
Powerful, flexible, large / multiple server install: Advanced Docker install guide
Why do you only officially support Docker?
Hosting Rails applications is complicated. Even if you already have Postgres, Redis and Ruby installed on your server, you still need to worry about running and monitoring your Sidekiq and Rails processes, as well as configuring Nginx. With Docker, our fully optimized Discourse configuration is available to you in a simple container, along with a web-based GUI that makes upgrading to new versions of Discourse as easy as clicking a button.
Hardware Requirements
- modern single core CPU, dual core recommended
- 1 GB RAM minimum (with swap)
- 64 bit Linux compatible with Docker
- 10 GB disk space minimum
Software Requirements
- PostgreSQL 18+ (with major version matching the version deployed in our docker images)
- Redis 7
- Ruby 4.0
Security
We take security very seriously at Discourse, and all our code is 100% open source and peer reviewed. Please read our security guide for an overview of security measures in Discourse.
List of Discourse Plugins
If you just want to get some plugins for your Discourse instance, check out the plugin category at meta. This is the most up to date place for plugin discussion and listing.
If you want to be safe, use only plugins on this list of Official Plugins:
Discourse Plugin Tutorials
Simple Demonstration Plugins
Discourse Security
We take security very seriously at Discourse. We welcome any peer review of our 100% open source code to ensure nobody's Discourse forum is ever compromised or hacked.
Where should I report security issues?
In order to give the community time to respond and upgrade we strongly urge you report all security issues privately. Please use our vulnerability disclosure program at Hacker One to provide details and repro steps and we will respond ASAP. If you are unable to use Hacker One, email us directly at team@discourse.org with details and repro steps. Security issues always take precedence over bug fixes and feature work. We can and do mark releases as "urgent" if they contain serious security fixes.
Please note: Due to a significant number of low quality security reports sent via email, we are unlikely to act on security reports sent to us via email unless they come from a trusted source, and include details on the vulnerability and step by step instructions to reproduce it. Theoretical reports without a proof of concept are not accepted. We strongly recommend you follow the Hacker One submission protocols.
For a list of recent security commits, check our GitHub commits prefixed with SECURITY.
Password Storage
Discourse uses the PBKDF2 algorithm to encrypt salted passwords. This algorithm is blessed by NIST. Security experts on the web tend to agree that PBKDF2 is a secure choice.
Discourse currently uses PBKDF2 with the sha256 hashing algorithm and 600,000 iterations.
XSS
The main vector for XSS attacks is via the post composer, as we allow users to enter Markdown, HTML (a safe subset thereof), and BBCode to format posts.
There are 3 main scenarios we protect against:
-
Markdown preview invokes an XSS. This is possibly severe in one specific case: when a forum staff member edits a user's post, seeing the raw markup, where a malicious user may have inserted code to run JavaScript. This code would only show up in the preview, but it would run in the context of a forum staff member, which is very bad.
-
Markdown displayed on the page invokes an XSS. To protect against client side preview XSS, Discourse uses xss.js in the preview window.
-
CSP is on by default for all Discourse installations as of Discourse 2.2. It can be switched off in the site settings, but it is default on.
On the server side we run a allowlist based sanitizer, implemented using the Sanitize gem. See the relevant Discourse code.
In addition, titles and all other places where non-admins can enter code are protected either using the Handlebars library or standard Rails XSS protection.
CSRF
CSRF allows malicious sites to perform HTTP requests in the context of a forum user without their knowledge -- mostly by getting users who already hold a valid forum login cookie to click a specific link in their web browser.
Discourse extends the built-in Rails CSRF protection in the following ways:
-
By default any non GET requests ALWAYS require a valid CSRF token. If a CSRF token is missing Discourse will raise an exception.
-
API calls using the secret API bypass CSRF checks.
-
Certain pages are "cacheable", we do not render the CSRF token (
<meta name='csrf-token' ...) on any cacheable pages. Instead when users are about to perform the first non GET request they retrieve the token just in time viaGET session/csrf
DDOS
If you install via our recommended Docker image in our install guide, nginx is the front end web server. For additional DDOS protection we recommend placing HAProxy in front.
Deployment concerns
We strongly recommend that the various Discourse processes (web server, sidekiq) run under a non-elevated account. This is handled automatically if you install via our recommended Docker image -- see our install guide for details.
Discourse Developer Testing Guide
Some notes about testing Discourse:
MailHog
Discourse depends heavily on (sending) email for notifications. We use MailHog to test emails. It's super convenient!
MailHog runs a super simple SMTP server which catches any message sent to it to display in a web interface. Run mailhog, set your favourite app to deliver to smtp://127.0.0.1:1025 instead of your default SMTP server, then check out http://127.0.0.1:8025 to see the mail that's arrived so far.
Contributor Covenant Code of Conduct
Our Pledge
We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community.
Our Standards
Examples of behavior that contributes to a positive environment for our community include:
- Demonstrating empathy and kindness toward other people
- Being respectful of differing opinions, viewpoints, and experiences
- Giving and gracefully accepting constructive feedback
- Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience
- Focusing on what is best not just for us as individuals, but for the overall community
Examples of unacceptable behavior include:
- The use of sexualized language or imagery, and sexual attention or advances of any kind
- Trolling, insulting or derogatory comments, and personal or political attacks
- Public or private harassment
- Publishing others' private information, such as a physical or email address, without their explicit permission
- Other conduct which could reasonably be considered inappropriate in a professional setting
Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful.
Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate.
Scope
This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event.
Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at team@discourse.org. All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the reporter of any incident.
Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct:
1. Correction
Community Impact: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community.
Consequence: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested.
2. Warning
Community Impact: A violation through a single incident or series of actions.
Consequence: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban.
3. Temporary Ban
Community Impact: A serious violation of community standards, including sustained inappropriate behavior.
Consequence: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban.
4. Permanent Ban
Community Impact: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals.
Consequence: A permanent ban from any sort of public interaction within the community.
Attribution
This Code of Conduct is adapted from the Contributor Covenant, version 2.0, available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.



