Przeglądaj źródła

feat: migrate to ES6 modules + Hugo's JS build system

The latest Hugo version, v0.78, enables support for JS ES6 module build support within Hugo/Go modules.

Initial refactor to ES6 modules with imports/exports. Further refactoring todo.

Refactor to use Hugo's new approach to JS param injection.

Refactor theming code from JQuery to vanilla JS.

Add fix for browser deprecation of the MediaQueryList listener.

Requires Hugo 0.78+

See #1929
See #1930
See #1402
George Cushen 4 lat temu
rodzic
commit
786b0341ee

+ 0 - 47
wowchemy/assets/js/load-theme.js

@@ -1,47 +0,0 @@
-(function () {
-  function getThemeMode() {
-    return parseInt(localStorage.getItem('dark_mode') || 2);
-  }
-
-  function canChangeTheme() {
-    // If var is set, then user is allowed to change the theme variation.
-    return Boolean(window.wcDarkLightEnabled);
-  }
-
-  function initThemeVariation() {
-    if (!canChangeTheme()) {
-      return;
-    }
-
-    let currentThemeMode = getThemeMode();
-    let isDarkTheme;
-    switch (currentThemeMode) {
-      case 0:
-        isDarkTheme = false;
-        break;
-      case 1:
-        isDarkTheme = true;
-        break;
-      default:
-        if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
-          // The visitor prefers dark themes and switching to the dark variation is allowed by admin.
-          isDarkTheme = true;
-        } else if (window.matchMedia('(prefers-color-scheme: light)').matches) {
-          // The visitor prefers light themes and switching to the dark variation is allowed by admin.
-          isDarkTheme = false;
-        } else {
-          // Use the site's default theme variation based on `light` in the theme file.
-          isDarkTheme = isSiteThemeDark;
-        }
-        break;
-    }
-    if (isDarkTheme) {
-      document.body.classList.add("dark");
-    } else {
-      document.body.classList.remove("dark");
-    }
-  }
-
-  // Initialize theme variation.
-  initThemeVariation();
-})();

+ 24 - 0
wowchemy/assets/js/wowchemy-animation.js

@@ -0,0 +1,24 @@
+/*************************************************
+ *  Wowchemy
+ *  https://github.com/wowchemy/wowchemy-hugo-modules
+ *
+ *  Wowchemy Animation
+ **************************************************/
+
+function fadeIn(element, duration = 600) {
+  element.style.display = '';
+  element.style.opacity = '0';
+  let last = +new Date();
+  let tick = function() {
+    element.style.opacity = (+element.style.opacity + (new Date() - last) / duration).toString();
+    last = +new Date();
+    if (+element.style.opacity < 1) {
+      (window.requestAnimationFrame && requestAnimationFrame(tick)) || setTimeout(tick, 16);
+    }
+  };
+  tick();
+}
+
+export {
+  fadeIn,
+};

+ 18 - 0
wowchemy/assets/js/wowchemy-init.js

@@ -0,0 +1,18 @@
+/*************************************************
+ *  Wowchemy
+ *  https://github.com/wowchemy/wowchemy-hugo-modules
+ *
+ *  Wowchemy Initialization
+ **************************************************/
+
+import {initThemeVariation} from './wowchemy-theming';
+
+import {wcDarkLightEnabled, wcIsSiteThemeDark} from '@params';
+
+window.wc = {
+  darkLightEnabled: wcDarkLightEnabled,
+  isSiteThemeDark: wcIsSiteThemeDark,
+}
+
+// Initialize theme variation.
+initThemeVariation();

+ 191 - 0
wowchemy/assets/js/wowchemy-theming.js

@@ -0,0 +1,191 @@
+/*************************************************
+ *  Wowchemy
+ *  https://github.com/wowchemy/wowchemy-hugo-modules
+ *
+ *  Wowchemy Theming System
+ *  Supported Modes: {0: Day, 1: Night, 2: Auto}
+ **************************************************/
+
+import {fadeIn} from './wowchemy-animation';
+
+function getThemeMode() {
+  return parseInt(localStorage.getItem('dark_mode') || 2);
+}
+
+function canChangeTheme() {
+  // If var is set, then user is allowed to change the theme variation.
+  return Boolean(window.wc.darkLightEnabled);
+}
+
+function initThemeVariation() {
+  if (!canChangeTheme()) {
+    return;
+  }
+
+  let currentThemeMode = getThemeMode();
+  let isDarkTheme;
+  switch (currentThemeMode) {
+    case 0:
+      isDarkTheme = false;
+      break;
+    case 1:
+      isDarkTheme = true;
+      break;
+    default:
+      if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
+        // The visitor prefers dark themes and switching to the dark variation is allowed by admin.
+        isDarkTheme = true;
+      } else if (window.matchMedia('(prefers-color-scheme: light)').matches) {
+        // The visitor prefers light themes and switching to the dark variation is allowed by admin.
+        isDarkTheme = false;
+      } else {
+        // Use the site's default theme variation based on `light` in the theme file.
+        isDarkTheme = window.wc.isSiteThemeDark;
+      }
+      break;
+  }
+  if (isDarkTheme) {
+    document.body.classList.add("dark");
+  } else {
+    document.body.classList.remove("dark");
+  }
+}
+
+function changeThemeModeClick(newMode) {
+  if (!canChangeTheme()) {
+    console.info('Cannot set theme - admin disabled theme selector.');
+    return;
+  }
+  let isDarkTheme;
+  switch (newMode) {
+    case 0:
+      localStorage.setItem('dark_mode', '1');
+      isDarkTheme = true;
+      console.info('User changed theme variation to Dark.');
+      showActiveTheme(0);
+      break;
+    case 1:
+      localStorage.setItem('dark_mode', '2');
+      if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
+        // The visitor prefers dark themes and switching to the dark variation is allowed by admin.
+        isDarkTheme = true;
+      } else if (window.matchMedia('(prefers-color-scheme: light)').matches) {
+        // The visitor prefers light themes and switching to the dark variation is allowed by admin.
+        isDarkTheme = false;
+      } else {
+        isDarkTheme = window.wc.isSiteThemeDark;  // Use the site's default theme variation based on `light` in the theme file.
+      }
+      console.info('User changed theme variation to Auto.');
+      showActiveTheme(1);
+      break;
+    default:
+      localStorage.setItem('dark_mode', '0');
+      isDarkTheme = false;
+      console.info('User changed theme variation to Light.');
+      showActiveTheme(2);
+      break;
+  }
+  renderThemeVariation(isDarkTheme);
+}
+
+function showActiveTheme(mode) {
+  let linkLight = document.querySelector('.js-set-theme-light');
+  let linkDark = document.querySelector('.js-set-theme-dark');
+  let linkAuto = document.querySelector('.js-set-theme-auto');
+  switch (mode) {
+    case 0:
+      // Dark.
+      linkLight.classList.remove('dropdown-item-active');
+      linkDark.classList.add('dropdown-item-active');
+      linkAuto.classList.remove('dropdown-item-active');
+      break;
+    case 1:
+      // Auto.
+      linkLight.classList.remove('dropdown-item-active');
+      linkDark.classList.remove('dropdown-item-active');
+      linkAuto.classList.add('dropdown-item-active');
+      break;
+    default:
+      // Light.
+      linkLight.classList.add('dropdown-item-active');
+      linkDark.classList.remove('dropdown-item-active');
+      linkAuto.classList.remove('dropdown-item-active');
+      break;
+  }
+}
+
+/**
+ * Render theme variation (day or night).
+ *
+ * @param {boolean} isDarkTheme
+ * @param {boolean} init
+ * @returns {undefined}
+ */
+function renderThemeVariation(isDarkTheme, init = false) {
+  // Is code highlighting enabled in site config?
+  const codeHlLight = document.querySelector('link[title=hl-light]');
+  const codeHlDark = document.querySelector('link[title=hl-dark]');
+  const codeHlEnabled = codeHlLight || codeHlDark;
+  const diagramEnabled = document.querySelector('script[title=mermaid]');
+  const body = document.body;
+
+  // Check if re-render required.
+  if (!init) {
+    // If request to render light when light variation already rendered, return.
+    // If request to render dark when dark variation already rendered, return.
+    if ((isDarkTheme === false && !body.classList.contains('dark')) || (isDarkTheme === true && body.classList.contains('dark'))) {
+      return;
+    }
+  }
+
+  if (isDarkTheme === false) {
+    if (!init) {
+      // Only fade in the page when changing the theme variation.
+      //$('body').css({opacity: 0, visibility: 'visible'}).animate({opacity: 1}, 500);
+      Object.assign(document.body.style, {opacity: 0, visibility: 'visible'});
+      fadeIn(document.body, 600);
+    }
+    body.classList.remove('dark');
+    if (codeHlEnabled) {
+      codeHlLight.disabled = false;
+      codeHlDark.disabled = true;
+    }
+    if (diagramEnabled) {
+      if (init) {
+        /** @namespace window.mermaid **/
+        window.mermaid.initialize({theme: 'default', securityLevel: 'loose'});
+      } else {
+        // Have to reload to re-initialise Mermaid with the new theme and re-parse the Mermaid code blocks.
+        location.reload();
+      }
+    }
+  } else if (isDarkTheme === true) {
+    if (!init) {
+      // Only fade in the page when changing the theme variation.
+      Object.assign(document.body.style, {opacity: 0, visibility: 'visible'});
+      fadeIn(document.body, 600);
+    }
+    body.classList.add("dark");
+    if (codeHlEnabled) {
+      codeHlLight.disabled = true;
+      codeHlDark.disabled = false;
+    }
+    if (diagramEnabled) {
+      if (init) {
+        /** @namespace window.mermaid **/
+        window.mermaid.initialize({theme: 'dark', securityLevel: 'loose'});
+      } else {
+        // Have to reload to re-initialise Mermaid with the new theme and re-parse the Mermaid code blocks.
+        location.reload();
+      }
+    }
+  }
+}
+
+
+export {
+  canChangeTheme,
+  initThemeVariation,
+  changeThemeModeClick,
+  renderThemeVariation,
+};

+ 513 - 704
wowchemy/assets/js/wowchemy.js

@@ -5,791 +5,600 @@
  *  Core JS functions and initialization.
  **************************************************/
 
-(function ($) {
-
-  /* ---------------------------------------------------------------------------
-   * Responsive scrolling for URL hashes.
-   * --------------------------------------------------------------------------- */
-
-  // Dynamically get responsive navigation bar height for offsetting Scrollspy.
-  function getNavBarHeight() {
-    let $navbar = $('#navbar-main');
-    let navbar_offset = $navbar.outerHeight();
-    console.debug('Navbar height: ' + navbar_offset);
-    return navbar_offset;
-  }
+import {canChangeTheme, changeThemeModeClick, initThemeVariation, renderThemeVariation} from './wowchemy-theming';
 
-  /**
-   * Responsive hash scrolling.
-   * Check for a URL hash as an anchor.
-   * If it exists on current page, scroll to it responsively.
-   * If `target` argument omitted (e.g. after event), assume it's the window's hash.
-   */
-  function scrollToAnchor(target) {
-    // If `target` is undefined or HashChangeEvent object, set it to window's hash.
-    // Decode the hash as browsers can encode non-ASCII characters (e.g. Chinese symbols).
-    target = (typeof target === 'undefined' || typeof target === 'object') ? decodeURIComponent(window.location.hash) : target;
-
-    // If target element exists, scroll to it taking into account fixed navigation bar offset.
-    if ($(target).length) {
-      // Escape special chars from IDs, such as colons found in Markdown footnote links.
-      target = '#' + $.escapeSelector(target.substring(1));  // Previously, `target = target.replace(/:/g, '\\:');`
-
-      let elementOffset = Math.ceil($(target).offset().top - getNavBarHeight());  // Round up to highlight right ID!
-      $('body').addClass('scrolling');
-      $('html, body').animate({
-        scrollTop: elementOffset
-      }, 600, function () {
-        $('body').removeClass('scrolling');
-      });
-    } else {
-      console.debug('Cannot scroll to target `#' + target + '`. ID not found!');
-    }
-  }
 
-  // Make Scrollspy responsive.
-  function fixScrollspy() {
-    let $body = $('body');
-    let data = $body.data('bs.scrollspy');
-    if (data) {
-      data._config.offset = getNavBarHeight();
-      $body.data('bs.scrollspy', data);
-      $body.scrollspy('refresh');
-    }
-  }
+/* ---------------------------------------------------------------------------
+ * Responsive scrolling for URL hashes.
+ * --------------------------------------------------------------------------- */
 
-  function removeQueryParamsFromUrl() {
-    if (window.history.replaceState) {
-      let urlWithoutSearchParams = window.location.protocol + "//" + window.location.host + window.location.pathname + window.location.hash;
-      window.history.replaceState({path: urlWithoutSearchParams}, '', urlWithoutSearchParams);
-    }
+// Dynamically get responsive navigation bar height for offsetting Scrollspy.
+function getNavBarHeight() {
+  let $navbar = $('#navbar-main');
+  let navbar_offset = $navbar.outerHeight();
+  console.debug('Navbar height: ' + navbar_offset);
+  return navbar_offset;
+}
+
+/**
+ * Responsive hash scrolling.
+ * Check for a URL hash as an anchor.
+ * If it exists on current page, scroll to it responsively.
+ * If `target` argument omitted (e.g. after event), assume it's the window's hash.
+ */
+function scrollToAnchor(target) {
+  // If `target` is undefined or HashChangeEvent object, set it to window's hash.
+  // Decode the hash as browsers can encode non-ASCII characters (e.g. Chinese symbols).
+  target = (typeof target === 'undefined' || typeof target === 'object') ? decodeURIComponent(window.location.hash) : target;
+
+  // If target element exists, scroll to it taking into account fixed navigation bar offset.
+  if ($(target).length) {
+    // Escape special chars from IDs, such as colons found in Markdown footnote links.
+    target = '#' + $.escapeSelector(target.substring(1));  // Previously, `target = target.replace(/:/g, '\\:');`
+
+    let elementOffset = Math.ceil($(target).offset().top - getNavBarHeight());  // Round up to highlight right ID!
+    $('body').addClass('scrolling');
+    $('html, body').animate({
+      scrollTop: elementOffset
+    }, 600, function () {
+      $('body').removeClass('scrolling');
+    });
+  } else {
+    console.debug('Cannot scroll to target `#' + target + '`. ID not found!');
   }
+}
+
+// Make Scrollspy responsive.
+function fixScrollspy() {
+  let $body = $('body');
+  let data = $body.data('bs.scrollspy');
+  if (data) {
+    data._config.offset = getNavBarHeight();
+    $body.data('bs.scrollspy', data);
+    $body.scrollspy('refresh');
+  }
+}
 
-  // Check for hash change event and fix responsive offset for hash links (e.g. Markdown footnotes).
-  window.addEventListener("hashchange", scrollToAnchor);
-
-  /* ---------------------------------------------------------------------------
-   * Add smooth scrolling to all links inside the main navbar.
-   * --------------------------------------------------------------------------- */
-
-  $('#navbar-main li.nav-item a.nav-link, .js-scroll').on('click', function (event) {
-    // Store requested URL hash.
-    let hash = this.hash;
-
-    // If we are on a widget page and the navbar link is to a section on the same page.
-    if (this.pathname === window.location.pathname && hash && $(hash).length && ($(".js-widget-page").length > 0)) {
-      // Prevent default click behavior.
-      event.preventDefault();
-
-      // Use jQuery's animate() method for smooth page scrolling.
-      // The numerical parameter specifies the time (ms) taken to scroll to the specified hash.
-      let elementOffset = Math.ceil($(hash).offset().top - getNavBarHeight());  // Round up to highlight right ID!
+function removeQueryParamsFromUrl() {
+  if (window.history.replaceState) {
+    let urlWithoutSearchParams = window.location.protocol + "//" + window.location.host + window.location.pathname + window.location.hash;
+    window.history.replaceState({path: urlWithoutSearchParams}, '', urlWithoutSearchParams);
+  }
+}
 
-      // Uncomment to debug.
-      // let scrollTop = $(window).scrollTop();
-      // let scrollDelta = (elementOffset - scrollTop);
-      // console.debug('Scroll Delta: ' + scrollDelta);
+// Check for hash change event and fix responsive offset for hash links (e.g. Markdown footnotes).
+window.addEventListener("hashchange", scrollToAnchor);
 
-      $('html, body').animate({
-        scrollTop: elementOffset
-      }, 800);
-    }
-  });
+/* ---------------------------------------------------------------------------
+ * Add smooth scrolling to all links inside the main navbar.
+ * --------------------------------------------------------------------------- */
 
-  /* ---------------------------------------------------------------------------
-   * Hide mobile collapsable menu on clicking a link.
-   * --------------------------------------------------------------------------- */
+$('#navbar-main li.nav-item a.nav-link, .js-scroll').on('click', function (event) {
+  // Store requested URL hash.
+  let hash = this.hash;
 
-  $(document).on('click', '.navbar-collapse.show', function (e) {
-    //get the <a> element that was clicked, even if the <span> element that is inside the <a> element is e.target
-    let targetElement = $(e.target).is('a') ? $(e.target) : $(e.target).parent();
+  // If we are on a widget page and the navbar link is to a section on the same page.
+  if (this.pathname === window.location.pathname && hash && $(hash).length && ($(".js-widget-page").length > 0)) {
+    // Prevent default click behavior.
+    event.preventDefault();
 
-    if (targetElement.is('a') && targetElement.attr('class') != 'dropdown-toggle') {
-      $(this).collapse('hide');
-    }
-  });
+    // Use jQuery's animate() method for smooth page scrolling.
+    // The numerical parameter specifies the time (ms) taken to scroll to the specified hash.
+    let elementOffset = Math.ceil($(hash).offset().top - getNavBarHeight());  // Round up to highlight right ID!
 
-  /* ---------------------------------------------------------------------------
-   * Filter publications.
-   * --------------------------------------------------------------------------- */
-
-  // Active publication filters.
-  let pubFilters = {};
-
-  // Search term.
-  let searchRegex;
-
-  // Filter values (concatenated).
-  let filterValues;
-
-  // Publication container.
-  let $grid_pubs = $('#container-publications');
-
-  // Initialise Isotope publication layout if required.
-  if ($grid_pubs.length)
-  {
-    $grid_pubs.isotope({
-      itemSelector: '.isotope-item',
-      percentPosition: true,
-      masonry: {
-        // Use Bootstrap compatible grid layout.
-        columnWidth: '.grid-sizer'
-      },
-      filter: function () {
-        let $this = $(this);
-        let searchResults = searchRegex ? $this.text().match(searchRegex) : true;
-        let filterResults = filterValues ? $this.is(filterValues) : true;
-        return searchResults && filterResults;
-      }
-    });
+    // Uncomment to debug.
+    // let scrollTop = $(window).scrollTop();
+    // let scrollDelta = (elementOffset - scrollTop);
+    // console.debug('Scroll Delta: ' + scrollDelta);
 
-    // Filter by search term.
-    let $quickSearch = $('.filter-search').keyup(debounce(function () {
-      searchRegex = new RegExp($quickSearch.val(), 'gi');
-      $grid_pubs.isotope();
-    }));
+    $('html, body').animate({
+      scrollTop: elementOffset
+    }, 800);
+  }
+});
 
-    $('.pub-filters').on('change', function () {
-      let $this = $(this);
+/* ---------------------------------------------------------------------------
+ * Hide mobile collapsable menu on clicking a link.
+ * --------------------------------------------------------------------------- */
 
-      // Get group key.
-      let filterGroup = $this[0].getAttribute('data-filter-group');
+$(document).on('click', '.navbar-collapse.show', function (e) {
+  //get the <a> element that was clicked, even if the <span> element that is inside the <a> element is e.target
+  let targetElement = $(e.target).is('a') ? $(e.target) : $(e.target).parent();
 
-      // Set filter for group.
-      pubFilters[filterGroup] = this.value;
+  if (targetElement.is('a') && targetElement.attr('class') != 'dropdown-toggle') {
+    $(this).collapse('hide');
+  }
+});
 
-      // Combine filters.
-      filterValues = concatValues(pubFilters);
+/* ---------------------------------------------------------------------------
+ * Filter publications.
+ * --------------------------------------------------------------------------- */
 
-      // Activate filters.
-      $grid_pubs.isotope();
+// Active publication filters.
+let pubFilters = {};
 
-      // If filtering by publication type, update the URL hash to enable direct linking to results.
-      if (filterGroup === "pubtype") {
-        // Set hash URL to current filter.
-        let url = $(this).val();
-        if (url.substr(0, 9) === '.pubtype-') {
-          window.location.hash = url.substr(9);
-        } else {
-          window.location.hash = '';
-        }
-      }
-    });
-  }
+// Search term.
+let searchRegex;
 
-  // Debounce input to prevent spamming filter requests.
-  function debounce(fn, threshold) {
-    let timeout;
-    threshold = threshold || 100;
-    return function debounced() {
-      clearTimeout(timeout);
-      let args = arguments;
-      let _this = this;
-
-      function delayed() {
-        fn.apply(_this, args);
-      }
+// Filter values (concatenated).
+let filterValues;
 
-      timeout = setTimeout(delayed, threshold);
-    };
-  }
+// Publication container.
+let $grid_pubs = $('#container-publications');
 
-  // Flatten object by concatenating values.
-  function concatValues(obj) {
-    let value = '';
-    for (let prop in obj) {
-      value += obj[prop];
+// Initialise Isotope publication layout if required.
+if ($grid_pubs.length) {
+  $grid_pubs.isotope({
+    itemSelector: '.isotope-item',
+    percentPosition: true,
+    masonry: {
+      // Use Bootstrap compatible grid layout.
+      columnWidth: '.grid-sizer'
+    },
+    filter: function () {
+      let $this = $(this);
+      let searchResults = searchRegex ? $this.text().match(searchRegex) : true;
+      let filterResults = filterValues ? $this.is(filterValues) : true;
+      return searchResults && filterResults;
     }
-    return value;
-  }
+  });
 
-  // Filter publications according to hash in URL.
-  function filter_publications() {
-    // Check for Isotope publication layout.
-    if (!$grid_pubs.length)
-      return
+  // Filter by search term.
+  let $quickSearch = $('.filter-search').keyup(debounce(function () {
+    searchRegex = new RegExp($quickSearch.val(), 'gi');
+    $grid_pubs.isotope();
+  }));
 
-    let urlHash = window.location.hash.replace('#', '');
-    let filterValue = '*';
+  $('.pub-filters').on('change', function () {
+    let $this = $(this);
 
-    // Check if hash is numeric.
-    if (urlHash != '' && !isNaN(urlHash)) {
-      filterValue = '.pubtype-' + urlHash;
-    }
+    // Get group key.
+    let filterGroup = $this[0].getAttribute('data-filter-group');
 
-    // Set filter.
-    let filterGroup = 'pubtype';
-    pubFilters[filterGroup] = filterValue;
+    // Set filter for group.
+    pubFilters[filterGroup] = this.value;
+
+    // Combine filters.
     filterValues = concatValues(pubFilters);
 
     // Activate filters.
     $grid_pubs.isotope();
 
-    // Set selected option.
-    $('.pubtype-select').val(filterValue);
-  }
-
-  /* ---------------------------------------------------------------------------
-  * Google Maps or OpenStreetMap via Leaflet.
-  * --------------------------------------------------------------------------- */
-
-  function initMap() {
-    if ($('#map').length) {
-      let map_provider = $('#map-provider').val();
-      let lat = $('#map-lat').val();
-      let lng = $('#map-lng').val();
-      let zoom = parseInt($('#map-zoom').val());
-      let address = $('#map-dir').val();
-      let api_key = $('#map-api-key').val();
-
-      if (map_provider == 1) {
-        let map = new GMaps({
-          div: '#map',
-          lat: lat,
-          lng: lng,
-          zoom: zoom,
-          zoomControl: true,
-          zoomControlOpt: {
-            style: 'SMALL',
-            position: 'TOP_LEFT'
-          },
-          panControl: false,
-          streetViewControl: false,
-          mapTypeControl: false,
-          overviewMapControl: false,
-          scrollwheel: true,
-          draggable: true
-        });
-
-        map.addMarker({
-          lat: lat,
-          lng: lng,
-          click: function (e) {
-            let url = 'https://www.google.com/maps/place/' + encodeURIComponent(address) + '/@' + lat + ',' + lng + '/';
-            window.open(url, '_blank')
-          },
-          title: address
-        })
+    // If filtering by publication type, update the URL hash to enable direct linking to results.
+    if (filterGroup === "pubtype") {
+      // Set hash URL to current filter.
+      let url = $(this).val();
+      if (url.substr(0, 9) === '.pubtype-') {
+        window.location.hash = url.substr(9);
       } else {
-        let map = new L.map('map').setView([lat, lng], zoom);
-        if (map_provider == 3 && api_key.length) {
-          L.tileLayer('https://api.mapbox.com/styles/v1/{id}/tiles/{z}/{x}/{y}?access_token={accessToken}', {
-            attribution: 'Map data &copy; <a href="http://openstreetmap.org">OpenStreetMap</a> contributors, <a href="http://creativecommons.org/licenses/by-sa/2.0/">CC-BY-SA</a>, Imagery © <a href="http://mapbox.com">Mapbox</a>',
-            tileSize: 512,
-            maxZoom: 18,
-            zoomOffset: -1,
-            id: 'mapbox/streets-v11',
-            accessToken: api_key
-          }).addTo(map);
-        } else {
-          L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
-            maxZoom: 19,
-            attribution: '&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>'
-          }).addTo(map);
-        }
-        let marker = L.marker([lat, lng]).addTo(map);
-        let url = lat + ',' + lng + '#map=' + zoom + '/' + lat + '/' + lng + '&layers=N';
-        marker.bindPopup(address + '<p><a href="https://www.openstreetmap.org/directions?engine=osrm_car&route=' + url + '">Routing via OpenStreetMap</a></p>');
+        window.location.hash = '';
       }
     }
-  }
+  });
+}
+
+// Debounce input to prevent spamming filter requests.
+function debounce(fn, threshold) {
+  let timeout;
+  threshold = threshold || 100;
+  return function debounced() {
+    clearTimeout(timeout);
+    let args = arguments;
+    let _this = this;
+
+    function delayed() {
+      fn.apply(_this, args);
+    }
 
-  /* ---------------------------------------------------------------------------
-   * GitHub API.
-   * --------------------------------------------------------------------------- */
-
-  function printLatestRelease(selector, repo) {
-    $.getJSON('https://api.github.com/repos/' + repo + '/tags').done(function (json) {
-      let release = json[0];
-      $(selector).append(' ' + release.name);
-    }).fail(function (jqxhr, textStatus, error) {
-      let err = textStatus + ", " + error;
-      console.log("Request Failed: " + err);
-    });
+    timeout = setTimeout(delayed, threshold);
+  };
+}
+
+// Flatten object by concatenating values.
+function concatValues(obj) {
+  let value = '';
+  for (let prop in obj) {
+    value += obj[prop];
   }
+  return value;
+}
 
-  /* ---------------------------------------------------------------------------
-  * Toggle search dialog.
-  * --------------------------------------------------------------------------- */
+// Filter publications according to hash in URL.
+function filter_publications() {
+  // Check for Isotope publication layout.
+  if (!$grid_pubs.length)
+    return
 
-  function toggleSearchDialog() {
-    if ($('body').hasClass('searching')) {
-      // Clear search query and hide search modal.
-      $('[id=search-query]').blur();
-      $('body').removeClass('searching compensate-for-scrollbar');
+  let urlHash = window.location.hash.replace('#', '');
+  let filterValue = '*';
 
-      // Remove search query params from URL as user has finished searching.
-      removeQueryParamsFromUrl();
+  // Check if hash is numeric.
+  if (urlHash != '' && !isNaN(urlHash)) {
+    filterValue = '.pubtype-' + urlHash;
+  }
+
+  // Set filter.
+  let filterGroup = 'pubtype';
+  pubFilters[filterGroup] = filterValue;
+  filterValues = concatValues(pubFilters);
+
+  // Activate filters.
+  $grid_pubs.isotope();
+
+  // Set selected option.
+  $('.pubtype-select').val(filterValue);
+}
+
+/* ---------------------------------------------------------------------------
+* Google Maps or OpenStreetMap via Leaflet.
+* --------------------------------------------------------------------------- */
+
+function initMap() {
+  if ($('#map').length) {
+    let map_provider = $('#map-provider').val();
+    let lat = $('#map-lat').val();
+    let lng = $('#map-lng').val();
+    let zoom = parseInt($('#map-zoom').val());
+    let address = $('#map-dir').val();
+    let api_key = $('#map-api-key').val();
+
+    if (map_provider == 1) {
+      let map = new GMaps({
+        div: '#map',
+        lat: lat,
+        lng: lng,
+        zoom: zoom,
+        zoomControl: true,
+        zoomControlOpt: {
+          style: 'SMALL',
+          position: 'TOP_LEFT'
+        },
+        panControl: false,
+        streetViewControl: false,
+        mapTypeControl: false,
+        overviewMapControl: false,
+        scrollwheel: true,
+        draggable: true
+      });
 
-      // Prevent fixed positioned elements (e.g. navbar) moving due to scrollbars.
-      $('#fancybox-style-noscroll').remove();
+      map.addMarker({
+        lat: lat,
+        lng: lng,
+        click: function (e) {
+          let url = 'https://www.google.com/maps/place/' + encodeURIComponent(address) + '/@' + lat + ',' + lng + '/';
+          window.open(url, '_blank')
+        },
+        title: address
+      })
     } else {
-      // Prevent fixed positioned elements (e.g. navbar) moving due to scrollbars.
-      if (!$('#fancybox-style-noscroll').length && document.body.scrollHeight > window.innerHeight) {
-        $('head').append(
-          '<style id="fancybox-style-noscroll">.compensate-for-scrollbar{margin-right:' +
-          (window.innerWidth - document.documentElement.clientWidth) +
-          'px;}</style>'
-        );
-        $('body').addClass('compensate-for-scrollbar');
+      let map = new L.map('map').setView([lat, lng], zoom);
+      if (map_provider == 3 && api_key.length) {
+        L.tileLayer('https://api.mapbox.com/styles/v1/{id}/tiles/{z}/{x}/{y}?access_token={accessToken}', {
+          attribution: 'Map data &copy; <a href="http://openstreetmap.org">OpenStreetMap</a> contributors, <a href="http://creativecommons.org/licenses/by-sa/2.0/">CC-BY-SA</a>, Imagery © <a href="http://mapbox.com">Mapbox</a>',
+          tileSize: 512,
+          maxZoom: 18,
+          zoomOffset: -1,
+          id: 'mapbox/streets-v11',
+          accessToken: api_key
+        }).addTo(map);
+      } else {
+        L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
+          maxZoom: 19,
+          attribution: '&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>'
+        }).addTo(map);
       }
-
-      // Show search modal.
-      $('body').addClass('searching');
-      $('.search-results').css({opacity: 0, visibility: 'visible'}).animate({opacity: 1}, 200);
-      $('#search-query').focus();
+      let marker = L.marker([lat, lng]).addTo(map);
+      let url = lat + ',' + lng + '#map=' + zoom + '/' + lat + '/' + lng + '&layers=N';
+      marker.bindPopup(address + '<p><a href="https://www.openstreetmap.org/directions?engine=osrm_car&route=' + url + '">Routing via OpenStreetMap</a></p>');
     }
   }
+}
 
-  /* ---------------------------------------------------------------------------
-  * Change Theme Mode (0: Day, 1: Night, 2: Auto).
-  * --------------------------------------------------------------------------- */
+/* ---------------------------------------------------------------------------
+ * GitHub API.
+ * --------------------------------------------------------------------------- */
 
-  // TODO: import theme functions from load-theme.js to avoid duplication.
-  function canChangeTheme() {
-    // If var is set, then user is allowed to change the theme variation.
-    return Boolean(window.wcDarkLightEnabled);
-  }
+function printLatestRelease(selector, repo) {
+  $.getJSON('https://api.github.com/repos/' + repo + '/tags').done(function (json) {
+    let release = json[0];
+    $(selector).append(' ' + release.name);
+  }).fail(function (jqxhr, textStatus, error) {
+    let err = textStatus + ", " + error;
+    console.log("Request Failed: " + err);
+  });
+}
+
+/* ---------------------------------------------------------------------------
+* Toggle search dialog.
+* --------------------------------------------------------------------------- */
+
+function toggleSearchDialog() {
+  if ($('body').hasClass('searching')) {
+    // Clear search query and hide search modal.
+    $('[id=search-query]').blur();
+    $('body').removeClass('searching compensate-for-scrollbar');
+
+    // Remove search query params from URL as user has finished searching.
+    removeQueryParamsFromUrl();
+
+    // Prevent fixed positioned elements (e.g. navbar) moving due to scrollbars.
+    $('#fancybox-style-noscroll').remove();
+  } else {
+    // Prevent fixed positioned elements (e.g. navbar) moving due to scrollbars.
+    if (!$('#fancybox-style-noscroll').length && document.body.scrollHeight > window.innerHeight) {
+      $('head').append(
+        '<style id="fancybox-style-noscroll">.compensate-for-scrollbar{margin-right:' +
+        (window.innerWidth - document.documentElement.clientWidth) +
+        'px;}</style>'
+      );
+      $('body').addClass('compensate-for-scrollbar');
+    }
 
-  function getThemeMode() {
-    return parseInt(localStorage.getItem('dark_mode') || 2);
+    // Show search modal.
+    $('body').addClass('searching');
+    $('.search-results').css({opacity: 0, visibility: 'visible'}).animate({opacity: 1}, 200);
+    $('#search-query').focus();
   }
-
-  function changeThemeModeClick(newMode) {
-    console.info('Request to set theme.');
-    if (!canChangeTheme()) {
-      console.info('Cannot set theme - admin disabled theme selector.');
-      return;
-    }
-    let isDarkTheme;
-    switch (newMode) {
-      case 0:
-        localStorage.setItem('dark_mode', '1');
-        isDarkTheme = true;
-        console.info('User changed theme variation to Dark.');
-        showActiveTheme(0);
-        break;
-      case 1:
-        localStorage.setItem('dark_mode', '2');
-        if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
-          // The visitor prefers dark themes and switching to the dark variation is allowed by admin.
-          isDarkTheme = true;
-        } else if (window.matchMedia('(prefers-color-scheme: light)').matches) {
-          // The visitor prefers light themes and switching to the dark variation is allowed by admin.
-          isDarkTheme = false;
-        } else {
-          isDarkTheme = isSiteThemeDark;  // Use the site's default theme variation based on `light` in the theme file.
-        }
-        console.info('User changed theme variation to Auto.');
-        showActiveTheme(1);
-        break;
-      default:
-        localStorage.setItem('dark_mode', '0');
-        isDarkTheme = false;
-        console.info('User changed theme variation to Light.');
-        showActiveTheme(2);
-        break;
-    }
-    renderThemeVariation(isDarkTheme);
+}
+
+/* ---------------------------------------------------------------------------
+* Normalize Bootstrap Carousel Slide Heights.
+* --------------------------------------------------------------------------- */
+
+function normalizeCarouselSlideHeights() {
+  $('.carousel').each(function () {
+    // Get carousel slides.
+    let items = $('.carousel-item', this);
+    // Reset all slide heights.
+    items.css('min-height', 0);
+    // Normalize all slide heights.
+    let maxHeight = Math.max.apply(null, items.map(function () {
+      return $(this).outerHeight()
+    }).get());
+    items.css('min-height', maxHeight + 'px');
+  })
+}
+
+/* ---------------------------------------------------------------------------
+* Fix Hugo's Goldmark output and Mermaid code blocks.
+* --------------------------------------------------------------------------- */
+
+/**
+ * Fix Hugo's Goldmark output.
+ */
+function fixHugoOutput() {
+  // Fix Goldmark table of contents.
+  // - Must be performed prior to initializing ScrollSpy.
+  $('#TableOfContents').addClass('nav flex-column');
+  $('#TableOfContents li').addClass('nav-item');
+  $('#TableOfContents li a').addClass('nav-link');
+
+  // Fix Goldmark task lists (remove bullet points).
+  $("input[type='checkbox'][disabled]").parents('ul').addClass('task-list');
+}
+
+/**
+ * Fix Mermaid.js clash with Highlight.js.
+ * Refactor Mermaid code blocks as divs to prevent Highlight parsing them and enable Mermaid to parse them.
+ */
+function fixMermaid() {
+  let mermaids = [];
+  [].push.apply(mermaids, document.getElementsByClassName('language-mermaid'));
+  for (let i = 0; i < mermaids.length; i++) {
+    $(mermaids[i]).unwrap('pre');  // Remove <pre> wrapper.
+    $(mermaids[i]).replaceWith(function () {
+      // Convert <code> block to <div> and add `mermaid` class so that Mermaid will parse it.
+      return $("<div />").append($(this).contents()).addClass('mermaid');
+    });
   }
+}
 
-  function showActiveTheme(mode){
-    switch (mode) {
-      case 0:
-        // Dark.
-        $('.js-set-theme-light').removeClass('dropdown-item-active');
-        $('.js-set-theme-dark').addClass('dropdown-item-active');
-        $('.js-set-theme-auto').removeClass('dropdown-item-active');
-        break;
-      case 1:
-        // Auto.
-        $('.js-set-theme-light').removeClass('dropdown-item-active');
-        $('.js-set-theme-dark').removeClass('dropdown-item-active');
-        $('.js-set-theme-auto').addClass('dropdown-item-active');
-        break;
-      default:
-        // Light.
-        $('.js-set-theme-light').addClass('dropdown-item-active');
-        $('.js-set-theme-dark').removeClass('dropdown-item-active');
-        $('.js-set-theme-auto').removeClass('dropdown-item-active');
-        break;
-    }
+/* ---------------------------------------------------------------------------
+ * On document ready.
+ * --------------------------------------------------------------------------- */
+
+$(document).ready(function () {
+  fixHugoOutput();
+  fixMermaid();
+
+  // Initialise code highlighting if enabled for this page.
+  // Note: this block should be processed after the Mermaid code-->div conversion.
+  if (code_highlighting) {
+    hljs.initHighlighting();
   }
 
-  function getThemeVariation() {
+  // Initialize theme variation.
+  initThemeVariation();
+
+  // Change theme mode.
+  $('.js-set-theme-light').click(function (e) {
+    e.preventDefault();
+    changeThemeModeClick(2);
+  });
+  $('.js-set-theme-dark').click(function (e) {
+    e.preventDefault();
+    changeThemeModeClick(0);
+  });
+  $('.js-set-theme-auto').click(function (e) {
+    e.preventDefault();
+    changeThemeModeClick(1);
+  });
+
+  // Live update of day/night mode on system preferences update (no refresh required).
+  // Note: since we listen only for *dark* events, we won't detect other scheme changes such as light to no-preference.
+  const darkModeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
+  darkModeMediaQuery.addEventListener("change", (e) => {
     if (!canChangeTheme()) {
-      return isSiteThemeDark;  // Use the site's default theme variation based on `light` in the theme file.
+      // Changing theme variation is not allowed by admin.
+      return;
     }
-    let currentThemeMode = getThemeMode();
+    const darkModeOn = e.matches;
+    console.log(`OS dark mode preference changed to ${darkModeOn ? '🌒 on' : '☀️ off'}.`);
+    let currentThemeVariation = parseInt(localStorage.getItem('dark_mode') || 2);
     let isDarkTheme;
-    switch (currentThemeMode) {
-      case 0:
-        isDarkTheme = false;
-        break;
-      case 1:
+    if (currentThemeVariation === 2) {
+      if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
+        // The visitor prefers dark themes.
         isDarkTheme = true;
-        break;
-      default:
-        if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
-          // The visitor prefers dark themes and switching to the dark variation is allowed by admin.
-          isDarkTheme = true;
-        } else if (window.matchMedia('(prefers-color-scheme: light)').matches) {
-          // The visitor prefers light themes and switching to the dark variation is allowed by admin.
-          isDarkTheme = false;
-        } else {
-          isDarkTheme = isSiteThemeDark;  // Use the site's default theme variation based on `light` in the theme file.
-        }
-        break;
-    }
-    return isDarkTheme;
-  }
-
-  /**
-   * Render theme variation (day or night).
-   *
-   * @param {boolean} isDarkTheme
-   * @param {boolean} init
-   * @returns {undefined}
-   */
-  function renderThemeVariation(isDarkTheme, init = false) {
-    // Is code highlighting enabled in site config?
-    const codeHlEnabled = $('link[title=hl-light]').length > 0;
-    const codeHlLight = $('link[title=hl-light]')[0];
-    const codeHlDark = $('link[title=hl-dark]')[0];
-    const diagramEnabled = $('script[title=mermaid]').length > 0;
-
-    // Check if re-render required.
-    if (!init) {
-      // If request to render light when light variation already rendered, return.
-      // If request to render dark when dark variation already rendered, return.
-      if ((isDarkTheme === false && !$('body').hasClass('dark')) || (isDarkTheme === true && $('body').hasClass('dark'))) {
-        return;
-      }
-    }
-
-    if (isDarkTheme === false) {
-      if (!init) {
-        // Only fade in the page when changing the theme variation.
-        $('body').css({opacity: 0, visibility: 'visible'}).animate({opacity: 1}, 500);
-      }
-      $('body').removeClass('dark');
-      if (codeHlEnabled) {
-        codeHlLight.disabled = false;
-        codeHlDark.disabled = true;
-      }
-      if (diagramEnabled) {
-        if (init) {
-          mermaid.initialize({theme: 'default', securityLevel: 'loose'});
-        } else {
-          // Have to reload to re-initialise Mermaid with the new theme and re-parse the Mermaid code blocks.
-          location.reload();
-        }
-      }
-    } else if (isDarkTheme === true) {
-      if (!init) {
-        // Only fade in the page when changing the theme variation.
-        $('body').css({opacity: 0, visibility: 'visible'}).animate({opacity: 1}, 500);
-      }
-      $('body').addClass('dark');
-      if (codeHlEnabled) {
-        codeHlLight.disabled = true;
-        codeHlDark.disabled = false;
-      }
-      if (diagramEnabled) {
-        if (init) {
-          mermaid.initialize({theme: 'dark', securityLevel: 'loose'});
-        } else {
-          // Have to reload to re-initialise Mermaid with the new theme and re-parse the Mermaid code blocks.
-          location.reload();
-        }
-      }
-    }
-  }
-
-  function initThemeVariation() {
-    // If theme changer component present, set its icon according to the theme mode (day, night, or auto).
-    if (canChangeTheme) {
-      let themeMode = getThemeMode();
-      switch (themeMode) {
-        case 0:
-          showActiveTheme(2);
-          console.info('Initialize theme variation to Light.');
-          break;
-        case 1:
-          showActiveTheme(0);
-          console.info('Initialize theme variation to Dark.');
-          break;
-        default:
-          showActiveTheme(1);
-          console.info('Initialize theme variation to Auto.');
-          break;
+      } else if (window.matchMedia('(prefers-color-scheme: light)').matches) {
+        // The visitor prefers light themes.
+        isDarkTheme = false;
+      } else {
+        // The visitor does not have a day or night preference, so use the theme's default setting.
+        isDarkTheme = isSiteThemeDark;
       }
+      renderThemeVariation(isDarkTheme);
     }
-    // Render the day or night theme.
-    let isDarkTheme = getThemeVariation();
-    renderThemeVariation(isDarkTheme, true);
-  }
-
-  /* ---------------------------------------------------------------------------
-  * Normalize Bootstrap Carousel Slide Heights.
-  * --------------------------------------------------------------------------- */
-
-  function normalizeCarouselSlideHeights() {
-    $('.carousel').each(function () {
-      // Get carousel slides.
-      let items = $('.carousel-item', this);
-      // Reset all slide heights.
-      items.css('min-height', 0);
-      // Normalize all slide heights.
-      let maxHeight = Math.max.apply(null, items.map(function () {
-        return $(this).outerHeight()
-      }).get());
-      items.css('min-height', maxHeight + 'px');
-    })
-  }
+  });
+});
 
-  /* ---------------------------------------------------------------------------
- * Fix Hugo's Goldmark output and Mermaid code blocks.
+/* ---------------------------------------------------------------------------
+ * On window loaded.
  * --------------------------------------------------------------------------- */
 
-  /**
-   * Fix Hugo's Goldmark output.
-   */
-  function fixHugoOutput() {
-    // Fix Goldmark table of contents.
-    // - Must be performed prior to initializing ScrollSpy.
-    $('#TableOfContents').addClass('nav flex-column');
-    $('#TableOfContents li').addClass('nav-item');
-    $('#TableOfContents li a').addClass('nav-link');
-
-    // Fix Goldmark task lists (remove bullet points).
-    $("input[type='checkbox'][disabled]").parents('ul').addClass('task-list');
+$(window).on('load', function () {
+  // On page load, scroll to hash (if set) in URL
+  // If URL contains a hash and there are no dynamically loaded images on the page,
+  // immediately scroll to target ID taking into account responsive offset.
+  // Otherwise, wait for `imagesLoaded()` to complete before scrolling to hash to prevent scrolling to wrong
+  // location.
+  if (window.location.hash && !$('.projects-container').length) {
+    scrollToAnchor();
   }
 
-  /**
-   * Fix Mermaid.js clash with Highlight.js.
-   * Refactor Mermaid code blocks as divs to prevent Highlight parsing them and enable Mermaid to parse them.
-   */
-  function fixMermaid() {
-    let mermaids = [];
-    [].push.apply(mermaids, document.getElementsByClassName('language-mermaid'));
-    for (let i = 0; i < mermaids.length; i++) {
-      $(mermaids[i]).unwrap('pre');  // Remove <pre> wrapper.
-      $(mermaids[i]).replaceWith(function () {
-        // Convert <code> block to <div> and add `mermaid` class so that Mermaid will parse it.
-        return $("<div />").append($(this).contents()).addClass('mermaid');
-      });
-    }
-  }
-
-  /* ---------------------------------------------------------------------------
-   * On document ready.
-   * --------------------------------------------------------------------------- */
-
-  $(document).ready(function () {
-    fixHugoOutput();
-    fixMermaid();
-
-    // Initialise code highlighting if enabled for this page.
-    // Note: this block should be processed after the Mermaid code-->div conversion.
-    if (code_highlighting) {
-      hljs.initHighlighting();
+  // Filter projects.
+  $('.projects-container').each(function (index, container) {
+    let $container = $(container);
+    let $section = $container.closest('section');
+    let layout;
+    if ($section.find('.isotope').hasClass('js-layout-row')) {
+      layout = 'fitRows';
+    } else {
+      layout = 'masonry';
     }
 
-    // Initialize theme variation.
-    initThemeVariation();
+    $container.imagesLoaded(function () {
+      // Initialize Isotope after all images have loaded.
+      $container.isotope({
+        itemSelector: '.isotope-item',
+        layoutMode: layout,
+        masonry: {
+          gutter: 20
+        },
+        filter: $section.find('.default-project-filter').text()
+      });
 
-    // Change theme mode.
-    $('.js-set-theme-light').click(function (e) {
-      e.preventDefault();
-      changeThemeModeClick(2);
-    });
-    $('.js-set-theme-dark').click(function (e) {
-      e.preventDefault();
-      changeThemeModeClick(0);
-    });
-    $('.js-set-theme-auto').click(function (e) {
-      e.preventDefault();
-      changeThemeModeClick(1);
-    });
+      // Filter items when filter link is clicked.
+      $section.find('.project-filters a').click(function () {
+        let selector = $(this).attr('data-filter');
+        $container.isotope({filter: selector});
+        $(this).removeClass('active').addClass('active').siblings().removeClass('active all');
+        return false;
+      });
 
-    // Live update of day/night mode on system preferences update (no refresh required).
-    // Note: since we listen only for *dark* events, we won't detect other scheme changes such as light to no-preference.
-    const darkModeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
-    darkModeMediaQuery.addListener((e) => {
-      if (!canChangeTheme()) {
-        // Changing theme variation is not allowed by admin.
-        return;
-      }
-      const darkModeOn = e.matches;
-      console.log(`OS dark mode preference changed to ${darkModeOn ? '🌒 on' : '☀️ off'}.`);
-      let currentThemeVariation = parseInt(localStorage.getItem('dark_mode') || 2);
-      let isDarkTheme;
-      if (currentThemeVariation === 2) {
-        if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
-          // The visitor prefers dark themes.
-          isDarkTheme = true;
-        } else if (window.matchMedia('(prefers-color-scheme: light)').matches) {
-          // The visitor prefers light themes.
-          isDarkTheme = false;
-        } else {
-          // The visitor does not have a day or night preference, so use the theme's default setting.
-          isDarkTheme = isSiteThemeDark;
-        }
-        renderThemeVariation(isDarkTheme);
+      // If window hash is set, scroll to hash.
+      // Placing this within `imagesLoaded` prevents scrolling to the wrong location due to dynamic image loading
+      // affecting page layout and position of the target anchor ID.
+      // Note: If there are multiple project widgets on a page, ideally only perform this once after images
+      // from *all* project widgets have finished loading.
+      if (window.location.hash) {
+        scrollToAnchor();
       }
     });
   });
 
-  /* ---------------------------------------------------------------------------
-   * On window loaded.
-   * --------------------------------------------------------------------------- */
-
-  $(window).on('load', function () {
-    // On page load, scroll to hash (if set) in URL
-    // If URL contains a hash and there are no dynamically loaded images on the page,
-    // immediately scroll to target ID taking into account responsive offset.
-    // Otherwise, wait for `imagesLoaded()` to complete before scrolling to hash to prevent scrolling to wrong
-    // location.
-    if (window.location.hash && !$('.projects-container').length) {
-      scrollToAnchor();
-    }
+  // Enable publication filter for publication index page.
+  if ($('.pub-filters-select')) {
+    filter_publications();
+    // Useful for changing hash manually (e.g. in development):
+    // window.addEventListener('hashchange', filter_publications, false);
+  }
 
-    // Filter projects.
-    $('.projects-container').each(function (index, container) {
-      let $container = $(container);
-      let $section = $container.closest('section');
-      let layout;
-      if ($section.find('.isotope').hasClass('js-layout-row')) {
-        layout = 'fitRows';
+  // Load citation modal on 'Cite' click.
+  $('.js-cite-modal').click(function (e) {
+    e.preventDefault();
+    let filename = $(this).attr('data-filename');
+    let modal = $('#modal');
+    modal.find('.modal-body code').load(filename, function (response, status, xhr) {
+      if (status == 'error') {
+        let msg = "Error: ";
+        $('#modal-error').html(msg + xhr.status + " " + xhr.statusText);
       } else {
-        layout = 'masonry';
+        $('.js-download-cite').attr('href', filename);
       }
-
-      $container.imagesLoaded(function () {
-        // Initialize Isotope after all images have loaded.
-        $container.isotope({
-          itemSelector: '.isotope-item',
-          layoutMode: layout,
-          masonry: {
-            gutter: 20
-          },
-          filter: $section.find('.default-project-filter').text()
-        });
-
-        // Filter items when filter link is clicked.
-        $section.find('.project-filters a').click(function () {
-          let selector = $(this).attr('data-filter');
-          $container.isotope({filter: selector});
-          $(this).removeClass('active').addClass('active').siblings().removeClass('active all');
-          return false;
-        });
-
-        // If window hash is set, scroll to hash.
-        // Placing this within `imagesLoaded` prevents scrolling to the wrong location due to dynamic image loading
-        // affecting page layout and position of the target anchor ID.
-        // Note: If there are multiple project widgets on a page, ideally only perform this once after images
-        // from *all* project widgets have finished loading.
-        if (window.location.hash) {
-          scrollToAnchor();
-        }
-      });
     });
+    modal.modal('show');
+  });
 
-    // Enable publication filter for publication index page.
-    if ($('.pub-filters-select')) {
-      filter_publications();
-      // Useful for changing hash manually (e.g. in development):
-      // window.addEventListener('hashchange', filter_publications, false);
+  // Copy citation text on 'Copy' click.
+  $('.js-copy-cite').click(function (e) {
+    e.preventDefault();
+    // Get selection.
+    let range = document.createRange();
+    let code_node = document.querySelector('#modal .modal-body');
+    range.selectNode(code_node);
+    window.getSelection().addRange(range);
+    try {
+      // Execute the copy command.
+      document.execCommand('copy');
+    } catch (e) {
+      console.log('Error: citation copy failed.');
     }
+    // Remove selection.
+    window.getSelection().removeRange(range);
+  });
 
-    // Load citation modal on 'Cite' click.
-    $('.js-cite-modal').click(function (e) {
-      e.preventDefault();
-      let filename = $(this).attr('data-filename');
-      let modal = $('#modal');
-      modal.find('.modal-body code').load(filename, function (response, status, xhr) {
-        if (status == 'error') {
-          let msg = "Error: ";
-          $('#modal-error').html(msg + xhr.status + " " + xhr.statusText);
-        } else {
-          $('.js-download-cite').attr('href', filename);
-        }
-      });
-      modal.modal('show');
-    });
-
-    // Copy citation text on 'Copy' click.
-    $('.js-copy-cite').click(function (e) {
-      e.preventDefault();
-      // Get selection.
-      let range = document.createRange();
-      let code_node = document.querySelector('#modal .modal-body');
-      range.selectNode(code_node);
-      window.getSelection().addRange(range);
-      try {
-        // Execute the copy command.
-        document.execCommand('copy');
-      } catch (e) {
-        console.log('Error: citation copy failed.');
-      }
-      // Remove selection.
-      window.getSelection().removeRange(range);
-    });
-
-    // Initialise Google Maps if necessary.
-    initMap();
+  // Initialise Google Maps if necessary.
+  initMap();
 
-    // Print latest version of GitHub projects.
-    let githubReleaseSelector = '.js-github-release';
-    if ($(githubReleaseSelector).length > 0)
-      printLatestRelease(githubReleaseSelector, $(githubReleaseSelector).data('repo'));
+  // Print latest version of GitHub projects.
+  let githubReleaseSelector = '.js-github-release';
+  if ($(githubReleaseSelector).length > 0)
+    printLatestRelease(githubReleaseSelector, $(githubReleaseSelector).data('repo'));
 
-    // On search icon click toggle search dialog.
-    $('.js-search').click(function (e) {
-      e.preventDefault();
-      toggleSearchDialog();
-    });
-    $(document).on('keydown', function (e) {
-      if (e.which == 27) {
-        // `Esc` key pressed.
-        if ($('body').hasClass('searching')) {
-          toggleSearchDialog();
-        }
-      } else if (e.which == 191 && e.shiftKey == false && !$('input,textarea').is(':focus')) {
-        // `/` key pressed outside of text input.
-        e.preventDefault();
+  // On search icon click toggle search dialog.
+  $('.js-search').click(function (e) {
+    e.preventDefault();
+    toggleSearchDialog();
+  });
+  $(document).on('keydown', function (e) {
+    if (e.which == 27) {
+      // `Esc` key pressed.
+      if ($('body').hasClass('searching')) {
         toggleSearchDialog();
       }
-    });
-
-    // Init. author notes (tooltips).
-    $('[data-toggle="tooltip"]').tooltip();
-
-    // Re-initialize Scrollspy with dynamic navbar height offset.
-    fixScrollspy();
-  });
-
-  // Normalize Bootstrap carousel slide heights.
-  $(window).on('load resize orientationchange', normalizeCarouselSlideHeights);
-
-  // Automatic main menu dropdowns on mouse over.
-  $('body').on('mouseenter mouseleave', '.dropdown', function (e) {
-    var dropdown = $(e.target).closest('.dropdown');
-    var menu = $('.dropdown-menu', dropdown);
-    dropdown.addClass('show');
-    menu.addClass('show');
-    setTimeout(function () {
-      dropdown[dropdown.is(':hover') ? 'addClass' : 'removeClass']('show');
-      menu[dropdown.is(':hover') ? 'addClass' : 'removeClass']('show');
-    }, 300);
-  });
-
-  // Call `fixScrollspy` when window is resized.
-  let resizeTimer;
-  $(window).resize(function () {
-    clearTimeout(resizeTimer);
-    resizeTimer = setTimeout(fixScrollspy, 200);
+    } else if (e.which == 191 && e.shiftKey == false && !$('input,textarea').is(':focus')) {
+      // `/` key pressed outside of text input.
+      e.preventDefault();
+      toggleSearchDialog();
+    }
   });
 
-})(jQuery);
+  // Init. author notes (tooltips).
+  $('[data-toggle="tooltip"]').tooltip();
+
+  // Re-initialize Scrollspy with dynamic navbar height offset.
+  fixScrollspy();
+});
+
+// Normalize Bootstrap carousel slide heights.
+$(window).on('load resize orientationchange', normalizeCarouselSlideHeights);
+
+// Automatic main menu dropdowns on mouse over.
+$('body').on('mouseenter mouseleave', '.dropdown', function (e) {
+  var dropdown = $(e.target).closest('.dropdown');
+  var menu = $('.dropdown-menu', dropdown);
+  dropdown.addClass('show');
+  menu.addClass('show');
+  setTimeout(function () {
+    dropdown[dropdown.is(':hover') ? 'addClass' : 'removeClass']('show');
+    menu[dropdown.is(':hover') ? 'addClass' : 'removeClass']('show');
+  }, 300);
+});
+
+// Call `fixScrollspy` when window is resized.
+let resizeTimer;
+$(window).resize(function () {
+  clearTimeout(resizeTimer);
+  resizeTimer = setTimeout(fixScrollspy, 200);
+});

+ 1 - 1
wowchemy/config.yaml

@@ -8,7 +8,7 @@ outputFormats:
     rel: manifest
 module:
   hugoVersion:
-    min: '0.73.0'
+    min: '0.78.0'
     extended: true
   mounts:
     - source: content

+ 6 - 12
wowchemy/layouts/_default/baseof.html

@@ -8,18 +8,12 @@
 {{- $highlight_active_link := site.Params.main_menu.highlight_active_link | default true -}}
 <body id="top" data-spy="scroll" {{ if $show_navbar }}data-offset="70"{{end}} data-target="{{ if or .IsHome (eq .Type "widget_page") | and $highlight_active_link }}#navbar-main{{else}}#TableOfContents{{end}}" class="page-wrapper {{ if not (.Scratch.Get "light") }}dark{{end}} {{ if not $show_navbar }}no-navbar{{end}}">
 
-  {{/* Load day/night theme. */}}
-  {{/* Initialise default theme. */}}
-  {{ if site.Params.day_night }}
-    <script>window.wcDarkLightEnabled = true;</script>
-  {{ end }}
-  {{ if eq (.Scratch.Get "light") true }}
-    <script>const isSiteThemeDark = false;</script>
-  {{ else }}
-    <script>const isSiteThemeDark = true;</script>
-  {{ end }}
-  {{ $load_theme := resources.Get "js/load-theme.js" }}
-  <script src="{{ $load_theme.RelPermalink }}"></script>
+  {{/* Initialise theme variation. */}}
+  {{ $wcDarkLightEnabled := site.Params.day_night | default false }}
+  {{ $wcIsSiteThemeDark := not (.Scratch.Get "light") | default false }}
+  {{ $js_params := dict "wcDarkLightEnabled" $wcDarkLightEnabled "wcIsSiteThemeDark" $wcIsSiteThemeDark }}
+  {{ $js_file := resources.Get "js/wowchemy-init.js" | js.Build (dict "params" $js_params) }}
+  <script src="{{ $js_file.RelPermalink }}"></script>
 
   {{ partial "search" . }}
 

+ 1 - 1
wowchemy/layouts/partials/site_js.html

@@ -155,7 +155,7 @@
     {{ $js_comment := printf "/* Wowchemy v%s | https://wowchemy.com/ */\n" site.Data.wowchemy.version }}
     {{ $js_bundle_head := $js_comment | resources.FromString "js/bundle-head.js" }}
     {{ $js_linebreak := "\n" | resources.FromString "js/linebreak.js" }}{{/* Fix no line break after Bootstrap JS causing error. */}}
-    {{ $js_academic := resources.Get "js/wowchemy.js" }}
+    {{ $js_academic := resources.Get "js/wowchemy.js" | js.Build }}
     {{ $js_academic_search := resources.Get "js/wowchemy-search.js" }}
     {{ $js_algolia_search := resources.Get "js/algolia-search.js" }}
     {{ $js_bootstrap := resources.Get "js/_vendor/bootstrap.bundle.js" }}