Rendering

This document explains content generation with the mojo.js renderer.

Concepts

Essentials every mojo.js developer should know.

Renderer

The renderer is a tiny black box turning stash data into actual responses utilizing multiple template engines and data encoding modules.

  1. {text: 'Hello.'} -> 200 OK, text/html, 'Hello.'
  2. {json: {x: 3}} -> 200 OK, application/json, '{"x":3}'
  3. {text: 'Oops.', status: '410'} -> 410 Gone, text/html, 'Oops.'

Views can be automatically detected if enough information is provided by the developer or routes. View names are expected to follow the view.format.engine scheme, with view defaulting to controller/action or the route name, format defaulting to html and engine to tmpl.

  1. {controller: 'users', action: 'list'} -> 'users/list.html.tmpl'
  2. {view: 'foo', format: 'txt'} -> 'foo.txt.tmpl'
  3. {view: 'foo', engine => 'haml'} -> 'foo.html.haml'

All views should be in the views directories of the application, which can be customized with app.renderer.viewPaths.

  1. app.renderer.viewPaths.unshift(app.home.child('more-views').toString());

The renderer can be easily extended to support additional template engine with plugins, but more about that later.

Templates

The default template engine used by mojo.js is @mojojs/template. It allows the embedding of JavaScript code right into actual content using a small set of special tags. Templates are compiled to async functions, so you can even use await.

  1. <% JavaScript code %>
  2. <%= JavaScript expression, replaced with XML escaped result %>
  3. <%== JavaScript expression, replaced with result %>
  4. <%# Comment, useful for debugging %>
  5. <%% Replaced with "<%", useful for generating templates %>
  6. % JavaScript code line, treated as "<% line =%>" (explained later)
  7. %= JavaScript expression line, treated as "<%= line %>"
  8. %== JavaScript expression line, treated as "<%== line %>"
  9. %# Comment line, useful for debugging
  10. %% Replaced with "%", useful for generating templates

Tags and lines work pretty much the same, but depending on context one will usually look a bit better. Semicolons get automatically appended to all expressions.

  1. <% const i = 10; %>
  2. <ul>
  3. <% for (let j = 1; i > j; j++) { %>
  4. <li>
  5. <%= j %>
  6. </li>
  7. <% } %>
  8. </ul>
  1. % const i = 10;
  2. <ul>
  3. % for (let j = 1; i > j; j++) {
  4. <li>
  5. %= j
  6. </li>
  7. % }
  8. </ul>

Aside from differences in whitespace handling, both examples generate similar JavaScript code, a naive translation could look like this.

  1. let __output = '';
  2. const i = 10;
  3. __output += '<ul>';
  4. for (let j = 1; i > j; j++) {
  5. __output += '<li>';
  6. __output += __escape(j);
  7. ___output += '</li>';
  8. }
  9. __output += '</ul>';
  10. return __output;

By default the characters <, >, &, ' and " will be escaped in results from JavaScript expressions, to prevent XSS attacks against your application.

  1. <%= 'I ♥ mojo.js!' %>
  2. <%== '<p>I ♥ mojo.js!</p>' %>

Whitespace characters around tags can be trimmed by adding an additional equal sign to the end of a tag.

  1. <% for (let i = 1; i <= 3; i++) { =%>
  2. <%= 'The code blocks around this expression are not visible in the output' %>
  3. <% } =%>

Code lines are automatically trimmed and always completely invisible in the output.

  1. % for (let i = 1; i <= 3; i++) {
  2. <%= 'The code lines around this expression are not visible in the output' %>
  3. % }

At the beginning of the template, stash values get automatically initialized as normal variables. Additionally there is also a stash variable and the context object is available as ctx, giving you full access to request information and helpers.

  1. ctx.stash.name = 'tester';
  1. Hello <%= name %> from <%= ctx.req.ip %>.

Basics

Most commonly used features every mojo.js developer should know about.

Rendering Templates

The renderer will always try to detect the right template, but you can also use the view stash value to render a specific one. Everything before the last slash will be interpreted as the subdirectory path in which to find the template.

  1. // views/foo/bar/baz.*.*
  2. await ctx.render({view: 'foo/bar/baz'});

Choosing a specific format and engine is just as easy.

  1. // views/foo/bar/baz.txt.tmpl
  2. await ctx.render({view: 'foo/bar/baz', format: 'txt', engine: 'tmpl'});

If you’re not sure in advance if a template actually exists, you can also use maybe render option to try multiple alternatives.

  1. if (await ctx.render({view: 'localized/baz', maybe: true}) === false) {
  2. await ctx.render({view: 'foo/bar/baz'});
  3. }

Rendering to Strings

Sometimes you might want to use the rendered result directly instead of generating a response, for example, to send emails, this can be done with ctx.renderToString().

  1. const html = await ctx.renderToString({view: 'email/confirmation'});

Template Variants

To make your application look great on many different devices you can also use the variant render option to choose between different variants of your templates.

  1. // views/foo/bar/baz.html+phone.tmpl
  2. // views/foo/bar/baz.html.tmpl
  3. await ctx.render({view: 'foo/bar/baz', variant: 'phone'});

This can be done very liberally since it only applies when a template with the correct name actually exists and falls back to the generic one otherwise.

Rendering Inline Templates

Some engines such as tmpl allow templates to be passed inline.

  1. await ctx.render({inline: 'The result is <%= 1 + 1 %>.'});

Since auto-detection depends on a path you might have to supply an engine name too.

  1. await ctx.render({inline: "The result is {{ result }}", engine => 'handlebars');

Rendering Text

Character strings (as well as binary buffers) can be rendered with the text stash value.

  1. await ctx.render({text: 'I ♥ Mojolicious!'});

Rendering JSON

The json stash value allows you to pass JavaScript data structures to the renderer which get directly encoded to JSON.

  1. await ctx.render({json: {foo: [1, 'test', 3]}});

Status Code

Response status codes can be changed with the status stash value.

  1. await ctx.render({text: 'Oops.', status: 500});

Content Type

The Content-Type header of the response is actually based on the MIME type mapping of the format render option.

  1. // Content-Type: text/plain
  2. await ctx.render({text: 'Hello.', format: 'txt'});
  3. // Content-Type: image/png
  4. await ctx.render({text: Buffer.from(...), format: 'png'});

While most common MIME types are supported by default, you can be easily add your own via app.mime.

  1. app.mime.custom.foo = 'application/foo';

Stash Data

Any of the native JavaScript data types can be passed to templates as references through ctx.stash.

  1. ctx.stash.description = 'web framework';
  2. ctx.stash.frameworks = ['Catalyst', 'Mojolicious', 'mojo.js'];
  3. ctx.stash.spinoffs = {minion: 'job queue'};
  1. <%= description %>
  2. <%= frameworks[1] %>
  3. <%= spinoffs.minion %>

Since everything is just JavaScript, normal control structures just work.

  1. % for (const framework of frameworks) {
  2. <%= framework %> is a <%= description %>.
  3. % }
  1. % const description = spinoffs.minion;
  2. % if (description !== undefined) {
  3. Minion is a <%= description %>.
  4. % }

For templates that might get rendered in different ways and where you’re not sure if a stash value will actually be set, you can just use ctx.stash.

  1. % const spinoffs = ctx.stash.spinoffs;
  2. % if (spinoffs !== undefined) {
  3. Minion is a <%= $spinoffs.minion %>.
  4. % }

Helpers

Helpers are little functions you can use in templates as well as controller code.

  1. %# Template
  2. %= ctx.inspect([1, 2, 3])
  1. // Controller
  2. const serialized = ctx.inspect([1, 2, 3]);

With a mock context object they can also be used without an active request.

  1. const ctx = app.newMockContext();
  2. const serialized = ctx.inspect([1, 2, 3]);

See the Cheatsheet for a full list of helpers that are currently available by default.

Content Negotiation

For resources with different representations and that require truly RESTful content negotiation you can also use ctx.respondTo().

  1. // GET /hello (Accept: application/json) -> "json"
  2. // GET /hello (Accept: application/xml) -> "xml"
  3. // GET /hello.json -> "json"
  4. // GET /hello.xml -> "xml"
  5. await ctx.respondTo({
  6. json: {json: {hello: 'world'}},
  7. xml: {text: '<hello>world</hello>', format: 'xml'}
  8. });

The best possible representation will be automatically selected from the ext stash value or Accept request header.

  1. await ctx.respondTo({
  2. json: {json: {hello: 'world'}},
  3. html: async ctx => {
  4. ctx.contentFor('header', '<meta name="author" content="sri">');
  5. await ctx.render({view: 'hello'}, {message: 'world'});
  6. }
  7. });

Functions can be used for representations that are too complex to fit into a single render call.

  1. // GET /hello (Accept: application/json) -> "json"
  2. // GET /hello (Accept: text/html) -> "html"
  3. // GET /hello (Accept: image/png) -> "any"
  4. // GET /hello.json -> "json"
  5. // GET /hello.html -> "html"
  6. // GET /hello.png -> "any"
  7. await ctx.respondTo({
  8. json: {json: {hello: 'world'}},
  9. html: {template: 'hello'}, {message: 'world'},
  10. any: {text: '', status: 204}
  11. });

And if no viable representation could be found, the any fallback will be used or an empty 204 response rendered automatically.

  1. // GET /hello -> "html"
  2. // GET /hello (Accept: text/html) -> "html"
  3. // GET /hello (Accept: text/xml) -> "xml"
  4. // GET /hello (Accept: text/plain) -> null
  5. // GET /hello.html -> "html"
  6. // GET /hello.xml -> "xml"
  7. // GET /hello.txt -> null
  8. const formats = ctx.accepts(['html', 'xml']);
  9. if (formats !== null) {
  10. ...
  11. }

For even more advanced negotiation logic you can also use ctx.accepts().

Exception and Not-Found Pages

By now you’ve probably already encountered the built-in 404 (Not Found) and 500 (Server Error) pages, that get rendered automatically when you make a mistake. Those are fallbacks for when your own exception handling fails, which can be especially helpful during development. You can also render them manually with the helpers ctx.exception() and ctx.notFound().

  1. import mojo from '@mojojs/core';
  2. const app = mojo();
  3. app.get('/divide/:dividend/by/:divisor', async ctx => {
  4. const params = await ctx.params();
  5. const dividend = parseInt(params.dividend);
  6. const divisor = parseInt(params.divisor);
  7. // 404
  8. if (isNaN(dividend) || isNaN(divisor)) return ctx.notFound();
  9. // 500
  10. if (divisor === 0) return ctx.exception(new Error('Division by zero!'));
  11. // 200
  12. return ctx.render({text: `${dividend / divisor}`});
  13. });
  14. app.start();

You can also change the templates of those pages, since you most likely want to show your users something more closely related to your application in production. The renderer will always try to find exception.${mode}.${format}.* or not_found.${mode}.${format}.* before falling back to the built-in default templates.

  1. %# views/exception.production.html.tmpl
  2. <!DOCTYPE html>
  3. <html>
  4. <head><title>Server error</title></head>
  5. <body>
  6. <h1>Exception</h1>
  7. <p><%= exception %></p>
  8. <h1>Stash</h1>
  9. <pre><%= ctx.inspect(ctx.stash) %></pre>
  10. </body>
  11. </html>

The default exception format is html, but that can be changed at application and context level. By default there are handlers for html, txt and json available.

  1. import mojo from '@mojojs/core';
  2. const app = mojo({exceptionFormat: 'json'});
  3. app.get('/json', ctx => {
  4. throw new Error('Just a test');
  5. });
  6. app.get('/txt', ctx => {
  7. ctx.exceptionFormat = 'txt';
  8. throw new Error('Just a test');
  9. });
  10. app.start();

There are also various exception helpers for you to overload to change the default behavior.

Layouts

Most of the time when using tmpl templates you will want to wrap your generated content in an HTML skeleton, thanks to layouts that’s absolutely trivial.

  1. import mojo from '@mojojs/core';
  2. const app = mojo();
  3. app.get('/', async ctx => {
  4. await ctx.render({view: 'foo/bar'});
  5. });
  6. app.start();
  1. %# views/foo/bar.html.tmpl
  2. % view.layout = 'mylayout';
  3. Hello World!
  1. %# views/layouts/mylayout.html.tmpl
  2. <!DOCTYPE html>
  3. <html>
  4. <head><title>MyApp</title></head>
  5. <body><%== ctx.content.main %></body>
  6. </html>

You just select the right layout with view.layout and position the rendered content of the main template in the layout with ctx.content.main.

  1. await ctx.render({view: 'mytemplate', layout: 'mylayout'});

Instead of using view.layout you can also pass the layout directly to ctx.render().

Partial Views

You can break up bigger templates into smaller, more manageable chunks. These partial views can also be shared with other templates. Just use ctx.include() to include one view into another.

  1. import mojo from '@mojojs/core';
  2. const app = mojo();
  3. app.get('/', async ctx => {
  4. await ctx.render({view: 'foo/bar'});
  5. });
  6. app.start();
  1. %# views/foo/bar.html.tmpl
  2. <!DOCTYPE html>
  3. <html>
  4. %= ctx.include({view: '_header'}, {title: 'Howdy'})
  5. <body>Bar</body>
  6. </html>
  1. %# views/_header.html.tmpl
  2. <head><title><%= title %></title></head>

You can name partial views however you like, but a leading underscore is a commonly used naming convention.

Reusable Template Blocks

It’s never fun to repeat yourself, that’s why you can create reusable template blocks in tmpl that work very similar to normal async JavaScript functions, with the <{blockName}> and <{/blockName}> tags.

  1. import mojo from '@mojojs/core';
  2. const app = mojo();
  3. app.get('/', async ctx => {
  4. await ctx.render({view: 'welcome'});
  5. });
  6. app.start();
  1. %# views/welcome.html.tmpl
  2. <{helloBlock(name)}>
  3. Hello <%= name %>.
  4. <{/helloBlock}>
  5. <%= await helloBlock('Wolfgang') %>
  6. <%= await helloBlock('Baerbel') %>

A naive translation of the template to JavaScript code could look like this.

  1. let __output = '';
  2. const helloBlock = async name => {
  3. let __output = '';
  4. __output += 'Hello ';
  5. __output += __escape(name);
  6. __output += '.\n';
  7. return new SafeString(__output);
  8. };
  9. __output += __escape(await helloBlock('Wolfgang'));
  10. __output += __escape(await helloBlock('Baerbel'));
  11. return __output;

While template blocks cannot be shared between templates, they are most commonly used to pass parts of a template to helpers.

Adding Helpers

You should always try to keep your actions small and reuse as much code as possible. Helpers make this very easy, they get passed the context object as first argument, and you can use them to do pretty much anything an action could do.

  1. import mojo from '@mojojs/core';
  2. const app = mojo();
  3. app.addHelper('debug', (ctx, str) => {
  4. ctx.app.log.debug(str);
  5. });
  6. app.get('/', async ctx => {
  7. ctx.debug('Hello from an action!');
  8. await ctx.render({view: 'index'});
  9. });
  10. app.start();
  1. %# views/index.html.tmpl
  2. % ctx.debug('Hello from a template!');

Helpers can also accept template blocks, this for example, allows pleasant to use tag helpers and filters. Wrapping the helper result into a SafeString object can prevent accidental double escaping.

  1. import mojo, {SafeString} from '@mojojs/core';
  2. const app = mojo();
  3. app.addHelper('trimNewline', async (ctx, block) => {
  4. const blockResult = await block();
  5. const trimmedResult = blockRsult.toString().replaceAll('\n', '');
  6. return new SafeString(trimmedResult);
  7. });
  8. app.get('/', async ctx => {
  9. await ctx.render({view: 'index'});
  10. });
  11. app.start();
  1. %# views/index.html.tmpl
  2. <{someBlock}>
  3. Some text.
  4. %= 1 + 1
  5. More text.
  6. <{/someBlock}>
  7. %= await trimNewline(someBlock)

Of course helpers can also be specific to a single use case, such as adding headers in actions.

  1. import mojo from '@mojojs/core';
  2. const app = mojo();
  3. app.addHelper('cacheControlNoCaching', ctx => {
  4. ctx.res.set('Cache-Control', 'private, max-age=0, no-cache');
  5. });
  6. app.addHelper('cacheControlFiveMinutes', ctx => {
  7. ctx.res.set('Cache-Control', 'public, max-age=300');
  8. });
  9. app.get('/news', async ctx => {
  10. ctx.cacheControlNoCaching();
  11. await ctx.render({text: 'Always up to date.'});
  12. });
  13. app.get('/some_older_story', async ctx => {
  14. ctx.cacheControlFiveMinutes();
  15. await ctx.render({text: 'This one can be cached for a bit.'});
  16. });
  17. app.start();

While helpers can also be redefined, this should only be done very carefully to avoid conflicts.

Content Blocks

The method ctx.contentFor() allows you to pass whole blocks of content from one template to another. This can be very useful when your layout has distinct sections, such as sidebars, where content should be inserted by the template.

  1. import mojo from '@mojojs/core';
  2. const app = mojo();
  3. app.get('/', async ctx => ctx.render({view: 'foo', layout: 'mylayout'}));
  4. app.start();
  1. %# views/foo.html.tmpl
  2. <{typeBlock}>
  3. <meta http-equiv="Content-Type" content="text/html">
  4. <{/typeBlock}>
  5. % ctx.contentFor('header', await typeBlock());
  6. <div>Hello World!</div>
  7. <{pragmaBlock}>
  8. <meta http-equiv="Pragma" content="no-cache">
  9. <{/pragmaBlock}>
  10. % ctx.contentFor('header', await pragmaBlock());
  1. %# views/layouts/mylayout.html.tmpl
  2. <!DOCTYPE html>
  3. <html>
  4. <head><%= ctx.content.header %></head>
  5. <body><%= ctx.content.main %></body>
  6. </html>

Forms

Since most browsers only allow forms to be submitted with GET and POST, but not request methods like PUT or DELETE, they can be spoofed with an _method query parameter.

  1. import mojo from '@mojojs/core';
  2. const app = mojo();
  3. app.get('/', ctx => ctx.render({view: 'form'})).name('index');
  4. // PUT /nothing
  5. // POST /nothing?_method=PUT
  6. app.put('/nothing', async ctx => {
  7. const params = await ctx.params();
  8. const value = params.whatever ?? '';
  9. const flash = await ctx.flash();
  10. flash.confirmation = `We did nothing with your value (${value}).`;
  11. await ctx.redirectTo('index');
  12. });
  13. app.start();
  1. %# views/form.html.tmpl
  2. <!DOCTYPE html>
  3. <html>
  4. <body>
  5. % const flash = await ctx.flash();
  6. % if (flash.confirmation !== null) {
  7. <p><%= flash.confirmation %></p>
  8. % }
  9. <form method="POST" action="<%= ctx.urlFor('nothing', {query: {_method: 'PUT'}}) %>">
  10. <input type="text" name="whatever" value="I ♥ Mojolicious!" />
  11. <input type="submit" />
  12. </form>
  13. </body>
  14. </html>

ctx.flash() and ctx.redirectTo() are often used together to prevent double form submission, allowing users to receive a confirmation message that will vanish if they decide to reload the page they’ve been redirected to.

Advanced

Less commonly used and more powerful features.

Serving Static Files

Static files are automatically served from the public directories of the application, which can be customized with app.static.publicPaths. And if that’s not enough you can also serve them manually with ctx.sendFile().

  1. import mojo from '@mojojs/core';
  2. import Path from '@mojojs/path';
  3. const app = mojo();
  4. app.get('/', async ctx => {
  5. await ctx.sendFile(ctx.home.child('public', 'index.html'));
  6. });
  7. app.get('/some_download', async ctx => {
  8. ctx.res.set('Content-Disposition', 'attachment; filename=bar.png;');
  9. await ctx.sendFile(ctx.home.child('public', 'foo', 'bar.png'));
  10. });
  11. app.get('/leak', async ctx => {
  12. await ctx.sendFile(new Path('/etc/passwd'));
  13. });
  14. app.start();

Custom Responses

For dynamic content that does not use the renderer you can use ctx.res.send directly.

  1. import mojo from '@mojojs/core';
  2. const app = mojo();
  3. app.get('/', async ctx => {
  4. ctx.res.statusCode = 200;
  5. ctx.res.set('Content-Type', 'text/plain');
  6. await ctx.res.send('Hello World!');
  7. });
  8. app.start();

This also works for readable streams.

  1. import mojo from '@mojojs/core';
  2. import Path from '@mojojs/path';
  3. const app = mojo();
  4. app.get('/', async ctx => {
  5. const readable = new Path('/etc/passwd').createReadStream();
  6. ctx.res.set('Content-Type', 'text/plain');
  7. await ctx.res.send(readable);
  8. });
  9. app.start();

Helper Plugins

Some helpers might be useful enough for you to share them between multiple applications, plugins make that very simple.

  1. export default function cachingHelperPlugin (app) {
  2. app.addHelper('noCaching', ctx => {
  3. ctx.res.set('Cache-Control', 'private, max-age=0, no-cache');
  4. });
  5. app.addHelper('fiveMinutesCaching', ctx => {
  6. ctx.res.set('Cache-Control', 'public, max-age=300');
  7. });
  8. }

The exported plugin function will be called by the mojo.js application during startup and may contain any code that could also appear in the main application script itself.

  1. import mojo from '@mojojs/core';
  2. import cachingHelperPlugin from './plugin.js';
  3. const app = mojo();
  4. app.plugin(cachingHelperPlugin);
  5. app.get('/', async ctx => {
  6. ctx.fiveMinutesCaching();
  7. await ctx.render({text: 'Hello Caching!'});
  8. });
  9. app.start();

A skeleton for a full npm compatible plugin can also be generated with the create-plugin command. We recommend the use of a mojo-plugin-* naming prefix to make the package easier to identify.

  1. $ mkdir mojo-plugin-caching-helpers
  2. $ cd mojo-plugin-caching-helpers
  3. $ npm install @mojojs/core
  4. $ npx mojo create-plugin mojo-plugin-caching-helpers

The generated test file test/basic.js uses tap by default and contains enough integration tests to get you started in no time.

  1. $ npm install
  2. $ npm run test

Once you are happy with your plugin you can share it with the community, all you need is an npm account. Don’t forget to update the metadata in your package.json file.

  1. $ npm version major
  2. $ npm publish

See mojo-plugin-ejs for a full example plugin you can fork. And if you’re writing your plugin in TypeScript, make sure to use declaration merging to add your helpers to the MojoContext interface.

  1. declare module '@mojojs/core' {
  2. interface MojoContext {
  3. noCaching: () => void;
  4. fiveMinutesCaching: (): void;
  5. }
  6. }

Chunked Transfer Encoding

For very dynamic content you might not know the response content length in advance, that’s where the chunked transfer encoding comes in handy.

  1. import mojo from '@mojojs/core';
  2. import {Stream} from 'stream';
  3. const app = mojo();
  4. app.get('/', async ctx => {
  5. ctx.res.set('Transfer-Encoding', 'chunked');
  6. const stream = Stream.Readable.from(['Hello', 'World!']);
  7. await ctx.res.send(stream);
  8. });
  9. app.start();

Just set the Transfer-Encoding header to chunked and send the response content in chunks via Readable stream.

  1. HTTP/1.1 200 OK
  2. Transfer-Encoding: chunked
  3. Date: Sun, 24 Apr 2022 02:43:21 GMT
  4. Connection: close
  5. 5
  6. Hello
  7. 6
  8. World!
  9. 0

Adding Your Favorite Template System

Maybe you would prefer a different template system than @mojojs/template, which is included with mojo.js under the name tmpl, and there is not already a plugin on npm for your favorite one. All you have to do, is to add a new template engine with renderer.addEngine() from your own plugin.

  1. // my-engine.js
  2. export default function myEnginePlugin (app) {
  3. app.renderer.addEngine('mine', {
  4. async render(ctx, options) {
  5. // Check for one-time use inline template
  6. const inline = options.inline;
  7. // Check for appropriate template in "views" directories
  8. const viewPath = options.viewPath;
  9. // This part is up to you and your template system :)
  10. ...
  11. // Pass the rendered result back to the renderer as a `Buffer` object
  12. return Buffer.from('Hello World!');
  13. // Or just throw and exception if an error occurs
  14. throw new Error('Something went wrong with the template');
  15. }
  16. });
  17. }

An inline template, if provided by the user, will be passed along with the options.

  1. // myapp.js
  2. import mojo from '@mojojs/core';
  3. import myEnginePlugin from './my-engine.js';
  4. const app = mojo();
  5. app.plugin(myEnginePlugin);
  6. // Render an inline template
  7. app.get('/inline', async ctx => {
  8. await ctx.render({inline: '...', engine: 'mine'});
  9. });
  10. // Render template file "views/test.html.mine"
  11. app.get('/template', async ctx => {
  12. await ctx.render({view: 'test'});
  13. });
  14. app.start();

See mojo-plugin-ejs for a full example plugin you can fork.

Support

If you have any questions the documentation might not yet answer, don’t hesitate to ask in the Forum, on Matrix, or IRC.