From 4973ba018e52a6fae369157e84ad248749729ea9 Mon Sep 17 00:00:00 2001 From: Simon Legner Date: Mon, 14 Sep 2026 09:49:31 +0200 Subject: [PATCH 01/34] Add a TypeScript 7 typecheck for the JavaScript assets The assets are plain JavaScript, so checkJs is off by default and each file opts in with a // @ts-check comment as it gains JSDoc types. --- .gitignore | 1 + package-lock.json | 402 +++++++++++++++++++++++++++++++++++++++++++++- package.json | 12 ++ tsconfig.json | 14 ++ 4 files changed, 428 insertions(+), 1 deletion(-) create mode 100644 package.json create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore index aac9f85ba5..0a7a47d759 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,4 @@ docs/**/* *.zip assets/stylesheets/components/_environment.scss assets/stylesheets/global/_icons.scss +node_modules diff --git a/package-lock.json b/package-lock.json index c77b993ac8..29a54b4d2e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2,5 +2,405 @@ "name": "devdocs", "lockfileVersion": 3, "requires": true, - "packages": {} + "packages": { + "": { + "name": "devdocs", + "devDependencies": { + "@types/node": "^24.9.2", + "typescript": "^7.0.2" + } + }, + "node_modules/@types/node": { + "version": "24.13.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.4.tgz", + "integrity": "sha512-YJ7EqCstVTzIr0fMr7qul/977en+pQHrfmuKIo6Zr9i75Be21dr3MovcfvGtyvi2HAUrRerWps5sMO9I7WaxDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + } + } } diff --git a/package.json b/package.json new file mode 100644 index 0000000000..a9b107bf07 --- /dev/null +++ b/package.json @@ -0,0 +1,12 @@ +{ + "name": "devdocs", + "private": true, + "scripts": { + "test": "node --test test/assets/", + "typecheck": "tsc" + }, + "devDependencies": { + "@types/node": "^24.9.2", + "typescript": "^7.0.2" + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000000..9f5673e0d5 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "allowJs": true, + "checkJs": false, + "noEmit": true, + "target": "ES2022", + "module": "preserve", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": ["node"] + }, + "include": ["assets/javascripts/**/*.js", "test/assets/**/*.js"], + "exclude": ["assets/javascripts/vendor"] +} From 9777204f5da29f1145ebccf7e4d1933a48c9a979 Mon Sep 17 00:00:00 2001 From: Simon Legner Date: Mon, 14 Sep 2026 09:51:38 +0200 Subject: [PATCH 02/34] Type the event emitter Split the asset tests into their own project so that the browser code isn't checked against Node's globals, and pin strict off now that TypeScript 7 turns it on by default. --- assets/javascripts/lib/events.js | 46 ++++++++++++++++++++++++++++++++ package.json | 2 +- test/assets/tsconfig.json | 12 +++++++++ tsconfig.json | 9 ++++--- 4 files changed, 65 insertions(+), 4 deletions(-) create mode 100644 test/assets/tsconfig.json diff --git a/assets/javascripts/lib/events.js b/assets/javascripts/lib/events.js index d735a3d55d..78d7e920a0 100644 --- a/assets/javascripts/lib/events.js +++ b/assets/javascripts/lib/events.js @@ -1,4 +1,34 @@ +// @ts-check + +/** + * A minimal event emitter. Most of the app's long-lived objects extend it. + * + * Event names are free-form strings; `on`, `off` and `removeEvent` also accept + * several of them separated by spaces. Every event is re-emitted as `all` with + * the original name prepended to the arguments. + * + * @typedef {(...args: any[]) => void} EventCallback + */ class Events { + /** + * Registered callbacks, keyed by event name. Created on first `on` call. + * + * @type {Record | undefined} + */ + _callbacks; + + /** + * The event being dispatched, while `trigger` is running. + * + * @type {{ name: string, args: any[] } | null} + */ + eventInProgress; + + /** + * @param {string} event One or more event names, separated by spaces. + * @param {EventCallback} callback + * @returns {this} + */ on(event, callback) { if (event.includes(" ")) { for (var name of event.split(" ")) { @@ -12,6 +42,11 @@ class Events { return this; } + /** + * @param {string} event One or more event names, separated by spaces. + * @param {EventCallback} callback The same reference that was passed to `on`. + * @returns {this} + */ off(event, callback) { let callbacks, index; if (event.includes(" ")) { @@ -30,6 +65,11 @@ class Events { return this; } + /** + * @param {string} event A single event name. + * @param {...any} args Passed on to each callback. + * @returns {this} + */ trigger(event, ...args) { this.eventInProgress = { name: event, args }; const callbacks = this._callbacks?.[event]; @@ -47,6 +87,12 @@ class Events { return this; } + /** + * Removes every callback registered for the given events. + * + * @param {string} event One or more event names, separated by spaces. + * @returns {this} + */ removeEvent(event) { if (this._callbacks != null) { for (var name of event.split(" ")) { diff --git a/package.json b/package.json index a9b107bf07..c70675fe83 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "private": true, "scripts": { "test": "node --test test/assets/", - "typecheck": "tsc" + "typecheck": "tsc -p tsconfig.json && tsc -p test/assets/tsconfig.json" }, "devDependencies": { "@types/node": "^24.9.2", diff --git a/test/assets/tsconfig.json b/test/assets/tsconfig.json new file mode 100644 index 0000000000..a3d68b1cd3 --- /dev/null +++ b/test/assets/tsconfig.json @@ -0,0 +1,12 @@ +{ + // The asset tests run under Node, so they need its globals rather than the + // browser ones the assets themselves are checked against. + "extends": "../../tsconfig.json", + "compilerOptions": { + "module": "node16", + "moduleResolution": "node16", + "lib": ["ES2022"], + "types": ["node"] + }, + "include": ["*.js"] +} diff --git a/tsconfig.json b/tsconfig.json index 9f5673e0d5..0a84fcf9f9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,14 +1,17 @@ { "compilerOptions": { "allowJs": true, + // TypeScript 7 enables strict by default; the assets adopt types + // incrementally, so keep the looser checks for now. + "strict": false, + // Each file opts in with a `// @ts-check` comment. "checkJs": false, "noEmit": true, "target": "ES2022", "module": "preserve", "moduleResolution": "bundler", - "lib": ["ES2022", "DOM", "DOM.Iterable"], - "types": ["node"] + "lib": ["ES2022", "DOM", "DOM.Iterable"] }, - "include": ["assets/javascripts/**/*.js", "test/assets/**/*.js"], + "include": ["assets/javascripts/**/*.js", "assets/javascripts/**/*.d.ts"], "exclude": ["assets/javascripts/vendor"] } From 4eed3e4ae343281d423b7071cca9a76974bcc6da Mon Sep 17 00:00:00 2001 From: Simon Legner Date: Mon, 14 Sep 2026 09:52:31 +0200 Subject: [PATCH 03/34] Type the storage wrappers and the Ajax helper Add ambient declarations for the vendored libraries, which stay outside the typecheck. --- assets/javascripts/globals.d.ts | 53 ++++++++++++ assets/javascripts/lib/ajax.js | 81 ++++++++++++++++++- assets/javascripts/lib/cookies_store.js | 54 +++++++++++-- assets/javascripts/lib/license.js | 2 + assets/javascripts/lib/local_storage_store.js | 25 ++++++ 5 files changed, 204 insertions(+), 11 deletions(-) create mode 100644 assets/javascripts/globals.d.ts diff --git a/assets/javascripts/globals.d.ts b/assets/javascripts/globals.d.ts new file mode 100644 index 0000000000..c989ae4594 --- /dev/null +++ b/assets/javascripts/globals.d.ts @@ -0,0 +1,53 @@ +/** + * Ambient declarations for the assets. + * + * Sprockets concatenates every file in `application.js` into a single script, + * so the assets share one global scope. TypeScript picks up top-level `class`, + * `function`, `const`, `let` and `var` declarations across files on its own; + * this file covers the two things it cannot see: + * + * - globals created by assigning to `this` at the top level of a file, and + * - the vendored libraries, which are excluded from the typecheck. + * + * The types themselves live in JSDoc next to the code that implements them. + */ + +// --- Vendored libraries (assets/javascripts/vendor) --- + +/** Cookies.js — github.com/ScottHamper/Cookies */ +declare const Cookies: { + (key: string): string | undefined; + (key: string, value: string, options?: CookieOptions): typeof Cookies; + get(key: string): string | undefined; + set(key: string, value: string, options?: CookieOptions): typeof Cookies; + expire(key: string, options?: CookieOptions): typeof Cookies; + defaults: CookieOptions; + enabled: boolean; +}; + +interface CookieOptions { + path?: string; + domain?: string; + expires?: number | string | Date; + secure?: boolean; +} + +/** Raven.js — the Sentry browser client. Only the parts the app uses. */ +declare const Raven: { + config(dsn: string, options?: Record): typeof Raven; + install(): typeof Raven; + captureException(error: unknown, options?: Record): void; + captureMessage(message: string, options?: Record): void; +}; + +/** Prism.js — only the parts the app uses. */ +declare const Prism: { + highlightElement(element: Element, async?: boolean): void; +}; + +// --- Augmentations --- + +interface XMLHttpRequest { + /** Set by lib/ajax.js so that the timeout can be cleared when it settles. */ + timer?: number; +} diff --git a/assets/javascripts/lib/ajax.js b/assets/javascripts/lib/ajax.js index dca1dc7483..01b98eeeb0 100644 --- a/assets/javascripts/lib/ajax.js +++ b/assets/javascripts/lib/ajax.js @@ -1,8 +1,36 @@ +// @ts-check + +/** + * @typedef {"error" | "invalid" | "timeout"} AjaxErrorType + * + * @typedef {object} AjaxOptions + * @property {string} [url] + * @property {string} [type] HTTP method. Defaults to `"GET"`. + * @property {boolean} [async] Defaults to `true`. When false, `ajax` returns the parsed response. + * @property {string} [dataType] `"json"` (the default), `"html"`, or a MIME type. + * @property {number} [timeout] Seconds before the request is aborted. Defaults to 30. + * @property {string} [contentType] + * @property {any} [context] `this` for the `success` and `error` callbacks. + * @property {Record | string | null} [data] Serialized into the query string for GET, into the body otherwise. + * @property {Record} [headers] + * @property {(event: ProgressEvent) => void} [progress] + * @property {(response: any, xhr: XMLHttpRequest, options: AjaxOptions) => void} [success] + * @property {(type: AjaxErrorType, xhr: XMLHttpRequest, options: AjaxOptions) => void} [error] + */ + +/** @type {Record} */ const MIME_TYPES = { json: "application/json", html: "text/html", }; +/** + * A small XMLHttpRequest wrapper. + * + * @param {AjaxOptions} options Merged over `ajax.defaults`. Mutated in place. + * @returns {{ abort: () => void } | any} A handle to abort the request when + * `async`, otherwise the parsed response. + */ function ajax(options) { applyDefaults(options); serializeData(options); @@ -13,7 +41,8 @@ function ajax(options) { applyCallbacks(xhr, options); applyHeaders(xhr, options); - xhr.send(options.data); + // serializeData has already reduced `data` to a string or null. + xhr.send(/** @type {string | null} */ (options.data)); if (options.async) { return { abort: abort.bind(undefined, xhr) }; @@ -21,6 +50,7 @@ function ajax(options) { return parseResponse(xhr, options); } + /** @param {AjaxOptions} options */ function applyDefaults(options) { for (var key in ajax.defaults) { if (options[key] == null) { @@ -29,19 +59,27 @@ function ajax(options) { } } + /** @param {AjaxOptions} options */ function serializeData(options) { if (!options.data) { return; } if (options.type === "GET") { - options.url += "?" + serializeParams(options.data); + options.url += + "?" + serializeParams(/** @type {Record} */ (options.data)); options.data = null; } else { - options.data = serializeParams(options.data); + options.data = serializeParams( + /** @type {Record} */ (options.data), + ); } } + /** + * @param {Record} params + * @returns {string} + */ function serializeParams(params) { return Object.entries(params) .map( @@ -51,6 +89,10 @@ function ajax(options) { .join("&"); } + /** + * @param {XMLHttpRequest} xhr + * @param {AjaxOptions} options + */ function applyCallbacks(xhr, options) { if (!options.async) { return; @@ -71,6 +113,10 @@ function ajax(options) { }; } + /** + * @param {XMLHttpRequest} xhr + * @param {AjaxOptions} options + */ function applyHeaders(xhr, options) { if (!options.headers) { options.headers = {}; @@ -99,6 +145,10 @@ function ajax(options) { } } + /** + * @param {XMLHttpRequest} xhr + * @param {AjaxOptions} options + */ function onComplete(xhr, options) { if (200 <= xhr.status && xhr.status < 300) { const response = parseResponse(xhr, options); @@ -112,29 +162,49 @@ function ajax(options) { } } + /** + * @param {any} response + * @param {XMLHttpRequest} xhr + * @param {AjaxOptions} options + */ function onSuccess(response, xhr, options) { if (options.success != null) { options.success.call(options.context, response, xhr, options); } } + /** + * @param {AjaxErrorType} type + * @param {XMLHttpRequest} xhr + * @param {AjaxOptions} options + */ function onError(type, xhr, options) { if (options.error != null) { options.error.call(options.context, type, xhr, options); } } + /** + * @param {XMLHttpRequest} xhr + * @param {AjaxOptions} options + */ function onTimeout(xhr, options) { xhr.abort(); onError("timeout", xhr, options); } + /** @param {XMLHttpRequest} xhr */ function abort(xhr) { clearTimeout(xhr.timer); xhr.onreadystatechange = null; xhr.abort(); } + /** + * @param {XMLHttpRequest} xhr + * @param {AjaxOptions} options + * @returns {any} `undefined` when a JSON response fails to parse. + */ function parseResponse(xhr, options) { if (options.dataType === "json") { return parseJSON(xhr.responseText); @@ -143,6 +213,10 @@ function ajax(options) { } } + /** + * @param {string} json + * @returns {any} `undefined` when parsing fails. + */ function parseJSON(json) { try { return JSON.parse(json); @@ -150,6 +224,7 @@ function ajax(options) { } } +/** @type {AjaxOptions} */ ajax.defaults = { async: true, dataType: "json", diff --git a/assets/javascripts/lib/cookies_store.js b/assets/javascripts/lib/cookies_store.js index 7878855c0c..a9e3d00908 100644 --- a/assets/javascripts/lib/cookies_store.js +++ b/assets/javascripts/lib/cookies_store.js @@ -1,12 +1,39 @@ -// Intentionally called CookiesStore instead of CookieStore -// Calling it CookieStore causes issues when the Experimental Web Platform features flag is enabled in Chrome -// Related issue: https://github.com/freeCodeCamp/devdocs/issues/932 +// @ts-check + +/** + * A cookie-backed key/value store. + * + * Values round-trip as strings, so integers are parsed back out on read and + * booleans are stored as `1` / absent. When a write doesn't stick — the usual + * cause is the browser blocking cookies — `onBlocked` is called so the app can + * warn the user. + * + * Intentionally called CookiesStore instead of CookieStore. Calling it + * CookieStore causes issues when the Experimental Web Platform features flag is + * enabled in Chrome. + * Related issue: https://github.com/freeCodeCamp/devdocs/issues/932 + * + * @typedef {string | number | undefined} CookieValue + */ class CookiesStore { static INT = /^\d+$/; - static onBlocked() {} + /** + * Hook called when a value read back after a write doesn't match what was + * written. Replaced by the app at boot; a no-op by default. + * + * @param {string} key + * @param {CookieValue | boolean} value The value that was written. + * @param {CookieValue} actual The value that was read back. + */ + static onBlocked(key, value, actual) {} + /** + * @param {string} key + * @returns {CookieValue} The stored value, as a number when it is all digits. + */ get(key) { + /** @type {CookieValue} */ let value = Cookies.get(key); if (value != null && CookiesStore.INT.test(value)) { value = parseInt(value, 10); @@ -14,6 +41,12 @@ class CookiesStore { return value; } + /** + * Writing `false` deletes the key; `true` is stored as `1`. + * + * @param {string} key + * @param {CookieValue | boolean} value + */ set(key, value) { if (value === false) { this.del(key); @@ -26,10 +59,10 @@ class CookiesStore { if ( value && (typeof CookiesStore.INT.test === "function" - ? CookiesStore.INT.test(value) + ? CookiesStore.INT.test(/** @type {string} */ (value)) : undefined) ) { - value = parseInt(value, 10); + value = parseInt(/** @type {string} */ (value), 10); } Cookies.set(key, "" + value, { path: "/", expires: 1e8 }); if (this.get(key) !== value) { @@ -37,10 +70,12 @@ class CookiesStore { } } + /** @param {string} key */ del(key) { Cookies.expire(key); } + /** Expires every cookie on the document. */ reset() { try { for (var cookie of document.cookie.split(/;\s?/)) { @@ -50,12 +85,15 @@ class CookiesStore { } catch (error) {} } + /** + * @returns {Record} Every non-internal cookie, unparsed. + */ dump() { const result = {}; for (var cookie of document.cookie.split(/;\s?/)) { if (cookie[0] !== "_") { - cookie = cookie.split("="); - result[cookie[0]] = cookie[1]; + const [name, value] = cookie.split("="); + result[name] = value; } } return result; diff --git a/assets/javascripts/lib/license.js b/assets/javascripts/lib/license.js index e4c3c0103a..100982bc74 100644 --- a/assets/javascripts/lib/license.js +++ b/assets/javascripts/lib/license.js @@ -1,3 +1,5 @@ +// @ts-check + /* * Copyright 2013-2026 Thibaut Courouble and other contributors * diff --git a/assets/javascripts/lib/local_storage_store.js b/assets/javascripts/lib/local_storage_store.js index 25a4ee90f7..3f77c02efe 100644 --- a/assets/javascripts/lib/local_storage_store.js +++ b/assets/javascripts/lib/local_storage_store.js @@ -1,10 +1,28 @@ +// @ts-check + +/** + * A JSON-encoded wrapper around `localStorage`. + * + * Every method swallows the exceptions the browser throws when storage is + * unavailable (private browsing, blocked cookies, quota exhausted) and reports + * failure by returning `undefined`. + */ this.LocalStorageStore = class LocalStorageStore { + /** + * @param {string} key + * @returns {any} The stored value, or `undefined` if it is missing or unreadable. + */ get(key) { try { return JSON.parse(localStorage.getItem(key)); } catch (error) {} } + /** + * @param {string} key + * @param {any} value + * @returns {boolean | undefined} `true` when stored, `undefined` when it failed. + */ set(key, value) { try { localStorage.setItem(key, JSON.stringify(value)); @@ -12,6 +30,10 @@ this.LocalStorageStore = class LocalStorageStore { } catch (error) {} } + /** + * @param {string} key + * @returns {boolean | undefined} `true` when removed, `undefined` when it failed. + */ del(key) { try { localStorage.removeItem(key); @@ -19,6 +41,9 @@ this.LocalStorageStore = class LocalStorageStore { } catch (error) {} } + /** + * @returns {boolean | undefined} `true` when cleared, `undefined` when it failed. + */ reset() { try { localStorage.clear(); From 9114c87691c21da5689458fe61aed146c8e6ab65 Mon Sep 17 00:00:00 2001 From: Simon Legner Date: Mon, 14 Sep 2026 09:54:00 +0200 Subject: [PATCH 04/34] Type the DOM helpers Declare the $ surface once, as JSDoc typedefs that the implementations are contextually typed by, rather than annotating each helper twice. Typing $.prepend turned up a crash: prepending into an element with no children called $.append(value) without the element to append to, so it read the value as the target and appended undefined. --- assets/javascripts/globals.d.ts | 8 ++ assets/javascripts/lib/util.js | 158 +++++++++++++++++++++++++++----- 2 files changed, 141 insertions(+), 25 deletions(-) diff --git a/assets/javascripts/globals.d.ts b/assets/javascripts/globals.d.ts index c989ae4594..b5e68fa832 100644 --- a/assets/javascripts/globals.d.ts +++ b/assets/javascripts/globals.d.ts @@ -12,6 +12,14 @@ * The types themselves live in JSDoc next to the code that implements them. */ +// --- Globals defined by assigning to `this` at the top level --- + +/** lib/util.js — queries one element, and carries the DOM helpers. */ +declare var $: DollarQuery & DollarHelpers; + +/** lib/util.js — queries every matching element. */ +declare var $$: DollarQueryAll; + // --- Vendored libraries (assets/javascripts/vendor) --- /** Cookies.js — github.com/ScottHamper/Cookies */ diff --git a/assets/javascripts/lib/util.js b/assets/javascripts/lib/util.js index 1e4ff320c0..6ca6b382db 100644 --- a/assets/javascripts/lib/util.js +++ b/assets/javascripts/lib/util.js @@ -1,17 +1,117 @@ +// @ts-check + +/** + * `$` is the app's DOM helper: calling it queries a single element, and it + * carries the traversal, event, manipulation and scrolling helpers used + * throughout the app as properties. + * + * The signatures below are the source of truth for the global, which is + * declared in globals.d.ts. The implementations are contextually typed by + * them, so they don't repeat the annotations. + * + * @callback DollarQuery + * @param {string} selector + * @param {ParentNode} [el] The root to search under. Defaults to `document`. + * @returns {any} The first match, or `undefined` if the selector is invalid. + * Typed loosely because callers immediately reach for element-specific + * properties. + */ + +/** + * @callback DollarQueryAll + * @param {string} selector + * @param {ParentNode} [el] The root to search under. Defaults to `document`. + * @returns {NodeListOf} All matches, or `undefined` if the selector is invalid. + */ + +/** + * Anything `$.append` and friends accept as content. + * + * @typedef {string | Node | ArrayLike} DollarContent + */ + +/** + * @typedef {object} DollarScrollOptions + * @property {number} [margin] Extra space above the target, for `"top"`. + * @property {number} [topGap] Gap above the target as a multiple of its height, for `"continuous"`. + * @property {number} [bottomGap] Gap below the target as a multiple of its height, for `"continuous"`. + */ + +/** + * The helpers hanging off `$`. + * + * @typedef {object} DollarHelpers + * + * @property {(id: string) => any} id Looks an element up by id. + * @property {(parent: Node, el: any) => boolean | undefined} hasChild Whether `el` is `parent` or a descendant of it. + * @property {(el: any, parent?: Node) => any} closestLink The nearest `` ancestor, stopping at `parent`. + * + * @property {(el: EventTarget, event: string, callback: (event: any) => void, useCapture?: boolean) => void} on Accepts several space-separated event names. + * @property {(el: EventTarget, event: string, callback: (event: any) => void, useCapture?: boolean) => void} off Accepts several space-separated event names. + * @property {(el: EventTarget, type: string, canBubble?: boolean, cancelable?: boolean) => void} trigger Dispatches a synthetic event. + * @property {(el: EventTarget) => void} click Dispatches a synthetic click. + * @property {(event: Event) => void} stopEvent Prevents the default and stops propagation, immediately. + * @property {(event: Event) => any} eventTarget The event target, resolving an SVG `` to the element that referenced it. + * + * @property {(el: Element, value: DollarContent) => void} append + * @property {(el: Element, value: DollarContent) => void} prepend + * @property {(el: Element, value: DollarContent) => void} before + * @property {(el: Element, value: DollarContent) => void} after + * @property {(value: any) => void} remove Detaches the node, or every node in the collection. + * @property {(el: Node) => void} empty Removes every child. + * @property {(el: Element, fn: (el: any) => void) => void} batchUpdate Runs `fn` with the element off the DOM, to avoid reflows. + * + * @property {(el: Element) => DOMRect} rect + * @property {(el: any, container?: Element) => { top: number, left: number }} offset Offset relative to `container`, which defaults to the body. + * @property {(el: any) => any} scrollParent The nearest scrollable ancestor. + * @property {(el: any, parent?: any, position?: "top" | "center" | "continuous", options?: DollarScrollOptions) => void} scrollTo + * @property {(el: any, parent?: any, ...args: any[]) => void} scrollToWithImageLock Like `scrollTo`, but holds the position while nearby images load. + * @property {(el: any, fn: () => void) => void} lockScroll Runs `fn` while holding the element's position relative to the window. + * @property {(el: any) => void} openDetailsAncestors Expands every `
` the element is inside. + * @property {(el: Element, end: number) => void} smoothScroll Animates `scrollTop` towards `end`. + * + * @property {(object: any) => any[]} makeArray + * @property {(array: any[], object: any) => boolean} arrayDelete Removes the first occurrence; reports whether it was there. + * @property {(object: any) => boolean} isCollection Whether the value is an array or a live DOM collection. + * @property {(string: string) => string} escape Escapes HTML-significant characters. + * @property {(string: string) => string} escapeRegexp + * @property {(string: string) => string} urlDecode Decodes a form-encoded component, where `+` means a space. + * @property {(string: string) => string} urlDecodeFragment Decodes a hash fragment, where `+` is literal. + * @property {(string: string) => string} classify Turns `snake_case` into `CamelCase`. + * + * @property {() => void} noop + * @property {(blob: Blob, filename: string) => void} download Saves the blob to the user's downloads. + * @property {(value: string | { href: string }) => void} popup Opens a URL in a new tab, without leaking the opener. + * @property {() => boolean} isMac + * @property {() => boolean} isIE + * @property {() => boolean} isChromeForAndroid + * @property {() => boolean} isAndroid + * @property {() => boolean} isIOS + * @property {() => boolean} overlayScrollbarsEnabled Whether the OS draws scrollbars as an overlay. + * @property {(el: Element, options?: { className?: string, delay?: number }) => void} highlight Adds a class, then removes it after a delay. + */ + // // Traversing // let smoothDistance, smoothDuration, smoothEnd, smoothStart; -this.$ = function (selector, el) { - if (el == null) { - el = document; - } - try { - return el.querySelector(selector); - } catch (error) {} -}; +// The helpers are attached to `$` below, so the function on its own doesn't +// yet satisfy the type the global is declared with. +this.$ = /** @type {DollarQuery & DollarHelpers} */ ( + /** @type {DollarQuery} */ ( + function (selector, el) { + if (el == null) { + el = document; + } + try { + return el.querySelector(selector); + } catch (error) {} + } + ) +); +/** @type {DollarQueryAll} */ this.$$ = function (selector, el) { if (el == null) { el = document; @@ -105,12 +205,19 @@ $.stopEvent = function (event) { event.stopImmediatePropagation(); }; -$.eventTarget = (event) => event.target.correspondingUseElement || event.target; +$.eventTarget = function (event) { + const target = /** @type {any} */ (event.target); + return target.correspondingUseElement || target; +}; // // Manipulation // +/** + * @param {DollarContent} value + * @returns {DocumentFragment} + */ const buildFragment = function (value) { const fragment = document.createDocumentFragment(); @@ -119,7 +226,7 @@ const buildFragment = function (value) { fragment.appendChild(child); } } else { - fragment.innerHTML = value; + /** @type {any} */ (fragment).innerHTML = value; } return fragment; @@ -132,20 +239,20 @@ $.append = function (el, value) { if ($.isCollection(value)) { value = buildFragment(value); } - el.appendChild(value); + el.appendChild(/** @type {Node} */ (value)); } }; $.prepend = function (el, value) { if (!el.firstChild) { - $.append(value); + $.append(el, value); } else if (typeof value === "string") { el.insertAdjacentHTML("afterbegin", value); } else { if ($.isCollection(value)) { value = buildFragment(value); } - el.insertBefore(value, el.firstChild); + el.insertBefore(/** @type {Node} */ (value), el.firstChild); } }; @@ -154,7 +261,7 @@ $.before = function (el, value) { value = buildFragment(value); } - el.parentNode.insertBefore(value, el); + el.parentNode.insertBefore(/** @type {Node} */ (value), el); }; $.after = function (el, value) { @@ -163,9 +270,9 @@ $.after = function (el, value) { } if (el.nextSibling) { - el.parentNode.insertBefore(value, el.nextSibling); + el.parentNode.insertBefore(/** @type {Node} */ (value), el.nextSibling); } else { - el.parentNode.appendChild(value); + el.parentNode.appendChild(/** @type {Node} */ (value)); } }; @@ -177,8 +284,9 @@ $.remove = function (value) { } } } else { - if (value.parentNode != null) { - value.parentNode.removeChild(value); + const node = /** @type {Node} */ (value); + if (node.parentNode != null) { + node.parentNode.removeChild(node); } } }; @@ -454,12 +562,12 @@ $.urlDecode = (string) => decodeURIComponent(string.replace(/\+/g, "%20")); $.urlDecodeFragment = (string) => decodeURIComponent(string); $.classify = function (string) { - string = string.split("_"); - for (let i = 0; i < string.length; i++) { - var substr = string[i]; - string[i] = substr[0].toUpperCase() + substr.slice(1); + const parts = string.split("_"); + for (let i = 0; i < parts.length; i++) { + var substr = parts[i]; + parts[i] = substr[0].toUpperCase() + substr.slice(1); } - return string.join(""); + return parts.join(""); }; // @@ -483,13 +591,13 @@ $.download = function (blob, filename) { $.popup = function (value) { try { - window.open(value.href || value, "_blank", "noopener"); + window.open(/** @type {any} */ (value).href || value, "_blank", "noopener"); } catch (error) { const win = window.open(); if (win.opener) { win.opener = null; } - win.location = value.href || value; + win.location = /** @type {any} */ (value).href || value; } }; From 6b01fcfb0615110ee847ebd4ab69f07b7686945d Mon Sep 17 00:00:00 2001 From: Simon Legner Date: Mon, 14 Sep 2026 10:16:20 +0200 Subject: [PATCH 05/34] Type the router, the favicon helper and the app singleton App's registries are keyed by name and populated by the files that define their members, so they are typed as records rather than enumerating the 37 view classes. Two call sites in onWindowError spread the window.onerror arguments into helpers that read at most the first two; pass those directly instead. --- assets/javascripts/app/app.js | 105 +++++++++- assets/javascripts/globals.d.ts | 28 +++ assets/javascripts/lib/favicon.js | 27 +++ assets/javascripts/lib/local_storage_store.js | 11 ++ assets/javascripts/lib/page.js | 185 ++++++++++++++++-- 5 files changed, 335 insertions(+), 21 deletions(-) diff --git a/assets/javascripts/app/app.js b/assets/javascripts/app/app.js index f05b963dd5..f7f381efd0 100644 --- a/assets/javascripts/app/app.js +++ b/assets/javascripts/app/app.js @@ -1,12 +1,107 @@ +// @ts-check + +/** + * The build-time configuration, rendered into the page by app/config.js.erb. + * + * @typedef {object} AppConfig + * @property {string} db_filename + * @property {string[]} default_docs Slugs enabled for a first-time visitor. + * @property {Record} docs_aliases Alternative spellings, by the name they resolve to. + * @property {string} docs_origin Where the documentation files are served from. + * @property {string} env + * @property {number} history_cache_size + * @property {string} index_filename + * @property {number} max_results + * @property {string} production_host + * @property {string} search_param The query parameter a search is read from. + * @property {string} sentry_dsn + * @property {number} version Cache-busting stamp for the offline data. + * @property {string} release + * @property {string} mathml_stylesheet + * @property {string} favicon_spritesheet + * @property {string} service_worker_path + * @property {boolean} service_worker_enabled + */ + +/** + * A doc as it appears in the manifest, before it becomes an `app.models.Doc`. + * + * @typedef {Record} DocData + */ + +/** + * The application singleton, and the namespace everything else registers into. + * + * The registries below are keyed by name and populated by the files that + * define their members, so they are typed as plain records rather than + * enumerating 37 view classes. + */ class App extends Events { + // Kept so that isInjectionError can tell whether an extension replaced the + // globals out from under us. _$ = $; _$$ = $$; _page = page; + + /** @type {Record} */ collections = {}; + /** @type {Record} */ models = {}; + /** @type {Record} */ templates = {}; + /** @type {Record} */ views = {}; + /** Set by app/config.js.erb. @type {AppConfig} */ + config; + + /** + * The manifest of every available doc, set by docs.js.erb. Deleted once the + * docs have been read into the collections. + * + * @type {DocData[] | undefined} + */ + DOCS; + + /** + * In single-doc mode, the one doc being shown, read off the body. Deleted + * once it has been read. + * + * @type {DocData | undefined} + */ + DOC; + + // The classes registered by the rest of app/, collections/, models/ and + // views/. They're constructors rather than instances. + /** @type {any} */ DB; + /** @type {any} */ OfflineBackup; + /** @type {any} */ Router; + /** @type {any} */ Searcher; + /** @type {any} */ SynchronousSearcher; + /** @type {any} */ ServiceWorker; + /** @type {any} */ Settings; + /** @type {any} */ Shortcuts; + /** @type {any} */ UpdateChecker; + /** @type {any} */ Collection; + /** @type {any} */ Model; + /** @type {any} */ View; + + /** + * The `window.onerror` handler that was installed before ours, if any. + * + * @type {any} + */ + previousErrorHandler; + + /** + * The most recent IndexedDB transaction, tracked by app/db.js so that a + * hung transaction can be detected. + * + * @type {number | undefined} + */ + lastIDBTransaction; + + /** Wires up the app and boots it. Called once the document is ready. */ init() { try { this.initErrorTracking(); @@ -316,9 +411,9 @@ class App extends Events { reboot() { if (location.pathname !== "/" && location.pathname !== "/settings") { - window.location = `/#${location.pathname}`; + window.location = /** @type {any} */ (`/#${location.pathname}`); } else { - window.location = "/"; + window.location = /** @type {any} */ ("/"); } } @@ -341,7 +436,7 @@ class App extends Events { if (this.serviceWorker != null) { this.serviceWorker.update(); } - window.location = "/"; + window.location = /** @type {any} */ ("/"); } showTip(tip) { @@ -392,9 +487,9 @@ class App extends Events { if (this.cookieBlocked) { return; } - if (this.isInjectionError(...args)) { + if (this.isInjectionError()) { this.onInjectionError(); - } else if (this.isAppError(...args)) { + } else if (this.isAppError(args[0], args[1])) { if (typeof this.previousErrorHandler === "function") { this.previousErrorHandler(...args); } diff --git a/assets/javascripts/globals.d.ts b/assets/javascripts/globals.d.ts index b5e68fa832..5707b7d046 100644 --- a/assets/javascripts/globals.d.ts +++ b/assets/javascripts/globals.d.ts @@ -20,6 +20,24 @@ declare var $: DollarQuery & DollarHelpers; /** lib/util.js — queries every matching element. */ declare var $$: DollarQueryAll; +/** lib/local_storage_store.js — a JSON-encoded wrapper around localStorage. */ +declare var LocalStorageStore: new () => LocalStorageStore; + +/** lib/page.js — the router. */ +declare var page: PageFn & PageHelpers; + +/** lib/page.js — expires the analytics cookies. */ +declare var resetAnalytics: () => void; + +/** app/app.js — the application singleton. */ +declare var app: App; + +/** lib/favicon.js — swaps the favicon for the doc's icon. */ +declare var setFaviconForDoc: (doc: any) => void; + +/** lib/favicon.js — restores the default favicon. */ +declare var resetFavicon: () => void; + // --- Vendored libraries (assets/javascripts/vendor) --- /** Cookies.js — github.com/ScottHamper/Cookies */ @@ -55,6 +73,16 @@ declare const Prism: { // --- Augmentations --- +interface Window { + /** Present when running inside Electron. */ + readonly process?: { versions?: Record }; +} + +interface Navigator { + /** Global Privacy Control. Not in lib.dom yet. */ + readonly globalPrivacyControl?: boolean; +} + interface XMLHttpRequest { /** Set by lib/ajax.js so that the timeout can be cleared when it settles. */ timer?: number; diff --git a/assets/javascripts/lib/favicon.js b/assets/javascripts/lib/favicon.js index 6b58016c64..d3676b7840 100644 --- a/assets/javascripts/lib/favicon.js +++ b/assets/javascripts/lib/favicon.js @@ -1,9 +1,27 @@ +// @ts-check + +/** + * The favicon the page was served with, read the first time a doc sets one. + * + * @type {string | null} + */ let defaultUrl = null; + +/** The doc whose icon is currently shown. @type {string | null} */ let currentSlug = null; +/** Loaded spritesheet and default favicon images, by URL. @type {Record} */ const imageCache = {}; + +/** Generated favicon data URLs, by doc slug. @type {Record} */ const urlCache = {}; +/** + * Runs `action` with the image at `url`, loading and caching it first if need be. + * + * @param {string} url + * @param {(img: HTMLImageElement) => void} action + */ const withImage = function (url, action) { if (imageCache[url]) { return action(imageCache[url]); @@ -18,6 +36,14 @@ const withImage = function (url, action) { } }; +/** + * Draws the doc's icon over the default favicon and swaps it in. + * + * Does nothing if the doc's icon is already shown, if the user turned + * doc-specific icons off, or if the icon can't be found. + * + * @param {{ slug: string }} doc + */ this.setFaviconForDoc = function (doc) { if (currentSlug === doc.slug || app.settings.get("noDocSpecificIcon")) { return; @@ -93,6 +119,7 @@ this.setFaviconForDoc = function (doc) { ); }; +/** Puts the default favicon back, if a doc replaced it. */ this.resetFavicon = function () { if (defaultUrl !== null && currentSlug !== null) { $('link[rel="icon"]').href = defaultUrl; diff --git a/assets/javascripts/lib/local_storage_store.js b/assets/javascripts/lib/local_storage_store.js index 3f77c02efe..d333b8bab4 100644 --- a/assets/javascripts/lib/local_storage_store.js +++ b/assets/javascripts/lib/local_storage_store.js @@ -1,5 +1,16 @@ // @ts-check +/** + * The instance side of the store, so that the constructor can be declared as a + * global in globals.d.ts. + * + * @typedef {object} LocalStorageStore + * @property {(key: string) => any} get + * @property {(key: string, value: any) => boolean | undefined} set + * @property {(key: string) => boolean | undefined} del + * @property {() => boolean | undefined} reset + */ + /** * A JSON-encoded wrapper around `localStorage`. * diff --git a/assets/javascripts/lib/page.js b/assets/javascripts/lib/page.js index b06f24bffc..4d1a174275 100644 --- a/assets/javascripts/lib/page.js +++ b/assets/javascripts/lib/page.js @@ -4,22 +4,90 @@ * Copyright 2012 TJ Holowaychuk */ +// @ts-check + +/** + * The history entry behind a navigation. Stored in `history.state`, so it + * survives reloads and has to stay JSON-serializable. + * + * @typedef {object} PageState + * @property {number} [id] Incrementing, so that the initial and last entries can be recognized. + * @property {number} [sessionId] Identifies the page load; a mismatch means the state outlived its session. + * @property {string} [path] + */ + +/** + * A named capture in a route pattern. + * + * @typedef {object} RouteKey + * @property {string} name + * @property {boolean} optional + */ + +/** + * A route callback. Calling `next` passes the context on to the route after it. + * + * @callback PageCallback + * @param {Context} context + * @param {() => any} next + * @returns {any} + */ + +/** + * `page` is the router. Its behaviour depends on what it is handed: + * + * - `page(fn)` registers `fn` for every path, + * - `page(path, fn)` registers `fn` for one route, + * - `page(path, state)` navigates, and + * - `page(options)` starts the router. + * + * The signatures below are the source of truth for the global, which is + * declared in globals.d.ts. + * + * @callback PageFn + * @param {string | RegExp | PageCallback | object} [value] + * @param {PageCallback | PageState} [fn] + * @returns {void} + */ + +/** + * @typedef {object} PageHelpers + * @property {(options?: object) => void} start Begins listening for clicks and history changes. + * @property {() => void} stop + * @property {(path: string, state?: PageState) => Context | undefined} show Navigates, pushing a history entry. + * @property {(path: string, state?: PageState, skipDispatch?: boolean, init?: boolean) => Context} replace Navigates, replacing the current history entry. + * @property {(context: Context) => any} dispatch Runs the context through the registered routes. + * @property {() => boolean} canGoBack + * @property {() => boolean} canGoForward + * @property {(fn: () => void) => void} track Registers an analytics callback, run on every navigation once consent is given. + */ + let running = false; + +/** @type {PageState | null} */ let currentState = null; + +/** @type {PageCallback[]} */ const callbacks = []; -this.page = function (value, fn) { - if (typeof value === "function") { - page("*", value); - } else if (typeof fn === "function") { - const route = new Route(value); - callbacks.push(route.middleware(fn)); - } else if (typeof value === "string") { - page.show(value, fn); - } else { - page.start(value); - } -}; +// The helpers are attached to `page` below, so the function on its own doesn't +// yet satisfy the type the global is declared with. +this.page = /** @type {PageFn & PageHelpers} */ ( + /** @type {PageFn} */ ( + function (value, fn) { + if (typeof value === "function") { + page("*", /** @type {PageCallback} */ (value)); + } else if (typeof fn === "function") { + const route = new Route(/** @type {string | RegExp | string[]} */ (value)); + callbacks.push(route.middleware(fn)); + } else if (typeof value === "string") { + page.show(value, /** @type {PageState} */ (fn)); + } else { + page.start(value); + } + } + ) +); page.start = function (options) { if (options == null) { @@ -118,22 +186,72 @@ class Context { */ static initialPath = currentPath(); + /** + * Whether the state is the first of the session. + * + * @param {PageState} state + * @returns {boolean} + */ static isInitialState(state) { return state.id === 0; } + /** + * Whether the state is the most recent one created. + * + * @param {PageState} state + * @returns {boolean} + */ static isLastState(state) { return state.id === Context.stateId - 1; } + /** + * Whether a popstate is the browser restoring the path the document loaded with. + * + * @param {PageState} state + * @returns {boolean} + */ static isInitialPopState(state) { return state.path === Context.initialPath && Context.stateId === 1; } + /** + * Whether the state was created by this page load. + * + * @param {PageState} state + * @returns {boolean} + */ static isSameSession(state) { return state.sessionId === Context.sessionId; } + /** + * Whether this context is the one the document was loaded with, rather than + * a later navigation. Set by `page.replace`. + * + * @type {boolean | undefined} + */ + init; + + /** + * The route's captured parameters, by name for named ones and by position + * for the rest. Set by `Route#middleware` when the route matches. + * + * @type {any} + */ + params; + + /** The query string, without the leading `?`. @type {string | undefined} */ + query; + + /** The hash fragment, without the leading `#`. @type {string | undefined} */ + hash; + + /** + * @param {string} [path] Defaults to `"/"`. + * @param {PageState} [state] + */ constructor(path, state) { if (path == null) { path = "/"; @@ -172,18 +290,32 @@ class Context { } } +/** A single route: a path pattern, and the parameter names it captures. */ class Route { + /** + * @param {string | RegExp | string[]} path + * @param {object} [options] Unused; kept for call-site compatibility. + */ constructor(path, options) { this.path = path; if (options == null) { options = {}; } + /** @type {RouteKey[]} */ this.keys = []; this.regexp = pathToRegexp(this.path, this.keys); } + /** + * Wraps `fn` so that it only runs when the route matches. + * + * @param {PageCallback} fn + * @returns {PageCallback} + */ middleware(fn) { return (context, next) => { + // Named captures are set as string keys alongside the positional ones. + /** @type {any} */ let params = []; if (this.match(context.pathname, params)) { context.params = params; @@ -194,6 +326,11 @@ class Route { }; } + /** + * @param {string} path + * @param {any} params Filled in with the captured parameters. + * @returns {boolean | undefined} `undefined` when the route doesn't match. + */ match(path, params) { const matchData = this.regexp.exec(path); if (!matchData) { @@ -217,6 +354,13 @@ class Route { } } +/** + * Compiles a path pattern into a regexp, collecting the named captures. + * + * @param {string | RegExp | string[]} path + * @param {RouteKey[]} keys Filled in with one entry per named capture. + * @returns {RegExp} + */ var pathToRegexp = function (path, keys) { if (path instanceof RegExp) { return path; @@ -257,6 +401,7 @@ var pathToRegexp = function (path, keys) { return new RegExp(`^${path}$`); }; +/** @type {(this: Window, ev: PopStateEvent) => any} */ var onpopstate = function (event) { if (!event.state || Context.isInitialPopState(event.state)) { return; @@ -269,6 +414,7 @@ var onpopstate = function (event) { } }; +/** @type {(this: Window, ev: PointerEvent) => any} */ var onclick = function (event) { try { if ( @@ -309,21 +455,27 @@ var onclick = function (event) { } }; +/** @param {string} url */ var isSameOrigin = (url) => url.startsWith(`${location.protocol}//${location.hostname}`); +/** Points the canonical link at the current path. */ var updateCanonicalLink = function () { - if (!this.canonicalLink) { - this.canonicalLink = document.head.querySelector('link[rel="canonical"]'); + // Cached on the global, which is what `this` is in the concatenated bundle. + const self = /** @type {any} */ (this); + if (!self.canonicalLink) { + self.canonicalLink = document.head.querySelector('link[rel="canonical"]'); } - return this.canonicalLink.setAttribute( + return self.canonicalLink.setAttribute( "href", `https://${location.host}${location.pathname}`, ); }; +/** @type {Array<() => void>} */ const trackers = []; +/** @param {() => void} fn */ page.track = function (fn) { trackers.push(fn); }; @@ -344,7 +496,7 @@ var track = function () { if (consentGiven === "1") { for (var tracker of trackers) { - tracker.call(); + tracker.call(undefined); } } else if (consentGiven === undefined && consentAsked === undefined) { // Only ask for consent once per browser session @@ -354,6 +506,7 @@ var track = function () { } }; +/** Expires the analytics cookies, which are the ones prefixed with a single `_`. */ this.resetAnalytics = function () { for (var cookie of document.cookie.split(/;\s?/)) { var name = cookie.split("=")[0]; From e8a2bd3c3381e061f101ff90213c2a54296bd759 Mon Sep 17 00:00:00 2001 From: Simon Legner Date: Mon, 14 Sep 2026 10:17:13 +0200 Subject: [PATCH 06/34] Type the models and the collections Model copies arbitrary attributes onto itself, so the attributes each subclass relies on are documented rather than declared: a field declaration would run after super() and blank the value out. --- assets/javascripts/collections/collection.js | 61 +++++++++- assets/javascripts/collections/docs.js | 29 +++++ assets/javascripts/collections/entries.js | 3 + assets/javascripts/collections/types.js | 13 +++ assets/javascripts/models/doc.js | 116 +++++++++++++++++-- assets/javascripts/models/entry.js | 36 +++++- assets/javascripts/models/model.js | 10 ++ assets/javascripts/models/type.js | 12 +- 8 files changed, 267 insertions(+), 13 deletions(-) diff --git a/assets/javascripts/collections/collection.js b/assets/javascripts/collections/collection.js index 79bca0be69..8cfc684d47 100644 --- a/assets/javascripts/collections/collection.js +++ b/assets/javascripts/collections/collection.js @@ -1,4 +1,14 @@ +// @ts-check + +/** + * An ordered list of models. + * + * Subclasses name the model they hold with a static `model` property, which is + * looked up in `app.models` so that the collection doesn't have to reference + * the class directly. + */ app.Collection = class Collection { + /** @param {any[]} [objects] Models, attribute objects, or other collections. */ constructor(objects) { if (objects == null) { objects = []; @@ -6,20 +16,37 @@ app.Collection = class Collection { this.reset(objects); } + /** + * The model class this collection holds. + * + * @returns {any} + */ model() { - return app.models[this.constructor.model]; + return app.models[/** @type {any} */ (this.constructor).model]; } + /** + * Replaces the contents. + * + * @param {any[]} [objects] + */ reset(objects) { if (objects == null) { objects = []; } + /** @type {any[]} */ this.models = []; for (var object of objects) { this.add(object); } } + /** + * Appends a model, an array of them, another collection's models, or an + * attribute object to build a model from. + * + * @param {any} object + */ add(object) { if (object instanceof app.Model) { this.models.push(object); @@ -34,40 +61,72 @@ app.Collection = class Collection { } } + /** + * @param {any} model + */ remove(model) { this.models.splice(this.models.indexOf(model), 1); } + /** @returns {number} */ size() { return this.models.length; } + /** @returns {boolean} */ isEmpty() { return this.models.length === 0; } + /** + * @param {(model: any) => void} fn + */ each(fn) { for (var model of this.models) { fn(model); } } + /** + * The underlying array, not a copy. + * + * @returns {any[]} + */ all() { return this.models; } + /** + * @param {any} model + * @returns {boolean} + */ contains(model) { return this.models.includes(model); } + /** + * @param {string} attr + * @param {any} value + * @returns {any} The first match, or `undefined`. + */ findBy(attr, value) { return this.models.find((model) => model[attr] === value); } + /** + * @param {string} attr + * @param {any} value + * @returns {any[]} + */ findAllBy(attr, value) { return this.models.filter((model) => model[attr] === value); } + /** + * @param {string} attr + * @param {any} value + * @returns {number} + */ countAllBy(attr, value) { let i = 0; for (var model of this.models) { diff --git a/assets/javascripts/collections/docs.js b/assets/javascripts/collections/docs.js index d4aa9c8400..1902d83ef4 100644 --- a/assets/javascripts/collections/docs.js +++ b/assets/javascripts/collections/docs.js @@ -1,3 +1,6 @@ +// @ts-check + +/** Every doc the app knows about, enabled or not. */ app.collections.Docs = class Docs extends app.Collection { static model = "Doc"; static NORMALIZE_VERSION_RGX = /\.(\d)$/; @@ -7,11 +10,20 @@ app.collections.Docs = class Docs extends app.Collection { // It's not pretty but I didn't want to import a promise library only for this. static CONCURRENCY = 3; + /** + * @param {string} slug With or without a version. + * @returns {any} The doc, or `undefined`. + */ findBySlug(slug) { return ( this.findBy("slug", slug) || this.findBy("slug_without_version", slug) ); } + /** + * Orders by name, then by version with the newest first. Sorts in place. + * + * @returns {any[]} + */ sort() { return this.models.sort((a, b) => { if (a.name === b.name) { @@ -37,6 +49,14 @@ app.collections.Docs = class Docs extends app.Collection { } }); } + /** + * Loads every doc's index, `CONCURRENCY` at a time. `onError` is called at + * most once, with the first failure. + * + * @param {() => void} onComplete + * @param {((args: any[]) => void) | null} onError + * @param {DocLoadOptions} [options] + */ load(onComplete, onError, options) { let i = 0; @@ -62,12 +82,18 @@ app.collections.Docs = class Docs extends app.Collection { } } + /** Drops every doc's cached index. */ clearCache() { for (var doc of this.models) { doc.clearCache(); } } + /** + * Removes every doc's offline database, one at a time. + * + * @param {() => void} callback + */ uninstall(callback) { let i = 0; var next = () => { @@ -80,6 +106,7 @@ app.collections.Docs = class Docs extends app.Collection { next(); } + /** @param {(statuses: Record | undefined) => void} callback */ getInstallStatuses(callback) { app.db.versions(this.models, (statuses) => { if (statuses) { @@ -92,6 +119,7 @@ app.collections.Docs = class Docs extends app.Collection { }); } + /** @param {(count: number) => void} callback Given the number of outdated docs. */ checkForUpdates(callback) { this.getInstallStatuses((statuses) => { let i = 0; @@ -107,6 +135,7 @@ app.collections.Docs = class Docs extends app.Collection { }); } + /** Reinstalls every doc whose offline copy is out of date. */ updateInBackground() { this.getInstallStatuses((statuses) => { if (!statuses) { diff --git a/assets/javascripts/collections/entries.js b/assets/javascripts/collections/entries.js index 2ea74707c1..5367cc9c1e 100644 --- a/assets/javascripts/collections/entries.js +++ b/assets/javascripts/collections/entries.js @@ -1,3 +1,6 @@ +// @ts-check + +/** Every searchable entry, across every enabled doc. */ app.collections.Entries = class Entries extends app.Collection { static model = "Entry"; }; diff --git a/assets/javascripts/collections/types.js b/assets/javascripts/collections/types.js index 0d23be0982..984a9cc12d 100644 --- a/assets/javascripts/collections/types.js +++ b/assets/javascripts/collections/types.js @@ -1,9 +1,18 @@ +// @ts-check + +/** The types within one doc, e.g. "Methods" or "Guides". */ app.collections.Types = class Types extends app.Collection { static model = "Type"; static GUIDES_RGX = /(^|\()(guides?|tutorials?|reference|book|getting\ started|manual|examples)($|[\):])/i; static APPENDIX_RGX = /appendix/i; + /** + * Splits the types into guides, regular types and appendices, in that + * order, dropping any group that ends up empty. + * + * @returns {any[][]} + */ groups() { const result = []; for (var type of this.models) { @@ -14,6 +23,10 @@ app.collections.Types = class Types extends app.Collection { return result.filter((e) => e.length > 0); } + /** + * @param {any} type + * @returns {number} The index of the group the type belongs in. + */ _groupFor(type) { if (Types.GUIDES_RGX.test(type.name)) { return 0; diff --git a/assets/javascripts/models/doc.js b/assets/javascripts/models/doc.js index 990c4046df..d36ad29e37 100644 --- a/assets/javascripts/models/doc.js +++ b/assets/javascripts/models/doc.js @@ -1,6 +1,29 @@ +// @ts-check + +/** + * How a doc's index and database are fetched. + * + * @typedef {object} DocLoadOptions + * @property {boolean} [readCache] Use the cached index instead of fetching, when it is current. + * @property {boolean} [writeCache] Cache the fetched index. + */ + +/** + * Whether a doc's database is stored offline, and how old the copy is. + * + * @typedef {object} InstallStatus + * @property {boolean} installed + * @property {number | undefined} mtime The `mtime` the stored copy was built from. + */ + +/** + * One version of one documentation set. + * + * Attributes, from the manifest: `name`, `slug`, `type`, `version`, `release`, + * `db_size`, `mtime`, `links`. The constructor derives `slug_without_version`, + * `fullName`, `icon`, `short_version` and `text` from them. + */ app.models.Doc = class Doc extends app.Model { - // Attributes: name, slug, type, version, release, db_size, mtime, links - static NUMBERED_VERSION_RGX = /^\d+(\.\d+)*$/; constructor() { @@ -15,11 +38,17 @@ app.models.Doc = class Doc extends app.Model { this.text = this.toEntry().text; } + /** + * Reloads the entries and types from freshly fetched index data. + * + * @param {{ entries?: any[], types?: any[] }} data + */ reset(data) { this.resetEntries(data.entries); this.resetTypes(data.types); } + /** @param {any[]} [entries] */ resetEntries(entries) { this.entries = new app.collections.Entries(entries); this.entries.each((entry) => { @@ -27,6 +56,7 @@ app.models.Doc = class Doc extends app.Model { }); } + /** @param {any[]} [types] */ resetTypes(types) { this.types = new app.collections.Types(types); this.types.each((type) => { @@ -34,6 +64,10 @@ app.models.Doc = class Doc extends app.Model { }); } + /** + * @param {string} [path] Relative to the doc. + * @returns {string} The app path for the page. + */ fullPath(path) { if (path == null) { path = ""; @@ -44,20 +78,32 @@ app.models.Doc = class Doc extends app.Model { return `/${this.slug}${path}`; } + /** + * @param {string} [path] + * @returns {string} Where the page's HTML is served from. + */ fileUrl(path) { return `${app.config.docs_origin}${this.fullPath(path)}?${this.mtime}`; } + /** @returns {string} Where the doc's offline database is served from. */ dbUrl() { return `${app.config.docs_origin}/${this.slug}/${app.config.db_filename}?${this.mtime}`; } + /** @returns {string} Where the doc's entry index is served from. */ indexUrl() { return `${app.config.docs_origin}/${this.slug}/${ app.config.index_filename }?${this.mtime}`; } + /** + * The entry standing for the doc itself, so that it can be searched for + * by name. Built once and reused. + * + * @returns {any} + */ toEntry() { if (this.entry) { return this.entry; @@ -73,6 +119,11 @@ app.models.Doc = class Doc extends app.Model { return this.entry; } + /** + * @param {string} path + * @param {string} [hash] Preferred over `path` alone when it matches an entry. + * @returns {any} The entry, or `undefined`. + */ findEntryByPathAndHash(path, hash) { const entry = hash && this.entries.findBy("path", `${path}#${hash}`); if (entry) { @@ -84,6 +135,13 @@ app.models.Doc = class Doc extends app.Model { } } + /** + * Fetches the doc's entry index, or reads it from the cache. + * + * @param {() => void} onSuccess + * @param {() => void} onError + * @param {DocLoadOptions} [options] + */ load(onSuccess, onError, options) { if (options == null) { options = {}; @@ -107,10 +165,15 @@ app.models.Doc = class Doc extends app.Model { }); } + /** Drops the cached index. */ clearCache() { app.localStorage.del(this.slug); } + /** + * @param {() => void} onSuccess Called asynchronously, to match the network path. + * @returns {boolean | undefined} `true` when the cache was used. + */ _loadFromCache(onSuccess) { const data = this._getCache(); if (!data) { @@ -126,6 +189,7 @@ app.models.Doc = class Doc extends app.Model { return true; } + /** @returns {any} The cached index, or `undefined` when it is missing or stale. */ _getCache() { const data = app.localStorage.get(this.slug); if (!data) { @@ -140,10 +204,19 @@ app.models.Doc = class Doc extends app.Model { } } + /** @param {any} data */ _setCache(data) { app.localStorage.set(this.slug, [this.mtime, data]); } + /** + * Downloads the doc's database and stores it offline. Does nothing while an + * install or uninstall is already running. + * + * @param {() => void} onSuccess + * @param {() => void} onError + * @param {(event: ProgressEvent) => void} [onProgress] + */ install(onSuccess, onError, onProgress) { if (this.installing) { return; @@ -169,6 +242,12 @@ app.models.Doc = class Doc extends app.Model { }); } + /** + * Removes the doc's offline database. + * + * @param {() => void} onSuccess + * @param {() => void} onError + */ uninstall(onSuccess, onError) { if (this.installing) { return; @@ -188,24 +267,34 @@ app.models.Doc = class Doc extends app.Model { app.db.unstore(this, success, error); } + /** @param {(status: InstallStatus) => void} callback */ getInstallStatus(callback) { app.db.version(this, (value) => callback({ installed: !!value, mtime: value }), ); } - // Whether the doc holds a numbered version of its documentation (e.g. "3.9"), - // as opposed to a variant (e.g. "10 LTS" or "Python"), which can't be - // ordered. An empty version means the doc holds the latest version - // (e.g. `angular`), whereas docs without a version aren't versioned at all. + /** + * Whether the doc holds a numbered version of its documentation (e.g. "3.9"), + * as opposed to a variant (e.g. "10 LTS" or "Python"), which can't be + * ordered. An empty version means the doc holds the latest version + * (e.g. `angular`), whereas docs without a version aren't versioned at all. + * + * @returns {boolean} + */ hasNumberedVersion() { return ( this.version === "" || Doc.NUMBERED_VERSION_RGX.test(this.version || "") ); } - // Compares numbered versions (e.g. "3.9" is older than "3.12"). - // An empty version means the latest version and is newer than any other. + /** + * Compares numbered versions (e.g. "3.9" is older than "3.12"). + * An empty version means the latest version and is newer than any other. + * + * @param {any} other + * @returns {boolean} + */ isNewerVersionThan(other) { if (this.version === "" || other.version === "") { return this.version === "" && other.version !== ""; @@ -222,8 +311,11 @@ app.models.Doc = class Doc extends app.Model { return false; } - // Returns the doc holding the latest version of the same documentation among - // `docs`, or the doc itself when there is none. + /** + * @param {any[]} docs + * @returns {any} The doc holding the latest version of the same + * documentation among `docs`, or the doc itself when there is none. + */ findLatestVersion(docs) { let latest = this; if (!this.hasNumberedVersion()) { @@ -241,6 +333,10 @@ app.models.Doc = class Doc extends app.Model { return latest; } + /** + * @param {InstallStatus | undefined} status + * @returns {boolean} Whether the offline copy is older than the served one. + */ isOutdated(status) { if (!status) { return false; diff --git a/assets/javascripts/models/entry.js b/assets/javascripts/models/entry.js index 58a0120418..23eef4b0d9 100644 --- a/assets/javascripts/models/entry.js +++ b/assets/javascripts/models/entry.js @@ -1,6 +1,23 @@ +// @ts-check + //= require app/searcher +/** + * One searchable page, or a heading within one. + * + * Attributes, from the doc's index: `name`, `type`, `path`. The constructor + * derives `text`, the normalized string the searcher matches against, and the + * doc sets `doc` when it builds its entries. + */ app.models.Entry = class Entry extends app.Model { + /** + * Expands a searchable string with its alias, if it has one, so that both + * spellings match. + * + * @param {string} string + * @returns {string | string[]} Both spellings when an alias applies, + * otherwise the string unchanged. + */ static applyAliases(string) { const aliases = app.config.docs_aliases; if (aliases.hasOwnProperty(string)) { @@ -18,12 +35,16 @@ app.models.Entry = class Entry extends app.Model { return string; } - // Attributes: name, type, path constructor() { super(...arguments); this.text = Entry.applyAliases(app.Searcher.normalizeString(this.name)); } + /** + * Makes the entry findable under another name as well. + * + * @param {string} name + */ addAlias(name) { const text = Entry.applyAliases(app.Searcher.normalizeString(name)); if (!Array.isArray(this.text)) { @@ -34,22 +55,27 @@ app.models.Entry = class Entry extends app.Model { this.text.push(...(Array.isArray(text) ? text : [text])); } + /** @returns {string} The app path for the entry's page. */ fullPath() { return this.doc.fullPath(this.isIndex() ? "" : this.path); } + /** @returns {string} The path the page is stored under offline, without the hash. */ dbPath() { return this.path.replace(/#.*/, ""); } + /** @returns {string} The app path of the entry's HTML file. */ filePath() { return this.doc.fullPath(this._filePath()); } + /** @returns {string} Where the entry's HTML is served from. */ fileUrl() { return this.doc.fileUrl(this._filePath()); } + /** @returns {string} The entry's path as a `.html` filename, without the hash. */ _filePath() { let result = this.path.replace(/#.*/, ""); if (result.slice(-5) !== ".html") { @@ -58,14 +84,22 @@ app.models.Entry = class Entry extends app.Model { return result; } + /** @returns {boolean} Whether the entry stands for the doc itself. */ isIndex() { return this.path === "index"; } + /** @returns {any} The entry's type, or `undefined`. */ getType() { return this.doc.types.findBy("name", this.type); } + /** + * Reads the entry's page out of the offline database. + * + * @param {(html: string) => void} onSuccess + * @param {() => void} onError + */ loadFile(onSuccess, onError) { return app.db.load(this, onSuccess, onError); } diff --git a/assets/javascripts/models/model.js b/assets/javascripts/models/model.js index def06e55ce..3e67cc50e2 100644 --- a/assets/javascripts/models/model.js +++ b/assets/javascripts/models/model.js @@ -1,4 +1,14 @@ +// @ts-check + +/** + * The base model: copies the attributes it is handed onto itself. + * + * Attributes vary by subclass and aren't known ahead of time, so subclasses + * document the ones they rely on rather than declaring them as fields — a + * field declaration would run after `super()` and blank the value out. + */ app.Model = class Model { + /** @param {Record} [attributes] */ constructor(attributes) { for (var key in attributes) { var value = attributes[key]; diff --git a/assets/javascripts/models/type.js b/assets/javascripts/models/type.js index bc264ac189..0bfe6a8140 100644 --- a/assets/javascripts/models/type.js +++ b/assets/javascripts/models/type.js @@ -1,14 +1,24 @@ +// @ts-check + +/** + * A group of entries within a doc, e.g. "Methods". + * + * Attributes, from the doc's index: `name`, `slug`, `count`. The doc sets + * `doc` when it builds its types. + */ app.models.Type = class Type extends app.Model { - // Attributes: name, slug, count + /** @returns {string} The app path for the type's page. */ fullPath() { return `/${this.doc.slug}-${this.slug}/`; } + /** @returns {any[]} Every entry of this type in the doc. */ entries() { return this.doc.entries.findAllBy("type", this.name); } + /** @returns {any} An entry standing for the type's page, so that it can be searched for. */ toEntry() { return new app.models.Entry({ doc: this.doc, From 1b631c6d8393c3127cc2d4d2f52978ede6f7b084 Mon Sep 17 00:00:00 2001 From: Simon Legner Date: Mon, 14 Sep 2026 10:17:49 +0200 Subject: [PATCH 07/34] Type the app services The IndexedDB version-mismatch handler used a function expression, so its `this` was the request rather than the DB, and the `error` it reported resolved to the global error template. Make it an arrow and report the request's own error. --- assets/javascripts/app/app.js | 6 +++--- assets/javascripts/app/db.js | 15 ++++++++++----- assets/javascripts/app/offline_backup.js | 4 +++- assets/javascripts/app/router.js | 4 +++- assets/javascripts/app/searcher.js | 4 +++- assets/javascripts/app/serviceworker.js | 2 ++ assets/javascripts/app/settings.js | 19 ++++++++++++++----- assets/javascripts/app/shortcuts.js | 2 ++ assets/javascripts/app/update_checker.js | 2 ++ 9 files changed, 42 insertions(+), 16 deletions(-) diff --git a/assets/javascripts/app/app.js b/assets/javascripts/app/app.js index f7f381efd0..9c54ee86d6 100644 --- a/assets/javascripts/app/app.js +++ b/assets/javascripts/app/app.js @@ -94,10 +94,10 @@ class App extends Events { previousErrorHandler; /** - * The most recent IndexedDB transaction, tracked by app/db.js so that a - * hung transaction can be detected. + * The stores and mode of the most recent IndexedDB transaction, tracked by + * app/db.js so that a hung transaction can be reported. * - * @type {number | undefined} + * @type {[string | string[], IDBTransactionMode] | undefined} */ lastIDBTransaction; diff --git a/assets/javascripts/app/db.js b/assets/javascripts/app/db.js index 862258d3f8..2f7c6a5aef 100644 --- a/assets/javascripts/app/db.js +++ b/assets/javascripts/app/db.js @@ -1,10 +1,13 @@ +// @ts-check + app.DB = class DB { static NAME = "docs"; static VERSION = 15; constructor() { this.versionMultipler = $.isIE() ? 1e5 : 1e9; - this.useIndexedDB = this.useIndexedDB(); + // Replaces the method of the same name with the answer it gives. + /** @type {any} */ (this).useIndexedDB = this.useIndexedDB(); this.callbacks = []; } @@ -78,7 +81,7 @@ app.DB = class DB { fail(reason, error) { this.cachedDocs = null; - this.useIndexedDB = false; + /** @type {any} */ (this).useIndexedDB = false; if (!this.reason) { this.reason = reason; } @@ -109,11 +112,13 @@ app.DB = class DB { onVersionError() { const req = indexedDB.open(DB.NAME); req.onsuccess = (event) => { - return this.handleVersionMismatch(event.target.result.version); + return this.handleVersionMismatch( + /** @type {IDBRequest} */ (event.target).result.version, + ); }; - req.onerror = function (event) { + req.onerror = (event) => { event.preventDefault(); - return this.fail("cant_open", error); + return this.fail("cant_open", req.error); }; } diff --git a/assets/javascripts/app/offline_backup.js b/assets/javascripts/app/offline_backup.js index 9034a90595..ed0e653abe 100644 --- a/assets/javascripts/app/offline_backup.js +++ b/assets/javascripts/app/offline_backup.js @@ -1,3 +1,5 @@ +// @ts-check + // Exports the offline data (the pages stored in IndexedDB and the index files // cached in localStorage) to a JSON file, and imports it back — either to // restore a backup after the browser evicted the data, or to move the @@ -75,7 +77,7 @@ app.OfflineBackup = class OfflineBackup { reader.onload = () => { const data = (() => { try { - return JSON.parse(reader.result); + return JSON.parse(/** @type {string} */ (reader.result)); } catch (error) {} })(); diff --git a/assets/javascripts/app/router.js b/assets/javascripts/app/router.js index 4772e49737..312ae1c305 100644 --- a/assets/javascripts/app/router.js +++ b/assets/javascripts/app/router.js @@ -1,3 +1,5 @@ +// @ts-check + app.Router = class Router extends Events { static routes = [ ["*", "before"], @@ -15,7 +17,7 @@ app.Router = class Router extends Events { constructor() { super(); - for (var [path, method] of this.constructor.routes) { + for (var [path, method] of /** @type {any} */ (this.constructor).routes) { page(path, this[method].bind(this)); } this.setInitialPath(); diff --git a/assets/javascripts/app/searcher.js b/assets/javascripts/app/searcher.js index 7cd6e82614..7b383eab88 100644 --- a/assets/javascripts/app/searcher.js +++ b/assets/javascripts/app/searcher.js @@ -1,3 +1,5 @@ +// @ts-check + // // Match functions // @@ -204,7 +206,7 @@ app.Searcher = class Searcher extends Events { } setup() { - query = this.query = this.constructor.normalizeQuery(this.query); + query = this.query = /** @type {any} */ (this.constructor).normalizeQuery(this.query); queryLength = query.length; this.dataLength = this.data.length; this.matchers = [exactMatch]; diff --git a/assets/javascripts/app/serviceworker.js b/assets/javascripts/app/serviceworker.js index 4c35a32c32..6406ade803 100644 --- a/assets/javascripts/app/serviceworker.js +++ b/assets/javascripts/app/serviceworker.js @@ -1,3 +1,5 @@ +// @ts-check + app.ServiceWorker = class ServiceWorker extends Events { static isEnabled() { return !!navigator.serviceWorker && app.config.service_worker_enabled; diff --git a/assets/javascripts/app/settings.js b/assets/javascripts/app/settings.js index 113005a65d..3523ee3468 100644 --- a/assets/javascripts/app/settings.js +++ b/assets/javascripts/app/settings.js @@ -1,3 +1,5 @@ +// @ts-check + app.Settings = class Settings { static PREFERENCE_KEYS = [ "hideDisabled", @@ -63,7 +65,7 @@ app.Settings = class Settings { this.cache[key] = (left = this.store.get(key)) != null ? left - : this.constructor.defaults[key]; + : /** @type {any} */ (this.constructor).defaults[key]; if (key === "theme" && this.cache[key] === "auto" && !this.darkModeQuery) { return (this.cache[key] = "default"); } else { @@ -91,7 +93,10 @@ app.Settings = class Settings { } getDocs() { - return this.store.get("docs")?.split("/") || app.config.default_docs; + return ( + /** @type {string | undefined} */ (this.store.get("docs"))?.split("/") || + app.config.default_docs + ); } setDocs(docs) { @@ -99,7 +104,7 @@ app.Settings = class Settings { } getTips() { - return this.store.get("tips")?.split("/") || []; + return /** @type {string | undefined} */ (this.store.get("tips"))?.split("/") || []; } setTips(tips) { @@ -109,7 +114,9 @@ app.Settings = class Settings { setLayout(name, enable) { this.toggleLayout(name, enable); - const layout = (this.store.get("layout") || "").split(" "); + const layout = /** @type {string} */ ( + this.store.get("layout") || "" + ).split(" "); $.arrayDelete(layout, ""); if (enable) { @@ -128,7 +135,9 @@ app.Settings = class Settings { } hasLayout(name) { - const layout = (this.store.get("layout") || "").split(" "); + const layout = /** @type {string} */ ( + this.store.get("layout") || "" + ).split(" "); return layout.includes(name); } diff --git a/assets/javascripts/app/shortcuts.js b/assets/javascripts/app/shortcuts.js index 05e09cf7bd..9ffbe3191e 100644 --- a/assets/javascripts/app/shortcuts.js +++ b/assets/javascripts/app/shortcuts.js @@ -1,3 +1,5 @@ +// @ts-check + app.Shortcuts = class Shortcuts extends Events { constructor() { super(); diff --git a/assets/javascripts/app/update_checker.js b/assets/javascripts/app/update_checker.js index 82d3cc92c6..61a7d56d97 100644 --- a/assets/javascripts/app/update_checker.js +++ b/assets/javascripts/app/update_checker.js @@ -1,3 +1,5 @@ +// @ts-check + app.UpdateChecker = class UpdateChecker { constructor() { this.lastCheck = Date.now(); From 4e0bf9db3be39b3cdf0c9f56791e1325d05f0750 Mon Sep 17 00:00:00 2001 From: Simon Legner Date: Mon, 14 Sep 2026 10:20:07 +0200 Subject: [PATCH 08/34] Document the app services DB#unstore's error handler was a function expression, so its `this` was the transaction: the NotFoundError retry threw instead of bumping the schema and trying again. DB#store, right above it, already used an arrow. --- assets/javascripts/app/db.js | 167 +++++++++++++++++++++-- assets/javascripts/app/offline_backup.js | 86 ++++++++++-- assets/javascripts/app/router.js | 68 +++++++++ assets/javascripts/app/searcher.js | 80 +++++++++++ assets/javascripts/app/serviceworker.js | 22 +++ assets/javascripts/app/settings.js | 52 ++++++- assets/javascripts/app/shortcuts.js | 49 +++++++ assets/javascripts/app/update_checker.js | 9 ++ 8 files changed, 511 insertions(+), 22 deletions(-) diff --git a/assets/javascripts/app/db.js b/assets/javascripts/app/db.js index 2f7c6a5aef..e2ab75a837 100644 --- a/assets/javascripts/app/db.js +++ b/assets/javascripts/app/db.js @@ -1,5 +1,36 @@ // @ts-check +/** + * An IndexedDB event, whose target is the request or transaction that raised + * it. lib.dom types `Event#target` as a bare `EventTarget`. + * + * @typedef {Event & { target: any }} IDBEvent + */ + +/** + * How a transaction is opened. + * + * @typedef {object} DBTransactionOptions + * @property {string | string[]} stores + * @property {IDBTransactionMode} mode + * @property {boolean} [ignoreError] Set to `false` to let errors surface. + * @property {boolean} [ignoreAbort] Set to `false` to let aborts surface. + */ + +/** + * The offline store: the docs' pages, kept in IndexedDB. + * + * The database is opened for the length of one batch of work and closed again, + * so every operation goes through `db`, which queues its callback and hands it + * the open database. When IndexedDB can't be used at all — private mode, a + * buggy implementation, an exceeded quota — `useIndexedDB` is turned off and + * every callback is run with no database, which makes the callers fall back to + * the network. + * + * The version number packs the schema version and the user's own version + * together, so that a doc being installed can force an upgrade without + * colliding with a schema change. + */ app.DB = class DB { static NAME = "docs"; static VERSION = 15; @@ -11,6 +42,12 @@ app.DB = class DB { this.callbacks = []; } + /** + * Opens the database and runs `fn` with it, or with nothing when IndexedDB + * is unavailable. Callbacks queued while an open is in flight share it. + * + * @param {(db?: IDBDatabase) => void} [fn] + */ db(fn) { if (!this.useIndexedDB) { return fn(); @@ -36,9 +73,15 @@ app.DB = class DB { } } + /** + * Runs the queued callbacks, unless the database turns out to be empty or + * buggy. + * + * @param {IDBEvent} event + */ onOpenSuccess(event) { let error; - const db = event.target.result; + const db = /** @type {IDBEvent} */ (event).target.result; if (db.objectStoreNames.length === 0) { try { @@ -59,10 +102,11 @@ app.DB = class DB { } } + /** @param {IDBEvent} event */ onOpenError(event) { event.preventDefault(); this.open = false; - const { error } = event.target; + const { error } = /** @type {IDBEvent} */ (event).target; switch (error.name) { case "QuotaExceededError": @@ -79,6 +123,12 @@ app.DB = class DB { } } + /** + * Turns IndexedDB off for the rest of the session and drains the queue. + * + * @param {string} reason + * @param {any} [error] + */ fail(reason, error) { this.cachedDocs = null; /** @type {any} */ (this).useIndexedDB = false; @@ -102,6 +152,7 @@ app.DB = class DB { } } + /** Drops the database and tells the app, so it can warn the user. */ onQuotaExceededError() { this.reset(); this.db(); @@ -109,6 +160,7 @@ app.DB = class DB { Raven.captureMessage("QuotaExceededError", { level: "warning" }); } + /** Reopens at the stored version, to tell a schema bump from a user one. */ onVersionError() { const req = indexedDB.open(DB.NAME); req.onsuccess = (event) => { @@ -122,6 +174,9 @@ app.DB = class DB { }; } + /** + * @param {number} actualVersion The version the stored database is at. + */ handleVersionMismatch(actualVersion) { if (Math.floor(actualVersion / this.versionMultipler) !== DB.VERSION) { this.fail("version"); @@ -131,6 +186,10 @@ app.DB = class DB { } } + /** + * @param {IDBDatabase} db + * @returns {any} The error a known-broken implementation throws, if any. + */ buggyIDB(db) { if (this.checkedBuggyIDB) { return; @@ -147,6 +206,9 @@ app.DB = class DB { } } + /** + * @param {IDBDatabase} [db] Omitted when the database couldn't be opened. + */ runCallbacks(db) { let fn; while ((fn = this.callbacks.shift())) { @@ -154,8 +216,13 @@ app.DB = class DB { } } + /** + * Creates an object store per enabled doc. + * + * @param {IDBVersionChangeEvent} event + */ onUpgradeNeeded(event) { - const db = event.target.result; + const db = /** @type {IDBEvent} */ (event).target.result; if (!db) { return; } @@ -183,6 +250,16 @@ app.DB = class DB { } } + /** + * Replaces the doc's stored pages. Whatever was there before is cleared. + * + * @param {any} doc + * @param {Record} data The doc's pages, by path. + * @param {number} mtime + * @param {() => void} onSuccess + * @param {(error?: any) => void} onError + * @param {boolean} [_retry] Internal: whether a failure may bump the schema and try again. + */ store(doc, data, mtime, onSuccess, onError, _retry) { if (_retry == null) { _retry = true; @@ -246,6 +323,14 @@ app.DB = class DB { }); } + /** + * Removes the doc's pages. + * + * @param {any} doc + * @param {() => void} onSuccess + * @param {(error?: any) => void} onError + * @param {boolean} [_retry] Internal: whether a failure may bump the schema and try again. + */ unstore(doc, onSuccess, onError, _retry) { if (_retry == null) { _retry = true; @@ -267,7 +352,7 @@ app.DB = class DB { } onSuccess(); }; - txn.onerror = function (event) { + txn.onerror = (event) => { event.preventDefault(); if (txn.error?.name === "NotFoundError" && _retry) { this.migrate(); @@ -290,6 +375,12 @@ app.DB = class DB { // Reads back everything that store() wrote for a doc: its pages and the // mtime it was installed with. Calls back with null when the doc isn't // installed or can't be read. + /** + * Reads the doc's stored pages, for a backup. + * + * @param {any} doc + * @param {(result: { mtime: number, data: any } | null) => void} callback + */ dump(doc, callback) { this.db((db) => { if (!db || !db.objectStoreNames.contains(doc.slug)) { @@ -318,12 +409,12 @@ app.DB = class DB { }; txn.objectStore("docs").get(doc.slug).onsuccess = (event) => { - mtime = event.target.result; + mtime = /** @type {IDBEvent} */ (event).target.result; }; const req = txn.objectStore(doc.slug).openCursor(); req.onsuccess = (event) => { - const cursor = event.target.result; + const cursor = /** @type {IDBEvent} */ (event).target.result; if (!cursor) { return; } @@ -333,6 +424,10 @@ app.DB = class DB { }); } + /** + * @param {any} doc + * @param {(version: number | false) => void} fn The stored `mtime`, or `false` when it isn't installed. + */ version(doc, fn) { const version = this.cachedVersion(doc); if (version != null) { @@ -363,6 +458,10 @@ app.DB = class DB { }); } + /** + * @param {any} doc + * @returns {number | false | undefined} `undefined` when the cache isn't loaded yet. + */ cachedVersion(doc) { if (!this.cachedDocs) { return; @@ -370,6 +469,10 @@ app.DB = class DB { return this.cachedDocs[doc.slug] || false; } + /** + * @param {any[]} docs + * @param {(versions: Record | false) => void} fn + */ versions(docs, fn) { const versions = this.cachedVersions(docs); if (versions) { @@ -406,6 +509,10 @@ app.DB = class DB { }); } + /** + * @param {any[]} docs + * @returns {Record | undefined} `undefined` when the cache isn't loaded yet. + */ cachedVersions(docs) { if (!this.cachedDocs) { return; @@ -417,6 +524,14 @@ app.DB = class DB { return result; } + /** + * Reads an entry's page, from the offline store when it is there and from + * the network otherwise. + * + * @param {any} entry + * @param {(html: string) => void} onSuccess + * @param {() => void} onError + */ load(entry, onSuccess, onError) { if (this.shouldLoadWithIDB(entry)) { return this.loadWithIDB(entry, onSuccess, () => @@ -427,6 +542,11 @@ app.DB = class DB { } } + /** + * @param {any} entry + * @param {(html: string) => void} onSuccess + * @param {() => void} onError + */ loadWithXHR(entry, onSuccess, onError) { return ajax({ url: entry.fileUrl(), @@ -436,6 +556,11 @@ app.DB = class DB { }); } + /** + * @param {any} entry + * @param {(html: string) => void} onSuccess + * @param {() => void} onError Called when the page isn't stored, so the caller can fall back. + */ loadWithIDB(entry, onSuccess, onError) { return this.db((db) => { if (!db) { @@ -471,6 +596,11 @@ app.DB = class DB { }); } + /** + * Reads every doc's stored `mtime` into memory, once per session. + * + * @param {IDBDatabase} db + */ loadDocsCache(db) { if (this.cachedDocs) { return; @@ -487,7 +617,7 @@ app.DB = class DB { const req = txn.objectStore("docs").openCursor(); req.onsuccess = (event) => { - const cursor = event.target.result; + const cursor = /** @type {IDBEvent} */ (event).target.result; if (!cursor) { return; } @@ -499,6 +629,7 @@ app.DB = class DB { }; } + /** Looks for docs whose store is missing its index page, and drops them. */ checkForCorruptedDocs() { this.db((db) => { let slug; @@ -545,14 +676,15 @@ app.DB = class DB { for (var doc of docs) { txn.objectStore(doc).get("index").onsuccess = (event) => { - if (!event.target.result) { - this.corruptedDocs.push(event.target.source.name); + if (!/** @type {IDBEvent} */ (event).target.result) { + this.corruptedDocs.push(/** @type {IDBEvent} */ (event).target.source.name); } }; } }); } + /** Forgets the docs `checkForCorruptedDocs` found. */ deleteCorruptedDocs() { this.db((db) => { let doc; @@ -573,12 +705,21 @@ app.DB = class DB { }); } + /** + * @param {any} entry + * @returns {boolean} Whether the entry's doc is installed. + */ shouldLoadWithIDB(entry) { return ( this.useIndexedDB && (!this.cachedDocs || this.cachedDocs[entry.doc.slug]) ); } + /** + * @param {IDBDatabase} db + * @param {DBTransactionOptions} options + * @returns {IDBTransaction} + */ idbTransaction(db, options) { app.lastIDBTransaction = [options.stores, options.mode]; const txn = db.transaction(options.stores, options.mode); @@ -595,12 +736,17 @@ app.DB = class DB { return txn; } + /** Deletes the whole database. */ reset() { try { indexedDB?.deleteDatabase(DB.NAME); } catch (error) {} } + /** + * @returns {boolean} Whether IndexedDB can be used at all. Replaced by its + * own result in the constructor. + */ useIndexedDB() { try { if (!app.isSingleDoc() && window.indexedDB) { @@ -614,14 +760,17 @@ app.DB = class DB { } } + /** Bumps the user's schema version, forcing the next open to upgrade. */ migrate() { app.settings.set("schema", this.userVersion() + 1); } + /** @param {number} version */ setUserVersion(version) { app.settings.set("schema", version); } + /** @returns {number} */ userVersion() { return app.settings.get("schema"); } diff --git a/assets/javascripts/app/offline_backup.js b/assets/javascripts/app/offline_backup.js index ed0e653abe..ad3dbe418e 100644 --- a/assets/javascripts/app/offline_backup.js +++ b/assets/javascripts/app/offline_backup.js @@ -1,22 +1,45 @@ // @ts-check -// Exports the offline data (the pages stored in IndexedDB and the index files -// cached in localStorage) to a JSON file, and imports it back — either to -// restore a backup after the browser evicted the data, or to move the -// documentations to another computer without downloading them again. +/** + * What an import ended up doing. + * + * @typedef {object} ImportSummary + * @property {any[]} docs The docs that were stored. + * @property {string[]} skipped Slugs in the file that this app doesn't know, or that were unusable. + * @property {any[]} failed Docs whose store failed. + * @property {number} enabled How many of the docs weren't enabled before. + */ + +/** + * Exports the offline data (the pages stored in IndexedDB and the index files + * cached in localStorage) to a JSON file, and imports it back — either to + * restore a backup after the browser evicted the data, or to move the + * documentations to another computer without downloading them again. + */ app.OfflineBackup = class OfflineBackup { static TYPE = "devdocs-offline"; static VERSION = 1; static MIME_TYPE = "application/json"; + /** + * @param {any[]} docs + * @returns {string} The name to save the backup under. + */ filename(docs) { const date = new Date().toISOString().slice(0, 10); const name = docs.length === 1 ? docs[0].slug : "offline"; return `devdocs-${name}-${date}.json`; } - // Calls back with a Blob containing every installed doc among `docs`, and - // the number of docs it holds. Docs that aren't installed are skipped. + /** + * Calls back with a Blob containing every installed doc among `docs`, and + * the number of docs it holds. Docs that aren't installed are skipped. + * + * @param {any[]} docs + * @param {(doc: any, i: number, total: number) => void} onProgress + * @param {(blob: Blob, count: number) => void} onSuccess + * @param {(reason: string) => void} onError + */ export(docs, onProgress, onSuccess, onError) { const chunks = [ `{"type":"${OfflineBackup.TYPE}","version":${ @@ -56,6 +79,11 @@ app.OfflineBackup = class OfflineBackup { next(); } + /** + * @param {any} doc + * @param {{ mtime: number, data: any }} result The doc's stored database. + * @returns {any} One entry of the backup's `docs` array. + */ serializeDoc(doc, result) { const entry = { slug: doc.slug, mtime: result.mtime, db: result.data }; const index = app.localStorage.get(doc.slug); @@ -67,6 +95,14 @@ app.OfflineBackup = class OfflineBackup { return entry; } + /** + * Reads a backup file and stores the docs it holds. + * + * @param {File | null} file + * @param {(doc: any, i: number, total: number) => void} onProgress + * @param {(summary: ImportSummary) => void} onSuccess + * @param {(reason: string, skipped?: string[]) => void} onError + */ import(file, onProgress, onSuccess, onError) { if (!file || (file.type && file.type !== OfflineBackup.MIME_TYPE)) { onError("invalid"); @@ -96,6 +132,14 @@ app.OfflineBackup = class OfflineBackup { reader.readAsText(file); } + /** + * Stores each valid entry, one at a time. + * + * @param {any[]} entries + * @param {(doc: any, i: number, total: number) => void} onProgress + * @param {(summary: ImportSummary) => void} onSuccess + * @param {(reason: string, skipped?: string[]) => void} onError + */ importDocs(entries, onProgress, onSuccess, onError) { const queue = []; const skipped = []; @@ -155,9 +199,14 @@ app.OfflineBackup = class OfflineBackup { next(); } - // Storing a doc clears whatever was installed before it, so an entry that - // isn't usable has to be rejected rather than wipe a working installation. - // The index page is what DB#checkForCorruptedDocs looks for. + /** + * Storing a doc clears whatever was installed before it, so an entry that + * isn't usable has to be rejected rather than wipe a working installation. + * The index page is what DB#checkForCorruptedDocs looks for. + * + * @param {any} entry + * @returns {boolean} + */ isValidEntry(entry) { return ( entry != null && @@ -170,6 +219,10 @@ app.OfflineBackup = class OfflineBackup { ); } + /** + * @param {any} index + * @returns {boolean} Whether the entry carries a usable index file. + */ isValidIndex(index) { return ( index?.constructor === Object && @@ -178,15 +231,24 @@ app.OfflineBackup = class OfflineBackup { ); } + /** + * @param {string} slug + * @returns {any} The doc, enabled or not, or `undefined`. + */ findDoc(slug) { return ( app.docs.findBy("slug", slug) || app.disabledDocs.findBy("slug", slug) ); } - // Enabling the docs up-front is what makes their object stores exist: the - // schema bump triggers DB#onUpgradeNeeded, which only creates stores for the - // enabled docs. Returns the number of docs that weren't enabled before. + /** + * Enabling the docs up-front is what makes their object stores exist: the + * schema bump triggers DB#onUpgradeNeeded, which only creates stores for the + * enabled docs. + * + * @param {any[]} docs + * @returns {number} How many docs weren't enabled before. + */ enableDocs(docs) { let enabled = 0; diff --git a/assets/javascripts/app/router.js b/assets/javascripts/app/router.js index 312ae1c305..9a6b9abac2 100644 --- a/assets/javascripts/app/router.js +++ b/assets/javascripts/app/router.js @@ -1,5 +1,12 @@ // @ts-check +/** + * Maps paths to route events. + * + * Each entry in `routes` names a method, which is registered with `page` in + * order. A handler either triggers its route event and returns nothing, or + * returns a path to redirect to, or calls `next` to fall through. + */ app.Router = class Router extends Events { static routes = [ ["*", "before"], @@ -23,19 +30,31 @@ app.Router = class Router extends Events { this.setInitialPath(); } + /** Begins routing, dispatching the current path. */ start() { page.start(); } + /** @param {string} path */ show(path) { page.show(path); } + /** + * Emits the route's event, then `after`. + * + * @param {string} name + */ triggerRoute(name) { this.trigger(name, this.context); this.trigger("after", name, this.context); } + /** + * @param {any} context + * @param {() => any} next + * @returns {any} A path to redirect to, or nothing when the route handled it. + */ before(context, next) { const previousContext = this.context; this.context = context; @@ -50,6 +69,11 @@ app.Router = class Router extends Events { } } + /** + * @param {any} context + * @param {() => any} next + * @returns {any} A path to redirect to, or nothing when the route handled it. + */ doc(context, next) { let doc; if ( @@ -66,6 +90,11 @@ app.Router = class Router extends Events { } } + /** + * @param {any} context + * @param {() => any} next + * @returns {any} A path to redirect to, or nothing when the route handled it. + */ type(context, next) { const doc = app.docs.findBySlug(context.params.doc); const type = doc?.types?.findBy("slug", context.params.type); @@ -80,6 +109,11 @@ app.Router = class Router extends Events { } } + /** + * @param {any} context + * @param {() => any} next + * @returns {any} A path to redirect to, or nothing when the route handled it. + */ entry(context, next) { const doc = app.docs.findBySlug(context.params.doc); if (!doc) { @@ -111,6 +145,7 @@ app.Router = class Router extends Events { return next(); } + /** @returns {string | undefined} */ root() { if (app.isSingleDoc()) { return "/"; @@ -118,6 +153,10 @@ app.Router = class Router extends Events { this.triggerRoute("root"); } + /** + * @param {any} context + * @returns {string | undefined} A redirect to the hash form when in single-doc mode. + */ settings(context) { if (app.isSingleDoc()) { return `/#/${context.path}`; @@ -125,6 +164,10 @@ app.Router = class Router extends Events { this.triggerRoute("settings"); } + /** + * @param {any} context + * @returns {string | undefined} A redirect to the hash form when in single-doc mode. + */ offline(context) { if (app.isSingleDoc()) { return `/#/${context.path}`; @@ -132,6 +175,10 @@ app.Router = class Router extends Events { this.triggerRoute("offline"); } + /** + * @param {any} context + * @returns {string | undefined} A redirect to the hash form when in single-doc mode. + */ about(context) { if (app.isSingleDoc()) { return `/#/${context.path}`; @@ -140,6 +187,10 @@ app.Router = class Router extends Events { this.triggerRoute("page"); } + /** + * @param {any} context + * @returns {string | undefined} A redirect to the hash form when in single-doc mode. + */ news(context) { if (app.isSingleDoc()) { return `/#/${context.path}`; @@ -148,6 +199,10 @@ app.Router = class Router extends Events { this.triggerRoute("page"); } + /** + * @param {any} context + * @returns {string | undefined} A redirect to the hash form when in single-doc mode. + */ help(context) { if (app.isSingleDoc()) { return `/#/${context.path}`; @@ -156,10 +211,12 @@ app.Router = class Router extends Events { this.triggerRoute("page"); } + /** @param {any} context */ notFound(context) { this.triggerRoute("notFound"); } + /** @returns {boolean} Whether the current page is the doc or app index. */ isIndex() { return ( this.context?.path === "/" || @@ -167,10 +224,15 @@ app.Router = class Router extends Events { ); } + /** @returns {boolean} */ isSettings() { return this.context?.path === "/settings"; } + /** + * Normalizes the path the document was loaded with, and follows the + * `#/path` form that single-doc mode redirects through. + */ setInitialPath() { // Remove superfluous forward slashes at the beginning of the path let path = location.pathname.replace(/^\/{2,}/g, "/"); @@ -185,12 +247,18 @@ app.Router = class Router extends Events { } } + /** @returns {string | undefined} The path encoded in the hash, if there is one. */ getInitialPathFromHash() { try { return new RegExp("#/(.+)").exec(decodeURIComponent(location.hash))?.[1]; } catch (error) {} } + /** + * Replaces the hash without dispatching a route. + * + * @param {string} [hash] Including the leading `#`. + */ replaceHash(hash) { page.replace( location.pathname + location.search + (hash || ""), diff --git a/assets/javascripts/app/searcher.js b/assets/javascripts/app/searcher.js index 7b383eab88..f2067fad04 100644 --- a/assets/javascripts/app/searcher.js +++ b/assets/javascripts/app/searcher.js @@ -147,6 +147,21 @@ function scoreFuzzyMatch() { // Searchers // +/** + * @typedef {object} SearcherOptions + * @property {number} [max_results] + * @property {number} [fuzzy_min_length] Shortest query that is also matched fuzzily. + */ + +/** + * Scores every candidate against a query and emits the best matches. + * + * The work is spread over chunks with a timeout between them so that typing + * stays responsive, and results are emitted as they are found: `results` may + * fire several times before `end`. The match functions above run against + * module-level state rather than arguments, which is what keeps the inner + * loop cheap. + */ app.Searcher = class Searcher extends Events { static CHUNK_SIZE = 20000; @@ -168,6 +183,13 @@ app.Searcher = class Searcher extends Events { static ELLIPSIS = "..."; static STRING = "string"; + /** + * Reduces a string to the form matches are made against: lowercased, with + * separators collapsed to dots and decoration stripped. + * + * @param {string} string + * @returns {string} + */ static normalizeString(string) { return string .toLowerCase() @@ -180,16 +202,30 @@ app.Searcher = class Searcher extends Events { .replace(Searcher.WHITESPACE_REGEXP, Searcher.EMPTY_STRING); } + /** + * Like `normalizeString`, but keeps a trailing separator meaningful. + * + * @param {string} string + * @returns {string} + */ static normalizeQuery(string) { string = this.normalizeString(string); return string.replace(Searcher.EOS_SEPARATORS_REGEXP, "$1."); } + /** @param {SearcherOptions} [options] */ constructor(options) { super(); this.options = { ...Searcher.DEFAULTS, ...(options || {}) }; } + /** + * Starts a search, abandoning whatever was running. + * + * @param {any[]} data The objects to search. + * @param {string} attr The attribute to match against; a string or an array of them. + * @param {string} q + */ find(data, attr, q) { this.kill(); @@ -205,6 +241,7 @@ app.Searcher = class Searcher extends Events { } } + /** Prepares the module-level state the match functions read. */ setup() { query = this.query = /** @type {any} */ (this.constructor).normalizeQuery(this.query); queryLength = query.length; @@ -214,6 +251,7 @@ app.Searcher = class Searcher extends Events { this.setupFuzzy(); } + /** Adds the fuzzy matcher, for queries long enough to warrant it. */ setupFuzzy() { if (queryLength >= this.options.fuzzy_min_length) { fuzzyRegexp = this.queryToFuzzyRegexp(query); @@ -223,10 +261,12 @@ app.Searcher = class Searcher extends Events { } } + /** @returns {boolean} Whether the query is worth running. */ isValid() { return queryLength > 0 && query !== SEPARATOR; } + /** Emits a final empty result set if nothing matched, then `end`. */ end() { if (!this.totalResults) { this.triggerResults([]); @@ -235,6 +275,7 @@ app.Searcher = class Searcher extends Events { this.free(); } + /** Abandons a search in progress. */ kill() { if (this.timeout) { clearTimeout(this.timeout); @@ -242,6 +283,7 @@ app.Searcher = class Searcher extends Events { } } + /** Drops the references the search held, so the data can be collected. */ free() { this.data = null; this.attr = null; @@ -255,6 +297,7 @@ app.Searcher = class Searcher extends Events { this.timeout = null; } + /** Runs the next matcher over the data, or ends the search. */ match() { if (!this.foundEnough() && (this.matcher = this.matchers.shift())) { this.setupMatcher(); @@ -264,11 +307,13 @@ app.Searcher = class Searcher extends Events { } } + /** Resets the per-matcher state: the cursor and the score buckets. */ setupMatcher() { this.cursor = 0; this.scoreMap = new Array(101); } + /** Runs one chunk, then either schedules the next or moves to the next matcher. */ matchChunks() { this.matchChunk(); @@ -280,6 +325,7 @@ app.Searcher = class Searcher extends Events { } } + /** Scores `chunkSize()` candidates, advancing the cursor. */ matchChunk() { ({ matcher } = this); for (let j = 0, end = this.chunkSize(); j < end; j++) { @@ -305,6 +351,7 @@ app.Searcher = class Searcher extends Events { } } + /** @returns {number} How many candidates are left in this chunk. */ chunkSize() { if (this.cursor + Searcher.CHUNK_SIZE > this.dataLength) { return this.dataLength % Searcher.CHUNK_SIZE; @@ -313,14 +360,22 @@ app.Searcher = class Searcher extends Events { } } + /** @returns {boolean} Whether enough perfect matches were found to stop early. */ scoredEnough() { return this.scoreMap[100]?.length >= this.options.max_results; } + /** @returns {boolean} Whether enough matches were found overall. */ foundEnough() { return this.totalResults >= this.options.max_results; } + /** + * Files a match under its rounded score. + * + * @param {any} object + * @param {number} score + */ addResult(object, score) { let name; ( @@ -329,6 +384,7 @@ app.Searcher = class Searcher extends Events { this.totalResults++; } + /** @returns {any[]} The best matches so far, highest score first. */ getResults() { const results = []; for (let j = this.scoreMap.length - 1; j >= 0; j--) { @@ -340,6 +396,7 @@ app.Searcher = class Searcher extends Events { return results.slice(0, this.options.max_results); } + /** Emits the matches found so far, if there are any. */ sendResults() { const results = this.getResults(); if (results.length) { @@ -347,14 +404,24 @@ app.Searcher = class Searcher extends Events { } } + /** @param {any[]} results */ triggerResults(results) { this.trigger("results", results); } + /** + * Yields to the event loop between chunks. + * + * @param {() => void} fn + */ delay(fn) { return (this.timeout = setTimeout(fn, 1)); } + /** + * @param {string} string + * @returns {RegExp} A regexp matching the characters in order, e.g. `abc` to `/a.*?b.*?c/`. + */ queryToFuzzyRegexp(string) { const chars = string.split(""); for (i = 0; i < chars.length; i++) { @@ -365,7 +432,12 @@ app.Searcher = class Searcher extends Events { } }; +/** + * 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. + */ app.SynchronousSearcher = class SynchronousSearcher extends app.Searcher { + /** Collects each matcher's results, instead of emitting them as it goes. */ match() { if (this.matcher) { if (!this.allResults) { @@ -376,22 +448,30 @@ app.SynchronousSearcher = class SynchronousSearcher extends app.Searcher { return super.match(...arguments); } + /** @inheritdoc */ free() { this.allResults = null; return super.free(...arguments); } + /** Emits every result collected, then ends. */ end() { this.sendResults(true); return super.end(...arguments); } + /** @param {boolean} [end] Results are only emitted once, at the end. */ sendResults(end) { if (end && this.allResults?.length) { return this.triggerResults(this.allResults); } } + /** + * Runs `fn` straight away, so the search never yields. + * + * @param {() => void} fn + */ delay(fn) { return fn(); } diff --git a/assets/javascripts/app/serviceworker.js b/assets/javascripts/app/serviceworker.js index 6406ade803..346fb73ffc 100644 --- a/assets/javascripts/app/serviceworker.js +++ b/assets/javascripts/app/serviceworker.js @@ -1,6 +1,13 @@ // @ts-check +/** + * Registers the service worker and reports when a new one is waiting. + * + * Emits `updateready` when an update is ready to take over, but only for + * checks the user asked for. + */ app.ServiceWorker = class ServiceWorker 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; } @@ -19,6 +26,11 @@ app.ServiceWorker = class ServiceWorker extends Events { ); } + /** + * Checks for a new worker, notifying the user if one is ready. + * + * @returns {Promise | undefined} + */ update() { if (!this.registration) { return; @@ -27,6 +39,11 @@ app.ServiceWorker = class ServiceWorker extends Events { return this.registration.update().catch(() => {}); } + /** + * Checks for a new worker without notifying the user. + * + * @returns {Promise | undefined} + */ updateInBackground() { if (!this.registration) { return; @@ -35,15 +52,18 @@ app.ServiceWorker = class ServiceWorker extends Events { return this.registration.update().catch(() => {}); } + /** @returns {Promise} Resolves once the app has been rebooted onto the new worker. */ reload() { return this.updateInBackground().then(() => app.reboot()); } + /** @param {ServiceWorkerRegistration} registration */ updateRegistration(registration) { this.registration = registration; $.on(this.registration, "updatefound", () => this.onUpdateFound()); } + /** Watches the worker being installed, so that its readiness can be reported. */ onUpdateFound() { if (this.installingRegistration) { $.off(this.installingRegistration, "statechange", this.onStateChange); @@ -52,6 +72,7 @@ app.ServiceWorker = class ServiceWorker extends Events { $.on(this.installingRegistration, "statechange", this.onStateChange); } + /** Reports readiness once the new worker is installed and one is already in control. */ onStateChange() { if ( this.installingRegistration && @@ -63,6 +84,7 @@ app.ServiceWorker = class ServiceWorker extends Events { } } + /** Emits `updateready`, unless the check was a background one. */ onUpdateReady() { if (this.notifyUpdate) { this.trigger("updateready"); diff --git a/assets/javascripts/app/settings.js b/assets/javascripts/app/settings.js index 3523ee3468..337bba248d 100644 --- a/assets/javascripts/app/settings.js +++ b/assets/javascripts/app/settings.js @@ -1,5 +1,11 @@ // @ts-check +/** + * The user's preferences, stored in cookies so that the server can read them. + * + * `PREFERENCE_KEYS` are the ones the user controls and that a backup carries; + * `INTERNAL_KEYS` are the app's own bookkeeping and stay out of backups. + */ app.Settings = class Settings { static PREFERENCE_KEYS = [ "hideDisabled", @@ -31,6 +37,7 @@ app.Settings = class Settings { "_text-justify-hyphenate", ]; + /** @type {Record} */ static defaults = { count: 0, hideDisabled: false, @@ -57,6 +64,12 @@ app.Settings = class Settings { } } + /** + * Reads a setting, falling back to its default. Cached after the first read. + * + * @param {string} key + * @returns {any} + */ get(key) { let left; if (this.cache.hasOwnProperty(key)) { @@ -73,25 +86,32 @@ app.Settings = class Settings { } } + /** + * @param {string} key + * @param {string | number | boolean} value + */ set(key, value) { this.store.set(key, value); delete this.cache[key]; if (key === "theme") { - this.setTheme(value); + this.setTheme(/** @type {string} */ (value)); } } + /** @param {string} key */ del(key) { this.store.del(key); delete this.cache[key]; } + /** @returns {boolean | undefined} Whether the user has ever chosen a set of docs. */ hasDocs() { try { return !!this.store.get("docs"); } catch (error) {} } + /** @returns {string[]} The enabled doc slugs, or the defaults. */ getDocs() { return ( /** @type {string | undefined} */ (this.store.get("docs"))?.split("/") || @@ -99,18 +119,27 @@ app.Settings = class Settings { ); } + /** @param {string[]} docs */ setDocs(docs) { this.set("docs", docs.join("/")); } + /** @returns {string[]} The tips the user has already been shown. */ getTips() { return /** @type {string | undefined} */ (this.store.get("tips"))?.split("/") || []; } + /** @param {string[]} tips */ setTips(tips) { this.set("tips", tips.join("/")); } + /** + * Applies a layout class and remembers it. + * + * @param {string} name One of `LAYOUTS`. + * @param {boolean} enable + */ setLayout(name, enable) { this.toggleLayout(name, enable); @@ -134,6 +163,10 @@ app.Settings = class Settings { } } + /** + * @param {string} name + * @returns {boolean} + */ hasLayout(name) { const layout = /** @type {string} */ ( this.store.get("layout") || "" @@ -141,14 +174,17 @@ app.Settings = class Settings { return layout.includes(name); } + /** @param {number} value The sidebar width, in pixels. */ setSize(value) { this.set("size", value); } + /** @returns {Record} Every stored setting, unparsed. */ dump() { return this.store.dump(); } + /** @returns {Record} The user's preferences, without the app's own bookkeeping. */ export() { const data = this.dump(); for (var key of Settings.INTERNAL_KEYS) { @@ -157,6 +193,11 @@ app.Settings = class Settings { return data; } + /** + * Replaces the user's preferences with `data`, dropping any it omits. + * + * @param {Record} data + */ import(data) { let key, value; const object = this.export(); @@ -174,11 +215,13 @@ app.Settings = class Settings { } } + /** Clears every setting. */ reset() { this.store.reset(); this.cache = {}; } + /** Applies the stored theme and layout to the document. Runs before the first paint. */ initLayout() { if (this.get("dark") === 1) { this.set("theme", "dark"); @@ -191,6 +234,7 @@ app.Settings = class Settings { this.initSidebarWidth(); } + /** @param {string} theme `"auto"`, `"dark"` or `"default"`. */ setTheme(theme) { if (theme === "auto") { theme = this.darkModeQuery.matches ? "dark" : "default"; @@ -201,6 +245,7 @@ app.Settings = class Settings { this.updateColorMeta(); } + /** Points the `theme-color` meta at the header colour of the current theme. */ updateColorMeta() { const color = getComputedStyle(document.documentElement) .getPropertyValue("--headerBackground") @@ -208,6 +253,10 @@ app.Settings = class Settings { $("meta[name=theme-color]").setAttribute("content", color); } + /** + * @param {string} layout + * @param {boolean} enable + */ toggleLayout(layout, enable) { const { classList } = document.body; // sidebar is always shown for settings; its state is updated in app.views.Settings @@ -217,6 +266,7 @@ app.Settings = class Settings { classList.toggle("_overlay-scrollbars", $.overlayScrollbarsEnabled()); } + /** Applies the stored sidebar width. */ initSidebarWidth() { const size = this.get("size"); if (size) { diff --git a/assets/javascripts/app/shortcuts.js b/assets/javascripts/app/shortcuts.js index 9ffbe3191e..7819df96f3 100644 --- a/assets/javascripts/app/shortcuts.js +++ b/assets/javascripts/app/shortcuts.js @@ -1,5 +1,17 @@ // @ts-check +/** + * A key event whose target is read loosely: the handlers check for form-field + * properties that only some elements have. + * + * @typedef {KeyboardEvent & { target: any }} ShortcutEvent + */ + +/** + * Translates key events into shortcut events. + * + * Handlers return `false` to swallow the event; anything else lets it through. + */ app.Shortcuts = class Shortcuts extends Events { constructor() { super(); @@ -9,33 +21,40 @@ app.Shortcuts = class Shortcuts extends Events { this.start(); } + /** Begins listening for key events. */ start() { $.on(document, "keydown", this.onKeydown); $.on(document, "keypress", this.onKeypress); } + /** Stops listening for key events. */ stop() { $.off(document, "keydown", this.onKeydown); $.off(document, "keypress", this.onKeypress); } + /** @returns {any} Whether the arrow keys scroll rather than move the selection. */ swapArrowKeysBehavior() { return app.settings.get("arrowScroll"); } + /** @returns {any} How far space scrolls, as a fraction of the viewport. */ spaceScroll() { return app.settings.get("spaceScroll"); } + /** Shows the key-navigation tip, once. */ showTip() { app.showTip("KeyNav"); return (this.showTip = null); } + /** @returns {any} How long after typing space stops scrolling, in seconds. */ spaceTimeout() { return app.settings.get("spaceTimeout"); } + /** @param {ShortcutEvent} event */ onKeydown(event) { if (this.buggyEvent(event)) { return; @@ -61,6 +80,7 @@ app.Shortcuts = class Shortcuts extends Events { } } + /** @param {ShortcutEvent} event */ onKeypress(event) { if ( this.buggyEvent(event) || @@ -76,6 +96,11 @@ app.Shortcuts = class Shortcuts extends Events { } } + /** + * @param {ShortcutEvent} event + * @param {boolean} [_force] + * @returns {any} `false` to swallow the event; anything else lets it through. + */ handleKeydownEvent(event, _force) { if ( !_force && @@ -161,6 +186,12 @@ app.Shortcuts = class Shortcuts extends Events { } } + /** + * Handles Ctrl/Cmd chords. + * + * @param {ShortcutEvent} event + * @returns {any} `false` to swallow the event; anything else lets it through. + */ handleKeydownSuperEvent(event) { switch (event.which) { case 13: @@ -189,6 +220,11 @@ app.Shortcuts = class Shortcuts extends Events { } } + /** + * @param {ShortcutEvent} event + * @param {boolean} [_force] + * @returns {any} `false` to swallow the event; anything else lets it through. + */ handleKeydownShiftEvent(event, _force) { if ( !_force && @@ -222,6 +258,11 @@ app.Shortcuts = class Shortcuts extends Events { } } + /** + * @param {ShortcutEvent} event + * @param {boolean} [_force] + * @returns {any} `false` to swallow the event; anything else lets it through. + */ handleKeydownAltEvent(event, _force) { if ( !_force && @@ -275,6 +316,10 @@ app.Shortcuts = class Shortcuts extends Events { } } + /** + * @param {ShortcutEvent} event + * @returns {any} `false` to swallow the event; anything else lets it through. + */ handleKeypressEvent(event) { if (event.which === 63 && !event.target.value) { this.trigger("help"); @@ -284,6 +329,10 @@ app.Shortcuts = class Shortcuts extends Events { } } + /** + * @param {ShortcutEvent} event + * @returns {boolean} Whether the event is one the browser reports incorrectly. + */ buggyEvent(event) { try { event.target; diff --git a/assets/javascripts/app/update_checker.js b/assets/javascripts/app/update_checker.js index 61a7d56d97..1e850bb294 100644 --- a/assets/javascripts/app/update_checker.js +++ b/assets/javascripts/app/update_checker.js @@ -1,5 +1,6 @@ // @ts-check +/** Watches for new builds of the app and new versions of the installed docs. */ app.UpdateChecker = class UpdateChecker { constructor() { this.lastCheck = Date.now(); @@ -12,6 +13,10 @@ app.UpdateChecker = class UpdateChecker { setTimeout(() => this.checkDocs(), 0); } + /** + * Checks whether a new build of the app is available, by asking the service + * worker to update or, without one, by re-requesting the app bundle. + */ check() { if (app.serviceWorker) { app.serviceWorker.update(); @@ -28,10 +33,12 @@ app.UpdateChecker = class UpdateChecker { } } + /** Offers the user a reload. */ onUpdateReady() { new app.views.Notif("UpdateReady", { autoHide: null }); } + /** Updates the installed docs, or offers to when updates are manual. */ checkDocs() { if (!app.settings.get("manualUpdate")) { app.docs.updateInBackground(); @@ -44,10 +51,12 @@ app.UpdateChecker = class UpdateChecker { } } + /** Offers the user a doc update. */ onDocsUpdateReady() { new app.views.Notif("UpdateDocs", { autoHide: null }); } + /** Re-checks when the tab is focused, at most every six hours. */ onFocus() { if (Date.now() - this.lastCheck > 21600e3) { this.lastCheck = Date.now(); From a58fdb37690f52483be4642fcec9e191863d425d Mon Sep 17 00:00:00 2001 From: Simon Legner Date: Mon, 14 Sep 2026 10:21:31 +0200 Subject: [PATCH 09/34] Type the view base class and check the views Views read their configuration off `this.constructor`, which is typed as a bare Function; route it through a `statics()` accessor instead. Two fixes the typecheck turned up: - Sidebar passed `(newDoc, previousDoc)` to onScopeChange, so the comma operator dropped newDoc and the handler revealed the doc being left instead of the one being entered. - View#findLastByClass indexed the first match rather than the list, so it always returned undefined. It has no callers. --- assets/javascripts/app/app.js | 8 + assets/javascripts/application.js | 2 + assets/javascripts/debug.js | 2 + assets/javascripts/globals.d.ts | 14 ++ assets/javascripts/templates/base.js | 2 + assets/javascripts/templates/error_tmpl.js | 2 + assets/javascripts/templates/notice_tmpl.js | 2 + assets/javascripts/templates/notif_tmpl.js | 2 + .../javascripts/templates/pages/about_tmpl.js | 2 + .../javascripts/templates/pages/help_tmpl.js | 2 + .../templates/pages/offline_tmpl.js | 2 + .../templates/pages/settings_tmpl.js | 2 + .../javascripts/templates/pages/type_tmpl.js | 2 + assets/javascripts/templates/path_tmpl.js | 2 + assets/javascripts/templates/sidebar_tmpl.js | 2 + assets/javascripts/templates/tip_tmpl.js | 2 + assets/javascripts/tracking.js | 6 +- assets/javascripts/views/content/content.js | 8 +- .../javascripts/views/content/entry_page.js | 4 +- .../javascripts/views/content/offline_page.js | 2 + assets/javascripts/views/content/root_page.js | 2 + .../views/content/settings_page.js | 4 +- .../javascripts/views/content/static_page.js | 4 +- assets/javascripts/views/content/type_page.js | 2 + assets/javascripts/views/layout/document.js | 2 + assets/javascripts/views/layout/menu.js | 8 +- assets/javascripts/views/layout/mobile.js | 2 + assets/javascripts/views/layout/path.js | 2 + assets/javascripts/views/layout/resizer.js | 2 + assets/javascripts/views/layout/settings.js | 2 + assets/javascripts/views/list/list_focus.js | 10 +- assets/javascripts/views/list/list_fold.js | 24 +-- assets/javascripts/views/list/list_select.js | 8 +- .../javascripts/views/list/paginated_list.js | 12 +- assets/javascripts/views/misc/news.js | 2 + assets/javascripts/views/misc/notice.js | 2 + assets/javascripts/views/misc/notif.js | 6 +- assets/javascripts/views/misc/tip.js | 2 + assets/javascripts/views/misc/updates.js | 2 + assets/javascripts/views/pages/base.js | 4 +- assets/javascripts/views/pages/hidden.js | 2 + assets/javascripts/views/pages/jquery.js | 2 + assets/javascripts/views/pages/rdoc.js | 2 + assets/javascripts/views/pages/sqlite.js | 2 + .../javascripts/views/pages/support_tables.js | 2 + assets/javascripts/views/search/search.js | 6 +- .../javascripts/views/search/search_scope.js | 2 + assets/javascripts/views/sidebar/doc_list.js | 2 + .../javascripts/views/sidebar/doc_picker.js | 2 + .../javascripts/views/sidebar/entry_list.js | 2 + assets/javascripts/views/sidebar/results.js | 2 + assets/javascripts/views/sidebar/sidebar.js | 4 +- .../views/sidebar/sidebar_hover.js | 4 +- assets/javascripts/views/sidebar/type_list.js | 2 + assets/javascripts/views/view.js | 143 ++++++++++++------ 55 files changed, 265 insertions(+), 86 deletions(-) diff --git a/assets/javascripts/app/app.js b/assets/javascripts/app/app.js index 9c54ee86d6..1ef4b86f0b 100644 --- a/assets/javascripts/app/app.js +++ b/assets/javascripts/app/app.js @@ -86,6 +86,14 @@ class App extends Events { /** @type {any} */ Model; /** @type {any} */ 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. * diff --git a/assets/javascripts/application.js b/assets/javascripts/application.js index dd5aaec99b..954c8131ef 100644 --- a/assets/javascripts/application.js +++ b/assets/javascripts/application.js @@ -1,3 +1,5 @@ +// @ts-check + //= require_tree ./vendor //= require lib/license diff --git a/assets/javascripts/debug.js b/assets/javascripts/debug.js index 8fcba75b45..6a0e11b8bf 100644 --- a/assets/javascripts/debug.js +++ b/assets/javascripts/debug.js @@ -1,3 +1,5 @@ +// @ts-check + // // App // diff --git a/assets/javascripts/globals.d.ts b/assets/javascripts/globals.d.ts index 5707b7d046..b3acb3209d 100644 --- a/assets/javascripts/globals.d.ts +++ b/assets/javascripts/globals.d.ts @@ -71,11 +71,25 @@ declare const Prism: { highlightElement(element: Element, async?: boolean): void; }; +// --- Analytics, loaded at runtime by tracking.js --- + +/** Google Analytics, once analytics.js has loaded. */ +declare var ga: (...args: any[]) => void; + +/** Gauges' command queue. */ +declare var _gauges: any[] | 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?: any[]; } interface Navigator { diff --git a/assets/javascripts/templates/base.js b/assets/javascripts/templates/base.js index fc445ef19c..07699132f1 100644 --- a/assets/javascripts/templates/base.js +++ b/assets/javascripts/templates/base.js @@ -1,3 +1,5 @@ +// @ts-check + app.templates.render = function (name, value, ...args) { const template = app.templates[name]; diff --git a/assets/javascripts/templates/error_tmpl.js b/assets/javascripts/templates/error_tmpl.js index 7f96247382..28e6922a62 100644 --- a/assets/javascripts/templates/error_tmpl.js +++ b/assets/javascripts/templates/error_tmpl.js @@ -1,3 +1,5 @@ +// @ts-check + const error = function (title, text, links) { if (text == null) { text = ""; diff --git a/assets/javascripts/templates/notice_tmpl.js b/assets/javascripts/templates/notice_tmpl.js index 26c8c947b1..fa868efff9 100644 --- a/assets/javascripts/templates/notice_tmpl.js +++ b/assets/javascripts/templates/notice_tmpl.js @@ -1,3 +1,5 @@ +// @ts-check + const notice = (text) => `

${text}

`; app.templates.singleDocNotice = (doc) => diff --git a/assets/javascripts/templates/notif_tmpl.js b/assets/javascripts/templates/notif_tmpl.js index f24c3c11a0..4efc33a265 100644 --- a/assets/javascripts/templates/notif_tmpl.js +++ b/assets/javascripts/templates/notif_tmpl.js @@ -1,3 +1,5 @@ +// @ts-check + const notif = function (title, html) { html = html.replace(/
${title} diff --git a/assets/javascripts/templates/pages/about_tmpl.js b/assets/javascripts/templates/pages/about_tmpl.js index 600169a5ac..8b785dbd8c 100644 --- a/assets/javascripts/templates/pages/about_tmpl.js +++ b/assets/javascripts/templates/pages/about_tmpl.js @@ -1,3 +1,5 @@ +// @ts-check + app.templates.aboutPage = function () { let doc; const all_docs = app.docs.all().concat(...(app.disabledDocs.all() || [])); diff --git a/assets/javascripts/templates/pages/help_tmpl.js b/assets/javascripts/templates/pages/help_tmpl.js index e155d82999..849898a9f9 100644 --- a/assets/javascripts/templates/pages/help_tmpl.js +++ b/assets/javascripts/templates/pages/help_tmpl.js @@ -1,3 +1,5 @@ +// @ts-check + app.templates.helpPage = function () { const ctrlKey = $.isMac() ? "cmd" : "ctrl"; const navKey = $.isMac() ? "cmd" : "alt"; diff --git a/assets/javascripts/templates/pages/offline_tmpl.js b/assets/javascripts/templates/pages/offline_tmpl.js index 2f6f9e7a2d..849fdd4016 100644 --- a/assets/javascripts/templates/pages/offline_tmpl.js +++ b/assets/javascripts/templates/pages/offline_tmpl.js @@ -1,3 +1,5 @@ +// @ts-check + app.templates.offlinePage = (docs, hasPersistence, isPersistent) => `\

Offline Documentation

diff --git a/assets/javascripts/templates/pages/settings_tmpl.js b/assets/javascripts/templates/pages/settings_tmpl.js index f0f84bfa19..e7ff8ab488 100644 --- a/assets/javascripts/templates/pages/settings_tmpl.js +++ b/assets/javascripts/templates/pages/settings_tmpl.js @@ -1,3 +1,5 @@ +// @ts-check + const themeOption = ({ label, value }, settings) => `\