PageSourceSearch

https://spec-zone.ru/assets/app/app.js

js spec-zone.ru collected 2026-10-05 04:51:28 UTC 16,333 bytes, 587 lines download raw bytes

1// @ts-check
2
3import { config } from "./config.js";
4import { DB } from "./db.js";
5import { Router } from "./router.js";
6import { AppServiceWorker } from "./serviceworker.js";
7import { Settings } from "./settings.js";
8import { Shortcuts } from "./shortcuts.js";
9import { UpdateChecker } from "./update_checker.js";
10import { Docs } from "../collections/docs.js";
11import { Entries } from "../collections/entries.js";
12import { SettingsStore } from "../lib/settings_store.js";
13import { Events } from "../lib/events.js";
14import { LocalStorageStore } from "../lib/local_storage_store.js";
15import { $ } from "../lib/util.js";
16import { Doc } from "../models/doc.js";
17import { unsupportedBrowser } from "../templates/error_tmpl.js";
18import { AppDocument } from "../views/layout/document.js";
19import { Mobile } from "../views/layout/mobile.js";
20import { News } from "../views/misc/news.js";
21import { Notice } from "../views/misc/notice.js";
22import { Notif } from "../views/misc/notif.js";
23import { Tip } from "../views/misc/tip.js";
24import { Updates } from "../views/misc/updates.js";
25
26/**
27 * A doc as it appears in the manifest, before it becomes a `Doc`.
28 *
29 * @typedef {Record<string, unknown>} DocData
30 */
31
32/**
33 * The application singleton: the live state the rest of the app reads off, and
34 * the boot sequence that builds it. Classes are imported where they are used
35 * rather than registered here.
36 */
37export class App extends Events {
38  /**
39   * The manifest of every available doc, set by docs.js.erb. Deleted once the
40   * docs have been read into the collections.
41   *
42   * @type {DocData[] | undefined}
43   */
44  DOCS;
45
46  /**
47   * In single-doc mode, the one doc being shown, read off the body. Deleted
48   * once it has been read.
49   *
50   * @type {DocData | undefined}
51   */
52  DOC;
53
54  /**
55   * The `window.onerror` handler that was installed before ours, if any.
56   *
57   * @type {unknown}
58   */
59  previousErrorHandler;
60
61  /**
62   * The stores and mode of the most recent IndexedDB transaction, tracked by
63   * app/db.js so that a hung transaction can be reported.
64   *
65   * @type {[string | string[], IDBTransactionMode] | undefined}
66   */
67  lastIDBTransaction;
68
69  /** Wires up the app and boots it. Called once the document is ready. */
70  init() {
71    try {
72      this.initErrorTracking();
73    } catch (error) {}
74    if (!this.browserCheck()) {
75      return;
76    }
77
78    this.el = $("._app");
79    this.localStorage = new LocalStorageStore();
80    if (AppServiceWorker.isEnabled()) {
81      this.serviceWorker = new AppServiceWorker();
82    }
83    this.settings = new Settings();
84    this.db = new DB();
85
86    this.settings.initLayout();
87
88    this.docs = new Docs();
89    this.disabledDocs = new Docs();
90    this.entries = new Entries();
91
92    this.router = new Router();
93    this.shortcuts = new Shortcuts();
94    this.document = new AppDocument();
95    if (this.isMobile()) {
96      this.mobile = new Mobile();
97    }
98
99    if (document.body.hasAttribute("data-doc")) {
100      this.DOC = JSON.parse(document.body.getAttribute("data-doc"));
101      this.bootOne();
102    } else if (this.DOCS) {
103      this.bootAll();
104    } else {
105      this.onBootError();
106    }
107  }
108
109  /**
110   * @returns {boolean} Whether to carry on booting. Replaces the page with a
111   *   warning when the browser is too old.
112   */
113  browserCheck() {
114    if (this.isSupportedBrowser()) {
115      return true;
116    }
117    document.body.innerHTML = /** @type {string} */ (
118      unsupportedBrowser
119    );
120    this.hideLoadingScreen();
121    return false;
122  }
123
124  /** Wires up Sentry and the global error handlers. */
125  initErrorTracking() {
126    // Show a warning message and don't track errors when the app is loaded
127    // from a domain other than our own, because things are likely to break.
128    // (e.g. cross-domain requests)
129    if (this.isInvalidLocation()) {
130      new Notif("InvalidLocation");
131    } else {
132      if (config.sentry_dsn) {
133        Raven.config(config.sentry_dsn, {
134          release: config.release,
135          whitelistUrls: [/devdocs/],
136          includePaths: [/devdocs/],
137          ignoreErrors: [/NPObject/, /NS_ERROR/, /^null$/, /EvalError/],
138          tags: {
139            mode: this.isSingleDoc() ? "single" : "full",
140            iframe: (window.top !== window).toString(),
141            electron: (!!window.process?.versions?.electron).toString(),
142          },
143          shouldSendCallback: () => {
144            try {
145              if (this.isAndroidWebview()) {
146                return false;
147              }
148            } catch (error) {}
149            return true;
150          },
151          dataCallback(data) {
152            try {
153              data.user ||= {};
154              Object.assign(data.user, app.settings.dump());
155              if (data.user.docs) {
156                data.user.docs = data.user.docs.split("/");
157              }
158              if (app.lastIDBTransaction) {
159                data.user.lastIDBTransaction = app.lastIDBTransaction;
160              }
161              data.tags.scriptCount = document.scripts.length;
162            } catch (error) {}
163            return data;
164          },
165        }).install();
166      }
167      this.previousErrorHandler = onerror;
168      window.onerror = this.onWindowError.bind(this);
169      SettingsStore.onBlocked = this.onStorageBlocked;
170    }
171  }
172
173  /** Boots in single-doc mode, with only the doc named on the body. */
174  bootOne() {
175    this.doc = new Doc(this.DOC);
176    this.docs.reset([this.doc]);
177    this.doc.load(this.start.bind(this), this.onBootError.bind(this), {
178      readCache: true,
179    });
180    new Notice("singleDoc", this.doc);
181    delete this.DOC;
182  }
183
184  /** Boots with every doc in the manifest, enabled or not. */
185  async bootAll() {
186    const docs = this.settings.getDocs();
187    for (var doc of this.DOCS) {
188      (docs.includes(/** @type {string} */ (doc.slug)) ? this.docs : this.disabledDocs).add(doc);
189    }
190    delete this.DOCS;
191    this.migrateDocs();
192    await this.migrateToLatestVersions();
193    this.docs.load(this.start.bind(this), this.onBootError.bind(this), {
194      readCache: true,
195      writeCache: true,
196    });
197  }
198
199  /** Builds the search index from the loaded docs and starts routing. */
200  start() {
201    let doc;
202    for (doc of this.docs.all()) {
203      this.entries.add(doc.toEntry());
204    }
205    for (doc of this.disabledDocs.all()) {
206      this.entries.add(doc.toEntry());
207    }
208    for (doc of this.docs.all()) {
209      this.initDoc(doc);
210    }
211    this.trigger("ready");
212    this.router.start();
213    this.hideLoadingScreen();
214    setTimeout(() => {
215      if (!this.doc) {
216        this.welcomeBack();
217      }
218      return this.removeEvent("ready bootError");
219    }, 50);
220  }
221
222  /**
223   * Adds a doc's types and entries to the search index.
224   *
225   * @param {Doc} doc
226   */
227  initDoc(doc) {
228    for (var type of doc.types.all()) {
229      doc.entries.add(type.toEntry());
230    }
231    this.entries.add(doc.entries.all());
232  }
233
234  /** Re-points enabled slugs that have since been renamed or reorganized. */
235  migrateDocs() {
236    let needsSaving;
237    for (var slug of this.settings.getDocs()) {
238      if (!this.docs.findBy("slug", slug)) {
239        var doc;
240
241        needsSaving = true;
242        if (slug === "webpack~2") {
243          doc = this.disabledDocs.findBy("slug", "webpack");
244        }
245        if (slug === "angular~4_typescript") {
246          doc = this.disabledDocs.findBy("slug", "angular");
247        }
248        if (slug === "angular~2_typescript") {
249          doc = this.disabledDocs.findBy("slug", "angular~2");
250        }
251        if (!doc) {
252          doc = this.disabledDocs.findBy("slug_without_version", slug);
253        }
254        if (doc) {
255          this.disabledDocs.remove(doc);
256          this.docs.add(doc);
257        }
258      }
259    }
260
261    if (needsSaving) {
262      this.saveDocs();
263    }
264  }
265
266  /**
267   * With the "latest version" preference enabled, replace the enabled docs for
268   * which a newer version is available with that version.
269   */
270  async migrateToLatestVersions() {
271    if (!this.settings.get("autoLatestVersion")) {
272      return;
273    }
274
275    const allDocs = this.docs.all().concat(this.disabledDocs.all());
276    // The same version can supersede several enabled docs, so it's only loaded
277    // once, e.g. when both CMake 3.9 and CMake 3.10 are enabled.
278    const migrations = new Map();
279
280    for (const outdated of this.docs.all()) {
281      const latest = outdated.findLatestVersion(allDocs);
282      if (latest === outdated) {
283        continue;
284      }
285      if (!migrations.has(latest)) {
286        migrations.set(latest, []);
287      }
288      migrations.get(latest).push(outdated);
289    }
290
291    const loaded = await this.loadLatestVersions([...migrations.keys()]);
292    let needsSaving;
293
294    for (const [latest, outdatedDocs] of migrations) {
295      if (!loaded.has(latest)) {
296        continue;
297      }
298      for (const outdated of outdatedDocs) {
299        this.docs.remove(outdated);
300        this.disabledDocs.add(outdated);
301      }
302      if (!this.docs.contains(latest)) {
303        this.disabledDocs.remove(latest);
304        this.docs.add(latest);
305      }
306      needsSaving = true;
307    }
308
309    if (needsSaving) {
310      this.docs.sort();
311      this.saveDocs();
312    }
313  }
314
315  /**
316   * Saving drops the offline data of the docs that are disabled, so the index
317   * of their latest version has to load before they are replaced. Loads no
318   * more docs at once than Docs#load does.
319   *
320   * @param {Doc[]} docs
321   * @returns {Promise<Set<unknown>>} The docs whose index loaded.
322   */
323  async loadLatestVersions(docs) {
324    const loaded = new Set();
325    let i = 0;
326
327    const next = async () => {
328      while (i < docs.length) {
329        const doc = docs[i++];
330        const success = await new Promise((resolve) =>
331          doc.load(
332            () => resolve(true),
333            () => resolve(false),
334            { readCache: true, writeCache: true },
335          ),
336        );
337        if (success) {
338          loaded.add(doc);
339        }
340      }
341    };
342
343    await Promise.all(
344      Array.from(
345        { length: Math.min(docs.length, Docs.CONCURRENCY) },
346        next,
347      ),
348    );
349
350    return loaded;
351  }
352
353  /**
354   * Turns a doc on, loading its index and installing it when the user has
355   * asked for that.
356   *
357   * @param {Doc} doc
358   * @param {() => void} _onSuccess
359   * @param {() => void} onError
360   */
361  enableDoc(doc, _onSuccess, onError) {
362    if (this.docs.contains(doc)) {
363      return;
364    }
365
366    const onSuccess = () => {
367      if (this.docs.contains(doc)) {
368        return;
369      }
370      this.disabledDocs.remove(doc);
371      this.docs.add(doc);
372      this.docs.sort();
373      this.initDoc(doc);
374      this.saveDocs();
375      if (app.settings.get("autoInstall")) {
376        doc.install(_onSuccess, onError);
377      } else {
378        _onSuccess();
379      }
380    };
381
382    doc.load(onSuccess, onError, { writeCache: true });
383  }
384
385  /** Stores the enabled docs and brings the offline database in line. */
386  saveDocs() {
387    this.settings.setDocs(this.docs.all().map((doc) => doc.slug));
388    this.db.migrate();
389    return this.serviceWorker != null
390      ? this.serviceWorker.updateInBackground()
391      : undefined;
392  }
393
394  /**
395   * Shows what's new since the user's last visit, and starts the update checks.
396   *
397   * Upstream also asks the fifth-time visitor to share the app; that prompt is
398   * left out, since its links point at devdocs.io rather than at this site.
399   */
400  welcomeBack() {
401    let visitCount = this.settings.get("count");
402    this.settings.set("count", ++visitCount);
403    new News();
404    new Updates();
405    return (this.updateChecker = new UpdateChecker());
406  }
407
408  /** Reloads the app, keeping the current path. */
409  reboot() {
410    if (location.pathname !== "/" && location.pathname !== "/settings") {
411      window.location.href = `/#${location.pathname}`;
412    } else {
413      window.location.href = "/";
414    }
415  }
416
417  /** Drops the cached indexes and reloads the app. */
418  reload() {
419    this.db.clearIndexes(() => {
420      if (this.serviceWorker) {
421        this.serviceWorker.reload();
422      } else {
423        this.reboot();
424      }
425    });
426  }
427
428  /** Clears every trace of the app and returns to the index. */
429  reset() {
430    this.localStorage.reset();
431    this.settings.reset();
432    if (this.db != null) {
433      this.db.reset();
434    }
435    if (this.serviceWorker != null) {
436      this.serviceWorker.update();
437    }
438    window.location.href = "/";
439  }
440
441  /**
442   * Shows a tip, unless the user has already seen it.
443   *
444   * @param {string} tip
445   */
446  showTip(tip) {
447    if (this.isSingleDoc()) {
448      return;
449    }
450    const tips = this.settings.getTips();
451    if (!tips.includes(tip)) {
452      tips.push(tip);
453      this.settings.setTips(tips);
454      new Tip(tip);
455    }
456  }
457
458  /** Takes the boot screen down. */
459  hideLoadingScreen() {
460    if ($.overlayScrollbarsEnabled()) {
461      document.body.classList.add("_overlay-scrollbars");
462    }
463    document.documentElement.classList.remove("_booting");
464  }
465
466  /** @param {...unknown} args */
467  onBootError(...args) {
468    this.trigger("bootError");
469    this.hideLoadingScreen();
470  }
471
472  /** Warns the user that the offline database has outgrown its quota. Once. */
473  onQuotaExceeded() {
474    if (this.quotaExceeded) {
475      return;
476    }
477    this.quotaExceeded = true;
478    new Notif("QuotaExceeded", { autoHide: null });
479  }
480
481  /**
482   * Warns the user that storage is blocked, so preferences won't stick. Once.
483   *
484   * @param {string} key
485   * @param {unknown} value What was written.
486   * @param {unknown} actual What was read back.
487   */
488  onStorageBlocked(key, value, actual) {
489    if (this.storageBlocked) {
490      return;
491    }
492    this.storageBlocked = true;
493    new Notif("StorageBlocked", { autoHide: null });
494    Raven.captureMessage(`StorageBlocked/${key}`, {
495      level: "warning",
496      extra: { value, actual },
497    });
498  }
499
500  /** @param {...unknown} args The `window.onerror` arguments. */
501  onWindowError(...args) {
502    if (this.storageBlocked) {
503      return;
504    }
505    if (this.isAppError(args[0], /** @type {string} */ (args[1]))) {
506      if (typeof this.previousErrorHandler === "function") {
507        this.previousErrorHandler(...args);
508      }
509      this.hideLoadingScreen();
510      if (!this.errorNotif) {
511        this.errorNotif = new Notif("Error");
512      }
513      this.errorNotif.show();
514    }
515  }
516
517  /**
518   * @param {unknown} error
519   * @param {string} [file] Where the error came from.
520   * @returns {boolean} Whether the error came from the app rather than an
521   *   external script.
522   */
523  isAppError(error, file) {
524    // Ignore errors from external scripts.
525    return file && file.includes("devdocs") && file.endsWith(".js");
526  }
527
528  /** @returns {boolean} Whether the browser has everything the app needs. */
529  isSupportedBrowser() {
530    try {
531      const features = {
532        bind: !!Function.prototype.bind,
533        pushState: !!history.pushState,
534        matchMedia: !!window.matchMedia,
535        insertAdjacentHTML: !!document.body.insertAdjacentHTML,
536        defaultPrevented:
537          document.createEvent("CustomEvent").defaultPrevented === false,
538        cssVariables: !!CSS.supports?.("(--t: 0)"),
539      };
540
541      for (var key in features) {
542        var value = features[key];
543        if (!value) {
544          Raven.captureMessage(`unsupported/${key}`, { level: "info" });
545          return false;
546        }
547      }
548
549      return true;
550    } catch (error) {
551      Raven.captureMessage("unsupported/exception", {
552        level: "info",
553        extra: { error },
554      });
555      return false;
556    }
557  }
558
559  /** @returns {boolean} Whether the app is showing one doc rather than all of them. */
560  isSingleDoc() {
561    return document.body.hasAttribute("data-doc");
562  }
563
564  /** @returns {boolean} Whether to use the phone layout. Decided once. */
565  isMobile() {
566    return this._isMobile != null
567      ? this._isMobile
568      : (this._isMobile = Mobile.detect());
569  }
570
571  /** @returns {boolean} Whether the app is inside an Android webview. Decided once. */
572  isAndroidWebview() {
573    return this._isAndroidWebview != null
574      ? this._isAndroidWebview
575      : (this._isAndroidWebview = Mobile.detectAndroidWebview());
576  }
577
578  /** @returns {boolean} Whether the app is being served from someone else's domain. */
579  isInvalidLocation() {
580    return (
581      config.env === "production" &&
582      !location.host.startsWith(config.production_host)
583    );
584  }
585}
586
587export const app = new App();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.