Feedback on "on-discourse" javascript for setting up custom JS for each page?

I’ve been writing a Svelte app that needs to install itself on each page. I figured out how to add my JS file into the head section and get it to load on the first page load. However, as I experimented, I realized that discourse is pulling in new content via XHR and replacing certain sections, and so my app was not getting reinitialized when a new page loads.

I played around with various attempts to get notified when the page changes, but it does not seem like Ember provides the hooks, and I was unable to find any custom events that I could listen for.

It looks like one way is to add a mutation observer to the DOM and watch for changes. I found that the "#topic div seems to be the one which is reloaded (and the attributes change so you can just watch for attribute changes). I setup a mutation observer there (mutation observers are the new-ish performant way to watch for DOM changes). The way it works is to watch for changes, and when they occur, run a callback to reload my Svelte app for that page.

I’m liking this approach, and would like feedback.

One question: should I instead watch for changes to the URL? Is that a better idea to register a listener for popstate?

To use it, do something like this in your theme/head:

<script src="https://files.extrastatic.dev/community/on-discourse.js"></script>
<script src="https://files.extrastatic.dev/community/index.js"></script>
<link rel="stylesheet" type="text/css" href="https://files.extrastatic.dev/community/index.css" ></link>

Then, inside your library you can call on-discourse like this:

function log(...msg) {
  console.log('svelte', ...msg);
}

// This is the Svelte install code
function setup() {
  try {
    const ID = 'my-special-target-id';
    log('Inside setup()');
    const el = document.getElementById(ID);
    if (el) {
      log('Removed existing element', ID);
      el.remove();
    }
    const target = document.createElement("div");
    target.setAttribute("id", ID);
    log('Created target');
    document.body.appendChild(target);
    log('Appended child to body');
    const app = new App({
      // eslint-disable-next-line no-undef
      target
    });
    log('Created app and installed');
  } catch(err) {
    console.error('Unable to complete setup()', err.toString() );
  }
}

(function start() {
  log('Starting custom Svelte app');
  // Re-install on changes
  window.onDiscourse && window.onDiscourse( setup );
  // Load the app the first page load
  window.addEventListener('load', () => {
    setup();
  });
  log('Finished custom Svelte app);  
})();

Basically, you just call window.onDiscourse(callback) with your callback (and you can run it multiple times to install multiple callbacks), and then when a mutation occurs, that callback is run to initialize your app.

Here is the full code for on-discourse.js. (edit: I updated this to use #topic, which seems like a good thing to watch, because the attributes change when the page loads, and then the mutation only needs to watch for attribute changes, rather than looking over the entirety of the DOM tree for #main-outlet)

let mutationObservers = [];

function log(...msg) {
  console.log("on-discourse", ...msg);
}

function observeMainOutlet() {
  log('Observing main outlet');
  // Select the node that will be observed for mutations
  const targetNode = document.getElementById("topic");
 
  if (targetNode) {
    // Options for the observer (which mutations to observe)
    const config = { attributes: true };
    
   // create an observer instance to reset when childList changes
    const observer = new MutationObserver(function(mutations) {
      let reset = false;
      mutations.forEach(function(mutation) {
	if (mutation.type === 'attributes') {
	  log('Found main-outlet mutation, running callbacks');
	    mutationObservers.forEach( (s,i) => {
		try {
		    log(`Running div callback ${i+1}`);	    
		    s();
		    log(`Finished div callback ${i+1}`);	    
		} catch( err ) {
		    log(`Div callback error (${i+1})`, err );	    	      
		}
	 });
       }
      });
    });
    
    // Start observing the target node for configured mutations
    observer.observe(targetNode, config);
    
    // Later, you can stop observing
    // observer.disconnect();
    log('Done with outlet observer');
  } else {
    console.error('on-discourse FATAL ERROR: Unable to find main-outlet');
  }
}

window.addDiscourseDivMutationObserver = (cb) => {
    log('Adding on-discourse div mutation callback');  
    mutationObservers.push(cb);
    log('Added on-discourse div mutation callback');    
}

window.addEventListener("load", () => {
    log('Setting up topic observer');
    if (mutationObservers.length > 0) {
	observeMainOutlet();
    }
    log('Created topic observer');  
});

log('Completed setup of on-discourse.js');

I think you want to put your code in an initializer rather than in a head. Look at the developer guide and see how you can put your JS in separate files.

1 Like

Thanks so much for the reply!

I think this is partly why I’m confused, I’m having trouble finding any references to extending Discourse by adding my own JS.

When I do a duckduckgo search for “Discourse developer guide” the first link I get is a link to the github repo.

The next link is to the “Discourse Advanced Developer Install Guide.” This guide is to tell you how to set it Rails for development, but does not have any links about how to install custom JS AFAICT. I’m trying to avoid a complicate build process which is what I remember from my Rails days. I would really like to develop this JS extension code in isolation, and then put a script tag into my site. So, I really don’t want to have to setup a Rails environment locally so I can build it; maybe I’m missing the utility of that? But, I really like being able to just update a docker container which uses a theme with a few <script> tags.

The next link is a “Beginner’s guide to developing Discourse Themes” which is about developing themes, not what I need, right?

I see links to the Discourse API which is obviously not what I want.

If I search for “discourse javascript initializer” I see this link from 5 years ago: Execute JavaScript code from a plugin once after load But, that seems like I am plugging into Rails, and I feel like there should be a simpler way, and this thread also seems unresolved?

Another link to “discourse javascript initializer” suggests doing what I am doing to install the JS, but does not have suggestions on how to make sure that anytime the page content changes (either through a full page refresh or XHR “turbolinks”-ish request): How do I add an external javascript file into discourse? - Stack Overflow

Is this the discussion I should be reviewing? A versioned API for client side plugins

Or, perhaps this? At first glance I don’t understand the syntax (those annotations don’t look like JS to me, are those rails conventions?) so I’m not sure if this is what I need: Using Plugin Outlet Connectors from a Theme or Plugin

Yeah it’s not all trivial.

Discourse is an EmberJS app.

Ideally JavaScript extensions should be written in the EmberJS framework using a Theme Component (or Plugin), the Discourse JavaScript API where appropriate and plugin outlets.

The main issue you will encounter in using ad hoc external scripts is making them fire at the right time.

To ensure that you need to link them to actions in a Component (which can be made to fire on insert or update)

1 Like

That’s good confirmation. But, I have to say, I’m really happy with my mutation observer code. It detects when the content of the page changes correctly, and then allows me to run my custom JS code. It’s working beautifully, and I didn’t have to learn Ember, nor modify the RoR app in any way. I’m really happy with this solution for now. Thanks for all the comments.

1 Like

I just tried to use a popstate event observer. If that would have worked, then the code would be 5 lines instead of 20. However, clicking around does not seem to trigger that event. If I use the forward or back buttons, I do see the event. I don’t obviously understand popstate enough but for now I am sticking with a div mutation observer.

I realized I was doing one foolish thing here. I’m modifying the theme and adding code to the head. If I switch to a different theme, those changes are lost. The right way is to add the code using a plugin. I used the template here: GitHub - discourse/discourse-plugin-skeleton: Template for Discourse plugins. Then, I added JavaScript code like this:

export default {
  name: 'alert',
    initialize() {
        const scripts = [
            'https://files.extrastatic.dev/community/on-discourse.js',
            'https://files.extrastatic.dev/community/index.js'
        ];
        scripts.forEach( s => {
            const script = document.createElement('script');
            script.setAttribute('src', s);
            document.head.appendChild(script);
        });

        const link = document.createElement('link');
        link.setAttribute('rel', "stylesheet");
        link.setAttribute('type', "text/css");
        link.setAttribute('href', "https://files.extrastatic.dev/community/index.css");
        document.head.appendChild(link);
    }
};

Then, I ran ./launcher rebuild app and then enabled the plugin.

Lastly, I needed to add CSP policy in the settings to permit loading those script. I went to admin->settings->security then added files.extrastatic.dev to the “content security policy script src” and clicked apply.

Not true.

If you are not modifying the API (e.g. using 100% javascript) there is no need to use a Plugin which are more cumbersome to deploy and switch out.

If all you are doing is modifying or adding some JavaScript a Theme Component should suffice.

2 Likes

OK. But, when I switch themes (or if I allow a user to choose their own theme), then am I not faced with the problem that I need to make sure each theme is 1) editable so I can add the new head tags and even worse 2) need to maintain my code across all themes?

When I started using different themes, some indicate that to edit the theme you need to modify the github repo. This seems really onerous and inflexible. But, maintaining my script tags across each theme seems very error prone and an even bigger problem.

What am I missing? Is there a way to resolve those problems only by using themes?

1 Like

In this case, you would make it a Theme Component and add that Component to all Themes. (I’ll admit this is an additional complexity).

If you want to run the site professionally, making sure all your code is on Github is a very good idea indeed.

However, initially when you are just trying some ideas, for sure, you can mess with it “locally” if you so please.

When code changes have settled down, you should probably stick it in source control, but I’d argue the earlier the better.

A way of combining quick evolution with GitHub is to combine it with this:

And deploy to a test Theme Component in a Test Theme … but now we are really getting funky …

Which allows you to deploy on the fly, then you can secure your changes to a git repo once happy.

1 Like

Have a look at GitHub - discourse/discourse-theme-skeleton: Template for Discourse themes

This is what you need How do you force a script to refire on every page load in Discourse? - #5 by simon

<script type="text/discourse-plugin" version="0.8">
    api.onPageChange(() =>{
        // code
    });
</script>
3 Likes

Wow @RGJ that looks exactly perfect! Thanks!

2 Likes

Hi @RGJ I have been trying to figure out how to do this, and have to admit I’m getting confused. It appears the plugin API has changed a bit over the years and I’m not sure how to get what’s current, or see an example.

Your code looks like it goes into an HTML page somewhere as it is wrapped by the script tags. I was using code like this which is the JS file itself.

How do I modify my plugin to use the code you provided? Is this code you suggested something that goes into a plugin, or do I put it into a theme, or otherwise add it to the pages?

I tried to use code like this:

export default {
  name: 'publicdo',
    initialize() {
     withPluginApi('0.1', api => {
                api.onPageChange(() => {
                   console.log('Run my code here.');
                });
      });
    }
}

But, this fails with Uncaught (in promise) ReferenceError: withPluginApi is not defined so it clearly isn’t something that generally loaded JS receives.

You must simply

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

3 Likes

You should really browse the sources linked in theme-component (the ecosystem is almost all open-source - fill your boots!).

You will notice Imports are often necessary and common (and so is the use of api.onPageChange and other useful api functions).

4 Likes

This topic was automatically closed 30 days after the last reply. New replies are no longer allowed.