W08-10: JuliaCon 2019

A large chunk of the past three weeks was taken up by the preparation for, traveling to and participating in JuliaCon 2019 that took place in Baltimore. I gave a talk on Documenter there (the slides deck is also available). While more of an overview of Documenter, I also briefly teased the new front end.

On Documenter development front:

  • I tagged Documenter 0.23.0 right before heading to JuliaCon, which included a bunch of updates, but most importantly the new doctest function and doctesting refactoring.
  • The doctesting refactoring introduced did introduce a few small bugs, so we have already had two additional patch releases (0.23.1, 0.23.2). The fixes were mainly for issues with the Markdown.MD to Markdown2.MD conversion (#1082, #1075).

For the HTML front end, there have been a few smaller updates:

  • Footnote back-references (i.e. clicking on the label at the footnote definition takes you back to the references).
  • A proper theme selector (see below).
  • Various fixes an cleanups of the new SCSS.

Theme selector

One slightly larger feature I implemented in the new front end was a proper theme selector. In the UI, it can be accessed by clicking the cogwheel on the top-right, which opens the following modal dialog:

Theme selector

It turned out to be a little bit tricky to implement, so I think it is worth documenting the final implementation here. We have the following requirements:

  1. We want the theme to persist between page loads
  2. We want to completely replace theme CSS files to apply a different theme
  3. We don't want to see a flash of the primary theme when loading the page

Persistence can be achieved with HTML5 Web Storage (although cookies would probably work equally well). Using it is very easy — you can just read and write to the window.localStorage dictionary with the Storage API, and you can access them on any page under the same domain even after browser restarts etc.

As Documenter sites are just static pages and there is no server-side logic, we need to apply the theme after the page has been loaded into user's browser with JS. As an added constraint, we also want to make sure that the default theme gets loaded even if the user has disabled JS.

So, naively, one could just read window.localStorage and then replace the <link> tag of the main CSS file if the user has the non-default theme loaded. However, this will cause the default theme to flash on every page load, as the CSS first needs to be fetched and parsed before it gets applied.[1] The same applied when trying to use the disabled attribute, type=prefetch etc.

The second approach I tried was to load all the CSS files, but leave the primary theme last. That way only the primary theme gets applied, right? No. The themes are not guaranteed to style all the elements in the exact same way, so it is very likely that some of the alternate themes will bleed through.

So finally, the solution that actually worked, was to wrap all the CSS for the non-default themes in another CSS class. That is, the themes only get applied if the <html> element has e.g. the theme--darkly class applied. This way the CSS of the alternative themes can be loaded, but it does not bleed through.

Fortunately, the CSS wrapping can be achieved pretty easily with SCSS and Bulma, even when reusing standard SCSS files. Simply wrapping all the @imports in a class seems to work. To illustrate what I mean, the main SCSS file for the Darkly theme looks the following:

@charset "UTF-8";

// Utilities and variables -- do not need to be wrapped in the theme class.
@import "darkly/variables";

$sidebar-background: $grey-darker;
$shadow: $grey-darker;
$sidebar-color: $text;
$lightness-unit: -8%;

$docstring-pre-background: adjust-color($background, $lightness: 5);

@import "documenter/utilities";
@import "documenter/variables";

@import "bulma/utilities/all";
@import "bulma/base/minireset.sass";
@import "bulma/base/helpers.sass";

// All secondary themes have to be nested in a theme--$(themename) class. When Documenter
// switches themes, it applies this class to <html> and then disables the primary
// stylesheet.
html.theme--darkly {
  @import "bulma/base/generic.sass";

  @import "documenter/overrides";

  @import "bulma/elements/all";
  @import "bulma/form/all";
  @import "bulma/components/all";
  @import "bulma/grid/all";
  @import "bulma/layout/all";
  @import "bulma-dashboard";

  @import "darkly/overrides";

  @import "documenter/components";
  @import "documenter/patches";
  @import "documenter/layout/all";

  @import "documenter/theme_overrides";

  .docstring > section {
    pre {
      background-color: $docstring-pre-background;
    }
  }
}

Note the html.theme--darkly {} around some of the @imports, which normally would not be there.

The only part that needs care the CSS that gets applied to the <html> element directly. Fortunately, Bulma does that only in limited number places.

Documenter also provides a special documenter/_theme_overrides.scss import which every theme should load inside their html.theme--* selector to make sure that the <html> elements gets styled appropriately:

& {
  background-color: $body-background-color;
  font-size: $body-size;

  // From: documenter/layout/_all
  overflow-x: hidden;
  overflow-y: hidden;
}

One last thing to mention is how the theme swapping is actually implemented in JS. Essentially, you just have to read the theme from Web Storage, find and disable the style sheets of other themes by looping over document.Stylesheets and apply the correct class to the <html> element.

To distinguish the theme style sheets from other ones (e.g. user-provided, highlight.js styles), Documenter sets a few data-theme-* attributes on the <link> tags. All in all, the implementation in JS is relatively straightforward:

function set_theme_from_local_storage() {
  // Browser does not support Web Storage, bail early.
  if(typeof(window.localStorage) === "undefined") return;
  // Get the user-picked theme from localStorage. May be `null`, which means the default
  // theme.
  var theme =  window.localStorage.getItem("documenter-theme");
  // Initialize a few variables for the loop:
  //
  //  - active: will contain the index of the theme that should be active. Note that there
  //    is no guarantee that localStorage contains sane values. If `active` stays `null`
  //    we either could not find the theme or it is the default (primary) theme anyway.
  //    Either way, we then need to stick to the primary theme.
  //
  //  - disabled: style sheets that should be disabled (i.e. all the theme style sheets
  //    that are not the currently active theme)
  var active = null; var disabled = [];
  for (var i = 0; i < document.styleSheets.length; i++) {
    var ss = document.styleSheets[i];
    // The <link> tag of each style sheet is expected to have a data-theme-name attribute
    // which must contain the name of the theme. The names in localStorage much match this.
    var themename = ss.ownerNode.getAttribute("data-theme-name");
    // attribute not set => non-theme stylesheet => ignore
    if(themename === null) continue;
    // To distinguish the default (primary) theme, it needs to have the data-theme-primary
    // attribute set.
    var isprimary = (ss.ownerNode.getAttribute("data-theme-primary") !== null);
    // If we find a matching theme (and it's not the default), we'll set active to non-null
    if(!isprimary && themename === theme) active = i;
    // Store the style sheets of inactive themes so that we could disable them
    if(themename !== theme) disabled.push(ss);
  }
  if(active !== null) {
    // If we did find an active theme, we'll (1) add the theme--$(theme) class to <html>
    document.getElementsByTagName('html')[0].className = "theme--" + theme;
    // and (2) disable all the other theme stylesheets
    disabled.forEach(function(ss){
      ss.disabled = true;
    });
  }
}

The only thing to keep in mind is that the JS code that swaps the theme on page load needs to execute early in the page load process. Hence, the standard RequireJS loading of the JS can not be used and Documenter instead calls it right in the <head> element.

Next week

With the GSoC end in sight, I need to finish up the new front end. So the goal for the next week is to make some final tweaks and merge the first iteration.

  • 1This will also happen if you do not include any the <link> elements in the generated HTML. In that case, you will see a flash of the unthemed page.