// ==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} - 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, }; }());