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

Your browser is unsupported, sorry.

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

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

If you're unable to upgrade, we apologize. diff --git a/assets/javascripts/templates/notice_tmpl.js b/assets/javascripts/templates/notice_tmpl.js index 1661b90db0..f881f3f41a 100644 --- a/assets/javascripts/templates/notice_tmpl.js +++ b/assets/javascripts/templates/notice_tmpl.js @@ -1,5 +1,8 @@ // @ts-check +import { config } from "../app/config.js"; +/** @import { Doc } from "../models/doc.js" */ + /** * The notices shown above the content: a bar of explanatory text. * @@ -9,16 +12,16 @@ const notice = (text) => `

${text}

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

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

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

Changelog

+export const newsPage = () => `

Changelog

For development updates, follow the project on GitHub. -

${app.templates.newsList(app.news)}
`; +
${newsList(news)}
`; -app.templates.newsList = function(news, options = {}) { +export const newsList = function(news, options = {}) { let year = new Date().getUTCFullYear(); let result = ''; @@ -37,4 +37,10 @@ var newsItem = function(date, news) { return result; }; -app.news = <%= App.news.to_json %> +/** + * The changelog, newest first. Each entry is a date followed by one line per + * item. Exported rather than assigned onto `app`: this module is a dependency + * of the app singleton, so it evaluates first and `app` would still be in its + * temporal dead zone. + */ +export const news = <%= App.news.to_json %>; diff --git a/assets/javascripts/templates/pages/offline_tmpl.js b/assets/javascripts/templates/pages/offline_tmpl.js index bc520c7278..cf784aa211 100644 --- a/assets/javascripts/templates/pages/offline_tmpl.js +++ b/assets/javascripts/templates/pages/offline_tmpl.js @@ -1,12 +1,19 @@ // @ts-check +import { app } from "../../app/app.js"; +import { config } from "../../app/config.js"; +import { AppServiceWorker } from "../../app/serviceworker.js"; +import { $ } from "../../lib/util.js"; +/** @import { ImportSummary } from "../../app/offline_backup.js" */ +/** @import { Doc, InstallStatus } from "../../models/doc.js" */ + /** * @param {string} docs The rendered rows, one per doc. * @param {boolean} hasPersistence Whether the browser exposes the storage API. * @param {boolean} isPersistent Whether storage has already been made persistent. * @returns {string} */ -app.templates.offlinePage = (docs, hasPersistence, isPersistent) => `\ +export const offlinePage = (docs, hasPersistence, isPersistent) => `\

Offline Documentation

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

Hi there!

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

Welcome!

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

Welcome!

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

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

Hi there

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

Preferences

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

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

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

    ProTip (click to dismiss) diff --git a/assets/javascripts/tracking.js b/assets/javascripts/tracking.js index a90c53de05..4d28d48918 100644 --- a/assets/javascripts/tracking.js +++ b/assets/javascripts/tracking.js @@ -4,8 +4,12 @@ // has consented. Without consent, whatever they left behind is cleared out. // The snippets below are the vendors' own bootstraps, kept as they ship them. +import { app } from "./app/app.js"; +import { config } from "./app/config.js"; +import { page, resetAnalytics } from "./lib/page.js"; + try { - if (app.config.env === "production") { + if (config.env === "production") { if (Cookies.get("analyticsConsent") === "1") { (function (i, s, o, g, r, a, m) { i["GoogleAnalyticsObject"] = r; diff --git a/assets/javascripts/unsupported.js b/assets/javascripts/unsupported.js new file mode 100644 index 0000000000..b5e94d4e21 --- /dev/null +++ b/assets/javascripts/unsupported.js @@ -0,0 +1,37 @@ +// @ts-check + +// A classic script, deliberately not a module: it has to run in browsers that +// cannot load the module graph at all. +// +// The app is served as ES modules whose relative imports are rewritten onto +// their digested URLs by an import map. A browser that supports modules but +// not import maps resolves those imports against the undigested paths, gets a +// 404 for every one, and sits on the loading screen forever — and because the +// in-app compatibility check lives inside the graph, it never runs to say why. +// +// Import maps are the newest thing the app needs, so testing for them stands +// in for the whole baseline. +(function () { + if ( + window.HTMLScriptElement && + typeof HTMLScriptElement.supports === "function" && + HTMLScriptElement.supports("importmap") + ) { + return; + } + + document.body.innerHTML = `\ +

    +

    Your browser is unsupported, sorry.

    +

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

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

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

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