Splitting up theme Javascript into multiple files

Complex theme javascript can now be split into multiple files, to keep things nicely organised.

To use the new functionality, simply add files to the /javascripts folder in your theme directory. Currently, 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 Adding to plugin-outlets using a theme by adding a single file to your theme:

/javascripts/mytheme/connectors/discovery-list-container-top/add-header-message.hbs

Welcome {{currentUser.username}}. Please visit <a class="nav-link " href="http://google.com" target="_blank">My Site</a>

To add a connector class, add another file

/javascripts/mytheme/connectors/discovery-list-container-top/add-header-message.js.es6

import { isAppleDevice } from "discourse/lib/utilities";

export default {
  shouldRender(args, component) {
    return isAppleDevice();
  }
};

If you want to simply move some existing theme javascript out of a <script type="text/discourse-plugin" block, you should wrap it in an initializer like this:

/javascripts/mytheme/initializers/initialize-stuff.js.es6

import { withPluginApi } from "discourse/lib/plugin-api";
export default {
  name: "my-initializer",
  initialize(){
    withPluginApi("0.8.7", api => {
      // Do something with the API here
    });
  }
}
24 Likes

When I use /javascripts/mytheme/initializers it works for the initializers. but /javascripts/mytheme/templates doesn’t work(i.e. placing a template in the folder doesn’t override the template). Interestingly, /javascripts/discourse/templates works.

Yeah, this is a problem we run into quite a bit. It doesn’t apply specifically to themes - it applies to plugins/core as well.

The relevant code is here:

Critically, baseName is hard coded to be discourse. I think we could safely do a lookup like <anything>/templates/${withoutType}, just like we do for controllers/models/routes/etc. What do you think @eviltrout?

(We will need to be careful to keep this efficient - lookups with a wildcard at the beginning are not ideal)

4 Likes

I would not be against removing that discourse need - I do feel like it makes a lot of plugin paths unnecessarily long.

As for matching the wildcard, we could probably get away with the string ending with /templates/${withoutType} in this case.

3 Likes

Do you mean removing it completely, so paths like

assets/javascripts/discourse/components/my-component.js.es6
assets/javascripts/discourse/templates/components/my-component.hbs

would become

assets/javascripts/components/my-component.js.es6
assets/javascripts/templates/components/my-component.hbs

?

:+1: sounds good. I was thinking that it is kind of tricky to lookup “key ends with” on a javascript object without looping over every key. I guess we can just strip anything before /templates from the keys when we load them in.

5 Likes

I’m not seeing this presently. I’m unable to override a template in a Theme already overriden in a plugin. Specifically topic-list-item.raw.hbs. If that file is present in an installed plugin the Theme will not successfully take precedence.

That’s a big shame as it would increase the utility of plugins tremendously if they could also be Themed.

1 Like

Oh my … hack tower is a bit of a concern, overriding raw templates is a hack so layering more hacks on it is a concern.

What may make more sense here is just having the plugin ship the backend changes, and then leave all the frontend to themes. That way you just pick the theme you want and don’t fight with the plugin cause it ships nothing.

Usability wise maybe plugins could hint that then need one of N themes, something like that.

4 Likes

That’s a good workaround Sam and I get your point. But the obvious downsides are:

  • that will make the installation of plugins more complex.

  • @fzngagan has made a good point that that will only work if the plugin is under your control. What if the plugin is third party, then you are stuck?

  • development is a little more fiddly.

  • you would need to ‘fork’ the JavaScript element to create a bespoke solution for a client. That would very easily and potentially quickly fall out of sync with the plugin evolution and you are left with a significant maintenance overhead.

One upside is it might encourage people to experiment more where Theming is split off.

However given all those downsides I still prefer the Double Frankenstein override

1 Like

Try using .hbr, we recently renamed the file extension for raw templates. Either should work, but with the “hack mountain” of overrides it might need to be consistent

Failing that, can you share the exact paths you’re using for the template in core/plugin/theme?

3 Likes

I did try that but the interstitial plugin had not been updated so not a thorough test. Will revert!

2 Likes

Finally got around to testing this in anger (ok not anger).

Unfortunately I don’t think the double-override works in the way that would be the most useful: looks like the Plugin takes precedence on templates.

STR, what I did (in non-docker dev if that makes any difference):

  1. Install Topic List Previews
  2. Create a new Theme Component and set up Theme sync.
  3. Copy over topic-list-item.hbr from the plugin to theme topic-list-tc/javascripts/discourse/templates/list/topic-list-item.hbr
  4. Edit something obvious in the latter
  5. Refresh browser - NO CHANGE

However, if you:

  1. Install Topic List Previews
  2. Create a new Theme Component and set up Theme sync.
  3. Copy over topic-list-item.hbr from the plugin to theme topic-list-tc/javascripts/discourse/templates/list/topic-list-item.hbr
  4. Edit something obvious in the latter
  5. Rename topic-list-item.hbr to topic-list-itemzzzzzzzzzz.hbr in plugin
  6. Restart server
  7. Refresh browser - REFLECTS UPDATE FROM THEME

So from this I conclude the plugin is taking precedence, not the TC. I still believe it should be the other way around.

This is not critical, because I can use a standalone TC option that I’ve developed, but it still would be a really nice general behaviour to have, especially as I said for modifying 3rd party plugin behaviour you don’t have control over/don’t wish to permanently change/only wish to patch on a single install.

The other issue is that this could be considered as inconsistent behaviour, because the CSS precedence was fixed:

Theme javascript is loaded after plugin Javascript, I am sure of that. So there must be something else at play here. Taking a look at a random site with the TLP plugin enabled, I can run this in the console:

(Note that Discourse.RAW_TEMPLATES became _DISCOURSE_RAW_TEMPLATES recently, but this is an out-of-date site)

> Object.keys(Discourse.RAW_TEMPLATES).filter(i => i.includes("topic-list-item"))
(4) ["list/topic-list-item", "mobile/list/topic-list-item", "javascripts/mobile/list/topic-list-item", "javascripts/list/topic-list-item"]

So core is registering the template as:

list/topic-list-item

And the plugin is registering it as

javascripts/list/topic-list-item

Then I think the theme will be registering it as

list/topic-list-item

Our resolver supports both, but it will prefer things prefixed with javascripts:

So the theme component is correctly overriding the core template, but the plugin one is still taking precedence. You might be able to work around this by manually adding /javascripts to the beginning of your theme template, but don’t rely on that workaround working long-term.

Of course, it would be better to avoid overriding core templates at all - it will almost always lead to pain down the road. :stuck_out_tongue: There are plenty of plugin outlets available, and if you need an extra one feel free to open a #dev topic and we can discuss.

@eviltrout I wonder, do you know why plugin templates are prefixed with javascripts/? It seems like it could be unintentionally added somewhere in the asset pipeline?

4 Likes

Thanks for looking at this. Useful learning here!

Yeah I definitely try to avoid that but have found with experience, ‘leaf’ templates are low risk.

Copy over  `topic-list-item.hbr`  from the plugin to theme  `topic-list-tc/javascripts/discourse/templates/list/topic-list-item.hbr`

So the theme template is within a javascripts directory, is that what you meant or am I missing something?

1 Like

I would try something like this in your theme:

topic-list-tc/javascripts/discourse/templates/javascripts/list/topic-list-item.hbr

(Note javascripts folder underneath templates)

It’s not pretty, but it might work around the problem

2 Likes

Thanks, that worked!

1 Like

Yes, your assumption is correct. It was added by the asset pipeline, and back in the day I decided to make our resolver super flexible and accept anything. In retrospect this was a mistake. It would have been better to ensure the paths fit a specific format and create the appropriate module names.

Ember CLI addons do something interesting - if you put something in an app folder it is merged into the existing app at the same place. If you put it in an addon folder it gets a module with the same name as the addon. I wish I’d thought of that!

8 Likes

From what I can see, these folder structure changes haven’t been implemented, correct?

This is the folder structure I have that seems to be working for themes I’m using to experiment with:

To override hbs templates:

theme-directory/javascripts/discourse/templates/[template-you-are-overriding].hbs

To add code at a plugin outlet:

theme-directory/javascripts/theme-name/connectors/plugin-outlet-name/unique-name-you-choose.hbs

To add javascript/jquery:

theme-directory/javascripts/theme-name/initializers/[your-initializer-file-name].js.es6

(and just for completeness, in that initializer, using the code structure like in the original post):

import { withPluginApi } from "discourse/lib/plugin-api";

export default {
  name: "my-initializer", //note this should be unique for each initializer
  initialize(){
    withPluginApi("0.8.7", api => {
     $(document).ready(function(){  //example with jquery
       console.log('LETS DO IT !!!')
     })
    });
  }
}
2 Likes

Correct, we haven’t made any structure changes yet. The examples you shared all look good :+1:

If you like, you can now remove the .es6 from the javascript file name. We now support the bare .js extension for es6 modules.

5 Likes

Don’t know if this is a reflection of my setup ( I’m using the theme cli ), but when I watch my theme on my live discourse app, I still need to include the es6 on the initializer files. If I don’t, I get this error:
Error in extra_js...Unrecognized file extension: js

This is for files in my javascripts/theme/initializers/initializer.js.es6. With the ES6 in the file name it works fine.