NH_spa

Support for Single-Page Applications (SPA).

Tento skript by neměl být instalován přímo. Jedná se o knihovnu, kterou by měly jiné skripty využívat pomocí meta příkazu // @require https://update.greasyfork.org/scripts/570146/1778324/NH_spa.js

K instalaci tototo skriptu si budete muset nainstalovat rozšíření jako Tampermonkey, Greasemonkey nebo Violentmonkey.

You will need to install an extension such as Tampermonkey to install this script.

K instalaci tohoto skriptu si budete muset nainstalovat rozšíření jako Tampermonkey nebo Violentmonkey.

K instalaci tohoto skriptu si budete muset nainstalovat rozšíření jako Tampermonkey nebo Userscripts.

You will need to install an extension such as Tampermonkey to install this script.

K instalaci tohoto skriptu si budete muset nainstalovat manažer uživatelských skriptů.

(Už mám manažer uživatelských skriptů, nechte mě ho nainstalovat!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(Už mám manažer uživatelských stylů, nechte mě ho nainstalovat!)

// ==UserScript==
// ==UserLibrary==
// @name        NH_spa
// @description Support for Single-Page Applications (SPA).
// @version     1
// @license     GPL-3.0-or-later; https://www.gnu.org/licenses/gpl-3.0-standalone.html
// @homepageURL https://github.com/nexushoratio/userscripts
// @supportURL  https://github.com/nexushoratio/userscripts/issues
// @match       https://www.example.com/*
// ==/UserLibrary==
// ==/UserScript==

window.NexusHoratio ??= {};

window.NexusHoratio.spa = (function spa() {
  'use strict';

  const version = 1;

  const NH = window.NexusHoratio.base.ensure([
    {name: 'xunit', minVersion: 54},
    {name: 'base', minVersion: 52},
    {name: 'web', minVersion: 8},
  ]);

  /** Library specific exception. */
  class Exception extends NH.base.Exception {}

  /**
   * Base class for handling various views of a single-page application.
   *
   * Pages are created by subclassing with at least a custom contructor that
   * provides {PageDetails} via super().  The classes are then registered with
   * a {SPA} instance where they are instantiated with a single parameter of
   * {SPA}.
   *
   */
  class Page {

    /**
     * @typedef {object} PageDetails
     * @property {SPA} spa - SPA instance that manages this Page.
     * @property {string} [name=this.constructor.name] - A human readable name
     * for this page (normally parsed from the subclass name).
     * @property {string|RegExp} [pathname=RegExp(.*)] - Pathname portion of
     * the URL this page should handle.
     * @property {string} [readySelector='body'] - CSS selector that is used
     * to detect that the page is loaded enough to activate.
     */

    /** @param {PageDetails} details - Details about the page. */
    constructor(details = {}) {
      if (new.target === Page) {
        throw new TypeError('Abstract class; do not instantiate directly.');
      }

      this.#id = this.constructor.name;
      this.#spa = details.spa;
      this.#logger = new NH.base.Logger(this.#id);
      this.#pathnameRE = this.#computePathnameRE(details.pathname);
      ({
        readySelector: this.#readySelector = 'body',
        name: this.#name = NH.base.simpleParseWords(this.#id)
          .join(' '),
      } = details);
      this.dispatcher
        .on('activate', this.#onActivate)
        .on('deactivate', this.#onDeactivate);
    }

    /** @type {NH.base.Dispatcher} */
    get dispatcher() {
      return this.#dispatcher;
    }

    /** @type {string} - Machine readable name for the page. */
    get id() {
      return this.#id;
    }

    /** @type {NH.base.Logger} */
    get logger() {
      return this.#logger;
    }

    /** @type {string} - Human readable name for the page. */
    get name() {
      return this.#name;
    }

    /** @type {RegExp} */
    get pathname() {
      return this.#pathnameRE;
    }

    /** @type {SPA} */
    get spa() {
      return this.#spa;
    }

    /**
     * Register a new {@link NH.base.Service}.
     * @param {function(): NH.base.Service} Klass - A service class to
     * instantiate.
     * @param {...*} rest - Arbitrary objects to pass to constructor.
     * @returns {NH.base.Service} - Instance of Klass.
     */
    addService(Klass, ...rest) {
      const me = this.addService.name;
      this.logger.entered(me, Klass, ...rest);

      let instance = null;
      if (Klass.prototype instanceof NH.base.Service) {
        instance = new Klass(this.constructor.name, ...rest);
        this.#services.add(instance);
      } else {
        throw new Exception(`${Klass} is not a Service`);
      }

      this.logger.leaving(me, instance);
      return instance;
    }

    static #supportedEvents = [
      'activate',
      'activated',
      'deactivate',
      'deactivated',
    ];

    #dispatcher = new NH.base.Dispatcher(...Page.#supportedEvents);
    #id
    #logger
    #name
    #pathnameRE
    #readySelector
    #services = new Set();
    #spa

    /**
     * Turn a path into a RegExp.
     * @param {string|RegExp} pathname - A path to convert.
     * @returns {RegExp} - A converted path.
     */
    #computePathnameRE = (pathname) => {
      const me = this.#computePathnameRE.name;
      this.logger.entered(me, pathname);

      let re = /.*/u;
      if (pathname instanceof RegExp) {
        re = pathname;
      } else if (pathname) {
        re = RegExp(`^${pathname}$`, 'u');
      }

      this.logger.leaving(me, re);
      return re;
    }

    #onActivate = () => {
      for (const service of this.#services) {
        service.activate();
      }
    }

    #onDeactivate = () => {
      for (const service of this.#services) {
        service.deactivate();
      }
    }

  }

  /* eslint-disable no-new */
  /* eslint-disable no-shadow */
  /* eslint-disable require-jsdoc */
  class PageTestCase extends NH.xunit.TestCase {

    static Service = class extends NH.base.Service {

      constructor(name) {
        super(`The ${name}`);
        this
          .on('activate', this.#onEvent)
          .on('activated', this.#onEvent)
          .on('deactivate', this.#onEvent)
          .on('deactivated', this.#onEvent);
      }

      mq = new NH.base.MessageQueue();

      #onEvent = (evt, data) => {
        this.mq.post(evt, data);
      }

    }

    static ADefaultPage = class extends Page {

      constructor(spa) {
        super({spa: spa});
        this.service = this.addService(PageTestCase.Service);
      }

    }

    static AnOverridePage = class extends Page {

      constructor(spa, method) {
        const pathnames = new Map([
          ['s', '/path/to/override'],
          // eslint-disable-next-line prefer-regex-literals
          ['r', RegExp('^/another/path/to/page/(?:sub1|sub2)/', 'u')],
        ]);
        const pathname = pathnames.get(method);

        super({
          spa: spa,
          pathname: pathname,
          ...PageTestCase.AnOverridePage.#details,
        });

      }

      static #details = {
        name: 'XyZzy Tasks',
      }

    }

    testAbstract() {
      this.assertRaises(TypeError, () => {
        new Page();
      });
    }

    testDefaultProperties() {
      // Assemble
      const spa = Symbol(this.id);
      const d = new PageTestCase.ADefaultPage(spa);

      // Assert
      this.assertTrue(
        d.dispatcher instanceof NH.base.Dispatcher, 'dispatcher'
      );
      this.assertEqual(d.id, 'ADefaultPage', 'id');
      this.assertTrue(d.logger instanceof NH.base.Logger, 'logger');
      this.assertEqual(d.name, 'A Default Page', 'name');
      this.assertEqual(d.pathname, /.*/u, 'path');
      this.assertEqual(d.spa, spa, 'spa');
    }

    testOverrideProperties() {
      // Assemble
      const spa = Symbol(this.id);
      // String path
      const s = new PageTestCase.AnOverridePage(spa, 's');
      // Regex path
      const r = new PageTestCase.AnOverridePage(spa, 'r');

      // Assert
      this.assertTrue(
        s.dispatcher instanceof NH.base.Dispatcher, 'dispatcher'
      );
      this.assertEqual(s.id, 'AnOverridePage', 'id');
      this.assertTrue(s.logger instanceof NH.base.Logger, 'logger');
      this.assertEqual(s.name, 'XyZzy Tasks', 'name');
      this.assertEqual(s.pathname, /^\/path\/to\/override$/u, 's-path');
      this.assertEqual(s.spa, spa, 'spa');

      this.assertEqual(
        r.pathname,
        /^\/another\/path\/to\/page\/(?:sub1|sub2)\//u,
        'r-path'
      );
    }

    testServices() {
      // Assemble
      const spa = Symbol(this.id);
      const page = new PageTestCase.ADefaultPage(spa);
      const messages = [];
      const cb = (...items) => {
        messages.push(items[0]);
        messages.push('---');
      };

      this.assertTrue(
        page.service instanceof PageTestCase.Service, 'our service'
      );

      this.assertRaisesRegExp(
        Exception,
        /not-a-service is not a Service/u,
        () => {
          page.addService('not-a-service');
        },
        'not a service'
      );

      page.dispatcher.fire('activate');
      page.service.mq.listen(cb);
      this.assertEqual(
        messages,
        ['activate', '---', 'activated', '---']
      );
    }

  }
  /* eslint-enable */

  NH.xunit.testing.testCases.push(PageTestCase);

  /**
   * Base class for site details.
   *
   * Single-page application userscripts should subclass, instantiate then
   * pass in while creating a {SPA} instance.
   *
   * Instances of {Page} will have access to maintain state and shared
   * information.
   */
  class Details {

    /** Create a Details instance. */
    constructor() {
      if (new.target === Details) {
        throw new TypeError('Abstract class; do not instantiate directly.');
      }

      this.#logger = new NH.base.Logger(`${this.constructor.name}`);
      this.#id = NH.base.safeId(NH.base.uuId(this.constructor.name));
      this.#dispatcher.on('initialize', this.#onInit);
    }

    /**
     * @type {string} - CSS selector to monitor if self-managing URL changes.
     *
     * Override in subclass if desired.
     *
     * The selector must resolve to an element that, once it exists, will
     * continue to exist for the lifetime of the SPA.
     */
    urlChangeMonitorSelector = 'body';

    /** @type {NH.base.Dispatcher} */
    get dispatcher() {
      return this.#dispatcher;
    }

    /** @type {string} - Unique ID for this instance . */
    get id() {
      return this.#id;
    }

    /** @type {NH.base.Logger} - NH.base.Logger instance. */
    get logger() {
      return this.#logger;
    }

    /** @type {SPA} */
    get spa() {
      return this.#spa;
    }

    static #supportedEvents = [
      'initialize',
      'initialized',
    ];

    #dispatcher = new NH.base.Dispatcher(...Details.#supportedEvents);
    #id
    #logger
    #spa

    #onInit = (...args) => {
      this.#spa = args[1];
    }

  }

  /* eslint-disable no-new */
  /* eslint-disable no-shadow */
  /* eslint-disable no-undefined */
  /* eslint-disable require-jsdoc */
  class DetailsTestCase extends NH.xunit.TestCase {

    static TestDeets = class extends Details {};

    static OverrideDeets = class extends Details {

      urlChangeMonitorSelector = 'html';

    };

    testAbstract() {
      this.assertRaises(TypeError, () => {
        new Details();
      });
    }

    testProperties() {
      const deets = new DetailsTestCase.TestDeets();

      this.assertEqual(deets.urlChangeMonitorSelector, 'body', 'url monitor');
      this.assertRegExp(
        deets.id,
        /^TestDeets-.*/u,
        'id'
      );
      this.assertTrue(deets.logger instanceof NH.base.Logger, 'logger');

      this.assertEqual(deets.spa, undefined, 'early spa');
      const spa = Symbol('spa');
      deets.dispatcher.fire('initialize', spa);
      this.assertEqual(deets.spa, spa, 'late spa');
    }

    testOverrides() {
      const deets = new DetailsTestCase.OverrideDeets();

      this.assertEqual(deets.urlChangeMonitorSelector, 'html', 'url monitor');
    }

  }
  /* eslint-enable */

  NH.xunit.testing.testCases.push(DetailsTestCase);

  /**
   * A userscript driver for working with a single-page application (SPA).
   *
   * In many cases, as a user navigates through a SPA, the website will change
   * the URL.  This allows for easy bookmarking by users.  By monitoring the
   * changes to the URL, this class facilitates making userscripts that adapt
   * to different views.
   *
   * Generally, a single instance of this class is created, and all {Page}
   * subclasses are registered with it.  As the user navigates through the
   * SPA, this class will react to the changes and enable and disable view
   * specific Pages as appropriate.
   *
   * Each Page can be customized to augment the individual site page.
   */
  class SPA {

    /** @param {SPA.Details} details - Implementation specific details. */
    constructor(details) {
      const name = `${this.constructor.name}: ${details.constructor.name}`;
      this.#details = details;
      this.#details.dispatcher.fire('initialize', this);
      this.#id = NH.base.safeId(NH.base.uuId(name));
      this.#logger = new NH.base.Logger(name);
      document.addEventListener('urlchange', this.#onUrlChange, true);
      this.#startUrlMonitor();
      this.#details.dispatcher.fire('initialized', null);
      this.#activate(window.location.pathname);
    }

    /**
     * Add a new page to those supported by this instance.
     * @param {Page} Klass - A {@link Page} class to instantiate.
     * @returns {SPA} - This instance, for chaining.
     */
    register(Klass) {
      const me = this.register.name;
      this.#logger.entered(me, Klass);

      if (Klass.prototype instanceof Page) {
        this.#pages.add(new Klass(this));
        this.#activate(window.location.pathname);
      } else {
        throw new Exception(`${Klass} is not a Page`);
      }

      this.#logger.leaving(me);
      return this;
    }

    #activePages = new Set();
    #details
    #id
    #logger
    #oldUrl
    #pages = new Set();

    /**
     * Handle switching from the old page (if any) to the new one.
     * @param {string} pathname - A {URL.pathname}.
     */
    #activate = (pathname) => {
      const me = this.#activate.name;
      this.#logger.entered(me, pathname);

      const pages = this.#findPages(pathname);
      const oldPages = new Set(this.#activePages);
      const newPages = new Set(pages);
      for (const page of pages) {
        oldPages.delete(page);
      }
      for (const page of oldPages) {
        page.dispatcher.fire('deactivate');
      }
      for (const page of newPages) {
        page.dispatcher.fire('activate');
      }
      this.#activePages = pages;

      this.#logger.leaving(me);
    }

    /**
     * Determine which pages can handle this portion of the URL.
     * @param {string} pathname - A {URL.pathname}.
     * @returns {Set<Page>} - The pages to use.
     */
    #findPages = (pathname) => {
      const pages = Array.from(this.#pages.values());
      return new Set(pages.filter(page => page.pathname.test(pathname)));
    }

    /**
     * Tampermonkey was the first(?) userscript manager to provide events
     * about URLs changing.  Hence the need for `@grant window.onurlchange` in
     * the UserScript header.
     * @fires Event#urlchange
     */
    #startUserscriptManagerUrlMonitor = () => {
      this.#logger.log('Using Userscript Manager provided URL monitor.');
      window.addEventListener('urlchange', (info) => {
        // The info that TM gives is not really an event.  So we turn it into
        // one and throw it again, this time onto `document` where something
        // is listening for it.
        const newUrl = new URL(info.url);
        const evt = new CustomEvent('urlchange', {detail: {url: newUrl}});
        document.dispatchEvent(evt);
      });
    }

    /**
     * Install a long lived MutationObserver that watches
     * {Details.urlChangeMonitorSelector}.
     *
     * Whenever it is triggered, it will check to see if the current URL has
     * changed, and if so, send an appropriate event.
     * @fires Event#urlchange
     */
    #startMutationObserverUrlMonitor = async () => {
      this.#logger.log('Using MutationObserver for monitoring URL changes.');

      const observeOptions = {childList: true, subtree: true};

      const element = await NH.web.waitForSelector(
        this.#details.urlChangeMonitorSelector, 0
      );
      this.#logger.log('element exists:', element);

      this.#oldUrl = new URL(window.location);
      new MutationObserver(() => {
        const newUrl = new URL(window.location);
        if (this.#oldUrl.href !== newUrl.href) {
          const evt = new CustomEvent('urlchange', {detail: {url: newUrl}});
          this.#oldUrl = newUrl;
          document.dispatchEvent(evt);
        }
      })
        .observe(element, observeOptions);
    }

    /** Select which way to monitor the URL for changes and start it. */
    #startUrlMonitor = () => {
      if (window.onurlchange === null) {
        this.#startUserscriptManagerUrlMonitor();
      } else {
        this.#startMutationObserverUrlMonitor();
      }
    }

    /**
     * Handle urlchange events that indicate a switch to a new page.
     * @param {CustomEvent} evt - Custom 'urlchange' event.
     */
    #onUrlChange = (evt) => {
      this.#activate(evt.detail.url.pathname);
    }

  }

  /* eslint-disable no-shadow */
  /* eslint-disable require-jsdoc */
  class SPATestCase extends NH.xunit.TestCase {

    static Deets = class extends Details {};
    static Paige = class extends Page {

      constructor(spa) {
        super({spa: spa});
      }

    };

    testConstructor() {
      const deets = new SPATestCase.Deets();
      const spa = new SPA(deets);

      this.assertEqual(deets.spa, spa, 'deets spa');
    }

    testRegister() {
      const deets = new SPATestCase.Deets();
      const spa = new SPA(deets);

      this.assertEqual(spa.register(SPATestCase.Paige), spa, 'chaining');

      this.assertRaisesRegExp(
        Exception,
        /not-a-page is not a Page/u,
        () => {
          spa.register('not-a-page');
        },
        'not a page'
      );

    }

  }
  /* eslint-enable */

  NH.xunit.testing.testCases.push(SPATestCase);

  return {
    version: version,
    Exception: Exception,
    Page: Page,
    Details: Details,
    SPA: SPA,
  };

}());