From 29ee30d94e5aa364102ad29fd316ddb1e4a9b8a0 Mon Sep 17 00:00:00 2001 From: Simon Legner Date: Mon, 14 Sep 2026 12:24:19 +0200 Subject: [PATCH 1/4] Load the assets as ES modules through an import map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sprockets has no notion of `import`/`export` — its whole JS pipeline is `concat_javascript_sources`, which appends each file and pokes a `;` between them. The single global scope that produced is what the `app` object was a registry for. Serving the modules individually instead costs ~33 KB of cross-file compression and six levels of import depth, which `modulepreload` flattens to about one round trip. Every module is served at a content-digested URL, `immutable` and cacheable forever, and the import map that points at those URLs ships inside the HTML, which is never cached. A client therefore reads one build's map and fetches that build's modules, and can't end up running half of one build and half of another. Old digests are kept for a couple of builds so a deploy doesn't strand a client mid-load. The modules import each other by relative path rather than by a bare specifier so that tsc, editors and Node resolve them with no extra configuration; the digests still apply because an import map may key on a URL as well as on a bare name. `app` keeps only the state the rest of the app reads off it. The four class registries are gone, and with them the reason for most of globals.d.ts. Two things had to move rather than be translated: - `config` becomes its own leaf module. Five classes read it from a `static` field, which runs at class-definition time and would find `app` still uninitialised inside the app/views import cycle. The same hazard made `static model = Doc` in the collections throw, so the model is resolved from a method now. - The injection-error check compared `window.$` against the app's own copy, to notice extensions replacing the globals. Module scope makes that impossible, which also makes the comparison always true — it would have reported every error as an injection error and shown an alert. Removed along with the fields that existed to feed it. The vendored libraries assign globals rather than exporting, so they stay one concatenated classic script loaded ahead of the graph. docs.js and debug.js stay separate entries: single-doc pages skip the catalog, and debug has to run before the boot it wraps. The tests import the modules directly instead of concatenating files into a `vm` context, with a loader hook standing in for the modules ERB generates at build time. A new test evaluates the whole graph in the order the browser does, which is the only thing that catches the initialisation-cycle bugs above. --- assets/javascripts/app/app.js | 247 +++------ assets/javascripts/app/config.d.ts | 31 ++ assets/javascripts/app/config.js.erb | 5 +- assets/javascripts/app/db.js | 12 +- assets/javascripts/app/offline_backup.js | 9 +- assets/javascripts/app/router.js | 11 +- assets/javascripts/app/searcher.js | 21 +- assets/javascripts/app/serviceworker.js | 15 +- assets/javascripts/app/settings.js | 17 +- assets/javascripts/app/shortcuts.js | 10 +- assets/javascripts/app/update_checker.js | 15 +- assets/javascripts/application.js | 51 +- assets/javascripts/collections/collection.js | 27 +- assets/javascripts/collections/docs.js | 17 +- assets/javascripts/collections/entries.js | 14 +- assets/javascripts/collections/types.js | 14 +- assets/javascripts/debug.js | 98 ++-- assets/javascripts/docs.d.ts | 5 + assets/javascripts/docs.js.erb | 2 + assets/javascripts/globals.d.ts | 502 +++++++++--------- assets/javascripts/lib/ajax.js | 2 +- assets/javascripts/lib/cookies_store.js | 2 +- assets/javascripts/lib/events.js | 2 +- assets/javascripts/lib/favicon.js | 10 +- assets/javascripts/lib/license.js | 9 - assets/javascripts/lib/local_storage_store.js | 2 +- assets/javascripts/lib/page.js | 17 +- assets/javascripts/lib/util.js | 4 +- assets/javascripts/models/doc.js | 28 +- assets/javascripts/models/entry.js | 20 +- assets/javascripts/models/model.js | 6 +- assets/javascripts/models/type.js | 11 +- assets/javascripts/templates/base.js | 48 +- assets/javascripts/templates/error_tmpl.js | 10 +- assets/javascripts/templates/notice_tmpl.js | 13 +- assets/javascripts/templates/notif_tmpl.js | 32 +- .../javascripts/templates/pages/about_tmpl.js | 4 +- .../javascripts/templates/pages/help_tmpl.js | 8 +- .../templates/pages/news_tmpl.d.ts | 6 + .../templates/pages/news_tmpl.js.erb | 8 +- .../templates/pages/offline_tmpl.js | 25 +- .../templates/pages/root_tmpl.d.ts | 8 + .../templates/pages/root_tmpl.js.erb | 10 +- .../templates/pages/settings_tmpl.js | 2 +- .../javascripts/templates/pages/type_tmpl.js | 11 +- assets/javascripts/templates/path_tmpl.js | 7 +- assets/javascripts/templates/sidebar_tmpl.js | 34 +- assets/javascripts/templates/tip_tmpl.js | 4 +- assets/javascripts/tracking.js | 6 +- assets/javascripts/vendor.js | 7 + assets/javascripts/views/content/content.js | 33 +- .../javascripts/views/content/entry_page.js | 37 +- .../javascripts/views/content/offline_page.js | 17 +- assets/javascripts/views/content/root_page.js | 10 +- .../views/content/settings_page.js | 20 +- .../javascripts/views/content/static_page.js | 9 +- assets/javascripts/views/content/type_page.js | 11 +- assets/javascripts/views/layout/document.js | 33 +- assets/javascripts/views/layout/menu.js | 9 +- assets/javascripts/views/layout/mobile.js | 17 +- assets/javascripts/views/layout/path.js | 11 +- assets/javascripts/views/layout/resizer.js | 10 +- assets/javascripts/views/layout/settings.js | 16 +- assets/javascripts/views/list/list_focus.js | 17 +- assets/javascripts/views/list/list_fold.js | 17 +- assets/javascripts/views/list/list_select.js | 9 +- .../javascripts/views/list/paginated_list.js | 12 +- assets/javascripts/views/misc/news.js | 17 +- assets/javascripts/views/misc/notice.js | 10 +- assets/javascripts/views/misc/notif.js | 9 +- assets/javascripts/views/misc/tip.js | 8 +- assets/javascripts/views/misc/updates.js | 18 +- assets/javascripts/views/pages/base.js | 10 +- assets/javascripts/views/pages/hidden.js | 13 +- assets/javascripts/views/pages/jquery.js | 9 +- assets/javascripts/views/pages/rdoc.js | 9 +- assets/javascripts/views/pages/sqlite.js | 9 +- .../javascripts/views/pages/support_tables.js | 9 +- assets/javascripts/views/search/search.js | 22 +- .../javascripts/views/search/search_scope.js | 19 +- assets/javascripts/views/sidebar/doc_list.js | 29 +- .../javascripts/views/sidebar/doc_picker.js | 24 +- .../javascripts/views/sidebar/entry_list.js | 9 +- assets/javascripts/views/sidebar/results.js | 21 +- assets/javascripts/views/sidebar/sidebar.js | 24 +- .../views/sidebar/sidebar_hover.js | 9 +- assets/javascripts/views/sidebar/type_list.js | 14 +- assets/javascripts/views/view.js | 30 +- lib/app.rb | 61 ++- lib/tasks/assets.thor | 7 +- package.json | 3 +- test/assets/doc_version_test.js | 91 ++-- test/assets/fixtures/config.js | 26 + test/assets/fixtures/docs.js | 5 + test/assets/fixtures/news_tmpl.js | 3 + test/assets/fixtures/root_tmpl.js | 5 + test/assets/module_graph_test.js | 32 ++ test/assets/search_hash_test.js | 59 +- test/assets/search_ranking_test.js | 58 +- test/assets/setup.js | 71 +++ test/assets/tsconfig.json | 13 +- tsconfig.json | 4 + views/index.erb | 14 +- views/other.erb | 11 +- 104 files changed, 1377 insertions(+), 1175 deletions(-) create mode 100644 assets/javascripts/app/config.d.ts create mode 100644 assets/javascripts/docs.d.ts delete mode 100644 assets/javascripts/lib/license.js create mode 100644 assets/javascripts/templates/pages/news_tmpl.d.ts create mode 100644 assets/javascripts/templates/pages/root_tmpl.d.ts create mode 100644 assets/javascripts/vendor.js create mode 100644 test/assets/fixtures/config.js create mode 100644 test/assets/fixtures/docs.js create mode 100644 test/assets/fixtures/news_tmpl.js create mode 100644 test/assets/fixtures/root_tmpl.js create mode 100644 test/assets/module_graph_test.js create mode 100644 test/assets/setup.js diff --git a/assets/javascripts/app/app.js b/assets/javascripts/app/app.js index 3a7bc6a3c9..49a65d1468 100644 --- a/assets/javascripts/app/app.js +++ b/assets/javascripts/app/app.js @@ -1,119 +1,40 @@ // @ts-check -/** - * An empty registry, to be filled in by the files that define its members. - * - * @template T - * @returns {T} - */ -const empty = () => /** @type {T} */ (/** @type {unknown} */ ({})); - -/** - * The build-time configuration, rendered into the page by app/config.js.erb. - * - * @typedef {object} AppConfig - * @property {string} db_filename - * @property {string[]} default_docs Slugs enabled for a first-time visitor. - * @property {Record} docs_aliases Alternative spellings, by the name they resolve to. - * @property {string} docs_origin Where the documentation files are served from. - * @property {string} env - * @property {number} history_cache_size - * @property {string} index_filename - * @property {number} max_results - * @property {string} production_host - * @property {string} search_param The query parameter a search is read from. - * @property {string} sentry_dsn - * @property {number} version Cache-busting stamp for the offline data. - * @property {string} release - * @property {string} mathml_stylesheet - * @property {string} favicon_spritesheet - * @property {string} service_worker_path - * @property {boolean} service_worker_enabled - */ +import { config } from "./config.js"; +import { DB } from "./db.js"; +import { Router } from "./router.js"; +import { AppServiceWorker } from "./serviceworker.js"; +import { Settings } from "./settings.js"; +import { Shortcuts } from "./shortcuts.js"; +import { UpdateChecker } from "./update_checker.js"; +import { Docs } from "../collections/docs.js"; +import { Entries } from "../collections/entries.js"; +import { CookiesStore } from "../lib/cookies_store.js"; +import { Events } from "../lib/events.js"; +import { LocalStorageStore } from "../lib/local_storage_store.js"; +import { $ } from "../lib/util.js"; +import { Doc } from "../models/doc.js"; +import { unsupportedBrowser } from "../templates/error_tmpl.js"; +import { AppDocument } from "../views/layout/document.js"; +import { Mobile } from "../views/layout/mobile.js"; +import { News } from "../views/misc/news.js"; +import { Notice } from "../views/misc/notice.js"; +import { Notif } from "../views/misc/notif.js"; +import { Tip } from "../views/misc/tip.js"; +import { Updates } from "../views/misc/updates.js"; /** - * A doc as it appears in the manifest, before it becomes an `app.models.Doc`. + * A doc as it appears in the manifest, before it becomes a `Doc`. * * @typedef {Record} DocData */ /** - * The application singleton, and the namespace everything else registers into. - * - * The models and collections are registered by name from the files that define - * them, so their entries are listed here; the views and templates are too many - * to enumerate and stay open-ended. + * The application singleton: the live state the rest of the app reads off, and + * the boot sequence that builds it. Classes are imported where they are used + * rather than registered here. */ -class App extends Events { - // Kept so that isInjectionError can tell whether an extension replaced the - // globals out from under us. - _$ = $; - _$$ = $$; - _page = page; - - /** @type {{ Docs: typeof Docs, Entries: typeof Entries, Types: typeof Types }} */ - collections = empty(); - /** @type {{ Doc: typeof Doc, Entry: typeof Entry, Type: typeof Type }} */ - models = empty(); - /** - * Templates are either a function of their arguments or plain markup. Most - * are reached by name through `render`, so the registry stays open-ended; - * the few that are called directly are named here so they stay callable. - * - * @type {Record string) | string> & { - * render: (name: string, value?: unknown, ...args: unknown[]) => string, - * newsList: (news: unknown[], options?: { years?: boolean }) => string, - * notifNews: (news: unknown[]) => string, - * notifUpdates: (docs: Doc[], disabledDocs: Doc[]) => string, - * }} - */ - templates = empty(); - /** - * @type {{ - * BasePage: typeof BasePage, - * Content: typeof Content, - * DocList: typeof DocList, - * DocPicker: typeof DocPicker, - * Document: typeof AppDocument, - * EntryList: typeof EntryList, - * EntryPage: typeof EntryPage, - * HiddenPage: typeof HiddenPage, - * JqueryPage: typeof JqueryPage, - * ListFocus: typeof ListFocus, - * ListFold: typeof ListFold, - * ListSelect: typeof ListSelect, - * Menu: typeof Menu, - * Mobile: typeof Mobile, - * News: typeof News, - * Notice: typeof Notice, - * Notif: typeof Notif, - * OfflinePage: typeof OfflinePage, - * PaginatedList: typeof PaginatedList, - * Path: typeof Path, - * RdocPage: typeof RdocPage, - * Resizer: typeof Resizer, - * Results: typeof Results, - * RootPage: typeof RootPage, - * Search: typeof Search, - * SearchScope: typeof SearchScope, - * Settings: typeof SettingsView, - * SettingsPage: typeof SettingsPage, - * Sidebar: typeof Sidebar, - * SidebarHover: typeof SidebarHover, - * SqlitePage: typeof SqlitePage, - * StaticPage: typeof StaticPage, - * SupportTablesPage: typeof SupportTablesPage, - * Tip: typeof Tip, - * TypeList: typeof TypeList, - * TypePage: typeof TypePage, - * Updates: typeof Updates, - * }} - */ - views = empty(); - - /** Set by app/config.js.erb. @type {AppConfig} */ - config; - +export class App extends Events { /** * The manifest of every available doc, set by docs.js.erb. Deleted once the * docs have been read into the collections. @@ -130,21 +51,6 @@ class App extends Events { */ DOC; - // The classes registered by the rest of app/, collections/, models/ and - // views/. They're constructors rather than instances. - /** @type {typeof DB} */ DB; - /** @type {typeof OfflineBackup} */ OfflineBackup; - /** @type {typeof Router} */ Router; - /** @type {typeof Searcher} */ Searcher; - /** @type {typeof SynchronousSearcher} */ SynchronousSearcher; - /** @type {typeof AppServiceWorker} */ ServiceWorker; - /** @type {typeof Settings} */ Settings; - /** @type {typeof Shortcuts} */ Shortcuts; - /** @type {typeof UpdateChecker} */ UpdateChecker; - /** @type {typeof Collection} */ Collection; - /** @type {typeof Model} */ Model; - /** @type {typeof View} */ View; - /** * The news entries, newest first, set by templates/pages/news_tmpl.js.erb. * Each is a date followed by one entry per line. @@ -179,23 +85,23 @@ class App extends Events { this.el = $("._app"); this.localStorage = new LocalStorageStore(); - if (app.ServiceWorker.isEnabled()) { - this.serviceWorker = new app.ServiceWorker(); + if (AppServiceWorker.isEnabled()) { + this.serviceWorker = new AppServiceWorker(); } - this.settings = new app.Settings(); - this.db = new app.DB(); + this.settings = new Settings(); + this.db = new DB(); this.settings.initLayout(); - this.docs = new app.collections.Docs(); - this.disabledDocs = new app.collections.Docs(); - this.entries = new app.collections.Entries(); + this.docs = new Docs(); + this.disabledDocs = new Docs(); + this.entries = new Entries(); - this.router = new app.Router(); - this.shortcuts = new app.Shortcuts(); - this.document = new app.views.Document(); + this.router = new Router(); + this.shortcuts = new Shortcuts(); + this.document = new AppDocument(); if (this.isMobile()) { - this.mobile = new app.views.Mobile(); + this.mobile = new Mobile(); } if (document.body.hasAttribute("data-doc")) { @@ -217,7 +123,7 @@ class App extends Events { return true; } document.body.innerHTML = /** @type {string} */ ( - app.templates.unsupportedBrowser + unsupportedBrowser ); this.hideLoadingScreen(); return false; @@ -229,11 +135,11 @@ class App extends Events { // from a domain other than our own, because things are likely to break. // (e.g. cross-domain requests) if (this.isInvalidLocation()) { - new app.views.Notif("InvalidLocation"); + new Notif("InvalidLocation"); } else { - if (this.config.sentry_dsn) { - Raven.config(this.config.sentry_dsn, { - release: this.config.release, + if (config.sentry_dsn) { + Raven.config(config.sentry_dsn, { + release: config.release, whitelistUrls: [/devdocs/], includePaths: [/devdocs/], ignoreErrors: [/NPObject/, /NS_ERROR/, /^null$/, /EvalError/], @@ -244,10 +150,6 @@ class App extends Events { }, shouldSendCallback: () => { try { - if (this.isInjectionError()) { - this.onInjectionError(); - return false; - } if (this.isAndroidWebview()) { return false; } @@ -278,12 +180,12 @@ class App extends Events { /** Boots in single-doc mode, with only the doc named on the body. */ bootOne() { - this.doc = new app.models.Doc(this.DOC); + this.doc = new Doc(this.DOC); this.docs.reset([this.doc]); this.doc.load(this.start.bind(this), this.onBootError.bind(this), { readCache: true, }); - new app.views.Notice("singleDoc", this.doc); + new Notice("singleDoc", this.doc); delete this.DOC; } @@ -448,7 +350,7 @@ class App extends Events { await Promise.all( Array.from( - { length: Math.min(docs.length, app.collections.Docs.CONCURRENCY) }, + { length: Math.min(docs.length, Docs.CONCURRENCY) }, next, ), ); @@ -502,11 +404,11 @@ class App extends Events { let visitCount = this.settings.get("count"); this.settings.set("count", ++visitCount); if (visitCount === 5) { - new app.views.Notif("Share", { autoHide: null }); + new Notif("Share", { autoHide: null }); } - new app.views.News(); - new app.views.Updates(); - return (this.updateChecker = new app.UpdateChecker()); + new News(); + new Updates(); + return (this.updateChecker = new UpdateChecker()); } /** Reloads the app, keeping the current path. */ @@ -555,7 +457,7 @@ class App extends Events { if (!tips.includes(tip)) { tips.push(tip); this.settings.setTips(tips); - new app.views.Tip(tip); + new Tip(tip); } } @@ -579,7 +481,7 @@ class App extends Events { return; } this.quotaExceeded = true; - new app.views.Notif("QuotaExceeded", { autoHide: null }); + new Notif("QuotaExceeded", { autoHide: null }); } /** @@ -594,7 +496,7 @@ class App extends Events { return; } this.cookieBlocked = true; - new app.views.Notif("CookieBlocked", { autoHide: null }); + new Notif("CookieBlocked", { autoHide: null }); Raven.captureMessage(`CookieBlocked/${key}`, { level: "warning", extra: { value, actual }, @@ -606,47 +508,18 @@ class App extends Events { if (this.cookieBlocked) { return; } - if (this.isInjectionError()) { - this.onInjectionError(); - } else if (this.isAppError(args[0], /** @type {string} */ (args[1]))) { + if (this.isAppError(args[0], /** @type {string} */ (args[1]))) { if (typeof this.previousErrorHandler === "function") { this.previousErrorHandler(...args); } this.hideLoadingScreen(); if (!this.errorNotif) { - this.errorNotif = new app.views.Notif("Error"); + this.errorNotif = new Notif("Error"); } this.errorNotif.show(); } } - /** Warns that an extension has broken the page. Once. */ - onInjectionError() { - if (!this.injectionError) { - this.injectionError = true; - alert(`\ -JavaScript code has been injected in the page which prevents DevDocs from running correctly. -Please check your browser extensions/addons. `); - Raven.captureMessage("injection error", { level: "info" }); - } - } - - /** - * @returns {boolean} Whether something replaced the app's globals — some - * browser extensions expect every page to use jQuery. - */ - isInjectionError() { - // Some browser extensions expect the entire web to use jQuery. - // I gave up trying to fight back. - return ( - window.$ !== app._$ || - window.$$ !== app._$$ || - window.page !== app._page || - typeof $.empty !== "function" || - typeof page.show !== "function" - ); - } - /** * @param {unknown} error * @param {string} [file] Where the error came from. @@ -698,23 +571,23 @@ Please check your browser extensions/addons. `); isMobile() { return this._isMobile != null ? this._isMobile - : (this._isMobile = app.views.Mobile.detect()); + : (this._isMobile = Mobile.detect()); } /** @returns {boolean} Whether the app is inside an Android webview. Decided once. */ isAndroidWebview() { return this._isAndroidWebview != null ? this._isAndroidWebview - : (this._isAndroidWebview = app.views.Mobile.detectAndroidWebview()); + : (this._isAndroidWebview = Mobile.detectAndroidWebview()); } /** @returns {boolean} Whether the app is being served from someone else's domain. */ isInvalidLocation() { return ( - this.config.env === "production" && - !location.host.startsWith(app.config.production_host) + config.env === "production" && + !location.host.startsWith(config.production_host) ); } } -this.app = new App(); +export const app = new App(); diff --git a/assets/javascripts/app/config.d.ts b/assets/javascripts/app/config.d.ts new file mode 100644 index 0000000000..e30766d46e --- /dev/null +++ b/assets/javascripts/app/config.d.ts @@ -0,0 +1,31 @@ +/** + * The build-time configuration, rendered by app/config.js.erb. + * + * The module is generated, so its shape is declared here rather than in JSDoc. + */ +export interface AppConfig { + db_filename: string; + /** Slugs enabled for a first-time visitor. */ + default_docs: string[]; + /** Alternative spellings, by the name they resolve to. */ + docs_aliases: Record; + /** Where the documentation files are served from. */ + docs_origin: string; + env: string; + history_cache_size: number; + index_filename: string; + max_results: number; + production_host: string; + /** The query parameter a search is read from. */ + search_param: string; + sentry_dsn: string; + /** Cache-busting stamp for the offline data. */ + version: number; + release: string; + mathml_stylesheet: string; + favicon_spritesheet: string; + service_worker_path: string; + service_worker_enabled: boolean; +} + +export const config: AppConfig; diff --git a/assets/javascripts/app/config.js.erb b/assets/javascripts/app/config.js.erb index f1362e4de2..f8c8ee5598 100644 --- a/assets/javascripts/app/config.js.erb +++ b/assets/javascripts/app/config.js.erb @@ -1,4 +1,7 @@ -app.config = { +// @ts-check + +/** @type {import('./config.js').AppConfig} */ +export const config = { db_filename: 'db.json', default_docs: <%= App.default_docs.to_json %>, docs_aliases: <%= App.docs_aliases.to_json %>, diff --git a/assets/javascripts/app/db.js b/assets/javascripts/app/db.js index 92e0b3bdf9..6c8aaf50a8 100644 --- a/assets/javascripts/app/db.js +++ b/assets/javascripts/app/db.js @@ -1,5 +1,11 @@ // @ts-check +import { app } from "./app.js"; +import { ajax } from "../lib/ajax.js"; +import { $ } from "../lib/util.js"; +/** @import { Doc } from "../models/doc.js" */ +/** @import { Entry } from "../models/entry.js" */ + /** * `DB#useIndexedDB` is a method that the instance shadows with the boolean it * returned, so the write needs a view of the instance that expects the value. @@ -41,7 +47,7 @@ const useIndexedDBOf = (db) => * together, so that a doc being installed can force an upgrade without * colliding with a schema change. */ -class DB { +export class DB { static NAME = "docs"; static VERSION = 15; @@ -800,7 +806,3 @@ class DB { return app.settings.get("schema"); } } - -// Registered on `app` so that the rest of the code can reach it; declared at -// the top level so that it can be named in a type. -app.DB = DB; diff --git a/assets/javascripts/app/offline_backup.js b/assets/javascripts/app/offline_backup.js index e8c2a839ff..7eae968d35 100644 --- a/assets/javascripts/app/offline_backup.js +++ b/assets/javascripts/app/offline_backup.js @@ -1,5 +1,8 @@ // @ts-check +import { app } from "./app.js"; +/** @import { Doc } from "../models/doc.js" */ + /** * One doc as it appears in a backup file. Unrelated to the Entry model: these * are the records the backup's `docs` array holds. @@ -27,7 +30,7 @@ * restore a backup after the browser evicted the data, or to move the * documentations to another computer without downloading them again. */ -class OfflineBackup { +export class OfflineBackup { static TYPE = "devdocs-offline"; static VERSION = 1; static MIME_TYPE = "application/json"; @@ -286,7 +289,3 @@ class OfflineBackup { return enabled; } } - -// Registered on `app` so that the rest of the code can reach it; declared at -// the top level so that it can be named in a type. -app.OfflineBackup = OfflineBackup; diff --git a/assets/javascripts/app/router.js b/assets/javascripts/app/router.js index a3f05df9c2..3c7d794718 100644 --- a/assets/javascripts/app/router.js +++ b/assets/javascripts/app/router.js @@ -1,5 +1,10 @@ // @ts-check +import { app } from "./app.js"; +import { Events } from "../lib/events.js"; +import { page } from "../lib/page.js"; +/** @import { Context } from "../lib/page.js" */ + /** * Maps paths to route events. * @@ -7,7 +12,7 @@ * order. A handler either triggers its route event and returns nothing, or * returns a path to redirect to, or calls `next` to fall through. */ -class Router extends Events { +export class Router extends Events { static routes = [ ["*", "before"], ["/", "root"], @@ -268,7 +273,3 @@ class Router extends Events { ); } } - -// Registered on `app` so that the rest of the code can reach it; declared at -// the top level so that it can be named in a type. -app.Router = Router; diff --git a/assets/javascripts/app/searcher.js b/assets/javascripts/app/searcher.js index 0be07d779f..055989c52b 100644 --- a/assets/javascripts/app/searcher.js +++ b/assets/javascripts/app/searcher.js @@ -4,6 +4,11 @@ // Match functions // +import { config } from "./config.js"; +import { Events } from "../lib/events.js"; +import { $ } from "../lib/util.js"; +/** @import { Model } from "../models/model.js" */ + let fuzzyRegexp, i, index, @@ -162,11 +167,11 @@ function scoreFuzzyMatch() { * module-level state rather than arguments, which is what keeps the inner * loop cheap. */ -class Searcher extends Events { +export class Searcher extends Events { static CHUNK_SIZE = 20000; static DEFAULTS = { - max_results: app.config.max_results, + max_results: config.max_results, fuzzy_min_length: 3, }; @@ -420,7 +425,7 @@ class Searcher extends Events { * Yields to the event loop between chunks. * * @param {() => void} fn - * @returns {number | void} The timeout handle, when there is one. + * @returns {ReturnType | void} The timeout handle, when there is one. */ delay(fn) { return (this.timeout = setTimeout(fn, 1)); @@ -440,15 +445,11 @@ class Searcher extends Events { } } -// Registered on `app` so that the rest of the code can reach it; declared at -// the top level so that it can be named in a type. -app.Searcher = Searcher; - /** * A searcher that runs to completion without yielding, and emits its results * once at the end. Used where the caller needs an answer before continuing. */ -class SynchronousSearcher extends app.Searcher { +export class SynchronousSearcher extends Searcher { /** Collects each matcher's results, instead of emitting them as it goes. */ match() { if (this.matcher) { @@ -488,7 +489,3 @@ class SynchronousSearcher extends app.Searcher { return fn(); } } - -// Registered on `app` so that the rest of the code can reach it; declared at -// the top level so that it can be named in a type. -app.SynchronousSearcher = SynchronousSearcher; diff --git a/assets/javascripts/app/serviceworker.js b/assets/javascripts/app/serviceworker.js index ee4d189eb9..0fc4890e84 100644 --- a/assets/javascripts/app/serviceworker.js +++ b/assets/javascripts/app/serviceworker.js @@ -1,15 +1,20 @@ // @ts-check +import { app } from "./app.js"; +import { config } from "./config.js"; +import { Events } from "../lib/events.js"; +import { $ } from "../lib/util.js"; + /** * Registers the service worker and reports when a new one is waiting. * * Emits `updateready` when an update is ready to take over, but only for * checks the user asked for. */ -class AppServiceWorker extends Events { +export class AppServiceWorker extends Events { /** @returns {boolean} Whether the browser supports service workers and the build enables them. */ static isEnabled() { - return !!navigator.serviceWorker && app.config.service_worker_enabled; + return !!navigator.serviceWorker && config.service_worker_enabled; } /** Registers the worker and starts watching for updates. */ @@ -20,7 +25,7 @@ class AppServiceWorker extends Events { this.notifyUpdate = true; navigator.serviceWorker - .register(app.config.service_worker_path, { scope: "/" }) + .register(config.service_worker_path, { scope: "/" }) .then( (registration) => this.updateRegistration(registration), (error) => console.error("Could not register service worker:", error), @@ -92,7 +97,3 @@ class AppServiceWorker extends Events { } } } - -// Registered on `app` so that the rest of the code can reach it; declared at -// the top level so that it can be named in a type. -app.ServiceWorker = AppServiceWorker; diff --git a/assets/javascripts/app/settings.js b/assets/javascripts/app/settings.js index 68e19adafa..9cdf963c75 100644 --- a/assets/javascripts/app/settings.js +++ b/assets/javascripts/app/settings.js @@ -1,5 +1,10 @@ // @ts-check +import { app } from "./app.js"; +import { config } from "./config.js"; +import { CookiesStore } from "../lib/cookies_store.js"; +import { $ } from "../lib/util.js"; + /** * A setting the user turns on or off. * @@ -47,7 +52,7 @@ * `PREFERENCE_KEYS` are the ones the user controls and that a backup carries; * `INTERNAL_KEYS` are the app's own bookkeeping and stay out of backups. */ -class Settings { +export class Settings { static PREFERENCE_KEYS = [ "hideDisabled", "hideIntro", @@ -163,7 +168,7 @@ class Settings { getDocs() { return ( /** @type {string | undefined} */ (this.store.get("docs"))?.split("/") || - app.config.default_docs + config.default_docs ); } @@ -276,7 +281,7 @@ class Settings { this.del("dark"); } this.setTheme(this.get("theme")); - for (var layout of app.Settings.LAYOUTS) { + for (var layout of Settings.LAYOUTS) { this.toggleLayout(layout, this.hasLayout(layout)); } this.initSidebarWidth(); @@ -307,7 +312,7 @@ class Settings { */ toggleLayout(layout, enable) { const { classList } = document.body; - // sidebar is always shown for settings; its state is updated in app.views.Settings + // sidebar is always shown for settings; its state is updated in SettingsView if (layout !== "_sidebar-hidden" || !app.router?.isSettings) { classList.toggle(layout, enable); } @@ -322,7 +327,3 @@ class Settings { } } } - -// Registered on `app` so that the rest of the code can reach it; declared at -// the top level so that it can be named in a type. -app.Settings = Settings; diff --git a/assets/javascripts/app/shortcuts.js b/assets/javascripts/app/shortcuts.js index 79e0846211..addb525ec3 100644 --- a/assets/javascripts/app/shortcuts.js +++ b/assets/javascripts/app/shortcuts.js @@ -1,5 +1,9 @@ // @ts-check +import { app } from "./app.js"; +import { Events } from "../lib/events.js"; +import { $ } from "../lib/util.js"; + /** * A key event whose target is read loosely: the handlers check for form-field * properties that only some elements have. @@ -12,7 +16,7 @@ * * Handlers return `false` to swallow the event; anything else lets it through. */ -class Shortcuts extends Events { +export class Shortcuts extends Events { /** Starts listening for key events. */ constructor() { super(); @@ -345,7 +349,3 @@ class Shortcuts extends Events { } } } - -// Registered on `app` so that the rest of the code can reach it; declared at -// the top level so that it can be named in a type. -app.Shortcuts = Shortcuts; diff --git a/assets/javascripts/app/update_checker.js b/assets/javascripts/app/update_checker.js index 6e24d5e5d3..911f981ebc 100644 --- a/assets/javascripts/app/update_checker.js +++ b/assets/javascripts/app/update_checker.js @@ -1,7 +1,12 @@ // @ts-check +import { app } from "./app.js"; +import { ajax } from "../lib/ajax.js"; +import { $ } from "../lib/util.js"; +import { Notif } from "../views/misc/notif.js"; + /** Watches for new builds of the app and new versions of the installed docs. */ -class UpdateChecker { +export class UpdateChecker { /** Starts watching for new builds and checks the docs once. */ constructor() { this.lastCheck = Date.now(); @@ -36,7 +41,7 @@ class UpdateChecker { /** Offers the user a reload. */ onUpdateReady() { - new app.views.Notif("UpdateReady", { autoHide: null }); + new Notif("UpdateReady", { autoHide: null }); } /** Updates the installed docs, or offers to when updates are manual. */ @@ -54,7 +59,7 @@ class UpdateChecker { /** Offers the user a doc update. */ onDocsUpdateReady() { - new app.views.Notif("UpdateDocs", { autoHide: null }); + new Notif("UpdateDocs", { autoHide: null }); } /** Re-checks when the tab is focused, at most every six hours. */ @@ -65,7 +70,3 @@ class UpdateChecker { } } } - -// Registered on `app` so that the rest of the code can reach it; declared at -// the top level so that it can be named in a type. -app.UpdateChecker = UpdateChecker; diff --git a/assets/javascripts/application.js b/assets/javascripts/application.js index 4196c9a3eb..51d89d2d2d 100644 --- a/assets/javascripts/application.js +++ b/assets/javascripts/application.js @@ -1,41 +1,20 @@ // @ts-check -//= require_tree ./vendor - -//= require lib/license -//= require_tree ./lib - -//= require app/app -//= require app/config -//= require_tree ./app - -//= require collections/collection -//= require_tree ./collections - -//= require models/model -//= require_tree ./models - -//= require views/view -//= require_tree ./views - -//= require_tree ./templates - -//= link_tree ../images/sprites - -//= require tracking - -/** - * Boots the app once the document is ready, retrying until the body exists — - * the bundle is loaded in the head, so it can run before the body is parsed. +import { app } from "./app/app.js"; +import "./tracking.js"; + +/* + * Copyright 2013-2026 Thibaut Courouble and other contributors + * + * This source code is licensed under the terms of the Mozilla + * Public License, v. 2.0, a copy of which may be obtained at: + * http://mozilla.org/MPL/2.0/ */ -var init = function () { - document.removeEventListener("DOMContentLoaded", init, false); - if (document.body) { - return app.init(); - } else { - return setTimeout(init, 42); - } -}; +// The entry module. Everything else is reached through imports from here; the +// import map pins each module to its content-digested URL, so the whole graph +// is fetched from immutable, individually cacheable files. -document.addEventListener("DOMContentLoaded", init, false); +// Module scripts are deferred, so the document has been parsed by the time +// this runs and `document.body` is always there. +app.init(); diff --git a/assets/javascripts/collections/collection.js b/assets/javascripts/collections/collection.js index f2fb8bb71c..ebf2ea5b23 100644 --- a/assets/javascripts/collections/collection.js +++ b/assets/javascripts/collections/collection.js @@ -1,15 +1,17 @@ // @ts-check +import { Model } from "../models/model.js"; + /** * An ordered list of models. * - * Subclasses name the model they hold with a static `model` property, which is - * looked up in `app.models` so that the collection doesn't have to reference - * the class directly, and declare which model that is with `@extends`. + * Subclasses return the model class they hold from `model()`, and declare + * which model that is with `@extends`. It is a method rather than a field so + * that a collection and its model can import each other. * * @template {Model} [T=Model] */ -class Collection { +export class Collection { /** @param {unknown[]} [objects] Models, attribute objects, or other collections. */ constructor(objects) { if (objects == null) { @@ -19,17 +21,12 @@ class Collection { } /** - * The model class this collection holds. + * The model class this collection holds. Implemented by the subclass. * * @returns {new (attributes?: Record) => T} */ model() { - const { model } = /** @type {{ model: keyof App["models"] }} */ ( - /** @type {unknown} */ (this.constructor) - ); - return /** @type {new (attributes?: Record) => T} */ ( - /** @type {unknown} */ (app.models[model]) - ); + throw new Error(`${this.constructor.name} doesn't declare a model`); } /** @@ -55,13 +52,13 @@ class Collection { * @param {T | T[] | Collection | Record} object */ add(object) { - if (object instanceof app.Model) { + if (object instanceof Model) { this.models.push(object); } else if (object instanceof Array) { for (var obj of object) { this.add(obj); } - } else if (object instanceof app.Collection) { + } else if (object instanceof Collection) { this.models.push(...(object.all() || [])); } else { this.models.push(new (this.model())(object)); @@ -144,7 +141,3 @@ class Collection { return i; } } - -// Registered on `app` so that the rest of the code can reach it; declared at -// the top level so that subclasses extend a type rather than `any`. -app.Collection = Collection; diff --git a/assets/javascripts/collections/docs.js b/assets/javascripts/collections/docs.js index 6cca908ccd..6c66f9fda2 100644 --- a/assets/javascripts/collections/docs.js +++ b/assets/javascripts/collections/docs.js @@ -1,10 +1,19 @@ // @ts-check +import { app } from "../app/app.js"; +import { Collection } from "./collection.js"; +import { $ } from "../lib/util.js"; +import { Doc } from "../models/doc.js"; +/** @import { DocLoadOptions, InstallStatus } from "../models/doc.js" */ + /** Every doc the app knows about, enabled or not. * * @extends {Collection} */ -class Docs extends Collection { - static model = "Doc"; +export class Docs extends Collection { + /** @inheritdoc */ + model() { + return Doc; + } static NORMALIZE_VERSION_RGX = /\.(\d)$/; static NORMALIZE_VERSION_SUB = ".0$1"; @@ -157,7 +166,3 @@ class Docs extends Collection { }); } } - -// Registered on `app` so that the rest of the code can reach it; declared at -// the top level so that it can be named in a type. -app.collections.Docs = Docs; diff --git a/assets/javascripts/collections/entries.js b/assets/javascripts/collections/entries.js index 0a70c0b277..505e4c1e7c 100644 --- a/assets/javascripts/collections/entries.js +++ b/assets/javascripts/collections/entries.js @@ -1,12 +1,14 @@ // @ts-check +import { Collection } from "./collection.js"; +import { Entry } from "../models/entry.js"; + /** Every searchable entry, across every enabled doc. * * @extends {Collection} */ -class Entries extends Collection { - static model = "Entry"; +export class Entries extends Collection { + /** @inheritdoc */ + model() { + return Entry; + } } - -// Registered on `app` so that the rest of the code can reach it; declared at -// the top level so that it can be named in a type. -app.collections.Entries = Entries; diff --git a/assets/javascripts/collections/types.js b/assets/javascripts/collections/types.js index 6ec2517531..d9abdba061 100644 --- a/assets/javascripts/collections/types.js +++ b/assets/javascripts/collections/types.js @@ -1,10 +1,16 @@ // @ts-check +import { Collection } from "./collection.js"; +import { Type } from "../models/type.js"; + /** The types within one doc, e.g. "Methods" or "Guides". * * @extends {Collection} */ -class Types extends Collection { - static model = "Type"; +export class Types extends Collection { + /** @inheritdoc */ + model() { + return Type; + } static GUIDES_RGX = /(^|\()(guides?|tutorials?|reference|book|getting\ started|manual|examples)($|[\):])/i; static APPENDIX_RGX = /appendix/i; @@ -39,7 +45,3 @@ class Types extends Collection { } } } - -// Registered on `app` so that the rest of the code can reach it; declared at -// the top level so that it can be named in a type. -app.collections.Types = Types; diff --git a/assets/javascripts/debug.js b/assets/javascripts/debug.js index 335f7c3b99..af9f635e22 100644 --- a/assets/javascripts/debug.js +++ b/assets/javascripts/debug.js @@ -1,13 +1,17 @@ // @ts-check -// Timing instrumentation, loaded in place of the app's own bundle during -// development. Wraps the boot sequence and the searcher in console timers, and -// adds `viewTree()` for inspecting which views are active. +// Timing instrumentation, loaded alongside the app during development. Wraps +// the boot sequence and the searcher in console timers, and exposes +// `viewTree()` on `window` for inspecting which views are active. // // App // +import { app } from "./app/app.js"; +import { Searcher } from "./app/searcher.js"; +/** @import { View } from "./views/view.js" */ + const _init = app.init; app.init = function () { console.time("Init"); @@ -28,49 +32,54 @@ app.start = function () { // Searcher // -/** The searcher, with each matcher's pass timed. */ -app.Searcher = class TimingSearcher extends app.Searcher { - /** Opens the timing group for this query. */ - setup() { - console.groupCollapsed(`Search: ${this.query}`); - console.time("Total"); - return super.setup(); - } +// The search views import `Searcher` directly, so the timing subclass can't be +// swapped in under them any more; the timers are patched onto the prototype. - /** Closes the previous matcher's timer before moving on. */ - match() { - if (this.matcher) { - console.timeEnd(this.matcher.name); - } - return super.match(); - } +const _setup = Searcher.prototype.setup; +/** Opens the timing group for this query. */ +Searcher.prototype.setup = function () { + console.groupCollapsed(`Search: ${this.query}`); + console.time("Total"); + return _setup.call(this); +}; - /** Starts a timer for the matcher about to run. */ - setupMatcher() { - console.time(this.matcher.name); - return super.setupMatcher(); +const _match = Searcher.prototype.match; +/** Closes the previous matcher's timer before moving on. */ +Searcher.prototype.match = function () { + if (this.matcher) { + console.timeEnd(this.matcher.name); } + return _match.call(this); +}; - /** Reports the result count and closes the group. */ - end() { - console.log(`Results: ${this.totalResults}`); - console.timeEnd("Total"); - console.groupEnd(); - return super.end(); - } +const _setupMatcher = Searcher.prototype.setupMatcher; +/** Starts a timer for the matcher about to run. */ +Searcher.prototype.setupMatcher = function () { + console.time(this.matcher.name); + return _setupMatcher.call(this); +}; - /** Closes the group when a search is abandoned part-way. */ - kill() { - if (this.timeout) { - if (this.matcher) { - console.timeEnd(this.matcher.name); - } - console.groupEnd(); - console.timeEnd("Total"); - console.warn("Killed"); +const _end = Searcher.prototype.end; +/** Reports the result count and closes the group. */ +Searcher.prototype.end = function () { + console.log(`Results: ${this.totalResults}`); + console.timeEnd("Total"); + console.groupEnd(); + return _end.call(this); +}; + +const _kill = Searcher.prototype.kill; +/** Closes the group when a search is abandoned part-way. */ +Searcher.prototype.kill = function () { + if (this.timeout) { + if (this.matcher) { + console.timeEnd(this.matcher.name); } - return super.kill(); + console.groupEnd(); + console.timeEnd("Total"); + console.warn("Killed"); } + return _kill.call(this); }; // @@ -86,7 +95,7 @@ app.Searcher = class TimingSearcher extends app.Searcher { * @param {unknown[]} [visited] The views already printed, so that the shared ones * aren't walked twice. */ -this.viewTree = function (view, level, visited) { +function viewTree(view, level, visited) { if (view == null) { view = app.document; } @@ -112,15 +121,18 @@ this.viewTree = function (view, level, visited) { var value = view[key]; if (key !== "view" && value) { if (typeof value === "object" && value.setupElement) { - this.viewTree(value, level + 1, visited); + viewTree(value, level + 1, visited); } else if (value.constructor.toString().match(/Object\(\)/)) { for (var k of Object.keys(value || {})) { var v = value[k]; if (v && typeof v === "object" && v.setupElement) { - this.viewTree(v, level + 1, visited); + viewTree(v, level + 1, visited); } } } } } -}; +} + +// Reachable from the console, where the module scope isn't. +window.viewTree = viewTree; diff --git a/assets/javascripts/docs.d.ts b/assets/javascripts/docs.d.ts new file mode 100644 index 0000000000..e9d7014c46 --- /dev/null +++ b/assets/javascripts/docs.d.ts @@ -0,0 +1,5 @@ +/** + * The doc manifest, rendered by docs.js.erb. The module is imported only for + * its side effect: it sets `app.DOCS` before the app boots. + */ +export {}; diff --git a/assets/javascripts/docs.js.erb b/assets/javascripts/docs.js.erb index 644a6066b4..401f90d83a 100644 --- a/assets/javascripts/docs.js.erb +++ b/assets/javascripts/docs.js.erb @@ -1,2 +1,4 @@ //= depend_on docs.json +import { app } from "./app/app.js"; + app.DOCS = <%= File.read App.docs_manifest_path %>; diff --git a/assets/javascripts/globals.d.ts b/assets/javascripts/globals.d.ts index 9df3e4b2a3..40cf8cd5ca 100644 --- a/assets/javascripts/globals.d.ts +++ b/assets/javascripts/globals.d.ts @@ -1,292 +1,298 @@ /** * Ambient declarations for the assets. * - * Sprockets concatenates every file in `application.js` into a single script, - * so the assets share one global scope. TypeScript picks up top-level `class`, - * `function`, `const`, `let` and `var` declarations across files on its own; - * this file covers the two things it cannot see: + * The assets are ES modules, so each file's own types travel with its exports. + * This file covers what has no module to live in: * - * - globals created by assigning to `this` at the top level of a file, and - * - the vendored libraries, which are excluded from the typecheck. + * - the vendored libraries, which are excluded from the typecheck, + * - the shapes the models and views acquire at runtime, and + * - the DOM-event aliases the views share. * - * The types themselves live in JSDoc next to the code that implements them. + * Everything else lives in JSDoc next to the code that implements it. */ -// --- Globals defined by assigning to `this` at the top level --- +export {}; + +declare global { + // --- Vendored libraries (assets/javascripts/vendor) --- + + /** Cookies.js — github.com/ScottHamper/Cookies */ + const Cookies: { + (key: string): string | undefined; + (key: string, value: string, options?: CookieOptions): typeof Cookies; + get(key: string): string | undefined; + set(key: string, value: string, options?: CookieOptions): typeof Cookies; + expire(key: string, options?: CookieOptions): typeof Cookies; + defaults: CookieOptions; + enabled: boolean; + }; + + interface CookieOptions { + path?: string; + domain?: string; + expires?: number | string | Date; + secure?: boolean; + } + + /** Raven.js — the Sentry browser client. Only the parts the app uses. */ + const Raven: { + config(dsn: string, options?: Record): typeof Raven; + install(): typeof Raven; + captureException(error: unknown, options?: Record): void; + captureMessage(message: string, options?: Record): void; + }; + + /** Prism.js — only the parts the app uses. */ + const Prism: { + highlightElement(element: Element, async?: boolean): void; + }; + + // --- View events --- -/** lib/util.js — queries one element, and carries the DOM helpers. */ -declare var $: DollarQuery & DollarHelpers; - -/** lib/util.js — queries every matching element. */ -declare var $$: DollarQueryAll; + /** + * A DOM event as a view handler reads it. + * + * lib.dom types `Event#target` as a bare `EventTarget`, which carries none of + * the element properties a handler reads. The handlers are bound to elements, + * so the target is narrowed to one; the form variants narrow it further, for + * the handlers bound to a field. + */ + type ViewEvent = Event & { target: HTMLElement; currentTarget: HTMLElement }; + type ViewMouseEvent = MouseEvent & { target: HTMLElement; currentTarget: HTMLElement }; + type ViewKeyboardEvent = KeyboardEvent & { target: HTMLElement; currentTarget: HTMLElement }; + type ViewInputEvent = Event & { target: HTMLInputElement; currentTarget: HTMLElement }; -/** lib/local_storage_store.js — a JSON-encoded wrapper around localStorage. */ -declare var LocalStorageStore: new () => LocalStorageStore; + // --- Analytics, loaded at runtime by tracking.js --- -/** lib/page.js — the router. */ -declare var page: PageFn & PageHelpers; + /** Google Analytics, once analytics.js has loaded. */ + var ga: (...args: unknown[]) => void; -/** lib/page.js — expires the analytics cookies. */ -declare var resetAnalytics: () => void; + /** Gauges' command queue. */ + var _gauges: unknown[] | undefined; -/** app/app.js — the application singleton. */ -declare var app: App; + // --- Augmentations --- -/** lib/favicon.js — swaps the favicon for the doc's icon. */ -declare var setFaviconForDoc: (doc: unknown) => void; + interface Window { + /** Present when running inside Electron. */ + readonly process?: { versions?: Record }; -/** lib/favicon.js — restores the default favicon. */ -declare var resetFavicon: () => void; + /** Set by vendor/mathml.js once it has probed for MathML support. */ + supportsMathML?: boolean; -/** debug.js — prints the view tree, with each view's activation state. */ -declare var viewTree: (view?: unknown, level?: number, visited?: unknown[]) => void; + /** Gauges' command queue. */ + _gauges?: unknown[]; -// --- Vendored libraries (assets/javascripts/vendor) --- + /** debug.js — prints the view tree, with each view's activation state. */ + viewTree?: (view?: unknown, level?: number, visited?: unknown[]) => void; + } -/** Cookies.js — github.com/ScottHamper/Cookies */ -declare const Cookies: { - (key: string): string | undefined; - (key: string, value: string, options?: CookieOptions): typeof Cookies; - get(key: string): string | undefined; - set(key: string, value: string, options?: CookieOptions): typeof Cookies; - expire(key: string, options?: CookieOptions): typeof Cookies; - defaults: CookieOptions; - enabled: boolean; -}; + interface Navigator { + /** Global Privacy Control. Not in lib.dom yet. */ + readonly globalPrivacyControl?: boolean; + } -interface CookieOptions { - path?: string; - domain?: string; - expires?: number | string | Date; - secure?: boolean; + interface XMLHttpRequest { + /** Set by lib/ajax.js so that the timeout can be cleared when it settles. */ + timer?: ReturnType; + } } -/** Raven.js — the Sentry browser client. Only the parts the app uses. */ -declare const Raven: { - config(dsn: string, options?: Record): typeof Raven; - install(): typeof Raven; - captureException(error: unknown, options?: Record): void; - captureMessage(message: string, options?: Record): void; -}; - -/** Prism.js — only the parts the app uses. */ -declare const Prism: { - highlightElement(element: Element, async?: boolean): void; -}; +// --- Model and view shapes --- -// --- Model attributes --- - -/** - * The models' own properties. - * - * Model copies the attributes it is constructed with onto itself, so a model's - * properties are decided by the manifest rather than declared on the class, - * and a field declaration would run after `super()` and blank them out again. - * These interfaces merge into the classes instead. - * - * Merging suppresses the inference of `this.x = ...`, so the properties each - * model derives for itself are declared here too. +/* + * The models copy their attributes onto themselves and the views have their + * `elements` selectors resolved by the base constructor, so neither can + * declare those properties as fields without blanking them out again. They + * are merged into each class from here instead. */ -interface Doc { - /** From the manifest. */ - name: string; - /** From the manifest. Carries the version, e.g. `html~5`. */ - slug: string; - /** From the manifest. The scraper that produced the doc. */ - type: string; +declare module "./models/doc.js" { /** - * From the manifest. Absent for docs that aren't versioned at all, and empty - * for the doc holding the latest version. + * The models' own properties. + * + * Model copies the attributes it is constructed with onto itself, so a model's + * properties are decided by the manifest rather than declared on the class, + * and a field declaration would run after `super()` and blank them out again. + * These interfaces merge into the classes instead. + * + * Merging suppresses the inference of `this.x = ...`, so the properties each + * model derives for itself are declared here too. */ - version?: string; - /** From the manifest. The upstream version the doc was built from. */ - release?: string; - /** From the manifest. When the doc was last built; also its cache key. */ - mtime?: number; - /** From the manifest. The offline database's size, in bytes. */ - db_size?: number; - /** From the manifest. The documentation's own home and source URLs. */ - links?: Record; - /** From the manifest. The licence notice shown on the About page. */ - attribution?: string; - /** - * From the manifest, which always emits the key and sets it to null when the - * doc has no alias. - */ - alias: string | null; - - /** Derived: the slug without its version. */ - slug_without_version: string; - /** Derived: the name with the version appended. */ - fullName: string; - /** Derived: which sprite to show. */ - icon: string; - /** Derived: the version up to its first space. */ - short_version?: string; - /** Derived: what the searcher matches against. */ - text: string | string[]; - - /** The doc's entries, once its index has loaded. */ - entries: Entries; - /** The doc's types, once its index has loaded. */ - types: Types; - /** The entry standing for the doc itself, built on demand. */ - entry?: Entry; - /** Set while an install or uninstall is running. */ - installing?: boolean | null; -} - -interface Entry { - /** From the doc's index. */ - name: string; - /** From the doc's index. Relative to the doc, and may carry a hash. */ - path: string; - /** From the doc's index. The name of the type the entry belongs to. */ - type?: string; - /** Set by the doc when it builds its entries. */ - doc: Doc; - /** Derived: what the searcher matches against. */ - text: string | string[]; -} - -interface Type { - /** From the doc's index. */ - name: string; - /** From the doc's index. */ - slug: string; - /** From the doc's index. How many entries it holds. */ - count: number; - /** Set by the doc when it builds its types. */ - doc: Doc; -} - -/** - * The properties the `elements` static injects. - * - * The base class resolves those selectors onto the instance from inside its - * own constructor, before a subclass's field initializers would run, so they - * can't be declared as fields without being blanked out again. These - * interfaces merge them into the classes instead. - * - * Merging suppresses the inference of `this.x = ...`, so each view's own - * properties are declared here too. - */ - -/** - * The two base views whose subclasses supply a hook the base calls. Declaring - * the hook here is what lets the base reference it. - */ -interface PaginatedList { - /** Implemented by the subclass: renders one page of rows. */ - render(data: unknown[]): string; - data: unknown[]; - page: number; + interface Doc { + /** From the manifest. */ + name: string; + /** From the manifest. Carries the version, e.g. `html~5`. */ + slug: string; + /** From the manifest. The scraper that produced the doc. */ + type: string; + /** + * From the manifest. Absent for docs that aren't versioned at all, and empty + * for the doc holding the latest version. + */ + version?: string; + /** From the manifest. The upstream version the doc was built from. */ + release?: string; + /** From the manifest. When the doc was last built; also its cache key. */ + mtime?: number; + /** From the manifest. The offline database's size, in bytes. */ + db_size?: number; + /** From the manifest. The documentation's own home and source URLs. */ + links?: Record; + /** From the manifest. The licence notice shown on the About page. */ + attribution?: string; + /** + * From the manifest, which always emits the key and sets it to null when the + * doc has no alias. + */ + alias: string | null; + + /** Derived: the slug without its version. */ + slug_without_version: string; + /** Derived: the name with the version appended. */ + fullName: string; + /** Derived: which sprite to show. */ + icon: string; + /** Derived: the version up to its first space. */ + short_version?: string; + /** Derived: what the searcher matches against. */ + text: string | string[]; + + /** The doc's entries, once its index has loaded. */ + entries: import("./collections/entries.js").Entries; + /** The doc's types, once its index has loaded. */ + types: import("./collections/types.js").Types; + /** The entry standing for the doc itself, built on demand. */ + entry?: import("./models/entry.js").Entry; + /** Set while an install or uninstall is running. */ + installing?: boolean | null; + } } -interface BasePage { - /** Implemented by the subclass, when it has anything to do after rendering. */ - afterRender?(): void; - entry: Entry; - highlightNodes: HTMLElement[]; - nodesPerFrame: number; - previousTiming: number | null; +declare module "./models/entry.js" { + interface Entry { + /** From the doc's index. */ + name: string; + /** From the doc's index. Relative to the doc, and may carry a hash. */ + path: string; + /** From the doc's index. The name of the type the entry belongs to. */ + type?: string; + /** Set by the doc when it builds its entries. */ + doc: import("./models/doc.js").Doc; + /** Derived: what the searcher matches against. */ + text: string | string[]; + } } -interface Mobile { - /** From `elements`. */ - body: HTMLElement; - /** From `elements`. */ - content: HTMLElement; - /** From `elements`. */ - sidebar: HTMLElement; - /** From `elements`. */ - docPicker: HTMLElement; - - back: HTMLElement; - forward: HTMLElement; - toggleSidebar: HTMLElement; - docPickerTab: HTMLElement; - settingsTab: HTMLElement; - contentTop: number; - sidebarTop: number; +declare module "./models/type.js" { + interface Type { + /** From the doc's index. */ + name: string; + /** From the doc's index. */ + slug: string; + /** From the doc's index. How many entries it holds. */ + count: number; + /** Set by the doc when it builds its types. */ + doc: import("./models/doc.js").Doc; + } } -interface SettingsView { - /** From `elements`. */ - sidebar: HTMLElement; - /** From `elements`. */ - saveBtn: HTMLElement; - /** From `elements`. */ - backBtn: HTMLElement; - - docPicker: DocPicker; - saving?: boolean; +declare module "./views/list/paginated_list.js" { + /** + * The two base views whose subclasses supply a hook the base calls. Declaring + * the hook here is what lets the base reference it. + */ + interface PaginatedList { + /** Implemented by the subclass: renders one page of rows. */ + render(data: unknown[]): string; + data: unknown[]; + page: number; + } } -interface Search { - /** From `elements`. */ - input: HTMLInputElement; - /** From `elements`. */ - resetLink: HTMLElement; - - scope: SearchScope; - searcher: Searcher; - value: string; - hasResults: boolean | null; - flags: { urlSearch?: boolean, initialResults?: boolean }; +declare module "./views/pages/base.js" { + interface BasePage { + /** Implemented by the subclass, when it has anything to do after rendering. */ + afterRender?(): void; + entry: import("./models/entry.js").Entry; + highlightNodes: HTMLElement[]; + nodesPerFrame: number; + previousTiming: number | null; + } } -interface SearchScope { - /** From `elements`. */ - input: HTMLInputElement; - /** From `elements`. */ - tag: HTMLElement; - - doc: Doc | null; - placeholder: string; - searcher: SynchronousSearcher; +declare module "./views/layout/mobile.js" { + interface Mobile { + /** From `elements`. */ + body: HTMLElement; + /** From `elements`. */ + content: HTMLElement; + /** From `elements`. */ + sidebar: HTMLElement; + /** From `elements`. */ + docPicker: HTMLElement; + + back: HTMLElement; + forward: HTMLElement; + toggleSidebar: HTMLElement; + docPickerTab: HTMLElement; + settingsTab: HTMLElement; + contentTop: number; + sidebarTop: number; + } } -interface DocList { - /** From `elements`. */ - disabledTitle: HTMLElement; - /** From `elements`. */ - disabledList: HTMLElement; - - lists: Record; - listFocus: ListFocus; - listFold: ListFold; - listSelect: ListSelect; +declare module "./views/layout/settings.js" { + interface SettingsView { + /** From `elements`. */ + sidebar: HTMLElement; + /** From `elements`. */ + saveBtn: HTMLElement; + /** From `elements`. */ + backBtn: HTMLElement; + + docPicker: import("./views/sidebar/doc_picker.js").DocPicker; + saving?: boolean; + } } -// --- Analytics, loaded at runtime by tracking.js --- - -/** Google Analytics, once analytics.js has loaded. */ -declare var ga: (...args: unknown[]) => void; - -/** Gauges' command queue. */ -declare var _gauges: unknown[] | undefined; - -// --- Augmentations --- - -interface Window { - /** Present when running inside Electron. */ - readonly process?: { versions?: Record }; - - /** Set by vendor/mathml.js once it has probed for MathML support. */ - supportsMathML?: boolean; - - /** Gauges' command queue. */ - _gauges?: unknown[]; +declare module "./views/search/search.js" { + interface Search { + /** From `elements`. */ + input: HTMLInputElement; + /** From `elements`. */ + resetLink: HTMLElement; + + scope: import("./views/search/search_scope.js").SearchScope; + searcher: import("./app/searcher.js").Searcher; + value: string; + hasResults: boolean | null; + flags: { urlSearch?: boolean, initialResults?: boolean }; + } } -interface Navigator { - /** Global Privacy Control. Not in lib.dom yet. */ - readonly globalPrivacyControl?: boolean; +declare module "./views/search/search_scope.js" { + interface SearchScope { + /** From `elements`. */ + input: HTMLInputElement; + /** From `elements`. */ + tag: HTMLElement; + + doc: import("./models/doc.js").Doc | null; + placeholder: string; + searcher: import("./app/searcher.js").SynchronousSearcher; + } } -interface XMLHttpRequest { - /** Set by lib/ajax.js so that the timeout can be cleared when it settles. */ - timer?: number; +declare module "./views/sidebar/doc_list.js" { + interface DocList { + /** From `elements`. */ + disabledTitle: HTMLElement; + /** From `elements`. */ + disabledList: HTMLElement; + + lists: Record; + listFocus: import("./views/list/list_focus.js").ListFocus; + listFold: import("./views/list/list_fold.js").ListFold; + listSelect: import("./views/list/list_select.js").ListSelect; + } } diff --git a/assets/javascripts/lib/ajax.js b/assets/javascripts/lib/ajax.js index fc559f75fe..32d6802ec1 100644 --- a/assets/javascripts/lib/ajax.js +++ b/assets/javascripts/lib/ajax.js @@ -32,7 +32,7 @@ const MIME_TYPES = { * synchronous request returns the parsed response instead, but nothing asks * for one. */ -function ajax(options) { +export function ajax(options) { applyDefaults(options); serializeData(options); diff --git a/assets/javascripts/lib/cookies_store.js b/assets/javascripts/lib/cookies_store.js index a9e3d00908..a9580202f7 100644 --- a/assets/javascripts/lib/cookies_store.js +++ b/assets/javascripts/lib/cookies_store.js @@ -15,7 +15,7 @@ * * @typedef {string | number | undefined} CookieValue */ -class CookiesStore { +export class CookiesStore { static INT = /^\d+$/; /** diff --git a/assets/javascripts/lib/events.js b/assets/javascripts/lib/events.js index e4dab7ec78..8cf56da049 100644 --- a/assets/javascripts/lib/events.js +++ b/assets/javascripts/lib/events.js @@ -12,7 +12,7 @@ * * @typedef {(...args: unknown[]) => void} EventCallback */ -class Events { +export class Events { /** * Registered callbacks, keyed by event name. Created on first `on` call. * diff --git a/assets/javascripts/lib/favicon.js b/assets/javascripts/lib/favicon.js index 608d0a1972..6e3de80e17 100644 --- a/assets/javascripts/lib/favicon.js +++ b/assets/javascripts/lib/favicon.js @@ -1,5 +1,9 @@ // @ts-check +import { app } from "../app/app.js"; +import { config } from "../app/config.js"; +import { $ } from "./util.js"; + /** * The favicon the page was served with, read the first time a doc sets one. * @@ -44,7 +48,7 @@ const withImage = function (url, action) { * * @param {{ slug: string }} doc */ -this.setFaviconForDoc = function (doc) { +export const setFaviconForDoc = function (doc) { if (currentSlug === doc.slug || app.settings.get("noDocSpecificIcon")) { return; } @@ -74,7 +78,7 @@ this.setFaviconForDoc = function (doc) { return; } - const bgUrl = app.config.favicon_spritesheet; + const bgUrl = config.favicon_spritesheet; const sourceSize = 16; const sourceX = Math.abs(parseInt(backgroundPositionX.slice(0, -2))); const sourceY = Math.abs(parseInt(backgroundPositionY.slice(0, -2))); @@ -120,7 +124,7 @@ this.setFaviconForDoc = function (doc) { }; /** Puts the default favicon back, if a doc replaced it. */ -this.resetFavicon = function () { +export const resetFavicon = function () { if (defaultUrl !== null && currentSlug !== null) { /** @type {HTMLLinkElement} */ ($('link[rel="icon"]')).href = defaultUrl; return (currentSlug = null); diff --git a/assets/javascripts/lib/license.js b/assets/javascripts/lib/license.js deleted file mode 100644 index 100982bc74..0000000000 --- a/assets/javascripts/lib/license.js +++ /dev/null @@ -1,9 +0,0 @@ -// @ts-check - -/* - * Copyright 2013-2026 Thibaut Courouble and other contributors - * - * This source code is licensed under the terms of the Mozilla - * Public License, v. 2.0, a copy of which may be obtained at: - * http://mozilla.org/MPL/2.0/ - */ diff --git a/assets/javascripts/lib/local_storage_store.js b/assets/javascripts/lib/local_storage_store.js index 37a0c0c210..ee28bc16bf 100644 --- a/assets/javascripts/lib/local_storage_store.js +++ b/assets/javascripts/lib/local_storage_store.js @@ -18,7 +18,7 @@ * unavailable (private browsing, blocked cookies, quota exhausted) and reports * failure by returning `undefined`. */ -this.LocalStorageStore = class LocalStorageStore { +export const LocalStorageStore = class LocalStorageStore { /** * @param {string} key * @returns {unknown} The stored value, or `undefined` if it is missing or unreadable. diff --git a/assets/javascripts/lib/page.js b/assets/javascripts/lib/page.js index a5d44eb5f7..5b469e8a83 100644 --- a/assets/javascripts/lib/page.js +++ b/assets/javascripts/lib/page.js @@ -1,3 +1,8 @@ +import { app } from "../app/app.js"; +import { config } from "../app/config.js"; +import { $ } from "./util.js"; +import { Notif } from "../views/misc/notif.js"; + /* * Based on github.com/visionmedia/page.js * Licensed under the MIT license @@ -79,7 +84,7 @@ const callbacks = []; // The helpers are attached to `page` below, so the function on its own doesn't // yet satisfy the type the global is declared with. -this.page = /** @type {PageFn & PageHelpers} */ ( +export const page = /** @type {PageFn & PageHelpers} */ ( /** @type {PageFn} */ ( function (value, fn) { if (typeof value === "function") { @@ -102,7 +107,7 @@ page.start = function (options) { } if (!running) { running = true; - // The app restores scroll positions itself (see app.views.Content), which + // The app restores scroll positions itself (see views/content/content.js), which // the browser's automatic restoration would race with and override. if ("scrollRestoration" in history) { history.scrollRestoration = "manual"; @@ -177,7 +182,7 @@ page.canGoForward = () => !Context.isLastState(currentState); const currentPath = () => location.pathname + location.search + location.hash; -class Context { +export class Context { /** * The number of states created so far; also the ID of the next state. */ @@ -512,7 +517,7 @@ page.track = function (fn) { }; var track = function () { - if (app.config.env !== "production") { + if (config.env !== "production") { return; } if (navigator.doNotTrack === "1") { @@ -533,12 +538,12 @@ var track = function () { // Only ask for consent once per browser session Cookies.set("analyticsConsentAsked", "1"); - new app.views.Notif("AnalyticsConsent", { autoHide: null }); + new Notif("AnalyticsConsent", { autoHide: null }); } }; /** Expires the analytics cookies, which are the ones prefixed with a single `_`. */ -this.resetAnalytics = function () { +export const resetAnalytics = function () { for (var cookie of document.cookie.split(/;\s?/)) { var name = cookie.split("=")[0]; if (name[0] === "_" && name[1] !== "_") { diff --git a/assets/javascripts/lib/util.js b/assets/javascripts/lib/util.js index eab3b96707..89a70caba6 100644 --- a/assets/javascripts/lib/util.js +++ b/assets/javascripts/lib/util.js @@ -97,7 +97,7 @@ let smoothDistance, smoothDuration, smoothEnd, smoothStart; // The helpers are attached to `$` below, so the function on its own doesn't // yet satisfy the type the global is declared with. -this.$ = /** @type {DollarQuery & DollarHelpers} */ ( +export const $ = /** @type {DollarQuery & DollarHelpers} */ ( /** @type {DollarQuery} */ ( function (selector, el) { if (el == null) { @@ -111,7 +111,7 @@ this.$ = /** @type {DollarQuery & DollarHelpers} */ ( ); /** @type {DollarQueryAll} */ -this.$$ = function (selector, el) { +export const $$ = function (selector, el) { if (el == null) { el = document; } diff --git a/assets/javascripts/models/doc.js b/assets/javascripts/models/doc.js index 39535ee0d7..f8c3bb173c 100644 --- a/assets/javascripts/models/doc.js +++ b/assets/javascripts/models/doc.js @@ -1,5 +1,13 @@ // @ts-check +import { app } from "../app/app.js"; +import { config } from "../app/config.js"; +import { Entries } from "../collections/entries.js"; +import { Types } from "../collections/types.js"; +import { ajax } from "../lib/ajax.js"; +import { Entry } from "./entry.js"; +import { Model } from "./model.js"; + /** * How a doc's index and database are fetched. * @@ -22,7 +30,7 @@ // `super()` and blank them out again. /** One version of one documentation set. */ -class Doc extends Model { +export class Doc extends Model { static NUMBERED_VERSION_RGX = /^\d+(\.\d+)*$/; /** @@ -54,7 +62,7 @@ class Doc extends Model { /** @param {unknown} [entries] */ resetEntries(entries) { - this.entries = new app.collections.Entries( + this.entries = new Entries( /** @type {unknown[]} */ (entries), ); this.entries.each((entry) => { @@ -64,7 +72,7 @@ class Doc extends Model { /** @param {unknown} [types] */ resetTypes(types) { - this.types = new app.collections.Types(/** @type {unknown[]} */ (types)); + this.types = new Types(/** @type {unknown[]} */ (types)); this.types.each((type) => { return (type.doc = this); }); @@ -89,18 +97,18 @@ class Doc extends Model { * @returns {string} Where the page's HTML is served from. */ fileUrl(path) { - return `${app.config.docs_origin}${this.fullPath(path)}?${this.mtime}`; + return `${config.docs_origin}${this.fullPath(path)}?${this.mtime}`; } /** @returns {string} Where the doc's offline database is served from. */ dbUrl() { - return `${app.config.docs_origin}/${this.slug}/${app.config.db_filename}?${this.mtime}`; + return `${config.docs_origin}/${this.slug}/${config.db_filename}?${this.mtime}`; } /** @returns {string} Where the doc's entry index is served from. */ indexUrl() { - return `${app.config.docs_origin}/${this.slug}/${ - app.config.index_filename + return `${config.docs_origin}/${this.slug}/${ + config.index_filename }?${this.mtime}`; } @@ -114,7 +122,7 @@ class Doc extends Model { if (this.entry) { return this.entry; } - this.entry = new app.models.Entry({ + this.entry = new Entry({ doc: this, name: this.fullName, path: "index", @@ -352,7 +360,3 @@ class Doc extends Model { return isInstalled && this.mtime !== status.mtime; } } - -// Registered on `app` so that the rest of the code can reach it; declared at -// the top level so that it can be named in a type. -app.models.Doc = Doc; diff --git a/assets/javascripts/models/entry.js b/assets/javascripts/models/entry.js index ef90e313d3..225237a6e2 100644 --- a/assets/javascripts/models/entry.js +++ b/assets/javascripts/models/entry.js @@ -1,12 +1,16 @@ // @ts-check -//= require app/searcher - // An entry's own properties are declared in globals.d.ts, for the reason // given in models/doc.js. +import { app } from "../app/app.js"; +import { config } from "../app/config.js"; +import { Searcher } from "../app/searcher.js"; +import { Model } from "./model.js"; +/** @import { Type } from "./type.js" */ + /** One searchable page, or a heading within one. */ -class Entry extends Model { +export class Entry extends Model { /** * Expands a searchable string with its alias, if it has one, so that both * spellings match. @@ -16,7 +20,7 @@ class Entry extends Model { * otherwise the string unchanged. */ static applyAliases(string) { - const aliases = app.config.docs_aliases; + const aliases = config.docs_aliases; if (aliases.hasOwnProperty(string)) { return [string, aliases[string]]; } else { @@ -35,7 +39,7 @@ class Entry extends Model { /** @param {Record} [attributes] Copied onto the entry by Model. */ constructor(attributes) { super(attributes); - this.text = Entry.applyAliases(app.Searcher.normalizeString(this.name)); + this.text = Entry.applyAliases(Searcher.normalizeString(this.name)); } /** @@ -44,7 +48,7 @@ class Entry extends Model { * @param {string} name */ addAlias(name) { - const text = Entry.applyAliases(app.Searcher.normalizeString(name)); + const text = Entry.applyAliases(Searcher.normalizeString(name)); if (!Array.isArray(this.text)) { this.text = [this.text]; } @@ -104,7 +108,3 @@ class Entry extends Model { return app.db.load(this, onSuccess, onError); } } - -// Registered on `app` so that the rest of the code can reach it; declared at -// the top level so that it can be named in a type. -app.models.Entry = Entry; diff --git a/assets/javascripts/models/model.js b/assets/javascripts/models/model.js index 6bfb002ce3..8f67f87fcd 100644 --- a/assets/javascripts/models/model.js +++ b/assets/javascripts/models/model.js @@ -7,7 +7,7 @@ * document the ones they rely on rather than declaring them as fields — a * field declaration would run after `super()` and blank the value out. */ -class Model { +export class Model { /** @param {Record} [attributes] */ constructor(attributes) { for (var key in attributes) { @@ -16,7 +16,3 @@ class Model { } } } - -// Registered on `app` so that the rest of the code can reach it; declared at -// the top level so that subclasses extend a type rather than `any`. -app.Model = Model; diff --git a/assets/javascripts/models/type.js b/assets/javascripts/models/type.js index 5d02656cc2..b35aff6f6f 100644 --- a/assets/javascripts/models/type.js +++ b/assets/javascripts/models/type.js @@ -3,8 +3,11 @@ // A type's own properties are declared in globals.d.ts, for the reason given // in models/doc.js. +import { Entry } from "./entry.js"; +import { Model } from "./model.js"; + /** A group of entries within a doc, e.g. "Methods". */ -class Type extends Model { +export class Type extends Model { /** @returns {string} The app path for the type's page. */ fullPath() { @@ -18,14 +21,10 @@ class Type extends Model { /** @returns {Entry} An entry standing for the type's page, so that it can be searched for. */ toEntry() { - return new app.models.Entry({ + return new Entry({ doc: this.doc, name: `${this.doc.name} / ${this.name}`, path: ".." + this.fullPath(), }); } } - -// Registered on `app` so that the rest of the code can reach it; declared at -// the top level so that it can be named in a type. -app.models.Type = Type; diff --git a/assets/javascripts/templates/base.js b/assets/javascripts/templates/base.js index b36acd454b..042b603006 100644 --- a/assets/javascripts/templates/base.js +++ b/assets/javascripts/templates/base.js @@ -1,5 +1,45 @@ // @ts-check +import * as errorTmpl from "./error_tmpl.js"; +import * as noticeTmpl from "./notice_tmpl.js"; +import * as notifTmpl from "./notif_tmpl.js"; +import * as aboutTmpl from "./pages/about_tmpl.js"; +import * as helpTmpl from "./pages/help_tmpl.js"; +import * as newsTmpl from "./pages/news_tmpl.js"; +import * as offlineTmpl from "./pages/offline_tmpl.js"; +import * as rootTmpl from "./pages/root_tmpl.js"; +import * as settingsTmpl from "./pages/settings_tmpl.js"; +import * as typeTmpl from "./pages/type_tmpl.js"; +import * as pathTmpl from "./path_tmpl.js"; +import * as sidebarTmpl from "./sidebar_tmpl.js"; +import * as tipTmpl from "./tip_tmpl.js"; + +/** + * Every template, under the name it is rendered by. + * + * Views reach templates by name (`this.tmpl("notifError")`), so the lookup has + * to stay dynamic even though the modules are imported statically. Spreading + * each module keeps the map in step with what it exports, rather than + * repeating every name here. + * + * @type {Record string) | string>} + */ +const templates = { + ...errorTmpl, + ...noticeTmpl, + ...notifTmpl, + ...aboutTmpl, + ...helpTmpl, + ...newsTmpl, + ...offlineTmpl, + ...rootTmpl, + ...settingsTmpl, + ...typeTmpl, + ...pathTmpl, + ...sidebarTmpl, + ...tipTmpl, +}; + /** * Renders a template by name. * @@ -7,13 +47,13 @@ * the template once per element and concatenates the results, which is how * lists are built. * - * @param {string} name The key under `app.templates`. + * @param {string} name The template's key. * @param {unknown} [value] The template's first argument, or an array of them. * @param {...unknown} args Passed on after `value`. * @returns {string} The rendered HTML. */ -app.templates.render = function (name, value, ...args) { - const template = app.templates[name]; +export function render(name, value, ...args) { + const template = templates[name]; if (Array.isArray(value) && typeof template === "function") { let result = ""; @@ -26,4 +66,4 @@ app.templates.render = function (name, value, ...args) { } else { return template; } -}; +} diff --git a/assets/javascripts/templates/error_tmpl.js b/assets/javascripts/templates/error_tmpl.js index 5fb868f6ee..2d5bf2f09d 100644 --- a/assets/javascripts/templates/error_tmpl.js +++ b/assets/javascripts/templates/error_tmpl.js @@ -27,14 +27,14 @@ const error = function (title, text, links) { const back = 'Go back'; -app.templates.notFoundPage = () => +export const notFoundPage = () => error( " Page not found. ", " It may be missing from the source documentation or this could be a bug. ", back, ); -app.templates.pageLoadError = () => +export const pageLoadError = () => error( " The page failed to load. ", ` It may be missing from the server (try reloading the app) or you could be offline (try installing the documentation for offline usage when online again).
@@ -43,7 +43,7 @@ If you're online and you keep seeing this, you're likely behind a proxy or firew · Retry `, ); -app.templates.bootError = () => +export const bootError = () => error( " The app failed to load. ", ` Check your Internet connection and try reloading.
@@ -55,7 +55,7 @@ If you keep seeing this, you're likely behind a proxy or firewall that blocks cr * @param {Error} [exception] The error the browser reported, when there was one. * @returns {string} */ -app.templates.offlineError = function (reason, exception) { +export const offlineError = function (reason, exception) { if (reason === "cookie_blocked") { return error(" Cookies must be enabled to use offline mode. "); } @@ -89,7 +89,7 @@ This could be because you're browsing in private mode or have disallowed offline return error("Offline mode is unavailable.", reason); }; -app.templates.unsupportedBrowser = `\ +export const unsupportedBrowser = `\

Your browser is unsupported, sorry.

DevDocs is an API documentation browser which supports the following browsers: diff --git a/assets/javascripts/templates/notice_tmpl.js b/assets/javascripts/templates/notice_tmpl.js index 1661b90db0..f881f3f41a 100644 --- a/assets/javascripts/templates/notice_tmpl.js +++ b/assets/javascripts/templates/notice_tmpl.js @@ -1,5 +1,8 @@ // @ts-check +import { config } from "../app/config.js"; +/** @import { Doc } from "../models/doc.js" */ + /** * The notices shown above the content: a bar of explanatory text. * @@ -9,16 +12,16 @@ const notice = (text) => `

${text}

`; /** @param {Doc} doc @returns {string} */ -app.templates.singleDocNotice = (doc) => +export const singleDocNotice = (doc) => notice(` You're browsing the ${doc.fullName} documentation. To browse all docs, go to -${app.config.production_host} (or press esc). `); +${config.production_host} (or press esc). `); -app.templates.disabledDocNotice = () => +export const disabledDocNotice = () => notice(` This documentation is disabled. To enable it, go to Preferences. `); -app.templates.noOriginalLinkNotice = () => +export const noOriginalLinkNotice = () => notice(` The original page link is not available for this documentation. `); -app.templates.copyFailedNotice = () => +export const copyFailedNotice = () => notice(` Couldn't copy the original page link to the clipboard. `); diff --git a/assets/javascripts/templates/notif_tmpl.js b/assets/javascripts/templates/notif_tmpl.js index 7cc28f1e2e..5642149fdf 100644 --- a/assets/javascripts/templates/notif_tmpl.js +++ b/assets/javascripts/templates/notif_tmpl.js @@ -1,5 +1,9 @@ // @ts-check +import { config } from "../app/config.js"; +import { newsList } from "./pages/news_tmpl.js"; +/** @import { Doc } from "../models/doc.js" */ + /** * The notification shown in the corner. Links inside `html` are given the * notification's own link class. @@ -26,13 +30,13 @@ ${html} const textNotif = (title, message) => notif(title, `

${message}`); -app.templates.notifUpdateReady = () => +export const notifUpdateReady = () => textNotif( 'DevDocs has been updated.', 'Reload the page to use the new version.', ); -app.templates.notifError = () => +export const notifError = () => textNotif( " Oops, an error occurred. ", ` Try reloading, and if the problem persists, @@ -40,38 +44,38 @@ app.templates.notifError = () => You can also report this issue on GitHub. `, ); -app.templates.notifQuotaExceeded = () => +export const notifQuotaExceeded = () => textNotif( " The offline database has exceeded its size limitation. ", " Unfortunately this quota can't be detected programmatically, and the database can't be opened while over the quota, so it had to be reset. ", ); -app.templates.notifCookieBlocked = () => +export const notifCookieBlocked = () => textNotif( " Please enable cookies. ", " DevDocs will not work properly if cookies are disabled. ", ); -app.templates.notifInvalidLocation = () => +export const notifInvalidLocation = () => textNotif( - ` DevDocs must be loaded from ${app.config.production_host} `, + ` DevDocs must be loaded from ${config.production_host} `, " Otherwise things are likely to break. ", ); -app.templates.notifImportInvalid = () => +export const notifImportInvalid = () => textNotif( " Oops, an error occurred. ", " The file you selected is invalid. ", ); /** - * @param {unknown[]} news + * @param {Array<[string, ...string[]]>} news The entries, as `app.news` holds them. * @returns {string} */ -app.templates.notifNews = (news) => +export const notifNews = (news) => notif( "Changelog", - `

${app.templates.newsList(news, { + `
${newsList(news, { years: false, })}
`, ); @@ -81,7 +85,7 @@ app.templates.notifNews = (news) => * @param {unknown[]} disabledDocs Disabled docs with a new release. * @returns {string} */ -app.templates.notifUpdates = function (docs, disabledDocs) { +export const notifUpdates = function (docs, disabledDocs) { let doc; let html = '
'; @@ -113,7 +117,7 @@ app.templates.notifUpdates = function (docs, disabledDocs) { return notif("Updates", `${html}
`); }; -app.templates.notifShare = () => +export const notifShare = () => textNotif( " Hi there! ", ` Like DevDocs? Help us reach more developers by sharing the link with your friends on @@ -121,13 +125,13 @@ app.templates.notifShare = () => Reddit, etc.
Thanks :) `, ); -app.templates.notifUpdateDocs = () => +export const notifUpdateDocs = () => textNotif( " Documentation updates available. ", ' Install them as soon as possible to avoid broken pages. ', ); -app.templates.notifAnalyticsConsent = () => +export const notifAnalyticsConsent = () => textNotif( " Tracking cookies ", ` We would like to gather usage data about how DevDocs is used through Google Analytics and Gauges. We only collect anonymous traffic information. diff --git a/assets/javascripts/templates/pages/about_tmpl.js b/assets/javascripts/templates/pages/about_tmpl.js index 8b785dbd8c..a57957cc45 100644 --- a/assets/javascripts/templates/pages/about_tmpl.js +++ b/assets/javascripts/templates/pages/about_tmpl.js @@ -1,6 +1,8 @@ // @ts-check -app.templates.aboutPage = function () { +import { app } from "../../app/app.js"; + +export const aboutPage = function () { let doc; const all_docs = app.docs.all().concat(...(app.disabledDocs.all() || [])); // de-duplicate docs by doc.name diff --git a/assets/javascripts/templates/pages/help_tmpl.js b/assets/javascripts/templates/pages/help_tmpl.js index 849898a9f9..1d2bc1f15c 100644 --- a/assets/javascripts/templates/pages/help_tmpl.js +++ b/assets/javascripts/templates/pages/help_tmpl.js @@ -1,11 +1,15 @@ // @ts-check -app.templates.helpPage = function () { +import { app } from "../../app/app.js"; +import { config } from "../../app/config.js"; +import { $ } from "../../lib/util.js"; + +export const helpPage = function () { const ctrlKey = $.isMac() ? "cmd" : "ctrl"; const navKey = $.isMac() ? "cmd" : "alt"; const arrowScroll = app.settings.get("arrowScroll"); - const aliases = Object.entries(app.config.docs_aliases); + const aliases = Object.entries(config.docs_aliases); const middle = Math.ceil(aliases.length / 2); const aliases_one = aliases.slice(0, middle); const aliases_two = aliases.slice(middle); diff --git a/assets/javascripts/templates/pages/news_tmpl.d.ts b/assets/javascripts/templates/pages/news_tmpl.d.ts new file mode 100644 index 0000000000..a84cb25b48 --- /dev/null +++ b/assets/javascripts/templates/pages/news_tmpl.d.ts @@ -0,0 +1,6 @@ +/** The changelog templates, rendered by news_tmpl.js.erb from news.json. */ +export const newsPage: () => string; +export const newsList: ( + news: Array<[string, ...string[]]>, + options?: { years?: boolean }, +) => string; diff --git a/assets/javascripts/templates/pages/news_tmpl.js.erb b/assets/javascripts/templates/pages/news_tmpl.js.erb index 3bcfec32f4..12a4d5a13c 100644 --- a/assets/javascripts/templates/pages/news_tmpl.js.erb +++ b/assets/javascripts/templates/pages/news_tmpl.js.erb @@ -1,11 +1,13 @@ //= depend_on news.json -app.templates.newsPage = () => `

Changelog

+import { app } from "../../app/app.js"; + +export const newsPage = () => `

Changelog

For development updates, follow the project on GitHub. -

${app.templates.newsList(app.news)}
`; +
${newsList(app.news)}
`; -app.templates.newsList = function(news, options = {}) { +export const newsList = function(news, options = {}) { let year = new Date().getUTCFullYear(); let result = ''; diff --git a/assets/javascripts/templates/pages/offline_tmpl.js b/assets/javascripts/templates/pages/offline_tmpl.js index bc520c7278..cf784aa211 100644 --- a/assets/javascripts/templates/pages/offline_tmpl.js +++ b/assets/javascripts/templates/pages/offline_tmpl.js @@ -1,12 +1,19 @@ // @ts-check +import { app } from "../../app/app.js"; +import { config } from "../../app/config.js"; +import { AppServiceWorker } from "../../app/serviceworker.js"; +import { $ } from "../../lib/util.js"; +/** @import { ImportSummary } from "../../app/offline_backup.js" */ +/** @import { Doc, InstallStatus } from "../../models/doc.js" */ + /** * @param {string} docs The rendered rows, one per doc. * @param {boolean} hasPersistence Whether the browser exposes the storage API. * @param {boolean} isPersistent Whether storage has already been made persistent. * @returns {string} */ -app.templates.offlinePage = (docs, hasPersistence, isPersistent) => `\ +export const offlinePage = (docs, hasPersistence, isPersistent) => `\

Offline Documentation

@@ -62,21 +69,21 @@ app.templates.offlinePage = (docs, hasPersistence, isPersistent) => `\ * @param {number} total * @returns {string} */ -app.templates.backupProgress = (action, doc, i, total) => +export const backupProgress = (action, doc, i, total) => `${action} ${doc.fullName}\u2026 (${i}/${total})`; /** * @param {number} count * @returns {string} */ -app.templates.backupExported = (count) => +export const backupExported = (count) => `Exported ${count} ${pluralizeDocs(count)}.`; /** * @param {ImportSummary} result * @returns {string} */ -app.templates.backupImported = function (result) { +export const backupImported = function (result) { let html = `Imported ${result.docs.length} ${pluralizeDocs( result.docs.length )}.`; @@ -99,7 +106,7 @@ app.templates.backupImported = function (result) { * @param {string} reason Why the export or import couldn't be done. * @returns {string} */ -app.templates.backupError = function (reason) { +export const backupError = function (reason) { switch (reason) { case "empty": return "No documentation is installed. Install one before exporting."; @@ -129,7 +136,7 @@ var listSlugs = (slugs) => slugs.map((slug) => $.escape(slug)).join(", "); * @param {Error} [exception] The error the browser reported, when there was one. * @returns {string} */ -app.templates.persistenceError = function (exception) { +export const persistenceError = function (exception) { const reason = exception ? `${exception.name}: ${exception.message}` : "Bookmark this site and try again."; @@ -165,12 +172,12 @@ var offlinePersistenceNote = function (hasPersistence, isPersistent) { }; var canICloseTheTab = function () { - if (app.ServiceWorker.isEnabled()) { + if (AppServiceWorker.isEnabled()) { return ' Yes! Even offline, you can open a new tab, go to devdocs.io, and everything will work as if you were online (provided you installed all the documentations you want to use beforehand). '; } else { let reason = "aren't available in your browser (or are disabled)"; - if (app.config.env !== "production") { + if (config.env !== "production") { reason = "are disabled in your development instance of DevDocs (enable them by setting the ENABLE_SERVICE_WORKER environment variable to true)"; } @@ -187,7 +194,7 @@ The current tab will continue to function even when you go offline (provided you * @param {InstallStatus} status * @returns {string} */ -app.templates.offlineDoc = function (doc, status) { +export const offlineDoc = function (doc, status) { const outdated = doc.isOutdated(status); let html = `\ diff --git a/assets/javascripts/templates/pages/root_tmpl.d.ts b/assets/javascripts/templates/pages/root_tmpl.d.ts new file mode 100644 index 0000000000..40be34e99b --- /dev/null +++ b/assets/javascripts/templates/pages/root_tmpl.d.ts @@ -0,0 +1,8 @@ +/** + * The landing-page templates, rendered by root_tmpl.js.erb. `intro` differs + * between development and production, so it is generated rather than written. + */ +export const splash: string; +export const intro: string; +export const mobileIntro: string; +export const androidWarning: string; diff --git a/assets/javascripts/templates/pages/root_tmpl.js.erb b/assets/javascripts/templates/pages/root_tmpl.js.erb index 176906696c..2a2cd21677 100644 --- a/assets/javascripts/templates/pages/root_tmpl.js.erb +++ b/assets/javascripts/templates/pages/root_tmpl.js.erb @@ -1,7 +1,7 @@ -app.templates.splash = "
DevDocs
"; +export const splash = "
DevDocs
"; <% if App.development? %> -app.templates.intro = `\ +export const intro = `\
Stop showing this message

Hi there!

@@ -24,7 +24,7 @@ app.templates.intro = `\
\ `; <% else %> -app.templates.intro = `\ +export const intro = `\
Stop showing this message

Welcome!

@@ -46,7 +46,7 @@ app.templates.intro = `\ `; <% end %> -app.templates.mobileIntro = `\ +export const mobileIntro = `\

Welcome!

DevDocs combines multiple API documentations in a fast, organized, and searchable interface. @@ -62,7 +62,7 @@ app.templates.mobileIntro = `\

\ `; -app.templates.androidWarning = `\ +export const androidWarning = `\

Hi there

DevDocs is running inside an Android WebView. Some features may not work properly. diff --git a/assets/javascripts/templates/pages/settings_tmpl.js b/assets/javascripts/templates/pages/settings_tmpl.js index 51a7515ab3..b4cec86df2 100644 --- a/assets/javascripts/templates/pages/settings_tmpl.js +++ b/assets/javascripts/templates/pages/settings_tmpl.js @@ -20,7 +20,7 @@ const themeOption = ({ label, value }, settings) => `\ * @param {Record} settings The user's current preferences. * @returns {string} */ -app.templates.settingsPage = (settings) => `\ +export const settingsPage = (settings) => `\

Preferences

diff --git a/assets/javascripts/templates/pages/type_tmpl.js b/assets/javascripts/templates/pages/type_tmpl.js index 9b49c27eb5..562ca90a63 100644 --- a/assets/javascripts/templates/pages/type_tmpl.js +++ b/assets/javascripts/templates/pages/type_tmpl.js @@ -1,14 +1,19 @@ // @ts-check +import { $ } from "../../lib/util.js"; +import { render } from "../base.js"; +/** @import { Entry } from "../../models/entry.js" */ +/** @import { Type } from "../../models/type.js" */ + /** * A type's page: every entry of that type in the doc. * * @param {Type} type * @returns {string} */ -app.templates.typePage = (type) => { +export const typePage = (type) => { return `

${type.doc.fullName} / ${type.name}

-
    ${app.templates.render( +
      ${render( "typePageEntry", type.entries(), )}
    `; @@ -20,6 +25,6 @@ app.templates.typePage = (type) => { * @param {Entry} entry * @returns {string} */ -app.templates.typePageEntry = (entry) => { +export const typePageEntry = (entry) => { return `
  • ${$.escape(entry.name)}
  • `; }; diff --git a/assets/javascripts/templates/path_tmpl.js b/assets/javascripts/templates/path_tmpl.js index a90afdd6de..3222d97c5f 100644 --- a/assets/javascripts/templates/path_tmpl.js +++ b/assets/javascripts/templates/path_tmpl.js @@ -1,5 +1,10 @@ // @ts-check +import { $ } from "../lib/util.js"; +/** @import { Doc } from "../models/doc.js" */ +/** @import { Entry } from "../models/entry.js" */ +/** @import { Type } from "../models/type.js" */ + /** * The breadcrumb above the content: the doc, then the type, then the entry. * @@ -8,7 +13,7 @@ * @param {Entry} [entry] * @returns {string} */ -app.templates.path = function (doc, type, entry) { +export const path = function (doc, type, entry) { const arrow = ''; let html = ` +export const sidebarType = (type) => `${arrow}${ @@ -64,7 +68,7 @@ templates.sidebarType = (type) => * @param {Entry} entry * @returns {string} */ -templates.sidebarEntry = (entry) => +export const sidebarEntry = (entry) => `${$.escape( entry.name, )}`; @@ -76,7 +80,7 @@ templates.sidebarEntry = (entry) => * @param {Entry} entry * @returns {string} */ -templates.sidebarResult = function (entry) { +export const sidebarResult = function (entry) { let addons = entry.isIndex() && app.disabledDocs.contains(entry.doc) ? `Enable` @@ -97,7 +101,7 @@ templates.sidebarResult = function (entry) { * * @returns {string} */ -templates.sidebarNoResults = function () { +export const sidebarNoResults = function () { let html = '
    No results.
    '; if (!app.isSingleDoc() && !app.disabledDocs.isEmpty()) { html += `\ @@ -113,7 +117,7 @@ templates.sidebarNoResults = function () { * @param {number} count How many entries are left. * @returns {string} */ -templates.sidebarPageLink = (count) => +export const sidebarPageLink = (count) => `Show more\u2026 (${count})`; /** @@ -123,7 +127,7 @@ templates.sidebarPageLink = (count) => * @param {SidebarOptions} [options] * @returns {string} */ -templates.sidebarLabel = function (doc, options) { +export const sidebarLabel = function (doc, options) { if (options == null) { options = {}; } @@ -146,7 +150,7 @@ templates.sidebarLabel = function (doc, options) { * @param {SidebarOptions} [options] * @returns {string} */ -templates.sidebarVersionedDoc = function (doc, versions, options) { +export const sidebarVersionedDoc = function (doc, versions, options) { if (options == null) { options = {}; } @@ -166,14 +170,14 @@ templates.sidebarVersionedDoc = function (doc, versions, options) { * @param {SidebarOptions} options * @returns {string} */ -templates.sidebarDisabled = (options) => +export const sidebarDisabled = (options) => `
    ${arrow}Disabled (${options.count}) Customize
    `; /** * @param {string} html The rendered disabled docs. * @returns {string} */ -templates.sidebarDisabledList = (html) => +export const sidebarDisabledList = (html) => `
    ${html}
    `; /** @@ -183,13 +187,13 @@ templates.sidebarDisabledList = (html) => * @param {string} versions The rendered rows for each version. * @returns {string} */ -templates.sidebarDisabledVersionedDoc = (doc, versions) => +export const sidebarDisabledVersionedDoc = (doc, versions) => `${arrow}${doc.name}
    ${versions}
    `; -templates.docPickerHeader = +export const docPickerHeader = '
    Documentation Enable
    '; -templates.docPickerNote = `\ +export const docPickerNote = `\
    Tip: for faster and better search results, select only the docs you need.
    Vote for new documentation\ `; diff --git a/assets/javascripts/templates/tip_tmpl.js b/assets/javascripts/templates/tip_tmpl.js index bcac2ff221..a9431e22ba 100644 --- a/assets/javascripts/templates/tip_tmpl.js +++ b/assets/javascripts/templates/tip_tmpl.js @@ -1,6 +1,8 @@ // @ts-check -app.templates.tipKeyNav = () => `\ +import { app } from "../app/app.js"; + +export const tipKeyNav = () => `\

    ProTip (click to dismiss) diff --git a/assets/javascripts/tracking.js b/assets/javascripts/tracking.js index a90c53de05..4d28d48918 100644 --- a/assets/javascripts/tracking.js +++ b/assets/javascripts/tracking.js @@ -4,8 +4,12 @@ // has consented. Without consent, whatever they left behind is cleared out. // The snippets below are the vendors' own bootstraps, kept as they ship them. +import { app } from "./app/app.js"; +import { config } from "./app/config.js"; +import { page, resetAnalytics } from "./lib/page.js"; + try { - if (app.config.env === "production") { + if (config.env === "production") { if (Cookies.get("analyticsConsent") === "1") { (function (i, s, o, g, r, a, m) { i["GoogleAnalyticsObject"] = r; diff --git a/assets/javascripts/vendor.js b/assets/javascripts/vendor.js new file mode 100644 index 0000000000..ea7ddfe40c --- /dev/null +++ b/assets/javascripts/vendor.js @@ -0,0 +1,7 @@ +// The third-party libraries, concatenated into one classic script. +// +// They assign their globals (Cookies, Prism, Raven) rather than exporting, so +// they are loaded ahead of the module graph instead of being part of it. Being +// one file also keeps them compressing against each other. + +//= require_tree ./vendor diff --git a/assets/javascripts/views/content/content.js b/assets/javascripts/views/content/content.js index bc2b5ea88b..8d5ee310b0 100644 --- a/assets/javascripts/views/content/content.js +++ b/assets/javascripts/views/content/content.js @@ -1,5 +1,18 @@ // @ts-check +import { app } from "../../app/app.js"; +import { config } from "../../app/config.js"; +import { resetFavicon } from "../../lib/favicon.js"; +import { $ } from "../../lib/util.js"; +import { EntryPage } from "./entry_page.js"; +import { OfflinePage } from "./offline_page.js"; +import { RootPage } from "./root_page.js"; +import { SettingsPage } from "./settings_page.js"; +import { StaticPage } from "./static_page.js"; +import { TypePage } from "./type_page.js"; +import { View } from "../view.js"; +/** @import { Context } from "../../lib/page.js" */ + /** * The pane holding whichever page is being shown. * @@ -8,7 +21,7 @@ * the way back, which is why the app turns the browser's own scroll * restoration off (see lib/page.js). */ -class Content extends app.View { +export class Content extends View { static el = "._content"; static loadingClass = "_content-loading"; @@ -37,12 +50,12 @@ class Content extends app.View { this.scrollMap = {}; this.scrollStack = []; - this.rootPage = new app.views.RootPage(); - this.staticPage = new app.views.StaticPage(); - this.settingsPage = new app.views.SettingsPage(); - this.offlinePage = new app.views.OfflinePage(); - this.typePage = new app.views.TypePage(); - this.entryPage = new app.views.EntryPage(); + this.rootPage = new RootPage(); + this.staticPage = new StaticPage(); + this.settingsPage = new SettingsPage(); + this.offlinePage = new OfflinePage(); + this.typePage = new TypePage(); + this.entryPage = new EntryPage(); this.entryPage .on("loading", () => this.onEntryLoading()) @@ -219,7 +232,7 @@ class Content extends app.View { if (this.scrollMap[this.routeCtx.state.id] == null) { this.scrollStack.push(this.routeCtx.state.id); - while (this.scrollStack.length > app.config.history_cache_size) { + while (this.scrollStack.length > config.history_cache_size) { delete this.scrollMap[this.scrollStack.shift()]; } } @@ -316,7 +329,3 @@ class Content extends app.View { return url?.startsWith("http:") || url?.startsWith("https:"); } } - -// Registered on `app` so that the rest of the code can reach it; declared at -// the top level so that it can be named in a type. -app.views.Content = Content; diff --git a/assets/javascripts/views/content/entry_page.js b/assets/javascripts/views/content/entry_page.js index 44c6f1572e..6934d9a3c5 100644 --- a/assets/javascripts/views/content/entry_page.js +++ b/assets/javascripts/views/content/entry_page.js @@ -1,5 +1,19 @@ // @ts-check +import { app } from "../../app/app.js"; +import { config } from "../../app/config.js"; +import { setFaviconForDoc } from "../../lib/favicon.js"; +import { $ } from "../../lib/util.js"; +import { Notice } from "../misc/notice.js"; +import { BasePage } from "../pages/base.js"; +import { HiddenPage } from "../pages/hidden.js"; +import { JqueryPage } from "../pages/jquery.js"; +import { RdocPage } from "../pages/rdoc.js"; +import { SqlitePage } from "../pages/sqlite.js"; +import { SupportTablesPage } from "../pages/support_tables.js"; +import { View } from "../view.js"; +/** @import { Context } from "../../lib/page.js" */ + /** * An entry's page. * @@ -8,7 +22,14 @@ * which is what the sub-view classes in views/pages are for. Recently viewed * pages are kept in memory so that going back doesn't refetch them. */ -class EntryPage extends app.View { +/** + * The doc-type-specific page views, by the name `subViewClass` derives from + * the doc's type. These used to be found on the global view registry; listing + * them keeps the modules reachable from the import graph. + */ +const TYPE_PAGES = { JqueryPage, RdocPage, SqlitePage, SupportTablesPage }; + +export class EntryPage extends View { static className = "_page"; static errorClass = "_page-error"; @@ -73,7 +94,7 @@ class EntryPage extends app.View { }); if (app.disabledDocs.findBy("slug", this.entry.doc.slug)) { - this.hiddenView = new app.views.HiddenPage(this.el, this.entry); + this.hiddenView = new HiddenPage(this.el, this.entry); } setFaviconForDoc(this.entry.doc); @@ -108,7 +129,7 @@ class EntryPage extends app.View { this.polyfilledMathML = true; $.append( document.head, - ``, + ``, ); } @@ -148,7 +169,7 @@ class EntryPage extends app.View { subViewClass() { // doc.type is optional (e.g. the Q documentation has none). const type = this.entry.doc.type; - return (type && app.views[`${$.classify(type)}Page`]) || app.views.BasePage; + return (type && TYPE_PAGES[`${$.classify(type)}Page`]) || BasePage; } /** @returns {string} */ @@ -232,7 +253,7 @@ class EntryPage extends app.View { this.cacheMap[path] = this.el.innerHTML; this.cacheStack.push(path); - while (this.cacheStack.length > app.config.history_cache_size) { + while (this.cacheStack.length > config.history_cache_size) { delete this.cacheMap[this.cacheStack.shift()]; } } @@ -306,7 +327,7 @@ class EntryPage extends app.View { /** @param {string} type Names the notice template to show. */ showTransientNotice(type) { this.hideTransientNotice(); - this.transientNotice = new app.views.Notice(type); + this.transientNotice = new Notice(type); // Persistent notices (single doc, disabled doc) share the same bounds and // z-index, so raise this one to keep it visible while it's shown. this.transientNotice.addClass("_notice-transient"); @@ -324,7 +345,3 @@ class EntryPage extends app.View { this.transientNoticeTimer = null; } } - -// Registered on `app` so that the rest of the code can reach it; declared at -// the top level so that it can be named in a type. -app.views.EntryPage = EntryPage; diff --git a/assets/javascripts/views/content/offline_page.js b/assets/javascripts/views/content/offline_page.js index 1dd5c998f0..d1f743ddba 100644 --- a/assets/javascripts/views/content/offline_page.js +++ b/assets/javascripts/views/content/offline_page.js @@ -1,10 +1,17 @@ // @ts-check +import { app } from "../../app/app.js"; +import { OfflineBackup } from "../../app/offline_backup.js"; +import { $ } from "../../lib/util.js"; +import { render } from "../../templates/base.js"; +import { View } from "../view.js"; +/** @import { Doc, InstallStatus } from "../../models/doc.js" */ + /** * The offline page: installing and removing each doc's database, and backing * the whole lot up to a file. */ -class OfflinePage extends app.View { +export class OfflinePage extends View { static className = "_static"; static events = { @@ -55,7 +62,7 @@ class OfflinePage extends app.View { * @param {InstallStatus} status */ renderDoc(doc, status) { - return app.templates.render("offlineDoc", doc, status); + return render("offlineDoc", doc, status); } /** @returns {string} */ @@ -191,7 +198,7 @@ class OfflinePage extends app.View { /** Exports every installed doc to a file. */ backup() { - return this._backup || (this._backup = new app.OfflineBackup()); + return this._backup || (this._backup = new OfflineBackup()); } // Exports `docs` into a single file. Returns false when another backup is @@ -345,7 +352,3 @@ class OfflinePage extends app.View { note.innerHTML = success ? "" : this.tmpl("persistenceError", exception); } } - -// Registered on `app` so that the rest of the code can reach it; declared at -// the top level so that it can be named in a type. -app.views.OfflinePage = OfflinePage; diff --git a/assets/javascripts/views/content/root_page.js b/assets/javascripts/views/content/root_page.js index 059a2cba7b..bc8bd541d8 100644 --- a/assets/javascripts/views/content/root_page.js +++ b/assets/javascripts/views/content/root_page.js @@ -1,10 +1,14 @@ // @ts-check +import { app } from "../../app/app.js"; +import { $ } from "../../lib/util.js"; +import { View } from "../view.js"; + /** * The app's index: the introduction, or the splash screen once the user has * dismissed it. */ -class RootPage extends app.View { +export class RootPage extends View { static events = { click: "onClick" }; /** @inheritdoc */ @@ -57,7 +61,3 @@ class RootPage extends app.View { } } } - -// Registered on `app` so that the rest of the code can reach it; declared at -// the top level so that it can be named in a type. -app.views.RootPage = RootPage; diff --git a/assets/javascripts/views/content/settings_page.js b/assets/javascripts/views/content/settings_page.js index 8183fe684c..0e8b5f7dd3 100644 --- a/assets/javascripts/views/content/settings_page.js +++ b/assets/javascripts/views/content/settings_page.js @@ -1,12 +1,20 @@ // @ts-check +import { app } from "../../app/app.js"; +import { Settings } from "../../app/settings.js"; +import { resetAnalytics } from "../../lib/page.js"; +import { $ } from "../../lib/util.js"; +import { Notif } from "../misc/notif.js"; +import { View } from "../view.js"; +/** @import { SettingsValues } from "../../app/settings.js" */ + /** * The preferences page: every setting, plus exporting and importing them. * * Some settings take effect immediately rather than on save, because the user * needs to see what they do. */ -class SettingsPage extends app.View { +export class SettingsPage extends View { static className = "_static"; static events = { @@ -33,7 +41,7 @@ class SettingsPage extends app.View { settings.spaceTimeout = app.settings.get("spaceTimeout"); settings.noDocSpecificIcon = app.settings.get("noDocSpecificIcon"); settings.autoSupported = app.settings.autoSupported; - for (var layout of app.Settings.LAYOUTS) { + for (var layout of Settings.LAYOUTS) { settings[layout] = app.settings.hasLayout(layout); } return settings; @@ -107,7 +115,7 @@ class SettingsPage extends app.View { */ import(file, input) { if (!file || file.type !== "application/json") { - new app.views.Notif("ImportInvalid", { autoHide: false }); + new Notif("ImportInvalid", { autoHide: false }); return; } @@ -119,7 +127,7 @@ class SettingsPage extends app.View { } catch (error) {} })(); if (!data || data.constructor !== Object) { - new app.views.Notif("ImportInvalid", { autoHide: false }); + new Notif("ImportInvalid", { autoHide: false }); return; } app.settings.import(data); @@ -177,7 +185,3 @@ class SettingsPage extends app.View { this.render(); } } - -// Registered on `app` so that the rest of the code can reach it; declared at -// the top level so that it can be named in a type. -app.views.SettingsPage = SettingsPage; diff --git a/assets/javascripts/views/content/static_page.js b/assets/javascripts/views/content/static_page.js index 9d354c6ab9..a6b9776cd2 100644 --- a/assets/javascripts/views/content/static_page.js +++ b/assets/javascripts/views/content/static_page.js @@ -1,7 +1,10 @@ // @ts-check +import { View } from "../view.js"; +/** @import { Context } from "../../lib/page.js" */ + /** The app's own pages — About, News, the user guide and the 404. */ -class StaticPage extends app.View { +export class StaticPage extends View { static className = "_static"; static titles = { @@ -35,7 +38,3 @@ class StaticPage extends app.View { this.render(context.page || "notFound"); } } - -// Registered on `app` so that the rest of the code can reach it; declared at -// the top level so that it can be named in a type. -app.views.StaticPage = StaticPage; diff --git a/assets/javascripts/views/content/type_page.js b/assets/javascripts/views/content/type_page.js index 78c2bf0889..41e3e2df39 100644 --- a/assets/javascripts/views/content/type_page.js +++ b/assets/javascripts/views/content/type_page.js @@ -1,7 +1,12 @@ // @ts-check +import { setFaviconForDoc } from "../../lib/favicon.js"; +import { View } from "../view.js"; +/** @import { Context } from "../../lib/page.js" */ +/** @import { Type } from "../../models/type.js" */ + /** A type's page: every entry of that type in the doc. */ -class TypePage extends app.View { +export class TypePage extends View { static className = "_page"; /** Also forgets which type was shown. */ @@ -29,7 +34,3 @@ class TypePage extends app.View { this.render(context.type); } } - -// Registered on `app` so that the rest of the code can reach it; declared at -// the top level so that it can be named in a type. -app.views.TypePage = TypePage; diff --git a/assets/javascripts/views/layout/document.js b/assets/javascripts/views/layout/document.js index 3af27eb5c2..f374f679cb 100644 --- a/assets/javascripts/views/layout/document.js +++ b/assets/javascripts/views/layout/document.js @@ -1,5 +1,16 @@ // @ts-check +import { app } from "../../app/app.js"; +import { $ } from "../../lib/util.js"; +import { Content } from "../content/content.js"; +import { Menu } from "./menu.js"; +import { Mobile } from "./mobile.js"; +import { Path } from "./path.js"; +import { Resizer } from "./resizer.js"; +import { SettingsView } from "./settings.js"; +import { Sidebar } from "../sidebar/sidebar.js"; +import { View } from "../view.js"; + /** * The root view, bound to the document itself. * @@ -7,7 +18,7 @@ * handles the shortcuts and the `data-behavior` links that aren't tied to any * one of them. */ -class AppDocument extends app.View { +export class AppDocument extends View { static el = document; static events = { visibilitychange: "onVisibilityChange" }; @@ -24,22 +35,22 @@ class AppDocument extends app.View { /** @inheritdoc */ init() { - this.menu = new app.views.Menu(); - this.sidebar = new app.views.Sidebar(); + this.menu = new Menu(); + this.sidebar = new Sidebar(); this.addSubview(this.sidebar); this.addSubview(this.menu); - if (app.views.Resizer.isSupported()) { - this.resizer = new app.views.Resizer(); + if (Resizer.isSupported()) { + this.resizer = new Resizer(); this.addSubview(this.resizer); } - this.content = new app.views.Content(); + this.content = new Content(); this.addSubview(this.content); if (!app.isSingleDoc() && !app.isMobile()) { - this.path = new app.views.Path(); + this.path = new Path(); this.addSubview(this.path); } if (!app.isSingleDoc()) { - this.settings = new app.views.Settings(); + this.settings = new SettingsView(); } $.on(document.body, "click", this.onClick); @@ -76,7 +87,7 @@ class AppDocument extends app.View { return; } this.delay(() => { - if (app.isMobile() !== app.views.Mobile.detect()) { + if (app.isMobile() !== Mobile.detect()) { location.reload(); } }, 300); @@ -150,7 +161,3 @@ class AppDocument extends app.View { } } } - -// Registered on `app` so that the rest of the code can reach it; declared at -// the top level so that it can be named in a type. -app.views.Document = AppDocument; diff --git a/assets/javascripts/views/layout/menu.js b/assets/javascripts/views/layout/menu.js index 43a4fd5eb3..85a19ef4a9 100644 --- a/assets/javascripts/views/layout/menu.js +++ b/assets/javascripts/views/layout/menu.js @@ -1,7 +1,10 @@ // @ts-check +import { $ } from "../../lib/util.js"; +import { View } from "../view.js"; + /** The header menu, opened by the toggle and closed by a click anywhere else. */ -class Menu extends app.View { +export class Menu extends View { static el = "._menu"; static activeClass = "active"; @@ -40,7 +43,3 @@ class Menu extends app.View { } } } - -// Registered on `app` so that the rest of the code can reach it; declared at -// the top level so that it can be named in a type. -app.views.Menu = Menu; diff --git a/assets/javascripts/views/layout/mobile.js b/assets/javascripts/views/layout/mobile.js index a639a69e42..7583ec9d0e 100644 --- a/assets/javascripts/views/layout/mobile.js +++ b/assets/javascripts/views/layout/mobile.js @@ -1,10 +1,17 @@ // @ts-check +import { app } from "../../app/app.js"; +import { page } from "../../lib/page.js"; +import { $ } from "../../lib/util.js"; +import { ListFold } from "../list/list_fold.js"; +import { ListSelect } from "../list/list_select.js"; +import { View } from "../view.js"; + /** * The phone layout: one pane at a time, with a toggle between the sidebar and * the content, and tabs for the doc picker and the preferences. */ -class Mobile extends app.View { +export class Mobile extends View { static className = "_mobile"; static elements = { @@ -100,7 +107,7 @@ class Mobile extends app.View { this.content.style.display = "none"; this.sidebar.style.display = "block"; - const selection = this.findByClass(app.views.ListSelect.activeClass); + const selection = this.findByClass(ListSelect.activeClass); if (selection) { const scrollContainer = window.scrollY === this.body.scrollTop @@ -110,7 +117,7 @@ class Mobile extends app.View { } else { window.scrollTo( 0, - (this.findByClass(app.views.ListFold.activeClass) && this.sidebarTop) || + (this.findByClass(ListFold.activeClass) && this.sidebarTop) || 0, ); } @@ -214,7 +221,3 @@ class Mobile extends app.View { } } } - -// Registered on `app` so that the rest of the code can reach it; declared at -// the top level so that it can be named in a type. -app.views.Mobile = Mobile; diff --git a/assets/javascripts/views/layout/path.js b/assets/javascripts/views/layout/path.js index b229826065..19884b3a23 100644 --- a/assets/javascripts/views/layout/path.js +++ b/assets/javascripts/views/layout/path.js @@ -1,10 +1,15 @@ // @ts-check +import { app } from "../../app/app.js"; +import { $ } from "../../lib/util.js"; +import { View } from "../view.js"; +/** @import { Context } from "../../lib/page.js" */ + /** * The breadcrumb above the content. Rebuilt on every route, and hidden on * pages that aren't part of a doc. */ -class Path extends app.View { +export class Path extends View { static className = "_path"; static attributes = { role: "complementary" }; @@ -68,7 +73,3 @@ class Path extends app.View { } } } - -// Registered on `app` so that the rest of the code can reach it; declared at -// the top level so that it can be named in a type. -app.views.Path = Path; diff --git a/assets/javascripts/views/layout/resizer.js b/assets/javascripts/views/layout/resizer.js index de9c296555..67cc609f12 100644 --- a/assets/javascripts/views/layout/resizer.js +++ b/assets/javascripts/views/layout/resizer.js @@ -1,5 +1,9 @@ // @ts-check +import { app } from "../../app/app.js"; +import { $ } from "../../lib/util.js"; +import { View } from "../view.js"; + /** * The handle between the sidebar and the content. * @@ -7,7 +11,7 @@ * saved on `dragend`; `dragover` fires far too often to write to storage, so * the live resize is throttled to one animation frame. */ -class Resizer extends app.View { +export class Resizer extends View { static className = "_resizer"; static events = { @@ -89,7 +93,3 @@ class Resizer extends app.View { this.resize(value, true); } } - -// Registered on `app` so that the rest of the code can reach it; declared at -// the top level so that it can be named in a type. -app.views.Resizer = Resizer; diff --git a/assets/javascripts/views/layout/settings.js b/assets/javascripts/views/layout/settings.js index 28ef26c718..874f2d1c78 100644 --- a/assets/javascripts/views/layout/settings.js +++ b/assets/javascripts/views/layout/settings.js @@ -1,12 +1,18 @@ // @ts-check +import { app } from "../../app/app.js"; +import { Docs } from "../../collections/docs.js"; +import { $ } from "../../lib/util.js"; +import { DocPicker } from "../sidebar/doc_picker.js"; +import { View } from "../view.js"; + /** * The preferences panel. * * Saving uninstalls the docs the user turned off and reloads the app, since * the offline database's schema is derived from the enabled docs. */ -class SettingsView extends View { +export class SettingsView extends View { static SIDEBAR_HIDDEN_LAYOUT = "_sidebar-hidden"; static el = "._settings"; @@ -28,7 +34,7 @@ class SettingsView extends View { /** @inheritdoc */ init() { - this.addSubview((this.docPicker = new app.views.DocPicker())); + this.addSubview((this.docPicker = new DocPicker())); } /** Also renders the panel and forces the sidebar to show. */ @@ -80,7 +86,7 @@ class SettingsView extends View { } this.saveBtn.textContent = "Saving\u2026"; - const disabledDocs = new app.collections.Docs( + const disabledDocs = new Docs( (() => { const result = []; for (var doc of app.docs.all()) { @@ -131,7 +137,3 @@ class SettingsView extends View { } } } - -// Registered on `app` so that the rest of the code can reach it; declared at -// the top level so that it can be named in a type. -app.views.Settings = SettingsView; diff --git a/assets/javascripts/views/list/list_focus.js b/assets/javascripts/views/list/list_focus.js index 5ebb16f4e7..1516f1f6cb 100644 --- a/assets/javascripts/views/list/list_focus.js +++ b/assets/javascripts/views/list/list_focus.js @@ -1,5 +1,10 @@ // @ts-check +import { $ } from "../../lib/util.js"; +import { ListFold } from "./list_fold.js"; +import { ListSelect } from "./list_select.js"; +import { View } from "../view.js"; + /** * The lists are built entirely from elements, so the sibling and parent walks * below only ever reach one. @@ -17,7 +22,7 @@ const asElement = (node) => /** @type {HTMLElement | null} */ (node); * focused yet, and stepping past the end of a page clicks its pagination link * so that the next page is rendered first. */ -class ListFocus extends app.View { +export class ListFocus extends View { static activeClass = "focus"; static events = { click: "onClick" }; @@ -67,7 +72,7 @@ class ListFocus extends app.View { getCursor() { return ( this.findByClass(this.statics().activeClass) || - this.findByClass(app.views.ListSelect.activeClass) + this.findByClass(ListSelect.activeClass) ); } @@ -194,11 +199,11 @@ class ListFocus extends app.View { const cursor = this.getCursor(); if ( cursor && - !cursor.classList.contains(app.views.ListFold.activeClass) && + !cursor.classList.contains(ListFold.activeClass) && cursor.parentNode !== this.el ) { const prev = asElement(asElement(cursor.parentNode)?.previousSibling ?? null); - if (prev && prev.classList.contains(app.views.ListFold.targetClass)) { + if (prev && prev.classList.contains(ListFold.targetClass)) { this.focusOnNextFrame(prev); } } @@ -231,7 +236,3 @@ class ListFocus extends app.View { } } } - -// Registered on `app` so that the rest of the code can reach it; declared at -// the top level so that it can be named in a type. -app.views.ListFocus = ListFocus; diff --git a/assets/javascripts/views/list/list_fold.js b/assets/javascripts/views/list/list_fold.js index 0702cfc5e9..ecdaa0a68e 100644 --- a/assets/javascripts/views/list/list_fold.js +++ b/assets/javascripts/views/list/list_fold.js @@ -1,5 +1,10 @@ // @ts-check +import { $ } from "../../lib/util.js"; +import { ListFocus } from "./list_focus.js"; +import { ListSelect } from "./list_select.js"; +import { View } from "../view.js"; + /** * Expanding and collapsing the sidebar's nested lists. * @@ -9,7 +14,7 @@ * and `close` on the row, which the lists listen for to render their contents * lazily. */ -class ListFold extends app.View { +export class ListFold extends View { static targetClass = "_list-dir"; static handleClass = "_list-arrow"; static activeClass = "open"; @@ -57,8 +62,8 @@ class ListFold extends app.View { /** @returns {HTMLElement | undefined} The focused row, or the selected one. */ getCursor() { return ( - this.findByClass(app.views.ListFocus.activeClass) || - this.findByClass(app.views.ListSelect.activeClass) + this.findByClass(ListFocus.activeClass) || + this.findByClass(ListSelect.activeClass) ); } @@ -101,7 +106,7 @@ class ListFold extends app.View { } else if (el.classList.contains(this.statics().targetClass)) { if (el.hasAttribute("href")) { if (el.classList.contains(this.statics().activeClass)) { - if (el.classList.contains(app.views.ListSelect.activeClass)) { + if (el.classList.contains(ListSelect.activeClass)) { this.close(el); } } else { @@ -113,7 +118,3 @@ class ListFold extends app.View { } } } - -// Registered on `app` so that the rest of the code can reach it; declared at -// the top level so that it can be named in a type. -app.views.ListFold = ListFold; diff --git a/assets/javascripts/views/list/list_select.js b/assets/javascripts/views/list/list_select.js index 4fa859012b..959652d104 100644 --- a/assets/javascripts/views/list/list_select.js +++ b/assets/javascripts/views/list/list_select.js @@ -1,12 +1,15 @@ // @ts-check +import { $ } from "../../lib/util.js"; +import { View } from "../view.js"; + /** * The selected row of a list — the entry currently being read, which stays * marked as the user moves the focus around. * * Selecting and deselecting emit `select` and `deselect` on the row. */ -class ListSelect extends app.View { +export class ListSelect extends View { static activeClass = "active"; static events = { click: "onClick" }; @@ -64,7 +67,3 @@ class ListSelect extends app.View { } } } - -// Registered on `app` so that the rest of the code can reach it; declared at -// the top level so that it can be named in a type. -app.views.ListSelect = ListSelect; diff --git a/assets/javascripts/views/list/paginated_list.js b/assets/javascripts/views/list/paginated_list.js index dde9f76c5c..99db40aa83 100644 --- a/assets/javascripts/views/list/paginated_list.js +++ b/assets/javascripts/views/list/paginated_list.js @@ -1,13 +1,17 @@ // @ts-check +import { config } from "../../app/config.js"; +import { $ } from "../../lib/util.js"; +import { View } from "../view.js"; + /** * A list too long to render at once: only a window of `PER_PAGE` rows is in * the document, with links at either end to extend it. * * Subclasses implement `render(dataSlice)`. */ -class PaginatedList extends app.View { - static PER_PAGE = app.config.max_results; +export class PaginatedList extends View { + static PER_PAGE = config.max_results; /** @param {unknown[]} data Every row, rendered a page at a time. */ constructor(data) { @@ -178,7 +182,3 @@ class PaginatedList extends app.View { } } } - -// Registered on `app` so that the rest of the code can reach it; declared at -// the top level so that it can be named in a type. -app.views.PaginatedList = PaginatedList; diff --git a/assets/javascripts/views/misc/news.js b/assets/javascripts/views/misc/news.js index f0bbb50ec0..b470cfb974 100644 --- a/assets/javascripts/views/misc/news.js +++ b/assets/javascripts/views/misc/news.js @@ -1,9 +1,11 @@ // @ts-check -//= require views/misc/notif +import { app } from "../../app/app.js"; +import { notifNews } from "../../templates/notif_tmpl.js"; +import { Notif } from "./notif.js"; /** The notification listing the changelog entries the user hasn't seen. */ -class News extends Notif { +export class News extends Notif { static className = "_notif _notif-news"; static defaultOptions = { autoHide: 30000 }; @@ -19,10 +21,13 @@ class News extends Notif { /** @inheritdoc */ render() { - this.html(app.templates.notifNews(this.unreadNews)); + this.html(notifNews(this.unreadNews)); } - /** @returns {Entry[]} Entries published since the user last saw the changelog. */ + /** + * @returns {Array<[string, ...string[]]>} The changelog entries published + * since the user last saw it. + */ getUnreadNews() { const time = this.getLastReadTime(); if (!time) { @@ -54,7 +59,3 @@ class News extends Notif { app.settings.set("news", this.getLastNewsTime()); } } - -// Registered on `app` so that the rest of the code can reach it; declared at -// the top level so that it can be named in a type. -app.views.News = News; diff --git a/assets/javascripts/views/misc/notice.js b/assets/javascripts/views/misc/notice.js index 0c3cc9e170..e4e2799db0 100644 --- a/assets/javascripts/views/misc/notice.js +++ b/assets/javascripts/views/misc/notice.js @@ -1,11 +1,15 @@ // @ts-check +import { app } from "../../app/app.js"; +import { $ } from "../../lib/util.js"; +import { View } from "../view.js"; + /** * A persistent bar above the content, e.g. to say that the doc being read is * disabled. The type names the template to render: a Notice of type * `singleDoc` renders `app.templates.singleDocNotice`. */ -class Notice extends app.View { +export class Notice extends View { static className = "_notice"; static attributes = { role: "alert" }; @@ -51,7 +55,3 @@ class Notice extends app.View { $.remove(this.el); } } - -// Registered on `app` so that the rest of the code can reach it; declared at -// the top level so that it can be named in a type. -app.views.Notice = Notice; diff --git a/assets/javascripts/views/misc/notif.js b/assets/javascripts/views/misc/notif.js index 801f30c7a3..e322cda16f 100644 --- a/assets/javascripts/views/misc/notif.js +++ b/assets/javascripts/views/misc/notif.js @@ -1,5 +1,8 @@ // @ts-check +import { $, $$ } from "../../lib/util.js"; +import { View } from "../view.js"; + /** * @typedef {object} NotifOptions * @property {number | null | false} [autoHide] How long to stay up, in @@ -13,7 +16,7 @@ * `app.templates.notifError`. Notifications stack, each positioned below the * one before it. */ -class Notif extends app.View { +export class Notif extends View { static className = "_notif"; static activeClass = "_in"; static attributes = { role: "alert" }; @@ -100,7 +103,3 @@ class Notif extends app.View { } } } - -// Registered on `app` so that the rest of the code can reach it; declared at -// the top level so that it can be named in a type. -app.views.Notif = Notif; diff --git a/assets/javascripts/views/misc/tip.js b/assets/javascripts/views/misc/tip.js index 7061d18009..3259d3f0cf 100644 --- a/assets/javascripts/views/misc/tip.js +++ b/assets/javascripts/views/misc/tip.js @@ -1,9 +1,9 @@ // @ts-check -//= require views/misc/notif +import { Notif } from "./notif.js"; /** A one-off hint, shown once per user and dismissed by clicking it. */ -class Tip extends Notif { +export class Tip extends Notif { static className = "_notif _notif-tip"; static defautOptions = { autoHide: false }; @@ -13,7 +13,3 @@ class Tip extends Notif { this.html(this.tmpl(`tip${this.type}`)); } } - -// Registered on `app` so that the rest of the code can reach it; declared at -// the top level so that it can be named in a type. -app.views.Tip = Tip; diff --git a/assets/javascripts/views/misc/updates.js b/assets/javascripts/views/misc/updates.js index 78e556c73b..53ce2ea6e6 100644 --- a/assets/javascripts/views/misc/updates.js +++ b/assets/javascripts/views/misc/updates.js @@ -1,12 +1,16 @@ // @ts-check -//= require views/misc/notif +import { app } from "../../app/app.js"; +import { config } from "../../app/config.js"; +import { notifUpdates } from "../../templates/notif_tmpl.js"; +import { Notif } from "./notif.js"; +/** @import { Doc } from "../../models/doc.js" */ /** * The notification listing the docs that gained a new release since the * user last saw it. */ -class Updates extends Notif { +export class Updates extends Notif { static className = "_notif _notif-news"; static defautOptions = { autoHide: 30000 }; @@ -25,7 +29,7 @@ class Updates extends Notif { /** @inheritdoc */ render() { this.html( - app.templates.notifUpdates(this.updatedDocs, this.updatedDisabledDocs), + notifUpdates(this.updatedDocs, this.updatedDisabledDocs), ); } @@ -68,13 +72,9 @@ class Updates extends Notif { markAllAsRead() { app.settings.set( "version", - app.config.env === "production" - ? app.config.version + config.env === "production" + ? config.version : Math.floor(Date.now() / 1000), ); } } - -// Registered on `app` so that the rest of the code can reach it; declared at -// the top level so that it can be named in a type. -app.views.Updates = Updates; diff --git a/assets/javascripts/views/pages/base.js b/assets/javascripts/views/pages/base.js index e099504b28..e23c1c3b5a 100644 --- a/assets/javascripts/views/pages/base.js +++ b/assets/javascripts/views/pages/base.js @@ -1,5 +1,9 @@ // @ts-check +import { $ } from "../../lib/util.js"; +import { View } from "../view.js"; +/** @import { Entry } from "../../models/entry.js" */ + /** * The base for the per-doc page views: docs whose pages need something done to * them once rendered. @@ -7,7 +11,7 @@ * Syntax highlighting is spread over animation frames, so that a page with a * lot of code doesn't block scrolling while it is painted. */ -class BasePage extends app.View { +export class BasePage extends View { /** * @param {HTMLElement} el * @param {Entry} entry @@ -95,7 +99,3 @@ class BasePage extends app.View { this.previousTiming = timing; } } - -// Registered on `app` so that the rest of the code can reach it; declared at -// the top level so that it can be named in a type. -app.views.BasePage = BasePage; diff --git a/assets/javascripts/views/pages/hidden.js b/assets/javascripts/views/pages/hidden.js index 7b45c49151..c1c537d3c9 100644 --- a/assets/javascripts/views/pages/hidden.js +++ b/assets/javascripts/views/pages/hidden.js @@ -1,10 +1,15 @@ // @ts-check +import { $ } from "../../lib/util.js"; +import { Notice } from "../misc/notice.js"; +import { View } from "../view.js"; +/** @import { Entry } from "../../models/entry.js" */ + /** * An entry belonging to a doc that isn't enabled: shown with a notice, and * with its links opened outside the app. */ -class HiddenPage extends app.View { +export class HiddenPage extends View { static events = { click: "onClick" }; /** @@ -18,7 +23,7 @@ class HiddenPage extends app.View { /** @inheritdoc */ init() { - this.notice = new app.views.Notice("disabledDoc"); + this.notice = new Notice("disabledDoc"); this.addSubview(this.notice); this.activate(); } @@ -32,7 +37,3 @@ class HiddenPage extends app.View { } } } - -// Registered on `app` so that the rest of the code can reach it; declared at -// the top level so that it can be named in a type. -app.views.HiddenPage = HiddenPage; diff --git a/assets/javascripts/views/pages/jquery.js b/assets/javascripts/views/pages/jquery.js index 1e588552d0..31c49059fc 100644 --- a/assets/javascripts/views/pages/jquery.js +++ b/assets/javascripts/views/pages/jquery.js @@ -1,6 +1,7 @@ // @ts-check -//= require views/pages/base +import { $ } from "../../lib/util.js"; +import { BasePage } from "./base.js"; /** * The jQuery docs' runnable examples, each rendered into its own iframe. @@ -9,7 +10,7 @@ * the API site, and a prefilter is injected that aborts any request that would * leave it, since they can't work from inside DevDocs. */ -class JqueryPage extends BasePage { +export class JqueryPage extends BasePage { static demoClassName = "_jquery-demo"; /** @inheritdoc */ @@ -92,7 +93,3 @@ class JqueryPage extends BasePage { return source.replace(/ +<%= module_preload_tags 'docs.js' %> +<% unless App.production? %> + +<% end %> + + diff --git a/views/other.erb b/views/other.erb index abd8bc20e1..15ed2e43f7 100644 --- a/views/other.erb +++ b/views/other.erb @@ -17,7 +17,14 @@ <%= erb :app -%> -<%= javascript_tag 'application' %><% unless App.production? %> -<%= javascript_tag 'debug' %><% end %> +<%# Single-doc pages read their one doc off the body, so they skip docs.js + and the whole catalog with it. %> +<%= javascript_tag 'vendor' %> + +<%= module_preload_tags %> +<% unless App.production? %> + +<% end %> + From c9aefdf9bf501ceaf74f12ac659ea34a61b1138c Mon Sep 17 00:00:00 2001 From: Simon Legner Date: Mon, 14 Sep 2026 12:37:00 +0200 Subject: [PATCH 2/4] Export the changelog instead of assigning it to the app news_tmpl.js is a dependency of the app singleton, so it evaluated before `app` was initialised and threw on every load. Export the data from the leaf module instead. The graph test swaps this module for a side-effect-free fixture and so could never have failed. Assert the property directly. --- assets/javascripts/app/app.js | 8 ----- assets/javascripts/templates/base.js | 5 +-- assets/javascripts/templates/notif_tmpl.js | 2 +- .../templates/pages/news_tmpl.d.ts | 3 +- .../templates/pages/news_tmpl.js.erb | 12 ++++--- assets/javascripts/views/misc/news.js | 9 +++--- test/assets/fixtures/docs.js | 9 ++++-- test/assets/fixtures/news_tmpl.js | 1 + test/assets/module_graph_test.js | 32 +++++++++++++++++++ 9 files changed, 58 insertions(+), 23 deletions(-) diff --git a/assets/javascripts/app/app.js b/assets/javascripts/app/app.js index 49a65d1468..60c2d58977 100644 --- a/assets/javascripts/app/app.js +++ b/assets/javascripts/app/app.js @@ -51,14 +51,6 @@ export class App extends Events { */ DOC; - /** - * The news entries, newest first, set by templates/pages/news_tmpl.js.erb. - * Each is a date followed by one entry per line. - * - * @type {Array<[string, ...string[]]>} - */ - news; - /** * The `window.onerror` handler that was installed before ours, if any. * diff --git a/assets/javascripts/templates/base.js b/assets/javascripts/templates/base.js index 042b603006..4c2dcdff66 100644 --- a/assets/javascripts/templates/base.js +++ b/assets/javascripts/templates/base.js @@ -5,7 +5,7 @@ import * as noticeTmpl from "./notice_tmpl.js"; import * as notifTmpl from "./notif_tmpl.js"; import * as aboutTmpl from "./pages/about_tmpl.js"; import * as helpTmpl from "./pages/help_tmpl.js"; -import * as newsTmpl from "./pages/news_tmpl.js"; +import { newsList, newsPage } from "./pages/news_tmpl.js"; import * as offlineTmpl from "./pages/offline_tmpl.js"; import * as rootTmpl from "./pages/root_tmpl.js"; import * as settingsTmpl from "./pages/settings_tmpl.js"; @@ -30,7 +30,8 @@ const templates = { ...notifTmpl, ...aboutTmpl, ...helpTmpl, - ...newsTmpl, + newsList, + newsPage, ...offlineTmpl, ...rootTmpl, ...settingsTmpl, diff --git a/assets/javascripts/templates/notif_tmpl.js b/assets/javascripts/templates/notif_tmpl.js index 5642149fdf..a433363933 100644 --- a/assets/javascripts/templates/notif_tmpl.js +++ b/assets/javascripts/templates/notif_tmpl.js @@ -69,7 +69,7 @@ export const notifImportInvalid = () => ); /** - * @param {Array<[string, ...string[]]>} news The entries, as `app.news` holds them. + * @param {Array<[string, ...string[]]>} news The entries, as news_tmpl.js exports them. * @returns {string} */ export const notifNews = (news) => diff --git a/assets/javascripts/templates/pages/news_tmpl.d.ts b/assets/javascripts/templates/pages/news_tmpl.d.ts index a84cb25b48..ef8480706d 100644 --- a/assets/javascripts/templates/pages/news_tmpl.d.ts +++ b/assets/javascripts/templates/pages/news_tmpl.d.ts @@ -1,6 +1,7 @@ -/** The changelog templates, rendered by news_tmpl.js.erb from news.json. */ +/** The changelog templates and data, rendered by news_tmpl.js.erb from news.json. */ export const newsPage: () => string; export const newsList: ( news: Array<[string, ...string[]]>, options?: { years?: boolean }, ) => string; +export const news: Array<[string, ...string[]]>; diff --git a/assets/javascripts/templates/pages/news_tmpl.js.erb b/assets/javascripts/templates/pages/news_tmpl.js.erb index 12a4d5a13c..c5284b0a7d 100644 --- a/assets/javascripts/templates/pages/news_tmpl.js.erb +++ b/assets/javascripts/templates/pages/news_tmpl.js.erb @@ -1,11 +1,9 @@ //= depend_on news.json -import { app } from "../../app/app.js"; - export const newsPage = () => `

    Changelog

    For development updates, follow the project on GitHub. -

    ${newsList(app.news)}
    `; +
    ${newsList(news)}
    `; export const newsList = function(news, options = {}) { let year = new Date().getUTCFullYear(); @@ -39,4 +37,10 @@ var newsItem = function(date, news) { return result; }; -app.news = <%= App.news.to_json %> +/** + * The changelog, newest first. Each entry is a date followed by one line per + * item. Exported rather than assigned onto `app`: this module is a dependency + * of the app singleton, so it evaluates first and `app` would still be in its + * temporal dead zone. + */ +export const news = <%= App.news.to_json %>; diff --git a/assets/javascripts/views/misc/news.js b/assets/javascripts/views/misc/news.js index b470cfb974..4c4f860304 100644 --- a/assets/javascripts/views/misc/news.js +++ b/assets/javascripts/views/misc/news.js @@ -1,6 +1,7 @@ // @ts-check import { app } from "../../app/app.js"; +import { news } from "../../templates/pages/news_tmpl.js"; import { notifNews } from "../../templates/notif_tmpl.js"; import { Notif } from "./notif.js"; @@ -35,18 +36,18 @@ export class News extends Notif { } const result = []; - for (var news of app.news) { - if (new Date(news[0]).getTime() <= time) { + for (var entry of news) { + if (new Date(entry[0]).getTime() <= time) { break; } - result.push(news); + result.push(entry); } return result; } /** @returns {number} When the newest entry was published, in milliseconds. */ getLastNewsTime() { - return new Date(app.news[0][0]).getTime(); + return new Date(news[0][0]).getTime(); } /** @returns {number} When the user last saw the changelog, in milliseconds. */ diff --git a/test/assets/fixtures/docs.js b/test/assets/fixtures/docs.js index bafce260bb..d1f02694af 100644 --- a/test/assets/fixtures/docs.js +++ b/test/assets/fixtures/docs.js @@ -1,5 +1,8 @@ /** - * Stands in for docs.js, rendered from docs.js.erb at build time. The real - * module sets `app.DOCS`; a test that needs docs sets them itself. + * Stands in for docs.js, rendered from docs.js.erb at build time. Mirrors the + * real module's side effect so a test that loads it sees the same shape: it is + * a separate entry, not a dependency of the app, so it may touch `app`. */ -export {}; +import { app } from "../../../assets/javascripts/app/app.js"; + +app.DOCS = []; diff --git a/test/assets/fixtures/news_tmpl.js b/test/assets/fixtures/news_tmpl.js index f960e38380..5fb8fca956 100644 --- a/test/assets/fixtures/news_tmpl.js +++ b/test/assets/fixtures/news_tmpl.js @@ -1,3 +1,4 @@ /** Stands in for templates/pages/news_tmpl.js, rendered from ERB at build time. */ export const newsPage = () => ""; export const newsList = () => ""; +export const news = []; diff --git a/test/assets/module_graph_test.js b/test/assets/module_graph_test.js index c8ef5a13cc..4ad4b64bdb 100644 --- a/test/assets/module_graph_test.js +++ b/test/assets/module_graph_test.js @@ -1,6 +1,7 @@ // @ts-check import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; import test from "node:test"; // The modules import each other in cycles (a view reaches the app singleton, @@ -30,3 +31,34 @@ test("the debug module patches the app without evaluating it twice", async () => await import("../../assets/javascripts/debug.js"); assert.notEqual(app.init, init, "debug.js should wrap app.init"); }); + +// The modules rendered from ERB are replaced by fixtures under Node, so the +// graph test above evaluates stand-ins rather than the real thing. These are +// dependencies of the app singleton (templates/base.js pulls the templates +// in), which means they evaluate *before* `app` is initialised — touching it +// there throws a ReferenceError in the browser, before boot, and no fixture +// would show it. Keep them leaves. +test("the generated modules stay out of the app's initialisation cycle", () => { + const generated = [ + "app/config.js.erb", + "templates/pages/news_tmpl.js.erb", + "templates/pages/root_tmpl.js.erb", + ]; + + for (const file of generated) { + const source = readFileSync( + new URL(`../../assets/javascripts/${file}`, import.meta.url), + "utf8", + ); + assert.doesNotMatch( + source, + /^import\b.*app\/app\.js/m, + `${file} must not import the app singleton`, + ); + assert.doesNotMatch( + source, + /^app\./m, + `${file} must not assign onto the app singleton`, + ); + } +}); From 080c5828b5aaaf76017b69ea7dacb116d85bd231 Mon Sep 17 00:00:00 2001 From: Simon Legner Date: Mon, 14 Sep 2026 12:37:05 +0200 Subject: [PATCH 3/4] Report the failure in browsers without import maps Such a browser resolves every relative import against the undigested path, 404s on all of them, and sits on the loading screen. The in-app check cannot report that from inside the graph that failed to load, so test for import maps from a classic script that runs first. The documented baseline was already wrong: the assets target ES2022. --- README.md | 13 +++++--- assets/javascripts/templates/error_tmpl.js | 6 ++-- assets/javascripts/unsupported.js | 37 ++++++++++++++++++++++ lib/app.rb | 11 ++++--- views/index.erb | 3 ++ views/other.erb | 3 ++ 6 files changed, 61 insertions(+), 12 deletions(-) create mode 100644 assets/javascripts/unsupported.js diff --git a/README.md b/README.md index 0e64756854..2ab43c29da 100644 --- a/README.md +++ b/README.md @@ -91,11 +91,14 @@ Another driving factor is performance and the fact that everything happens in th DevDocs being a developer tool, the browser requirements are high: * Recent versions of Firefox, Chrome, or Opera -* Safari 11.1+ -* Edge 17+ -* iOS 11.3+ - -This allows the code to take advantage of the latest DOM and HTML5 APIs and make developing DevDocs a lot more fun! +* Safari 16.4+ +* Edge 89+ +* iOS 16.4+ + +The app is served as ES modules resolved through an import map, which is the +newest thing it relies on and what sets the versions above. This allows the code +to take advantage of the latest DOM and HTML5 APIs and make developing DevDocs a +lot more fun! ## Scraper diff --git a/assets/javascripts/templates/error_tmpl.js b/assets/javascripts/templates/error_tmpl.js index 2d5bf2f09d..cbdf429ce7 100644 --- a/assets/javascripts/templates/error_tmpl.js +++ b/assets/javascripts/templates/error_tmpl.js @@ -95,9 +95,9 @@ export const unsupportedBrowser = `\

    DevDocs is an API documentation browser which supports the following browsers:

    • Recent versions of Firefox, Chrome, or Opera -
    • Safari 11.1+ -
    • Edge 17+ -
    • iOS 11.3+ +
    • Safari 16.4+ +
    • Edge 89+ +
    • iOS 16.4+

    If you're unable to upgrade, we apologize. diff --git a/assets/javascripts/unsupported.js b/assets/javascripts/unsupported.js new file mode 100644 index 0000000000..b5e94d4e21 --- /dev/null +++ b/assets/javascripts/unsupported.js @@ -0,0 +1,37 @@ +// @ts-check + +// A classic script, deliberately not a module: it has to run in browsers that +// cannot load the module graph at all. +// +// The app is served as ES modules whose relative imports are rewritten onto +// their digested URLs by an import map. A browser that supports modules but +// not import maps resolves those imports against the undigested paths, gets a +// 404 for every one, and sits on the loading screen forever — and because the +// in-app compatibility check lives inside the graph, it never runs to say why. +// +// Import maps are the newest thing the app needs, so testing for them stands +// in for the whole baseline. +(function () { + if ( + window.HTMLScriptElement && + typeof HTMLScriptElement.supports === "function" && + HTMLScriptElement.supports("importmap") + ) { + return; + } + + document.body.innerHTML = `\ +

    +

    Your browser is unsupported, sorry.

    +

    DevDocs is an API documentation browser which supports the following browsers: +

      +
    • Recent versions of Firefox, Chrome, or Opera +
    • Safari 16.4+ +
    • Edge 89+ +
    • iOS 16.4+ +
    +

    + If you're unable to upgrade, we apologize. + We decided to prioritize speed and new features over support for older browsers. +

    `; +})(); diff --git a/lib/app.rb b/lib/app.rb index a8e87403ea..3ec6066496 100644 --- a/lib/app.rb +++ b/lib/app.rb @@ -46,15 +46,17 @@ class App < Sinatra::Application set :assets_path, File.join(public_folder, assets_prefix) set :assets_manifest_path, File.join(assets_path, 'manifest.json') # Every ES module in the app, by the logical path the import map keys on. - # The vendored libraries are concatenated into vendor.js instead, and the - # debug module is only served outside production. + # The vendored libraries are concatenated into vendor.js, and unsupported.js + # guards the module graph from outside it, so neither is a module; the debug + # module is only served outside production. set :js_modules, Dir.glob('**/*.js{,.erb}', base: root.join('assets', 'javascripts')) - .reject { |path| path.start_with?('vendor/') || path == 'vendor.js' } + .reject { |path| path.start_with?('vendor/') || + %w(vendor.js unsupported.js).include?(path) } .map { |path| path.delete_suffix('.erb') } .sort .freeze - set :assets_compile, %w(*.png docs.json vendor.js application.css application-dark.css) + js_modules + set :assets_compile, %w(*.png docs.json vendor.js unsupported.js application.css application-dark.css) + js_modules require 'json' set :docs_prefix, 'docs' @@ -263,6 +265,7 @@ def service_worker_asset_urls @@service_worker_asset_urls ||= [ *mapped_js_modules.map { |logical| javascript_path(logical) }, javascript_path('vendor'), + javascript_path('unsupported'), stylesheet_path('application'), image_path('sprites/docs.png'), image_path('sprites/docs@2x.png'), diff --git a/views/index.erb b/views/index.erb index 6fed6a6cec..7da6898deb 100644 --- a/views/index.erb +++ b/views/index.erb @@ -45,6 +45,9 @@ ahead of the module graph. The import map rewrites each module's relative imports onto its digested URL; it has to come before any module loads. %> <%= javascript_tag 'vendor' %> +<%# Classic script: tells a browser too old for import maps why nothing loaded, + which the module graph can't do from inside itself. %> +<%= javascript_tag 'unsupported' %> <%= module_preload_tags 'docs.js' %> <% unless App.production? %> diff --git a/views/other.erb b/views/other.erb index 15ed2e43f7..358fb1c2f9 100644 --- a/views/other.erb +++ b/views/other.erb @@ -20,6 +20,9 @@ <%# Single-doc pages read their one doc off the body, so they skip docs.js and the whole catalog with it. %> <%= javascript_tag 'vendor' %> +<%# Classic script: tells a browser too old for import maps why nothing loaded, + which the module graph can't do from inside itself. %> +<%= javascript_tag 'unsupported' %> <%= module_preload_tags %> <% unless App.production? %> From 5bde5cbb315165016ec03a06710c009e1c75fe77 Mon Sep 17 00:00:00 2001 From: Simon Legner Date: Mon, 14 Sep 2026 12:38:59 +0200 Subject: [PATCH 4/4] Stop reaching for the global through `this` Concatenated scripts run sloppy, so a plain function's `this` was the global object. page.js cached the canonical link element there and favicon.js used it to reach resetFavicon. Modules are strict, and the first of those threw on the boot path. noImplicitThis catches the favicon shape. It does not catch page.js, where a cast asserting `this` had a type silenced the check. --- assets/javascripts/lib/favicon.js | 2 +- assets/javascripts/lib/page.js | 14 ++++---- test/assets/page_test.js | 57 +++++++++++++++++++++++++++++++ tsconfig.json | 3 ++ 4 files changed, 68 insertions(+), 8 deletions(-) create mode 100644 test/assets/page_test.js diff --git a/assets/javascripts/lib/favicon.js b/assets/javascripts/lib/favicon.js index 6e3de80e17..807fe54281 100644 --- a/assets/javascripts/lib/favicon.js +++ b/assets/javascripts/lib/favicon.js @@ -117,7 +117,7 @@ export const setFaviconForDoc = function (doc) { return (currentSlug = doc.slug); } catch (error) { Raven.captureException(error, { level: "info" }); - return this.resetFavicon(); + return resetFavicon(); } }), ); diff --git a/assets/javascripts/lib/page.js b/assets/javascripts/lib/page.js index 5b469e8a83..7996ba4097 100644 --- a/assets/javascripts/lib/page.js +++ b/assets/javascripts/lib/page.js @@ -493,16 +493,16 @@ var onDocumentClick = function (event) { var isSameOrigin = (url) => url.startsWith(`${location.protocol}//${location.hostname}`); +/** The canonical link element, looked up once. */ +/** @type {HTMLLinkElement | null} */ +var canonicalLink = null; + /** Points the canonical link at the current path. */ var updateCanonicalLink = function () { - // Cached on the global, which is what `this` is in the concatenated bundle. - const self = /** @type {{ canonicalLink?: HTMLLinkElement }} */ ( - /** @type {unknown} */ (this) - ); - if (!self.canonicalLink) { - self.canonicalLink = document.head.querySelector('link[rel="canonical"]'); + if (!canonicalLink) { + canonicalLink = document.head.querySelector('link[rel="canonical"]'); } - return self.canonicalLink.setAttribute( + return canonicalLink.setAttribute( "href", `https://${location.host}${location.pathname}`, ); diff --git a/test/assets/page_test.js b/test/assets/page_test.js new file mode 100644 index 0000000000..5649630c66 --- /dev/null +++ b/test/assets/page_test.js @@ -0,0 +1,57 @@ +// @ts-check + +import assert from "node:assert/strict"; +import test from "node:test"; + +import { page } from "../../assets/javascripts/lib/page.js"; + +/** + * Replaces a global that the module reads directly. `location` is getter-only + * on the Node global, so it has to be defined rather than assigned. + * + * @param {string} name + * @param {unknown} value + */ +const define = (name, value) => + Object.defineProperty(globalThis, name, { + value, + writable: true, + configurable: true, + }); + +// `page.replace` runs on every navigation and on boot, by way of +// `app.start` -> `router.start` -> `page.start`. It used to cache the +// canonical link element on `this`, which was the global object while the +// assets were one concatenated script; as a module it is undefined, and the +// whole app failed to boot on a TypeError. Exercise the path rather than +// trusting that the modules merely parse. +test("navigating points the canonical link at the current path", () => { + const link = { + /** @type {Record} */ attrs: {}, + /** @param {string} k @param {string} v */ + setAttribute(k, v) { + this.attrs[k] = v; + }, + }; + + Object.defineProperty(globalThis.document, "head", { + value: { + querySelector: (/** @type {string} */ selector) => + selector === 'link[rel="canonical"]' ? link : null, + }, + writable: true, + configurable: true, + }); + define("location", { + hash: "", + href: "https://devdocs.io/css/", + host: "devdocs.io", + pathname: "/css/", + search: "", + }); + define("history", { replaceState() {}, pushState() {} }); + + page.replace("/css/", null, true, true); + + assert.equal(link.attrs.href, "https://devdocs.io/css/"); +}); diff --git a/tsconfig.json b/tsconfig.json index 0034fb0f3b..02bf8e732f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,6 +10,9 @@ // request each. Unused *parameters* are left alone: the code has plenty of // deliberately-ignored handler arguments. "noUnusedLocals": true, + // Modules are strict mode, so `this` is undefined in a plain function + // rather than the global object the concatenated bundle gave it. + "noImplicitThis": true, "noEmit": true, "target": "ES2022", "module": "preserve",