diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 154339a1b7..41b7a9a7c3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,4 +1,4 @@ -name: Ruby tests +name: Tests on: pull_request: @@ -7,6 +7,7 @@ on: jobs: test: + name: Ruby tests runs-on: ubuntu-latest steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 @@ -16,3 +17,19 @@ jobs: bundler-cache: true # runs 'bundle install' and caches installed gems automatically - name: Run tests run: bundle exec rake + + assets: + name: Asset typecheck and tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - name: Set up Node + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version-file: .tool-versions + cache: npm + - run: npm ci + - name: Typecheck the assets + run: npm run typecheck + - name: Run the asset tests + run: npm test diff --git a/.gitignore b/.gitignore index aac9f85ba5..0a7a47d759 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,4 @@ docs/**/* *.zip assets/stylesheets/components/_environment.scss assets/stylesheets/global/_icons.scss +node_modules diff --git a/.tool-versions b/.tool-versions index 05913eae49..c523abe3f7 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1 +1,2 @@ ruby 4.0.6 +nodejs 26.8.2 diff --git a/assets/javascripts/app/app.js b/assets/javascripts/app/app.js index f05b963dd5..3a7bc6a3c9 100644 --- a/assets/javascripts/app/app.js +++ b/assets/javascripts/app/app.js @@ -1,12 +1,174 @@ +// @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 + */ + +/** + * A doc as it appears in the manifest, before it becomes an `app.models.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. + */ class App extends Events { + // Kept so that isInjectionError can tell whether an extension replaced the + // globals out from under us. _$ = $; _$$ = $$; _page = page; - collections = {}; - models = {}; - templates = {}; - views = {}; + /** @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; + + /** + * The manifest of every available doc, set by docs.js.erb. Deleted once the + * docs have been read into the collections. + * + * @type {DocData[] | undefined} + */ + DOCS; + + /** + * In single-doc mode, the one doc being shown, read off the body. Deleted + * once it has been read. + * + * @type {DocData | undefined} + */ + 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. + * + * @type {Array<[string, ...string[]]>} + */ + news; + + /** + * The `window.onerror` handler that was installed before ours, if any. + * + * @type {unknown} + */ + previousErrorHandler; + + /** + * The stores and mode of the most recent IndexedDB transaction, tracked by + * app/db.js so that a hung transaction can be reported. + * + * @type {[string | string[], IDBTransactionMode] | undefined} + */ + lastIDBTransaction; + + /** Wires up the app and boots it. Called once the document is ready. */ init() { try { this.initErrorTracking(); @@ -46,15 +208,22 @@ class App extends Events { } } + /** + * @returns {boolean} Whether to carry on booting. Replaces the page with a + * warning when the browser is too old. + */ browserCheck() { if (this.isSupportedBrowser()) { return true; } - document.body.innerHTML = app.templates.unsupportedBrowser; + document.body.innerHTML = /** @type {string} */ ( + app.templates.unsupportedBrowser + ); this.hideLoadingScreen(); return false; } + /** Wires up Sentry and the global error handlers. */ initErrorTracking() { // Show a warning message and don't track errors when the app is loaded // from a domain other than our own, because things are likely to break. @@ -107,6 +276,7 @@ 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.docs.reset([this.doc]); @@ -117,10 +287,11 @@ class App extends Events { delete this.DOC; } + /** Boots with every doc in the manifest, enabled or not. */ async bootAll() { const docs = this.settings.getDocs(); for (var doc of this.DOCS) { - (docs.includes(doc.slug) ? this.docs : this.disabledDocs).add(doc); + (docs.includes(/** @type {string} */ (doc.slug)) ? this.docs : this.disabledDocs).add(doc); } delete this.DOCS; this.migrateDocs(); @@ -131,6 +302,7 @@ class App extends Events { }); } + /** Builds the search index from the loaded docs and starts routing. */ start() { let doc; for (doc of this.docs.all()) { @@ -153,6 +325,11 @@ class App extends Events { }, 50); } + /** + * Adds a doc's types and entries to the search index. + * + * @param {Doc} doc + */ initDoc(doc) { for (var type of doc.types.all()) { doc.entries.add(type.toEntry()); @@ -160,6 +337,7 @@ class App extends Events { this.entries.add(doc.entries.all()); } + /** Re-points enabled slugs that have since been renamed or reorganized. */ migrateDocs() { let needsSaving; for (var slug of this.settings.getDocs()) { @@ -191,8 +369,10 @@ class App extends Events { } } - // With the "latest version" preference enabled, replace the enabled docs for - // which a newer version is available with that version. + /** + * With the "latest version" preference enabled, replace the enabled docs for + * which a newer version is available with that version. + */ async migrateToLatestVersions() { if (!this.settings.get("autoLatestVersion")) { return; @@ -238,9 +418,14 @@ class App extends Events { } } - // Saving drops the offline data of the docs that are disabled, so the index - // of their latest version has to load before they are replaced. Loads no - // more docs at once than Docs#load does. + /** + * Saving drops the offline data of the docs that are disabled, so the index + * of their latest version has to load before they are replaced. Loads no + * more docs at once than Docs#load does. + * + * @param {Doc[]} docs + * @returns {Promise>} The docs whose index loaded. + */ async loadLatestVersions(docs) { const loaded = new Set(); let i = 0; @@ -271,6 +456,14 @@ class App extends Events { return loaded; } + /** + * Turns a doc on, loading its index and installing it when the user has + * asked for that. + * + * @param {Doc} doc + * @param {() => void} _onSuccess + * @param {() => void} onError + */ enableDoc(doc, _onSuccess, onError) { if (this.docs.contains(doc)) { return; @@ -295,6 +488,7 @@ class App extends Events { doc.load(onSuccess, onError, { writeCache: true }); } + /** Stores the enabled docs and brings the offline database in line. */ saveDocs() { this.settings.setDocs(this.docs.all().map((doc) => doc.slug)); this.db.migrate(); @@ -303,6 +497,7 @@ class App extends Events { : undefined; } + /** Shows what's new since the user's last visit, and starts the update checks. */ welcomeBack() { let visitCount = this.settings.get("count"); this.settings.set("count", ++visitCount); @@ -314,14 +509,16 @@ class App extends Events { return (this.updateChecker = new app.UpdateChecker()); } + /** Reloads the app, keeping the current path. */ reboot() { if (location.pathname !== "/" && location.pathname !== "/settings") { - window.location = `/#${location.pathname}`; + window.location.href = `/#${location.pathname}`; } else { - window.location = "/"; + window.location.href = "/"; } } + /** Drops the cached indexes and reloads the app. */ reload() { this.docs.clearCache(); this.disabledDocs.clearCache(); @@ -332,6 +529,7 @@ class App extends Events { } } + /** Clears every trace of the app and returns to the index. */ reset() { this.localStorage.reset(); this.settings.reset(); @@ -341,9 +539,14 @@ class App extends Events { if (this.serviceWorker != null) { this.serviceWorker.update(); } - window.location = "/"; + window.location.href = "/"; } + /** + * Shows a tip, unless the user has already seen it. + * + * @param {string} tip + */ showTip(tip) { if (this.isSingleDoc()) { return; @@ -356,6 +559,7 @@ class App extends Events { } } + /** Takes the boot screen down. */ hideLoadingScreen() { if ($.overlayScrollbarsEnabled()) { document.body.classList.add("_overlay-scrollbars"); @@ -363,11 +567,13 @@ class App extends Events { document.documentElement.classList.remove("_booting"); } + /** @param {...unknown} args */ onBootError(...args) { this.trigger("bootError"); this.hideLoadingScreen(); } + /** Warns the user that the offline database has outgrown its quota. Once. */ onQuotaExceeded() { if (this.quotaExceeded) { return; @@ -376,6 +582,13 @@ class App extends Events { new app.views.Notif("QuotaExceeded", { autoHide: null }); } + /** + * Warns the user that cookies are blocked, so preferences won't stick. Once. + * + * @param {string} key + * @param {unknown} value What was written. + * @param {unknown} actual What was read back. + */ onCookieBlocked(key, value, actual) { if (this.cookieBlocked) { return; @@ -388,13 +601,14 @@ class App extends Events { }); } + /** @param {...unknown} args The `window.onerror` arguments. */ onWindowError(...args) { if (this.cookieBlocked) { return; } - if (this.isInjectionError(...args)) { + if (this.isInjectionError()) { this.onInjectionError(); - } else if (this.isAppError(...args)) { + } else if (this.isAppError(args[0], /** @type {string} */ (args[1]))) { if (typeof this.previousErrorHandler === "function") { this.previousErrorHandler(...args); } @@ -406,6 +620,7 @@ class App extends Events { } } + /** Warns that an extension has broken the page. Once. */ onInjectionError() { if (!this.injectionError) { this.injectionError = true; @@ -416,6 +631,10 @@ Please check your browser extensions/addons. `); } } + /** + * @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. @@ -428,11 +647,18 @@ Please check your browser extensions/addons. `); ); } + /** + * @param {unknown} error + * @param {string} [file] Where the error came from. + * @returns {boolean} Whether the error came from the app rather than an + * external script. + */ isAppError(error, file) { // Ignore errors from external scripts. return file && file.includes("devdocs") && file.endsWith(".js"); } + /** @returns {boolean} Whether the browser has everything the app needs. */ isSupportedBrowser() { try { const features = { @@ -463,22 +689,26 @@ Please check your browser extensions/addons. `); } } + /** @returns {boolean} Whether the app is showing one doc rather than all of them. */ isSingleDoc() { return document.body.hasAttribute("data-doc"); } + /** @returns {boolean} Whether to use the phone layout. Decided once. */ isMobile() { return this._isMobile != null ? this._isMobile : (this._isMobile = app.views.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()); } + /** @returns {boolean} Whether the app is being served from someone else's domain. */ isInvalidLocation() { return ( this.config.env === "production" && diff --git a/assets/javascripts/app/db.js b/assets/javascripts/app/db.js index 862258d3f8..92e0b3bdf9 100644 --- a/assets/javascripts/app/db.js +++ b/assets/javascripts/app/db.js @@ -1,13 +1,64 @@ -app.DB = class DB { +// @ts-check + +/** + * `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. + * + * @param {DB} db + * @returns {{ useIndexedDB: boolean }} + */ +const useIndexedDBOf = (db) => + /** @type {{ useIndexedDB: boolean }} */ (/** @type {unknown} */ (db)); + +/** + * An IndexedDB event, whose target is the request or transaction that raised + * it. lib.dom types `Event#target` as a bare `EventTarget`. + * + * @typedef {Event & { target: IDBRequest }} IDBEvent + */ + +/** + * How a transaction is opened. + * + * @typedef {object} DBTransactionOptions + * @property {string | string[]} stores + * @property {IDBTransactionMode} mode + * @property {boolean} [ignoreError] Set to `false` to let errors surface. + * @property {boolean} [ignoreAbort] Set to `false` to let aborts surface. + */ + +/** + * The offline store: the docs' pages, kept in IndexedDB. + * + * The database is opened for the length of one batch of work and closed again, + * so every operation goes through `db`, which queues its callback and hands it + * the open database. When IndexedDB can't be used at all — private mode, a + * buggy implementation, an exceeded quota — `useIndexedDB` is turned off and + * every callback is run with no database, which makes the callers fall back to + * the network. + * + * The version number packs the schema version and the user's own version + * together, so that a doc being installed can force an upgrade without + * colliding with a schema change. + */ +class DB { static NAME = "docs"; static VERSION = 15; + /** Probes for IndexedDB support and prepares the callback queue. */ constructor() { this.versionMultipler = $.isIE() ? 1e5 : 1e9; - this.useIndexedDB = this.useIndexedDB(); + // Shadows the method of the same name with the answer it gives. + useIndexedDBOf(this).useIndexedDB = this.useIndexedDB(); this.callbacks = []; } + /** + * Opens the database and runs `fn` with it, or with nothing when IndexedDB + * is unavailable. Callbacks queued while an open is in flight share it. + * + * @param {(db?: IDBDatabase) => void} [fn] + */ db(fn) { if (!this.useIndexedDB) { return fn(); @@ -25,17 +76,26 @@ app.DB = class DB { DB.NAME, DB.VERSION * this.versionMultipler + this.userVersion(), ); - req.onsuccess = (event) => this.onOpenSuccess(event); - req.onerror = (event) => this.onOpenError(event); + req.onsuccess = (event) => + this.onOpenSuccess(/** @type {IDBEvent} */ (event)); + req.onerror = (event) => + this.onOpenError(/** @type {IDBEvent} */ (event)); req.onupgradeneeded = (event) => this.onUpgradeNeeded(event); } catch (error) { this.fail("exception", error); } } + /** + * Runs the queued callbacks, unless the database turns out to be empty or + * buggy. + * + * @param {IDBEvent} event + */ onOpenSuccess(event) { let error; - const db = event.target.result; + const db = /** @type {IDBEvent} */ (/** @type {unknown} */ (event)).target + .result; if (db.objectStoreNames.length === 0) { try { @@ -56,10 +116,11 @@ app.DB = class DB { } } + /** @param {IDBEvent} event */ onOpenError(event) { event.preventDefault(); this.open = false; - const { error } = event.target; + const { error } = /** @type {IDBEvent} */ (event).target; switch (error.name) { case "QuotaExceededError": @@ -76,9 +137,15 @@ app.DB = class DB { } } + /** + * Turns IndexedDB off for the rest of the session and drains the queue. + * + * @param {string} reason + * @param {unknown} [error] + */ fail(reason, error) { this.cachedDocs = null; - this.useIndexedDB = false; + useIndexedDBOf(this).useIndexedDB = false; if (!this.reason) { this.reason = reason; } @@ -92,13 +159,17 @@ app.DB = class DB { } this.runCallbacks(); if (error && reason === "cant_open") { - Raven.captureMessage(`${error.name}: ${error.message}`, { + const { name, message } = /** @type {{ name?: string, message?: string }} */ ( + error + ); + Raven.captureMessage(`${name}: ${message}`, { level: "warning", - fingerprint: [error.name], + fingerprint: [name], }); } } + /** Drops the database and tells the app, so it can warn the user. */ onQuotaExceededError() { this.reset(); this.db(); @@ -106,17 +177,23 @@ app.DB = class DB { Raven.captureMessage("QuotaExceededError", { level: "warning" }); } + /** Reopens at the stored version, to tell a schema bump from a user one. */ onVersionError() { const req = indexedDB.open(DB.NAME); req.onsuccess = (event) => { - return this.handleVersionMismatch(event.target.result.version); + return this.handleVersionMismatch( + /** @type {IDBRequest} */ (event.target).result.version, + ); }; - req.onerror = function (event) { + req.onerror = (event) => { event.preventDefault(); - return this.fail("cant_open", error); + return this.fail("cant_open", req.error); }; } + /** + * @param {number} actualVersion The version the stored database is at. + */ handleVersionMismatch(actualVersion) { if (Math.floor(actualVersion / this.versionMultipler) !== DB.VERSION) { this.fail("version"); @@ -126,6 +203,10 @@ app.DB = class DB { } } + /** + * @param {IDBDatabase} db + * @returns {unknown} The error a known-broken implementation throws, if any. + */ buggyIDB(db) { if (this.checkedBuggyIDB) { return; @@ -142,6 +223,9 @@ app.DB = class DB { } } + /** + * @param {IDBDatabase} [db] Omitted when the database couldn't be opened. + */ runCallbacks(db) { let fn; while ((fn = this.callbacks.shift())) { @@ -149,8 +233,14 @@ app.DB = class DB { } } + /** + * Creates an object store per enabled doc. + * + * @param {IDBVersionChangeEvent} event + */ onUpgradeNeeded(event) { - const db = event.target.result; + const db = /** @type {IDBEvent} */ (/** @type {unknown} */ (event)).target + .result; if (!db) { return; } @@ -178,6 +268,16 @@ app.DB = class DB { } } + /** + * Replaces the doc's stored pages. Whatever was there before is cleared. + * + * @param {Doc} doc + * @param {Record} data The doc's pages, by path. + * @param {number} mtime + * @param {() => void} onSuccess + * @param {(error?: unknown) => void} onError + * @param {boolean} [_retry] Internal: whether a failure may bump the schema and try again. + */ store(doc, data, mtime, onSuccess, onError, _retry) { if (_retry == null) { _retry = true; @@ -241,6 +341,14 @@ app.DB = class DB { }); } + /** + * Removes the doc's pages. + * + * @param {Doc} doc + * @param {() => void} onSuccess + * @param {(error?: unknown) => void} onError + * @param {boolean} [_retry] Internal: whether a failure may bump the schema and try again. + */ unstore(doc, onSuccess, onError, _retry) { if (_retry == null) { _retry = true; @@ -262,7 +370,7 @@ app.DB = class DB { } onSuccess(); }; - txn.onerror = function (event) { + txn.onerror = (event) => { event.preventDefault(); if (txn.error?.name === "NotFoundError" && _retry) { this.migrate(); @@ -285,6 +393,12 @@ app.DB = class DB { // Reads back everything that store() wrote for a doc: its pages and the // mtime it was installed with. Calls back with null when the doc isn't // installed or can't be read. + /** + * Reads the doc's stored pages, for a backup. + * + * @param {Doc} doc + * @param {(result: { mtime: number, data: unknown } | null) => void} callback + */ dump(doc, callback) { this.db((db) => { if (!db || !db.objectStoreNames.contains(doc.slug)) { @@ -313,12 +427,12 @@ app.DB = class DB { }; txn.objectStore("docs").get(doc.slug).onsuccess = (event) => { - mtime = event.target.result; + mtime = /** @type {IDBEvent} */ (event).target.result; }; const req = txn.objectStore(doc.slug).openCursor(); req.onsuccess = (event) => { - const cursor = event.target.result; + const cursor = /** @type {IDBEvent} */ (event).target.result; if (!cursor) { return; } @@ -328,6 +442,10 @@ app.DB = class DB { }); } + /** + * @param {Doc} doc + * @param {(version: number | false) => void} fn The stored `mtime`, or `false` when it isn't installed. + */ version(doc, fn) { const version = this.cachedVersion(doc); if (version != null) { @@ -358,6 +476,10 @@ app.DB = class DB { }); } + /** + * @param {Doc} doc + * @returns {number | false | undefined} `undefined` when the cache isn't loaded yet. + */ cachedVersion(doc) { if (!this.cachedDocs) { return; @@ -365,6 +487,10 @@ app.DB = class DB { return this.cachedDocs[doc.slug] || false; } + /** + * @param {Doc[]} docs + * @param {(versions: Record | false) => void} fn + */ versions(docs, fn) { const versions = this.cachedVersions(docs); if (versions) { @@ -401,6 +527,10 @@ app.DB = class DB { }); } + /** + * @param {Doc[]} docs + * @returns {Record | undefined} `undefined` when the cache isn't loaded yet. + */ cachedVersions(docs) { if (!this.cachedDocs) { return; @@ -412,16 +542,32 @@ app.DB = class DB { return result; } + /** + * Reads an entry's page, from the offline store when it is there and from + * the network otherwise. + * + * @param {Entry} entry + * @param {(html: string) => void} onSuccess + * @param {() => void} onError + * @returns {{ abort: () => void } | undefined} The pending request, when it + * went to the network. + */ load(entry, onSuccess, onError) { if (this.shouldLoadWithIDB(entry)) { - return this.loadWithIDB(entry, onSuccess, () => - this.loadWithXHR(entry, onSuccess, onError) + this.loadWithIDB(entry, onSuccess, () => + this.loadWithXHR(entry, onSuccess, onError), ); - } else { - return this.loadWithXHR(entry, onSuccess, onError); + return; } + return this.loadWithXHR(entry, onSuccess, onError); } + /** + * @param {Entry} entry + * @param {(html: string) => void} onSuccess + * @param {() => void} onError + * @returns {{ abort: () => void }} + */ loadWithXHR(entry, onSuccess, onError) { return ajax({ url: entry.fileUrl(), @@ -431,6 +577,11 @@ app.DB = class DB { }); } + /** + * @param {Entry} entry + * @param {(html: string) => void} onSuccess + * @param {() => void} onError Called when the page isn't stored, so the caller can fall back. + */ loadWithIDB(entry, onSuccess, onError) { return this.db((db) => { if (!db) { @@ -466,6 +617,11 @@ app.DB = class DB { }); } + /** + * Reads every doc's stored `mtime` into memory, once per session. + * + * @param {IDBDatabase} db + */ loadDocsCache(db) { if (this.cachedDocs) { return; @@ -482,7 +638,7 @@ app.DB = class DB { const req = txn.objectStore("docs").openCursor(); req.onsuccess = (event) => { - const cursor = event.target.result; + const cursor = /** @type {IDBEvent} */ (event).target.result; if (!cursor) { return; } @@ -494,6 +650,7 @@ app.DB = class DB { }; } + /** Looks for docs whose store is missing its index page, and drops them. */ checkForCorruptedDocs() { this.db((db) => { let slug; @@ -540,14 +697,19 @@ app.DB = class DB { for (var doc of docs) { txn.objectStore(doc).get("index").onsuccess = (event) => { - if (!event.target.result) { - this.corruptedDocs.push(event.target.source.name); + if (!/** @type {IDBEvent} */ (event).target.result) { + this.corruptedDocs.push( + /** @type {IDBObjectStore} */ ( + /** @type {IDBEvent} */ (event).target.source + ).name, + ); } }; } }); } + /** Forgets the docs `checkForCorruptedDocs` found. */ deleteCorruptedDocs() { this.db((db) => { let doc; @@ -568,12 +730,21 @@ app.DB = class DB { }); } + /** + * @param {Entry} entry + * @returns {boolean} Whether the entry's doc is installed. + */ shouldLoadWithIDB(entry) { return ( this.useIndexedDB && (!this.cachedDocs || this.cachedDocs[entry.doc.slug]) ); } + /** + * @param {IDBDatabase} db + * @param {DBTransactionOptions} options + * @returns {IDBTransaction} + */ idbTransaction(db, options) { app.lastIDBTransaction = [options.stores, options.mode]; const txn = db.transaction(options.stores, options.mode); @@ -590,12 +761,17 @@ app.DB = class DB { return txn; } + /** Deletes the whole database. */ reset() { try { indexedDB?.deleteDatabase(DB.NAME); } catch (error) {} } + /** + * @returns {boolean} Whether IndexedDB can be used at all. Replaced by its + * own result in the constructor. + */ useIndexedDB() { try { if (!app.isSingleDoc() && window.indexedDB) { @@ -609,15 +785,22 @@ app.DB = class DB { } } + /** Bumps the user's schema version, forcing the next open to upgrade. */ migrate() { app.settings.set("schema", this.userVersion() + 1); } + /** @param {number} version */ setUserVersion(version) { app.settings.set("schema", version); } + /** @returns {number} */ userVersion() { 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 9034a90595..e8c2a839ff 100644 --- a/assets/javascripts/app/offline_backup.js +++ b/assets/javascripts/app/offline_backup.js @@ -1,20 +1,56 @@ -// Exports the offline data (the pages stored in IndexedDB and the index files -// cached in localStorage) to a JSON file, and imports it back — either to -// restore a backup after the browser evicted the data, or to move the -// documentations to another computer without downloading them again. -app.OfflineBackup = class OfflineBackup { +// @ts-check + +/** + * One doc as it appears in a backup file. Unrelated to the Entry model: these + * are the records the backup's `docs` array holds. + * + * @typedef {object} BackupEntry + * @property {string} slug + * @property {number} mtime The build the stored pages came from. + * @property {Record} db The doc's pages, by path. + * @property {unknown} [index] The doc's entry index, when the backup carried it. + */ + +/** + * What an import ended up doing. + * + * @typedef {object} ImportSummary + * @property {Doc[]} docs The docs that were stored. + * @property {string[]} skipped Slugs in the file that this app doesn't know, or that were unusable. + * @property {string[]} failed The slugs of the docs whose store failed. + * @property {number} enabled How many of the docs weren't enabled before. + */ + +/** + * Exports the offline data (the pages stored in IndexedDB and the index files + * cached in localStorage) to a JSON file, and imports it back — either to + * restore a backup after the browser evicted the data, or to move the + * documentations to another computer without downloading them again. + */ +class OfflineBackup { static TYPE = "devdocs-offline"; static VERSION = 1; static MIME_TYPE = "application/json"; + /** + * @param {Doc[]} docs + * @returns {string} The name to save the backup under. + */ filename(docs) { const date = new Date().toISOString().slice(0, 10); const name = docs.length === 1 ? docs[0].slug : "offline"; return `devdocs-${name}-${date}.json`; } - // Calls back with a Blob containing every installed doc among `docs`, and - // the number of docs it holds. Docs that aren't installed are skipped. + /** + * Calls back with a Blob containing every installed doc among `docs`, and + * the number of docs it holds. Docs that aren't installed are skipped. + * + * @param {Doc[]} docs + * @param {(doc: unknown, i: number, total: number) => void} onProgress + * @param {(blob: Blob, count: number) => void} onSuccess + * @param {(reason: string) => void} onError + */ export(docs, onProgress, onSuccess, onError) { const chunks = [ `{"type":"${OfflineBackup.TYPE}","version":${ @@ -54,6 +90,11 @@ app.OfflineBackup = class OfflineBackup { next(); } + /** + * @param {Doc} doc + * @param {{ mtime: number, data: unknown }} result The doc's stored database. + * @returns {unknown} One entry of the backup's `docs` array. + */ serializeDoc(doc, result) { const entry = { slug: doc.slug, mtime: result.mtime, db: result.data }; const index = app.localStorage.get(doc.slug); @@ -65,6 +106,14 @@ app.OfflineBackup = class OfflineBackup { return entry; } + /** + * Reads a backup file and stores the docs it holds. + * + * @param {File | null} file + * @param {(doc: unknown, i: number, total: number) => void} onProgress + * @param {(summary: ImportSummary) => void} onSuccess + * @param {(reason: string, skipped?: string[]) => void} onError + */ import(file, onProgress, onSuccess, onError) { if (!file || (file.type && file.type !== OfflineBackup.MIME_TYPE)) { onError("invalid"); @@ -75,7 +124,7 @@ app.OfflineBackup = class OfflineBackup { reader.onload = () => { const data = (() => { try { - return JSON.parse(reader.result); + return JSON.parse(/** @type {string} */ (reader.result)); } catch (error) {} })(); @@ -94,6 +143,14 @@ app.OfflineBackup = class OfflineBackup { reader.readAsText(file); } + /** + * Stores each valid entry, one at a time. + * + * @param {unknown[]} entries The backup's `docs` array, not yet validated. + * @param {(doc: unknown, i: number, total: number) => void} onProgress + * @param {(summary: ImportSummary) => void} onSuccess + * @param {(reason: string, skipped?: string[]) => void} onError + */ importDocs(entries, onProgress, onSuccess, onError) { const queue = []; const skipped = []; @@ -103,7 +160,8 @@ app.OfflineBackup = class OfflineBackup { if (doc) { queue.push([doc, entry]); } else { - skipped.push(typeof entry?.slug === "string" ? entry.slug : "?"); + const slug = /** @type {{ slug?: unknown }} */ (entry)?.slug; + skipped.push(typeof slug === "string" ? slug : "?"); } } @@ -153,38 +211,61 @@ app.OfflineBackup = class OfflineBackup { next(); } - // Storing a doc clears whatever was installed before it, so an entry that - // isn't usable has to be rejected rather than wipe a working installation. - // The index page is what DB#checkForCorruptedDocs looks for. + /** + * Storing a doc clears whatever was installed before it, so an entry that + * isn't usable has to be rejected rather than wipe a working installation. + * The index page is what DB#checkForCorruptedDocs looks for. + * + * @param {unknown} entry Straight out of the file. + * @returns {entry is BackupEntry} Narrows the entry for the caller. + */ isValidEntry(entry) { + // Read optimistically; the checks below are what decide whether it holds. + const e = /** @type {BackupEntry} */ (entry); return ( - entry != null && - typeof entry.slug === "string" && - Number.isSafeInteger(entry.mtime) && - entry.mtime > 0 && - entry.db?.constructor === Object && - typeof entry.db.index === "string" && - entry.db.index.length > 0 + e != null && + typeof e.slug === "string" && + Number.isSafeInteger(e.mtime) && + e.mtime > 0 && + e.db?.constructor === Object && + typeof e.db.index === "string" && + e.db.index.length > 0 ); } + /** + * @param {unknown} index + * @returns {index is { entries: unknown[], types: unknown[] }} Whether the + * entry carries a usable index file. + */ isValidIndex(index) { + // Read optimistically; the checks below are what decide whether it holds. + const i = /** @type {{ entries: unknown, types: unknown }} */ (index); return ( - index?.constructor === Object && - Array.isArray(index.entries) && - Array.isArray(index.types) + i?.constructor === Object && + Array.isArray(i.entries) && + Array.isArray(i.types) ); } + /** + * @param {string} slug + * @returns {Doc | undefined} The doc, enabled or not. + */ findDoc(slug) { return ( app.docs.findBy("slug", slug) || app.disabledDocs.findBy("slug", slug) ); } - // Enabling the docs up-front is what makes their object stores exist: the - // schema bump triggers DB#onUpgradeNeeded, which only creates stores for the - // enabled docs. Returns the number of docs that weren't enabled before. + /** + * Enabling the docs up-front is what makes their object stores exist: the + * schema bump triggers DB#onUpgradeNeeded, which only creates stores for the + * enabled docs. + * + * @param {Doc[]} docs + * @returns {number} How many docs weren't enabled before. + */ enableDocs(docs) { let enabled = 0; @@ -204,4 +285,8 @@ app.OfflineBackup = 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 4772e49737..a3f05df9c2 100644 --- a/assets/javascripts/app/router.js +++ b/assets/javascripts/app/router.js @@ -1,4 +1,13 @@ -app.Router = class Router extends Events { +// @ts-check + +/** + * Maps paths to route events. + * + * Each entry in `routes` names a method, which is registered with `page` in + * 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 { static routes = [ ["*", "before"], ["/", "root"], @@ -13,27 +22,40 @@ app.Router = class Router extends Events { ["*", "notFound"], ]; + /** Registers every route with `page` and normalizes the initial path. */ constructor() { super(); - for (var [path, method] of this.constructor.routes) { + for (var [path, method] of Router.routes) { page(path, this[method].bind(this)); } this.setInitialPath(); } + /** Begins routing, dispatching the current path. */ start() { page.start(); } + /** @param {string} path */ show(path) { page.show(path); } + /** + * Emits the route's event, then `after`. + * + * @param {string} name + */ triggerRoute(name) { this.trigger(name, this.context); this.trigger("after", name, this.context); } + /** + * @param {Context} context + * @param {() => unknown} next + * @returns {unknown} A path to redirect to, or nothing when the route handled it. + */ before(context, next) { const previousContext = this.context; this.context = context; @@ -48,6 +70,11 @@ app.Router = class Router extends Events { } } + /** + * @param {Context} context + * @param {() => unknown} next + * @returns {unknown} A path to redirect to, or nothing when the route handled it. + */ doc(context, next) { let doc; if ( @@ -64,6 +91,11 @@ app.Router = class Router extends Events { } } + /** + * @param {Context} context + * @param {() => unknown} next + * @returns {unknown} A path to redirect to, or nothing when the route handled it. + */ type(context, next) { const doc = app.docs.findBySlug(context.params.doc); const type = doc?.types?.findBy("slug", context.params.type); @@ -78,6 +110,11 @@ app.Router = class Router extends Events { } } + /** + * @param {Context} context + * @param {() => unknown} next + * @returns {unknown} A path to redirect to, or nothing when the route handled it. + */ entry(context, next) { const doc = app.docs.findBySlug(context.params.doc); if (!doc) { @@ -109,6 +146,7 @@ app.Router = class Router extends Events { return next(); } + /** @returns {string | undefined} */ root() { if (app.isSingleDoc()) { return "/"; @@ -116,6 +154,10 @@ app.Router = class Router extends Events { this.triggerRoute("root"); } + /** + * @param {Context} context + * @returns {string | undefined} A redirect to the hash form when in single-doc mode. + */ settings(context) { if (app.isSingleDoc()) { return `/#/${context.path}`; @@ -123,6 +165,10 @@ app.Router = class Router extends Events { this.triggerRoute("settings"); } + /** + * @param {Context} context + * @returns {string | undefined} A redirect to the hash form when in single-doc mode. + */ offline(context) { if (app.isSingleDoc()) { return `/#/${context.path}`; @@ -130,6 +176,10 @@ app.Router = class Router extends Events { this.triggerRoute("offline"); } + /** + * @param {Context} context + * @returns {string | undefined} A redirect to the hash form when in single-doc mode. + */ about(context) { if (app.isSingleDoc()) { return `/#/${context.path}`; @@ -138,6 +188,10 @@ app.Router = class Router extends Events { this.triggerRoute("page"); } + /** + * @param {Context} context + * @returns {string | undefined} A redirect to the hash form when in single-doc mode. + */ news(context) { if (app.isSingleDoc()) { return `/#/${context.path}`; @@ -146,6 +200,10 @@ app.Router = class Router extends Events { this.triggerRoute("page"); } + /** + * @param {Context} context + * @returns {string | undefined} A redirect to the hash form when in single-doc mode. + */ help(context) { if (app.isSingleDoc()) { return `/#/${context.path}`; @@ -154,10 +212,12 @@ app.Router = class Router extends Events { this.triggerRoute("page"); } + /** @param {unknown} context */ notFound(context) { this.triggerRoute("notFound"); } + /** @returns {boolean} Whether the current page is the doc or app index. */ isIndex() { return ( this.context?.path === "/" || @@ -165,10 +225,15 @@ app.Router = class Router extends Events { ); } + /** @returns {boolean} */ isSettings() { return this.context?.path === "/settings"; } + /** + * Normalizes the path the document was loaded with, and follows the + * `#/path` form that single-doc mode redirects through. + */ setInitialPath() { // Remove superfluous forward slashes at the beginning of the path let path = location.pathname.replace(/^\/{2,}/g, "/"); @@ -183,12 +248,18 @@ app.Router = class Router extends Events { } } + /** @returns {string | undefined} The path encoded in the hash, if there is one. */ getInitialPathFromHash() { try { return new RegExp("#/(.+)").exec(decodeURIComponent(location.hash))?.[1]; } catch (error) {} } + /** + * Replaces the hash without dispatching a route. + * + * @param {string} [hash] Including the leading `#`. + */ replaceHash(hash) { page.replace( location.pathname + location.search + (hash || ""), @@ -196,4 +267,8 @@ app.Router = class Router extends Events { 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.Router = Router; diff --git a/assets/javascripts/app/searcher.js b/assets/javascripts/app/searcher.js index 7cd6e82614..0be07d779f 100644 --- a/assets/javascripts/app/searcher.js +++ b/assets/javascripts/app/searcher.js @@ -1,3 +1,5 @@ +// @ts-check + // // Match functions // @@ -145,7 +147,22 @@ function scoreFuzzyMatch() { // Searchers // -app.Searcher = class Searcher extends Events { +/** + * @typedef {object} SearcherOptions + * @property {number} [max_results] + * @property {number} [fuzzy_min_length] Shortest query that is also matched fuzzily. + */ + +/** + * Scores every candidate against a query and emits the best matches. + * + * The work is spread over chunks with a timeout between them so that typing + * stays responsive, and results are emitted as they are found: `results` may + * fire several times before `end`. The match functions above run against + * module-level state rather than arguments, which is what keeps the inner + * loop cheap. + */ +class Searcher extends Events { static CHUNK_SIZE = 20000; static DEFAULTS = { @@ -166,6 +183,13 @@ app.Searcher = class Searcher extends Events { static ELLIPSIS = "..."; static STRING = "string"; + /** + * Reduces a string to the form matches are made against: lowercased, with + * separators collapsed to dots and decoration stripped. + * + * @param {string} string + * @returns {string} + */ static normalizeString(string) { return string .toLowerCase() @@ -178,16 +202,31 @@ app.Searcher = class Searcher extends Events { .replace(Searcher.WHITESPACE_REGEXP, Searcher.EMPTY_STRING); } + /** + * Like `normalizeString`, but keeps a trailing separator meaningful. + * + * @param {string} string + * @returns {string} + */ static normalizeQuery(string) { string = this.normalizeString(string); return string.replace(Searcher.EOS_SEPARATORS_REGEXP, "$1."); } + /** @param {SearcherOptions} [options] */ constructor(options) { super(); this.options = { ...Searcher.DEFAULTS, ...(options || {}) }; } + /** + * Starts a search, abandoning whatever was running. + * + * @param {Model[]} data The models to search. They flow back out through + * the `results` event unchanged. + * @param {string} attr The attribute to match against; a string or an array of them. + * @param {string} q + */ find(data, attr, q) { this.kill(); @@ -203,8 +242,9 @@ app.Searcher = class Searcher extends Events { } } + /** Prepares the module-level state the match functions read. */ setup() { - query = this.query = this.constructor.normalizeQuery(this.query); + query = this.query = Searcher.normalizeQuery(this.query); queryLength = query.length; this.dataLength = this.data.length; this.matchers = [exactMatch]; @@ -212,6 +252,7 @@ app.Searcher = class Searcher extends Events { this.setupFuzzy(); } + /** Adds the fuzzy matcher, for queries long enough to warrant it. */ setupFuzzy() { if (queryLength >= this.options.fuzzy_min_length) { fuzzyRegexp = this.queryToFuzzyRegexp(query); @@ -221,10 +262,12 @@ app.Searcher = class Searcher extends Events { } } + /** @returns {boolean} Whether the query is worth running. */ isValid() { return queryLength > 0 && query !== SEPARATOR; } + /** Emits a final empty result set if nothing matched, then `end`. */ end() { if (!this.totalResults) { this.triggerResults([]); @@ -233,6 +276,7 @@ app.Searcher = class Searcher extends Events { this.free(); } + /** Abandons a search in progress. */ kill() { if (this.timeout) { clearTimeout(this.timeout); @@ -240,6 +284,7 @@ app.Searcher = class Searcher extends Events { } } + /** Drops the references the search held, so the data can be collected. */ free() { this.data = null; this.attr = null; @@ -253,6 +298,7 @@ app.Searcher = class Searcher extends Events { this.timeout = null; } + /** Runs the next matcher over the data, or ends the search. */ match() { if (!this.foundEnough() && (this.matcher = this.matchers.shift())) { this.setupMatcher(); @@ -262,11 +308,13 @@ app.Searcher = class Searcher extends Events { } } + /** Resets the per-matcher state: the cursor and the score buckets. */ setupMatcher() { this.cursor = 0; this.scoreMap = new Array(101); } + /** Runs one chunk, then either schedules the next or moves to the next matcher. */ matchChunks() { this.matchChunk(); @@ -278,31 +326,39 @@ app.Searcher = class Searcher extends Events { } } + /** Scores `chunkSize()` candidates, advancing the cursor. */ matchChunk() { ({ matcher } = this); for (let j = 0, end = this.chunkSize(); j < end; j++) { - value = this.data[this.cursor][this.attr]; - if (value.split) { - // string + const model = this.data[this.cursor]; + // The attribute is named at run time, and holds either the model's + // searchable string or every spelling of it. + const attribute = /** @type {string | string[]} */ ( + /** @type {Record} */ (/** @type {unknown} */ (model))[ + this.attr + ] + ); + if (typeof attribute === "string") { + value = attribute; valueLength = value.length; if ((score = matcher())) { - this.addResult(this.data[this.cursor], score); + this.addResult(model, score); } } else { - // array score = 0; - for (value of Array.from(this.data[this.cursor][this.attr])) { + for (value of attribute) { valueLength = value.length; score = Math.max(score, matcher() || 0); } if (score > 0) { - this.addResult(this.data[this.cursor], score); + this.addResult(model, score); } } this.cursor++; } } + /** @returns {number} How many candidates are left in this chunk. */ chunkSize() { if (this.cursor + Searcher.CHUNK_SIZE > this.dataLength) { return this.dataLength % Searcher.CHUNK_SIZE; @@ -311,14 +367,22 @@ app.Searcher = class Searcher extends Events { } } + /** @returns {boolean} Whether enough perfect matches were found to stop early. */ scoredEnough() { return this.scoreMap[100]?.length >= this.options.max_results; } + /** @returns {boolean} Whether enough matches were found overall. */ foundEnough() { return this.totalResults >= this.options.max_results; } + /** + * Files a match under its rounded score. + * + * @param {Model} object + * @param {number} score + */ addResult(object, score) { let name; ( @@ -327,6 +391,7 @@ app.Searcher = class Searcher extends Events { this.totalResults++; } + /** @returns {unknown[]} The best matches so far, highest score first. */ getResults() { const results = []; for (let j = this.scoreMap.length - 1; j >= 0; j--) { @@ -338,6 +403,7 @@ app.Searcher = class Searcher extends Events { return results.slice(0, this.options.max_results); } + /** Emits the matches found so far, if there are any. */ sendResults() { const results = this.getResults(); if (results.length) { @@ -345,14 +411,25 @@ app.Searcher = class Searcher extends Events { } } + /** @param {unknown[]} results */ triggerResults(results) { this.trigger("results", results); } + /** + * Yields to the event loop between chunks. + * + * @param {() => void} fn + * @returns {number | void} The timeout handle, when there is one. + */ delay(fn) { return (this.timeout = setTimeout(fn, 1)); } + /** + * @param {string} string + * @returns {RegExp} A regexp matching the characters in order, e.g. `abc` to `/a.*?b.*?c/`. + */ queryToFuzzyRegexp(string) { const chars = string.split(""); for (i = 0; i < chars.length; i++) { @@ -361,9 +438,18 @@ app.Searcher = class Searcher extends Events { } return new RegExp(chars.join(".*?")); // abc -> /a.*?b.*?c.*?/ } -}; +} + +// 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; -app.SynchronousSearcher = class SynchronousSearcher extends app.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 { + /** Collects each matcher's results, instead of emitting them as it goes. */ match() { if (this.matcher) { if (!this.allResults) { @@ -371,26 +457,38 @@ app.SynchronousSearcher = class SynchronousSearcher extends app.Searcher { } this.allResults.push(...this.getResults()); } - return super.match(...arguments); + return super.match(); } + /** @inheritdoc */ free() { this.allResults = null; - return super.free(...arguments); + return super.free(); } + /** Emits every result collected, then ends. */ end() { this.sendResults(true); - return super.end(...arguments); + return super.end(); } + /** @param {boolean} [end] Results are only emitted once, at the end. */ sendResults(end) { if (end && this.allResults?.length) { return this.triggerResults(this.allResults); } } + /** + * Runs `fn` straight away, so the search never yields. + * + * @param {() => void} fn + */ delay(fn) { 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 4c35a32c32..ee4d189eb9 100644 --- a/assets/javascripts/app/serviceworker.js +++ b/assets/javascripts/app/serviceworker.js @@ -1,8 +1,18 @@ -app.ServiceWorker = class ServiceWorker extends Events { +// @ts-check + +/** + * 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 { + /** @returns {boolean} Whether the browser supports service workers and the build enables them. */ static isEnabled() { return !!navigator.serviceWorker && app.config.service_worker_enabled; } + /** Registers the worker and starts watching for updates. */ constructor() { super(); this.onStateChange = this.onStateChange.bind(this); @@ -17,6 +27,11 @@ app.ServiceWorker = class ServiceWorker extends Events { ); } + /** + * Checks for a new worker, notifying the user if one is ready. + * + * @returns {Promise | undefined} + */ update() { if (!this.registration) { return; @@ -25,6 +40,11 @@ app.ServiceWorker = class ServiceWorker extends Events { return this.registration.update().catch(() => {}); } + /** + * Checks for a new worker without notifying the user. + * + * @returns {Promise | undefined} + */ updateInBackground() { if (!this.registration) { return; @@ -33,15 +53,18 @@ app.ServiceWorker = class ServiceWorker extends Events { return this.registration.update().catch(() => {}); } + /** @returns {Promise} Resolves once the app has been rebooted onto the new worker. */ reload() { return this.updateInBackground().then(() => app.reboot()); } + /** @param {ServiceWorkerRegistration} registration */ updateRegistration(registration) { this.registration = registration; $.on(this.registration, "updatefound", () => this.onUpdateFound()); } + /** Watches the worker being installed, so that its readiness can be reported. */ onUpdateFound() { if (this.installingRegistration) { $.off(this.installingRegistration, "statechange", this.onStateChange); @@ -50,6 +73,7 @@ app.ServiceWorker = class ServiceWorker extends Events { $.on(this.installingRegistration, "statechange", this.onStateChange); } + /** Reports readiness once the new worker is installed and one is already in control. */ onStateChange() { if ( this.installingRegistration && @@ -61,9 +85,14 @@ app.ServiceWorker = class ServiceWorker extends Events { } } + /** Emits `updateready`, unless the check was a background one. */ onUpdateReady() { if (this.notifyUpdate) { this.trigger("updateready"); } } -}; +} + +// 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 113005a65d..68e19adafa 100644 --- a/assets/javascripts/app/settings.js +++ b/assets/javascripts/app/settings.js @@ -1,4 +1,53 @@ -app.Settings = class Settings { +// @ts-check + +/** + * A setting the user turns on or off. + * + * It is a boolean going in, but CookiesStore writes `true` as `1` and parses + * the digit back out on read, so it comes back as a number. A setting that was + * never written falls back to its default, which is a real boolean. Both are + * truthy or falsy as intended; only a strict comparison would go wrong. + * + * @typedef {boolean | number} StoredFlag + */ + +/** + * What each setting holds. + * + * @typedef {object} SettingsValues + * @property {number} count How many times the user has visited. + * @property {StoredFlag} hideDisabled + * @property {StoredFlag} hideIntro + * @property {number} news When the changelog was last read, as a Unix timestamp. + * @property {StoredFlag} manualUpdate + * @property {number} schema The offline database's schema version. + * @property {StoredFlag} analyticsConsent Written as 1 or 0 rather than deleted, + * so that consent that was refused is remembered. + * @property {string} theme `"auto"`, `"dark"` or `"default"`. + * @property {number} spaceScroll How far space scrolls, as a fraction of the viewport. + * @property {number | string} spaceTimeout How long after typing space stops + * scrolling, in seconds. Not an integer, so it comes back as a string. + * @property {StoredFlag} noDocSpecificIcon + * @property {StoredFlag} autoLatestVersion + * @property {number} version The build the user last saw. + * @property {StoredFlag} fastScroll + * @property {StoredFlag} arrowScroll + * @property {StoredFlag} noAutofocus + * @property {StoredFlag} autoInstall + * @property {number} dark Legacy; replaced by `theme`. + * @property {string} docs The enabled slugs, separated by `/`. + * @property {string} tips The tips already shown, separated by `/`. + * @property {string} layout The layout classes, separated by spaces. + * @property {number} size The sidebar's width, in pixels. + */ + +/** + * The user's preferences, stored in cookies so that the server can read them. + * + * `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 { static PREFERENCE_KEYS = [ "hideDisabled", "hideIntro", @@ -29,6 +78,7 @@ app.Settings = class Settings { "_text-justify-hyphenate", ]; + /** @type {Record} */ static defaults = { count: 0, hideDisabled: false, @@ -44,6 +94,7 @@ app.Settings = class Settings { autoLatestVersion: false, }; + /** Opens the cookie store and starts following the system colour scheme. */ constructor() { this.store = new CookiesStore(); this.cache = {}; @@ -55,61 +106,94 @@ app.Settings = class Settings { } } + /** + * Reads a setting, falling back to its default. Cached after the first read. + * + * @template {keyof SettingsValues} K + * @param {K} key + * @returns {SettingsValues[K]} + */ get(key) { + // The store and the defaults are both keyed by name but hold mixed types, + // so the value is narrowed to the one this key stands for on the way out. + const cache = /** @type {Record} */ (this.cache); + const cast = (/** @type {unknown} */ value) => + /** @type {SettingsValues[K]} */ (value); + let left; - if (this.cache.hasOwnProperty(key)) { - return this.cache[key]; + if (cache.hasOwnProperty(key)) { + return cast(cache[key]); } - this.cache[key] = - (left = this.store.get(key)) != null - ? left - : this.constructor.defaults[key]; - if (key === "theme" && this.cache[key] === "auto" && !this.darkModeQuery) { - return (this.cache[key] = "default"); + cache[key] = + (left = this.store.get(key)) != null ? left : Settings.defaults[key]; + if (key === "theme" && cache[key] === "auto" && !this.darkModeQuery) { + return cast((cache[key] = "default")); } else { - return this.cache[key]; + return cast(cache[key]); } } + /** + * @template {keyof SettingsValues} K + * @param {K} key + * @param {SettingsValues[K]} value + */ set(key, value) { - this.store.set(key, value); - delete this.cache[key]; + this.store.set(key, /** @type {string | number | boolean} */ (value)); + delete (/** @type {Record} */ (this.cache))[key]; if (key === "theme") { - this.setTheme(value); + this.setTheme(/** @type {string} */ (value)); } } + /** @param {string} key */ del(key) { this.store.del(key); - delete this.cache[key]; + delete (/** @type {Record} */ (this.cache))[key]; } + /** @returns {boolean | undefined} Whether the user has ever chosen a set of docs. */ hasDocs() { try { return !!this.store.get("docs"); } catch (error) {} } + /** @returns {string[]} The enabled doc slugs, or the defaults. */ getDocs() { - return this.store.get("docs")?.split("/") || app.config.default_docs; + return ( + /** @type {string | undefined} */ (this.store.get("docs"))?.split("/") || + app.config.default_docs + ); } + /** @param {string[]} docs */ setDocs(docs) { this.set("docs", docs.join("/")); } + /** @returns {string[]} The tips the user has already been shown. */ getTips() { - return this.store.get("tips")?.split("/") || []; + return /** @type {string | undefined} */ (this.store.get("tips"))?.split("/") || []; } + /** @param {string[]} tips */ setTips(tips) { this.set("tips", tips.join("/")); } + /** + * Applies a layout class and remembers it. + * + * @param {string} name One of `LAYOUTS`. + * @param {boolean} enable + */ setLayout(name, enable) { this.toggleLayout(name, enable); - const layout = (this.store.get("layout") || "").split(" "); + const layout = /** @type {string} */ ( + this.store.get("layout") || "" + ).split(" "); $.arrayDelete(layout, ""); if (enable) { @@ -127,19 +211,28 @@ app.Settings = class Settings { } } + /** + * @param {string} name + * @returns {boolean} + */ hasLayout(name) { - const layout = (this.store.get("layout") || "").split(" "); + const layout = /** @type {string} */ ( + this.store.get("layout") || "" + ).split(" "); return layout.includes(name); } + /** @param {number} value The sidebar width, in pixels. */ setSize(value) { this.set("size", value); } + /** @returns {Record} Every stored setting, unparsed. */ dump() { return this.store.dump(); } + /** @returns {Record} The user's preferences, without the app's own bookkeeping. */ export() { const data = this.dump(); for (var key of Settings.INTERNAL_KEYS) { @@ -148,6 +241,11 @@ app.Settings = class Settings { return data; } + /** + * Replaces the user's preferences with `data`, dropping any it omits. + * + * @param {Record} data + */ import(data) { let key, value; const object = this.export(); @@ -165,11 +263,13 @@ app.Settings = class Settings { } } + /** Clears every setting. */ reset() { this.store.reset(); this.cache = {}; } + /** Applies the stored theme and layout to the document. Runs before the first paint. */ initLayout() { if (this.get("dark") === 1) { this.set("theme", "dark"); @@ -182,6 +282,7 @@ app.Settings = class Settings { this.initSidebarWidth(); } + /** @param {string} theme `"auto"`, `"dark"` or `"default"`. */ setTheme(theme) { if (theme === "auto") { theme = this.darkModeQuery.matches ? "dark" : "default"; @@ -192,6 +293,7 @@ app.Settings = class Settings { this.updateColorMeta(); } + /** Points the `theme-color` meta at the header colour of the current theme. */ updateColorMeta() { const color = getComputedStyle(document.documentElement) .getPropertyValue("--headerBackground") @@ -199,6 +301,10 @@ app.Settings = class Settings { $("meta[name=theme-color]").setAttribute("content", color); } + /** + * @param {string} layout + * @param {boolean} enable + */ toggleLayout(layout, enable) { const { classList } = document.body; // sidebar is always shown for settings; its state is updated in app.views.Settings @@ -208,10 +314,15 @@ app.Settings = class Settings { classList.toggle("_overlay-scrollbars", $.overlayScrollbarsEnabled()); } + /** Applies the stored sidebar width. */ initSidebarWidth() { const size = this.get("size"); if (size) { document.documentElement.style.setProperty("--sidebarWidth", size + "px"); } } -}; +} + +// 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 05e09cf7bd..79e0846211 100644 --- a/assets/javascripts/app/shortcuts.js +++ b/assets/javascripts/app/shortcuts.js @@ -1,4 +1,19 @@ -app.Shortcuts = class Shortcuts extends Events { +// @ts-check + +/** + * A key event whose target is read loosely: the handlers check for form-field + * properties that only some elements have. + * + * @typedef {KeyboardEvent & { target: HTMLElement & Partial }} ShortcutEvent + */ + +/** + * Translates key events into shortcut events. + * + * Handlers return `false` to swallow the event; anything else lets it through. + */ +class Shortcuts extends Events { + /** Starts listening for key events. */ constructor() { super(); this.onKeydown = this.onKeydown.bind(this); @@ -7,33 +22,40 @@ app.Shortcuts = class Shortcuts extends Events { this.start(); } + /** Begins listening for key events. */ start() { $.on(document, "keydown", this.onKeydown); $.on(document, "keypress", this.onKeypress); } + /** Stops listening for key events. */ stop() { $.off(document, "keydown", this.onKeydown); $.off(document, "keypress", this.onKeypress); } + /** @returns {boolean} Whether the arrow keys scroll rather than move the selection. */ swapArrowKeysBehavior() { - return app.settings.get("arrowScroll"); + return !!app.settings.get("arrowScroll"); } + /** @returns {number} How far space scrolls, as a fraction of the viewport. */ spaceScroll() { return app.settings.get("spaceScroll"); } + /** Shows the key-navigation tip, once. */ showTip() { app.showTip("KeyNav"); return (this.showTip = null); } + /** @returns {number | string} How long after typing space stops scrolling, in seconds. */ spaceTimeout() { return app.settings.get("spaceTimeout"); } + /** @param {ShortcutEvent} event */ onKeydown(event) { if (this.buggyEvent(event)) { return; @@ -59,6 +81,7 @@ app.Shortcuts = class Shortcuts extends Events { } } + /** @param {ShortcutEvent} event */ onKeypress(event) { if ( this.buggyEvent(event) || @@ -74,6 +97,11 @@ app.Shortcuts = class Shortcuts extends Events { } } + /** + * @param {ShortcutEvent} event + * @param {boolean} [_force] + * @returns {unknown} `false` to swallow the event; anything else lets it through. + */ handleKeydownEvent(event, _force) { if ( !_force && @@ -108,7 +136,7 @@ app.Shortcuts = class Shortcuts extends Events { event.target.type === "search" && this.spaceScroll() && (!this.lastKeypress || - this.lastKeypress < Date.now() - this.spaceTimeout() * 1000) + this.lastKeypress < Date.now() - Number(this.spaceTimeout()) * 1000) ) { this.trigger("pageDown"); return false; @@ -159,6 +187,12 @@ app.Shortcuts = class Shortcuts extends Events { } } + /** + * Handles Ctrl/Cmd chords. + * + * @param {ShortcutEvent} event + * @returns {unknown} `false` to swallow the event; anything else lets it through. + */ handleKeydownSuperEvent(event) { switch (event.which) { case 13: @@ -187,6 +221,11 @@ app.Shortcuts = class Shortcuts extends Events { } } + /** + * @param {ShortcutEvent} event + * @param {boolean} [_force] + * @returns {unknown} `false` to swallow the event; anything else lets it through. + */ handleKeydownShiftEvent(event, _force) { if ( !_force && @@ -220,6 +259,11 @@ app.Shortcuts = class Shortcuts extends Events { } } + /** + * @param {ShortcutEvent} event + * @param {boolean} [_force] + * @returns {unknown} `false` to swallow the event; anything else lets it through. + */ handleKeydownAltEvent(event, _force) { if ( !_force && @@ -273,6 +317,10 @@ app.Shortcuts = class Shortcuts extends Events { } } + /** + * @param {ShortcutEvent} event + * @returns {unknown} `false` to swallow the event; anything else lets it through. + */ handleKeypressEvent(event) { if (event.which === 63 && !event.target.value) { this.trigger("help"); @@ -282,6 +330,10 @@ app.Shortcuts = class Shortcuts extends Events { } } + /** + * @param {ShortcutEvent} event + * @returns {boolean} Whether the event is one the browser reports incorrectly. + */ buggyEvent(event) { try { event.target; @@ -292,4 +344,8 @@ app.Shortcuts = class Shortcuts extends Events { return 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.Shortcuts = Shortcuts; diff --git a/assets/javascripts/app/update_checker.js b/assets/javascripts/app/update_checker.js index 82d3cc92c6..6e24d5e5d3 100644 --- a/assets/javascripts/app/update_checker.js +++ b/assets/javascripts/app/update_checker.js @@ -1,4 +1,8 @@ -app.UpdateChecker = class UpdateChecker { +// @ts-check + +/** Watches for new builds of the app and new versions of the installed docs. */ +class UpdateChecker { + /** Starts watching for new builds and checks the docs once. */ constructor() { this.lastCheck = Date.now(); @@ -10,6 +14,10 @@ app.UpdateChecker = class UpdateChecker { setTimeout(() => this.checkDocs(), 0); } + /** + * Checks whether a new build of the app is available, by asking the service + * worker to update or, without one, by re-requesting the app bundle. + */ check() { if (app.serviceWorker) { app.serviceWorker.update(); @@ -26,10 +34,12 @@ app.UpdateChecker = class UpdateChecker { } } + /** Offers the user a reload. */ onUpdateReady() { new app.views.Notif("UpdateReady", { autoHide: null }); } + /** Updates the installed docs, or offers to when updates are manual. */ checkDocs() { if (!app.settings.get("manualUpdate")) { app.docs.updateInBackground(); @@ -42,14 +52,20 @@ app.UpdateChecker = class UpdateChecker { } } + /** Offers the user a doc update. */ onDocsUpdateReady() { new app.views.Notif("UpdateDocs", { autoHide: null }); } + /** Re-checks when the tab is focused, at most every six hours. */ onFocus() { if (Date.now() - this.lastCheck > 21600e3) { this.lastCheck = Date.now(); this.check(); } } -}; +} + +// 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 dd5aaec99b..4196c9a3eb 100644 --- a/assets/javascripts/application.js +++ b/assets/javascripts/application.js @@ -1,3 +1,5 @@ +// @ts-check + //= require_tree ./vendor //= require lib/license @@ -22,6 +24,10 @@ //= 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. + */ var init = function () { document.removeEventListener("DOMContentLoaded", init, false); diff --git a/assets/javascripts/collections/collection.js b/assets/javascripts/collections/collection.js index 79bca0be69..f2fb8bb71c 100644 --- a/assets/javascripts/collections/collection.js +++ b/assets/javascripts/collections/collection.js @@ -1,4 +1,16 @@ -app.Collection = class Collection { +// @ts-check + +/** + * 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`. + * + * @template {Model} [T=Model] + */ +class Collection { + /** @param {unknown[]} [objects] Models, attribute objects, or other collections. */ constructor(objects) { if (objects == null) { objects = []; @@ -6,20 +18,42 @@ app.Collection = class Collection { this.reset(objects); } + /** + * The model class this collection holds. + * + * @returns {new (attributes?: Record) => T} + */ model() { - return app.models[this.constructor.model]; + const { model } = /** @type {{ model: keyof App["models"] }} */ ( + /** @type {unknown} */ (this.constructor) + ); + return /** @type {new (attributes?: Record) => T} */ ( + /** @type {unknown} */ (app.models[model]) + ); } + /** + * Replaces the contents. + * + * @param {unknown[]} [objects] + */ reset(objects) { if (objects == null) { objects = []; } + /** @type {T[]} */ this.models = []; for (var object of objects) { - this.add(object); + this.add(/** @type {T} */ (object)); } } + /** + * Appends a model, an array of them, another collection's models, or an + * attribute object to build a model from. + * + * @param {T | T[] | Collection | Record} object + */ add(object) { if (object instanceof app.Model) { this.models.push(object); @@ -34,40 +68,72 @@ app.Collection = class Collection { } } + /** + * @param {T} model + */ remove(model) { this.models.splice(this.models.indexOf(model), 1); } + /** @returns {number} */ size() { return this.models.length; } + /** @returns {boolean} */ isEmpty() { return this.models.length === 0; } + /** + * @param {(model: T) => void} fn + */ each(fn) { for (var model of this.models) { fn(model); } } + /** + * The underlying array, not a copy. + * + * @returns {T[]} + */ all() { return this.models; } + /** + * @param {T} model + * @returns {boolean} + */ contains(model) { return this.models.includes(model); } + /** + * @param {string} attr + * @param {unknown} value + * @returns {T | undefined} + */ findBy(attr, value) { return this.models.find((model) => model[attr] === value); } + /** + * @param {string} attr + * @param {unknown} value + * @returns {T[]} + */ findAllBy(attr, value) { return this.models.filter((model) => model[attr] === value); } + /** + * @param {string} attr + * @param {unknown} value + * @returns {number} + */ countAllBy(attr, value) { let i = 0; for (var model of this.models) { @@ -77,4 +143,8 @@ app.Collection = 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 d4aa9c8400..6cca908ccd 100644 --- a/assets/javascripts/collections/docs.js +++ b/assets/javascripts/collections/docs.js @@ -1,4 +1,9 @@ -app.collections.Docs = class Docs extends app.Collection { +// @ts-check + +/** Every doc the app knows about, enabled or not. * + * @extends {Collection} + */ +class Docs extends Collection { static model = "Doc"; static NORMALIZE_VERSION_RGX = /\.(\d)$/; static NORMALIZE_VERSION_SUB = ".0$1"; @@ -7,11 +12,20 @@ app.collections.Docs = class Docs extends app.Collection { // It's not pretty but I didn't want to import a promise library only for this. static CONCURRENCY = 3; + /** + * @param {string} slug With or without a version. + * @returns {Doc | undefined} + */ findBySlug(slug) { return ( this.findBy("slug", slug) || this.findBy("slug_without_version", slug) ); } + /** + * Orders by name, then by version with the newest first. Sorts in place. + * + * @returns {unknown[]} + */ sort() { return this.models.sort((a, b) => { if (a.name === b.name) { @@ -37,6 +51,14 @@ app.collections.Docs = class Docs extends app.Collection { } }); } + /** + * Loads every doc's index, `CONCURRENCY` at a time. `onError` is called at + * most once, with the first failure. + * + * @param {() => void} onComplete + * @param {((args: unknown[]) => void) | null} onError + * @param {DocLoadOptions} [options] + */ load(onComplete, onError, options) { let i = 0; @@ -62,12 +84,18 @@ app.collections.Docs = class Docs extends app.Collection { } } + /** Drops every doc's cached index. */ clearCache() { for (var doc of this.models) { doc.clearCache(); } } + /** + * Removes every doc's offline database, one at a time. + * + * @param {() => void} callback + */ uninstall(callback) { let i = 0; var next = () => { @@ -80,18 +108,24 @@ app.collections.Docs = class Docs extends app.Collection { next(); } + /** @param {(statuses: Record | false) => void} callback */ getInstallStatuses(callback) { - app.db.versions(this.models, (statuses) => { - if (statuses) { - for (var key in statuses) { - var value = statuses[key]; - statuses[key] = { installed: !!value, mtime: value }; - } + app.db.versions(this.models, (versions) => { + if (!versions) { + callback(false); + return; + } + /** @type {Record} */ + const statuses = {}; + for (var key in versions) { + var value = versions[key]; + statuses[key] = { installed: !!value, mtime: value }; } callback(statuses); }); } + /** @param {(count: number) => void} callback Given the number of outdated docs. */ checkForUpdates(callback) { this.getInstallStatuses((statuses) => { let i = 0; @@ -107,6 +141,7 @@ app.collections.Docs = class Docs extends app.Collection { }); } + /** Reinstalls every doc whose offline copy is out of date. */ updateInBackground() { this.getInstallStatuses((statuses) => { if (!statuses) { @@ -121,4 +156,8 @@ app.collections.Docs = class Docs extends app.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 2ea74707c1..0a70c0b277 100644 --- a/assets/javascripts/collections/entries.js +++ b/assets/javascripts/collections/entries.js @@ -1,3 +1,12 @@ -app.collections.Entries = class Entries extends app.Collection { +// @ts-check + +/** Every searchable entry, across every enabled doc. * + * @extends {Collection} + */ +class Entries extends Collection { static model = "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 0d23be0982..6ec2517531 100644 --- a/assets/javascripts/collections/types.js +++ b/assets/javascripts/collections/types.js @@ -1,9 +1,20 @@ -app.collections.Types = class Types extends app.Collection { +// @ts-check + +/** The types within one doc, e.g. "Methods" or "Guides". * + * @extends {Collection} + */ +class Types extends Collection { static model = "Type"; static GUIDES_RGX = /(^|\()(guides?|tutorials?|reference|book|getting\ started|manual|examples)($|[\):])/i; static APPENDIX_RGX = /appendix/i; + /** + * Splits the types into guides, regular types and appendices, in that + * order, dropping any group that ends up empty. + * + * @returns {unknown[][]} + */ groups() { const result = []; for (var type of this.models) { @@ -14,6 +25,10 @@ app.collections.Types = class Types extends app.Collection { return result.filter((e) => e.length > 0); } + /** + * @param {Type} type + * @returns {number} The index of the group the type belongs in. + */ _groupFor(type) { if (Types.GUIDES_RGX.test(type.name)) { return 0; @@ -23,4 +38,8 @@ app.collections.Types = class Types extends app.Collection { return 1; } } -}; +} + +// 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 8fcba75b45..335f7c3b99 100644 --- a/assets/javascripts/debug.js +++ b/assets/javascripts/debug.js @@ -1,3 +1,9 @@ +// @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. + // // App // @@ -22,13 +28,16 @@ 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(); } + /** Closes the previous matcher's timer before moving on. */ match() { if (this.matcher) { console.timeEnd(this.matcher.name); @@ -36,11 +45,13 @@ app.Searcher = class TimingSearcher extends app.Searcher { return super.match(); } + /** Starts a timer for the matcher about to run. */ setupMatcher() { console.time(this.matcher.name); return super.setupMatcher(); } + /** Reports the result count and closes the group. */ end() { console.log(`Results: ${this.totalResults}`); console.timeEnd("Total"); @@ -48,6 +59,7 @@ app.Searcher = class TimingSearcher extends app.Searcher { return super.end(); } + /** Closes the group when a search is abandoned part-way. */ kill() { if (this.timeout) { if (this.matcher) { @@ -65,6 +77,15 @@ app.Searcher = class TimingSearcher extends app.Searcher { // View tree // +/** + * Prints the view tree under `view`, with each view coloured by whether it is + * currently activated. + * + * @param {View} [view] Defaults to the root view. + * @param {number} [level] The current depth, used for indentation. + * @param {unknown[]} [visited] The views already printed, so that the shared ones + * aren't walked twice. + */ this.viewTree = function (view, level, visited) { if (view == null) { view = app.document; diff --git a/assets/javascripts/globals.d.ts b/assets/javascripts/globals.d.ts new file mode 100644 index 0000000000..9df3e4b2a3 --- /dev/null +++ b/assets/javascripts/globals.d.ts @@ -0,0 +1,292 @@ +/** + * 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: + * + * - globals created by assigning to `this` at the top level of a file, and + * - the vendored libraries, which are excluded from the typecheck. + * + * The types themselves live in JSDoc next to the code that implements them. + */ + +// --- Globals defined by assigning to `this` at the top level --- + +/** 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; + +/** lib/local_storage_store.js — a JSON-encoded wrapper around localStorage. */ +declare var LocalStorageStore: new () => LocalStorageStore; + +/** lib/page.js — the router. */ +declare var page: PageFn & PageHelpers; + +/** lib/page.js — expires the analytics cookies. */ +declare var resetAnalytics: () => void; + +/** app/app.js — the application singleton. */ +declare var app: App; + +/** lib/favicon.js — swaps the favicon for the doc's icon. */ +declare var setFaviconForDoc: (doc: unknown) => void; + +/** lib/favicon.js — restores the default favicon. */ +declare var resetFavicon: () => void; + +/** debug.js — prints the view tree, with each view's activation state. */ +declare var viewTree: (view?: unknown, level?: number, visited?: unknown[]) => void; + +// --- Vendored libraries (assets/javascripts/vendor) --- + +/** 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 CookieOptions { + path?: string; + domain?: string; + expires?: number | string | Date; + secure?: boolean; +} + +/** 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 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. + */ + +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: 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 BasePage { + /** Implemented by the subclass, when it has anything to do after rendering. */ + afterRender?(): void; + entry: Entry; + highlightNodes: HTMLElement[]; + nodesPerFrame: number; + previousTiming: number | null; +} + +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 SettingsView { + /** From `elements`. */ + sidebar: HTMLElement; + /** From `elements`. */ + saveBtn: HTMLElement; + /** From `elements`. */ + backBtn: HTMLElement; + + docPicker: DocPicker; + saving?: boolean; +} + +interface Search { + /** From `elements`. */ + input: HTMLInputElement; + /** From `elements`. */ + resetLink: HTMLElement; + + scope: SearchScope; + searcher: Searcher; + value: string; + hasResults: boolean | null; + flags: { urlSearch?: boolean, initialResults?: boolean }; +} + +interface SearchScope { + /** From `elements`. */ + input: HTMLInputElement; + /** From `elements`. */ + tag: HTMLElement; + + doc: Doc | null; + placeholder: string; + searcher: SynchronousSearcher; +} + +interface DocList { + /** From `elements`. */ + disabledTitle: HTMLElement; + /** From `elements`. */ + disabledList: HTMLElement; + + lists: Record; + listFocus: ListFocus; + listFold: ListFold; + listSelect: ListSelect; +} + +// --- 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[]; +} + +interface Navigator { + /** Global Privacy Control. Not in lib.dom yet. */ + readonly globalPrivacyControl?: boolean; +} + +interface XMLHttpRequest { + /** Set by lib/ajax.js so that the timeout can be cleared when it settles. */ + timer?: number; +} diff --git a/assets/javascripts/lib/ajax.js b/assets/javascripts/lib/ajax.js index dca1dc7483..fc559f75fe 100644 --- a/assets/javascripts/lib/ajax.js +++ b/assets/javascripts/lib/ajax.js @@ -1,8 +1,37 @@ +// @ts-check + +/** + * @typedef {"error" | "invalid" | "timeout"} AjaxErrorType + * + * @typedef {object} AjaxOptions + * @property {string} [url] + * @property {string} [type] HTTP method. Defaults to `"GET"`. + * @property {boolean} [async] Defaults to `true`. When false, `ajax` returns the parsed response. + * @property {string} [dataType] `"json"` (the default), `"html"`, or a MIME type. + * @property {number} [timeout] Seconds before the request is aborted. Defaults to 30. + * @property {string} [contentType] + * @property {unknown} [context] `this` for the `success` and `error` callbacks. + * @property {Record | string | null} [data] Serialized into the query string for GET, into the body otherwise. + * @property {Record} [headers] + * @property {(event: ProgressEvent) => void} [progress] + * @property {(response: unknown, xhr: XMLHttpRequest, options: AjaxOptions) => void} [success] + * @property {(type: AjaxErrorType, xhr: XMLHttpRequest, options: AjaxOptions) => void} [error] + */ + +/** @type {Record} */ const MIME_TYPES = { json: "application/json", html: "text/html", }; +/** + * A small XMLHttpRequest wrapper. + * + * @param {AjaxOptions} options Merged over `ajax.defaults`. Mutated in place. + * @returns {{ abort: () => void }} A handle to abort the request. A + * synchronous request returns the parsed response instead, but nothing asks + * for one. + */ function ajax(options) { applyDefaults(options); serializeData(options); @@ -13,14 +42,16 @@ function ajax(options) { applyCallbacks(xhr, options); applyHeaders(xhr, options); - xhr.send(options.data); + // serializeData has already reduced `data` to a string or null. + xhr.send(/** @type {string | null} */ (options.data)); if (options.async) { return { abort: abort.bind(undefined, xhr) }; } else { - return parseResponse(xhr, options); + return /** @type {{ abort: () => void }} */ (parseResponse(xhr, options)); } + /** @param {AjaxOptions} options */ function applyDefaults(options) { for (var key in ajax.defaults) { if (options[key] == null) { @@ -29,28 +60,40 @@ function ajax(options) { } } + /** @param {AjaxOptions} options */ function serializeData(options) { if (!options.data) { return; } if (options.type === "GET") { - options.url += "?" + serializeParams(options.data); + options.url += + "?" + serializeParams(/** @type {Record} */ (options.data)); options.data = null; } else { - options.data = serializeParams(options.data); + options.data = serializeParams( + /** @type {Record} */ (options.data), + ); } } + /** + * @param {Record} params + * @returns {string} + */ function serializeParams(params) { return Object.entries(params) .map( ([key, value]) => - `${encodeURIComponent(key)}=${encodeURIComponent(value)}`, + `${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`, ) .join("&"); } + /** + * @param {XMLHttpRequest} xhr + * @param {AjaxOptions} options + */ function applyCallbacks(xhr, options) { if (!options.async) { return; @@ -71,6 +114,10 @@ function ajax(options) { }; } + /** + * @param {XMLHttpRequest} xhr + * @param {AjaxOptions} options + */ function applyHeaders(xhr, options) { if (!options.headers) { options.headers = {}; @@ -99,6 +146,10 @@ function ajax(options) { } } + /** + * @param {XMLHttpRequest} xhr + * @param {AjaxOptions} options + */ function onComplete(xhr, options) { if (200 <= xhr.status && xhr.status < 300) { const response = parseResponse(xhr, options); @@ -112,29 +163,49 @@ function ajax(options) { } } + /** + * @param {unknown} response + * @param {XMLHttpRequest} xhr + * @param {AjaxOptions} options + */ function onSuccess(response, xhr, options) { if (options.success != null) { options.success.call(options.context, response, xhr, options); } } + /** + * @param {AjaxErrorType} type + * @param {XMLHttpRequest} xhr + * @param {AjaxOptions} options + */ function onError(type, xhr, options) { if (options.error != null) { options.error.call(options.context, type, xhr, options); } } + /** + * @param {XMLHttpRequest} xhr + * @param {AjaxOptions} options + */ function onTimeout(xhr, options) { xhr.abort(); onError("timeout", xhr, options); } + /** @param {XMLHttpRequest} xhr */ function abort(xhr) { clearTimeout(xhr.timer); xhr.onreadystatechange = null; xhr.abort(); } + /** + * @param {XMLHttpRequest} xhr + * @param {AjaxOptions} options + * @returns {unknown} `undefined` when a JSON response fails to parse. + */ function parseResponse(xhr, options) { if (options.dataType === "json") { return parseJSON(xhr.responseText); @@ -143,6 +214,10 @@ function ajax(options) { } } + /** + * @param {string} json + * @returns {unknown} `undefined` when parsing fails. + */ function parseJSON(json) { try { return JSON.parse(json); @@ -150,6 +225,7 @@ function ajax(options) { } } +/** @type {AjaxOptions} */ ajax.defaults = { async: true, dataType: "json", diff --git a/assets/javascripts/lib/cookies_store.js b/assets/javascripts/lib/cookies_store.js index 7878855c0c..a9e3d00908 100644 --- a/assets/javascripts/lib/cookies_store.js +++ b/assets/javascripts/lib/cookies_store.js @@ -1,12 +1,39 @@ -// Intentionally called CookiesStore instead of CookieStore -// Calling it CookieStore causes issues when the Experimental Web Platform features flag is enabled in Chrome -// Related issue: https://github.com/freeCodeCamp/devdocs/issues/932 +// @ts-check + +/** + * A cookie-backed key/value store. + * + * Values round-trip as strings, so integers are parsed back out on read and + * booleans are stored as `1` / absent. When a write doesn't stick — the usual + * cause is the browser blocking cookies — `onBlocked` is called so the app can + * warn the user. + * + * Intentionally called CookiesStore instead of CookieStore. Calling it + * CookieStore causes issues when the Experimental Web Platform features flag is + * enabled in Chrome. + * Related issue: https://github.com/freeCodeCamp/devdocs/issues/932 + * + * @typedef {string | number | undefined} CookieValue + */ class CookiesStore { static INT = /^\d+$/; - static onBlocked() {} + /** + * Hook called when a value read back after a write doesn't match what was + * written. Replaced by the app at boot; a no-op by default. + * + * @param {string} key + * @param {CookieValue | boolean} value The value that was written. + * @param {CookieValue} actual The value that was read back. + */ + static onBlocked(key, value, actual) {} + /** + * @param {string} key + * @returns {CookieValue} The stored value, as a number when it is all digits. + */ get(key) { + /** @type {CookieValue} */ let value = Cookies.get(key); if (value != null && CookiesStore.INT.test(value)) { value = parseInt(value, 10); @@ -14,6 +41,12 @@ class CookiesStore { return value; } + /** + * Writing `false` deletes the key; `true` is stored as `1`. + * + * @param {string} key + * @param {CookieValue | boolean} value + */ set(key, value) { if (value === false) { this.del(key); @@ -26,10 +59,10 @@ class CookiesStore { if ( value && (typeof CookiesStore.INT.test === "function" - ? CookiesStore.INT.test(value) + ? CookiesStore.INT.test(/** @type {string} */ (value)) : undefined) ) { - value = parseInt(value, 10); + value = parseInt(/** @type {string} */ (value), 10); } Cookies.set(key, "" + value, { path: "/", expires: 1e8 }); if (this.get(key) !== value) { @@ -37,10 +70,12 @@ class CookiesStore { } } + /** @param {string} key */ del(key) { Cookies.expire(key); } + /** Expires every cookie on the document. */ reset() { try { for (var cookie of document.cookie.split(/;\s?/)) { @@ -50,12 +85,15 @@ class CookiesStore { } catch (error) {} } + /** + * @returns {Record} Every non-internal cookie, unparsed. + */ dump() { const result = {}; for (var cookie of document.cookie.split(/;\s?/)) { if (cookie[0] !== "_") { - cookie = cookie.split("="); - result[cookie[0]] = cookie[1]; + const [name, value] = cookie.split("="); + result[name] = value; } } return result; diff --git a/assets/javascripts/lib/events.js b/assets/javascripts/lib/events.js index d735a3d55d..e4dab7ec78 100644 --- a/assets/javascripts/lib/events.js +++ b/assets/javascripts/lib/events.js @@ -1,4 +1,37 @@ +// @ts-check + +/** + * A minimal event emitter. Most of the app's long-lived objects extend it. + * + * Event names are free-form strings; `on`, `off` and `removeEvent` also accept + * several of them separated by spaces. Every event is re-emitted as `all` with + * the original name prepended to the arguments. + * + * Listeners know the shape of the event they subscribed to, which the + * emitter itself has no way to express, so the arguments stay untyped. + * + * @typedef {(...args: unknown[]) => void} EventCallback + */ class Events { + /** + * Registered callbacks, keyed by event name. Created on first `on` call. + * + * @type {Record | undefined} + */ + _callbacks; + + /** + * The event being dispatched, while `trigger` is running. + * + * @type {{ name: string, args: unknown[] } | null} + */ + eventInProgress; + + /** + * @param {string} event One or more event names, separated by spaces. + * @param {EventCallback} callback + * @returns {this} + */ on(event, callback) { if (event.includes(" ")) { for (var name of event.split(" ")) { @@ -12,6 +45,11 @@ class Events { return this; } + /** + * @param {string} event One or more event names, separated by spaces. + * @param {EventCallback} callback The same reference that was passed to `on`. + * @returns {this} + */ off(event, callback) { let callbacks, index; if (event.includes(" ")) { @@ -30,6 +68,11 @@ class Events { return this; } + /** + * @param {string} event A single event name. + * @param {...unknown} args Passed on to each callback. + * @returns {this} + */ trigger(event, ...args) { this.eventInProgress = { name: event, args }; const callbacks = this._callbacks?.[event]; @@ -47,6 +90,12 @@ class Events { return this; } + /** + * Removes every callback registered for the given events. + * + * @param {string} event One or more event names, separated by spaces. + * @returns {this} + */ removeEvent(event) { if (this._callbacks != null) { for (var name of event.split(" ")) { diff --git a/assets/javascripts/lib/favicon.js b/assets/javascripts/lib/favicon.js index 6b58016c64..608d0a1972 100644 --- a/assets/javascripts/lib/favicon.js +++ b/assets/javascripts/lib/favicon.js @@ -1,9 +1,27 @@ +// @ts-check + +/** + * The favicon the page was served with, read the first time a doc sets one. + * + * @type {string | null} + */ let defaultUrl = null; + +/** The doc whose icon is currently shown. @type {string | null} */ let currentSlug = null; +/** Loaded spritesheet and default favicon images, by URL. @type {Record} */ const imageCache = {}; + +/** Generated favicon data URLs, by doc slug. @type {Record} */ const urlCache = {}; +/** + * Runs `action` with the image at `url`, loading and caching it first if need be. + * + * @param {string} url + * @param {(img: HTMLImageElement) => void} action + */ const withImage = function (url, action) { if (imageCache[url]) { return action(imageCache[url]); @@ -18,12 +36,20 @@ const withImage = function (url, action) { } }; +/** + * Draws the doc's icon over the default favicon and swaps it in. + * + * Does nothing if the doc's icon is already shown, if the user turned + * doc-specific icons off, or if the icon can't be found. + * + * @param {{ slug: string }} doc + */ this.setFaviconForDoc = function (doc) { if (currentSlug === doc.slug || app.settings.get("noDocSpecificIcon")) { return; } - const favicon = $('link[rel="icon"]'); + const favicon = /** @type {HTMLLinkElement} */ ($('link[rel="icon"]')); if (defaultUrl === null) { defaultUrl = favicon.href; @@ -93,9 +119,10 @@ this.setFaviconForDoc = function (doc) { ); }; +/** Puts the default favicon back, if a doc replaced it. */ this.resetFavicon = function () { if (defaultUrl !== null && currentSlug !== null) { - $('link[rel="icon"]').href = defaultUrl; + /** @type {HTMLLinkElement} */ ($('link[rel="icon"]')).href = defaultUrl; return (currentSlug = null); } }; diff --git a/assets/javascripts/lib/license.js b/assets/javascripts/lib/license.js index e4c3c0103a..100982bc74 100644 --- a/assets/javascripts/lib/license.js +++ b/assets/javascripts/lib/license.js @@ -1,3 +1,5 @@ +// @ts-check + /* * Copyright 2013-2026 Thibaut Courouble and other contributors * diff --git a/assets/javascripts/lib/local_storage_store.js b/assets/javascripts/lib/local_storage_store.js index 25a4ee90f7..37a0c0c210 100644 --- a/assets/javascripts/lib/local_storage_store.js +++ b/assets/javascripts/lib/local_storage_store.js @@ -1,10 +1,39 @@ +// @ts-check + +/** + * The instance side of the store, so that the constructor can be declared as a + * global in globals.d.ts. + * + * @typedef {object} LocalStorageStore + * @property {(key: string) => unknown} get + * @property {(key: string, value: unknown) => boolean | undefined} set + * @property {(key: string) => boolean | undefined} del + * @property {() => boolean | undefined} reset + */ + +/** + * A JSON-encoded wrapper around `localStorage`. + * + * Every method swallows the exceptions the browser throws when storage is + * unavailable (private browsing, blocked cookies, quota exhausted) and reports + * failure by returning `undefined`. + */ this.LocalStorageStore = class LocalStorageStore { + /** + * @param {string} key + * @returns {unknown} The stored value, or `undefined` if it is missing or unreadable. + */ get(key) { try { return JSON.parse(localStorage.getItem(key)); } catch (error) {} } + /** + * @param {string} key + * @param {unknown} value + * @returns {boolean | undefined} `true` when stored, `undefined` when it failed. + */ set(key, value) { try { localStorage.setItem(key, JSON.stringify(value)); @@ -12,6 +41,10 @@ this.LocalStorageStore = class LocalStorageStore { } catch (error) {} } + /** + * @param {string} key + * @returns {boolean | undefined} `true` when removed, `undefined` when it failed. + */ del(key) { try { localStorage.removeItem(key); @@ -19,6 +52,9 @@ this.LocalStorageStore = class LocalStorageStore { } catch (error) {} } + /** + * @returns {boolean | undefined} `true` when cleared, `undefined` when it failed. + */ reset() { try { localStorage.clear(); diff --git a/assets/javascripts/lib/page.js b/assets/javascripts/lib/page.js index b06f24bffc..a5d44eb5f7 100644 --- a/assets/javascripts/lib/page.js +++ b/assets/javascripts/lib/page.js @@ -4,22 +4,97 @@ * Copyright 2012 TJ Holowaychuk */ +// @ts-check + +/** + * The history entry behind a navigation. Stored in `history.state`, so it + * survives reloads and has to stay JSON-serializable. + * + * @typedef {object} PageState + * @property {number} [id] Incrementing, so that the initial and last entries can be recognized. + * @property {number} [sessionId] Identifies the page load; a mismatch means the state outlived its session. + * @property {string} [path] + */ + +/** + * A route's captured parameters: an array of the positional captures, which + * also carries the named ones as string keys. + * + * @typedef {string[] & Record} RouteParams + */ + +/** + * A named capture in a route pattern. + * + * @typedef {object} RouteKey + * @property {string} name + * @property {boolean} optional + */ + +/** + * A route callback. Calling `next` passes the context on to the route after it. + * + * @callback PageCallback + * @param {Context} context + * @param {() => unknown} next + * @returns {unknown} + */ + +/** + * `page` is the router. Its behaviour depends on what it is handed: + * + * - `page(fn)` registers `fn` for every path, + * - `page(path, fn)` registers `fn` for one route, + * - `page(path, state)` navigates, and + * - `page(options)` starts the router. + * + * The signatures below are the source of truth for the global, which is + * declared in globals.d.ts. + * + * @callback PageFn + * @param {string | RegExp | PageCallback | object} [value] + * @param {PageCallback | PageState} [fn] + * @returns {void} + */ + +/** + * @typedef {object} PageHelpers + * @property {(options?: object) => void} start Begins listening for clicks and history changes. + * @property {() => void} stop + * @property {(path: string, state?: PageState) => Context | undefined} show Navigates, pushing a history entry. + * @property {(path: string, state?: PageState, skipDispatch?: boolean, init?: boolean) => Context} replace Navigates, replacing the current history entry. + * @property {(context: Context) => string | undefined} dispatch Runs the context through the registered routes, returning a redirect. + * @property {() => boolean} canGoBack + * @property {() => boolean} canGoForward + * @property {(fn: () => void) => void} track Registers an analytics callback, run on every navigation once consent is given. + */ + let running = false; + +/** @type {PageState | null} */ let currentState = null; + +/** @type {PageCallback[]} */ const callbacks = []; -this.page = function (value, fn) { - if (typeof value === "function") { - page("*", value); - } else if (typeof fn === "function") { - const route = new Route(value); - callbacks.push(route.middleware(fn)); - } else if (typeof value === "string") { - page.show(value, fn); - } else { - page.start(value); - } -}; +// 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} */ ( + /** @type {PageFn} */ ( + function (value, fn) { + if (typeof value === "function") { + page("*", /** @type {PageCallback} */ (value)); + } else if (typeof fn === "function") { + const route = new Route(/** @type {string | RegExp | string[]} */ (value)); + callbacks.push(route.middleware(fn)); + } else if (typeof value === "string") { + page.show(value, /** @type {PageState} */ (fn)); + } else { + page.start(value); + } + } + ) +); page.start = function (options) { if (options == null) { @@ -32,8 +107,8 @@ page.start = function (options) { if ("scrollRestoration" in history) { history.scrollRestoration = "manual"; } - addEventListener("popstate", onpopstate); - addEventListener("click", onclick); + addEventListener("popstate", onHistoryPopState); + addEventListener("click", onDocumentClick); page.replace(currentPath(), null, null, true); } }; @@ -41,8 +116,8 @@ page.start = function (options) { page.stop = function () { if (running) { running = false; - removeEventListener("click", onclick); - removeEventListener("popstate", onpopstate); + removeEventListener("click", onDocumentClick); + removeEventListener("popstate", onHistoryPopState); } }; @@ -91,7 +166,7 @@ page.dispatch = function (context) { let i = 0; const next = function () { let fn = callbacks[i++]; - return fn?.(context, next); + return /** @type {string | undefined} */ (fn?.(context, next)); }; return next(); }; @@ -118,22 +193,88 @@ class Context { */ static initialPath = currentPath(); + /** + * Whether the state is the first of the session. + * + * @param {PageState} state + * @returns {boolean} + */ static isInitialState(state) { return state.id === 0; } + /** + * Whether the state is the most recent one created. + * + * @param {PageState} state + * @returns {boolean} + */ static isLastState(state) { return state.id === Context.stateId - 1; } + /** + * Whether a popstate is the browser restoring the path the document loaded with. + * + * @param {PageState} state + * @returns {boolean} + */ static isInitialPopState(state) { return state.path === Context.initialPath && Context.stateId === 1; } + /** + * Whether the state was created by this page load. + * + * @param {PageState} state + * @returns {boolean} + */ static isSameSession(state) { return state.sessionId === Context.sessionId; } + /** + * Whether this context is the one the document was loaded with, rather than + * a later navigation. Set by `page.replace`. + * + * @type {boolean | undefined} + */ + init; + + /** + * The route's captured parameters, by name for named ones and by position + * for the rest. Set by `Route#middleware` when the route matches. + * + * @type {RouteParams} + */ + params; + + /** + * The models the route resolved the path to, set by app/router.js. + * + * @type {Doc} + */ + doc; + + /** @type {Entry} */ + entry; + + /** @type {Type} */ + type; + + /** The static page the route resolved to, if any. @type {string | undefined} */ + page; + + /** The query string, without the leading `?`. @type {string | undefined} */ + query; + + /** The hash fragment, without the leading `#`. @type {string | undefined} */ + hash; + + /** + * @param {string} [path] Defaults to `"/"`. + * @param {PageState} [state] + */ constructor(path, state) { if (path == null) { path = "/"; @@ -161,10 +302,12 @@ class Context { this.state.path = this.path; } + /** Adds a history entry for this context. */ pushState() { history.pushState(this.state, "", this.path); } + /** Replaces the current history entry with this context. */ replaceState() { try { history.replaceState(this.state, "", this.path); @@ -172,19 +315,32 @@ class Context { } } +/** A single route: a path pattern, and the parameter names it captures. */ class Route { + /** + * @param {string | RegExp | string[]} path + * @param {object} [options] Unused; kept for call-site compatibility. + */ constructor(path, options) { this.path = path; if (options == null) { options = {}; } + /** @type {RouteKey[]} */ this.keys = []; this.regexp = pathToRegexp(this.path, this.keys); } + /** + * Wraps `fn` so that it only runs when the route matches. + * + * @param {PageCallback} fn + * @returns {PageCallback} + */ middleware(fn) { return (context, next) => { - let params = []; + // Named captures are set as string keys alongside the positional ones. + const params = /** @type {RouteParams} */ (/** @type {unknown} */ ([])); if (this.match(context.pathname, params)) { context.params = params; return fn(context, next); @@ -194,6 +350,11 @@ class Route { }; } + /** + * @param {string} path + * @param {RouteParams} params Filled in with the captured parameters. + * @returns {boolean | undefined} `undefined` when the route doesn't match. + */ match(path, params) { const matchData = this.regexp.exec(path); if (!matchData) { @@ -217,6 +378,13 @@ class Route { } } +/** + * Compiles a path pattern into a regexp, collecting the named captures. + * + * @param {string | RegExp | string[]} path + * @param {RouteKey[]} keys Filled in with one entry per named capture. + * @returns {RegExp} + */ var pathToRegexp = function (path, keys) { if (path instanceof RegExp) { return path; @@ -257,7 +425,8 @@ var pathToRegexp = function (path, keys) { return new RegExp(`^${path}$`); }; -var onpopstate = function (event) { +/** @param {PopStateEvent} event */ +var onHistoryPopState = function (event) { if (!event.state || Context.isInitialPopState(event.state)) { return; } @@ -269,7 +438,8 @@ var onpopstate = function (event) { } }; -var onclick = function (event) { +/** @param {MouseEvent} event */ +var onDocumentClick = function (event) { try { if ( event.which !== 1 || @@ -284,21 +454,26 @@ var onclick = function (event) { return; } - let link = $.eventTarget(event); - while (link && !(link.tagName === "A" || link.tagName === "a")) { - link = link.parentNode; + /** @type {HTMLElement | null} */ + let el = $.eventTarget(event); + while (el && !(el.tagName === "A" || el.tagName === "a")) { + el = el.parentElement; } - if (!link) return; + if (!el) return; // If the `` is in an SVG, its attributes are `SVGAnimatedString`s // instead of strings - let href = link.href instanceof SVGAnimatedString - ? new URL(link.href.baseVal, location.href).href - : link.href; - let target = link.target instanceof SVGAnimatedString - ? link.target.baseVal - : link.target; + const link = + /** @type {{ href: string | SVGAnimatedString, target: string | SVGAnimatedString }} */ ( + /** @type {unknown} */ (el) + ); + let href = + link.href instanceof SVGAnimatedString + ? new URL(link.href.baseVal, location.href).href + : link.href; + let target = + link.target instanceof SVGAnimatedString ? link.target.baseVal : link.target; if (!target && isSameOrigin(href)) { event.preventDefault(); @@ -309,21 +484,29 @@ var onclick = function (event) { } }; +/** @param {string} url */ var isSameOrigin = (url) => url.startsWith(`${location.protocol}//${location.hostname}`); +/** Points the canonical link at the current path. */ var updateCanonicalLink = function () { - if (!this.canonicalLink) { - this.canonicalLink = document.head.querySelector('link[rel="canonical"]'); + // 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"]'); } - return this.canonicalLink.setAttribute( + return self.canonicalLink.setAttribute( "href", `https://${location.host}${location.pathname}`, ); }; +/** @type {Array<() => void>} */ const trackers = []; +/** @param {() => void} fn */ page.track = function (fn) { trackers.push(fn); }; @@ -344,7 +527,7 @@ var track = function () { if (consentGiven === "1") { for (var tracker of trackers) { - tracker.call(); + tracker.call(undefined); } } else if (consentGiven === undefined && consentAsked === undefined) { // Only ask for consent once per browser session @@ -354,6 +537,7 @@ var track = function () { } }; +/** Expires the analytics cookies, which are the ones prefixed with a single `_`. */ this.resetAnalytics = function () { for (var cookie of document.cookie.split(/;\s?/)) { var name = cookie.split("=")[0]; diff --git a/assets/javascripts/lib/util.js b/assets/javascripts/lib/util.js index 1e4ff320c0..eab3b96707 100644 --- a/assets/javascripts/lib/util.js +++ b/assets/javascripts/lib/util.js @@ -1,17 +1,116 @@ +// @ts-check + +/** + * `$` is the app's DOM helper: calling it queries a single element, and it + * carries the traversal, event, manipulation and scrolling helpers used + * throughout the app as properties. + * + * The signatures below are the source of truth for the global, which is + * declared in globals.d.ts. The implementations are contextually typed by + * them, so they don't repeat the annotations. + * + * @callback DollarQuery + * @param {string} selector + * @param {ParentNode} [el] The root to search under. Defaults to `document`. + * @returns {HTMLElement} The first match, or `undefined` if the selector is + * invalid. Callers that need a more specific element narrow it themselves. + */ + +/** + * @callback DollarQueryAll + * @param {string} selector + * @param {ParentNode} [el] The root to search under. Defaults to `document`. + * @returns {NodeListOf} All matches, or `undefined` if the selector is invalid. + */ + +/** + * Anything `$.append` and friends accept as content. + * + * @typedef {string | Node | ArrayLike} DollarContent + */ + +/** + * @typedef {object} DollarScrollOptions + * @property {number} [margin] Extra space above the target, for `"top"`. + * @property {number} [topGap] Gap above the target as a multiple of its height, for `"continuous"`. + * @property {number} [bottomGap] Gap below the target as a multiple of its height, for `"continuous"`. + */ + +/** + * The helpers hanging off `$`. + * + * @typedef {object} DollarHelpers + * + * @property {(id: string) => unknown} id Looks an element up by id. + * @property {(parent: Node, el: Node | null) => boolean | undefined} hasChild Whether `el` is `parent` or a descendant of it. + * @property {(el: Node | null, parent?: Node) => HTMLAnchorElement | undefined} closestLink The nearest `` ancestor, stopping at `parent`. + * + * @property {(el: EventTarget, event: string, callback: (event: never) => void, useCapture?: boolean) => void} on Accepts several space-separated event names. + * @property {(el: EventTarget, event: string, callback: (event: unknown) => void, useCapture?: boolean) => void} off Accepts several space-separated event names. + * @property {(el: EventTarget, type: string, canBubble?: boolean, cancelable?: boolean) => void} trigger Dispatches a synthetic event. + * @property {(el: EventTarget) => void} click Dispatches a synthetic click. + * @property {(event: Event) => void} stopEvent Prevents the default and stops propagation, immediately. + * @property {(event: Event) => HTMLElement} eventTarget The event target, resolving an SVG `` to the element that referenced it. + * + * @property {(el: Element, value: DollarContent) => void} append + * @property {(el: Element, value: DollarContent) => void} prepend + * @property {(el: Element, value: DollarContent) => void} before + * @property {(el: Element, value: DollarContent) => void} after + * @property {(value: Node | ArrayLike) => void} remove Detaches the node, or every node in the collection. + * @property {(el: Node) => void} empty Removes every child. + * @property {(el: Element, fn: (el: unknown) => void) => void} batchUpdate Runs `fn` with the element off the DOM, to avoid reflows. + * + * @property {(el: Element) => DOMRect} rect + * @property {(el: HTMLElement | null, container?: Element) => { top: number, left: number }} offset Offset relative to `container`, which defaults to the body. + * @property {(el: Node | null) => HTMLElement} scrollParent The nearest scrollable ancestor. + * @property {(el: HTMLElement | null, parent?: HTMLElement | null, position?: "top" | "center" | "continuous", options?: DollarScrollOptions) => void} scrollTo + * @property {(el: HTMLElement | null, parent?: HTMLElement | null, position?: "top" | "center" | "continuous", options?: DollarScrollOptions) => void} scrollToWithImageLock Like `scrollTo`, but holds the position while nearby images load. + * @property {(el: HTMLElement, fn: () => void) => void} lockScroll Runs `fn` while holding the element's position relative to the window. + * @property {(el: Element | null) => void} openDetailsAncestors Expands every `
` the element is inside. + * @property {(el: Element, end: number) => void} smoothScroll Animates `scrollTop` towards `end`. + * + * @property {(object: ArrayLike | T[]) => T[]} makeArray + * @property {(array: unknown[], object: unknown) => boolean} arrayDelete Removes the first occurrence; reports whether it was there. + * @property {(object: unknown) => boolean} isCollection Whether the value is an array or a live DOM collection. + * @property {(string: string) => string} escape Escapes HTML-significant characters. + * @property {(string: string) => string} escapeRegexp + * @property {(string: string) => string} urlDecode Decodes a form-encoded component, where `+` means a space. + * @property {(string: string) => string} urlDecodeFragment Decodes a hash fragment, where `+` is literal. + * @property {(string: string) => string} classify Turns `snake_case` into `CamelCase`. + * + * @property {() => void} noop + * @property {(blob: Blob, filename: string) => void} download Saves the blob to the user's downloads. + * @property {(value: string | { href: string }) => void} popup Opens a URL in a new tab, without leaking the opener. + * @property {() => boolean} isMac + * @property {() => boolean} isIE + * @property {() => boolean} isChromeForAndroid + * @property {() => boolean} isAndroid + * @property {() => boolean} isIOS + * @property {() => boolean} overlayScrollbarsEnabled Whether the OS draws scrollbars as an overlay. + * @property {(el: Element, options?: { className?: string, delay?: number }) => void} highlight Adds a class, then removes it after a delay. + */ + // // Traversing // let smoothDistance, smoothDuration, smoothEnd, smoothStart; -this.$ = function (selector, el) { - if (el == null) { - el = document; - } - try { - return el.querySelector(selector); - } catch (error) {} -}; +// 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} */ ( + /** @type {DollarQuery} */ ( + function (selector, el) { + if (el == null) { + el = document; + } + try { + return el.querySelector(selector); + } catch (error) {} + } + ) +); +/** @type {DollarQueryAll} */ this.$$ = function (selector, el) { if (el == null) { el = document; @@ -43,8 +142,8 @@ $.closestLink = function (el, parent) { parent = document.body; } while (el) { - if (el.tagName === "A") { - return el; + if (/** @type {Element} */ (el).tagName === "A") { + return /** @type {HTMLAnchorElement} */ (el); } if (el === parent) { return; @@ -105,21 +204,33 @@ $.stopEvent = function (event) { event.stopImmediatePropagation(); }; -$.eventTarget = (event) => event.target.correspondingUseElement || event.target; +$.eventTarget = function (event) { + const target = /** @type {HTMLElement & { correspondingUseElement?: HTMLElement }} */ ( + event.target + ); + return target.correspondingUseElement || target; +}; // // Manipulation // +/** + * @param {DollarContent} value + * @returns {DocumentFragment} + */ const buildFragment = function (value) { const fragment = document.createDocumentFragment(); if ($.isCollection(value)) { - for (var child of $.makeArray(value)) { + for (var child of $.makeArray(/** @type {ArrayLike} */ (value))) { fragment.appendChild(child); } } else { - fragment.innerHTML = value; + // DocumentFragment has no innerHTML; only collections reach this branch + // in practice (see $.before and $.after). + /** @type {{ innerHTML: unknown }} */ (/** @type {unknown} */ (fragment)).innerHTML = + value; } return fragment; @@ -132,20 +243,20 @@ $.append = function (el, value) { if ($.isCollection(value)) { value = buildFragment(value); } - el.appendChild(value); + el.appendChild(/** @type {Node} */ (value)); } }; $.prepend = function (el, value) { if (!el.firstChild) { - $.append(value); + $.append(el, value); } else if (typeof value === "string") { el.insertAdjacentHTML("afterbegin", value); } else { if ($.isCollection(value)) { value = buildFragment(value); } - el.insertBefore(value, el.firstChild); + el.insertBefore(/** @type {Node} */ (value), el.firstChild); } }; @@ -154,7 +265,7 @@ $.before = function (el, value) { value = buildFragment(value); } - el.parentNode.insertBefore(value, el); + el.parentNode.insertBefore(/** @type {Node} */ (value), el); }; $.after = function (el, value) { @@ -163,22 +274,23 @@ $.after = function (el, value) { } if (el.nextSibling) { - el.parentNode.insertBefore(value, el.nextSibling); + el.parentNode.insertBefore(/** @type {Node} */ (value), el.nextSibling); } else { - el.parentNode.appendChild(value); + el.parentNode.appendChild(/** @type {Node} */ (value)); } }; $.remove = function (value) { if ($.isCollection(value)) { - for (var el of $.makeArray(value)) { + for (var el of $.makeArray(/** @type {ArrayLike} */ (value))) { if (el.parentNode != null) { el.parentNode.removeChild(el); } } } else { - if (value.parentNode != null) { - value.parentNode.removeChild(value); + const node = /** @type {Node} */ (value); + if (node.parentNode != null) { + node.parentNode.removeChild(node); } } }; @@ -221,7 +333,7 @@ $.offset = function (el, container) { while (el && el !== container) { top += el.offsetTop; left += el.offsetLeft; - el = el.offsetParent; + el = /** @type {HTMLElement} */ (el.offsetParent); } return { @@ -232,14 +344,15 @@ $.offset = function (el, container) { $.scrollParent = function (el) { while ((el = el.parentNode) && el.nodeType === 1) { - if (el.scrollTop > 0) { + const element = /** @type {HTMLElement} */ (el); + if (element.scrollTop > 0) { break; } - if (["auto", "scroll"].includes(getComputedStyle(el)?.overflowY ?? "")) { + if (["auto", "scroll"].includes(getComputedStyle(element)?.overflowY ?? "")) { break; } } - return el; + return /** @type {HTMLElement} */ (el); }; $.scrollTo = function (el, parent, position, options) { @@ -267,7 +380,7 @@ $.scrollTo = function (el, parent, position, options) { } const { top } = $.offset(el, parent); - const { offsetTop } = parent.firstElementChild; + const { offsetTop } = /** @type {HTMLElement} */ (parent.firstElementChild); switch (position) { case "top": @@ -281,9 +394,8 @@ $.scrollTo = function (el, parent, position, options) { var { scrollTop } = parent; var height = el.offsetHeight; - var lastElementOffset = - parent.lastElementChild.offsetTop + - parent.lastElementChild.offsetHeight; + var lastChild = /** @type {HTMLElement} */ (parent.lastElementChild); + var lastElementOffset = lastChild.offsetTop + lastChild.offsetHeight; var offsetBottom = lastElementOffset > 0 ? parentScrollHeight - lastElementOffset : 0; @@ -357,7 +469,7 @@ $.lockScroll = function (el, fn) { $.openDetailsAncestors = function (el) { while (el) { if (el.tagName === "DETAILS") { - el.open = true; + /** @type {HTMLDetailsElement} */ (el).open = true; } el = el.parentElement; } @@ -428,7 +540,8 @@ $.arrayDelete = function (array, object) { // Returns true if the object is an array or a collection of DOM elements. $.isCollection = (object) => - Array.isArray(object) || typeof object?.item === "function"; + Array.isArray(object) || + typeof (/** @type {{ item?: unknown }} */ (object))?.item === "function"; const ESCAPE_HTML_MAP = { "&": "&", @@ -454,12 +567,12 @@ $.urlDecode = (string) => decodeURIComponent(string.replace(/\+/g, "%20")); $.urlDecodeFragment = (string) => decodeURIComponent(string); $.classify = function (string) { - string = string.split("_"); - for (let i = 0; i < string.length; i++) { - var substr = string[i]; - string[i] = substr[0].toUpperCase() + substr.slice(1); + const parts = string.split("_"); + for (let i = 0; i < parts.length; i++) { + var substr = parts[i]; + parts[i] = substr[0].toUpperCase() + substr.slice(1); } - return string.join(""); + return parts.join(""); }; // @@ -481,15 +594,21 @@ $.download = function (blob, filename) { setTimeout(() => URL.revokeObjectURL(url), 1000); }; +/** + * @param {string | { href: string }} value + * @returns {string} + */ +const hrefOf = (value) => (typeof value === "string" ? value : value.href); + $.popup = function (value) { try { - window.open(value.href || value, "_blank", "noopener"); + window.open(hrefOf(value), "_blank", "noopener"); } catch (error) { const win = window.open(); if (win.opener) { win.opener = null; } - win.location = value.href || value; + win.location = /** @type {string & Location} */ (hrefOf(value)); } }; diff --git a/assets/javascripts/models/doc.js b/assets/javascripts/models/doc.js index 990c4046df..39535ee0d7 100644 --- a/assets/javascripts/models/doc.js +++ b/assets/javascripts/models/doc.js @@ -1,10 +1,36 @@ -app.models.Doc = class Doc extends app.Model { - // Attributes: name, slug, type, version, release, db_size, mtime, links - +// @ts-check + +/** + * How a doc's index and database are fetched. + * + * @typedef {object} DocLoadOptions + * @property {boolean} [readCache] Use the cached index instead of fetching, when it is current. + * @property {boolean} [writeCache] Cache the fetched index. + */ + +/** + * Whether a doc's database is stored offline, and how old the copy is. + * + * @typedef {object} InstallStatus + * @property {boolean} installed + * @property {number | false} [mtime] The `mtime` the stored copy was built + * from, or `false` when it isn't installed. + */ + +// A doc's own properties are declared in globals.d.ts: Model copies the +// manifest attributes on, so a field declaration here would run after +// `super()` and blank them out again. + +/** One version of one documentation set. */ +class Doc extends Model { static NUMBERED_VERSION_RGX = /^\d+(\.\d+)*$/; - constructor() { - super(...arguments); + /** + * @param {Record} [attributes] Copied onto the doc by Model; + * the derived attributes are then worked out from them. + */ + constructor(attributes) { + super(attributes); this.reset(this); this.slug_without_version = this.slug.split("~")[0]; this.fullName = `${this.name}` + (this.version ? ` ${this.version}` : ""); @@ -15,25 +41,39 @@ app.models.Doc = class Doc extends app.Model { this.text = this.toEntry().text; } + /** + * Reloads the entries and types from freshly fetched index data. + * + * @param {{ entries?: unknown, types?: unknown }} data Freshly fetched index + * data, or the doc itself when its attributes carry the index. + */ reset(data) { this.resetEntries(data.entries); this.resetTypes(data.types); } + /** @param {unknown} [entries] */ resetEntries(entries) { - this.entries = new app.collections.Entries(entries); + this.entries = new app.collections.Entries( + /** @type {unknown[]} */ (entries), + ); this.entries.each((entry) => { return (entry.doc = this); }); } + /** @param {unknown} [types] */ resetTypes(types) { - this.types = new app.collections.Types(types); + this.types = new app.collections.Types(/** @type {unknown[]} */ (types)); this.types.each((type) => { return (type.doc = this); }); } + /** + * @param {string} [path] Relative to the doc. + * @returns {string} The app path for the page. + */ fullPath(path) { if (path == null) { path = ""; @@ -44,20 +84,32 @@ app.models.Doc = class Doc extends app.Model { return `/${this.slug}${path}`; } + /** + * @param {string} [path] + * @returns {string} Where the page's HTML is served from. + */ fileUrl(path) { return `${app.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}`; } + /** @returns {string} Where the doc's entry index is served from. */ indexUrl() { return `${app.config.docs_origin}/${this.slug}/${ app.config.index_filename }?${this.mtime}`; } + /** + * The entry standing for the doc itself, so that it can be searched for + * by name. Built once and reused. + * + * @returns {Entry} + */ toEntry() { if (this.entry) { return this.entry; @@ -73,6 +125,11 @@ app.models.Doc = class Doc extends app.Model { return this.entry; } + /** + * @param {string} path + * @param {string} [hash] Preferred over `path` alone when it matches an entry. + * @returns {Entry | undefined} + */ findEntryByPathAndHash(path, hash) { const entry = hash && this.entries.findBy("path", `${path}#${hash}`); if (entry) { @@ -84,6 +141,13 @@ app.models.Doc = class Doc extends app.Model { } } + /** + * Fetches the doc's entry index, or reads it from the cache. + * + * @param {() => void} onSuccess + * @param {() => void} onError + * @param {DocLoadOptions} [options] + */ load(onSuccess, onError, options) { if (options == null) { options = {}; @@ -107,10 +171,15 @@ app.models.Doc = class Doc extends app.Model { }); } + /** Drops the cached index. */ clearCache() { app.localStorage.del(this.slug); } + /** + * @param {() => void} onSuccess Called asynchronously, to match the network path. + * @returns {boolean | undefined} `true` when the cache was used. + */ _loadFromCache(onSuccess) { const data = this._getCache(); if (!data) { @@ -126,6 +195,7 @@ app.models.Doc = class Doc extends app.Model { return true; } + /** @returns {unknown} The cached index, or `undefined` when it is missing or stale. */ _getCache() { const data = app.localStorage.get(this.slug); if (!data) { @@ -140,10 +210,19 @@ app.models.Doc = class Doc extends app.Model { } } + /** @param {unknown} data */ _setCache(data) { app.localStorage.set(this.slug, [this.mtime, data]); } + /** + * Downloads the doc's database and stores it offline. Does nothing while an + * install or uninstall is already running. + * + * @param {() => void} onSuccess + * @param {() => void} onError + * @param {(event: ProgressEvent) => void} [onProgress] + */ install(onSuccess, onError, onProgress) { if (this.installing) { return; @@ -169,6 +248,12 @@ app.models.Doc = class Doc extends app.Model { }); } + /** + * Removes the doc's offline database. + * + * @param {() => void} onSuccess + * @param {() => void} onError + */ uninstall(onSuccess, onError) { if (this.installing) { return; @@ -188,24 +273,34 @@ app.models.Doc = class Doc extends app.Model { app.db.unstore(this, success, error); } + /** @param {(status: InstallStatus) => void} callback */ getInstallStatus(callback) { app.db.version(this, (value) => callback({ installed: !!value, mtime: value }), ); } - // Whether the doc holds a numbered version of its documentation (e.g. "3.9"), - // as opposed to a variant (e.g. "10 LTS" or "Python"), which can't be - // ordered. An empty version means the doc holds the latest version - // (e.g. `angular`), whereas docs without a version aren't versioned at all. + /** + * Whether the doc holds a numbered version of its documentation (e.g. "3.9"), + * as opposed to a variant (e.g. "10 LTS" or "Python"), which can't be + * ordered. An empty version means the doc holds the latest version + * (e.g. `angular`), whereas docs without a version aren't versioned at all. + * + * @returns {boolean} + */ hasNumberedVersion() { return ( this.version === "" || Doc.NUMBERED_VERSION_RGX.test(this.version || "") ); } - // Compares numbered versions (e.g. "3.9" is older than "3.12"). - // An empty version means the latest version and is newer than any other. + /** + * Compares numbered versions (e.g. "3.9" is older than "3.12"). + * An empty version means the latest version and is newer than any other. + * + * @param {Doc} other + * @returns {boolean} + */ isNewerVersionThan(other) { if (this.version === "" || other.version === "") { return this.version === "" && other.version !== ""; @@ -222,9 +317,13 @@ app.models.Doc = class Doc extends app.Model { return false; } - // Returns the doc holding the latest version of the same documentation among - // `docs`, or the doc itself when there is none. + /** + * @param {Doc[]} docs + * @returns {unknown} The doc holding the latest version of the same + * documentation among `docs`, or the doc itself when there is none. + */ findLatestVersion(docs) { + /** @type {Doc} */ let latest = this; if (!this.hasNumberedVersion()) { return latest; @@ -241,6 +340,10 @@ app.models.Doc = class Doc extends app.Model { return latest; } + /** + * @param {InstallStatus | undefined} status + * @returns {boolean} Whether the offline copy is older than the served one. + */ isOutdated(status) { if (!status) { return false; @@ -248,4 +351,8 @@ app.models.Doc = class Doc extends app.Model { const isInstalled = status.installed || app.settings.get("autoInstall"); 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 58a0120418..ef90e313d3 100644 --- a/assets/javascripts/models/entry.js +++ b/assets/javascripts/models/entry.js @@ -1,6 +1,20 @@ +// @ts-check + //= require app/searcher -app.models.Entry = class Entry extends app.Model { +// An entry's own properties are declared in globals.d.ts, for the reason +// given in models/doc.js. + +/** One searchable page, or a heading within one. */ +class Entry extends Model { + /** + * Expands a searchable string with its alias, if it has one, so that both + * spellings match. + * + * @param {string} string + * @returns {string | string[]} Both spellings when an alias applies, + * otherwise the string unchanged. + */ static applyAliases(string) { const aliases = app.config.docs_aliases; if (aliases.hasOwnProperty(string)) { @@ -18,12 +32,17 @@ app.models.Entry = class Entry extends app.Model { return string; } - // Attributes: name, type, path - constructor() { - super(...arguments); + /** @param {Record} [attributes] Copied onto the entry by Model. */ + constructor(attributes) { + super(attributes); this.text = Entry.applyAliases(app.Searcher.normalizeString(this.name)); } + /** + * Makes the entry findable under another name as well. + * + * @param {string} name + */ addAlias(name) { const text = Entry.applyAliases(app.Searcher.normalizeString(name)); if (!Array.isArray(this.text)) { @@ -34,22 +53,27 @@ app.models.Entry = class Entry extends app.Model { this.text.push(...(Array.isArray(text) ? text : [text])); } + /** @returns {string} The app path for the entry's page. */ fullPath() { return this.doc.fullPath(this.isIndex() ? "" : this.path); } + /** @returns {string} The path the page is stored under offline, without the hash. */ dbPath() { return this.path.replace(/#.*/, ""); } + /** @returns {string} The app path of the entry's HTML file. */ filePath() { return this.doc.fullPath(this._filePath()); } + /** @returns {string} Where the entry's HTML is served from. */ fileUrl() { return this.doc.fileUrl(this._filePath()); } + /** @returns {string} The entry's path as a `.html` filename, without the hash. */ _filePath() { let result = this.path.replace(/#.*/, ""); if (result.slice(-5) !== ".html") { @@ -58,15 +82,29 @@ app.models.Entry = class Entry extends app.Model { return result; } + /** @returns {boolean} Whether the entry stands for the doc itself. */ isIndex() { return this.path === "index"; } + /** @returns {Type | undefined} The entry's type. */ getType() { return this.doc.types.findBy("name", this.type); } + /** + * Reads the entry's page out of the offline database. + * + * @param {(html: string) => void} onSuccess + * @param {() => void} onError + * @returns {{ abort: () => void } | undefined} The pending request, when it + * went to the network. + */ loadFile(onSuccess, onError) { 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 def06e55ce..6bfb002ce3 100644 --- a/assets/javascripts/models/model.js +++ b/assets/javascripts/models/model.js @@ -1,8 +1,22 @@ -app.Model = class Model { +// @ts-check + +/** + * The base model: copies the attributes it is handed onto itself. + * + * Attributes vary by subclass and aren't known ahead of time, so subclasses + * 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 { + /** @param {Record} [attributes] */ constructor(attributes) { for (var key in attributes) { var value = attributes[key]; this[key] = value; } } -}; +} + +// 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 bc264ac189..5d02656cc2 100644 --- a/assets/javascripts/models/type.js +++ b/assets/javascripts/models/type.js @@ -1,14 +1,22 @@ -app.models.Type = class Type extends app.Model { - // Attributes: name, slug, count +// @ts-check +// A type's own properties are declared in globals.d.ts, for the reason given +// in models/doc.js. + +/** A group of entries within a doc, e.g. "Methods". */ +class Type extends Model { + + /** @returns {string} The app path for the type's page. */ fullPath() { return `/${this.doc.slug}-${this.slug}/`; } + /** @returns {Entry[]} Every entry of this type in the doc. */ entries() { return this.doc.entries.findAllBy("type", this.name); } + /** @returns {Entry} An entry standing for the type's page, so that it can be searched for. */ toEntry() { return new app.models.Entry({ doc: this.doc, @@ -16,4 +24,8 @@ app.models.Type = class Type extends app.Model { 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 fc445ef19c..b36acd454b 100644 --- a/assets/javascripts/templates/base.js +++ b/assets/javascripts/templates/base.js @@ -1,7 +1,21 @@ +// @ts-check + +/** + * Renders a template by name. + * + * Templates are either functions or plain strings. Passing an array renders + * the template once per element and concatenates the results, which is how + * lists are built. + * + * @param {string} name The key under `app.templates`. + * @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]; - if (Array.isArray(value)) { + if (Array.isArray(value) && typeof template === "function") { let result = ""; for (var val of value) { result += template(val, ...args); diff --git a/assets/javascripts/templates/error_tmpl.js b/assets/javascripts/templates/error_tmpl.js index 7f96247382..5fb868f6ee 100644 --- a/assets/javascripts/templates/error_tmpl.js +++ b/assets/javascripts/templates/error_tmpl.js @@ -1,3 +1,14 @@ +// @ts-check + +/** + * The error shown in place of the content: a title, an explanation and some + * ways out. + * + * @param {string} title + * @param {string} [text] + * @param {string} [links] + * @returns {string} + */ const error = function (title, text, links) { if (text == null) { text = ""; @@ -39,6 +50,11 @@ app.templates.bootError = () => If you keep seeing this, you're likely behind a proxy or firewall that blocks cross-domain requests. `, ); +/** + * @param {string} reason Why offline mode is unavailable. + * @param {Error} [exception] The error the browser reported, when there was one. + * @returns {string} + */ app.templates.offlineError = function (reason, exception) { if (reason === "cookie_blocked") { return error(" Cookies must be enabled to use offline mode. "); diff --git a/assets/javascripts/templates/notice_tmpl.js b/assets/javascripts/templates/notice_tmpl.js index 26c8c947b1..1661b90db0 100644 --- a/assets/javascripts/templates/notice_tmpl.js +++ b/assets/javascripts/templates/notice_tmpl.js @@ -1,5 +1,14 @@ +// @ts-check + +/** + * The notices shown above the content: a bar of explanatory text. + * + * @param {string} text + * @returns {string} + */ const notice = (text) => `

${text}

`; +/** @param {Doc} doc @returns {string} */ app.templates.singleDocNotice = (doc) => notice(` You're browsing the ${doc.fullName} documentation. To browse all docs, go to
${app.config.production_host} (or press esc). `); diff --git a/assets/javascripts/templates/notif_tmpl.js b/assets/javascripts/templates/notif_tmpl.js index f24c3c11a0..7cc28f1e2e 100644 --- a/assets/javascripts/templates/notif_tmpl.js +++ b/assets/javascripts/templates/notif_tmpl.js @@ -1,3 +1,13 @@ +// @ts-check + +/** + * The notification shown in the corner. Links inside `html` are given the + * notification's own link class. + * + * @param {string} title + * @param {string} html + * @returns {string} + */ const notif = function (title, html) { html = html.replace(/${title} @@ -6,6 +16,13 @@ ${html} `; }; +/** + * A notification whose body is a single paragraph. + * + * @param {string} title + * @param {string} message + * @returns {string} + */ const textNotif = (title, message) => notif(title, `

${message}`); @@ -47,6 +64,10 @@ app.templates.notifImportInvalid = () => " The file you selected is invalid. ", ); +/** + * @param {unknown[]} news + * @returns {string} + */ app.templates.notifNews = (news) => notif( "Changelog", @@ -55,6 +76,11 @@ app.templates.notifNews = (news) => })}`, ); +/** + * @param {Doc[]} docs Enabled docs with a new release. + * @param {unknown[]} disabledDocs Disabled docs with a new release. + * @returns {string} + */ app.templates.notifUpdates = function (docs, disabledDocs) { let doc; let html = '

'; diff --git a/assets/javascripts/templates/pages/about_tmpl.js b/assets/javascripts/templates/pages/about_tmpl.js index 600169a5ac..8b785dbd8c 100644 --- a/assets/javascripts/templates/pages/about_tmpl.js +++ b/assets/javascripts/templates/pages/about_tmpl.js @@ -1,3 +1,5 @@ +// @ts-check + app.templates.aboutPage = function () { let doc; const all_docs = app.docs.all().concat(...(app.disabledDocs.all() || [])); diff --git a/assets/javascripts/templates/pages/help_tmpl.js b/assets/javascripts/templates/pages/help_tmpl.js index e155d82999..849898a9f9 100644 --- a/assets/javascripts/templates/pages/help_tmpl.js +++ b/assets/javascripts/templates/pages/help_tmpl.js @@ -1,3 +1,5 @@ +// @ts-check + app.templates.helpPage = function () { const ctrlKey = $.isMac() ? "cmd" : "ctrl"; const navKey = $.isMac() ? "cmd" : "alt"; diff --git a/assets/javascripts/templates/pages/offline_tmpl.js b/assets/javascripts/templates/pages/offline_tmpl.js index 2f6f9e7a2d..bc520c7278 100644 --- a/assets/javascripts/templates/pages/offline_tmpl.js +++ b/assets/javascripts/templates/pages/offline_tmpl.js @@ -1,3 +1,11 @@ +// @ts-check + +/** + * @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) => `\

Offline Documentation

@@ -47,12 +55,27 @@ app.templates.offlinePage = (docs, hasPersistence, isPersistent) => `\ \ `; +/** + * @param {string} action What is being done, e.g. "Exporting". + * @param {Doc} doc + * @param {number} i The doc's position, one-based. + * @param {number} total + * @returns {string} + */ app.templates.backupProgress = (action, doc, i, total) => `${action} ${doc.fullName}\u2026 (${i}/${total})`; +/** + * @param {number} count + * @returns {string} + */ app.templates.backupExported = (count) => `Exported ${count} ${pluralizeDocs(count)}.`; +/** + * @param {ImportSummary} result + * @returns {string} + */ app.templates.backupImported = function (result) { let html = `Imported ${result.docs.length} ${pluralizeDocs( result.docs.length @@ -72,6 +95,10 @@ app.templates.backupImported = function (result) { return html; }; +/** + * @param {string} reason Why the export or import couldn't be done. + * @returns {string} + */ app.templates.backupError = function (reason) { switch (reason) { case "empty": @@ -85,11 +112,23 @@ app.templates.backupError = function (reason) { } }; +/** + * @param {number} count + * @returns {string} + */ var pluralizeDocs = (count) => count === 1 ? "documentation" : "documentations"; +/** + * @param {string[]} slugs Escaped, since they come from an imported file. + * @returns {string} + */ 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) { const reason = exception ? `${exception.name}: ${exception.message}` @@ -98,6 +137,14 @@ app.templates.persistenceError = function (exception) { return `

Persistent storage was denied by your browser. ${reason}`; }; +/** + * The warning that the browser may evict the offline data, with a way to ask + * for persistent storage. Empty once storage is already persistent. + * + * @param {boolean} hasPersistence Whether the browser exposes the storage API. + * @param {boolean} isPersistent + * @returns {string} + */ var offlinePersistenceNote = function (hasPersistence, isPersistent) { if (isPersistent) { return ""; @@ -133,6 +180,13 @@ The current tab will continue to function even when you go offline (provided you } }; +/** + * One row of the offline page: a doc, its size, and what can be done with it. + * + * @param {Doc} doc + * @param {InstallStatus} status + * @returns {string} + */ app.templates.offlineDoc = function (doc, status) { const outdated = doc.isOutdated(status); diff --git a/assets/javascripts/templates/pages/settings_tmpl.js b/assets/javascripts/templates/pages/settings_tmpl.js index f0f84bfa19..51a7515ab3 100644 --- a/assets/javascripts/templates/pages/settings_tmpl.js +++ b/assets/javascripts/templates/pages/settings_tmpl.js @@ -1,3 +1,12 @@ +// @ts-check + +/** + * One radio button in the theme picker. + * + * @param {{ label: string, value: string }} option + * @param {Record} settings The user's current preferences. + * @returns {string} + */ const themeOption = ({ label, value }, settings) => `\ \ `; +/** + * @param {Record} settings The user's current preferences. + * @returns {string} + */ app.templates.settingsPage = (settings) => `\

Preferences

diff --git a/assets/javascripts/templates/pages/type_tmpl.js b/assets/javascripts/templates/pages/type_tmpl.js index 8e0723325c..9b49c27eb5 100644 --- a/assets/javascripts/templates/pages/type_tmpl.js +++ b/assets/javascripts/templates/pages/type_tmpl.js @@ -1,3 +1,11 @@ +// @ts-check + +/** + * A type's page: every entry of that type in the doc. + * + * @param {Type} type + * @returns {string} + */ app.templates.typePage = (type) => { return `

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

    ${app.templates.render( @@ -6,6 +14,12 @@ app.templates.typePage = (type) => { )}
`; }; +/** + * One row of a type page. + * + * @param {Entry} entry + * @returns {string} + */ app.templates.typePageEntry = (entry) => { return `
  • ${$.escape(entry.name)}
  • `; }; diff --git a/assets/javascripts/templates/path_tmpl.js b/assets/javascripts/templates/path_tmpl.js index 9d02c042e7..a90afdd6de 100644 --- a/assets/javascripts/templates/path_tmpl.js +++ b/assets/javascripts/templates/path_tmpl.js @@ -1,3 +1,13 @@ +// @ts-check + +/** + * The breadcrumb above the content: the doc, then the type, then the entry. + * + * @param {Doc} doc + * @param {Type} [type] + * @param {Entry} [entry] + * @returns {string} + */ app.templates.path = function (doc, type, entry) { const arrow = ''; let html = `'; +/** + * A doc's row in the sidebar. + * + * @param {Doc} doc + * @param {SidebarOptions} [options] + * @returns {string} + */ templates.sidebarDoc = function (doc, options) { if (options == null) { options = {}; @@ -24,6 +45,12 @@ templates.sidebarDoc = function (doc, options) { return link + ""; }; +/** + * A type's row, with the number of entries it holds. + * + * @param {Type} type + * @returns {string} + */ templates.sidebarType = (type) => `${$.escape(type.name)}`; +/** + * An entry's row. + * + * @param {Entry} entry + * @returns {string} + */ templates.sidebarEntry = (entry) => `${$.escape( entry.name, )}`; +/** + * A search result: like an entry's row, plus the doc it belongs to and a way + * to reveal it in the list or enable its doc. + * + * @param {Entry} entry + * @returns {string} + */ templates.sidebarResult = function (entry) { let addons = entry.isIndex() && app.disabledDocs.contains(entry.doc) @@ -51,6 +91,12 @@ templates.sidebarResult = function (entry) { )}`; }; +/** + * Shown when a search matched nothing, with a pointer to the preferences + * when some docs are disabled. + * + * @returns {string} + */ templates.sidebarNoResults = function () { let html = '
    No results.
    '; if (!app.isSingleDoc() && !app.disabledDocs.isEmpty()) { @@ -61,9 +107,22 @@ templates.sidebarNoResults = function () { return html; }; +/** + * The row that loads the next page of a long list. + * + * @param {number} count How many entries are left. + * @returns {string} + */ templates.sidebarPageLink = (count) => `Show more\u2026 (${count})`; +/** + * A doc's row in the picker, with a checkbox. + * + * @param {Doc} doc + * @param {SidebarOptions} [options] + * @returns {string} + */ templates.sidebarLabel = function (doc, options) { if (options == null) { options = {}; @@ -79,6 +138,14 @@ templates.sidebarLabel = function (doc, options) { return label + `>${doc.fullName}`; }; +/** + * A doc that has several versions, as an expandable row. + * + * @param {Doc} doc + * @param {string} versions The rendered rows for each version. + * @param {SidebarOptions} [options] + * @returns {string} + */ templates.sidebarVersionedDoc = function (doc, versions, options) { if (options == null) { options = {}; @@ -93,12 +160,29 @@ templates.sidebarVersionedDoc = function (doc, versions, options) { ); }; +/** + * The heading above the disabled docs. + * + * @param {SidebarOptions} options + * @returns {string} + */ templates.sidebarDisabled = (options) => `
    ${arrow}Disabled (${options.count}) Customize
    `; +/** + * @param {string} html The rendered disabled docs. + * @returns {string} + */ templates.sidebarDisabledList = (html) => `
    ${html}
    `; +/** + * A disabled doc that has several versions. + * + * @param {Doc} doc + * @param {string} versions The rendered rows for each version. + * @returns {string} + */ templates.sidebarDisabledVersionedDoc = (doc, versions) => `${arrow}${doc.name}
    ${versions}
    `; diff --git a/assets/javascripts/templates/tip_tmpl.js b/assets/javascripts/templates/tip_tmpl.js index 223ffe9587..bcac2ff221 100644 --- a/assets/javascripts/templates/tip_tmpl.js +++ b/assets/javascripts/templates/tip_tmpl.js @@ -1,3 +1,5 @@ +// @ts-check + app.templates.tipKeyNav = () => `\

    ProTip diff --git a/assets/javascripts/tracking.js b/assets/javascripts/tracking.js index c15781f5c3..a90c53de05 100644 --- a/assets/javascripts/tracking.js +++ b/assets/javascripts/tracking.js @@ -1,3 +1,9 @@ +// @ts-check + +// Loads the analytics vendors, but only in production and only once the user +// has consented. Without consent, whatever they left behind is cleared out. +// The snippets below are the vendors' own bootstraps, kept as they ship them. + try { if (app.config.env === "production") { if (Cookies.get("analyticsConsent") === "1") { @@ -8,7 +14,7 @@ try { function () { (i[r].q = i[r].q || []).push(arguments); }), - (i[r].l = 1 * new Date()); + (i[r].l = new Date().getTime()); (a = s.createElement(o)), (m = s.getElementsByTagName(o)[0]); a.async = 1; a.src = g; @@ -36,7 +42,7 @@ try { else (function () { var _gauges = _gauges || []; - !(function () { + (function () { var a = document.createElement("script"); (a.type = "text/javascript"), (a.async = !0), diff --git a/assets/javascripts/views/content/content.js b/assets/javascripts/views/content/content.js index b1f23ffc4c..bc2b5ea88b 100644 --- a/assets/javascripts/views/content/content.js +++ b/assets/javascripts/views/content/content.js @@ -1,4 +1,14 @@ -app.views.Content = class Content extends app.View { +// @ts-check + +/** + * The pane holding whichever page is being shown. + * + * Owns one instance of each page view and swaps between them as the route + * changes. Scroll positions are remembered per history entry and restored on + * 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 { static el = "._content"; static loadingClass = "_content-loading"; @@ -19,9 +29,10 @@ app.views.Content = class Content extends app.View { after: "afterRoute", }; + /** @inheritdoc */ init() { this.scrollEl = app.isMobile() - ? document.scrollingElement || document.body + ? /** @type {HTMLElement} */ (document.scrollingElement) || document.body : this.el; this.scrollMap = {}; this.scrollStack = []; @@ -42,6 +53,7 @@ app.views.Content = class Content extends app.View { .on("bootError", () => this.onBootError()); } + /** @param {View} view The page to show, replacing whatever is there. */ show(view) { this.hideLoading(); if (view !== this.view) { @@ -53,22 +65,27 @@ app.views.Content = class Content extends app.View { } } + /** Marks the pane as waiting for a page. */ showLoading() { - this.addClass(this.constructor.loadingClass); + this.addClass(this.statics().loadingClass); } + /** @returns {boolean} */ isLoading() { - return this.el.classList.contains(this.constructor.loadingClass); + return this.el.classList.contains(this.statics().loadingClass); } + /** Clears the loading state. */ hideLoading() { - this.removeClass(this.constructor.loadingClass); + this.removeClass(this.statics().loadingClass); } + /** @param {number} [value] The offset to jump to. Defaults to the top. */ scrollTo(value) { this.scrollEl.scrollTop = value || 0; } + /** @param {number} value The offset to animate to. */ smoothScrollTo(value) { if (app.settings.get("fastScroll")) { this.scrollTo(value); @@ -77,41 +94,49 @@ app.views.Content = class Content extends app.View { } } + /** @param {number} n How far to scroll, in pixels. */ scrollBy(n) { this.smoothScrollTo(this.scrollEl.scrollTop + n); } + /** Scrolls to the top of the page. */ scrollToTop() { this.smoothScrollTo(0); } + /** Scrolls to the bottom of the page. */ scrollToBottom() { this.smoothScrollTo(this.scrollEl.scrollHeight); } + /** Scrolls up by a small step. */ scrollStepUp() { this.scrollBy(-80); } + /** Scrolls down by a small step. */ scrollStepDown() { this.scrollBy(80); } + /** Scrolls up by most of a viewport. */ scrollPageUp() { this.scrollBy(40 - this.scrollEl.clientHeight); } + /** Scrolls down by most of a viewport. */ scrollPageDown() { this.scrollBy(this.scrollEl.clientHeight - 40); } + /** Brings the element named by the URL hash into view. */ scrollToTarget() { let el; if ( this.routeCtx.hash && (el = this.findTargetByHash(this.routeCtx.hash)) ) { - $.scrollToWithImageLock(el, this.scrollEl, "top", { + $.scrollToWithImageLock(/** @type {HTMLElement} */ (el), this.scrollEl, "top", { margin: this.scrollEl === this.el ? 0 : $.offset(this.el).top, }); $.openDetailsAncestors(el); @@ -121,15 +146,18 @@ app.views.Content = class Content extends app.View { } } + /** Shows the first page once the docs have loaded. */ onReady() { this.hideLoading(); } + /** Shows the boot error in place of a page. */ onBootError() { this.hideLoading(); this.html(this.tmpl("bootError")); } + /** Marks the pane as waiting while an entry's page is fetched. */ onEntryLoading() { this.showLoading(); if (this.scrollToTargetTimeout) { @@ -138,6 +166,7 @@ app.views.Content = class Content extends app.View { } } + /** Clears the loading state once the entry has arrived. */ onEntryLoaded() { this.hideLoading(); if (this.scrollToTargetTimeout) { @@ -147,6 +176,7 @@ app.views.Content = class Content extends app.View { this.scrollToTarget(); } + /** @param {Context} context */ beforeRoute(context) { this.cacheScrollPosition(context); @@ -163,6 +193,12 @@ app.views.Content = class Content extends app.View { this.scrollToTargetTimeout = this.delay(this.scrollToTarget); } + /** + * Records where the page being left was scrolled to, against its history + * entry, so that going back restores it. + * + * @param {Context} context + */ cacheScrollPosition(context) { if (!this.routeCtx || this.routeCtx.hash) { return; @@ -191,6 +227,10 @@ app.views.Content = class Content extends app.View { this.scrollMap[this.routeCtx.state.id] = this.scrollEl.scrollTop; } + /** + * @param {string} route + * @param {unknown} context + */ afterRoute(route, context) { if (route !== "entry" && route !== "type") { resetFavicon(); @@ -224,6 +264,7 @@ app.views.Content = class Content extends app.View { ); } + /** @param {ViewMouseEvent} event */ onClick(event) { const link = $.closestLink($.eventTarget(event), this.el); if (link && this.isExternalUrl(link.getAttribute("href"))) { @@ -232,6 +273,11 @@ app.views.Content = class Content extends app.View { } } + /** + * Lets the browser's own find-in-page through. + * + * @param {ViewKeyboardEvent} event + */ onAltF(event) { if ( !document.activeElement || @@ -242,6 +288,10 @@ app.views.Content = class Content extends app.View { } } + /** + * @param {string} hash Including the leading `#`. + * @returns {unknown} The element the hash points at, or `undefined`. + */ findTargetByHash(hash) { let el = (() => { try { @@ -258,7 +308,15 @@ app.views.Content = class Content extends app.View { return el; } + /** + * @param {string} url + * @returns {boolean} Whether the URL leaves the app. + */ isExternalUrl(url) { 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 2fc24d4b5d..44c6f1572e 100644 --- a/assets/javascripts/views/content/entry_page.js +++ b/assets/javascripts/views/content/entry_page.js @@ -1,4 +1,14 @@ -app.views.EntryPage = class EntryPage extends app.View { +// @ts-check + +/** + * An entry's page. + * + * The HTML comes from the offline database when the doc is installed and from + * the network otherwise. A few docs need their own handling once rendered, + * 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 { static className = "_page"; static errorClass = "_page-error"; @@ -16,24 +26,32 @@ app.views.EntryPage = class EntryPage extends app.View { code: "Source code", }; + /** @inheritdoc */ init() { this.cacheMap = {}; this.cacheStack = []; } + /** Also abandons any page still loading. */ deactivate() { - if (super.deactivate(...arguments)) { + if (super.deactivate()) { this.hideTransientNotice(); this.empty(); this.entry = null; } } + /** Announces that a page is on its way. */ loading() { this.empty(); this.trigger("loading"); } + /** + * @param {string} content The entry's HTML. + * @param {boolean} [fromCache] Whether it came from the in-memory cache, + * in which case it has already been prepared. + */ render(content, fromCache) { if (content == null) { content = ""; @@ -63,6 +81,7 @@ app.views.EntryPage = class EntryPage extends app.View { this.trigger("loaded"); } + /** Adds a copy button to each code block. */ addCopyButtons() { if (!this.copyButton) { this.copyButton = document.createElement("button"); @@ -77,6 +96,7 @@ app.views.EntryPage = class EntryPage extends app.View { } } + /** Loads the MathML stylesheet, for browsers that don't render it natively. */ polyfillMathML() { if ( window.supportsMathML !== false || @@ -92,6 +112,10 @@ app.views.EntryPage = class EntryPage extends app.View { ); } + /** + * @param {string} content + * @returns {string} The HTML with the doc's own fixes applied. + */ prepareContent(content) { if (!this.entry.isIndex() || !this.entry.doc.links) { return content; @@ -104,6 +128,7 @@ app.views.EntryPage = class EntryPage extends app.View { return `

    ${content}`; } + /** Also tears down the doc's sub-view. */ empty() { if (this.subview != null) { this.subview.deactivate(); @@ -116,15 +141,17 @@ app.views.EntryPage = class EntryPage extends app.View { this.hiddenView = null; this.resetClass(); - super.empty(...arguments); + super.empty(); } + /** @returns {typeof BasePage | undefined} The views/pages class this doc needs. */ 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; } + /** @returns {string} */ getTitle() { return ( this.entry.doc.fullName + @@ -132,11 +159,13 @@ app.views.EntryPage = class EntryPage extends app.View { ); } + /** Abandons a page still in flight when the route changes. */ beforeRoute() { this.cache(); this.abort(); } + /** @param {Context} context */ onRoute(context) { const isSameFile = context.entry.filePath() === this.entry?.filePath?.(); this.entry = context.entry; @@ -145,6 +174,14 @@ app.views.EntryPage = class EntryPage extends app.View { } } + /** + * Fetches the entry's page, from the offline database or the network. + * + * @type {{ abort: () => void } | null} + */ + xhr; + + /** Fetches the entry's page, from the offline database or the network. */ load() { this.loading(); this.xhr = this.entry.loadFile( @@ -153,6 +190,7 @@ app.views.EntryPage = class EntryPage extends app.View { ); } + /** Cancels the request in flight, if any. */ abort() { if (this.xhr) { this.xhr.abort(); @@ -160,6 +198,7 @@ app.views.EntryPage = class EntryPage extends app.View { } } + /** @param {string} response The entry's HTML. */ onSuccess(response) { if (!this.activated) { return; @@ -168,16 +207,18 @@ app.views.EntryPage = class EntryPage extends app.View { this.render(this.prepareContent(response)); } + /** Shows the load error in place of the page. */ onError() { this.xhr = null; this.render(this.tmpl("pageLoadError")); this.resetClass(); - this.addClass(this.constructor.errorClass); + this.addClass(this.statics().errorClass); if (app.serviceWorker != null) { app.serviceWorker.update(); } } + /** Keeps the rendered page in memory, evicting the oldest. */ cache() { let path; if ( @@ -196,6 +237,7 @@ app.views.EntryPage = class EntryPage extends app.View { } } + /** @returns {boolean | undefined} `true` when the page came from memory. */ restore() { const path = this.entry.filePath(); if (this.cacheMap[[path]]) { @@ -204,6 +246,7 @@ app.views.EntryPage = class EntryPage extends app.View { } } + /** @param {ViewMouseEvent} event */ onClick(event) { const target = $.eventTarget(event); if (target.hasAttribute("data-retry")) { @@ -219,13 +262,17 @@ app.views.EntryPage = class EntryPage extends app.View { } } + /** @returns {HTMLAnchorElement | undefined} The link to the entry on the documentation's own site. */ originalLink() { // The attribution is appended last but may be followed by other elements, // so match on the last attribution rather than on its sibling position. - const links = this.findAll("._attribution ._attribution-link"); + const links = /** @type {NodeListOf} */ ( + this.findAll("._attribution ._attribution-link") + ); return links[links.length - 1]; } + /** Copies the original page's link. */ onAltC() { const link = this.originalLink(); if (!link) { @@ -246,6 +293,7 @@ app.views.EntryPage = class EntryPage extends app.View { }); } + /** Opens the original page. */ onAltO() { const link = this.originalLink(); if (!link) { @@ -255,6 +303,7 @@ app.views.EntryPage = class EntryPage extends app.View { this.delay(() => $.popup(link.href + location.hash)); } + /** @param {string} type Names the notice template to show. */ showTransientNotice(type) { this.hideTransientNotice(); this.transientNotice = new app.views.Notice(type); @@ -264,6 +313,7 @@ app.views.EntryPage = class EntryPage extends app.View { this.transientNoticeTimer = this.delay(this.hideTransientNotice, 3000); } + /** Takes the notice back off. */ hideTransientNotice() { if (!this.transientNotice) { return; @@ -273,4 +323,8 @@ app.views.EntryPage = class EntryPage extends app.View { this.transientNotice = null; 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 1b811599d4..1dd5c998f0 100644 --- a/assets/javascripts/views/content/offline_page.js +++ b/assets/javascripts/views/content/offline_page.js @@ -1,4 +1,10 @@ -app.views.OfflinePage = class OfflinePage extends app.View { +// @ts-check + +/** + * The offline page: installing and removing each doc's database, and backing + * the whole lot up to a file. + */ +class OfflinePage extends app.View { static className = "_static"; static events = { @@ -6,12 +12,14 @@ app.views.OfflinePage = class OfflinePage extends app.View { change: "onChange", }; + /** Also empties the table. */ deactivate() { - if (super.deactivate(...arguments)) { + if (super.deactivate()) { this.empty(); } } + /** Rebuilds the table from the docs and their install statuses. */ render() { if (app.cookieBlocked) { this.html(this.tmpl("offlineError", "cookie_blocked")); @@ -42,14 +50,20 @@ app.views.OfflinePage = class OfflinePage extends app.View { }); } + /** + * @param {Doc} doc + * @param {InstallStatus} status + */ renderDoc(doc, status) { return app.templates.render("offlineDoc", doc, status); } + /** @returns {string} */ getTitle() { return "Offline"; } + /** Re-reads the install statuses and rebuilds the table. */ refreshLinks() { for (var action of ["install", "update", "uninstall"]) { this.find(`[data-action-all='${action}']`).classList[ @@ -58,22 +72,32 @@ app.views.OfflinePage = class OfflinePage extends app.View { } } + /** + * @param {HTMLElement} el A node inside a row. + * @returns {Doc | undefined} The row's doc. + */ docByEl(el) { let slug; while (!(slug = el.getAttribute("data-slug"))) { - el = el.parentNode; + el = el.parentElement; } return app.docs.findBy("slug", slug); } + /** + * @param {Doc} doc + * @returns {HTMLElement} The doc's row. + */ docEl(doc) { return this.find(`[data-slug='${doc.slug}']`); } + /** @param {unknown} context */ onRoute(context) { this.render(); } + /** @param {ViewMouseEvent} event */ onClick(event) { let el = $.eventTarget(event); let action = el.getAttribute("data-action"); @@ -89,7 +113,7 @@ app.views.OfflinePage = class OfflinePage extends app.View { this.onInstallError.bind(this, doc), this.onInstallProgress.bind(this, doc) ); - el.parentNode.innerHTML = `${el.textContent.replace(/e$/, "")}ing…`; + el.parentElement.innerHTML = `${el.textContent.replace(/e$/, "")}ing…`; } else if ( (action = el.getAttribute("data-action-all") || @@ -109,6 +133,7 @@ app.views.OfflinePage = class OfflinePage extends app.View { } } + /** @param {Doc} doc */ onInstallSuccess(doc) { if (!this.activated) { return; @@ -126,6 +151,7 @@ app.views.OfflinePage = class OfflinePage extends app.View { }); } + /** @param {Doc} doc */ onInstallError(doc) { if (!this.activated) { return; @@ -136,6 +162,10 @@ app.views.OfflinePage = class OfflinePage extends app.View { } } + /** + * @param {Doc} doc + * @param {ProgressEvent} event + */ onInstallProgress(doc, event) { if (!this.activated || !event.lengthComputable) { return; @@ -150,6 +180,7 @@ app.views.OfflinePage = class OfflinePage extends app.View { } } + /** @param {ViewInputEvent} event */ onChange(event) { if (event.target.name === "autoUpdate") { app.settings.set("manualUpdate", !event.target.checked); @@ -158,12 +189,19 @@ app.views.OfflinePage = class OfflinePage extends app.View { } } + /** Exports every installed doc to a file. */ backup() { return this._backup || (this._backup = new app.OfflineBackup()); } // Exports `docs` into a single file. Returns false when another backup is // already running, in which case `onDone` is never called. + /** + * @param {Doc[]} docs + * @param {(success: boolean) => void} [onDone] + * @returns {boolean} Whether the export started; it doesn't while one is + * already running. + */ exportDocs(docs, onDone) { if (this.backingUp) { return false; @@ -198,15 +236,20 @@ app.views.OfflinePage = class OfflinePage extends app.View { return true; } + /** + * @param {Doc} doc + * @param {HTMLElement} el The doc's row. + */ exportDoc(doc, el) { const started = this.exportDocs([doc], (success) => success ? this.onInstallSuccess(doc) : this.onInstallError(doc), ); if (started) { - el.parentNode.innerHTML = "Exporting\u2026"; + el.parentElement.innerHTML = "Exporting\u2026"; } } + /** @param {HTMLInputElement} input The file field the backup was chosen with. */ importDocs(input) { const file = input.files[0]; input.value = ""; // so that picking the same file again fires a change event @@ -252,6 +295,10 @@ app.views.OfflinePage = class OfflinePage extends app.View { ); } + /** + * @param {string} html + * @param {boolean} [isError] + */ setBackupStatus(html, isError) { const el = this.find("#_offline-backup-status"); if (el) { @@ -259,6 +306,7 @@ app.views.OfflinePage = class OfflinePage extends app.View { } } + /** @param {(hasPersistence: boolean, isPersistent: boolean) => void} callback */ checkPersistence(callback) { if (navigator.storage && navigator.storage.persisted) { navigator.storage @@ -270,6 +318,7 @@ app.views.OfflinePage = class OfflinePage extends app.View { } } + /** Asks the browser not to evict the offline data. */ requestPersistence() { navigator.storage .persist() @@ -279,6 +328,10 @@ app.views.OfflinePage = class OfflinePage extends app.View { ); } + /** + * @param {boolean} success + * @param {unknown} [exception] + */ onPersistenceRequestCompleted(success, exception) { if (!this.activated) { return; @@ -291,4 +344,8 @@ app.views.OfflinePage = class OfflinePage extends app.View { // the page would produce; the disappearing button is the confirmation. 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 26bfa0b1c5..059a2cba7b 100644 --- a/assets/javascripts/views/content/root_page.js +++ b/assets/javascripts/views/content/root_page.js @@ -1,6 +1,13 @@ -app.views.RootPage = class RootPage extends app.View { +// @ts-check + +/** + * The app's index: the introduction, or the splash screen once the user has + * dismissed it. + */ +class RootPage extends app.View { static events = { click: "onClick" }; + /** @inheritdoc */ init() { if (!this.isHidden()) { this.setHidden(false); @@ -8,6 +15,7 @@ app.views.RootPage = class RootPage extends app.View { this.render(); } + /** Shows whichever of the introduction and the splash belongs here. */ render() { this.empty(); @@ -22,25 +30,34 @@ app.views.RootPage = class RootPage extends app.View { this.append(this.tmpl(tmpl)); } + /** Dismisses the introduction for good. */ hideIntro() { this.setHidden(true); this.render(); } + /** @param {boolean} value */ setHidden(value) { app.settings.set("hideIntro", value); } + /** @returns {boolean} Whether the introduction has been dismissed. */ isHidden() { - return app.isSingleDoc() || app.settings.get("hideIntro"); + return app.isSingleDoc() || !!app.settings.get("hideIntro"); } + /** @inheritdoc */ onRoute() {} + /** @param {ViewMouseEvent} event */ onClick(event) { if ($.eventTarget(event).hasAttribute("data-hide-intro")) { $.stopEvent(event); this.hideIntro(); } } -}; +} + +// 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 fa68fecd90..8183fe684c 100644 --- a/assets/javascripts/views/content/settings_page.js +++ b/assets/javascripts/views/content/settings_page.js @@ -1,4 +1,12 @@ -app.views.SettingsPage = class SettingsPage extends app.View { +// @ts-check + +/** + * 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 { static className = "_static"; static events = { @@ -6,10 +14,12 @@ app.views.SettingsPage = class SettingsPage extends app.View { change: "onChange", }; + /** Rebuilds the form from the stored preferences. */ render() { this.html(this.tmpl("settingsPage", this.currentSettings())); } + /** @returns {Record} The values the form should show. */ currentSettings() { const settings = {}; settings.theme = app.settings.get("theme"); @@ -29,41 +39,59 @@ app.views.SettingsPage = class SettingsPage extends app.View { return settings; } + /** @returns {string} */ getTitle() { return "Preferences"; } + /** @param {string} value */ setTheme(value) { app.settings.set("theme", value); } + /** + * @param {string} layout + * @param {boolean} enable + */ toggleLayout(layout, enable) { app.settings.setLayout(layout, enable); } + /** @param {boolean} enable */ toggleSmoothScroll(enable) { app.settings.set("fastScroll", !enable); } + /** @param {boolean} enable Clears the analytics cookies when turned off. */ toggleAnalyticsConsent(enable) { - app.settings.set("analyticsConsent", enable ? "1" : "0"); + app.settings.set("analyticsConsent", enable ? 1 : 0); if (!enable) { resetAnalytics(); } } + /** @param {boolean} enable */ toggleSpaceScroll(enable) { app.settings.set("spaceScroll", enable ? 1 : 0); } + /** + * @param {string | number} value In seconds. Comes straight off the field, + * so it is a string; the store keeps it as one and the reader coerces. + */ setScrollTimeout(value) { return app.settings.set("spaceTimeout", value); } + /** + * @param {keyof SettingsValues} name + * @param {boolean} enable + */ toggle(name, enable) { app.settings.set(name, enable); } + /** Saves the preferences to a file. */ export() { const data = new Blob([JSON.stringify(app.settings.export())], { type: "application/json", @@ -71,6 +99,12 @@ app.views.SettingsPage = class SettingsPage extends app.View { $.download(data, "devdocs.json"); } + /** + * Replaces the preferences with the contents of a file. + * + * @param {File} file + * @param {HTMLInputElement} input The file field, reset once the import is done. + */ import(file, input) { if (!file || file.type !== "application/json") { new app.views.Notif("ImportInvalid", { autoHide: false }); @@ -81,7 +115,7 @@ app.views.SettingsPage = class SettingsPage extends app.View { reader.onloadend = function () { const data = (() => { try { - return JSON.parse(reader.result); + return JSON.parse(/** @type {string} */ (reader.result)); } catch (error) {} })(); if (!data || data.constructor !== Object) { @@ -94,6 +128,7 @@ app.views.SettingsPage = class SettingsPage extends app.View { reader.readAsText(file); } + /** @param {ViewInputEvent} event */ onChange(event) { const input = event.target; switch (input.name) { @@ -119,10 +154,14 @@ app.views.SettingsPage = class SettingsPage extends app.View { this.setScrollTimeout(input.value); break; default: - this.toggle(input.name, input.checked); + this.toggle( + /** @type {keyof SettingsValues} */ (input.name), + input.checked, + ); } } + /** @param {ViewMouseEvent} event */ onClick(event) { const target = $.eventTarget(event); switch (target.getAttribute("data-action")) { @@ -133,7 +172,12 @@ app.views.SettingsPage = class SettingsPage extends app.View { } } + /** @param {unknown} context */ onRoute(context) { 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 bdada2e1fa..9d354c6ab9 100644 --- a/assets/javascripts/views/content/static_page.js +++ b/assets/javascripts/views/content/static_page.js @@ -1,4 +1,7 @@ -app.views.StaticPage = class StaticPage extends app.View { +// @ts-check + +/** The app's own pages — About, News, the user guide and the 404. */ +class StaticPage extends app.View { static className = "_static"; static titles = { @@ -8,23 +11,31 @@ app.views.StaticPage = class StaticPage extends app.View { notFound: "404", }; + /** Also forgets which page was shown. */ deactivate() { - if (super.deactivate(...arguments)) { + if (super.deactivate()) { this.empty(); this.page = null; } } + /** @param {string} page One of the keys of `titles`. */ render(page) { this.page = page; this.html(this.tmpl(`${this.page}Page`)); } + /** @returns {string} */ getTitle() { - return this.constructor.titles[this.page]; + return this.statics().titles[this.page]; } + /** @param {Context} context */ onRoute(context) { 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 a7dbafae75..78c2bf0889 100644 --- a/assets/javascripts/views/content/type_page.js +++ b/assets/javascripts/views/content/type_page.js @@ -1,24 +1,35 @@ -app.views.TypePage = class TypePage extends app.View { +// @ts-check + +/** A type's page: every entry of that type in the doc. */ +class TypePage extends app.View { static className = "_page"; + /** Also forgets which type was shown. */ deactivate() { - if (super.deactivate(...arguments)) { + if (super.deactivate()) { this.empty(); this.type = null; } } + /** @param {Type} type */ render(type) { this.type = type; this.html(this.tmpl("typePage", this.type)); setFaviconForDoc(this.type.doc); } + /** @returns {string} */ getTitle() { return `${this.type.doc.fullName} / ${this.type.name}`; } + /** @param {Context} context */ onRoute(context) { 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 f3dfc448e1..3af27eb5c2 100644 --- a/assets/javascripts/views/layout/document.js +++ b/assets/javascripts/views/layout/document.js @@ -1,4 +1,13 @@ -app.views.Document = class Document extends app.View { +// @ts-check + +/** + * The root view, bound to the document itself. + * + * Owns the menu, the sidebar, the content and the preferences panel, and + * handles the shortcuts and the `data-behavior` links that aren't tied to any + * one of them. + */ +class AppDocument extends app.View { static el = document; static events = { visibilitychange: "onVisibilityChange" }; @@ -13,10 +22,12 @@ app.views.Document = class Document extends app.View { static routes = { after: "afterRoute" }; + /** @inheritdoc */ init() { this.menu = new app.views.Menu(); this.sidebar = new app.views.Sidebar(); - this.addSubview(this.menu, this.addSubview(this.sidebar)); + this.addSubview(this.sidebar); + this.addSubview(this.menu); if (app.views.Resizer.isSupported()) { this.resizer = new app.views.Resizer(); this.addSubview(this.resizer); @@ -36,12 +47,14 @@ app.views.Document = class Document extends app.View { this.activate(); } + /** @param {string} [title] Prefixed to the app's name, or omitted for the app's name alone. */ setTitle(title) { return (this.el.title = title ? `${title} — DevDocs` : "DevDocs API Documentation"); } + /** @param {string} route */ afterRoute(route) { if (route === "settings") { if (this.settings != null) { @@ -54,8 +67,12 @@ app.views.Document = class Document extends app.View { } } + /** + * Reloads when the viewport crossed the phone-layout threshold while the + * tab was in the background, e.g. after the device was rotated. + */ onVisibilityChange() { - if (this.el.visibilityState !== "visible") { + if (document.visibilityState !== "visible") { return; } this.delay(() => { @@ -65,14 +82,17 @@ app.views.Document = class Document extends app.View { }, 300); } + /** Opens the keyboard shortcuts. */ onHelp() { app.router.show("/help#shortcuts"); } + /** Opens the preferences. */ onPreferences() { app.router.show("/settings"); } + /** Goes up to the doc's index, or to the app's index. */ onEscape() { const path = !app.isSingleDoc() || location.pathname === app.doc.fullPath() @@ -82,14 +102,21 @@ app.views.Document = class Document extends app.View { app.router.show(path); } + /** Goes back. */ onBack() { history.back(); } + /** Goes forward. */ onForward() { history.forward(); } + /** + * Runs the `data-behavior` the click landed on, if any. + * + * @param {ViewMouseEvent} event + */ onClick(event) { const target = $.eventTarget(event); if (!target.hasAttribute("data-behavior")) { @@ -122,4 +149,8 @@ app.views.Document = class Document extends app.View { break; } } -}; +} + +// 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 51e6648ec2..43a4fd5eb3 100644 --- a/assets/javascripts/views/layout/menu.js +++ b/assets/javascripts/views/layout/menu.js @@ -1,13 +1,22 @@ -app.views.Menu = class Menu extends app.View { +// @ts-check + +/** The header menu, opened by the toggle and closed by a click anywhere else. */ +class Menu extends app.View { static el = "._menu"; static activeClass = "active"; static events = { click: "onClick" }; + /** @inheritdoc */ init() { $.on(document.body, "click", (event) => this.onGlobalClick(event)); } + /** + * Drops the focus ring after following a link. + * + * @param {ViewMouseEvent} event + */ onClick(event) { const target = $.eventTarget(event); if (target.tagName === "A") { @@ -15,6 +24,7 @@ app.views.Menu = class Menu extends app.View { } } + /** @param {ViewMouseEvent} event */ onGlobalClick(event) { if (event.which !== 1) { return; @@ -24,9 +34,13 @@ app.views.Menu = class Menu extends app.View { ? event.target.hasAttribute("data-toggle-menu") : undefined ) { - this.toggleClass(this.constructor.activeClass); - } else if (this.hasClass(this.constructor.activeClass)) { - this.removeClass(this.constructor.activeClass); + this.toggleClass(this.statics().activeClass); + } else if (this.hasClass(this.statics().activeClass)) { + this.removeClass(this.statics().activeClass); } } -}; +} + +// 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 844c3feed0..a639a69e42 100644 --- a/assets/javascripts/views/layout/mobile.js +++ b/assets/javascripts/views/layout/mobile.js @@ -1,4 +1,10 @@ -app.views.Mobile = class Mobile extends app.View { +// @ts-check + +/** + * 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 { static className = "_mobile"; static elements = { @@ -12,6 +18,11 @@ app.views.Mobile = class Mobile extends app.View { static routes = { after: "afterRoute" }; + /** + * @returns {boolean} Whether to use the phone layout. The user agent is + * consulted as well as the viewport, because some devices report a + * desktop-sized width. + */ static detect() { if (Cookies.get("override-mobile-detect") != null) { return JSON.parse(Cookies.get("override-mobile-detect")); @@ -33,6 +44,7 @@ app.views.Mobile = class Mobile extends app.View { } } + /** @returns {boolean} Whether the app is running inside an Android webview. */ static detectAndroidWebview() { try { return /(Android).*( Version\/.\.. ).*(Chrome)/.test(navigator.userAgent); @@ -41,10 +53,12 @@ app.views.Mobile = class Mobile extends app.View { } } + /** Binds to the document element, which carries the layout classes. */ constructor() { super(document.documentElement); } + /** @inheritdoc */ init() { $.on($("._search"), "touchend", () => this.onTapSearch()); @@ -75,6 +89,7 @@ app.views.Mobile = class Mobile extends app.View { this.activate(); } + /** Brings the sidebar into view. */ showSidebar() { if (this.isSidebarShown()) { window.scrollTo(0, 0); @@ -101,6 +116,7 @@ app.views.Mobile = class Mobile extends app.View { } } + /** Puts the content back in view. */ hideSidebar() { if (!this.isSidebarShown()) { return; @@ -111,18 +127,22 @@ app.views.Mobile = class Mobile extends app.View { window.scrollTo(0, this.contentTop || 0); } + /** @returns {boolean} */ isSidebarShown() { return this.sidebar.style.display !== "none"; } + /** Goes back, or up to the doc's index. */ onClickBack() { return history.back(); } + /** Goes forward. */ onClickForward() { return history.forward(); } + /** Swaps between the sidebar and the content. */ onClickToggleSidebar() { if (this.isSidebarShown()) { this.hideSidebar(); @@ -131,16 +151,19 @@ app.views.Mobile = class Mobile extends app.View { } } + /** @param {ViewMouseEvent} event */ onClickDocPickerTab(event) { $.stopEvent(event); this.showDocPicker(); } + /** @param {ViewMouseEvent} event */ onClickSettingsTab(event) { $.stopEvent(event); this.showSettings(); } + /** Switches the preferences panel to the doc picker. */ showDocPicker() { window.scrollTo(0, 0); this.docPickerTab.classList.add("active"); @@ -149,6 +172,7 @@ app.views.Mobile = class Mobile extends app.View { this.content.style.display = "none"; } + /** Switches the preferences panel to the settings. */ showSettings() { window.scrollTo(0, 0); this.docPickerTab.classList.remove("active"); @@ -157,14 +181,17 @@ app.views.Mobile = class Mobile extends app.View { this.content.style.display = "block"; } + /** Reveals the sidebar when the search field is tapped. */ onTapSearch() { return window.scrollTo(0, 0); } + /** Leaves the sidebar. */ onEscape() { return this.hideSidebar(); } + /** @param {string} route */ afterRoute(route) { this.hideSidebar(); @@ -186,4 +213,8 @@ app.views.Mobile = class Mobile extends app.View { this.forward.setAttribute("disabled", "disabled"); } } -}; +} + +// 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 1b597e7af8..b229826065 100644 --- a/assets/javascripts/views/layout/path.js +++ b/assets/javascripts/views/layout/path.js @@ -1,4 +1,10 @@ -app.views.Path = class Path extends app.View { +// @ts-check + +/** + * 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 { static className = "_path"; static attributes = { role: "complementary" }; @@ -6,23 +12,32 @@ app.views.Path = class Path extends app.View { static routes = { after: "afterRoute" }; + /** @param {...unknown} args The doc, then optionally the type and the entry. */ render(...args) { this.html(this.tmpl("path", ...args)); this.show(); } + /** Puts the breadcrumb above the content, if it isn't there already. */ show() { if (!this.el.parentNode) { this.prependTo(app.el); } } + /** Takes it off the page. */ hide() { if (this.el.parentNode) { $.remove(this.el); } } + /** + * Notes that the next route came from the breadcrumb, so that the sidebar + * can be reset to match. + * + * @param {ViewMouseEvent} event + */ onClick(event) { const link = $.closestLink(event.target, this.el); if (link) { @@ -30,6 +45,10 @@ app.views.Path = class Path extends app.View { } } + /** + * @param {string} route + * @param {Context} context + */ afterRoute(route, context) { if (context.type) { this.render(context.doc, context.type); @@ -48,4 +67,8 @@ app.views.Path = class Path extends app.View { app.document.sidebar.reset(); } } -}; +} + +// 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 4b20efb088..de9c296555 100644 --- a/assets/javascripts/views/layout/resizer.js +++ b/assets/javascripts/views/layout/resizer.js @@ -1,4 +1,13 @@ -app.views.Resizer = class Resizer extends app.View { +// @ts-check + +/** + * The handle between the sidebar and the content. + * + * Dragged with the HTML5 drag-and-drop API, which is why the width is only + * 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 { static className = "_resizer"; static events = { @@ -9,15 +18,22 @@ app.views.Resizer = class Resizer extends app.View { static MIN = 260; static MAX = 600; + /** @returns {boolean} Whether the browser supports dragging and isn't a phone. */ static isSupported() { return "ondragstart" in document.createElement("div") && !app.isMobile(); } + /** @inheritdoc */ init() { this.el.setAttribute("draggable", "true"); this.appendTo($("._app")); } + /** + * @param {number} value The sidebar's new width, as a page coordinate. + * Clamped between `MIN` and `MAX`. + * @param {boolean} save Whether to remember the width. + */ resize(value, save) { value -= app.el.offsetLeft; if (!(value > 0)) { @@ -31,6 +47,7 @@ app.views.Resizer = class Resizer extends app.View { } } + /** @param {DragEvent} event */ onDragStart(event) { event.dataTransfer.effectAllowed = "link"; event.dataTransfer.setData("Text", ""); @@ -38,6 +55,7 @@ app.views.Resizer = class Resizer extends app.View { $.on(window, "dragover", this.onDrag); } + /** @param {DragEvent} event */ onDrag(event) { const value = event.pageX; if (!(value > 0)) { @@ -53,6 +71,7 @@ app.views.Resizer = class Resizer extends app.View { }); } + /** @param {DragEvent} event */ onDragEnd(event) { if (this.rafPending) { cancelAnimationFrame(this.rafPending); @@ -69,4 +88,8 @@ app.views.Resizer = 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 52546dd9c6..28ef26c718 100644 --- a/assets/javascripts/views/layout/settings.js +++ b/assets/javascripts/views/layout/settings.js @@ -1,4 +1,12 @@ -app.views.Settings = class Settings extends app.View { +// @ts-check + +/** + * 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 { static SIDEBAR_HIDDEN_LAYOUT = "_sidebar-hidden"; static el = "._settings"; @@ -18,33 +26,44 @@ app.views.Settings = class Settings extends app.View { static shortcuts = { enter: "onEnter" }; + /** @inheritdoc */ init() { this.addSubview((this.docPicker = new app.views.DocPicker())); } + /** Also renders the panel and forces the sidebar to show. */ activate() { - if (super.activate(...arguments)) { + if (super.activate()) { this.render(); - document.body.classList.remove(Settings.SIDEBAR_HIDDEN_LAYOUT); + document.body.classList.remove(SettingsView.SIDEBAR_HIDDEN_LAYOUT); } } + /** Also puts the sidebar back the way the user had it. */ deactivate() { - if (super.deactivate(...arguments)) { + if (super.deactivate()) { this.resetClass(); this.docPicker.detach(); - if (app.settings.hasLayout(Settings.SIDEBAR_HIDDEN_LAYOUT)) { - document.body.classList.add(Settings.SIDEBAR_HIDDEN_LAYOUT); + if (app.settings.hasLayout(SettingsView.SIDEBAR_HIDDEN_LAYOUT)) { + document.body.classList.add(SettingsView.SIDEBAR_HIDDEN_LAYOUT); } } } + /** Puts the doc picker in the sidebar and slides the panel in. */ render() { this.docPicker.appendTo(this.sidebar); this.refreshElements(); this.addClass("_in"); } + /** + * Applies the chosen docs and reloads. Does nothing while a save is + * already running. + * + * @param {{ import?: boolean }} [options] Pass `import` when the docs were + * just replaced by an import, so the picker isn't read back. + */ save(options) { if (options == null) { options = {}; @@ -79,24 +98,29 @@ app.views.Settings = class Settings extends app.View { } } + /** Marks the panel as having unsaved changes. */ onChange() { this.addClass("_dirty"); } + /** Saves on Enter. */ onEnter() { this.save(); } + /** @param {ViewEvent} event */ onSubmit(event) { event.preventDefault(); this.save(); } + /** Saves after the preferences were replaced by an import. */ onImport() { this.addClass("_dirty"); this.save({ import: true }); } + /** @param {ViewMouseEvent} event */ onClick(event) { if (event.which !== 1) { return; @@ -106,4 +130,8 @@ app.views.Settings = class Settings extends app.View { app.router.show("/"); } } -}; +} + +// 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 a1a010b20a..5ebb16f4e7 100644 --- a/assets/javascripts/views/list/list_focus.js +++ b/assets/javascripts/views/list/list_focus.js @@ -1,4 +1,23 @@ -app.views.ListFocus = class ListFocus extends app.View { +// @ts-check + +/** + * The lists are built entirely from elements, so the sibling and parent walks + * below only ever reach one. + * + * @param {ChildNode | ParentNode | null} node + * @returns {HTMLElement | null} + */ +const asElement = (node) => /** @type {HTMLElement | null} */ (node); + +/** + * Keyboard navigation through a list. + * + * The focused row carries `activeClass`; moving the focus emits `focus` and + * `blur` on the rows. The focus starts from the selected row when nothing is + * 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 { static activeClass = "focus"; static events = { click: "onClick" }; @@ -12,41 +31,52 @@ app.views.ListFocus = class ListFocus extends app.View { escape: "blur", }; + /** @param {HTMLElement} [el] The list to navigate. */ constructor(el) { super(el); this.focusOnNextFrame = (el) => requestAnimationFrame(() => this.focus(el)); } + /** + * @param {HTMLElement} el The row to focus. + * @param {{ silent?: boolean }} [options] Pass `silent` to move without emitting `focus`. + */ focus(el, options) { if (options == null) { options = {}; } - if (el && !el.classList.contains(this.constructor.activeClass)) { + if (el && !el.classList.contains(this.statics().activeClass)) { this.blur(); - el.classList.add(this.constructor.activeClass); + el.classList.add(this.statics().activeClass); if (options.silent !== true) { $.trigger(el, "focus"); } } } + /** Clears the focus. */ blur() { const cursor = this.getCursor(); if (cursor) { - cursor.classList.remove(this.constructor.activeClass); + cursor.classList.remove(this.statics().activeClass); $.trigger(cursor, "blur"); } } + /** @returns {HTMLElement | undefined} The focused row, or the selected one when nothing is focused. */ getCursor() { return ( - this.findByClass(this.constructor.activeClass) || + this.findByClass(this.statics().activeClass) || this.findByClass(app.views.ListSelect.activeClass) ); } + /** + * @param {HTMLElement | null} cursor + * @returns {HTMLElement | null | undefined} The row after `cursor`, descending into expanded sub-lists. + */ findNext(cursor) { - const next = cursor.nextSibling; + const next = asElement(cursor.nextSibling); if (next) { if (next.tagName === "A") { return next; @@ -66,12 +96,16 @@ app.views.ListFocus = class ListFocus extends app.View { return this.findNext(next); } } else if (cursor.parentNode !== this.el) { - return this.findNext(cursor.parentNode); + return this.findNext(asElement(cursor.parentNode)); } } + /** + * @param {HTMLElement | null} cursor + * @returns {HTMLElement | null | undefined} The first row of the sub-list under `cursor`. + */ findFirst(cursor) { - const first = cursor.firstChild; + const first = asElement(cursor.firstChild); if (!first) { return; } @@ -85,8 +119,12 @@ app.views.ListFocus = class ListFocus extends app.View { } } + /** + * @param {HTMLElement | null} cursor + * @returns {HTMLElement | null | undefined} The row before `cursor`, descending into expanded sub-lists. + */ findPrev(cursor) { - const prev = cursor.previousSibling; + const prev = asElement(cursor.previousSibling); if (prev) { if (prev.tagName === "A") { return prev; @@ -96,7 +134,7 @@ app.views.ListFocus = class ListFocus extends app.View { return this.findPrev(cursor); } else if (prev.tagName === "DIV") { // sub-list - if (prev.previousSibling.className.includes("open")) { + if (asElement(prev.previousSibling)?.className.includes("open")) { return this.findLast(prev) || this.findPrev(prev); } else { return this.findPrev(prev); @@ -106,12 +144,16 @@ app.views.ListFocus = class ListFocus extends app.View { return this.findPrev(prev); } } else if (cursor.parentNode !== this.el) { - return this.findPrev(cursor.parentNode); + return this.findPrev(asElement(cursor.parentNode)); } } + /** + * @param {HTMLElement | null} cursor + * @returns {HTMLElement | null | undefined} The last row of the sub-list under `cursor`. + */ findLast(cursor) { - const last = cursor.lastChild; + const last = asElement(cursor.lastChild); if (!last) { return; } @@ -127,6 +169,7 @@ app.views.ListFocus = class ListFocus extends app.View { } } + /** Moves the focus down one row. */ onDown() { const cursor = this.getCursor(); if (cursor) { @@ -136,6 +179,7 @@ app.views.ListFocus = class ListFocus extends app.View { } } + /** Moves the focus up one row. */ onUp() { const cursor = this.getCursor(); if (cursor) { @@ -145,6 +189,7 @@ app.views.ListFocus = class ListFocus extends app.View { } } + /** Moves the focus out to the row the current sub-list hangs off. */ onLeft() { const cursor = this.getCursor(); if ( @@ -152,13 +197,14 @@ app.views.ListFocus = class ListFocus extends app.View { !cursor.classList.contains(app.views.ListFold.activeClass) && cursor.parentNode !== this.el ) { - const prev = cursor.parentNode.previousSibling; + const prev = asElement(asElement(cursor.parentNode)?.previousSibling ?? null); if (prev && prev.classList.contains(app.views.ListFold.targetClass)) { - this.focusOnNextFrame(cursor.parentNode.previousSibling); + this.focusOnNextFrame(prev); } } } + /** Follows the focused row. */ onEnter() { const cursor = this.getCursor(); if (cursor) { @@ -166,13 +212,15 @@ app.views.ListFocus = class ListFocus extends app.View { } } + /** Opens the focused row outside the app. */ onSuperEnter() { const cursor = this.getCursor(); if (cursor) { - $.popup(cursor); + $.popup(/** @type {HTMLAnchorElement} */ (cursor)); } } + /** @param {ViewMouseEvent} event */ onClick(event) { if (event.which !== 1 || event.metaKey || event.ctrlKey) { return; @@ -182,4 +230,8 @@ app.views.ListFocus = class ListFocus extends app.View { this.focus(target, { silent: 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.ListFocus = ListFocus; diff --git a/assets/javascripts/views/list/list_fold.js b/assets/javascripts/views/list/list_fold.js index 3964888bc9..0702cfc5e9 100644 --- a/assets/javascripts/views/list/list_fold.js +++ b/assets/javascripts/views/list/list_fold.js @@ -1,4 +1,15 @@ -app.views.ListFold = class ListFold extends app.View { +// @ts-check + +/** + * Expanding and collapsing the sidebar's nested lists. + * + * Attached alongside a list rather than owning it: rows carrying + * `targetClass` can be folded, the arrow carrying `handleClass` toggles them, + * and an expanded row carries `activeClass`. Opening and closing emit `open` + * and `close` on the row, which the lists listen for to render their contents + * lazily. + */ +class ListFold extends app.View { static targetClass = "_list-dir"; static handleClass = "_list-arrow"; static activeClass = "open"; @@ -10,35 +21,40 @@ app.views.ListFold = class ListFold extends app.View { right: "onRight", }; + /** @param {HTMLElement} el The row to expand. */ open(el) { - if (el && !el.classList.contains(this.constructor.activeClass)) { - el.classList.add(this.constructor.activeClass); + if (el && !el.classList.contains(this.statics().activeClass)) { + el.classList.add(this.statics().activeClass); $.trigger(el, "open"); } } + /** @param {HTMLElement} el The row to collapse. */ close(el) { - if (el && el.classList.contains(this.constructor.activeClass)) { - el.classList.remove(this.constructor.activeClass); + if (el && el.classList.contains(this.statics().activeClass)) { + el.classList.remove(this.statics().activeClass); $.trigger(el, "close"); } } + /** @param {HTMLElement} el */ toggle(el) { - if (el.classList.contains(this.constructor.activeClass)) { + if (el.classList.contains(this.statics().activeClass)) { this.close(el); } else { this.open(el); } } + /** Collapses every expanded row. */ reset() { let el; - while ((el = this.findByClass(this.constructor.activeClass))) { + while ((el = this.findByClass(this.statics().activeClass))) { this.close(el); } } + /** @returns {HTMLElement | undefined} The focused row, or the selected one. */ getCursor() { return ( this.findByClass(app.views.ListFocus.activeClass) || @@ -46,24 +62,27 @@ app.views.ListFold = class ListFold extends app.View { ); } + /** Collapses the row under the cursor. */ onLeft() { const cursor = this.getCursor(); - if (cursor?.classList?.contains(this.constructor.activeClass)) { + if (cursor?.classList?.contains(this.statics().activeClass)) { this.close(cursor); } } + /** Expands the row under the cursor. */ onRight() { const cursor = this.getCursor(); if ( cursor != null - ? cursor.classList.contains(this.constructor.targetClass) + ? cursor.classList.contains(this.statics().targetClass) : undefined ) { this.open(cursor); } } + /** @param {ViewMouseEvent} event */ onClick(event) { if (event.which !== 1 || event.metaKey || event.ctrlKey) { return; @@ -72,16 +91,16 @@ app.views.ListFold = class ListFold extends app.View { return; } // ignore fabricated clicks let el = $.eventTarget(event); - if (el.parentNode.tagName.toUpperCase() === "SVG") { - el = el.parentNode; + if (el.parentElement?.tagName.toUpperCase() === "SVG") { + el = el.parentElement; } - if (el.classList.contains(this.constructor.handleClass)) { + if (el.classList.contains(this.statics().handleClass)) { $.stopEvent(event); - this.toggle(el.parentNode); - } else if (el.classList.contains(this.constructor.targetClass)) { + this.toggle(el.parentElement); + } else if (el.classList.contains(this.statics().targetClass)) { if (el.hasAttribute("href")) { - if (el.classList.contains(this.constructor.activeClass)) { + if (el.classList.contains(this.statics().activeClass)) { if (el.classList.contains(app.views.ListSelect.activeClass)) { this.close(el); } @@ -93,4 +112,8 @@ app.views.ListFold = 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 f0203803c6..4fa859012b 100644 --- a/assets/javascripts/views/list/list_select.js +++ b/assets/javascripts/views/list/list_select.js @@ -1,44 +1,59 @@ -app.views.ListSelect = class ListSelect extends app.View { +// @ts-check + +/** + * 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 { static activeClass = "active"; static events = { click: "onClick" }; + /** Also clears the selection. */ deactivate() { - if (super.deactivate(...arguments)) { + if (super.deactivate()) { this.deselect(); } } + /** @param {HTMLElement} el The row to select, deselecting whatever was selected. */ select(el) { this.deselect(); if (el) { - el.classList.add(this.constructor.activeClass); + el.classList.add(this.statics().activeClass); $.trigger(el, "select"); } } + /** Clears the selection. */ deselect() { const selection = this.getSelection(); if (selection) { - selection.classList.remove(this.constructor.activeClass); + selection.classList.remove(this.statics().activeClass); $.trigger(selection, "deselect"); } } + /** @param {string} href */ selectByHref(href) { if (this.getSelection()?.getAttribute("href") !== href) { this.select(this.find(`a[href='${href}']`)); } } + /** Selects the row pointing at the current page. */ selectCurrent() { this.selectByHref(location.pathname + location.hash); } + /** @returns {HTMLElement | undefined} The selected row. */ getSelection() { - return this.findByClass(this.constructor.activeClass); + return this.findByClass(this.statics().activeClass); } + /** @param {ViewMouseEvent} event */ onClick(event) { if (event.which !== 1 || event.metaKey || event.ctrlKey) { return; @@ -48,4 +63,8 @@ app.views.ListSelect = class ListSelect extends app.View { this.select(target); } } -}; +} + +// 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 7dabbcfe6d..dde9f76c5c 100644 --- a/assets/javascripts/views/list/paginated_list.js +++ b/assets/javascripts/views/list/paginated_list.js @@ -1,15 +1,25 @@ -app.views.PaginatedList = class PaginatedList extends app.View { +// @ts-check + +/** + * 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; + /** @param {unknown[]} data Every row, rendered a page at a time. */ constructor(data) { super(); this.data = data; - this.constructor.events = this.constructor.events || {}; - if (this.constructor.events.click == null) { - this.constructor.events.click = "onClick"; + this.statics().events = this.statics().events || {}; + if (this.statics().events.click == null) { + this.statics().events.click = "onClick"; } } + /** Renders the first page, or the whole list when it fits on one. */ renderPaginated() { this.page = 0; @@ -22,10 +32,15 @@ app.views.PaginatedList = class PaginatedList extends app.View { // render: (dataSlice) -> implemented by subclass + /** @returns {string} Every row. */ renderAll() { return this.render(this.data); } + /** + * @param {number} page One-based. + * @returns {string} + */ renderPage(page) { return this.render( this.data.slice( @@ -35,39 +50,62 @@ app.views.PaginatedList = class PaginatedList extends app.View { ); } + /** + * @param {number} count How many rows the link would add. + * @returns {string} + */ renderPageLink(count) { return this.tmpl("sidebarPageLink", count); } + /** + * @param {number} page + * @returns {string} The link that prepends the page before `page`. + */ renderPrevLink(page) { return this.renderPageLink((page - 1) * PaginatedList.PER_PAGE); } + /** + * @param {number} page + * @returns {string} The link that appends the page after `page`. + */ renderNextLink(page) { return this.renderPageLink( this.data.length - page * PaginatedList.PER_PAGE, ); } + /** @returns {number} */ totalPages() { return Math.ceil(this.data.length / PaginatedList.PER_PAGE); } + /** + * Extends the list in the direction the link points, holding the scroll + * position so that the rows under the pointer don't move. + * + * @param {HTMLElement} link + */ paginate(link) { - $.lockScroll(link.nextSibling || link.previousSibling, () => { - $.batchUpdate(this.el, () => { - if (link.nextSibling) { - this.paginatePrev(link); - } else { - this.paginateNext(link); - } - }); - }); + $.lockScroll( + /** @type {HTMLElement} */ (link.nextSibling || link.previousSibling), + () => { + $.batchUpdate(this.el, () => { + if (link.nextSibling) { + this.paginatePrev(); + } else { + this.paginateNext(); + } + }); + }, + ); } + /** Appends the page after the current one. */ paginateNext() { if (this.el.lastChild) { - this.remove(this.el.lastChild); + this.remove(/** @type {HTMLElement} */ (this.el.lastChild)); } // remove link if (this.page >= 2) { this.hideTopPage(); @@ -79,8 +117,9 @@ app.views.PaginatedList = class PaginatedList extends app.View { } } + /** Prepends the page before the current one. */ paginatePrev() { - this.remove(this.el.firstChild); // remove link + this.remove(/** @type {HTMLElement} */ (this.el.firstChild)); // remove link this.hideBottomPage(); this.page--; this.prepend(this.renderPage(this.page - 1)); // previous page is offset by one @@ -89,6 +128,11 @@ app.views.PaginatedList = class PaginatedList extends app.View { } } + /** + * Renders whichever page holds `object`. + * + * @param {unknown} object A row of `data`. + */ paginateTo(object) { const index = this.data.indexOf(object); if (index >= PaginatedList.PER_PAGE) { @@ -102,26 +146,29 @@ app.views.PaginatedList = class PaginatedList extends app.View { } } + /** Drops the page above the window, replacing it with a link. */ hideTopPage() { const n = this.page <= 2 ? PaginatedList.PER_PAGE : PaginatedList.PER_PAGE + 1; // remove link for (let i = 0, end = n; i < end; i++) { - this.remove(this.el.firstChild); + this.remove(/** @type {HTMLElement} */ (this.el.firstChild)); } this.prepend(this.renderPrevLink(this.page)); } + /** Drops the page below the window, replacing it with a link. */ hideBottomPage() { const n = this.page === this.totalPages() ? this.data.length % PaginatedList.PER_PAGE || PaginatedList.PER_PAGE : PaginatedList.PER_PAGE + 1; // remove link for (let i = 0, end = n; i < end; i++) { - this.remove(this.el.lastChild); + this.remove(/** @type {HTMLElement} */ (this.el.lastChild)); } this.append(this.renderNextLink(this.page - 1)); } + /** @param {ViewMouseEvent} event */ onClick(event) { const target = $.eventTarget(event); if (target.tagName === "SPAN") { @@ -130,4 +177,8 @@ app.views.PaginatedList = class PaginatedList extends app.View { this.paginate(target); } } -}; +} + +// 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 23d4f1193b..f0bbb50ec0 100644 --- a/assets/javascripts/views/misc/news.js +++ b/assets/javascripts/views/misc/news.js @@ -1,10 +1,14 @@ +// @ts-check + //= require views/misc/notif -app.views.News = class News extends app.views.Notif { +/** The notification listing the changelog entries the user hasn't seen. */ +class News extends Notif { static className = "_notif _notif-news"; static defaultOptions = { autoHide: 30000 }; + /** @inheritdoc */ init0() { this.unreadNews = this.getUnreadNews(); if (this.unreadNews.length) { @@ -13,10 +17,12 @@ app.views.News = class News extends app.views.Notif { this.markAllAsRead(); } + /** @inheritdoc */ render() { this.html(app.templates.notifNews(this.unreadNews)); } + /** @returns {Entry[]} Entries published since the user last saw the changelog. */ getUnreadNews() { const time = this.getLastReadTime(); if (!time) { @@ -33,15 +39,22 @@ app.views.News = class News extends app.views.Notif { return result; } + /** @returns {number} When the newest entry was published, in milliseconds. */ getLastNewsTime() { return new Date(app.news[0][0]).getTime(); } + /** @returns {number} When the user last saw the changelog, in milliseconds. */ getLastReadTime() { return app.settings.get("news"); } + /** Records that the user has seen every entry. */ markAllAsRead() { 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 1370733758..0c3cc9e170 100644 --- a/assets/javascripts/views/misc/notice.js +++ b/assets/javascripts/views/misc/notice.js @@ -1,7 +1,18 @@ -app.views.Notice = class Notice extends app.View { +// @ts-check + +/** + * 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 { static className = "_notice"; static attributes = { role: "alert" }; + /** + * @param {string} type Names the template to render. + * @param {...unknown} args Passed on to the template. + */ constructor(type, ...args) { super(); this.type = type; @@ -10,28 +21,37 @@ app.views.Notice = class Notice extends app.View { this.refreshElements(); } + /** Called by the constructor once `args` is set. */ init0() { this.activate(); } + /** Also puts the notice on the page. */ activate() { - if (super.activate(...arguments)) { + if (super.activate()) { this.show(); } } + /** Also takes it off. */ deactivate() { - if (super.deactivate(...arguments)) { + if (super.deactivate()) { this.hide(); } } + /** Renders the notice and puts it above the content. */ show() { this.html(this.tmpl(`${this.type}Notice`, ...this.args)); this.prependTo(app.el); } + /** Takes it off the page. */ hide() { $.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 d2b1858e00..801f30c7a3 100644 --- a/assets/javascripts/views/misc/notif.js +++ b/assets/javascripts/views/misc/notif.js @@ -1,4 +1,19 @@ -app.views.Notif = class Notif extends app.View { +// @ts-check + +/** + * @typedef {object} NotifOptions + * @property {number | null | false} [autoHide] How long to stay up, in + * milliseconds. `null` or `false` keeps it up until dismissed. + */ + +/** + * A transient message in the corner of the window. + * + * The type names the template to render: a Notif of type `Error` renders + * `app.templates.notifError`. Notifications stack, each positioned below the + * one before it. + */ +class Notif extends app.View { static className = "_notif"; static activeClass = "_in"; static attributes = { role: "alert" }; @@ -7,18 +22,25 @@ app.views.Notif = class Notif extends app.View { static events = { click: "onClick" }; + /** + * @param {string} [type] Names the template to render. Omitted by the + * subclasses that render their own body. + * @param {NotifOptions} [options] + */ constructor(type, options) { super(); this.type = type; - this.options = { ...this.constructor.defaultOptions, ...(options || {}) }; + this.options = { ...this.statics().defaultOptions, ...(options || {}) }; this.init0(); // needs this.options this.refreshElements(); } + /** Called by the constructor once `options` is set. Shows the notification. */ init0() { this.show(); } + /** Renders and shows it, or restarts the auto-hide timer if already up. */ show() { if (this.timeout) { clearTimeout(this.timeout); @@ -29,23 +51,26 @@ app.views.Notif = class Notif extends app.View { this.activate(); this.appendTo(document.body); this.el.offsetWidth; // force reflow - this.addClass(this.constructor.activeClass); + this.addClass(this.statics().activeClass); if (this.options.autoHide) { this.timeout = this.delay(this.hide, this.options.autoHide); } } } + /** Takes it back off the page. */ hide() { clearTimeout(this.timeout); this.timeout = null; this.detach(); } + /** Renders the template named by the type. */ render() { this.html(this.tmpl(`notif${this.type}`)); } + /** Stacks it below whichever notification is already up. */ position() { const notifications = $$(`.${Notif.className}`); if (notifications.length) { @@ -55,6 +80,12 @@ app.views.Notif = class Notif extends app.View { } } + /** + * Dismisses on click, unless the click was on a link or on something with + * a behavior of its own. + * + * @param {ViewMouseEvent} event + */ onClick(event) { if (event.which !== 1) { return; @@ -68,4 +99,8 @@ app.views.Notif = class Notif extends app.View { this.hide(); } } -}; +} + +// 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 c9b4cf8903..7061d18009 100644 --- a/assets/javascripts/views/misc/tip.js +++ b/assets/javascripts/views/misc/tip.js @@ -1,11 +1,19 @@ +// @ts-check + //= require views/misc/notif -app.views.Tip = class Tip extends app.views.Notif { +/** A one-off hint, shown once per user and dismissed by clicking it. */ +class Tip extends Notif { static className = "_notif _notif-tip"; static defautOptions = { autoHide: false }; + /** @inheritdoc */ render() { 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 480f8599d4..78e556c73b 100644 --- a/assets/javascripts/views/misc/updates.js +++ b/assets/javascripts/views/misc/updates.js @@ -1,10 +1,17 @@ +// @ts-check + //= require views/misc/notif -app.views.Updates = class Updates extends app.views.Notif { +/** + * The notification listing the docs that gained a new release since the + * user last saw it. + */ +class Updates extends Notif { static className = "_notif _notif-news"; static defautOptions = { autoHide: 30000 }; + /** @inheritdoc */ init0() { this.lastUpdateTime = this.getLastUpdateTime(); this.updatedDocs = this.getUpdatedDocs(); @@ -15,12 +22,14 @@ app.views.Updates = class Updates extends app.views.Notif { this.markAllAsRead(); } + /** @inheritdoc */ render() { this.html( app.templates.notifUpdates(this.updatedDocs, this.updatedDisabledDocs), ); } + /** @returns {Doc[]} Enabled docs built since the last time updates were shown. */ getUpdatedDocs() { if (!this.lastUpdateTime) { return []; @@ -30,6 +39,10 @@ app.views.Updates = class Updates extends app.views.Notif { ); } + /** + * @returns {Doc[]} Disabled docs built since then, but only where another + * version of the same doc is enabled. + */ getUpdatedDisabledDocs() { if (!this.lastUpdateTime) { return []; @@ -46,10 +59,12 @@ app.views.Updates = class Updates extends app.views.Notif { return result; } + /** @returns {number} When updates were last shown, as a Unix timestamp. */ getLastUpdateTime() { return app.settings.get("version"); } + /** Records that the user has seen the current set of releases. */ markAllAsRead() { app.settings.set( "version", @@ -58,4 +73,8 @@ app.views.Updates = class Updates extends app.views.Notif { : 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 1df5971a7d..e099504b28 100644 --- a/assets/javascripts/views/pages/base.js +++ b/assets/javascripts/views/pages/base.js @@ -1,22 +1,40 @@ -app.views.BasePage = class BasePage extends app.View { +// @ts-check + +/** + * The base for the per-doc page views: docs whose pages need something done to + * them once rendered. + * + * 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 { + /** + * @param {HTMLElement} el + * @param {Entry} entry + */ constructor(el, entry) { super(el); this.entry = entry; } + /** Also drops the code blocks left to highlight. */ deactivate() { - if (super.deactivate(...arguments)) { - return (this.highlightNodes = []); + if (super.deactivate()) { + this.highlightNodes = []; } } + /** + * @param {string} content + * @param {boolean} [fromCache] + */ render(content, fromCache) { if (fromCache == null) { fromCache = false; } this.highlightNodes = []; this.previousTiming = null; - if (!this.constructor.className) { + if (!this.statics().className) { this.addClass(`_${this.entry.doc.type}`); } this.html(content); @@ -32,6 +50,7 @@ app.views.BasePage = class BasePage extends app.View { } } + /** Collects the code blocks and starts painting them. */ highlightCode() { for (var el of this.findAll("pre[data-language]")) { var language = el.getAttribute("data-language"); @@ -40,6 +59,11 @@ app.views.BasePage = class BasePage extends app.View { } } + /** + * Highlights as many code blocks as fit in the frame, then yields. + * + * @param {number} [timing] When the current frame started. + */ paintCode(timing) { if (this.previousTiming) { if (Math.round(1000 / (timing - this.previousTiming)) > 50) { @@ -70,4 +94,8 @@ app.views.BasePage = 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 8872bdf0cc..7b45c49151 100644 --- a/assets/javascripts/views/pages/hidden.js +++ b/assets/javascripts/views/pages/hidden.js @@ -1,17 +1,29 @@ -app.views.HiddenPage = class HiddenPage extends app.View { +// @ts-check + +/** + * 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 { static events = { click: "onClick" }; + /** + * @param {HTMLElement} el + * @param {Entry} entry + */ constructor(el, entry) { super(el); this.entry = entry; } + /** @inheritdoc */ init() { this.notice = new app.views.Notice("disabledDoc"); this.addSubview(this.notice); this.activate(); } + /** @param {ViewMouseEvent} event */ onClick(event) { const link = $.closestLink(event.target, this.el); if (link) { @@ -19,4 +31,8 @@ app.views.HiddenPage = class HiddenPage extends app.View { $.popup(link); } } -}; +} + +// 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 3d5c0cb9b4..1e588552d0 100644 --- a/assets/javascripts/views/pages/jquery.js +++ b/assets/javascripts/views/pages/jquery.js @@ -1,8 +1,18 @@ +// @ts-check + //= require views/pages/base -app.views.JqueryPage = class JqueryPage extends app.views.BasePage { +/** + * The jQuery docs' runnable examples, each rendered into its own iframe. + * + * The example's source is rewritten first: its relative URLs are pointed at + * 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 { static demoClassName = "_jquery-demo"; + /** @inheritdoc */ afterRender() { // Prevent jQuery Mobile's demo iframes from scrolling the page for (var iframe of this.findAllByTag("iframe")) { @@ -14,11 +24,13 @@ app.views.JqueryPage = class JqueryPage extends app.views.BasePage { return this.runExamples(); } + /** @param {ViewEvent} event */ onIframeLoaded(event) { event.target.style.display = ""; $.off(event.target, "load", this.onIframeLoaded); } + /** Renders every example on the page. */ runExamples() { for (var el of this.findAllByClass("entry-example")) { try { @@ -27,18 +39,21 @@ app.views.JqueryPage = class JqueryPage extends app.views.BasePage { } } + /** @param {HTMLElement} el The example's container. */ runExample(el) { const source = el.getElementsByClassName("syntaxhighlighter")[0]; if (!source || source.innerHTML.indexOf("!doctype") === -1) { return; } - let iframe = el.getElementsByClassName(JqueryPage.demoClassName)[0]; + let iframe = /** @type {HTMLIFrameElement} */ ( + el.getElementsByClassName(JqueryPage.demoClassName)[0] + ); if (!iframe) { iframe = document.createElement("iframe"); iframe.className = JqueryPage.demoClassName; iframe.width = "100%"; - iframe.height = 200; + iframe.height = "200"; el.appendChild(iframe); } @@ -47,6 +62,10 @@ app.views.JqueryPage = class JqueryPage extends app.views.BasePage { doc.close(); } + /** + * @param {string} source + * @returns {string} The example's HTML, fixed up to run inside the app. + */ fixIframeSource(source) { source = source.replace( '"/resources/', @@ -72,4 +91,8 @@ app.views.JqueryPage = class JqueryPage extends app.views.BasePage { ); return source.replace(/