diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md deleted file mode 100644 index 26f8ab09e8..0000000000 --- a/.github/CODE_OF_CONDUCT.md +++ /dev/null @@ -1,2 +0,0 @@ - -> Our Code of Conduct is available here: diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 2922dcf0d5..fc0dac2e3d 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -64,7 +64,7 @@ If the latest [documentation versions report](https://github.com/freeCodeCamp/de Follow the following steps to update documentations to their latest version: 1. Make version/release changes in the scraper file. -2. Check if the license is still correct. If you update `options[:attribution]`, also update the documentation's entry in the array in [`assets/javascripts/templates/pages/about_tmpl.coffee`](../assets/javascripts/templates/pages/about_tmpl.coffee) to match. +2. Check if the license is still correct. Update `options[:attribution]` if needed. 3. If the documentation has a custom icon, ensure the icons in public/icons/*your_scraper_name*/ are up-to-date. If you pull the updated icon from a place different than the one specified in the `SOURCE` file, make sure to replace the old link with the new one. 4. If `self.links` is defined, check if the urls are still correct. 5. If the scraper inherits from `FileScraper` rather than `URLScraper`, follow the instructions for that scraper in [`file-scrapers.md`](../docs/file-scrapers.md) to obtain the source material for the scraper. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index c93bfc45f9..029babb22a 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -22,7 +22,7 @@ If you’re adding a new scraper, please ensure that you have: If you're updating existing documentation to its latest version, please ensure that you have: - [ ] Updated the versions and releases in the scraper file -- [ ] Ensured the license is up-to-date and that the documentation's entry in the array in `about_tmpl.coffee` matches its data in `self.attribution` +- [ ] Ensured the license is up-to-date - [ ] Ensured the icons and the `SOURCE` file in public/icons/*your_scraper_name*/ are up-to-date if the documentation has a custom icon - [ ] Ensured `self.links` contains up-to-date urls if `self.links` is defined - [ ] Tested the changes locally to ensure: diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1b1ef4ee6f..e83dfeb3df 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -8,18 +8,21 @@ on: jobs: deploy: name: Deploy to Heroku - runs-on: ubuntu-20.04 + runs-on: ubuntu-24.04 if: github.repository == 'freeCodeCamp/devdocs' steps: - uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2.7.0 - name: Set up Ruby - uses: ruby/setup-ruby@55283cc23133118229fd3f97f9336ee23a179fcf # v1.146.0 + uses: ruby/setup-ruby@8aeb6ff8030dd539317f8e1769a044873b56ea71 # v1.268.0 with: bundler-cache: true # runs 'bundle install' and caches installed gems automatically - name: Run tests run: bundle exec rake + - name: Install Heroku CLI + run: | + curl https://cli-assets.heroku.com/install.sh | sh - name: Deploy to Heroku - uses: akhileshns/heroku-deploy@5ef17ff17a3fb8e9ad822ae6a61648a3ef9e0c3d # v3.12.13 + uses: akhileshns/heroku-deploy@e3eb99d45a8e2ec5dca08735e089607befa4bf28 # v3.14.15 with: heroku_api_key: ${{secrets.HEROKU_API_KEY}} heroku_app_name: "devdocs" diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml new file mode 100644 index 0000000000..dd0ac8a062 --- /dev/null +++ b/.github/workflows/docker-build.yml @@ -0,0 +1,54 @@ +name: Build and Push Docker Images +on: + schedule: + - cron: '0 0 1 * *' # Run monthly on the 1st + workflow_dispatch: # Allow manual triggers +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} +jobs: + build-and-push: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + strategy: + matrix: + variant: + - name: regular + file: Dockerfile + suffix: '' + - name: alpine + file: Dockerfile-alpine + suffix: '-alpine' + + steps: + - name: Checkout repository + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + + - name: Log in to the Container registry + uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata for Docker + id: meta + uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=raw,value=latest${{ matrix.variant.suffix }} + type=raw,value={{date 'YYYYMMDD'}}${{ matrix.variant.suffix }} + + - name: Build and push image + uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5 + with: + context: . + file: ./${{ matrix.variant.file }} + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/.github/workflows/schedule-doc-report.yml b/.github/workflows/schedule-doc-report.yml index cecbbb0ba7..f5d1eb1213 100644 --- a/.github/workflows/schedule-doc-report.yml +++ b/.github/workflows/schedule-doc-report.yml @@ -2,15 +2,16 @@ name: Generate documentation versions report on: schedule: - cron: '17 4 1 * *' + workflow_dispatch: jobs: report: - runs-on: ubuntu-20.04 + runs-on: ubuntu-24.04 if: github.repository == 'freeCodeCamp/devdocs' steps: - uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2.7.0 - name: Set up Ruby - uses: ruby/setup-ruby@55283cc23133118229fd3f97f9336ee23a179fcf # v1.146.0 + uses: ruby/setup-ruby@8aeb6ff8030dd539317f8e1769a044873b56ea71 # v1.268.0 with: bundler-cache: true # runs 'bundle install' and caches installed gems automatically - name: Generate report diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3c0feacca8..953d3e0256 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -7,11 +7,11 @@ on: jobs: test: - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest steps: - - uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2.7.0 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Set up Ruby - uses: ruby/setup-ruby@55283cc23133118229fd3f97f9336ee23a179fcf # v1.146.0 + uses: ruby/setup-ruby@8aeb6ff8030dd539317f8e1769a044873b56ea71 # v1.268.0 with: bundler-cache: true # runs 'bundle install' and caches installed gems automatically - name: Run tests diff --git a/.ruby-version b/.ruby-version index be94e6f53d..2aa5131992 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.2.2 +3.4.7 diff --git a/.tool-versions b/.tool-versions index f2a971aa75..3f03c7a73d 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1 +1 @@ -ruby 3.2.2 +ruby 3.4.7 diff --git a/COPYRIGHT b/COPYRIGHT index 9a294f24f1..374054bdbf 100644 --- a/COPYRIGHT +++ b/COPYRIGHT @@ -1,4 +1,4 @@ -Copyright 2013-2023 Thibaut Courouble and other contributors +Copyright 2013-2025 Thibaut Courouble and other contributors This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of the MPL was not distributed with this diff --git a/Dockerfile b/Dockerfile index f5d9b2494c..aff0ae395a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM ruby:3.2.2 +FROM ruby:3.4.7 ENV LANG=C.UTF-8 ENV ENABLE_SERVICE_WORKER=true @@ -11,7 +11,8 @@ RUN apt-get update && \ COPY Gemfile Gemfile.lock Rakefile /devdocs/ -RUN bundle install --system && \ +RUN bundle config set path.system true && \ + bundle install && \ rm -rf ~/.gem /root/.bundle/cache /usr/local/bundle/cache COPY . /devdocs diff --git a/Dockerfile-alpine b/Dockerfile-alpine index 39ca37c51e..b82d4dc49a 100644 --- a/Dockerfile-alpine +++ b/Dockerfile-alpine @@ -1,4 +1,4 @@ -FROM ruby:3.2.2-alpine +FROM ruby:3.4.7-alpine ENV LANG=C.UTF-8 ENV ENABLE_SERVICE_WORKER=true @@ -9,7 +9,9 @@ COPY . /devdocs RUN apk --update add nodejs build-base libstdc++ gzip git zlib-dev libcurl && \ gem install bundler && \ - bundle install --system --without test && \ + bundle config set path.system true && \ + bundle config set without 'test' && \ + bundle install && \ thor docs:download --all && \ thor assets:compile && \ apk del gzip build-base git zlib-dev && \ diff --git a/Gemfile b/Gemfile index f276b26383..9893bb5774 100644 --- a/Gemfile +++ b/Gemfile @@ -1,5 +1,5 @@ source 'https://rubygems.org' -ruby '3.2.2' +ruby '3.4.7' gem 'activesupport', require: false gem 'html-pipeline' @@ -14,7 +14,6 @@ gem 'yajl-ruby', require: false group :app do gem 'browser' gem 'chunky_png' - gem 'coffee-script' gem 'erubi' gem 'image_optim_pack', platforms: :ruby gem 'image_optim' @@ -32,7 +31,7 @@ end group :production do gem 'newrelic_rpm' - gem 'uglifier' + gem "terser" end group :development do @@ -40,7 +39,6 @@ group :development do end group :docs do - gem 'net-sftp', require: false gem 'progress_bar', require: false gem 'redcarpet' gem 'tty-pager', require: false diff --git a/Gemfile.lock b/Gemfile.lock index be0265a8a7..0dc3ed1f8e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,38 +1,47 @@ GEM remote: https://rubygems.org/ specs: - activesupport (7.0.4.3) - concurrent-ruby (~> 1.0, >= 1.0.2) + activesupport (7.2.3) + base64 + benchmark (>= 0.3) + bigdecimal + concurrent-ruby (~> 1.0, >= 1.3.1) + connection_pool (>= 2.2.5) + drb i18n (>= 1.6, < 2) + logger (>= 1.4.2) minitest (>= 5.1) - tzinfo (~> 2.0) - better_errors (2.9.1) - coderay (>= 1.0.0) + securerandom (>= 0.3) + tzinfo (~> 2.0, >= 2.0.5) + base64 (0.2.0) + benchmark (0.3.0) + better_errors (2.10.1) erubi (>= 1.0.0) rack (>= 0.9.0) + rouge (>= 1.0.0) + bigdecimal (3.1.9) browser (5.3.1) - byebug (11.1.3) + byebug (12.0.0) chunky_png (1.4.0) coderay (1.1.3) - coffee-script (2.4.1) - coffee-script-source - execjs - coffee-script-source (1.12.2) - concurrent-ruby (1.1.10) + concurrent-ruby (1.3.5) + connection_pool (2.4.1) daemons (1.4.1) - erubi (1.12.0) - ethon (0.16.0) + drb (2.2.3) + erubi (1.13.1) + ethon (0.17.0) ffi (>= 1.15.0) eventmachine (1.2.7) - execjs (2.8.1) - exifr (1.3.10) - ffi (1.15.5) + execjs (2.9.1) + exifr (1.4.0) + ffi (1.17.2) fspath (3.1.2) - highline (2.0.3) + highline (3.1.2) + reline html-pipeline (2.14.3) activesupport (>= 2) nokogiri (>= 1.4) - i18n (1.12.0) + i18n (1.14.7) concurrent-ruby (~> 1.0) image_optim (0.31.3) exifr (~> 1.2, >= 1.2.2) @@ -40,50 +49,53 @@ GEM image_size (>= 1.5, < 4) in_threads (~> 1.3) progress (~> 3.0, >= 3.0.1) - image_optim_pack (0.9.1.20230325) + image_optim_pack (0.10.1) fspath (>= 2.1, < 4) image_optim (~> 0.19) - image_size (3.2.0) + image_size (3.3.0) in_threads (1.6.0) - method_source (1.0.0) - mini_portile2 (2.8.1) - minitest (5.18.0) + io-console (0.8.0) + logger (1.6.6) + method_source (1.1.0) + mini_portile2 (2.8.9) + minitest (5.27.0) multi_json (1.15.0) - mustermann (3.0.0) + mustermann (3.0.3) ruby2_keywords (~> 0.0.1) - net-sftp (4.0.0) - net-ssh (>= 5.0.0, < 8.0.0) - net-ssh (7.0.1) newrelic_rpm (8.16.0) - nokogiri (1.14.3) - mini_portile2 (~> 2.8.0) + nokogiri (1.18.10) + mini_portile2 (~> 2.8.2) racc (~> 1.4) options (2.3.2) progress (3.6.0) - progress_bar (1.3.3) - highline (>= 1.6, < 3) + progress_bar (1.3.4) + highline (>= 1.6) options (~> 2.3.0) - pry (0.14.2) + pry (0.15.2) coderay (~> 1.1) method_source (~> 1.0) - pry-byebug (3.10.1) - byebug (~> 11.0) - pry (>= 0.13, < 0.15) - racc (1.6.2) - rack (2.2.6.4) - rack-protection (3.0.5) - rack + pry-byebug (3.11.0) + byebug (~> 12.0) + pry (>= 0.13, < 0.16) + racc (1.8.1) + rack (2.2.21) + rack-protection (3.2.0) + base64 (>= 0.1.0) + rack (~> 2.2, >= 2.2.4) rack-ssl-enforcer (0.2.9) - rack-test (2.1.0) + rack-test (2.2.0) rack (>= 1.3) - rake (13.0.6) + rake (13.3.1) rb-fsevent (0.11.2) rb-inotify (0.10.1) ffi (~> 1.0) - redcarpet (3.6.0) - rexml (3.2.5) - rr (3.1.0) - rss (0.2.9) + redcarpet (3.6.1) + reline (0.6.0) + io-console (~> 0.5) + rexml (3.3.9) + rouge (1.11.1) + rr (3.1.2) + rss (0.3.1) rexml ruby2_keywords (0.0.5) sass (3.7.4) @@ -91,18 +103,20 @@ GEM sass-listen (4.0.0) rb-fsevent (~> 0.9, >= 0.9.4) rb-inotify (~> 0.9, >= 0.9.7) - sinatra (3.0.5) + securerandom (0.3.2) + sinatra (3.2.0) mustermann (~> 3.0) rack (~> 2.2, >= 2.2.4) - rack-protection (= 3.0.5) + rack-protection (= 3.2.0) tilt (~> 2.0) - sinatra-contrib (3.0.5) - multi_json + sinatra-contrib (3.2.0) + multi_json (>= 0.0.2) mustermann (~> 3.0) - rack-protection (= 3.0.5) - sinatra (= 3.0.5) + rack-protection (= 3.2.0) + sinatra (= 3.2.0) tilt (~> 2.0) - sprockets (3.7.2) + sprockets (3.7.5) + base64 concurrent-ruby (~> 1.0) rack (> 1, < 3) sprockets-helpers (1.4.0) @@ -116,22 +130,22 @@ GEM strings-ansi (0.2.0) terminal-table (3.0.2) unicode-display_width (>= 1.1.1, < 3) + terser (1.2.6) + execjs (>= 0.3.0, < 3) thin (1.8.2) daemons (~> 1.0, >= 1.0.9) eventmachine (~> 1.0, >= 1.0.4) rack (>= 1, < 3) - thor (1.2.1) - tilt (2.0.11) + thor (1.4.0) + tilt (2.6.0) tty-pager (0.14.0) strings (~> 0.2.0) tty-screen (~> 0.8) tty-screen (0.8.1) - typhoeus (1.4.0) + typhoeus (1.4.1) ethon (>= 0.9.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) - uglifier (4.2.0) - execjs (>= 0.3.0, < 3) unicode-display_width (2.3.0) unicode_utils (1.4.0) unix_utils (0.0.15) @@ -145,13 +159,11 @@ DEPENDENCIES better_errors browser chunky_png - coffee-script erubi html-pipeline image_optim image_optim_pack minitest - net-sftp newrelic_rpm nokogiri progress_bar @@ -170,16 +182,16 @@ DEPENDENCIES sprockets-helpers sprockets-sass terminal-table + terser thin thor tty-pager typhoeus - uglifier unix_utils yajl-ruby RUBY VERSION - ruby 3.2.2p53 + ruby 3.4.7p58 BUNDLED WITH 2.4.6 diff --git a/README.md b/README.md index b5e117738e..fcdb677d84 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Please reach out to the community on [Discord](https://discord.gg/PRyKn3Vbay) if Keep track of development news: -* Join the devdocs chat room on [Discord](https://discord.gg/PRyKn3Vbay) +* Join the `#contributors` chat room on [Discord](https://discord.gg/PRyKn3Vbay) * Watch the repository on [GitHub](https://github.com/freeCodeCamp/devdocs/subscription) * Follow [@DevDocs](https://twitter.com/DevDocs) on Twitter @@ -20,9 +20,35 @@ Keep track of development news: Unless you wish to contribute to the project, we recommend using the hosted version at [devdocs.io](https://devdocs.io). It's up-to-date and works offline out-of-the-box. +### Using Docker (Recommended) + +The easiest way to run DevDocs locally is using Docker: + +```sh +docker run --name devdocs -d -p 9292:9292 ghcr.io/freecodecamp/devdocs:latest +``` + +This will start DevDocs at [localhost:9292](http://localhost:9292). We provide both regular and Alpine-based images: +- `ghcr.io/freecodecamp/devdocs:latest` - Standard image +- `ghcr.io/freecodecamp/devdocs:latest-alpine` - Alpine-based (smaller size) + +Images are automatically built and updated monthly with the latest documentation. + +Alternatively, you can build the image yourself: + +```sh +git clone https://github.com/freeCodeCamp/devdocs.git && cd devdocs +docker build -t devdocs . +docker run --name devdocs -d -p 9292:9292 devdocs +``` + +### Manual Installation + DevDocs is made of two pieces: a Ruby scraper that generates the documentation and metadata, and a JavaScript app powered by a small Sinatra app. -DevDocs requires Ruby 3.2.1, libcurl, and a JavaScript runtime supported by [ExecJS](https://github.com/rails/execjs#readme) (included in OS X and Windows; [Node.js](https://nodejs.org/en/) on Linux). Once you have these installed, run the following commands: +DevDocs requires Ruby 3.4.1 (defined in [`Gemfile`](./Gemfile)), libcurl, and a JavaScript runtime supported by [ExecJS](https://github.com/rails/execjs#readme) (included in OS X and Windows; [Node.js](https://nodejs.org/en/) on Linux). On Arch Linux run `pacman -S ruby ruby-bundler ruby-erb ruby-irb`. + +Once you have these installed, run the following commands: ```sh git clone https://github.com/freeCodeCamp/devdocs.git && cd devdocs @@ -38,27 +64,16 @@ The `thor docs:download` command is used to download pre-generated documentation **Note:** there is currently no update mechanism other than `git pull origin main` to update the code and `thor docs:download --installed` to download the latest version of the docs. To stay informed about new releases, be sure to [watch](https://github.com/freeCodeCamp/devdocs/subscription) this repository. -Alternatively, DevDocs may be started as a Docker container: - -```sh -# First, build the image -git clone https://github.com/freeCodeCamp/devdocs.git && cd devdocs -docker build -t thibaut/devdocs . - -# Finally, start a DevDocs container (access http://localhost:9292) -docker run --name devdocs -d -p 9292:9292 thibaut/devdocs -``` - ## Vision DevDocs aims to make reading and searching reference documentation fast, easy and enjoyable. -The app's main goals are to: +The app's main goals are to: * Keep load times as short as possible * Improve the quality, speed, and order of search results * Maximize the use of caching and other performance optimizations -* Maintain a clean and readable user interface +* Maintain a clean and readable user interface * Be fully functional offline * Support full keyboard navigation * Reduce “context switch” by using a consistent typography and design across all documentations @@ -68,7 +83,7 @@ The app's main goals are to: ## App -The web app is all client-side JavaScript, written in [CoffeeScript](http://coffeescript.org), and powered by a small [Sinatra](http://www.sinatrarb.com)/[Sprockets](https://github.com/rails/sprockets) application. It relies on files generated by the [scraper](#scraper). +The web app is all client-side JavaScript, powered by a small [Sinatra](http://www.sinatrarb.com)/[Sprockets](https://github.com/rails/sprockets) application. It relies on files generated by the [scraper](#scraper). Many of the code's design decisions were driven by the fact that the app uses XHR to load content directly into the main frame. This includes stripping the original documents of most of their HTML markup (e.g. scripts and stylesheets) to avoid polluting the main frame, and prefixing all CSS class names with an underscore to prevent conflicts. @@ -126,7 +141,7 @@ thor docs:clean # Delete documentation packages thor console # Start a REPL thor console:docs # Start a REPL in the "Docs" module -# Tests can be run quickly from within the console using the "test" command. +# Tests can be run quickly from within the console using the "test" command. # Run "help test" for usage instructions. thor test:all # Run all tests thor test:docs # Run "Docs" tests @@ -150,6 +165,24 @@ Contributions are welcome. Please read the [contributing guidelines](./.github/C * [Filter Reference](./docs/filter-reference.md) * [Maintainers’ Guide](./docs/maintainers.md) +## DevDocs Quick Usage Cheatsheet + +Below are some helpful shortcuts and usage tips that are not immediately obvious to new users: + +- Press / or Ctrl + K to instantly focus the search bar. +- Press ? to open DevDocs’ built-in help overlay. +- Press or to navigate search results without touching the mouse. +- Press Enter to open the highlighted search result. +- Press Backspace to go back to the previously viewed page. +- Press Shift + S to toggle the sidebar visibility. +- Press A to open the list of all installed documentation sets. +- Press Esc to close popups, overlays, and search. +- Use the **⚡ Offline Mode toggle** to download docs for offline use. +- You can pin specific documentation sets to the sidebar for quicker access. + +These shortcuts make DevDocs faster to navigate and more efficient for daily use. + + ## Related Projects Made something cool? Feel free to open a PR to add a new row to this table! You might want to discover new projects via https://github.com/topics/devdocs. @@ -172,12 +205,17 @@ Made something cool? Feel free to open a PR to add a new row to this table! You | [mohamed3nan/DevDocs-Tab](https://github.com/mohamed3nan/DevDocs-Tab) | VS Code extension (view as tab) | ![Latest GitHub commit](https://img.shields.io/github/last-commit/mohamed3nan/DevDocs-Tab?logo=github&label) | ![GitHub stars](https://img.shields.io/github/stars/mohamed3nan/DevDocs-Tab?logo=github&label) | | [deibit/vscode-devdocs](https://marketplace.visualstudio.com/items?itemName=deibit.devdocs) | VS Code extension (open the browser) | ![Latest GitHub commit](https://img.shields.io/github/last-commit/deibit/vscode-devdocs?logo=github&label) | ![GitHub stars](https://img.shields.io/github/stars/deibit/vscode-devdocs?logo=github&label) | | [mdh34/quickDocs](https://github.com/mdh34/quickDocs) | Vala/Python based viewer | ![Latest GitHub commit](https://img.shields.io/github/last-commit/mdh34/quickDocs?logo=github&label) | ![GitHub stars](https://img.shields.io/github/stars/mdh34/quickDocs?logo=github&label) | +| [girishji/devdocs.vim](https://github.com/girishji/devdocs.vim) | Vim plugin & TUI (browse inside Vim) | ![Latest GitHub commit](https://img.shields.io/github/last-commit/girishji/devdocs.vim?logo=github&label) | ![GitHub stars](https://img.shields.io/github/stars/girishji/devdocs.vim?logo=github&label) | | [romainl/vim-devdocs](https://github.com/romainl/vim-devdocs) | Vim plugin | ![Latest GitHub commit](https://img.shields.io/github/last-commit/romainl/vim-devdocs?logo=github&label) | ![GitHub stars](https://img.shields.io/github/stars/romainl/vim-devdocs?logo=github&label) | | [waiting-for-dev/vim-www](https://github.com/waiting-for-dev/vim-www) | Vim plugin | ![Latest GitHub commit](https://img.shields.io/github/last-commit/waiting-for-dev/vim-www?logo=github&label) | ![GitHub stars](https://img.shields.io/github/stars/waiting-for-dev/vim-www?logo=github&label) | +| [emmanueltouzery/apidocs.nvim](https://github.com/emmanueltouzery/apidocs.nvim) | Neovim plugin | ![Latest GitHub commit](https://img.shields.io/github/last-commit/emmanueltouzery/apidocs.nvim?logo=github&label) | ![GitHub stars](https://img.shields.io/github/stars/emmanueltouzery/apidocs.nvim?logo=github&label) | +| [toiletbril/dedoc](https://github.com/toiletbril/dedoc) | Terminal based viewer | ![Latest GitHub commit](https://img.shields.io/github/last-commit/toiletbril/dedoc?logo=github&label) | ![GitHub stars](https://img.shields.io/github/stars/toiletbril/dedoc?logo=github&label) | +| [Raycast Devdocs](https://www.raycast.com/djpowers/devdocs) | Raycast extension | Unavailable | Unavailable | +| [chrisgrieser/alfred-docs-searches](https://github.com/chrisgrieser/alfred-docs-searches) | Alfred workflow | ![Latest GitHub commit](https://img.shields.io/github/last-commit/chrisgrieser/alfred-docs-searches?logo=github&label) | ![GitHub stars](https://img.shields.io/github/stars/chrisgrieser/alfred-docs-searches?logo=github&label) | ## Copyright / License -Copyright 2013–2023 Thibaut Courouble and [other contributors](https://github.com/freeCodeCamp/devdocs/graphs/contributors) +Copyright 2013–2025 Thibaut Courouble and [other contributors](https://github.com/freeCodeCamp/devdocs/graphs/contributors) This software is licensed under the terms of the Mozilla Public License v2.0. See the [COPYRIGHT](./COPYRIGHT) and [LICENSE](./LICENSE) files. @@ -187,4 +225,4 @@ We also wish that any documentation file generated using this software be attrib ## Questions? -If you have any questions, please feel free to ask them on the contributor chat room on [Discord](https://discord.gg/PRyKn3Vbay). +If you have any questions, please feel free to ask them on the `#contributors` chat room on [Discord](https://discord.gg/PRyKn3Vbay). diff --git a/assets/javascripts/app/app.coffee b/assets/javascripts/app/app.coffee deleted file mode 100644 index b55e552c8b..0000000000 --- a/assets/javascripts/app/app.coffee +++ /dev/null @@ -1,283 +0,0 @@ -@app = - _$: $ - _$$: $$ - _page: page - collections: {} - models: {} - templates: {} - views: {} - - init: -> - try @initErrorTracking() catch - return unless @browserCheck() - - @el = $('._app') - @localStorage = new LocalStorageStore - @serviceWorker = new app.ServiceWorker if app.ServiceWorker.isEnabled() - @settings = new app.Settings - @db = new app.DB() - - @settings.initLayout() - - @docs = new app.collections.Docs - @disabledDocs = new app.collections.Docs - @entries = new app.collections.Entries - - @router = new app.Router - @shortcuts = new app.Shortcuts - @document = new app.views.Document - @mobile = new app.views.Mobile if @isMobile() - - if document.body.hasAttribute('data-doc') - @DOC = JSON.parse(document.body.getAttribute('data-doc')) - @bootOne() - else if @DOCS - @bootAll() - else - @onBootError() - return - - browserCheck: -> - return true if @isSupportedBrowser() - document.body.innerHTML = app.templates.unsupportedBrowser - @hideLoadingScreen() - false - - initErrorTracking: -> - # Show a warning message and don't track errors when the app is loaded - # from a domain other than our own, because things are likely to break. - # (e.g. cross-domain requests) - if @isInvalidLocation() - new app.views.Notif 'InvalidLocation' - else - if @config.sentry_dsn - Raven.config @config.sentry_dsn, - release: @config.release - whitelistUrls: [/devdocs/] - includePaths: [/devdocs/] - ignoreErrors: [/NPObject/, /NS_ERROR/, /^null$/, /EvalError/] - tags: - mode: if @isSingleDoc() then 'single' else 'full' - iframe: (window.top isnt window).toString() - electron: (!!window.process?.versions?.electron).toString() - shouldSendCallback: => - try - if @isInjectionError() - @onInjectionError() - return false - if @isAndroidWebview() - return false - true - dataCallback: (data) -> - try - $.extend(data.user ||= {}, app.settings.dump()) - data.user.docs = data.user.docs.split('/') if data.user.docs - data.user.lastIDBTransaction = app.lastIDBTransaction if app.lastIDBTransaction - data.tags.scriptCount = document.scripts.length - data - .install() - @previousErrorHandler = onerror - window.onerror = @onWindowError.bind(@) - CookiesStore.onBlocked = @onCookieBlocked - return - - bootOne: -> - @doc = new app.models.Doc @DOC - @docs.reset [@doc] - @doc.load @start.bind(@), @onBootError.bind(@), readCache: true - new app.views.Notice 'singleDoc', @doc - delete @DOC - return - - bootAll: -> - docs = @settings.getDocs() - for doc in @DOCS - (if docs.indexOf(doc.slug) >= 0 then @docs else @disabledDocs).add(doc) - @migrateDocs() - @docs.load @start.bind(@), @onBootError.bind(@), readCache: true, writeCache: true - delete @DOCS - return - - start: -> - @entries.add doc.toEntry() for doc in @docs.all() - @entries.add doc.toEntry() for doc in @disabledDocs.all() - @initDoc(doc) for doc in @docs.all() - @trigger 'ready' - @router.start() - @hideLoadingScreen() - setTimeout => - @welcomeBack() unless @doc - @removeEvent 'ready bootError' - , 50 - return - - initDoc: (doc) -> - doc.entries.add type.toEntry() for type in doc.types.all() - @entries.add doc.entries.all() - return - - migrateDocs: -> - for slug in @settings.getDocs() when not @docs.findBy('slug', slug) - needsSaving = true - doc = @disabledDocs.findBy('slug', 'webpack') if slug == 'webpack~2' - doc = @disabledDocs.findBy('slug', 'angular') if slug == 'angular~4_typescript' - doc = @disabledDocs.findBy('slug', 'angular~2') if slug == 'angular~2_typescript' - doc ||= @disabledDocs.findBy('slug_without_version', slug) - if doc - @disabledDocs.remove(doc) - @docs.add(doc) - - @saveDocs() if needsSaving - return - - enableDoc: (doc, _onSuccess, onError) -> - return if @docs.contains(doc) - - onSuccess = => - return if @docs.contains(doc) - @disabledDocs.remove(doc) - @docs.add(doc) - @docs.sort() - @initDoc(doc) - @saveDocs() - if app.settings.get('autoInstall') - doc.install(_onSuccess, onError) - else - _onSuccess() - return - - doc.load onSuccess, onError, writeCache: true - return - - saveDocs: -> - @settings.setDocs(doc.slug for doc in @docs.all()) - @db.migrate() - @serviceWorker?.updateInBackground() - - welcomeBack: -> - visitCount = @settings.get('count') - @settings.set 'count', ++visitCount - new app.views.Notif 'Share', autoHide: null if visitCount is 5 - new app.views.News() - new app.views.Updates() - @updateChecker = new app.UpdateChecker() - - reboot: -> - if location.pathname isnt '/' and location.pathname isnt '/settings' - window.location = "/##{location.pathname}" - else - window.location = '/' - return - - reload: -> - @docs.clearCache() - @disabledDocs.clearCache() - if @serviceWorker then @serviceWorker.reload() else @reboot() - return - - reset: -> - @localStorage.reset() - @settings.reset() - @db?.reset() - @serviceWorker?.update() - window.location = '/' - return - - showTip: (tip) -> - return if @isSingleDoc() - tips = @settings.getTips() - if tips.indexOf(tip) is -1 - tips.push(tip) - @settings.setTips(tips) - new app.views.Tip(tip) - return - - hideLoadingScreen: -> - document.body.classList.add '_overlay-scrollbars' if $.overlayScrollbarsEnabled() - document.documentElement.classList.remove '_booting' - return - - indexHost: -> - # Can't load the index files from the host/CDN when service worker is - # enabled because it doesn't support caching URLs that use CORS. - @config[if @serviceWorker and @settings.hasDocs() then 'index_path' else 'docs_origin'] - - onBootError: (args...) -> - @trigger 'bootError' - @hideLoadingScreen() - return - - onQuotaExceeded: -> - return if @quotaExceeded - @quotaExceeded = true - new app.views.Notif 'QuotaExceeded', autoHide: null - return - - onCookieBlocked: (key, value, actual) -> - return if @cookieBlocked - @cookieBlocked = true - new app.views.Notif 'CookieBlocked', autoHide: null - Raven.captureMessage "CookieBlocked/#{key}", level: 'warning', extra: {value, actual} - return - - onWindowError: (args...) -> - return if @cookieBlocked - if @isInjectionError args... - @onInjectionError() - else if @isAppError args... - @previousErrorHandler? args... - @hideLoadingScreen() - @errorNotif or= new app.views.Notif 'Error' - @errorNotif.show() - return - - onInjectionError: -> - unless @injectionError - @injectionError = true - alert """ - JavaScript code has been injected in the page which prevents DevDocs from running correctly. - Please check your browser extensions/addons. """ - Raven.captureMessage 'injection error', level: 'info' - return - - isInjectionError: -> - # Some browser extensions expect the entire web to use jQuery. - # I gave up trying to fight back. - window.$ isnt app._$ or window.$$ isnt app._$$ or window.page isnt app._page or typeof $.empty isnt 'function' or typeof page.show isnt 'function' - - isAppError: (error, file) -> - # Ignore errors from external scripts. - file and file.indexOf('devdocs') isnt -1 and file.indexOf('.js') is file.length - 3 - - isSupportedBrowser: -> - try - features = - bind: !!Function::bind - pushState: !!history.pushState - matchMedia: !!window.matchMedia - insertAdjacentHTML: !!document.body.insertAdjacentHTML - defaultPrevented: document.createEvent('CustomEvent').defaultPrevented is false - cssVariables: !!CSS?.supports?('(--t: 0)') - - for key, value of features when not value - Raven.captureMessage "unsupported/#{key}", level: 'info' - return false - - true - catch error - Raven.captureMessage 'unsupported/exception', level: 'info', extra: { error: error } - false - - isSingleDoc: -> - document.body.hasAttribute('data-doc') - - isMobile: -> - @_isMobile ?= app.views.Mobile.detect() - - isAndroidWebview: -> - @_isAndroidWebview ?= app.views.Mobile.detectAndroidWebview() - - isInvalidLocation: -> - @config.env is 'production' and location.host.indexOf(app.config.production_host) isnt 0 - -$.extend app, Events diff --git a/assets/javascripts/app/app.js b/assets/javascripts/app/app.js new file mode 100644 index 0000000000..06d5327e60 --- /dev/null +++ b/assets/javascripts/app/app.js @@ -0,0 +1,419 @@ +class App extends Events { + _$ = $; + _$$ = $$; + _page = page; + collections = {}; + models = {}; + templates = {}; + views = {}; + + init() { + try { + this.initErrorTracking(); + } catch (error) {} + if (!this.browserCheck()) { + return; + } + + this.el = $("._app"); + this.localStorage = new LocalStorageStore(); + if (app.ServiceWorker.isEnabled()) { + this.serviceWorker = new app.ServiceWorker(); + } + this.settings = new app.Settings(); + this.db = new app.DB(); + + this.settings.initLayout(); + + this.docs = new app.collections.Docs(); + this.disabledDocs = new app.collections.Docs(); + this.entries = new app.collections.Entries(); + + this.router = new app.Router(); + this.shortcuts = new app.Shortcuts(); + this.document = new app.views.Document(); + if (this.isMobile()) { + this.mobile = new app.views.Mobile(); + } + + if (document.body.hasAttribute("data-doc")) { + this.DOC = JSON.parse(document.body.getAttribute("data-doc")); + this.bootOne(); + } else if (this.DOCS) { + this.bootAll(); + } else { + this.onBootError(); + } + } + + browserCheck() { + if (this.isSupportedBrowser()) { + return true; + } + document.body.innerHTML = app.templates.unsupportedBrowser; + this.hideLoadingScreen(); + return false; + } + + initErrorTracking() { + // Show a warning message and don't track errors when the app is loaded + // from a domain other than our own, because things are likely to break. + // (e.g. cross-domain requests) + if (this.isInvalidLocation()) { + new app.views.Notif("InvalidLocation"); + } else { + if (this.config.sentry_dsn) { + Raven.config(this.config.sentry_dsn, { + release: this.config.release, + whitelistUrls: [/devdocs/], + includePaths: [/devdocs/], + ignoreErrors: [/NPObject/, /NS_ERROR/, /^null$/, /EvalError/], + tags: { + mode: this.isSingleDoc() ? "single" : "full", + iframe: (window.top !== window).toString(), + electron: (!!window.process?.versions?.electron).toString(), + }, + shouldSendCallback: () => { + try { + if (this.isInjectionError()) { + this.onInjectionError(); + return false; + } + if (this.isAndroidWebview()) { + return false; + } + } catch (error) {} + return true; + }, + dataCallback(data) { + try { + data.user ||= {}; + Object.assign(data.user, app.settings.dump()); + if (data.user.docs) { + data.user.docs = data.user.docs.split("/"); + } + if (app.lastIDBTransaction) { + data.user.lastIDBTransaction = app.lastIDBTransaction; + } + data.tags.scriptCount = document.scripts.length; + } catch (error) {} + return data; + }, + }).install(); + } + this.previousErrorHandler = onerror; + window.onerror = this.onWindowError.bind(this); + CookiesStore.onBlocked = this.onCookieBlocked; + } + } + + bootOne() { + this.doc = new app.models.Doc(this.DOC); + this.docs.reset([this.doc]); + this.doc.load(this.start.bind(this), this.onBootError.bind(this), { + readCache: true, + }); + new app.views.Notice("singleDoc", this.doc); + delete this.DOC; + } + + bootAll() { + const docs = this.settings.getDocs(); + for (var doc of this.DOCS) { + (docs.includes(doc.slug) ? this.docs : this.disabledDocs).add(doc); + } + this.migrateDocs(); + this.docs.load(this.start.bind(this), this.onBootError.bind(this), { + readCache: true, + writeCache: true, + }); + delete this.DOCS; + } + + start() { + let doc; + for (doc of this.docs.all()) { + this.entries.add(doc.toEntry()); + } + for (doc of this.disabledDocs.all()) { + this.entries.add(doc.toEntry()); + } + for (doc of this.docs.all()) { + this.initDoc(doc); + } + this.trigger("ready"); + this.router.start(); + this.hideLoadingScreen(); + setTimeout(() => { + if (!this.doc) { + this.welcomeBack(); + } + return this.removeEvent("ready bootError"); + }, 50); + } + + initDoc(doc) { + for (var type of doc.types.all()) { + doc.entries.add(type.toEntry()); + } + this.entries.add(doc.entries.all()); + } + + migrateDocs() { + let needsSaving; + for (var slug of this.settings.getDocs()) { + if (!this.docs.findBy("slug", slug)) { + var doc; + + needsSaving = true; + if (slug === "webpack~2") { + doc = this.disabledDocs.findBy("slug", "webpack"); + } + if (slug === "angular~4_typescript") { + doc = this.disabledDocs.findBy("slug", "angular"); + } + if (slug === "angular~2_typescript") { + doc = this.disabledDocs.findBy("slug", "angular~2"); + } + if (!doc) { + doc = this.disabledDocs.findBy("slug_without_version", slug); + } + if (doc) { + this.disabledDocs.remove(doc); + this.docs.add(doc); + } + } + } + + if (needsSaving) { + this.saveDocs(); + } + } + + enableDoc(doc, _onSuccess, onError) { + if (this.docs.contains(doc)) { + return; + } + + const onSuccess = () => { + if (this.docs.contains(doc)) { + return; + } + this.disabledDocs.remove(doc); + this.docs.add(doc); + this.docs.sort(); + this.initDoc(doc); + this.saveDocs(); + if (app.settings.get("autoInstall")) { + doc.install(_onSuccess, onError); + } else { + _onSuccess(); + } + }; + + doc.load(onSuccess, onError, { writeCache: true }); + } + + saveDocs() { + this.settings.setDocs(this.docs.all().map((doc) => doc.slug)); + this.db.migrate(); + return this.serviceWorker != null + ? this.serviceWorker.updateInBackground() + : undefined; + } + + welcomeBack() { + let visitCount = this.settings.get("count"); + this.settings.set("count", ++visitCount); + if (visitCount === 5) { + new app.views.Notif("Share", { autoHide: null }); + } + new app.views.News(); + new app.views.Updates(); + return (this.updateChecker = new app.UpdateChecker()); + } + + reboot() { + if (location.pathname !== "/" && location.pathname !== "/settings") { + window.location = `/#${location.pathname}`; + } else { + window.location = "/"; + } + } + + reload() { + this.docs.clearCache(); + this.disabledDocs.clearCache(); + if (this.serviceWorker) { + this.serviceWorker.reload(); + } else { + this.reboot(); + } + } + + reset() { + this.localStorage.reset(); + this.settings.reset(); + if (this.db != null) { + this.db.reset(); + } + if (this.serviceWorker != null) { + this.serviceWorker.update(); + } + window.location = "/"; + } + + showTip(tip) { + if (this.isSingleDoc()) { + return; + } + const tips = this.settings.getTips(); + if (!tips.includes(tip)) { + tips.push(tip); + this.settings.setTips(tips); + new app.views.Tip(tip); + } + } + + hideLoadingScreen() { + if ($.overlayScrollbarsEnabled()) { + document.body.classList.add("_overlay-scrollbars"); + } + document.documentElement.classList.remove("_booting"); + } + + indexHost() { + // Can't load the index files from the host/CDN when service worker is + // enabled because it doesn't support caching URLs that use CORS. + return this.config[ + this.serviceWorker && this.settings.hasDocs() + ? "index_path" + : "docs_origin" + ]; + } + + onBootError(...args) { + this.trigger("bootError"); + this.hideLoadingScreen(); + } + + onQuotaExceeded() { + if (this.quotaExceeded) { + return; + } + this.quotaExceeded = true; + new app.views.Notif("QuotaExceeded", { autoHide: null }); + } + + onCookieBlocked(key, value, actual) { + if (this.cookieBlocked) { + return; + } + this.cookieBlocked = true; + new app.views.Notif("CookieBlocked", { autoHide: null }); + Raven.captureMessage(`CookieBlocked/${key}`, { + level: "warning", + extra: { value, actual }, + }); + } + + onWindowError(...args) { + if (this.cookieBlocked) { + return; + } + if (this.isInjectionError(...args)) { + this.onInjectionError(); + } else if (this.isAppError(...args)) { + if (typeof this.previousErrorHandler === "function") { + this.previousErrorHandler(...args); + } + this.hideLoadingScreen(); + if (!this.errorNotif) { + this.errorNotif = new app.views.Notif("Error"); + } + this.errorNotif.show(); + } + } + + onInjectionError() { + if (!this.injectionError) { + this.injectionError = true; + alert(`\ +JavaScript code has been injected in the page which prevents DevDocs from running correctly. +Please check your browser extensions/addons. `); + Raven.captureMessage("injection error", { level: "info" }); + } + } + + isInjectionError() { + // Some browser extensions expect the entire web to use jQuery. + // I gave up trying to fight back. + return ( + window.$ !== app._$ || + window.$$ !== app._$$ || + window.page !== app._page || + typeof $.empty !== "function" || + typeof page.show !== "function" + ); + } + + isAppError(error, file) { + // Ignore errors from external scripts. + return file && file.includes("devdocs") && file.endsWith(".js"); + } + + isSupportedBrowser() { + try { + const features = { + bind: !!Function.prototype.bind, + pushState: !!history.pushState, + matchMedia: !!window.matchMedia, + insertAdjacentHTML: !!document.body.insertAdjacentHTML, + defaultPrevented: + document.createEvent("CustomEvent").defaultPrevented === false, + cssVariables: !!CSS.supports?.("(--t: 0)"), + }; + + for (var key in features) { + var value = features[key]; + if (!value) { + Raven.captureMessage(`unsupported/${key}`, { level: "info" }); + return false; + } + } + + return true; + } catch (error) { + Raven.captureMessage("unsupported/exception", { + level: "info", + extra: { error }, + }); + return false; + } + } + + isSingleDoc() { + return document.body.hasAttribute("data-doc"); + } + + isMobile() { + return this._isMobile != null + ? this._isMobile + : (this._isMobile = app.views.Mobile.detect()); + } + + isAndroidWebview() { + return this._isAndroidWebview != null + ? this._isAndroidWebview + : (this._isAndroidWebview = app.views.Mobile.detectAndroidWebview()); + } + + isInvalidLocation() { + return ( + this.config.env === "production" && + !location.host.startsWith(app.config.production_host) + ); + } +} + +this.app = new App(); diff --git a/assets/javascripts/app/config.coffee.erb b/assets/javascripts/app/config.coffee.erb deleted file mode 100644 index 97e91ace30..0000000000 --- a/assets/javascripts/app/config.coffee.erb +++ /dev/null @@ -1,18 +0,0 @@ -app.config = - db_filename: 'db.json' - default_docs: <%= App.default_docs.to_json %> - docs_origin: '<%= App.docs_origin %>' - env: '<%= App.environment %>' - history_cache_size: 10 - index_filename: 'index.json' - index_path: '/<%= App.docs_prefix %>' - max_results: 50 - production_host: 'devdocs.io' - search_param: 'q' - sentry_dsn: '<%= App.sentry_dsn %>' - version: <%= Time.now.to_i %> - release: <%= Time.now.utc.httpdate.to_json %> - mathml_stylesheet: '/mathml.css' - favicon_spritesheet: '<%= image_path('sprites/docs.png') %>' - service_worker_path: '/service-worker.js' - service_worker_enabled: <%= App.environment == :production || ENV['ENABLE_SERVICE_WORKER'] == 'true' %> diff --git a/assets/javascripts/app/config.js.erb b/assets/javascripts/app/config.js.erb new file mode 100644 index 0000000000..04bc8cbaf4 --- /dev/null +++ b/assets/javascripts/app/config.js.erb @@ -0,0 +1,20 @@ +app.config = { + db_filename: 'db.json', + default_docs: <%= App.default_docs.to_json %>, + docs_aliases: <%= App.docs_aliases.to_json %>, + docs_origin: '<%= App.docs_origin %>', + env: '<%= App.environment %>', + history_cache_size: 10, + index_filename: 'index.json', + index_path: '/<%= App.docs_prefix %>', + max_results: 50, + production_host: 'devdocs.io', + search_param: 'q', + sentry_dsn: '<%= App.sentry_dsn %>', + version: <%= Time.now.to_i %>, + release: <%= Time.now.utc.httpdate.to_json %>, + mathml_stylesheet: '/mathml.css', + favicon_spritesheet: '<%= image_path('sprites/docs.png') %>', + service_worker_path: '/service-worker.js', + service_worker_enabled: <%= App.environment == :production || ENV['ENABLE_SERVICE_WORKER'] == 'true' %>, +} diff --git a/assets/javascripts/app/db.coffee b/assets/javascripts/app/db.coffee deleted file mode 100644 index 28e4b0eaeb..0000000000 --- a/assets/javascripts/app/db.coffee +++ /dev/null @@ -1,382 +0,0 @@ -class app.DB - NAME = 'docs' - VERSION = 15 - - constructor: -> - @versionMultipler = if $.isIE() then 1e5 else 1e9 - @useIndexedDB = @useIndexedDB() - @callbacks = [] - - db: (fn) -> - return fn() unless @useIndexedDB - @callbacks.push(fn) if fn - return if @open - - try - @open = true - req = indexedDB.open(NAME, VERSION * @versionMultipler + @userVersion()) - req.onsuccess = @onOpenSuccess - req.onerror = @onOpenError - req.onupgradeneeded = @onUpgradeNeeded - catch error - @fail 'exception', error - return - - onOpenSuccess: (event) => - db = event.target.result - - if db.objectStoreNames.length is 0 - try db.close() - @open = false - @fail 'empty' - else if error = @buggyIDB(db) - try db.close() - @open = false - @fail 'buggy', error - else - @runCallbacks(db) - @open = false - db.close() - return - - onOpenError: (event) => - event.preventDefault() - @open = false - error = event.target.error - - switch error.name - when 'QuotaExceededError' - @onQuotaExceededError() - when 'VersionError' - @onVersionError() - when 'InvalidStateError' - @fail 'private_mode' - else - @fail 'cant_open', error - return - - fail: (reason, error) -> - @cachedDocs = null - @useIndexedDB = false - @reason or= reason - @error or= error - console.error? 'IDB error', error if error - @runCallbacks() - if error and reason is 'cant_open' - Raven.captureMessage "#{error.name}: #{error.message}", level: 'warning', fingerprint: [error.name] - return - - onQuotaExceededError: -> - @reset() - @db() - app.onQuotaExceeded() - Raven.captureMessage 'QuotaExceededError', level: 'warning' - return - - onVersionError: -> - req = indexedDB.open(NAME) - req.onsuccess = (event) => - @handleVersionMismatch event.target.result.version - req.onerror = (event) -> - event.preventDefault() - @fail 'cant_open', error - return - - handleVersionMismatch: (actualVersion) -> - if Math.floor(actualVersion / @versionMultipler) isnt VERSION - @fail 'version' - else - @setUserVersion actualVersion - VERSION * @versionMultipler - @db() - return - - buggyIDB: (db) -> - return if @checkedBuggyIDB - @checkedBuggyIDB = true - try - @idbTransaction(db, stores: $.makeArray(db.objectStoreNames)[0..1], mode: 'readwrite').abort() # https://bugs.webkit.org/show_bug.cgi?id=136937 - return - catch error - return error - - runCallbacks: (db) -> - fn(db) while fn = @callbacks.shift() - return - - onUpgradeNeeded: (event) -> - return unless db = event.target.result - - objectStoreNames = $.makeArray(db.objectStoreNames) - - unless $.arrayDelete(objectStoreNames, 'docs') - try db.createObjectStore('docs') - - for doc in app.docs.all() when not $.arrayDelete(objectStoreNames, doc.slug) - try db.createObjectStore(doc.slug) - - for name in objectStoreNames - try db.deleteObjectStore(name) - return - - store: (doc, data, onSuccess, onError, _retry = true) -> - @db (db) => - unless db - onError() - return - - txn = @idbTransaction db, stores: ['docs', doc.slug], mode: 'readwrite', ignoreError: false - txn.oncomplete = => - @cachedDocs?[doc.slug] = doc.mtime - onSuccess() - return - txn.onerror = (event) => - event.preventDefault() - if txn.error?.name is 'NotFoundError' and _retry - @migrate() - setTimeout => - @store(doc, data, onSuccess, onError, false) - , 0 - else - onError(event) - return - - store = txn.objectStore(doc.slug) - store.clear() - store.add(content, path) for path, content of data - - store = txn.objectStore('docs') - store.put(doc.mtime, doc.slug) - return - return - - unstore: (doc, onSuccess, onError, _retry = true) -> - @db (db) => - unless db - onError() - return - - txn = @idbTransaction db, stores: ['docs', doc.slug], mode: 'readwrite', ignoreError: false - txn.oncomplete = => - delete @cachedDocs?[doc.slug] - onSuccess() - return - txn.onerror = (event) -> - event.preventDefault() - if txn.error?.name is 'NotFoundError' and _retry - @migrate() - setTimeout => - @unstore(doc, onSuccess, onError, false) - , 0 - else - onError(event) - return - - store = txn.objectStore('docs') - store.delete(doc.slug) - - store = txn.objectStore(doc.slug) - store.clear() - return - return - - version: (doc, fn) -> - if (version = @cachedVersion(doc))? - fn(version) - return - - @db (db) => - unless db - fn(false) - return - - txn = @idbTransaction db, stores: ['docs'], mode: 'readonly' - store = txn.objectStore('docs') - - req = store.get(doc.slug) - req.onsuccess = -> - fn(req.result) - return - req.onerror = (event) -> - event.preventDefault() - fn(false) - return - return - return - - cachedVersion: (doc) -> - return unless @cachedDocs - @cachedDocs[doc.slug] or false - - versions: (docs, fn) -> - if versions = @cachedVersions(docs) - fn(versions) - return - - @db (db) => - unless db - fn(false) - return - - txn = @idbTransaction db, stores: ['docs'], mode: 'readonly' - txn.oncomplete = -> - fn(result) - return - store = txn.objectStore('docs') - result = {} - - docs.forEach (doc) -> - req = store.get(doc.slug) - req.onsuccess = -> - result[doc.slug] = req.result - return - req.onerror = (event) -> - event.preventDefault() - result[doc.slug] = false - return - return - return - - cachedVersions: (docs) -> - return unless @cachedDocs - result = {} - result[doc.slug] = @cachedVersion(doc) for doc in docs - result - - load: (entry, onSuccess, onError) -> - if @shouldLoadWithIDB(entry) - onError = @loadWithXHR.bind(@, entry, onSuccess, onError) - @loadWithIDB entry, onSuccess, onError - else - @loadWithXHR entry, onSuccess, onError - - loadWithXHR: (entry, onSuccess, onError) -> - ajax - url: entry.fileUrl() - dataType: 'html' - success: onSuccess - error: onError - - loadWithIDB: (entry, onSuccess, onError) -> - @db (db) => - unless db - onError() - return - - unless db.objectStoreNames.contains(entry.doc.slug) - onError() - @loadDocsCache(db) - return - - txn = @idbTransaction db, stores: [entry.doc.slug], mode: 'readonly' - store = txn.objectStore(entry.doc.slug) - - req = store.get(entry.dbPath()) - req.onsuccess = -> - if req.result then onSuccess(req.result) else onError() - return - req.onerror = (event) -> - event.preventDefault() - onError() - return - @loadDocsCache(db) - return - - loadDocsCache: (db) -> - return if @cachedDocs - @cachedDocs = {} - - txn = @idbTransaction db, stores: ['docs'], mode: 'readonly' - txn.oncomplete = => - setTimeout(@checkForCorruptedDocs, 50) - return - - req = txn.objectStore('docs').openCursor() - req.onsuccess = (event) => - return unless cursor = event.target.result - @cachedDocs[cursor.key] = cursor.value - cursor.continue() - return - req.onerror = (event) -> - event.preventDefault() - return - return - - checkForCorruptedDocs: => - @db (db) => - @corruptedDocs = [] - docs = (key for key, value of @cachedDocs when value) - return if docs.length is 0 - - for slug in docs when not app.docs.findBy('slug', slug) - @corruptedDocs.push(slug) - - for slug in @corruptedDocs - $.arrayDelete(docs, slug) - - if docs.length is 0 - setTimeout(@deleteCorruptedDocs, 0) - return - - txn = @idbTransaction(db, stores: docs, mode: 'readonly', ignoreError: false) - txn.oncomplete = => - setTimeout(@deleteCorruptedDocs, 0) if @corruptedDocs.length > 0 - return - - for doc in docs - txn.objectStore(doc).get('index').onsuccess = (event) => - @corruptedDocs.push(event.target.source.name) unless event.target.result - return - return - return - - deleteCorruptedDocs: => - @db (db) => - txn = @idbTransaction(db, stores: ['docs'], mode: 'readwrite', ignoreError: false) - store = txn.objectStore('docs') - while doc = @corruptedDocs.pop() - @cachedDocs[doc] = false - store.delete(doc) - return - Raven.captureMessage 'corruptedDocs', level: 'info', extra: { docs: @corruptedDocs.join(',') } - return - - shouldLoadWithIDB: (entry) -> - @useIndexedDB and (not @cachedDocs or @cachedDocs[entry.doc.slug]) - - idbTransaction: (db, options) -> - app.lastIDBTransaction = [options.stores, options.mode] - txn = db.transaction(options.stores, options.mode) - unless options.ignoreError is false - txn.onerror = (event) -> - event.preventDefault() - return - unless options.ignoreAbort is false - txn.onabort = (event) -> - event.preventDefault() - return - txn - - reset: -> - try indexedDB?.deleteDatabase(NAME) catch - return - - useIndexedDB: -> - try - if !app.isSingleDoc() and window.indexedDB - true - else - @reason = 'not_supported' - false - catch - false - - migrate: -> - app.settings.set('schema', @userVersion() + 1) - return - - setUserVersion: (version) -> - app.settings.set('schema', version) - return - - userVersion: -> - app.settings.get('schema') diff --git a/assets/javascripts/app/db.js b/assets/javascripts/app/db.js new file mode 100644 index 0000000000..64e69d9542 --- /dev/null +++ b/assets/javascripts/app/db.js @@ -0,0 +1,559 @@ +app.DB = class DB { + static NAME = "docs"; + static VERSION = 15; + + constructor() { + this.versionMultipler = $.isIE() ? 1e5 : 1e9; + this.useIndexedDB = this.useIndexedDB(); + this.callbacks = []; + } + + db(fn) { + if (!this.useIndexedDB) { + return fn(); + } + if (fn) { + this.callbacks.push(fn); + } + if (this.open) { + return; + } + + try { + this.open = true; + const req = indexedDB.open( + DB.NAME, + DB.VERSION * this.versionMultipler + this.userVersion(), + ); + req.onsuccess = (event) => this.onOpenSuccess(event); + req.onerror = (event) => this.onOpenError(event); + req.onupgradeneeded = (event) => this.onUpgradeNeeded(event); + } catch (error) { + this.fail("exception", error); + } + } + + onOpenSuccess(event) { + let error; + const db = event.target.result; + + if (db.objectStoreNames.length === 0) { + try { + db.close(); + } catch (error1) {} + this.open = false; + this.fail("empty"); + } else if ((error = this.buggyIDB(db))) { + try { + db.close(); + } catch (error2) {} + this.open = false; + this.fail("buggy", error); + } else { + this.runCallbacks(db); + this.open = false; + db.close(); + } + } + + onOpenError(event) { + event.preventDefault(); + this.open = false; + const { error } = event.target; + + switch (error.name) { + case "QuotaExceededError": + this.onQuotaExceededError(); + break; + case "VersionError": + this.onVersionError(); + break; + case "InvalidStateError": + this.fail("private_mode"); + break; + default: + this.fail("cant_open", error); + } + } + + fail(reason, error) { + this.cachedDocs = null; + this.useIndexedDB = false; + if (!this.reason) { + this.reason = reason; + } + if (!this.error) { + this.error = error; + } + if (error) { + if (typeof console.error === "function") { + console.error("IDB error", error); + } + } + this.runCallbacks(); + if (error && reason === "cant_open") { + Raven.captureMessage(`${error.name}: ${error.message}`, { + level: "warning", + fingerprint: [error.name], + }); + } + } + + onQuotaExceededError() { + this.reset(); + this.db(); + app.onQuotaExceeded(); + Raven.captureMessage("QuotaExceededError", { level: "warning" }); + } + + onVersionError() { + const req = indexedDB.open(DB.NAME); + req.onsuccess = (event) => { + return this.handleVersionMismatch(event.target.result.version); + }; + req.onerror = function (event) { + event.preventDefault(); + return this.fail("cant_open", error); + }; + } + + handleVersionMismatch(actualVersion) { + if (Math.floor(actualVersion / this.versionMultipler) !== DB.VERSION) { + this.fail("version"); + } else { + this.setUserVersion(actualVersion - DB.VERSION * this.versionMultipler); + this.db(); + } + } + + buggyIDB(db) { + if (this.checkedBuggyIDB) { + return; + } + this.checkedBuggyIDB = true; + try { + this.idbTransaction(db, { + stores: $.makeArray(db.objectStoreNames).slice(0, 2), + mode: "readwrite", + }).abort(); // https://bugs.webkit.org/show_bug.cgi?id=136937 + return; + } catch (error) { + return error; + } + } + + runCallbacks(db) { + let fn; + while ((fn = this.callbacks.shift())) { + fn(db); + } + } + + onUpgradeNeeded(event) { + const db = event.target.result; + if (!db) { + return; + } + + const objectStoreNames = $.makeArray(db.objectStoreNames); + + if (!$.arrayDelete(objectStoreNames, "docs")) { + try { + db.createObjectStore("docs"); + } catch (error) {} + } + + for (var doc of app.docs.all()) { + if (!$.arrayDelete(objectStoreNames, doc.slug)) { + try { + db.createObjectStore(doc.slug); + } catch (error1) {} + } + } + + for (var name of objectStoreNames) { + try { + db.deleteObjectStore(name); + } catch (error2) {} + } + } + + store(doc, data, onSuccess, onError, _retry) { + if (_retry == null) { + _retry = true; + } + this.db((db) => { + if (!db) { + onError(); + return; + } + + const txn = this.idbTransaction(db, { + stores: ["docs", doc.slug], + mode: "readwrite", + ignoreError: false, + }); + txn.oncomplete = () => { + if (this.cachedDocs != null) { + this.cachedDocs[doc.slug] = doc.mtime; + } + onSuccess(); + }; + txn.onerror = (event) => { + event.preventDefault(); + if (txn.error?.name === "NotFoundError" && _retry) { + this.migrate(); + setTimeout(() => { + return this.store(doc, data, onSuccess, onError, false); + }, 0); + } else { + onError(event); + } + }; + + let store = txn.objectStore(doc.slug); + store.clear(); + for (var path in data) { + var content = data[path]; + store.add(content, path); + } + + store = txn.objectStore("docs"); + store.put(doc.mtime, doc.slug); + }); + } + + unstore(doc, onSuccess, onError, _retry) { + if (_retry == null) { + _retry = true; + } + this.db((db) => { + if (!db) { + onError(); + return; + } + + const txn = this.idbTransaction(db, { + stores: ["docs", doc.slug], + mode: "readwrite", + ignoreError: false, + }); + txn.oncomplete = () => { + if (this.cachedDocs != null) { + delete this.cachedDocs[doc.slug]; + } + onSuccess(); + }; + txn.onerror = function (event) { + event.preventDefault(); + if (txn.error?.name === "NotFoundError" && _retry) { + this.migrate(); + setTimeout(() => { + return this.unstore(doc, onSuccess, onError, false); + }, 0); + } else { + onError(event); + } + }; + + let store = txn.objectStore("docs"); + store.delete(doc.slug); + + store = txn.objectStore(doc.slug); + store.clear(); + }); + } + + version(doc, fn) { + const version = this.cachedVersion(doc); + if (version != null) { + fn(version); + return; + } + + this.db((db) => { + if (!db) { + fn(false); + return; + } + + const txn = this.idbTransaction(db, { + stores: ["docs"], + mode: "readonly", + }); + const store = txn.objectStore("docs"); + + const req = store.get(doc.slug); + req.onsuccess = function () { + fn(req.result); + }; + req.onerror = function (event) { + event.preventDefault(); + fn(false); + }; + }); + } + + cachedVersion(doc) { + if (!this.cachedDocs) { + return; + } + return this.cachedDocs[doc.slug] || false; + } + + versions(docs, fn) { + const versions = this.cachedVersions(docs); + if (versions) { + fn(versions); + return; + } + + return this.db((db) => { + if (!db) { + fn(false); + return; + } + + const txn = this.idbTransaction(db, { + stores: ["docs"], + mode: "readonly", + }); + txn.oncomplete = function () { + fn(result); + }; + const store = txn.objectStore("docs"); + var result = {}; + + docs.forEach((doc) => { + const req = store.get(doc.slug); + req.onsuccess = function () { + result[doc.slug] = req.result; + }; + req.onerror = function (event) { + event.preventDefault(); + result[doc.slug] = false; + }; + }); + }); + } + + cachedVersions(docs) { + if (!this.cachedDocs) { + return; + } + const result = {}; + for (var doc of docs) { + result[doc.slug] = this.cachedVersion(doc); + } + return result; + } + + load(entry, onSuccess, onError) { + if (this.shouldLoadWithIDB(entry)) { + return this.loadWithIDB(entry, onSuccess, () => + this.loadWithXHR(entry, onSuccess, onError) + ); + } else { + return this.loadWithXHR(entry, onSuccess, onError); + } + } + + loadWithXHR(entry, onSuccess, onError) { + return ajax({ + url: entry.fileUrl(), + dataType: "html", + success: onSuccess, + error: onError, + }); + } + + loadWithIDB(entry, onSuccess, onError) { + return this.db((db) => { + if (!db) { + onError(); + return; + } + + if (!db.objectStoreNames.contains(entry.doc.slug)) { + onError(); + this.loadDocsCache(db); + return; + } + + const txn = this.idbTransaction(db, { + stores: [entry.doc.slug], + mode: "readonly", + }); + const store = txn.objectStore(entry.doc.slug); + + const req = store.get(entry.dbPath()); + req.onsuccess = function () { + if (req.result) { + onSuccess(req.result); + } else { + onError(); + } + }; + req.onerror = function (event) { + event.preventDefault(); + onError(); + }; + this.loadDocsCache(db); + }); + } + + loadDocsCache(db) { + if (this.cachedDocs) { + return; + } + this.cachedDocs = {}; + + const txn = this.idbTransaction(db, { + stores: ["docs"], + mode: "readonly", + }); + txn.oncomplete = () => { + setTimeout(() => this.checkForCorruptedDocs(), 50); + }; + + const req = txn.objectStore("docs").openCursor(); + req.onsuccess = (event) => { + const cursor = event.target.result; + if (!cursor) { + return; + } + this.cachedDocs[cursor.key] = cursor.value; + cursor.continue(); + }; + req.onerror = function (event) { + event.preventDefault(); + }; + } + + checkForCorruptedDocs() { + this.db((db) => { + let slug; + this.corruptedDocs = []; + const docs = (() => { + const result = []; + for (var key in this.cachedDocs) { + var value = this.cachedDocs[key]; + if (value) { + result.push(key); + } + } + return result; + })(); + if (docs.length === 0) { + return; + } + + for (slug of docs) { + if (!app.docs.findBy("slug", slug)) { + this.corruptedDocs.push(slug); + } + } + + for (slug of this.corruptedDocs) { + $.arrayDelete(docs, slug); + } + + if (docs.length === 0) { + setTimeout(() => this.deleteCorruptedDocs(), 0); + return; + } + + const txn = this.idbTransaction(db, { + stores: docs, + mode: "readonly", + ignoreError: false, + }); + txn.oncomplete = () => { + if (this.corruptedDocs.length > 0) { + setTimeout(() => this.deleteCorruptedDocs(), 0); + } + }; + + for (var doc of docs) { + txn.objectStore(doc).get("index").onsuccess = (event) => { + if (!event.target.result) { + this.corruptedDocs.push(event.target.source.name); + } + }; + } + }); + } + + deleteCorruptedDocs() { + this.db((db) => { + let doc; + const txn = this.idbTransaction(db, { + stores: ["docs"], + mode: "readwrite", + ignoreError: false, + }); + const store = txn.objectStore("docs"); + while ((doc = this.corruptedDocs.pop())) { + this.cachedDocs[doc] = false; + store.delete(doc); + } + }); + Raven.captureMessage("corruptedDocs", { + level: "info", + extra: { docs: this.corruptedDocs.join(",") }, + }); + } + + shouldLoadWithIDB(entry) { + return ( + this.useIndexedDB && (!this.cachedDocs || this.cachedDocs[entry.doc.slug]) + ); + } + + idbTransaction(db, options) { + app.lastIDBTransaction = [options.stores, options.mode]; + const txn = db.transaction(options.stores, options.mode); + if (options.ignoreError !== false) { + txn.onerror = function (event) { + event.preventDefault(); + }; + } + if (options.ignoreAbort !== false) { + txn.onabort = function (event) { + event.preventDefault(); + }; + } + return txn; + } + + reset() { + try { + indexedDB?.deleteDatabase(DB.NAME); + } catch (error) {} + } + + useIndexedDB() { + try { + if (!app.isSingleDoc() && window.indexedDB) { + return true; + } else { + this.reason = "not_supported"; + return false; + } + } catch (error) { + return false; + } + } + + migrate() { + app.settings.set("schema", this.userVersion() + 1); + } + + setUserVersion(version) { + app.settings.set("schema", version); + } + + userVersion() { + return app.settings.get("schema"); + } +}; diff --git a/assets/javascripts/app/router.coffee b/assets/javascripts/app/router.coffee deleted file mode 100644 index ba25148acd..0000000000 --- a/assets/javascripts/app/router.coffee +++ /dev/null @@ -1,154 +0,0 @@ -class app.Router - $.extend @prototype, Events - - @routes: [ - ['*', 'before' ] - ['/', 'root' ] - ['/settings', 'settings' ] - ['/offline', 'offline' ] - ['/about', 'about' ] - ['/news', 'news' ] - ['/help', 'help' ] - ['/:doc-:type/', 'type' ] - ['/:doc/', 'doc' ] - ['/:doc/:path(*)', 'entry' ] - ['*', 'notFound' ] - ] - - constructor: -> - for [path, method] in @constructor.routes - page path, @[method].bind(@) - @setInitialPath() - - start: -> - page.start() - return - - show: (path) -> - page.show(path) - return - - triggerRoute: (name) -> - @trigger name, @context - @trigger 'after', name, @context - return - - before: (context, next) -> - previousContext = @context - @context = context - @trigger 'before', context - - if res = next() - @context = previousContext - return res - else - return - - doc: (context, next) -> - if doc = app.docs.findBySlug(context.params.doc) or app.disabledDocs.findBySlug(context.params.doc) - context.doc = doc - context.entry = doc.toEntry() - @triggerRoute 'entry' - return - else - return next() - - type: (context, next) -> - doc = app.docs.findBySlug(context.params.doc) - - if type = doc?.types.findBy 'slug', context.params.type - context.doc = doc - context.type = type - @triggerRoute 'type' - return - else - return next() - - entry: (context, next) -> - doc = app.docs.findBySlug(context.params.doc) - return next() unless doc - path = context.params.path - hash = context.hash - - if entry = doc.findEntryByPathAndHash(path, hash) - context.doc = doc - context.entry = entry - @triggerRoute 'entry' - return - else if path.slice(-6) is '/index' - path = path.substr(0, path.length - 6) - return entry.fullPath() if entry = doc.findEntryByPathAndHash(path, hash) - else - path = "#{path}/index" - return entry.fullPath() if entry = doc.findEntryByPathAndHash(path, hash) - - return next() - - root: -> - return '/' if app.isSingleDoc() - @triggerRoute 'root' - return - - settings: (context) -> - return "/#/#{context.path}" if app.isSingleDoc() - @triggerRoute 'settings' - return - - offline: (context)-> - return "/#/#{context.path}" if app.isSingleDoc() - @triggerRoute 'offline' - return - - about: (context) -> - return "/#/#{context.path}" if app.isSingleDoc() - context.page = 'about' - @triggerRoute 'page' - return - - news: (context) -> - return "/#/#{context.path}" if app.isSingleDoc() - context.page = 'news' - @triggerRoute 'page' - return - - help: (context) -> - return "/#/#{context.path}" if app.isSingleDoc() - context.page = 'help' - @triggerRoute 'page' - return - - notFound: (context) -> - @triggerRoute 'notFound' - return - - isIndex: -> - @context?.path is '/' or (app.isSingleDoc() and @context?.entry?.isIndex()) - - isSettings: -> - @context?.path is '/settings' - - setInitialPath: -> - # Remove superfluous forward slashes at the beginning of the path - if (path = location.pathname.replace /^\/{2,}/g, '/') isnt location.pathname - page.replace path + location.search + location.hash, null, true - - if location.pathname is '/' - if path = @getInitialPathFromHash() - page.replace path + location.search, null, true - else if path = @getInitialPathFromCookie() - page.replace path + location.search + location.hash, null, true - return - - getInitialPathFromHash: -> - try - (new RegExp "#/(.+)").exec(decodeURIComponent location.hash)?[1] - catch - - getInitialPathFromCookie: -> - if path = Cookies.get('initial_path') - Cookies.expire('initial_path') - path - - replaceHash: (hash) -> - page.replace location.pathname + location.search + (hash or ''), null, true - return diff --git a/assets/javascripts/app/router.js b/assets/javascripts/app/router.js new file mode 100644 index 0000000000..3c243341fe --- /dev/null +++ b/assets/javascripts/app/router.js @@ -0,0 +1,209 @@ +app.Router = class Router extends Events { + static routes = [ + ["*", "before"], + ["/", "root"], + ["/settings", "settings"], + ["/offline", "offline"], + ["/about", "about"], + ["/news", "news"], + ["/help", "help"], + ["/:doc-:type/", "type"], + ["/:doc/", "doc"], + ["/:doc/:path(*)", "entry"], + ["*", "notFound"], + ]; + + constructor() { + super(); + for (var [path, method] of this.constructor.routes) { + page(path, this[method].bind(this)); + } + this.setInitialPath(); + } + + start() { + page.start(); + } + + show(path) { + page.show(path); + } + + triggerRoute(name) { + this.trigger(name, this.context); + this.trigger("after", name, this.context); + } + + before(context, next) { + const previousContext = this.context; + this.context = context; + this.trigger("before", context); + + const res = next(); + if (res) { + this.context = previousContext; + return res; + } else { + return; + } + } + + doc(context, next) { + let doc; + if ( + (doc = + app.docs.findBySlug(context.params.doc) || + app.disabledDocs.findBySlug(context.params.doc)) + ) { + context.doc = doc; + context.entry = doc.toEntry(); + this.triggerRoute("entry"); + return; + } else { + return next(); + } + } + + type(context, next) { + const doc = app.docs.findBySlug(context.params.doc); + const type = doc?.types?.findBy("slug", context.params.type); + + if (type) { + context.doc = doc; + context.type = type; + this.triggerRoute("type"); + return; + } else { + return next(); + } + } + + entry(context, next) { + const doc = app.docs.findBySlug(context.params.doc); + if (!doc) { + return next(); + } + let { path } = context.params; + const { hash } = context; + + let entry = doc.findEntryByPathAndHash(path, hash); + if (entry) { + context.doc = doc; + context.entry = entry; + this.triggerRoute("entry"); + return; + } else if (path.slice(-6) === "/index") { + path = path.substr(0, path.length - 6); + entry = doc.findEntryByPathAndHash(path, hash); + if (entry) { + return entry.fullPath(); + } + } else { + path = `${path}/index`; + entry = doc.findEntryByPathAndHash(path, hash); + if (entry) { + return entry.fullPath(); + } + } + + return next(); + } + + root() { + if (app.isSingleDoc()) { + return "/"; + } + this.triggerRoute("root"); + } + + settings(context) { + if (app.isSingleDoc()) { + return `/#/${context.path}`; + } + this.triggerRoute("settings"); + } + + offline(context) { + if (app.isSingleDoc()) { + return `/#/${context.path}`; + } + this.triggerRoute("offline"); + } + + about(context) { + if (app.isSingleDoc()) { + return `/#/${context.path}`; + } + context.page = "about"; + this.triggerRoute("page"); + } + + news(context) { + if (app.isSingleDoc()) { + return `/#/${context.path}`; + } + context.page = "news"; + this.triggerRoute("page"); + } + + help(context) { + if (app.isSingleDoc()) { + return `/#/${context.path}`; + } + context.page = "help"; + this.triggerRoute("page"); + } + + notFound(context) { + this.triggerRoute("notFound"); + } + + isIndex() { + return ( + this.context?.path === "/" || + (app.isSingleDoc() && this.context?.entry?.isIndex()) + ); + } + + isSettings() { + return this.context?.path === "/settings"; + } + + setInitialPath() { + // Remove superfluous forward slashes at the beginning of the path + let path = location.pathname.replace(/^\/{2,}/g, "/"); + if (path !== location.pathname) { + page.replace(path + location.search + location.hash, null, true); + } + + if (location.pathname === "/") { + if ((path = this.getInitialPathFromHash())) { + page.replace(path + location.search, null, true); + } else if ((path = this.getInitialPathFromCookie())) { + page.replace(path + location.search + location.hash, null, true); + } + } + } + + getInitialPathFromHash() { + try { + return new RegExp("#/(.+)").exec(decodeURIComponent(location.hash))?.[1]; + } catch (error) {} + } + + getInitialPathFromCookie() { + const path = Cookies.get("initial_path"); + if (path) { + Cookies.expire("initial_path"); + return path; + } + } + + replaceHash(hash) { + page.replace( + location.pathname + location.search + (hash || ""), + null, + true + ); + } +}; diff --git a/assets/javascripts/app/searcher.coffee b/assets/javascripts/app/searcher.coffee deleted file mode 100644 index 79f6a304c3..0000000000 --- a/assets/javascripts/app/searcher.coffee +++ /dev/null @@ -1,292 +0,0 @@ -# -# Match functions -# - -SEPARATOR = '.' - -query = -queryLength = -value = -valueLength = -matcher = # current match function -fuzzyRegexp = # query fuzzy regexp -index = # position of the query in the string being matched -lastIndex = # last position of the query in the string being matched -match = # regexp match data -matchIndex = -matchLength = -score = # score for the current match -separators = # counter -i = null # cursor - -`function exactMatch() {` -index = value.indexOf(query) -return unless index >= 0 - -lastIndex = value.lastIndexOf(query) - -if index isnt lastIndex - return Math.max(scoreExactMatch(), ((index = lastIndex) and scoreExactMatch()) or 0) -else - return scoreExactMatch() -`}` - -`function scoreExactMatch() {` -# Remove one point for each unmatched character. -score = 100 - (valueLength - queryLength) - -if index > 0 - # If the character preceding the query is a dot, assign the same score - # as if the query was found at the beginning of the string, minus one. - if value.charAt(index - 1) is SEPARATOR - score += index - 1 - # Don't match a single-character query unless it's found at the beginning - # of the string or is preceded by a dot. - else if queryLength is 1 - return - # (1) Remove one point for each unmatched character up to the nearest - # preceding dot or the beginning of the string. - # (2) Remove one point for each unmatched character following the query. - else - i = index - 2 - i-- while i >= 0 and value.charAt(i) isnt SEPARATOR - score -= (index - i) + # (1) - (valueLength - queryLength - index) # (2) - - # Remove one point for each dot preceding the query, except for the one - # immediately before the query. - separators = 0 - i = index - 2 - while i >= 0 - separators++ if value.charAt(i) is SEPARATOR - i-- - score -= separators - -# Remove five points for each dot following the query. -separators = 0 -i = valueLength - queryLength - index - 1 -while i >= 0 - separators++ if value.charAt(index + queryLength + i) is SEPARATOR - i-- -score -= separators * 5 - -return Math.max 1, score -`}` - -`function fuzzyMatch() {` -return if valueLength <= queryLength or value.indexOf(query) >= 0 -return unless match = fuzzyRegexp.exec(value) -matchIndex = match.index -matchLength = match[0].length -score = scoreFuzzyMatch() -if match = fuzzyRegexp.exec(value.slice(i = value.lastIndexOf(SEPARATOR) + 1)) - matchIndex = i + match.index - matchLength = match[0].length - return Math.max(score, scoreFuzzyMatch()) -else - return score -`}` - -`function scoreFuzzyMatch() {` -# When the match is at the beginning of the string or preceded by a dot. -if matchIndex is 0 or value.charAt(matchIndex - 1) is SEPARATOR - return Math.max 66, 100 - matchLength -# When the match is at the end of the string. -else if matchIndex + matchLength is valueLength - return Math.max 33, 67 - matchLength -# When the match is in the middle of the string. -else - return Math.max 1, 34 - matchLength -`}` - -# -# Searchers -# - -class app.Searcher - $.extend @prototype, Events - - CHUNK_SIZE = 20000 - - DEFAULTS = - max_results: app.config.max_results - fuzzy_min_length: 3 - - SEPARATORS_REGEXP = /#|::|:-|->|\$(?=\w)|\-(?=\w)|\:(?=\w)|\ [\/\-&]\ |:\ |\ /g - EOS_SEPARATORS_REGEXP = /(\w)[\-:]$/ - INFO_PARANTHESES_REGEXP = /\ \(\w+?\)$/ - EMPTY_PARANTHESES_REGEXP = /\(\)/ - EVENT_REGEXP = /\ event$/ - DOT_REGEXP = /\.+/g - WHITESPACE_REGEXP = /\s/g - - EMPTY_STRING = '' - ELLIPSIS = '...' - STRING = 'string' - - @normalizeString: (string) -> - string - .toLowerCase() - .replace ELLIPSIS, EMPTY_STRING - .replace EVENT_REGEXP, EMPTY_STRING - .replace INFO_PARANTHESES_REGEXP, EMPTY_STRING - .replace SEPARATORS_REGEXP, SEPARATOR - .replace DOT_REGEXP, SEPARATOR - .replace EMPTY_PARANTHESES_REGEXP, EMPTY_STRING - .replace WHITESPACE_REGEXP, EMPTY_STRING - - @normalizeQuery: (string) -> - string = @normalizeString(string) - string.replace EOS_SEPARATORS_REGEXP, '$1.' - - constructor: (options = {}) -> - @options = $.extend {}, DEFAULTS, options - - find: (data, attr, q) -> - @kill() - - @data = data - @attr = attr - @query = q - @setup() - - if @isValid() then @match() else @end() - return - - setup: -> - query = @query = @constructor.normalizeQuery(@query) - queryLength = query.length - @dataLength = @data.length - @matchers = [exactMatch] - @totalResults = 0 - @setupFuzzy() - return - - setupFuzzy: -> - if queryLength >= @options.fuzzy_min_length - fuzzyRegexp = @queryToFuzzyRegexp(query) - @matchers.push(fuzzyMatch) - else - fuzzyRegexp = null - return - - isValid: -> - queryLength > 0 and query isnt SEPARATOR - - end: -> - @triggerResults [] unless @totalResults - @trigger 'end' - @free() - return - - kill: -> - if @timeout - clearTimeout @timeout - @free() - return - - free: -> - @data = @attr = @dataLength = @matchers = @matcher = @query = - @totalResults = @scoreMap = @cursor = @timeout = null - return - - match: => - if not @foundEnough() and @matcher = @matchers.shift() - @setupMatcher() - @matchChunks() - else - @end() - return - - setupMatcher: -> - @cursor = 0 - @scoreMap = new Array(101) - return - - matchChunks: => - @matchChunk() - - if @cursor is @dataLength or @scoredEnough() - @delay @match - @sendResults() - else - @delay @matchChunks - return - - matchChunk: -> - matcher = @matcher - for [0...@chunkSize()] - value = @data[@cursor][@attr] - if value.split # string - valueLength = value.length - @addResult(@data[@cursor], score) if score = matcher() - else # array - score = 0 - for value in @data[@cursor][@attr] - valueLength = value.length - score = Math.max(score, matcher() || 0) - @addResult(@data[@cursor], score) if score > 0 - @cursor++ - return - - chunkSize: -> - if @cursor + CHUNK_SIZE > @dataLength - @dataLength % CHUNK_SIZE - else - CHUNK_SIZE - - scoredEnough: -> - @scoreMap[100]?.length >= @options.max_results - - foundEnough: -> - @totalResults >= @options.max_results - - addResult: (object, score) -> - (@scoreMap[Math.round(score)] or= []).push(object) - @totalResults++ - return - - getResults: -> - results = [] - for objects in @scoreMap by -1 when objects - results.push.apply results, objects - results[0...@options.max_results] - - sendResults: -> - results = @getResults() - @triggerResults results if results.length - return - - triggerResults: (results) -> - @trigger 'results', results - return - - delay: (fn) -> - @timeout = setTimeout(fn, 1) - - queryToFuzzyRegexp: (string) -> - chars = string.split '' - chars[i] = $.escapeRegexp(char) for char, i in chars - new RegExp chars.join('.*?') # abc -> /a.*?b.*?c.*?/ - -class app.SynchronousSearcher extends app.Searcher - match: => - if @matcher - @allResults or= [] - @allResults.push.apply @allResults, @getResults() - super - - free: -> - @allResults = null - super - - end: -> - @sendResults true - super - - sendResults: (end) -> - if end and @allResults?.length - @triggerResults @allResults - - delay: (fn) -> - fn() diff --git a/assets/javascripts/app/searcher.js b/assets/javascripts/app/searcher.js new file mode 100644 index 0000000000..7cd6e82614 --- /dev/null +++ b/assets/javascripts/app/searcher.js @@ -0,0 +1,396 @@ +// +// Match functions +// + +let fuzzyRegexp, + i, + index, + lastIndex, + match, + matcher, + matchIndex, + matchLength, + queryLength, + score, + separators, + value, + valueLength; +const SEPARATOR = "."; + +let query = + (queryLength = + value = + valueLength = + matcher = // current match function + fuzzyRegexp = // query fuzzy regexp + index = // position of the query in the string being matched + lastIndex = // last position of the query in the string being matched + match = // regexp match data + matchIndex = + matchLength = + score = // score for the current match + separators = // counter + i = + null); // cursor + +function exactMatch() { + index = value.indexOf(query); + if (!(index >= 0)) { + return; + } + + lastIndex = value.lastIndexOf(query); + + if (index !== lastIndex) { + return Math.max( + scoreExactMatch(), + ((index = lastIndex) && scoreExactMatch()) || 0, + ); + } else { + return scoreExactMatch(); + } +} + +function scoreExactMatch() { + // Remove one point for each unmatched character. + score = 100 - (valueLength - queryLength); + + if (index > 0) { + // If the character preceding the query is a dot, assign the same score + // as if the query was found at the beginning of the string, minus one. + if (value.charAt(index - 1) === SEPARATOR) { + score += index - 1; + // Don't match a single-character query unless it's found at the beginning + // of the string or is preceded by a dot. + } else if (queryLength === 1) { + return; + // (1) Remove one point for each unmatched character up to the nearest + // preceding dot or the beginning of the string. + // (2) Remove one point for each unmatched character following the query. + } else { + i = index - 2; + while (i >= 0 && value.charAt(i) !== SEPARATOR) { + i--; + } + score -= + index - + i + // (1) + (valueLength - queryLength - index); // (2) + } + + // Remove one point for each dot preceding the query, except for the one + // immediately before the query. + separators = 0; + i = index - 2; + while (i >= 0) { + if (value.charAt(i) === SEPARATOR) { + separators++; + } + i--; + } + score -= separators; + } + + // Remove five points for each dot following the query. + separators = 0; + i = valueLength - queryLength - index - 1; + while (i >= 0) { + if (value.charAt(index + queryLength + i) === SEPARATOR) { + separators++; + } + i--; + } + score -= separators * 5; + + return Math.max(1, score); +} + +function fuzzyMatch() { + if (valueLength <= queryLength || value.includes(query)) { + return; + } + if (!(match = fuzzyRegexp.exec(value))) { + return; + } + matchIndex = match.index; + matchLength = match[0].length; + score = scoreFuzzyMatch(); + if ( + (match = fuzzyRegexp.exec( + value.slice((i = value.lastIndexOf(SEPARATOR) + 1)), + )) + ) { + matchIndex = i + match.index; + matchLength = match[0].length; + return Math.max(score, scoreFuzzyMatch()); + } else { + return score; + } +} + +function scoreFuzzyMatch() { + // When the match is at the beginning of the string or preceded by a dot. + if (matchIndex === 0 || value.charAt(matchIndex - 1) === SEPARATOR) { + return Math.max(66, 100 - matchLength); + // When the match is at the end of the string. + } else if (matchIndex + matchLength === valueLength) { + return Math.max(33, 67 - matchLength); + // When the match is in the middle of the string. + } else { + return Math.max(1, 34 - matchLength); + } +} + +// +// Searchers +// + +app.Searcher = class Searcher extends Events { + static CHUNK_SIZE = 20000; + + static DEFAULTS = { + max_results: app.config.max_results, + fuzzy_min_length: 3, + }; + + static SEPARATORS_REGEXP = + /#|::|:-|->|\$(?=\w)|\-(?=\w)|\:(?=\w)|\ [\/\-&]\ |:\ |\ /g; + static EOS_SEPARATORS_REGEXP = /(\w)[\-:]$/; + static INFO_PARANTHESES_REGEXP = /\ \(\w+?\)$/; + static EMPTY_PARANTHESES_REGEXP = /\(\)/; + static EVENT_REGEXP = /\ event$/; + static DOT_REGEXP = /\.+/g; + static WHITESPACE_REGEXP = /\s/g; + + static EMPTY_STRING = ""; + static ELLIPSIS = "..."; + static STRING = "string"; + + static normalizeString(string) { + return string + .toLowerCase() + .replace(Searcher.ELLIPSIS, Searcher.EMPTY_STRING) + .replace(Searcher.EVENT_REGEXP, Searcher.EMPTY_STRING) + .replace(Searcher.INFO_PARANTHESES_REGEXP, Searcher.EMPTY_STRING) + .replace(Searcher.SEPARATORS_REGEXP, SEPARATOR) + .replace(Searcher.DOT_REGEXP, SEPARATOR) + .replace(Searcher.EMPTY_PARANTHESES_REGEXP, Searcher.EMPTY_STRING) + .replace(Searcher.WHITESPACE_REGEXP, Searcher.EMPTY_STRING); + } + + static normalizeQuery(string) { + string = this.normalizeString(string); + return string.replace(Searcher.EOS_SEPARATORS_REGEXP, "$1."); + } + + constructor(options) { + super(); + this.options = { ...Searcher.DEFAULTS, ...(options || {}) }; + } + + find(data, attr, q) { + this.kill(); + + this.data = data; + this.attr = attr; + this.query = q; + this.setup(); + + if (this.isValid()) { + this.match(); + } else { + this.end(); + } + } + + setup() { + query = this.query = this.constructor.normalizeQuery(this.query); + queryLength = query.length; + this.dataLength = this.data.length; + this.matchers = [exactMatch]; + this.totalResults = 0; + this.setupFuzzy(); + } + + setupFuzzy() { + if (queryLength >= this.options.fuzzy_min_length) { + fuzzyRegexp = this.queryToFuzzyRegexp(query); + this.matchers.push(fuzzyMatch); + } else { + fuzzyRegexp = null; + } + } + + isValid() { + return queryLength > 0 && query !== SEPARATOR; + } + + end() { + if (!this.totalResults) { + this.triggerResults([]); + } + this.trigger("end"); + this.free(); + } + + kill() { + if (this.timeout) { + clearTimeout(this.timeout); + this.free(); + } + } + + free() { + this.data = null; + this.attr = null; + this.dataLength = null; + this.matchers = null; + this.matcher = null; + this.query = null; + this.totalResults = null; + this.scoreMap = null; + this.cursor = null; + this.timeout = null; + } + + match() { + if (!this.foundEnough() && (this.matcher = this.matchers.shift())) { + this.setupMatcher(); + this.matchChunks(); + } else { + this.end(); + } + } + + setupMatcher() { + this.cursor = 0; + this.scoreMap = new Array(101); + } + + matchChunks() { + this.matchChunk(); + + if (this.cursor === this.dataLength || this.scoredEnough()) { + this.delay(() => this.match()); + this.sendResults(); + } else { + this.delay(() => this.matchChunks()); + } + } + + matchChunk() { + ({ matcher } = this); + for (let j = 0, end = this.chunkSize(); j < end; j++) { + value = this.data[this.cursor][this.attr]; + if (value.split) { + // string + valueLength = value.length; + if ((score = matcher())) { + this.addResult(this.data[this.cursor], score); + } + } else { + // array + score = 0; + for (value of Array.from(this.data[this.cursor][this.attr])) { + valueLength = value.length; + score = Math.max(score, matcher() || 0); + } + if (score > 0) { + this.addResult(this.data[this.cursor], score); + } + } + this.cursor++; + } + } + + chunkSize() { + if (this.cursor + Searcher.CHUNK_SIZE > this.dataLength) { + return this.dataLength % Searcher.CHUNK_SIZE; + } else { + return Searcher.CHUNK_SIZE; + } + } + + scoredEnough() { + return this.scoreMap[100]?.length >= this.options.max_results; + } + + foundEnough() { + return this.totalResults >= this.options.max_results; + } + + addResult(object, score) { + let name; + ( + this.scoreMap[(name = Math.round(score))] || (this.scoreMap[name] = []) + ).push(object); + this.totalResults++; + } + + getResults() { + const results = []; + for (let j = this.scoreMap.length - 1; j >= 0; j--) { + var objects = this.scoreMap[j]; + if (objects) { + results.push(...objects); + } + } + return results.slice(0, this.options.max_results); + } + + sendResults() { + const results = this.getResults(); + if (results.length) { + this.triggerResults(results); + } + } + + triggerResults(results) { + this.trigger("results", results); + } + + delay(fn) { + return (this.timeout = setTimeout(fn, 1)); + } + + queryToFuzzyRegexp(string) { + const chars = string.split(""); + for (i = 0; i < chars.length; i++) { + var char = chars[i]; + chars[i] = $.escapeRegexp(char); + } + return new RegExp(chars.join(".*?")); // abc -> /a.*?b.*?c.*?/ + } +}; + +app.SynchronousSearcher = class SynchronousSearcher extends app.Searcher { + match() { + if (this.matcher) { + if (!this.allResults) { + this.allResults = []; + } + this.allResults.push(...this.getResults()); + } + return super.match(...arguments); + } + + free() { + this.allResults = null; + return super.free(...arguments); + } + + end() { + this.sendResults(true); + return super.end(...arguments); + } + + sendResults(end) { + if (end && this.allResults?.length) { + return this.triggerResults(this.allResults); + } + } + + delay(fn) { + return fn(); + } +}; diff --git a/assets/javascripts/app/serviceworker.coffee b/assets/javascripts/app/serviceworker.coffee deleted file mode 100644 index 4023556658..0000000000 --- a/assets/javascripts/app/serviceworker.coffee +++ /dev/null @@ -1,49 +0,0 @@ -class app.ServiceWorker - $.extend @prototype, Events - - @isEnabled: -> - !!navigator.serviceWorker and app.config.service_worker_enabled - - constructor: -> - @registration = null - @notifyUpdate = true - - navigator.serviceWorker.register(app.config.service_worker_path, {scope: '/'}) - .then( - (registration) => @updateRegistration(registration), - (error) -> console.error('Could not register service worker:', error) - ) - - update: -> - return unless @registration - @notifyUpdate = true - return @registration.update().catch(->) - - updateInBackground: -> - return unless @registration - @notifyUpdate = false - return @registration.update().catch(->) - - reload: -> - return @updateInBackground().then(() -> app.reboot()) - - updateRegistration: (registration) -> - @registration = registration - $.on @registration, 'updatefound', @onUpdateFound - return - - onUpdateFound: => - $.off @installingRegistration, 'statechange', @onStateChange() if @installingRegistration - @installingRegistration = @registration.installing - $.on @installingRegistration, 'statechange', @onStateChange - return - - onStateChange: => - if @installingRegistration and @installingRegistration.state == 'installed' and navigator.serviceWorker.controller - @installingRegistration = null - @onUpdateReady() - return - - onUpdateReady: -> - @trigger 'updateready' if @notifyUpdate - return diff --git a/assets/javascripts/app/serviceworker.js b/assets/javascripts/app/serviceworker.js new file mode 100644 index 0000000000..4c35a32c32 --- /dev/null +++ b/assets/javascripts/app/serviceworker.js @@ -0,0 +1,69 @@ +app.ServiceWorker = class ServiceWorker extends Events { + static isEnabled() { + return !!navigator.serviceWorker && app.config.service_worker_enabled; + } + + constructor() { + super(); + this.onStateChange = this.onStateChange.bind(this); + this.registration = null; + this.notifyUpdate = true; + + navigator.serviceWorker + .register(app.config.service_worker_path, { scope: "/" }) + .then( + (registration) => this.updateRegistration(registration), + (error) => console.error("Could not register service worker:", error), + ); + } + + update() { + if (!this.registration) { + return; + } + this.notifyUpdate = true; + return this.registration.update().catch(() => {}); + } + + updateInBackground() { + if (!this.registration) { + return; + } + this.notifyUpdate = false; + return this.registration.update().catch(() => {}); + } + + reload() { + return this.updateInBackground().then(() => app.reboot()); + } + + updateRegistration(registration) { + this.registration = registration; + $.on(this.registration, "updatefound", () => this.onUpdateFound()); + } + + onUpdateFound() { + if (this.installingRegistration) { + $.off(this.installingRegistration, "statechange", this.onStateChange); + } + this.installingRegistration = this.registration.installing; + $.on(this.installingRegistration, "statechange", this.onStateChange); + } + + onStateChange() { + if ( + this.installingRegistration && + this.installingRegistration.state === "installed" && + navigator.serviceWorker.controller + ) { + this.installingRegistration = null; + this.onUpdateReady(); + } + } + + onUpdateReady() { + if (this.notifyUpdate) { + this.trigger("updateready"); + } + } +}; diff --git a/assets/javascripts/app/settings.coffee b/assets/javascripts/app/settings.coffee deleted file mode 100644 index 74e32a658c..0000000000 --- a/assets/javascripts/app/settings.coffee +++ /dev/null @@ -1,170 +0,0 @@ -class app.Settings - PREFERENCE_KEYS = [ - 'hideDisabled' - 'hideIntro' - 'manualUpdate' - 'fastScroll' - 'arrowScroll' - 'analyticsConsent' - 'docs' - 'dark' # legacy - 'theme' - 'layout' - 'size' - 'tips' - 'noAutofocus' - 'autoInstall' - 'spaceScroll' - 'spaceTimeout' - ] - - INTERNAL_KEYS = [ - 'count' - 'schema' - 'version' - 'news' - ] - - LAYOUTS: [ - '_max-width' - '_sidebar-hidden' - '_native-scrollbars' - '_text-justify-hyphenate' - ] - - @defaults: - count: 0 - hideDisabled: false - hideIntro: false - news: 0 - manualUpdate: false - schema: 1 - analyticsConsent: false - theme: 'auto' - spaceScroll: 1 - spaceTimeout: 0.5 - - constructor: -> - @store = new CookiesStore - @cache = {} - @autoSupported = window.matchMedia('(prefers-color-scheme)').media != 'not all' - if @autoSupported - @darkModeQuery = window.matchMedia('(prefers-color-scheme: dark)') - @darkModeQuery.addListener => @setTheme(@get('theme')) - - - get: (key) -> - return @cache[key] if @cache.hasOwnProperty(key) - @cache[key] = @store.get(key) ? @constructor.defaults[key] - if key == 'theme' and @cache[key] == 'auto' and !@darkModeQuery - @cache[key] = 'default' - else - @cache[key] - - set: (key, value) -> - @store.set(key, value) - delete @cache[key] - @setTheme(value) if key == 'theme' - return - - del: (key) -> - @store.del(key) - delete @cache[key] - return - - hasDocs: -> - try !!@store.get('docs') - - getDocs: -> - @store.get('docs')?.split('/') or app.config.default_docs - - setDocs: (docs) -> - @set 'docs', docs.join('/') - return - - getTips: -> - @store.get('tips')?.split('/') or [] - - setTips: (tips) -> - @set 'tips', tips.join('/') - return - - setLayout: (name, enable) -> - @toggleLayout(name, enable) - - layout = (@store.get('layout') || '').split(' ') - $.arrayDelete(layout, '') - - if enable - layout.push(name) if layout.indexOf(name) is -1 - else - $.arrayDelete(layout, name) - - if layout.length > 0 - @set 'layout', layout.join(' ') - else - @del 'layout' - return - - hasLayout: (name) -> - layout = (@store.get('layout') || '').split(' ') - layout.indexOf(name) isnt -1 - - setSize: (value) -> - @set 'size', value - return - - dump: -> - @store.dump() - - export: -> - data = @dump() - delete data[key] for key in INTERNAL_KEYS - data - - import: (data) -> - for key, value of @export() - @del key unless data.hasOwnProperty(key) - for key, value of data - @set key, value if PREFERENCE_KEYS.indexOf(key) isnt -1 - return - - reset: -> - @store.reset() - @cache = {} - return - - initLayout: -> - if @get('dark') is 1 - @set('theme', 'dark') - @del 'dark' - @setTheme(@get('theme')) - @toggleLayout(layout, @hasLayout(layout)) for layout in @LAYOUTS - @initSidebarWidth() - return - - setTheme: (theme) -> - if theme is 'auto' - theme = if @darkModeQuery.matches then 'dark' else 'default' - classList = document.documentElement.classList - classList.remove('_theme-default', '_theme-dark') - classList.add('_theme-' + theme) - @updateColorMeta() - return - - updateColorMeta: -> - color = getComputedStyle(document.documentElement).getPropertyValue('--headerBackground').trim() - $('meta[name=theme-color]').setAttribute('content', color) - return - - toggleLayout: (layout, enable) -> - classList = document.body.classList - # sidebar is always shown for settings; its state is updated in app.views.Settings - classList.toggle(layout, enable) unless layout is '_sidebar-hidden' and app.router?.isSettings - classList.toggle('_overlay-scrollbars', $.overlayScrollbarsEnabled()) - return - - initSidebarWidth: -> - size = @get('size') - document.documentElement.style.setProperty('--sidebarWidth', size + 'px') if size - return diff --git a/assets/javascripts/app/settings.js b/assets/javascripts/app/settings.js new file mode 100644 index 0000000000..617830ecc9 --- /dev/null +++ b/assets/javascripts/app/settings.js @@ -0,0 +1,215 @@ +app.Settings = class Settings { + static PREFERENCE_KEYS = [ + "hideDisabled", + "hideIntro", + "manualUpdate", + "fastScroll", + "arrowScroll", + "analyticsConsent", + "docs", + "dark", // legacy + "theme", + "layout", + "size", + "tips", + "noAutofocus", + "autoInstall", + "spaceScroll", + "spaceTimeout", + "noDocSpecificIcon", + ]; + + static INTERNAL_KEYS = ["count", "schema", "version", "news"]; + + static LAYOUTS = [ + "_max-width", + "_sidebar-hidden", + "_native-scrollbars", + "_text-justify-hyphenate", + ]; + + static defaults = { + count: 0, + hideDisabled: false, + hideIntro: false, + news: 0, + manualUpdate: false, + schema: 1, + analyticsConsent: false, + theme: "auto", + spaceScroll: 1, + spaceTimeout: 0.5, + noDocSpecificIcon: false, + }; + + constructor() { + this.store = new CookiesStore(); + this.cache = {}; + this.autoSupported = + window.matchMedia("(prefers-color-scheme)").media !== "not all"; + if (this.autoSupported) { + this.darkModeQuery = window.matchMedia("(prefers-color-scheme: dark)"); + this.darkModeQuery.addListener(() => this.setTheme(this.get("theme"))); + } + } + + get(key) { + let left; + if (this.cache.hasOwnProperty(key)) { + return this.cache[key]; + } + this.cache[key] = + (left = this.store.get(key)) != null + ? left + : this.constructor.defaults[key]; + if (key === "theme" && this.cache[key] === "auto" && !this.darkModeQuery) { + return (this.cache[key] = "default"); + } else { + return this.cache[key]; + } + } + + set(key, value) { + this.store.set(key, value); + delete this.cache[key]; + if (key === "theme") { + this.setTheme(value); + } + } + + del(key) { + this.store.del(key); + delete this.cache[key]; + } + + hasDocs() { + try { + return !!this.store.get("docs"); + } catch (error) {} + } + + getDocs() { + return this.store.get("docs")?.split("/") || app.config.default_docs; + } + + setDocs(docs) { + this.set("docs", docs.join("/")); + } + + getTips() { + return this.store.get("tips")?.split("/") || []; + } + + setTips(tips) { + this.set("tips", tips.join("/")); + } + + setLayout(name, enable) { + this.toggleLayout(name, enable); + + const layout = (this.store.get("layout") || "").split(" "); + $.arrayDelete(layout, ""); + + if (enable) { + if (!layout.includes(name)) { + layout.push(name); + } + } else { + $.arrayDelete(layout, name); + } + + if (layout.length > 0) { + this.set("layout", layout.join(" ")); + } else { + this.del("layout"); + } + } + + hasLayout(name) { + const layout = (this.store.get("layout") || "").split(" "); + return layout.includes(name); + } + + setSize(value) { + this.set("size", value); + } + + dump() { + return this.store.dump(); + } + + export() { + const data = this.dump(); + for (var key of Settings.INTERNAL_KEYS) { + delete data[key]; + } + return data; + } + + import(data) { + let key, value; + const object = this.export(); + for (key in object) { + value = object[key]; + if (!data.hasOwnProperty(key)) { + this.del(key); + } + } + for (key in data) { + value = data[key]; + if (Settings.PREFERENCE_KEYS.includes(key)) { + this.set(key, value); + } + } + } + + reset() { + this.store.reset(); + this.cache = {}; + } + + initLayout() { + if (this.get("dark") === 1) { + this.set("theme", "dark"); + this.del("dark"); + } + this.setTheme(this.get("theme")); + for (var layout of app.Settings.LAYOUTS) { + this.toggleLayout(layout, this.hasLayout(layout)); + } + this.initSidebarWidth(); + } + + setTheme(theme) { + if (theme === "auto") { + theme = this.darkModeQuery.matches ? "dark" : "default"; + } + const { classList } = document.documentElement; + classList.remove("_theme-default", "_theme-dark"); + classList.add("_theme-" + theme); + this.updateColorMeta(); + } + + updateColorMeta() { + const color = getComputedStyle(document.documentElement) + .getPropertyValue("--headerBackground") + .trim(); + $("meta[name=theme-color]").setAttribute("content", color); + } + + toggleLayout(layout, enable) { + const { classList } = document.body; + // sidebar is always shown for settings; its state is updated in app.views.Settings + if (layout !== "_sidebar-hidden" || !app.router?.isSettings) { + classList.toggle(layout, enable); + } + classList.toggle("_overlay-scrollbars", $.overlayScrollbarsEnabled()); + } + + initSidebarWidth() { + const size = this.get("size"); + if (size) { + document.documentElement.style.setProperty("--sidebarWidth", size + "px"); + } + } +}; diff --git a/assets/javascripts/app/shortcuts.coffee b/assets/javascripts/app/shortcuts.coffee deleted file mode 100644 index 28ddf0b8a2..0000000000 --- a/assets/javascripts/app/shortcuts.coffee +++ /dev/null @@ -1,193 +0,0 @@ -class app.Shortcuts - $.extend @prototype, Events - - constructor: -> - @isMac = $.isMac() - @start() - - start: -> - $.on document, 'keydown', @onKeydown - $.on document, 'keypress', @onKeypress - return - - stop: -> - $.off document, 'keydown', @onKeydown - $.off document, 'keypress', @onKeypress - return - - swapArrowKeysBehavior: -> - app.settings.get('arrowScroll') - - spaceScroll: -> - app.settings.get('spaceScroll') - - showTip: -> - app.showTip('KeyNav') - @showTip = null - - spaceTimeout: -> - app.settings.get('spaceTimeout') - - onKeydown: (event) => - return if @buggyEvent(event) - result = if event.ctrlKey or event.metaKey - @handleKeydownSuperEvent event unless event.altKey or event.shiftKey - else if event.shiftKey - @handleKeydownShiftEvent event unless event.altKey - else if event.altKey - @handleKeydownAltEvent event - else - @handleKeydownEvent event - - event.preventDefault() if result is false - return - - onKeypress: (event) => - return if @buggyEvent(event) or (event.charCode == 63 and document.activeElement.tagName == 'INPUT') - unless event.ctrlKey or event.metaKey - result = @handleKeypressEvent event - event.preventDefault() if result is false - return - - handleKeydownEvent: (event, _force) -> - return @handleKeydownAltEvent(event, true) if not _force and event.which in [37, 38, 39, 40] and @swapArrowKeysBehavior() - - if not event.target.form and (48 <= event.which <= 57 or 65 <= event.which <= 90) - @trigger 'typing' - return - - switch event.which - when 8 - @trigger 'typing' unless event.target.form - when 13 - @trigger 'enter' - when 27 - @trigger 'escape' - false - when 32 - if event.target.type is 'search' and @spaceScroll() and (not @lastKeypress or @lastKeypress < Date.now() - (@spaceTimeout() * 1000)) - @trigger 'pageDown' - false - when 33 - @trigger 'pageUp' - when 34 - @trigger 'pageDown' - when 35 - @trigger 'pageBottom' unless event.target.form - when 36 - @trigger 'pageTop' unless event.target.form - when 37 - @trigger 'left' unless event.target.value - when 38 - @trigger 'up' - @showTip?() - false - when 39 - @trigger 'right' unless event.target.value - when 40 - @trigger 'down' - @showTip?() - false - when 191 - unless event.target.form - @trigger 'typing' - false - - handleKeydownSuperEvent: (event) -> - switch event.which - when 13 - @trigger 'superEnter' - when 37 - if @isMac - @trigger 'superLeft' - false - when 38 - @trigger 'pageTop' - false - when 39 - if @isMac - @trigger 'superRight' - false - when 40 - @trigger 'pageBottom' - false - when 188 - @trigger 'preferences' - false - - handleKeydownShiftEvent: (event, _force) -> - return @handleKeydownEvent(event, true) if not _force and event.which in [37, 38, 39, 40] and @swapArrowKeysBehavior() - - if not event.target.form and 65 <= event.which <= 90 - @trigger 'typing' - return - - switch event.which - when 32 - @trigger 'pageUp' - false - when 38 - unless getSelection()?.toString() - @trigger 'altUp' - false - when 40 - unless getSelection()?.toString() - @trigger 'altDown' - false - - handleKeydownAltEvent: (event, _force) -> - return @handleKeydownEvent(event, true) if not _force and event.which in [37, 38, 39, 40] and @swapArrowKeysBehavior() - - switch event.which - when 9 - @trigger 'altRight', event - when 37 - unless @isMac - @trigger 'superLeft' - false - when 38 - @trigger 'altUp' - false - when 39 - unless @isMac - @trigger 'superRight' - false - when 40 - @trigger 'altDown' - false - when 67 - @trigger 'altC' - false - when 68 - @trigger 'altD' - false - when 70 - @trigger 'altF', event - when 71 - @trigger 'altG' - false - when 79 - @trigger 'altO' - false - when 82 - @trigger 'altR' - false - when 83 - @trigger 'altS' - false - - handleKeypressEvent: (event) -> - if event.which is 63 and not event.target.value - @trigger 'help' - false - else - @lastKeypress = Date.now() - - buggyEvent: (event) -> - try - event.target - event.ctrlKey - event.which - return false - catch - return true diff --git a/assets/javascripts/app/shortcuts.js b/assets/javascripts/app/shortcuts.js new file mode 100644 index 0000000000..05e09cf7bd --- /dev/null +++ b/assets/javascripts/app/shortcuts.js @@ -0,0 +1,295 @@ +app.Shortcuts = class Shortcuts extends Events { + constructor() { + super(); + this.onKeydown = this.onKeydown.bind(this); + this.onKeypress = this.onKeypress.bind(this); + this.isMac = $.isMac(); + this.start(); + } + + start() { + $.on(document, "keydown", this.onKeydown); + $.on(document, "keypress", this.onKeypress); + } + + stop() { + $.off(document, "keydown", this.onKeydown); + $.off(document, "keypress", this.onKeypress); + } + + swapArrowKeysBehavior() { + return app.settings.get("arrowScroll"); + } + + spaceScroll() { + return app.settings.get("spaceScroll"); + } + + showTip() { + app.showTip("KeyNav"); + return (this.showTip = null); + } + + spaceTimeout() { + return app.settings.get("spaceTimeout"); + } + + onKeydown(event) { + if (this.buggyEvent(event)) { + return; + } + const result = (() => { + if (event.ctrlKey || event.metaKey) { + if (!event.altKey && !event.shiftKey) { + return this.handleKeydownSuperEvent(event); + } + } else if (event.shiftKey) { + if (!event.altKey) { + return this.handleKeydownShiftEvent(event); + } + } else if (event.altKey) { + return this.handleKeydownAltEvent(event); + } else { + return this.handleKeydownEvent(event); + } + })(); + + if (result === false) { + event.preventDefault(); + } + } + + onKeypress(event) { + if ( + this.buggyEvent(event) || + (event.charCode === 63 && document.activeElement.tagName === "INPUT") + ) { + return; + } + if (!event.ctrlKey && !event.metaKey) { + const result = this.handleKeypressEvent(event); + if (result === false) { + event.preventDefault(); + } + } + } + + handleKeydownEvent(event, _force) { + if ( + !_force && + [37, 38, 39, 40].includes(event.which) && + this.swapArrowKeysBehavior() + ) { + return this.handleKeydownAltEvent(event, true); + } + + if ( + !event.target.form && + ((48 <= event.which && event.which <= 57) || + (65 <= event.which && event.which <= 90)) + ) { + this.trigger("typing"); + return; + } + + switch (event.which) { + case 8: + if (!event.target.form) { + return this.trigger("typing"); + } + break; + case 13: + return this.trigger("enter"); + case 27: + this.trigger("escape"); + return false; + case 32: + if ( + event.target.type === "search" && + this.spaceScroll() && + (!this.lastKeypress || + this.lastKeypress < Date.now() - this.spaceTimeout() * 1000) + ) { + this.trigger("pageDown"); + return false; + } + break; + case 33: + return this.trigger("pageUp"); + case 34: + return this.trigger("pageDown"); + case 35: + if (!event.target.form) { + return this.trigger("pageBottom"); + } + break; + case 36: + if (!event.target.form) { + return this.trigger("pageTop"); + } + break; + case 37: + if (!event.target.value) { + return this.trigger("left"); + } + break; + case 38: + this.trigger("up"); + if (typeof this.showTip === "function") { + this.showTip(); + } + return false; + case 39: + if (!event.target.value) { + return this.trigger("right"); + } + break; + case 40: + this.trigger("down"); + if (typeof this.showTip === "function") { + this.showTip(); + } + return false; + case 191: + if (!event.target.form) { + this.trigger("typing"); + return false; + } + break; + } + } + + handleKeydownSuperEvent(event) { + switch (event.which) { + case 13: + return this.trigger("superEnter"); + case 37: + if (this.isMac) { + this.trigger("superLeft"); + return false; + } + break; + case 38: + this.trigger("pageTop"); + return false; + case 39: + if (this.isMac) { + this.trigger("superRight"); + return false; + } + break; + case 40: + this.trigger("pageBottom"); + return false; + case 188: + this.trigger("preferences"); + return false; + } + } + + handleKeydownShiftEvent(event, _force) { + if ( + !_force && + [37, 38, 39, 40].includes(event.which) && + this.swapArrowKeysBehavior() + ) { + return this.handleKeydownEvent(event, true); + } + + if (!event.target.form && 65 <= event.which && event.which <= 90) { + this.trigger("typing"); + return; + } + + switch (event.which) { + case 32: + this.trigger("pageUp"); + return false; + case 38: + if (!getSelection()?.toString()) { + this.trigger("altUp"); + return false; + } + break; + case 40: + if (!getSelection()?.toString()) { + this.trigger("altDown"); + return false; + } + break; + } + } + + handleKeydownAltEvent(event, _force) { + if ( + !_force && + [37, 38, 39, 40].includes(event.which) && + this.swapArrowKeysBehavior() + ) { + return this.handleKeydownEvent(event, true); + } + + switch (event.which) { + case 9: + return this.trigger("altRight", event); + case 37: + if (!this.isMac) { + this.trigger("superLeft"); + return false; + } + break; + case 38: + this.trigger("altUp"); + return false; + case 39: + if (!this.isMac) { + this.trigger("superRight"); + return false; + } + break; + case 40: + this.trigger("altDown"); + return false; + case 67: + this.trigger("altC"); + return false; + case 68: + this.trigger("altD"); + return false; + case 70: + return this.trigger("altF", event); + case 71: + this.trigger("altG"); + return false; + case 79: + this.trigger("altO"); + return false; + case 82: + this.trigger("altR"); + return false; + case 83: + this.trigger("altS"); + return false; + } + } + + handleKeypressEvent(event) { + if (event.which === 63 && !event.target.value) { + this.trigger("help"); + return false; + } else { + return (this.lastKeypress = Date.now()); + } + } + + buggyEvent(event) { + try { + event.target; + event.ctrlKey; + event.which; + return false; + } catch (error) { + return true; + } + } +}; diff --git a/assets/javascripts/app/update_checker.coffee b/assets/javascripts/app/update_checker.coffee deleted file mode 100644 index 3558d6bc4e..0000000000 --- a/assets/javascripts/app/update_checker.coffee +++ /dev/null @@ -1,39 +0,0 @@ -class app.UpdateChecker - constructor: -> - @lastCheck = Date.now() - - $.on window, 'focus', @onFocus - app.serviceWorker?.on 'updateready', @onUpdateReady - - setTimeout @checkDocs, 0 - - check: -> - if app.serviceWorker - app.serviceWorker.update() - else - ajax - url: $('script[src*="application"]').getAttribute('src') - dataType: 'application/javascript' - error: (_, xhr) => @onUpdateReady() if xhr.status is 404 - return - - onUpdateReady: -> - new app.views.Notif 'UpdateReady', autoHide: null - return - - checkDocs: => - unless app.settings.get('manualUpdate') - app.docs.updateInBackground() - else - app.docs.checkForUpdates (i) => @onDocsUpdateReady() if i > 0 - return - - onDocsUpdateReady: -> - new app.views.Notif 'UpdateDocs', autoHide: null - return - - onFocus: => - if Date.now() - @lastCheck > 21600e3 - @lastCheck = Date.now() - @check() - return diff --git a/assets/javascripts/app/update_checker.js b/assets/javascripts/app/update_checker.js new file mode 100644 index 0000000000..82d3cc92c6 --- /dev/null +++ b/assets/javascripts/app/update_checker.js @@ -0,0 +1,55 @@ +app.UpdateChecker = class UpdateChecker { + constructor() { + this.lastCheck = Date.now(); + + $.on(window, "focus", () => this.onFocus()); + if (app.serviceWorker) { + app.serviceWorker.on("updateready", () => this.onUpdateReady()); + } + + setTimeout(() => this.checkDocs(), 0); + } + + check() { + if (app.serviceWorker) { + app.serviceWorker.update(); + } else { + ajax({ + url: $('script[src*="application"]').getAttribute("src"), + dataType: "application/javascript", + error: (_, xhr) => { + if (xhr.status === 404) { + return this.onUpdateReady(); + } + }, + }); + } + } + + onUpdateReady() { + new app.views.Notif("UpdateReady", { autoHide: null }); + } + + checkDocs() { + if (!app.settings.get("manualUpdate")) { + app.docs.updateInBackground(); + } else { + app.docs.checkForUpdates((i) => { + if (i > 0) { + return this.onDocsUpdateReady(); + } + }); + } + } + + onDocsUpdateReady() { + new app.views.Notif("UpdateDocs", { autoHide: null }); + } + + onFocus() { + if (Date.now() - this.lastCheck > 21600e3) { + this.lastCheck = Date.now(); + this.check(); + } + } +}; diff --git a/assets/javascripts/application.js b/assets/javascripts/application.js new file mode 100644 index 0000000000..0bfa45687b --- /dev/null +++ b/assets/javascripts/application.js @@ -0,0 +1,33 @@ +//= require_tree ./vendor + +//= require lib/license +//= require_tree ./lib + +//= require app/app +//= require app/config +//= require_tree ./app + +//= require collections/collection +//= require_tree ./collections + +//= require models/model +//= require_tree ./models + +//= require views/view +//= require_tree ./views + +//= require_tree ./templates + +//= require tracking + +var init = function () { + document.removeEventListener("DOMContentLoaded", init, false); + + if (document.body) { + return app.init(); + } else { + return setTimeout(init, 42); + } +}; + +document.addEventListener("DOMContentLoaded", init, false); diff --git a/assets/javascripts/application.js.coffee b/assets/javascripts/application.js.coffee deleted file mode 100644 index 6bf87f1ce8..0000000000 --- a/assets/javascripts/application.js.coffee +++ /dev/null @@ -1,31 +0,0 @@ -#= require_tree ./vendor - -#= require lib/license -#= require_tree ./lib - -#= require app/app -#= require app/config -#= require_tree ./app - -#= require collections/collection -#= require_tree ./collections - -#= require models/model -#= require_tree ./models - -#= require views/view -#= require_tree ./views - -#= require_tree ./templates - -#= require tracking - -init = -> - document.removeEventListener 'DOMContentLoaded', init, false - - if document.body - app.init() - else - setTimeout(init, 42) - -document.addEventListener 'DOMContentLoaded', init, false diff --git a/assets/javascripts/collections/collection.coffee b/assets/javascripts/collections/collection.coffee deleted file mode 100644 index b902a498ef..0000000000 --- a/assets/javascripts/collections/collection.coffee +++ /dev/null @@ -1,55 +0,0 @@ -class app.Collection - constructor: (objects = []) -> - @reset objects - - model: -> - app.models[@constructor.model] - - reset: (objects = []) -> - @models = [] - @add object for object in objects - return - - add: (object) -> - if object instanceof app.Model - @models.push object - else if object instanceof Array - @add obj for obj in object - else if object instanceof app.Collection - @models.push object.all()... - else - @models.push new (@model())(object) - return - - remove: (model) -> - @models.splice @models.indexOf(model), 1 - return - - size: -> - @models.length - - isEmpty: -> - @models.length is 0 - - each: (fn) -> - fn(model) for model in @models - return - - all: -> - @models - - contains: (model) -> - @models.indexOf(model) >= 0 - - findBy: (attr, value) -> - for model in @models - return model if model[attr] is value - return - - findAllBy: (attr, value) -> - model for model in @models when model[attr] is value - - countAllBy: (attr, value) -> - i = 0 - i += 1 for model in @models when model[attr] is value - i diff --git a/assets/javascripts/collections/collection.js b/assets/javascripts/collections/collection.js new file mode 100644 index 0000000000..79bca0be69 --- /dev/null +++ b/assets/javascripts/collections/collection.js @@ -0,0 +1,80 @@ +app.Collection = class Collection { + constructor(objects) { + if (objects == null) { + objects = []; + } + this.reset(objects); + } + + model() { + return app.models[this.constructor.model]; + } + + reset(objects) { + if (objects == null) { + objects = []; + } + this.models = []; + for (var object of objects) { + this.add(object); + } + } + + add(object) { + if (object instanceof app.Model) { + this.models.push(object); + } else if (object instanceof Array) { + for (var obj of object) { + this.add(obj); + } + } else if (object instanceof app.Collection) { + this.models.push(...(object.all() || [])); + } else { + this.models.push(new (this.model())(object)); + } + } + + remove(model) { + this.models.splice(this.models.indexOf(model), 1); + } + + size() { + return this.models.length; + } + + isEmpty() { + return this.models.length === 0; + } + + each(fn) { + for (var model of this.models) { + fn(model); + } + } + + all() { + return this.models; + } + + contains(model) { + return this.models.includes(model); + } + + findBy(attr, value) { + return this.models.find((model) => model[attr] === value); + } + + findAllBy(attr, value) { + return this.models.filter((model) => model[attr] === value); + } + + countAllBy(attr, value) { + let i = 0; + for (var model of this.models) { + if (model[attr] === value) { + i += 1; + } + } + return i; + } +}; diff --git a/assets/javascripts/collections/docs.coffee b/assets/javascripts/collections/docs.coffee deleted file mode 100644 index d76e0f07ef..0000000000 --- a/assets/javascripts/collections/docs.coffee +++ /dev/null @@ -1,85 +0,0 @@ -class app.collections.Docs extends app.Collection - @model: 'Doc' - - findBySlug: (slug) -> - @findBy('slug', slug) or @findBy('slug_without_version', slug) - - NORMALIZE_VERSION_RGX = /\.(\d)$/ - NORMALIZE_VERSION_SUB = '.0$1' - sort: -> - @models.sort (a, b) -> - if a.name is b.name - if not a.version or a.version.replace(NORMALIZE_VERSION_RGX, NORMALIZE_VERSION_SUB) > b.version.replace(NORMALIZE_VERSION_RGX, NORMALIZE_VERSION_SUB) - -1 - else - 1 - else if a.name.toLowerCase() > b.name.toLowerCase() - 1 - else - -1 - - # Load models concurrently. - # It's not pretty but I didn't want to import a promise library only for this. - CONCURRENCY = 3 - load: (onComplete, onError, options) -> - i = 0 - - next = => - if i < @models.length - @models[i].load(next, fail, options) - else if i is @models.length + CONCURRENCY - 1 - onComplete() - i++ - return - - fail = (args...) -> - if onError - onError(args...) - onError = null - next() - return - - next() for [0...CONCURRENCY] - return - - clearCache: -> - doc.clearCache() for doc in @models - return - - uninstall: (callback) -> - i = 0 - next = => - if i < @models.length - @models[i++].uninstall(next, next) - else - callback() - return - next() - return - - getInstallStatuses: (callback) -> - app.db.versions @models, (statuses) -> - if statuses - for key, value of statuses - statuses[key] = installed: !!value, mtime: value - callback(statuses) - return - return - - checkForUpdates: (callback) -> - @getInstallStatuses (statuses) => - i = 0 - if statuses - i += 1 for slug, status of statuses when @findBy('slug', slug).isOutdated(status) - callback(i) - return - return - - updateInBackground: -> - @getInstallStatuses (statuses) => - return unless statuses - for slug, status of statuses - doc = @findBy 'slug', slug - doc.install($.noop, $.noop) if doc.isOutdated(status) - return - return diff --git a/assets/javascripts/collections/docs.js b/assets/javascripts/collections/docs.js new file mode 100644 index 0000000000..d4aa9c8400 --- /dev/null +++ b/assets/javascripts/collections/docs.js @@ -0,0 +1,124 @@ +app.collections.Docs = class Docs extends app.Collection { + static model = "Doc"; + static NORMALIZE_VERSION_RGX = /\.(\d)$/; + static NORMALIZE_VERSION_SUB = ".0$1"; + + // Load models concurrently. + // It's not pretty but I didn't want to import a promise library only for this. + static CONCURRENCY = 3; + + findBySlug(slug) { + return ( + this.findBy("slug", slug) || this.findBy("slug_without_version", slug) + ); + } + sort() { + return this.models.sort((a, b) => { + if (a.name === b.name) { + if ( + !a.version || + a.version.replace( + Docs.NORMALIZE_VERSION_RGX, + Docs.NORMALIZE_VERSION_SUB, + ) > + b.version.replace( + Docs.NORMALIZE_VERSION_RGX, + Docs.NORMALIZE_VERSION_SUB, + ) + ) { + return -1; + } else { + return 1; + } + } else if (a.name.toLowerCase() > b.name.toLowerCase()) { + return 1; + } else { + return -1; + } + }); + } + load(onComplete, onError, options) { + let i = 0; + + var next = () => { + if (i < this.models.length) { + this.models[i].load(next, fail, options); + } else if (i === this.models.length + Docs.CONCURRENCY - 1) { + onComplete(); + } + i++; + }; + + var fail = function (...args) { + if (onError) { + onError(args); + onError = null; + } + next(); + }; + + for (let j = 0, end = Docs.CONCURRENCY; j < end; j++) { + next(); + } + } + + clearCache() { + for (var doc of this.models) { + doc.clearCache(); + } + } + + uninstall(callback) { + let i = 0; + var next = () => { + if (i < this.models.length) { + this.models[i++].uninstall(next, next); + } else { + callback(); + } + }; + next(); + } + + getInstallStatuses(callback) { + app.db.versions(this.models, (statuses) => { + if (statuses) { + for (var key in statuses) { + var value = statuses[key]; + statuses[key] = { installed: !!value, mtime: value }; + } + } + callback(statuses); + }); + } + + checkForUpdates(callback) { + this.getInstallStatuses((statuses) => { + let i = 0; + if (statuses) { + for (var slug in statuses) { + var status = statuses[slug]; + if (this.findBy("slug", slug).isOutdated(status)) { + i += 1; + } + } + } + callback(i); + }); + } + + updateInBackground() { + this.getInstallStatuses((statuses) => { + if (!statuses) { + return; + } + for (var slug in statuses) { + var status = statuses[slug]; + var doc = this.findBy("slug", slug); + if (doc.isOutdated(status)) { + doc.install($.noop, $.noop); + } + } + }); + } +}; diff --git a/assets/javascripts/collections/entries.coffee b/assets/javascripts/collections/entries.coffee deleted file mode 100644 index f978b68be4..0000000000 --- a/assets/javascripts/collections/entries.coffee +++ /dev/null @@ -1,2 +0,0 @@ -class app.collections.Entries extends app.Collection - @model: 'Entry' diff --git a/assets/javascripts/collections/entries.js b/assets/javascripts/collections/entries.js new file mode 100644 index 0000000000..2ea74707c1 --- /dev/null +++ b/assets/javascripts/collections/entries.js @@ -0,0 +1,3 @@ +app.collections.Entries = class Entries extends app.Collection { + static model = "Entry"; +}; diff --git a/assets/javascripts/collections/types.coffee b/assets/javascripts/collections/types.coffee deleted file mode 100644 index 8e76eeab34..0000000000 --- a/assets/javascripts/collections/types.coffee +++ /dev/null @@ -1,19 +0,0 @@ -class app.collections.Types extends app.Collection - @model: 'Type' - - groups: -> - result = [] - for type in @models - (result[@_groupFor(type)] ||= []).push(type) - result.filter (e) -> e.length > 0 - - GUIDES_RGX = /(^|\()(guides?|tutorials?|reference|book|getting\ started|manual|examples)($|[\):])/i - APPENDIX_RGX = /appendix/i - - _groupFor: (type) -> - if GUIDES_RGX.test(type.name) - 0 - else if APPENDIX_RGX.test(type.name) - 2 - else - 1 diff --git a/assets/javascripts/collections/types.js b/assets/javascripts/collections/types.js new file mode 100644 index 0000000000..0d23be0982 --- /dev/null +++ b/assets/javascripts/collections/types.js @@ -0,0 +1,26 @@ +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; + + groups() { + const result = []; + for (var type of this.models) { + const name = this._groupFor(type); + result[name] ||= []; + result[name].push(type); + } + return result.filter((e) => e.length > 0); + } + + _groupFor(type) { + if (Types.GUIDES_RGX.test(type.name)) { + return 0; + } else if (Types.APPENDIX_RGX.test(type.name)) { + return 2; + } else { + return 1; + } + } +}; diff --git a/assets/javascripts/debug.js b/assets/javascripts/debug.js new file mode 100644 index 0000000000..8fcba75b45 --- /dev/null +++ b/assets/javascripts/debug.js @@ -0,0 +1,105 @@ +// +// App +// + +const _init = app.init; +app.init = function () { + console.time("Init"); + _init.call(app); + console.timeEnd("Init"); + return console.time("Load"); +}; + +const _start = app.start; +app.start = function () { + console.timeEnd("Load"); + console.time("Start"); + _start.call(app, ...arguments); + return console.timeEnd("Start"); +}; + +// +// Searcher +// + +app.Searcher = class TimingSearcher extends app.Searcher { + setup() { + console.groupCollapsed(`Search: ${this.query}`); + console.time("Total"); + return super.setup(); + } + + match() { + if (this.matcher) { + console.timeEnd(this.matcher.name); + } + return super.match(); + } + + setupMatcher() { + console.time(this.matcher.name); + return super.setupMatcher(); + } + + end() { + console.log(`Results: ${this.totalResults}`); + console.timeEnd("Total"); + console.groupEnd(); + return super.end(); + } + + kill() { + if (this.timeout) { + if (this.matcher) { + console.timeEnd(this.matcher.name); + } + console.groupEnd(); + console.timeEnd("Total"); + console.warn("Killed"); + } + return super.kill(); + } +}; + +// +// View tree +// + +this.viewTree = function (view, level, visited) { + if (view == null) { + view = app.document; + } + if (level == null) { + level = 0; + } + if (visited == null) { + visited = []; + } + if (visited.includes(view)) { + return; + } + visited.push(view); + + console.log( + `%c ${Array(level + 1).join(" ")}${ + view.constructor.name + }: ${!!view.activated}`, + "color:" + ((view.activated && "green") || "red"), + ); + + for (var key of Object.keys(view || {})) { + var value = view[key]; + if (key !== "view" && value) { + if (typeof value === "object" && value.setupElement) { + this.viewTree(value, level + 1, visited); + } else if (value.constructor.toString().match(/Object\(\)/)) { + for (var k of Object.keys(value || {})) { + var v = value[k]; + if (v && typeof v === "object" && v.setupElement) { + this.viewTree(v, level + 1, visited); + } + } + } + } + } +}; diff --git a/assets/javascripts/debug.js.coffee b/assets/javascripts/debug.js.coffee deleted file mode 100644 index 032d93ac15..0000000000 --- a/assets/javascripts/debug.js.coffee +++ /dev/null @@ -1,85 +0,0 @@ -return unless console?.time and console.groupCollapsed - -# -# App -# - -_init = app.init -app.init = -> - console.time 'Init' - _init.call(app) - console.timeEnd 'Init' - console.time 'Load' - -_start = app.start -app.start = -> - console.timeEnd 'Load' - console.time 'Start' - _start.call(app, arguments...) - console.timeEnd 'Start' - -# -# Searcher -# - -_super = app.Searcher -_proto = app.Searcher.prototype - -app.Searcher = -> - _super.apply @, arguments - - _setup = @setup.bind(@) - @setup = -> - console.groupCollapsed "Search: #{@query}" - console.time 'Total' - _setup() - - _match = @match.bind(@) - @match = => - console.timeEnd @matcher.name if @matcher - _match() - - _setupMatcher = @setupMatcher.bind(@) - @setupMatcher = -> - console.time @matcher.name - _setupMatcher() - - _end = @end.bind(@) - @end = -> - console.log "Results: #{@totalResults}" - console.timeEnd 'Total' - console.groupEnd() - _end() - - _kill = @kill.bind(@) - @kill = -> - if @timeout - console.timeEnd @matcher.name if @matcher - console.groupEnd() - console.timeEnd 'Total' - console.warn 'Killed' - _kill() - - return - -$.extend(app.Searcher, _super) -_proto.constructor = app.Searcher -app.Searcher.prototype = _proto - -# -# View tree -# - -@viewTree = (view = app.document, level = 0, visited = []) -> - return if visited.indexOf(view) >= 0 - visited.push(view) - - console.log "%c #{Array(level + 1).join(' ')}#{view.constructor.name}: #{!!view.activated}", - 'color:' + (view.activated and 'green' or 'red') - - for own key, value of view when key isnt 'view' and value - if typeof value is 'object' and value.setupElement - @viewTree(value, level + 1, visited) - else if value.constructor.toString().match(/Object\(\)/) - @viewTree(v, level + 1, visited) for own k, v of value when v and typeof v is 'object' and v.setupElement - return diff --git a/assets/javascripts/lib/ajax.coffee b/assets/javascripts/lib/ajax.coffee deleted file mode 100644 index 4138ce7b0f..0000000000 --- a/assets/javascripts/lib/ajax.coffee +++ /dev/null @@ -1,118 +0,0 @@ -MIME_TYPES = - json: 'application/json' - html: 'text/html' - -@ajax = (options) -> - applyDefaults(options) - serializeData(options) - - xhr = new XMLHttpRequest() - xhr.open(options.type, options.url, options.async) - - applyCallbacks(xhr, options) - applyHeaders(xhr, options) - - xhr.send(options.data) - - if options.async - abort: abort.bind(undefined, xhr) - else - parseResponse(xhr, options) - -ajax.defaults = - async: true - dataType: 'json' - timeout: 30 - type: 'GET' - # contentType - # context - # data - # error - # headers - # progress - # success - # url - -applyDefaults = (options) -> - for key of ajax.defaults - options[key] ?= ajax.defaults[key] - return - -serializeData = (options) -> - return unless options.data - - if options.type is 'GET' - options.url += '?' + serializeParams(options.data) - options.data = null - else - options.data = serializeParams(options.data) - return - -serializeParams = (params) -> - ("#{encodeURIComponent key}=#{encodeURIComponent value}" for key, value of params).join '&' - -applyCallbacks = (xhr, options) -> - return unless options.async - - xhr.timer = setTimeout onTimeout.bind(undefined, xhr, options), options.timeout * 1000 - xhr.onprogress = options.progress if options.progress - xhr.onreadystatechange = -> - if xhr.readyState is 4 - clearTimeout(xhr.timer) - onComplete(xhr, options) - return - return - -applyHeaders = (xhr, options) -> - options.headers or= {} - - if options.contentType - options.headers['Content-Type'] = options.contentType - - if not options.headers['Content-Type'] and options.data and options.type isnt 'GET' - options.headers['Content-Type'] = 'application/x-www-form-urlencoded' - - if options.dataType - options.headers['Accept'] = MIME_TYPES[options.dataType] or options.dataType - - for key, value of options.headers - xhr.setRequestHeader(key, value) - return - -onComplete = (xhr, options) -> - if 200 <= xhr.status < 300 - if (response = parseResponse(xhr, options))? - onSuccess response, xhr, options - else - onError 'invalid', xhr, options - else - onError 'error', xhr, options - return - -onSuccess = (response, xhr, options) -> - options.success?.call options.context, response, xhr, options - return - -onError = (type, xhr, options) -> - options.error?.call options.context, type, xhr, options - return - -onTimeout = (xhr, options) -> - xhr.abort() - onError 'timeout', xhr, options - return - -abort = (xhr) -> - clearTimeout(xhr.timer) - xhr.onreadystatechange = null - xhr.abort() - return - -parseResponse = (xhr, options) -> - if options.dataType is 'json' - parseJSON(xhr.responseText) - else - xhr.responseText - -parseJSON = (json) -> - try JSON.parse(json) catch diff --git a/assets/javascripts/lib/ajax.js b/assets/javascripts/lib/ajax.js new file mode 100644 index 0000000000..dca1dc7483 --- /dev/null +++ b/assets/javascripts/lib/ajax.js @@ -0,0 +1,166 @@ +const MIME_TYPES = { + json: "application/json", + html: "text/html", +}; + +function ajax(options) { + applyDefaults(options); + serializeData(options); + + const xhr = new XMLHttpRequest(); + xhr.open(options.type, options.url, options.async); + + applyCallbacks(xhr, options); + applyHeaders(xhr, options); + + xhr.send(options.data); + + if (options.async) { + return { abort: abort.bind(undefined, xhr) }; + } else { + return parseResponse(xhr, options); + } + + function applyDefaults(options) { + for (var key in ajax.defaults) { + if (options[key] == null) { + options[key] = ajax.defaults[key]; + } + } + } + + function serializeData(options) { + if (!options.data) { + return; + } + + if (options.type === "GET") { + options.url += "?" + serializeParams(options.data); + options.data = null; + } else { + options.data = serializeParams(options.data); + } + } + + function serializeParams(params) { + return Object.entries(params) + .map( + ([key, value]) => + `${encodeURIComponent(key)}=${encodeURIComponent(value)}`, + ) + .join("&"); + } + + function applyCallbacks(xhr, options) { + if (!options.async) { + return; + } + + xhr.timer = setTimeout( + onTimeout.bind(undefined, xhr, options), + options.timeout * 1000, + ); + if (options.progress) { + xhr.onprogress = options.progress; + } + xhr.onreadystatechange = function () { + if (xhr.readyState === 4) { + clearTimeout(xhr.timer); + onComplete(xhr, options); + } + }; + } + + function applyHeaders(xhr, options) { + if (!options.headers) { + options.headers = {}; + } + + if (options.contentType) { + options.headers["Content-Type"] = options.contentType; + } + + if ( + !options.headers["Content-Type"] && + options.data && + options.type !== "GET" + ) { + options.headers["Content-Type"] = "application/x-www-form-urlencoded"; + } + + if (options.dataType) { + options.headers["Accept"] = + MIME_TYPES[options.dataType] || options.dataType; + } + + for (var key in options.headers) { + var value = options.headers[key]; + xhr.setRequestHeader(key, value); + } + } + + function onComplete(xhr, options) { + if (200 <= xhr.status && xhr.status < 300) { + const response = parseResponse(xhr, options); + if (response != null) { + onSuccess(response, xhr, options); + } else { + onError("invalid", xhr, options); + } + } else { + onError("error", xhr, options); + } + } + + function onSuccess(response, xhr, options) { + if (options.success != null) { + options.success.call(options.context, response, xhr, options); + } + } + + function onError(type, xhr, options) { + if (options.error != null) { + options.error.call(options.context, type, xhr, options); + } + } + + function onTimeout(xhr, options) { + xhr.abort(); + onError("timeout", xhr, options); + } + + function abort(xhr) { + clearTimeout(xhr.timer); + xhr.onreadystatechange = null; + xhr.abort(); + } + + function parseResponse(xhr, options) { + if (options.dataType === "json") { + return parseJSON(xhr.responseText); + } else { + return xhr.responseText; + } + } + + function parseJSON(json) { + try { + return JSON.parse(json); + } catch (error) {} + } +} + +ajax.defaults = { + async: true, + dataType: "json", + timeout: 30, + type: "GET", + // contentType + // context + // data + // error + // headers + // progress + // success + // url +}; diff --git a/assets/javascripts/lib/cookies_store.coffee b/assets/javascripts/lib/cookies_store.coffee deleted file mode 100644 index eaf1bd4f7c..0000000000 --- a/assets/javascripts/lib/cookies_store.coffee +++ /dev/null @@ -1,42 +0,0 @@ -class @CookiesStore - # 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 - - INT = /^\d+$/ - - @onBlocked: -> - - get: (key) -> - value = Cookies.get(key) - value = parseInt(value, 10) if value? and INT.test(value) - value - - set: (key, value) -> - if value == false - @del(key) - return - - value = 1 if value == true - value = parseInt(value, 10) if value and INT.test?(value) - Cookies.set(key, '' + value, path: '/', expires: 1e8) - @constructor.onBlocked(key, value, @get(key)) if @get(key) != value - return - - del: (key) -> - Cookies.expire(key) - return - - reset: -> - try - for cookie in document.cookie.split(/;\s?/) - Cookies.expire(cookie.split('=')[0]) - return - catch - - dump: -> - result = {} - for cookie in document.cookie.split(/;\s?/) when cookie[0] isnt '_' - cookie = cookie.split('=') - result[cookie[0]] = cookie[1] - result diff --git a/assets/javascripts/lib/cookies_store.js b/assets/javascripts/lib/cookies_store.js new file mode 100644 index 0000000000..7878855c0c --- /dev/null +++ b/assets/javascripts/lib/cookies_store.js @@ -0,0 +1,63 @@ +// 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 +class CookiesStore { + static INT = /^\d+$/; + + static onBlocked() {} + + get(key) { + let value = Cookies.get(key); + if (value != null && CookiesStore.INT.test(value)) { + value = parseInt(value, 10); + } + return value; + } + + set(key, value) { + if (value === false) { + this.del(key); + return; + } + + if (value === true) { + value = 1; + } + if ( + value && + (typeof CookiesStore.INT.test === "function" + ? CookiesStore.INT.test(value) + : undefined) + ) { + value = parseInt(value, 10); + } + Cookies.set(key, "" + value, { path: "/", expires: 1e8 }); + if (this.get(key) !== value) { + CookiesStore.onBlocked(key, value, this.get(key)); + } + } + + del(key) { + Cookies.expire(key); + } + + reset() { + try { + for (var cookie of document.cookie.split(/;\s?/)) { + Cookies.expire(cookie.split("=")[0]); + } + return; + } catch (error) {} + } + + dump() { + const result = {}; + for (var cookie of document.cookie.split(/;\s?/)) { + if (cookie[0] !== "_") { + cookie = cookie.split("="); + result[cookie[0]] = cookie[1]; + } + } + return result; + } +} diff --git a/assets/javascripts/lib/events.coffee b/assets/javascripts/lib/events.coffee deleted file mode 100644 index 0593607659..0000000000 --- a/assets/javascripts/lib/events.coffee +++ /dev/null @@ -1,28 +0,0 @@ -@Events = - on: (event, callback) -> - if event.indexOf(' ') >= 0 - @on name, callback for name in event.split(' ') - else - ((@_callbacks ?= {})[event] ?= []).push callback - @ - - off: (event, callback) -> - if event.indexOf(' ') >= 0 - @off name, callback for name in event.split(' ') - else if (callbacks = @_callbacks?[event]) and (index = callbacks.indexOf callback) >= 0 - callbacks.splice index, 1 - delete @_callbacks[event] unless callbacks.length - @ - - trigger: (event, args...) -> - @eventInProgress = { name: event, args: args } - if callbacks = @_callbacks?[event] - callback? args... for callback in callbacks.slice(0) - @eventInProgress = null - @trigger 'all', event, args... unless event is 'all' - @ - - removeEvent: (event) -> - if @_callbacks? - delete @_callbacks[name] for name in event.split(' ') - @ diff --git a/assets/javascripts/lib/events.js b/assets/javascripts/lib/events.js new file mode 100644 index 0000000000..d735a3d55d --- /dev/null +++ b/assets/javascripts/lib/events.js @@ -0,0 +1,58 @@ +class Events { + on(event, callback) { + if (event.includes(" ")) { + for (var name of event.split(" ")) { + this.on(name, callback); + } + } else { + this._callbacks ||= {}; + this._callbacks[event] ||= []; + this._callbacks[event].push(callback); + } + return this; + } + + off(event, callback) { + let callbacks, index; + if (event.includes(" ")) { + for (var name of event.split(" ")) { + this.off(name, callback); + } + } else if ( + (callbacks = this._callbacks?.[event]) && + (index = callbacks.indexOf(callback)) >= 0 + ) { + callbacks.splice(index, 1); + if (!callbacks.length) { + delete this._callbacks[event]; + } + } + return this; + } + + trigger(event, ...args) { + this.eventInProgress = { name: event, args }; + const callbacks = this._callbacks?.[event]; + if (callbacks) { + for (const callback of callbacks.slice(0)) { + if (typeof callback === "function") { + callback(...args); + } + } + } + this.eventInProgress = null; + if (event !== "all") { + this.trigger("all", event, ...args); + } + return this; + } + + removeEvent(event) { + if (this._callbacks != null) { + for (var name of event.split(" ")) { + delete this._callbacks[name]; + } + } + return this; + } +} diff --git a/assets/javascripts/lib/favicon.coffee b/assets/javascripts/lib/favicon.coffee deleted file mode 100644 index 428eae453a..0000000000 --- a/assets/javascripts/lib/favicon.coffee +++ /dev/null @@ -1,76 +0,0 @@ -defaultUrl = null -currentSlug = null - -imageCache = {} -urlCache = {} - -withImage = (url, action) -> - if imageCache[url] - action(imageCache[url]) - else - img = new Image() - img.crossOrigin = 'anonymous' - img.src = url - img.onload = () => - imageCache[url] = img - action(img) - -@setFaviconForDoc = (doc) -> - return if currentSlug == doc.slug - - favicon = $('link[rel="icon"]') - - if defaultUrl == null - defaultUrl = favicon.href - - if urlCache[doc.slug] - favicon.href = urlCache[doc.slug] - currentSlug = doc.slug - return - - iconEl = $("._icon-#{doc.slug.split('~')[0]}") - return if iconEl == null - - styles = window.getComputedStyle(iconEl, ':before') - - backgroundPositionX = styles['background-position-x'] - backgroundPositionY = styles['background-position-y'] - return if backgroundPositionX == undefined || backgroundPositionY == undefined - - bgUrl = app.config.favicon_spritesheet - sourceSize = 16 - sourceX = Math.abs(parseInt(backgroundPositionX.slice(0, -2))) - sourceY = Math.abs(parseInt(backgroundPositionY.slice(0, -2))) - - withImage(bgUrl, (docImg) -> - withImage(defaultUrl, (defaultImg) -> - size = defaultImg.width - - canvas = document.createElement('canvas') - ctx = canvas.getContext('2d') - - canvas.width = size - canvas.height = size - ctx.drawImage(defaultImg, 0, 0) - - docIconPercentage = 65 - destinationCoords = size / 100 * (100 - docIconPercentage) - destinationSize = size / 100 * docIconPercentage - - ctx.drawImage(docImg, sourceX, sourceY, sourceSize, sourceSize, destinationCoords, destinationCoords, destinationSize, destinationSize) - - try - urlCache[doc.slug] = canvas.toDataURL() - favicon.href = urlCache[doc.slug] - - currentSlug = doc.slug - catch error - Raven.captureException error, { level: 'info' } - @resetFavicon() - ) - ) - -@resetFavicon = () -> - if defaultUrl != null and currentSlug != null - $('link[rel="icon"]').href = defaultUrl - currentSlug = null diff --git a/assets/javascripts/lib/favicon.js b/assets/javascripts/lib/favicon.js new file mode 100644 index 0000000000..6b58016c64 --- /dev/null +++ b/assets/javascripts/lib/favicon.js @@ -0,0 +1,101 @@ +let defaultUrl = null; +let currentSlug = null; + +const imageCache = {}; +const urlCache = {}; + +const withImage = function (url, action) { + if (imageCache[url]) { + return action(imageCache[url]); + } else { + const img = new Image(); + img.crossOrigin = "anonymous"; + img.src = url; + return (img.onload = () => { + imageCache[url] = img; + return action(img); + }); + } +}; + +this.setFaviconForDoc = function (doc) { + if (currentSlug === doc.slug || app.settings.get("noDocSpecificIcon")) { + return; + } + + const favicon = $('link[rel="icon"]'); + + if (defaultUrl === null) { + defaultUrl = favicon.href; + } + + if (urlCache[doc.slug]) { + favicon.href = urlCache[doc.slug]; + currentSlug = doc.slug; + return; + } + + const iconEl = $(`._icon-${doc.slug.split("~")[0]}`); + if (iconEl === null) { + return; + } + + const styles = window.getComputedStyle(iconEl, ":before"); + + const backgroundPositionX = styles["background-position-x"]; + const backgroundPositionY = styles["background-position-y"]; + if (backgroundPositionX === undefined || backgroundPositionY === undefined) { + return; + } + + const bgUrl = app.config.favicon_spritesheet; + const sourceSize = 16; + const sourceX = Math.abs(parseInt(backgroundPositionX.slice(0, -2))); + const sourceY = Math.abs(parseInt(backgroundPositionY.slice(0, -2))); + + return withImage(bgUrl, (docImg) => + withImage(defaultUrl, function (defaultImg) { + const size = defaultImg.width; + + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext("2d"); + + canvas.width = size; + canvas.height = size; + ctx.drawImage(defaultImg, 0, 0); + + const docIconPercentage = 65; + const destinationCoords = (size / 100) * (100 - docIconPercentage); + const destinationSize = (size / 100) * docIconPercentage; + + ctx.drawImage( + docImg, + sourceX, + sourceY, + sourceSize, + sourceSize, + destinationCoords, + destinationCoords, + destinationSize, + destinationSize, + ); + + try { + urlCache[doc.slug] = canvas.toDataURL(); + favicon.href = urlCache[doc.slug]; + + return (currentSlug = doc.slug); + } catch (error) { + Raven.captureException(error, { level: "info" }); + return this.resetFavicon(); + } + }), + ); +}; + +this.resetFavicon = function () { + if (defaultUrl !== null && currentSlug !== null) { + $('link[rel="icon"]').href = defaultUrl; + return (currentSlug = null); + } +}; diff --git a/assets/javascripts/lib/license.coffee b/assets/javascripts/lib/license.js similarity index 68% rename from assets/javascripts/lib/license.coffee rename to assets/javascripts/lib/license.js index c397b93b19..15b42c98f4 100644 --- a/assets/javascripts/lib/license.coffee +++ b/assets/javascripts/lib/license.js @@ -1,7 +1,7 @@ -### - * Copyright 2013-2023 Thibaut Courouble and other contributors +/* + * Copyright 2013-2025 Thibaut Courouble and other contributors * * This source code is licensed under the terms of the Mozilla * Public License, v. 2.0, a copy of which may be obtained at: * http://mozilla.org/MPL/2.0/ -### + */ diff --git a/assets/javascripts/lib/local_storage_store.coffee b/assets/javascripts/lib/local_storage_store.coffee deleted file mode 100644 index f4438c86a7..0000000000 --- a/assets/javascripts/lib/local_storage_store.coffee +++ /dev/null @@ -1,23 +0,0 @@ -class @LocalStorageStore - get: (key) -> - try - JSON.parse localStorage.getItem(key) - catch - - set: (key, value) -> - try - localStorage.setItem(key, JSON.stringify(value)) - true - catch - - del: (key) -> - try - localStorage.removeItem(key) - true - catch - - reset: -> - try - localStorage.clear() - true - catch diff --git a/assets/javascripts/lib/local_storage_store.js b/assets/javascripts/lib/local_storage_store.js new file mode 100644 index 0000000000..25a4ee90f7 --- /dev/null +++ b/assets/javascripts/lib/local_storage_store.js @@ -0,0 +1,28 @@ +this.LocalStorageStore = class LocalStorageStore { + get(key) { + try { + return JSON.parse(localStorage.getItem(key)); + } catch (error) {} + } + + set(key, value) { + try { + localStorage.setItem(key, JSON.stringify(value)); + return true; + } catch (error) {} + } + + del(key) { + try { + localStorage.removeItem(key); + return true; + } catch (error) {} + } + + reset() { + try { + localStorage.clear(); + return true; + } catch (error) {} + } +}; diff --git a/assets/javascripts/lib/page.coffee b/assets/javascripts/lib/page.coffee deleted file mode 100644 index 5ad89b32ae..0000000000 --- a/assets/javascripts/lib/page.coffee +++ /dev/null @@ -1,223 +0,0 @@ -### - * Based on github.com/visionmedia/page.js - * Licensed under the MIT license - * Copyright 2012 TJ Holowaychuk -### - -running = false -currentState = null -callbacks = [] - -@page = (value, fn) -> - if typeof value is 'function' - page '*', value - else if typeof fn is 'function' - route = new Route(value) - callbacks.push route.middleware(fn) - else if typeof value is 'string' - page.show(value, fn) - else - page.start(value) - return - -page.start = (options = {}) -> - unless running - running = true - addEventListener 'popstate', onpopstate - addEventListener 'click', onclick - page.replace currentPath(), null, null, true - return - -page.stop = -> - if running - running = false - removeEventListener 'click', onclick - removeEventListener 'popstate', onpopstate - return - -page.show = (path, state) -> - return if path is currentState?.path - context = new Context(path, state) - previousState = currentState - currentState = context.state - if res = page.dispatch(context) - currentState = previousState - location.assign(res) - else - context.pushState() - updateCanonicalLink() - track() - context - -page.replace = (path, state, skipDispatch, init) -> - context = new Context(path, state or currentState) - context.init = init - currentState = context.state - result = page.dispatch(context) unless skipDispatch - if result - context = new Context(result) - context.init = init - currentState = context.state - page.dispatch(context) - context.replaceState() - updateCanonicalLink() - track() unless skipDispatch - context - -page.dispatch = (context) -> - i = 0 - next = -> - res = fn(context, next) if fn = callbacks[i++] - return res - return next() - -page.canGoBack = -> - not Context.isIntialState(currentState) - -page.canGoForward = -> - not Context.isLastState(currentState) - -currentPath = -> - location.pathname + location.search + location.hash - -class Context - @initialPath: currentPath() - @sessionId: Date.now() - @stateId: 0 - - @isIntialState: (state) -> - state.id == 0 - - @isLastState: (state) -> - state.id == @stateId - 1 - - @isInitialPopState: (state) -> - state.path is @initialPath and @stateId is 1 - - @isSameSession: (state) -> - state.sessionId is @sessionId - - constructor: (@path = '/', @state = {}) -> - @pathname = @path.replace /(?:\?([^#]*))?(?:#(.*))?$/, (_, query, hash) => - @query = query - @hash = hash - '' - - @state.id ?= @constructor.stateId++ - @state.sessionId ?= @constructor.sessionId - @state.path = @path - - pushState: -> - history.pushState @state, '', @path - return - - replaceState: -> - try history.replaceState @state, '', @path # NS_ERROR_FAILURE in Firefox - return - -class Route - constructor: (@path, options = {}) -> - @keys = [] - @regexp = pathtoRegexp @path, @keys - - middleware: (fn) -> - (context, next) => - if @match context.pathname, params = [] - context.params = params - return fn(context, next) - else - return next() - - match: (path, params) -> - return unless matchData = @regexp.exec(path) - - for value, i in matchData[1..] - value = decodeURIComponent value if typeof value is 'string' - if key = @keys[i] - params[key.name] = value - else - params.push value - true - -pathtoRegexp = (path, keys) -> - return path if path instanceof RegExp - - path = "(#{path.join '|'})" if path instanceof Array - path = path - .replace /\/\(/g, '(?:/' - .replace /(\/)?(\.)?:(\w+)(?:(\(.*?\)))?(\?)?/g, (_, slash = '', format = '', key, capture, optional) -> - keys.push name: key, optional: !!optional - str = if optional then '' else slash - str += '(?:' - str += slash if optional - str += format - str += capture or if format then '([^/.]+?)' else '([^/]+?)' - str += ')' - str += optional if optional - str - .replace /([\/.])/g, '\\$1' - .replace /\*/g, '(.*)' - - new RegExp "^#{path}$" - -onpopstate = (event) -> - return if not event.state or Context.isInitialPopState(event.state) - - if Context.isSameSession(event.state) - page.replace(event.state.path, event.state) - else - location.reload() - return - -onclick = (event) -> - try - return if event.which isnt 1 or event.metaKey or event.ctrlKey or event.shiftKey or event.defaultPrevented - catch - return - - link = $.eventTarget(event) - link = link.parentNode while link and link.tagName isnt 'A' - - if link and not link.target and isSameOrigin(link.href) - event.preventDefault() - path = link.pathname + link.search + link.hash - path = path.replace /^\/\/+/, '/' # IE11 bug - page.show(path) - return - -isSameOrigin = (url) -> - url.indexOf("#{location.protocol}//#{location.hostname}") is 0 - -updateCanonicalLink = -> - @canonicalLink ||= document.head.querySelector('link[rel="canonical"]') - @canonicalLink.setAttribute('href', "https://#{location.host}#{location.pathname}") - -trackers = [] - -page.track = (fn) -> - trackers.push(fn) - return - -track = -> - return unless app.config.env == 'production' - return if navigator.doNotTrack == '1' - return if navigator.globalPrivacyControl - - consentGiven = Cookies.get('analyticsConsent') - consentAsked = Cookies.get('analyticsConsentAsked') - - if consentGiven == '1' - tracker.call() for tracker in trackers - else if consentGiven == undefined and consentAsked == undefined - # Only ask for consent once per browser session - Cookies.set('analyticsConsentAsked', '1') - - new app.views.Notif 'AnalyticsConsent', autoHide: null - return - -@resetAnalytics = -> - for cookie in document.cookie.split(/;\s?/) - name = cookie.split('=')[0] - if name[0] == '_' && name[1] != '_' - Cookies.expire(name) - return diff --git a/assets/javascripts/lib/page.js b/assets/javascripts/lib/page.js new file mode 100644 index 0000000000..0043e76612 --- /dev/null +++ b/assets/javascripts/lib/page.js @@ -0,0 +1,347 @@ +/* + * Based on github.com/visionmedia/page.js + * Licensed under the MIT license + * Copyright 2012 TJ Holowaychuk + */ + +let running = false; +let currentState = null; +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); + } +}; + +page.start = function (options) { + if (options == null) { + options = {}; + } + if (!running) { + running = true; + addEventListener("popstate", onpopstate); + addEventListener("click", onclick); + page.replace(currentPath(), null, null, true); + } +}; + +page.stop = function () { + if (running) { + running = false; + removeEventListener("click", onclick); + removeEventListener("popstate", onpopstate); + } +}; + +page.show = function (path, state) { + if (path === currentState?.path) { + return; + } + const context = new Context(path, state); + const previousState = currentState; + currentState = context.state; + const res = page.dispatch(context); + if (res) { + currentState = previousState; + location.assign(res); + } else { + context.pushState(); + updateCanonicalLink(); + track(); + } + return context; +}; + +page.replace = function (path, state, skipDispatch, init) { + let result; + let context = new Context(path, state || currentState); + context.init = init; + currentState = context.state; + if (!skipDispatch) { + result = page.dispatch(context); + } + if (result) { + context = new Context(result); + context.init = init; + currentState = context.state; + page.dispatch(context); + } + context.replaceState(); + updateCanonicalLink(); + if (!skipDispatch) { + track(); + } + return context; +}; + +page.dispatch = function (context) { + let i = 0; + const next = function () { + let fn = callbacks[i++]; + return fn?.(context, next); + }; + return next(); +}; + +page.canGoBack = () => !Context.isIntialState(currentState); + +page.canGoForward = () => !Context.isLastState(currentState); + +const currentPath = () => location.pathname + location.search + location.hash; + +class Context { + static isIntialState(state) { + return state.id === 0; + } + + static isLastState(state) { + return state.id === this.stateId - 1; + } + + static isInitialPopState(state) { + return state.path === this.initialPath && this.stateId === 1; + } + + static isSameSession(state) { + return state.sessionId === this.sessionId; + } + + constructor(path, state) { + this.initialPath = currentPath(); + this.sessionId = Date.now(); + this.stateId = 0; + if (path == null) { + path = "/"; + } + this.path = path; + if (state == null) { + state = {}; + } + this.state = state; + this.pathname = this.path.replace( + /(?:\?([^#]*))?(?:#(.*))?$/, + (_, query, hash) => { + this.query = query; + this.hash = hash; + return ""; + }, + ); + + if (this.state.id == null) { + this.state.id = this.constructor.stateId++; + } + if (this.state.sessionId == null) { + this.state.sessionId = this.constructor.sessionId; + } + this.state.path = this.path; + } + + pushState() { + history.pushState(this.state, "", this.path); + } + + replaceState() { + try { + history.replaceState(this.state, "", this.path); + } catch (error) {} // NS_ERROR_FAILURE in Firefox + } +} + +class Route { + constructor(path, options) { + this.path = path; + if (options == null) { + options = {}; + } + this.keys = []; + this.regexp = pathToRegexp(this.path, this.keys); + } + + middleware(fn) { + return (context, next) => { + let params = []; + if (this.match(context.pathname, params)) { + context.params = params; + return fn(context, next); + } else { + return next(); + } + }; + } + + match(path, params) { + const matchData = this.regexp.exec(path); + if (!matchData) { + return; + } + + const iterable = matchData.slice(1); + for (let i = 0; i < iterable.length; i++) { + var key = this.keys[i]; + var value = iterable[i]; + if (typeof value === "string") { + value = decodeURIComponent(value); + } + if (key) { + params[key.name] = value; + } else { + params.push(value); + } + } + return true; + } +} + +var pathToRegexp = function (path, keys) { + if (path instanceof RegExp) { + return path; + } + + if (path instanceof Array) { + path = `(${path.join("|")})`; + } + path = path + .replace(/\/\(/g, "(?:/") + .replace( + /(\/)?(\.)?:(\w+)(?:(\(.*?\)))?(\?)?/g, + (_, slash, format, key, capture, optional) => { + if (slash == null) { + slash = ""; + } + if (format == null) { + format = ""; + } + keys.push({ name: key, optional: !!optional }); + let str = optional ? "" : slash; + str += "(?:"; + if (optional) { + str += slash; + } + str += format; + str += capture || (format ? "([^/.]+?)" : "([^/]+?)"); + str += ")"; + if (optional) { + str += optional; + } + return str; + }, + ) + .replace(/([\/.])/g, "\\$1") + .replace(/\*/g, "(.*)"); + + return new RegExp(`^${path}$`); +}; + +var onpopstate = function (event) { + if (!event.state || Context.isInitialPopState(event.state)) { + return; + } + + if (Context.isSameSession(event.state)) { + page.replace(event.state.path, event.state); + } else { + location.reload(); + } +}; + +var onclick = function (event) { + try { + if ( + event.which !== 1 || + event.metaKey || + event.ctrlKey || + event.shiftKey || + event.defaultPrevented + ) { + return; + } + } catch (error) { + return; + } + + let link = $.eventTarget(event); + while (link && !(link.tagName === "A" || link.tagName === "a")) { + link = link.parentNode; + } + + if (!link) return; + + // If the `` is in an SVG, its attributes are `SVGAnimatedString`s + // instead of strings + let href = link.href instanceof SVGAnimatedString + ? new URL(link.href.baseVal, location.href).href + : link.href; + let target = link.target instanceof SVGAnimatedString + ? link.target.baseVal + : link.target; + + if (!target && isSameOrigin(href)) { + event.preventDefault(); + let parsedHref = new URL(href); + let path = parsedHref.pathname + parsedHref.search + parsedHref.hash; + path = path.replace(/^\/\/+/, "/"); // IE11 bug + page.show(path); + } +}; + +var isSameOrigin = (url) => + url.startsWith(`${location.protocol}//${location.hostname}`); + +var updateCanonicalLink = function () { + if (!this.canonicalLink) { + this.canonicalLink = document.head.querySelector('link[rel="canonical"]'); + } + return this.canonicalLink.setAttribute( + "href", + `https://${location.host}${location.pathname}`, + ); +}; + +const trackers = []; + +page.track = function (fn) { + trackers.push(fn); +}; + +var track = function () { + if (app.config.env !== "production") { + return; + } + if (navigator.doNotTrack === "1") { + return; + } + if (navigator.globalPrivacyControl) { + return; + } + + const consentGiven = Cookies.get("analyticsConsent"); + const consentAsked = Cookies.get("analyticsConsentAsked"); + + if (consentGiven === "1") { + for (var tracker of trackers) { + tracker.call(); + } + } else if (consentGiven === undefined && consentAsked === undefined) { + // Only ask for consent once per browser session + Cookies.set("analyticsConsentAsked", "1"); + + new app.views.Notif("AnalyticsConsent", { autoHide: null }); + } +}; + +this.resetAnalytics = function () { + for (var cookie of document.cookie.split(/;\s?/)) { + var name = cookie.split("=")[0]; + if (name[0] === "_" && name[1] !== "_") { + Cookies.expire(name); + } + } +}; diff --git a/assets/javascripts/lib/util.coffee b/assets/javascripts/lib/util.coffee deleted file mode 100644 index 001b13dee7..0000000000 --- a/assets/javascripts/lib/util.coffee +++ /dev/null @@ -1,399 +0,0 @@ -# -# Traversing -# - -@$ = (selector, el = document) -> - try el.querySelector(selector) catch - -@$$ = (selector, el = document) -> - try el.querySelectorAll(selector) catch - -$.id = (id) -> - document.getElementById(id) - -$.hasChild = (parent, el) -> - return unless parent - while el - return true if el is parent - return if el is document.body - el = el.parentNode - -$.closestLink = (el, parent = document.body) -> - while el - return el if el.tagName is 'A' - return if el is parent - el = el.parentNode - -# -# Events -# - -$.on = (el, event, callback, useCapture = false) -> - if event.indexOf(' ') >= 0 - $.on el, name, callback for name in event.split(' ') - else - el.addEventListener(event, callback, useCapture) - return - -$.off = (el, event, callback, useCapture = false) -> - if event.indexOf(' ') >= 0 - $.off el, name, callback for name in event.split(' ') - else - el.removeEventListener(event, callback, useCapture) - return - -$.trigger = (el, type, canBubble = true, cancelable = true) -> - event = document.createEvent 'Event' - event.initEvent(type, canBubble, cancelable) - el.dispatchEvent(event) - return - -$.click = (el) -> - event = document.createEvent 'MouseEvent' - event.initMouseEvent 'click', true, true, window, null, 0, 0, 0, 0, false, false, false, false, 0, null - el.dispatchEvent(event) - return - -$.stopEvent = (event) -> - event.preventDefault() - event.stopPropagation() - event.stopImmediatePropagation() - return - -$.eventTarget = (event) -> - event.target.correspondingUseElement || event.target - -# -# Manipulation -# - -buildFragment = (value) -> - fragment = document.createDocumentFragment() - - if $.isCollection(value) - fragment.appendChild(child) for child in $.makeArray(value) - else - fragment.innerHTML = value - - fragment - -$.append = (el, value) -> - if typeof value is 'string' - el.insertAdjacentHTML 'beforeend', value - else - value = buildFragment(value) if $.isCollection(value) - el.appendChild(value) - return - -$.prepend = (el, value) -> - if not el.firstChild - $.append(value) - else if typeof value is 'string' - el.insertAdjacentHTML 'afterbegin', value - else - value = buildFragment(value) if $.isCollection(value) - el.insertBefore(value, el.firstChild) - return - -$.before = (el, value) -> - if typeof value is 'string' or $.isCollection(value) - value = buildFragment(value) - - el.parentNode.insertBefore(value, el) - return - -$.after = (el, value) -> - if typeof value is 'string' or $.isCollection(value) - value = buildFragment(value) - - if el.nextSibling - el.parentNode.insertBefore(value, el.nextSibling) - else - el.parentNode.appendChild(value) - return - -$.remove = (value) -> - if $.isCollection(value) - el.parentNode?.removeChild(el) for el in $.makeArray(value) - else - value.parentNode?.removeChild(value) - return - -$.empty = (el) -> - el.removeChild(el.firstChild) while el.firstChild - return - -# Calls the function while the element is off the DOM to avoid triggering -# unnecessary reflows and repaints. -$.batchUpdate = (el, fn) -> - parent = el.parentNode - sibling = el.nextSibling - parent.removeChild(el) - - fn(el) - - if (sibling) - parent.insertBefore(el, sibling) - else - parent.appendChild(el) - return - -# -# Offset -# - -$.rect = (el) -> - el.getBoundingClientRect() - -$.offset = (el, container = document.body) -> - top = 0 - left = 0 - - while el and el isnt container - top += el.offsetTop - left += el.offsetLeft - el = el.offsetParent - - top: top - left: left - -$.scrollParent = (el) -> - while (el = el.parentNode) and el.nodeType is 1 - break if el.scrollTop > 0 - break if getComputedStyle(el)?.overflowY in ['auto', 'scroll'] - el - -$.scrollTo = (el, parent, position = 'center', options = {}) -> - return unless el - - parent ?= $.scrollParent(el) - return unless parent - - parentHeight = parent.clientHeight - parentScrollHeight = parent.scrollHeight - return unless parentScrollHeight > parentHeight - - top = $.offset(el, parent).top - offsetTop = parent.firstElementChild.offsetTop - - switch position - when 'top' - parent.scrollTop = top - offsetTop - (if options.margin? then options.margin else 0) - when 'center' - parent.scrollTop = top - Math.round(parentHeight / 2 - el.offsetHeight / 2) - when 'continuous' - scrollTop = parent.scrollTop - height = el.offsetHeight - - lastElementOffset = parent.lastElementChild.offsetTop + parent.lastElementChild.offsetHeight - offsetBottom = if lastElementOffset > 0 then parentScrollHeight - lastElementOffset else 0 - - # If the target element is above the visible portion of its scrollable - # ancestor, move it near the top with a gap = options.topGap * target's height. - if top - offsetTop <= scrollTop + height * (options.topGap or 1) - parent.scrollTop = top - offsetTop - height * (options.topGap or 1) - # If the target element is below the visible portion of its scrollable - # ancestor, move it near the bottom with a gap = options.bottomGap * target's height. - else if top + offsetBottom >= scrollTop + parentHeight - height * ((options.bottomGap or 1) + 1) - parent.scrollTop = top + offsetBottom - parentHeight + height * ((options.bottomGap or 1) + 1) - return - -$.scrollToWithImageLock = (el, parent, args...) -> - parent ?= $.scrollParent(el) - return unless parent - - $.scrollTo el, parent, args... - - # Lock the scroll position on the target element for up to 3 seconds while - # nearby images are loaded and rendered. - for image in parent.getElementsByTagName('img') when not image.complete - do -> - onLoad = (event) -> - clearTimeout(timeout) - unbind(event.target) - $.scrollTo el, parent, args... - - unbind = (target) -> - $.off target, 'load', onLoad - - $.on image, 'load', onLoad - timeout = setTimeout unbind.bind(null, image), 3000 - return - -# Calls the function while locking the element's position relative to the window. -$.lockScroll = (el, fn) -> - if parent = $.scrollParent(el) - top = $.rect(el).top - top -= $.rect(parent).top unless parent in [document.body, document.documentElement] - fn() - parent.scrollTop = $.offset(el, parent).top - top - else - fn() - return - -smoothScroll = smoothStart = smoothEnd = smoothDistance = smoothDuration = null - -$.smoothScroll = (el, end) -> - unless window.requestAnimationFrame - el.scrollTop = end - return - - smoothEnd = end - - if smoothScroll - newDistance = smoothEnd - smoothStart - smoothDuration += Math.min 300, Math.abs(smoothDistance - newDistance) - smoothDistance = newDistance - return - - smoothStart = el.scrollTop - smoothDistance = smoothEnd - smoothStart - smoothDuration = Math.min 300, Math.abs(smoothDistance) - startTime = Date.now() - - smoothScroll = -> - p = Math.min 1, (Date.now() - startTime) / smoothDuration - y = Math.max 0, Math.floor(smoothStart + smoothDistance * (if p < 0.5 then 2 * p * p else p * (4 - p * 2) - 1)) - el.scrollTop = y - if p is 1 - smoothScroll = null - else - requestAnimationFrame(smoothScroll) - requestAnimationFrame(smoothScroll) - -# -# Utilities -# - -$.extend = (target, objects...) -> - for object in objects when object - for key, value of object - target[key] = value - target - -$.makeArray = (object) -> - if Array.isArray(object) - object - else - Array::slice.apply(object) - -$.arrayDelete = (array, object) -> - index = array.indexOf(object) - if index >= 0 - array.splice(index, 1) - true - else - false - -# Returns true if the object is an array or a collection of DOM elements. -$.isCollection = (object) -> - Array.isArray(object) or typeof object?.item is 'function' - -ESCAPE_HTML_MAP = - '&': '&' - '<': '<' - '>': '>' - '"': '"' - "'": ''' - '/': '/' - -ESCAPE_HTML_REGEXP = /[&<>"'\/]/g - -$.escape = (string) -> - string.replace ESCAPE_HTML_REGEXP, (match) -> ESCAPE_HTML_MAP[match] - -ESCAPE_REGEXP = /([.*+?^=!:${}()|\[\]\/\\])/g - -$.escapeRegexp = (string) -> - string.replace ESCAPE_REGEXP, "\\$1" - -$.urlDecode = (string) -> - decodeURIComponent string.replace(/\+/g, '%20') - -$.classify = (string) -> - string = string.split('_') - for substr, i in string - string[i] = substr[0].toUpperCase() + substr[1..] - string.join('') - -$.framify = (fn, obj) -> - if window.requestAnimationFrame - (args...) -> requestAnimationFrame(fn.bind(obj, args...)) - else - fn - -$.requestAnimationFrame = (fn) -> - if window.requestAnimationFrame - requestAnimationFrame(fn) - else - setTimeout(fn, 0) - return - -# -# Miscellaneous -# - -$.noop = -> - -$.popup = (value) -> - try - win = window.open() - win.opener = null if win.opener - win.location = value.href or value - catch - window.open value.href or value, '_blank' - return - -isMac = null -$.isMac = -> - isMac ?= navigator.userAgent?.indexOf('Mac') >= 0 - -isIE = null -$.isIE = -> - isIE ?= navigator.userAgent?.indexOf('MSIE') >= 0 || navigator.userAgent?.indexOf('rv:11.0') >= 0 - -isChromeForAndroid = null -$.isChromeForAndroid = -> - isChromeForAndroid ?= navigator.userAgent?.indexOf('Android') >= 0 && /Chrome\/([.0-9])+ Mobile/.test(navigator.userAgent) - -isAndroid = null -$.isAndroid = -> - isAndroid ?= navigator.userAgent?.indexOf('Android') >= 0 - -isIOS = null -$.isIOS = -> - isIOS ?= navigator.userAgent?.indexOf('iPhone') >= 0 || navigator.userAgent?.indexOf('iPad') >= 0 - -$.overlayScrollbarsEnabled = -> - return false unless $.isMac() - div = document.createElement('div') - div.setAttribute('style', 'width: 100px; height: 100px; overflow: scroll; position: absolute') - document.body.appendChild(div) - result = div.offsetWidth is div.clientWidth - document.body.removeChild(div) - result - -HIGHLIGHT_DEFAULTS = - className: 'highlight' - delay: 1000 - -$.highlight = (el, options = {}) -> - options = $.extend {}, HIGHLIGHT_DEFAULTS, options - el.classList.add(options.className) - setTimeout (-> el.classList.remove(options.className)), options.delay - return - -$.copyToClipboard = (string) -> - textarea = document.createElement('textarea') - textarea.style.position = 'fixed' - textarea.style.opacity = 0 - textarea.value = string - document.body.appendChild(textarea) - try - textarea.select() - result = !!document.execCommand('copy') - catch - result = false - finally - document.body.removeChild(textarea) - result diff --git a/assets/javascripts/lib/util.js b/assets/javascripts/lib/util.js new file mode 100644 index 0000000000..ca977da113 --- /dev/null +++ b/assets/javascripts/lib/util.js @@ -0,0 +1,538 @@ +// +// Traversing +// + +let smoothDistance, smoothDuration, smoothEnd, smoothStart; +this.$ = function (selector, el) { + if (el == null) { + el = document; + } + try { + return el.querySelector(selector); + } catch (error) {} +}; + +this.$$ = function (selector, el) { + if (el == null) { + el = document; + } + try { + return el.querySelectorAll(selector); + } catch (error) {} +}; + +$.id = (id) => document.getElementById(id); + +$.hasChild = function (parent, el) { + if (!parent) { + return; + } + while (el) { + if (el === parent) { + return true; + } + if (el === document.body) { + return; + } + el = el.parentNode; + } +}; + +$.closestLink = function (el, parent) { + if (parent == null) { + parent = document.body; + } + while (el) { + if (el.tagName === "A") { + return el; + } + if (el === parent) { + return; + } + el = el.parentNode; + } +}; + +// +// Events +// + +$.on = function (el, event, callback, useCapture) { + if (useCapture == null) { + useCapture = false; + } + if (event.includes(" ")) { + for (var name of event.split(" ")) { + $.on(el, name, callback); + } + } else { + el.addEventListener(event, callback, useCapture); + } +}; + +$.off = function (el, event, callback, useCapture) { + if (useCapture == null) { + useCapture = false; + } + if (event.includes(" ")) { + for (var name of event.split(" ")) { + $.off(el, name, callback); + } + } else { + el.removeEventListener(event, callback, useCapture); + } +}; + +$.trigger = function (el, type, canBubble, cancelable) { + const event = new Event(type, { + bubbles: canBubble ?? true, + cancelable: cancelable ?? true, + }); + el.dispatchEvent(event); +}; + +$.click = function (el) { + const event = new MouseEvent("click", { + bubbles: true, + cancelable: true, + }); + el.dispatchEvent(event); +}; + +$.stopEvent = function (event) { + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); +}; + +$.eventTarget = (event) => event.target.correspondingUseElement || event.target; + +// +// Manipulation +// + +const buildFragment = function (value) { + const fragment = document.createDocumentFragment(); + + if ($.isCollection(value)) { + for (var child of $.makeArray(value)) { + fragment.appendChild(child); + } + } else { + fragment.innerHTML = value; + } + + return fragment; +}; + +$.append = function (el, value) { + if (typeof value === "string") { + el.insertAdjacentHTML("beforeend", value); + } else { + if ($.isCollection(value)) { + value = buildFragment(value); + } + el.appendChild(value); + } +}; + +$.prepend = function (el, value) { + if (!el.firstChild) { + $.append(value); + } else if (typeof value === "string") { + el.insertAdjacentHTML("afterbegin", value); + } else { + if ($.isCollection(value)) { + value = buildFragment(value); + } + el.insertBefore(value, el.firstChild); + } +}; + +$.before = function (el, value) { + if (typeof value === "string" || $.isCollection(value)) { + value = buildFragment(value); + } + + el.parentNode.insertBefore(value, el); +}; + +$.after = function (el, value) { + if (typeof value === "string" || $.isCollection(value)) { + value = buildFragment(value); + } + + if (el.nextSibling) { + el.parentNode.insertBefore(value, el.nextSibling); + } else { + el.parentNode.appendChild(value); + } +}; + +$.remove = function (value) { + if ($.isCollection(value)) { + for (var el of $.makeArray(value)) { + if (el.parentNode != null) { + el.parentNode.removeChild(el); + } + } + } else { + if (value.parentNode != null) { + value.parentNode.removeChild(value); + } + } +}; + +$.empty = function (el) { + while (el.firstChild) { + el.removeChild(el.firstChild); + } +}; + +// Calls the function while the element is off the DOM to avoid triggering +// unnecessary reflows and repaints. +$.batchUpdate = function (el, fn) { + const parent = el.parentNode; + const sibling = el.nextSibling; + parent.removeChild(el); + + fn(el); + + if (sibling) { + parent.insertBefore(el, sibling); + } else { + parent.appendChild(el); + } +}; + +// +// Offset +// + +$.rect = (el) => el.getBoundingClientRect(); + +$.offset = function (el, container) { + if (container == null) { + container = document.body; + } + let top = 0; + let left = 0; + + while (el && el !== container) { + top += el.offsetTop; + left += el.offsetLeft; + el = el.offsetParent; + } + + return { + top, + left, + }; +}; + +$.scrollParent = function (el) { + while ((el = el.parentNode) && el.nodeType === 1) { + if (el.scrollTop > 0) { + break; + } + if (["auto", "scroll"].includes(getComputedStyle(el)?.overflowY ?? "")) { + break; + } + } + return el; +}; + +$.scrollTo = function (el, parent, position, options) { + if (position == null) { + position = "center"; + } + if (options == null) { + options = {}; + } + if (!el) { + return; + } + + if (parent == null) { + parent = $.scrollParent(el); + } + if (!parent) { + return; + } + + const parentHeight = parent.clientHeight; + const parentScrollHeight = parent.scrollHeight; + if (!(parentScrollHeight > parentHeight)) { + return; + } + + const { top } = $.offset(el, parent); + const { offsetTop } = parent.firstElementChild; + + switch (position) { + case "top": + parent.scrollTop = top - offsetTop - (options.margin || 0); + break; + case "center": + parent.scrollTop = + top - Math.round(parentHeight / 2 - el.offsetHeight / 2); + break; + case "continuous": + var { scrollTop } = parent; + var height = el.offsetHeight; + + var lastElementOffset = + parent.lastElementChild.offsetTop + + parent.lastElementChild.offsetHeight; + var offsetBottom = + lastElementOffset > 0 ? parentScrollHeight - lastElementOffset : 0; + + // If the target element is above the visible portion of its scrollable + // ancestor, move it near the top with a gap = options.topGap * target's height. + if (top - offsetTop <= scrollTop + height * (options.topGap || 1)) { + parent.scrollTop = top - offsetTop - height * (options.topGap || 1); + // If the target element is below the visible portion of its scrollable + // ancestor, move it near the bottom with a gap = options.bottomGap * target's height. + } else if ( + top + offsetBottom >= + scrollTop + parentHeight - height * ((options.bottomGap || 1) + 1) + ) { + parent.scrollTop = + top + + offsetBottom - + parentHeight + + height * ((options.bottomGap || 1) + 1); + } + break; + } +}; + +$.scrollToWithImageLock = function (el, parent, ...args) { + if (parent == null) { + parent = $.scrollParent(el); + } + if (!parent) { + return; + } + + $.scrollTo(el, parent, ...args); + + // Lock the scroll position on the target element for up to 3 seconds while + // nearby images are loaded and rendered. + for (var image of parent.getElementsByTagName("img")) { + if (!image.complete) { + (function () { + let timeout; + const onLoad = function (event) { + clearTimeout(timeout); + unbind(event.target); + return $.scrollTo(el, parent, ...args); + }; + + var unbind = (target) => $.off(target, "load", onLoad); + + $.on(image, "load", onLoad); + return (timeout = setTimeout(unbind.bind(null, image), 3000)); + })(); + } + } +}; + +// Calls the function while locking the element's position relative to the window. +$.lockScroll = function (el, fn) { + const parent = $.scrollParent(el); + if (parent) { + let { top } = $.rect(el); + if (![document.body, document.documentElement].includes(parent)) { + top -= $.rect(parent).top; + } + fn(); + parent.scrollTop = $.offset(el, parent).top - top; + } else { + fn(); + } +}; + +// If `el` is inside any `
` elements, expand them. +$.openDetailsAncestors = function (el) { + while (el) { + if (el.tagName === "DETAILS") { + el.open = true; + } + el = el.parentElement; + } +} + +let smoothScroll = + (smoothStart = + smoothEnd = + smoothDistance = + smoothDuration = + null); + +$.smoothScroll = function (el, end) { + smoothEnd = end; + + if (smoothScroll) { + const newDistance = smoothEnd - smoothStart; + smoothDuration += Math.min(300, Math.abs(smoothDistance - newDistance)); + smoothDistance = newDistance; + return; + } + + smoothStart = el.scrollTop; + smoothDistance = smoothEnd - smoothStart; + smoothDuration = Math.min(300, Math.abs(smoothDistance)); + const startTime = Date.now(); + + smoothScroll = function () { + const p = Math.min(1, (Date.now() - startTime) / smoothDuration); + const y = Math.max( + 0, + Math.floor( + smoothStart + + smoothDistance * (p < 0.5 ? 2 * p * p : p * (4 - p * 2) - 1), + ), + ); + el.scrollTop = y; + if (p === 1) { + return (smoothScroll = null); + } else { + return requestAnimationFrame(smoothScroll); + } + }; + return requestAnimationFrame(smoothScroll); +}; + +// +// Utilities +// + +$.makeArray = function (object) { + if (Array.isArray(object)) { + return object; + } else { + return Array.prototype.slice.apply(object); + } +}; + +$.arrayDelete = function (array, object) { + const index = array.indexOf(object); + if (index >= 0) { + array.splice(index, 1); + return true; + } else { + return false; + } +}; + +// Returns true if the object is an array or a collection of DOM elements. +$.isCollection = (object) => + Array.isArray(object) || typeof object?.item === "function"; + +const ESCAPE_HTML_MAP = { + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", + "/": "/", +}; + +const ESCAPE_HTML_REGEXP = /[&<>"'\/]/g; + +$.escape = (string) => + string.replace(ESCAPE_HTML_REGEXP, (match) => ESCAPE_HTML_MAP[match]); + +const ESCAPE_REGEXP = /([.*+?^=!:${}()|\[\]\/\\])/g; + +$.escapeRegexp = (string) => string.replace(ESCAPE_REGEXP, "\\$1"); + +$.urlDecode = (string) => decodeURIComponent(string.replace(/\+/g, "%20")); + +$.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); + } + return string.join(""); +}; + +// +// Miscellaneous +// + +$.noop = function () {}; + +$.popup = function (value) { + try { + window.open(value.href || value, "_blank", "noopener"); + } catch (error) { + const win = window.open(); + if (win.opener) { + win.opener = null; + } + win.location = value.href || value; + } +}; + +let isMac = null; +$.isMac = () => + isMac != null ? isMac : (isMac = navigator.userAgent.includes("Mac")); + +let isIE = null; +$.isIE = () => + isIE != null + ? isIE + : (isIE = + navigator.userAgent.includes("MSIE") || + navigator.userAgent.includes("rv:11.0")); + +let isChromeForAndroid = null; +$.isChromeForAndroid = () => + isChromeForAndroid != null + ? isChromeForAndroid + : (isChromeForAndroid = + navigator.userAgent.includes("Android") && + /Chrome\/([.0-9])+ Mobile/.test(navigator.userAgent)); + +let isAndroid = null; +$.isAndroid = () => + isAndroid != null + ? isAndroid + : (isAndroid = navigator.userAgent.includes("Android")); + +let isIOS = null; +$.isIOS = () => + isIOS != null + ? isIOS + : (isIOS = + navigator.userAgent.includes("iPhone") || + navigator.userAgent.includes("iPad")); + +$.overlayScrollbarsEnabled = function () { + if (!$.isMac()) { + return false; + } + const div = document.createElement("div"); + div.setAttribute( + "style", + "width: 100px; height: 100px; overflow: scroll; position: absolute", + ); + document.body.appendChild(div); + const result = div.offsetWidth === div.clientWidth; + document.body.removeChild(div); + return result; +}; + +const HIGHLIGHT_DEFAULTS = { + className: "highlight", + delay: 1000, +}; + +$.highlight = function (el, options) { + options = { ...HIGHLIGHT_DEFAULTS, ...(options || {}) }; + el.classList.add(options.className); + setTimeout(() => el.classList.remove(options.className), options.delay); +}; diff --git a/assets/javascripts/models/doc.coffee b/assets/javascripts/models/doc.coffee deleted file mode 100644 index c51e13fae4..0000000000 --- a/assets/javascripts/models/doc.coffee +++ /dev/null @@ -1,147 +0,0 @@ -class app.models.Doc extends app.Model - # Attributes: name, slug, type, version, release, db_size, mtime, links - - constructor: -> - super - @reset @ - @slug_without_version = @slug.split('~')[0] - @fullName = "#{@name}" + if @version then " #{@version}" else '' - @icon = @slug_without_version - @short_version = @version.split(' ')[0] if @version - @text = @toEntry().text - - reset: (data) -> - @resetEntries data.entries - @resetTypes data.types - return - - resetEntries: (entries) -> - @entries = new app.collections.Entries(entries) - @entries.each (entry) => entry.doc = @ - return - - resetTypes: (types) -> - @types = new app.collections.Types(types) - @types.each (type) => type.doc = @ - return - - fullPath: (path = '') -> - path = "/#{path}" unless path[0] is '/' - "/#{@slug}#{path}" - - fileUrl: (path) -> - "#{app.config.docs_origin}#{@fullPath(path)}?#{@mtime}" - - dbUrl: -> - "#{app.config.docs_origin}/#{@slug}/#{app.config.db_filename}?#{@mtime}" - - indexUrl: -> - "#{app.indexHost()}/#{@slug}/#{app.config.index_filename}?#{@mtime}" - - toEntry: -> - return @entry if @entry - @entry = new app.models.Entry - doc: @ - name: @fullName - path: 'index' - @entry.addAlias(@name) if @version - @entry - - findEntryByPathAndHash: (path, hash) -> - if hash and entry = @entries.findBy 'path', "#{path}##{hash}" - entry - else if path is 'index' - @toEntry() - else - @entries.findBy 'path', path - - load: (onSuccess, onError, options = {}) -> - return if options.readCache and @_loadFromCache(onSuccess) - - callback = (data) => - @reset data - onSuccess() - @_setCache data if options.writeCache - return - - ajax - url: @indexUrl() - success: callback - error: onError - - clearCache: -> - app.localStorage.del @slug - return - - _loadFromCache: (onSuccess) -> - return unless data = @_getCache() - - callback = => - @reset data - onSuccess() - return - - setTimeout callback, 0 - true - - _getCache: -> - return unless data = app.localStorage.get @slug - - if data[0] is @mtime - return data[1] - else - @clearCache() - return - - _setCache: (data) -> - app.localStorage.set @slug, [@mtime, data] - return - - install: (onSuccess, onError, onProgress) -> - return if @installing - @installing = true - - error = => - @installing = null - onError() - return - - success = (data) => - @installing = null - app.db.store @, data, onSuccess, error - return - - ajax - url: @dbUrl() - success: success - error: error - progress: onProgress - timeout: 3600 - return - - uninstall: (onSuccess, onError) -> - return if @installing - @installing = true - - success = => - @installing = null - onSuccess() - return - - error = => - @installing = null - onError() - return - - app.db.unstore @, success, error - return - - getInstallStatus: (callback) -> - app.db.version @, (value) -> - callback installed: !!value, mtime: value - return - - isOutdated: (status) -> - return false if not status - isInstalled = status.installed or app.settings.get('autoInstall') - isInstalled and @mtime isnt status.mtime diff --git a/assets/javascripts/models/doc.js b/assets/javascripts/models/doc.js new file mode 100644 index 0000000000..a900c61ea1 --- /dev/null +++ b/assets/javascripts/models/doc.js @@ -0,0 +1,202 @@ +app.models.Doc = class Doc extends app.Model { + // Attributes: name, slug, type, version, release, db_size, mtime, links + + constructor() { + super(...arguments); + this.reset(this); + this.slug_without_version = this.slug.split("~")[0]; + this.fullName = `${this.name}` + (this.version ? ` ${this.version}` : ""); + this.icon = this.slug_without_version; + if (this.version) { + this.short_version = this.version.split(" ")[0]; + } + this.text = this.toEntry().text; + } + + reset(data) { + this.resetEntries(data.entries); + this.resetTypes(data.types); + } + + resetEntries(entries) { + this.entries = new app.collections.Entries(entries); + this.entries.each((entry) => { + return (entry.doc = this); + }); + } + + resetTypes(types) { + this.types = new app.collections.Types(types); + this.types.each((type) => { + return (type.doc = this); + }); + } + + fullPath(path) { + if (path == null) { + path = ""; + } + if (path[0] !== "/") { + path = `/${path}`; + } + return `/${this.slug}${path}`; + } + + fileUrl(path) { + return `${app.config.docs_origin}${this.fullPath(path)}?${this.mtime}`; + } + + dbUrl() { + return `${app.config.docs_origin}/${this.slug}/${app.config.db_filename}?${this.mtime}`; + } + + indexUrl() { + return `${app.indexHost()}/${this.slug}/${app.config.index_filename}?${ + this.mtime + }`; + } + + toEntry() { + if (this.entry) { + return this.entry; + } + this.entry = new app.models.Entry({ + doc: this, + name: this.fullName, + path: "index", + }); + if (this.version) { + this.entry.addAlias(this.name); + } + return this.entry; + } + + findEntryByPathAndHash(path, hash) { + const entry = hash && this.entries.findBy("path", `${path}#${hash}`); + if (entry) { + return entry; + } else if (path === "index") { + return this.toEntry(); + } else { + return this.entries.findBy("path", path); + } + } + + load(onSuccess, onError, options) { + if (options == null) { + options = {}; + } + if (options.readCache && this._loadFromCache(onSuccess)) { + return; + } + + const callback = (data) => { + this.reset(data); + onSuccess(); + if (options.writeCache) { + this._setCache(data); + } + }; + + return ajax({ + url: this.indexUrl(), + success: callback, + error: onError, + }); + } + + clearCache() { + app.localStorage.del(this.slug); + } + + _loadFromCache(onSuccess) { + const data = this._getCache(); + if (!data) { + return; + } + + const callback = () => { + this.reset(data); + onSuccess(); + }; + + setTimeout(callback, 0); + return true; + } + + _getCache() { + const data = app.localStorage.get(this.slug); + if (!data) { + return; + } + + if (data[0] === this.mtime) { + return data[1]; + } else { + this.clearCache(); + return; + } + } + + _setCache(data) { + app.localStorage.set(this.slug, [this.mtime, data]); + } + + install(onSuccess, onError, onProgress) { + if (this.installing) { + return; + } + this.installing = true; + + const error = () => { + this.installing = null; + onError(); + }; + + const success = (data) => { + this.installing = null; + app.db.store(this, data, onSuccess, error); + }; + + ajax({ + url: this.dbUrl(), + success, + error, + progress: onProgress, + timeout: 3600, + }); + } + + uninstall(onSuccess, onError) { + if (this.installing) { + return; + } + this.installing = true; + + const success = () => { + this.installing = null; + onSuccess(); + }; + + const error = () => { + this.installing = null; + onError(); + }; + + app.db.unstore(this, success, error); + } + + getInstallStatus(callback) { + app.db.version(this, (value) => + callback({ installed: !!value, mtime: value }), + ); + } + + isOutdated(status) { + if (!status) { + return false; + } + const isInstalled = status.installed || app.settings.get("autoInstall"); + return isInstalled && this.mtime !== status.mtime; + } +}; diff --git a/assets/javascripts/models/entry.coffee b/assets/javascripts/models/entry.coffee deleted file mode 100644 index 2d07c1599c..0000000000 --- a/assets/javascripts/models/entry.coffee +++ /dev/null @@ -1,85 +0,0 @@ -#= require app/searcher - -class app.models.Entry extends app.Model - # Attributes: name, type, path - - constructor: -> - super - @text = applyAliases(app.Searcher.normalizeString(@name)) - - addAlias: (name) -> - text = applyAliases(app.Searcher.normalizeString(name)) - @text = [@text] unless Array.isArray(@text) - @text.push(if Array.isArray(text) then text[1] else text) - return - - fullPath: -> - @doc.fullPath if @isIndex() then '' else @path - - dbPath: -> - @path.replace /#.*/, '' - - filePath: -> - @doc.fullPath @_filePath() - - fileUrl: -> - @doc.fileUrl @_filePath() - - _filePath: -> - result = @path.replace /#.*/, '' - result += '.html' unless result[-5..-1] is '.html' - result - - isIndex: -> - @path is 'index' - - getType: -> - @doc.types.findBy 'name', @type - - loadFile: (onSuccess, onError) -> - app.db.load(@, onSuccess, onError) - - applyAliases = (string) -> - if ALIASES.hasOwnProperty(string) - return [string, ALIASES[string]] - else - words = string.split('.') - for word, i in words when ALIASES.hasOwnProperty(word) - words[i] = ALIASES[word] - return [string, words.join('.')] - return string - - @ALIASES = ALIASES = - 'angular': 'ng' - 'angular.js': 'ng' - 'backbone.js': 'bb' - 'c++': 'cpp' - 'coffeescript': 'cs' - 'crystal': 'cr' - 'elixir': 'ex' - 'javascript': 'js' - 'julia': 'jl' - 'jquery': '$' - 'knockout.js': 'ko' - 'kubernetes': 'k8s' - 'less': 'ls' - 'lodash': '_' - 'löve': 'love' - 'marionette': 'mn' - 'markdown': 'md' - 'matplotlib': 'mpl' - 'modernizr': 'mdr' - 'moment.js': 'mt' - 'openjdk': 'java' - 'nginx': 'ngx' - 'numpy': 'np' - 'pandas': 'pd' - 'postgresql': 'pg' - 'python': 'py' - 'ruby.on.rails': 'ror' - 'ruby': 'rb' - 'rust': 'rs' - 'sass': 'scss' - 'tensorflow': 'tf' - 'typescript': 'ts' - 'underscore.js': '_' diff --git a/assets/javascripts/models/entry.js b/assets/javascripts/models/entry.js new file mode 100644 index 0000000000..98822bc97e --- /dev/null +++ b/assets/javascripts/models/entry.js @@ -0,0 +1,70 @@ +//= require app/searcher + +app.models.Entry = class Entry extends app.Model { + static applyAliases(string) { + const aliases = app.config.docs_aliases; + if (aliases.hasOwnProperty(string)) { + return [string, aliases[string]]; + } else { + const words = string.split("."); + for (let i = 0; i < words.length; i++) { + var word = words[i]; + if (aliases.hasOwnProperty(word)) { + words[i] = aliases[word]; + return [string, words.join(".")]; + } + } + } + return string; + } + + // Attributes: name, type, path + constructor() { + super(...arguments); + this.text = Entry.applyAliases(app.Searcher.normalizeString(this.name)); + } + + addAlias(name) { + const text = Entry.applyAliases(app.Searcher.normalizeString(name)); + if (!Array.isArray(this.text)) { + this.text = [this.text]; + } + this.text.push(Array.isArray(text) ? text[1] : text); + } + + fullPath() { + return this.doc.fullPath(this.isIndex() ? "" : this.path); + } + + dbPath() { + return this.path.replace(/#.*/, ""); + } + + filePath() { + return this.doc.fullPath(this._filePath()); + } + + fileUrl() { + return this.doc.fileUrl(this._filePath()); + } + + _filePath() { + let result = this.path.replace(/#.*/, ""); + if (result.slice(-5) !== ".html") { + result += ".html"; + } + return result; + } + + isIndex() { + return this.path === "index"; + } + + getType() { + return this.doc.types.findBy("name", this.type); + } + + loadFile(onSuccess, onError) { + return app.db.load(this, onSuccess, onError); + } +}; diff --git a/assets/javascripts/models/model.coffee b/assets/javascripts/models/model.coffee deleted file mode 100644 index 7f157f7c4f..0000000000 --- a/assets/javascripts/models/model.coffee +++ /dev/null @@ -1,3 +0,0 @@ -class app.Model - constructor: (attributes) -> - @[key] = value for key, value of attributes diff --git a/assets/javascripts/models/model.js b/assets/javascripts/models/model.js new file mode 100644 index 0000000000..def06e55ce --- /dev/null +++ b/assets/javascripts/models/model.js @@ -0,0 +1,8 @@ +app.Model = class Model { + constructor(attributes) { + for (var key in attributes) { + var value = attributes[key]; + this[key] = value; + } + } +}; diff --git a/assets/javascripts/models/type.coffee b/assets/javascripts/models/type.coffee deleted file mode 100644 index 6351ad16d7..0000000000 --- a/assets/javascripts/models/type.coffee +++ /dev/null @@ -1,14 +0,0 @@ -class app.models.Type extends app.Model - # Attributes: name, slug, count - - fullPath: -> - "/#{@doc.slug}-#{@slug}/" - - entries: -> - @doc.entries.findAllBy 'type', @name - - toEntry: -> - new app.models.Entry - doc: @doc - name: "#{@doc.name} / #{@name}" - path: '..' + @fullPath() diff --git a/assets/javascripts/models/type.js b/assets/javascripts/models/type.js new file mode 100644 index 0000000000..bc264ac189 --- /dev/null +++ b/assets/javascripts/models/type.js @@ -0,0 +1,19 @@ +app.models.Type = class Type extends app.Model { + // Attributes: name, slug, count + + fullPath() { + return `/${this.doc.slug}-${this.slug}/`; + } + + entries() { + return this.doc.entries.findAllBy("type", this.name); + } + + toEntry() { + return new app.models.Entry({ + doc: this.doc, + name: `${this.doc.name} / ${this.name}`, + path: ".." + this.fullPath(), + }); + } +}; diff --git a/assets/javascripts/news.json b/assets/javascripts/news.json index 7e24f0957f..1f821abae4 100644 --- a/assets/javascripts/news.json +++ b/assets/javascripts/news.json @@ -1,4 +1,72 @@ [ + [ + "2025-10-19", + "New documentations: Lit, Graphviz, Bun" + ], + [ + "2025-07-14", + "New documentation: Tcllib" + ], + [ + "2025-06-27", + "New documentation: Zsh" + ], + [ + "2025-06-04", + "New documentation: es-toolkit" + ], + [ + "2025-05-28", + "New documentation: Vert.x" + ], + [ + "2025-02-23", + "New documentation: Three.js" + ], + [ + "2025-02-16", + "New documentation: OpenLayers" + ], + [ + "2024-11-23", + "New documentation: DuckDB" + ], + [ + "2024-08-20", + "New documentation: Linux man pages" + ], + [ + "2024-07-28", + "New documentation: OpenGL" + ], + [ + "2024-06-12", + "New documentations: Next.js, click" + ], + [ + "2024-01-24", + "New documentation: Playwright" + ], + [ + "2024-01-20", + "New documentation: htmx" + ], + [ + "2024-01-12", + "New documentation: Hammerspoon" + ], + [ + "2024-01-05", + "New documentation: Bazel" + ], + [ + "2023-10-09", + "New documentations: hapi, joi, Nushell, Varnish" + ], + [ + "2023-08-24", + "New documentation: Fluture" + ], [ "2022-12-20", "New documentations: QUnit, Wagtail" diff --git a/assets/javascripts/templates/base.coffee b/assets/javascripts/templates/base.coffee deleted file mode 100644 index 841d1e0bb7..0000000000 --- a/assets/javascripts/templates/base.coffee +++ /dev/null @@ -1,11 +0,0 @@ -app.templates.render = (name, value, args...) -> - template = app.templates[name] - - if Array.isArray(value) - result = '' - result += template(val, args...) for val in value - result - else if typeof template is 'function' - template(value, args...) - else - template diff --git a/assets/javascripts/templates/base.js b/assets/javascripts/templates/base.js new file mode 100644 index 0000000000..fc445ef19c --- /dev/null +++ b/assets/javascripts/templates/base.js @@ -0,0 +1,15 @@ +app.templates.render = function (name, value, ...args) { + const template = app.templates[name]; + + if (Array.isArray(value)) { + let result = ""; + for (var val of value) { + result += template(val, ...args); + } + return result; + } else if (typeof template === "function") { + return template(value, ...args); + } else { + return template; + } +}; diff --git a/assets/javascripts/templates/error_tmpl.coffee b/assets/javascripts/templates/error_tmpl.coffee deleted file mode 100644 index 9cca1f9d32..0000000000 --- a/assets/javascripts/templates/error_tmpl.coffee +++ /dev/null @@ -1,73 +0,0 @@ -error = (title, text = '', links = '') -> - text = """

#{text}

""" if text - links = """""" if links - """

#{title}

#{text}#{links}
""" - -back = 'Go back' - -app.templates.notFoundPage = -> - error """ Page not found. """, - """ It may be missing from the source documentation or this could be a bug. """, - back - -app.templates.pageLoadError = -> - error """ The page failed to load. """, - """ It may be missing from the server (try reloading the app) or you could be offline (try installing the documentation for offline usage when online again).
- If you're online and you keep seeing this, you're likely behind a proxy or firewall that blocks cross-domain requests. """, - """ #{back} · Reload - · Retry """ - -app.templates.bootError = -> - error """ The app failed to load. """, - """ Check your Internet connection and try reloading.
- If you keep seeing this, you're likely behind a proxy or firewall that blocks cross-domain requests. """ - -app.templates.offlineError = (reason, exception) -> - if reason is 'cookie_blocked' - return error """ Cookies must be enabled to use offline mode. """ - - reason = switch reason - when 'not_supported' - """ DevDocs requires IndexedDB to cache documentations for offline access.
- Unfortunately your browser either doesn't support IndexedDB or doesn't make it available. """ - when 'buggy' - """ DevDocs requires IndexedDB to cache documentations for offline access.
- Unfortunately your browser's implementation of IndexedDB contains bugs that prevent DevDocs from using it. """ - when 'private_mode' - """ Your browser appears to be running in private mode.
- This prevents DevDocs from caching documentations for offline access.""" - when 'exception' - """ An error occurred when trying to open the IndexedDB database:
- #{exception.name}: #{exception.message} """ - when 'cant_open' - """ An error occurred when trying to open the IndexedDB database:
- #{exception.name}: #{exception.message}
- This could be because you're browsing in private mode or have disallowed offline storage on the domain. """ - when 'version' - """ The IndexedDB database was modified with a newer version of the app.
- Reload the page to use offline mode. """ - when 'empty' - """ The IndexedDB database appears to be corrupted. Try resetting the app. """ - - error 'Offline mode is unavailable.', reason - -app.templates.unsupportedBrowser = """ -
-

Your browser is unsupported, sorry.

-

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

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

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

- Note: if you're already using one of the browsers above, check your settings and add-ons. - The app uses feature detection, not user agent sniffing. -

- — @DevDocs -

-""" diff --git a/assets/javascripts/templates/error_tmpl.js b/assets/javascripts/templates/error_tmpl.js new file mode 100644 index 0000000000..c047d46e5e --- /dev/null +++ b/assets/javascripts/templates/error_tmpl.js @@ -0,0 +1,95 @@ +const error = function (title, text, links) { + if (text == null) { + text = ""; + } + if (links == null) { + links = ""; + } + if (text) { + text = `

${text}

`; + } + if (links) { + links = ``; + } + return `

${title}

${text}${links}
`; +}; + +const back = 'Go back'; + +app.templates.notFoundPage = () => + error( + " Page not found. ", + " It may be missing from the source documentation or this could be a bug. ", + back, + ); + +app.templates.pageLoadError = () => + error( + " The page failed to load. ", + ` It may be missing from the server (try reloading the app) or you could be offline (try installing the documentation for offline usage when online again).
+If you're online and you keep seeing this, you're likely behind a proxy or firewall that blocks cross-domain requests. `, + ` ${back} · ReloadRetry `, + ); + +app.templates.bootError = () => + error( + " The app failed to load. ", + ` Check your Internet connection and try reloading.
+If you keep seeing this, you're likely behind a proxy or firewall that blocks cross-domain requests. `, + ); + +app.templates.offlineError = function (reason, exception) { + if (reason === "cookie_blocked") { + return error(" Cookies must be enabled to use offline mode. "); + } + + reason = (() => { + switch (reason) { + case "not_supported": + return ` DevDocs requires IndexedDB to cache documentations for offline access.
+Unfortunately your browser either doesn't support IndexedDB or doesn't make it available. `; + case "buggy": + return ` DevDocs requires IndexedDB to cache documentations for offline access.
+Unfortunately your browser's implementation of IndexedDB contains bugs that prevent DevDocs from using it. `; + case "private_mode": + return ` Your browser appears to be running in private mode.
+This prevents DevDocs from caching documentations for offline access.`; + case "exception": + return ` An error occurred when trying to open the IndexedDB database:
+${exception.name}: ${exception.message} `; + case "cant_open": + return ` An error occurred when trying to open the IndexedDB database:
+${exception.name}: ${exception.message}
+This could be because you're browsing in private mode or have disallowed offline storage on the domain. `; + case "version": + return ` The IndexedDB database was modified with a newer version of the app.
+Reload the page to use offline mode. `; + case "empty": + return ' The IndexedDB database appears to be corrupted. Try resetting the app. '; + } + })(); + + return error("Offline mode is unavailable.", reason); +}; + +app.templates.unsupportedBrowser = `\ +
+

Your browser is unsupported, sorry.

+

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

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

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

+ Note: if you're already using one of the browsers above, check your settings and add-ons. + The app uses feature detection, not user agent sniffing. +

+ — @DevDocs +

\ +`; diff --git a/assets/javascripts/templates/notice_tmpl.coffee b/assets/javascripts/templates/notice_tmpl.coffee deleted file mode 100644 index 10cc534eb0..0000000000 --- a/assets/javascripts/templates/notice_tmpl.coffee +++ /dev/null @@ -1,9 +0,0 @@ -notice = (text) -> """

#{text}

""" - -app.templates.singleDocNotice = (doc) -> - notice """ You're browsing the #{doc.fullName} documentation. To browse all docs, go to - #{app.config.production_host} (or press esc). """ - -app.templates.disabledDocNotice = -> - notice """ This documentation is disabled. - To enable it, go to Preferences. """ diff --git a/assets/javascripts/templates/notice_tmpl.js b/assets/javascripts/templates/notice_tmpl.js new file mode 100644 index 0000000000..49793eb571 --- /dev/null +++ b/assets/javascripts/templates/notice_tmpl.js @@ -0,0 +1,9 @@ +const notice = (text) => `

${text}

`; + +app.templates.singleDocNotice = (doc) => + notice(` You're browsing the ${doc.fullName} documentation. To browse all docs, go to +${app.config.production_host} (or press esc). `); + +app.templates.disabledDocNotice = () => + notice(` This documentation is disabled. +To enable it, go to Preferences. `); diff --git a/assets/javascripts/templates/notif_tmpl.coffee b/assets/javascripts/templates/notif_tmpl.coffee deleted file mode 100644 index 0821036e77..0000000000 --- a/assets/javascripts/templates/notif_tmpl.coffee +++ /dev/null @@ -1,76 +0,0 @@ -notif = (title, html) -> - html = html.replace /#{title} - #{html} - - - - -
- - - - - - - - #{docs} -
DocumentationSizeStatusAction
-
-

Note: your browser may delete DevDocs's offline data if your computer is running low on disk space and you haven't used the app in a while. Load this page before going offline to make sure the data is still there. -

Questions & Answers

-
-
How does this work? -
Each page is cached as a key-value pair in IndexedDB (downloaded from a single file).
- The app also uses Service Workers and localStorage to cache the assets and index files. -
Can I close the tab/browser? -
#{canICloseTheTab()} -
What if I don't update a documentation? -
You'll see outdated content and some pages will be missing or broken, because the rest of the app (including data for the search and sidebar) uses a different caching mechanism that's updated automatically. -
I found a bug, where do I report it? -
In the issue tracker. Thanks! -
How do I uninstall/reset the app? -
Click here. -
Why aren't all documentations listed above? -
You have to enable them first. -
-""" - -canICloseTheTab = -> - if app.ServiceWorker.isEnabled() - """ Yes! Even offline, you can open a new tab, go to devdocs.io, and everything will work as if you were online (provided you installed all the documentations you want to use beforehand). """ - else - reason = "aren't available in your browser (or are disabled)" - - if app.config.env != 'production' - reason = "are disabled in your development instance of DevDocs (enable them by setting the ENABLE_SERVICE_WORKER environment variable to true)" - - """ No. Service Workers #{reason}, so loading devdocs.io offline won't work.
- The current tab will continue to function even when you go offline (provided you installed all the documentations beforehand). """ - -app.templates.offlineDoc = (doc, status) -> - outdated = doc.isOutdated(status) - - html = """ - - #{doc.fullName} - #{Math.ceil(doc.db_size / 100000) / 10} MB - """ - - html += if !(status and status.installed) - """ - - - - """ - else if outdated - """ - Outdated - - - """ - else - """ - Up‑to‑date - - """ - - html + '' diff --git a/assets/javascripts/templates/pages/offline_tmpl.js b/assets/javascripts/templates/pages/offline_tmpl.js new file mode 100644 index 0000000000..ad46753195 --- /dev/null +++ b/assets/javascripts/templates/pages/offline_tmpl.js @@ -0,0 +1,88 @@ +app.templates.offlinePage = (docs) => `\ +

Offline Documentation

+ +
+ + +
+ +
+ + + + + + + + ${docs} +
DocumentationSizeStatusAction
+
+

Note: your browser may delete DevDocs's offline data if your computer is running low on disk space and you haven't used the app in a while. Load this page before going offline to make sure the data is still there. +

Questions & Answers

+
+
How does this work? +
Each page is cached as a key-value pair in IndexedDB (downloaded from a single file).
+ The app also uses Service Workers and localStorage to cache the assets and index files. +
Can I close the tab/browser? +
${canICloseTheTab()} +
What if I don't update a documentation? +
You'll see outdated content and some pages will be missing or broken, because the rest of the app (including data for the search and sidebar) uses a different caching mechanism that's updated automatically. +
I found a bug, where do I report it? +
In the issue tracker. Thanks! +
How do I uninstall/reset the app? +
Click here. +
Why aren't all documentations listed above? +
You have to enable them first. +
\ +`; + +var canICloseTheTab = function () { + if (app.ServiceWorker.isEnabled()) { + return ' Yes! Even offline, you can open a new tab, go to devdocs.io, and everything will work as if you were online (provided you installed all the documentations you want to use beforehand). '; + } else { + let reason = "aren't available in your browser (or are disabled)"; + + if (app.config.env !== "production") { + reason = + "are disabled in your development instance of DevDocs (enable them by setting the ENABLE_SERVICE_WORKER environment variable to true)"; + } + + return ` No. Service Workers ${reason}, so loading devdocs.io offline won't work.
+The current tab will continue to function even when you go offline (provided you installed all the documentations beforehand). `; + } +}; + +app.templates.offlineDoc = function (doc, status) { + const outdated = doc.isOutdated(status); + + let html = `\ + + ${doc.fullName} + ${ + Math.ceil(doc.db_size / 100000) / 10 + } MB\ +`; + + html += !(status && status.installed) + ? `\ +- +\ +` + : outdated + ? `\ +Outdated + - \ +` + : `\ +Up‑to‑date +\ +`; + + return html + ""; +}; diff --git a/assets/javascripts/templates/pages/root_tmpl.coffee.erb b/assets/javascripts/templates/pages/root_tmpl.coffee.erb deleted file mode 100644 index 559a30c9f1..0000000000 --- a/assets/javascripts/templates/pages/root_tmpl.coffee.erb +++ /dev/null @@ -1,74 +0,0 @@ -app.templates.splash = """
DevDocs
""" - -<% if App.development? %> -app.templates.intro = """ -
- Stop showing this message -

Hi there!

-

Thanks for downloading DevDocs. Here are a few things you should know: -

    -
  1. Your local version of DevDocs won't self-update. Unless you're modifying the code, - we recommend using the hosted version at devdocs.io. -
  2. Run thor docs:list to see all available documentations. -
  3. Run thor docs:download <name> to download documentations. -
  4. Run thor docs:download --installed to update all downloaded documentations. -
  5. To be notified about new versions, don't forget to watch the repository on GitHub. -
  6. The issue tracker is the preferred channel for bug reports and - feature requests. For everything else, use Discord. -
  7. Contributions are welcome. See the guidelines. -
  8. DevDocs is licensed under the terms of the Mozilla Public License v2.0. For more information, - see the COPYRIGHT and - LICENSE files. -
-

Happy coding! -

-""" -<% else %> -app.templates.intro = """ -
- Stop showing this message -

Welcome!

-

DevDocs combines multiple API documentations in a fast, organized, and searchable interface. - Here's what you should know before you start: -

    -
  1. Open the Preferences to enable more docs and customize the UI. -
  2. You don't have to use your mouse — see the list of keyboard shortcuts. -
  3. The search supports fuzzy matching (e.g. "bgcp" brings up "background-clip"). -
  4. To search a specific documentation, type its name (or an abbr.), then Tab. -
  5. You can search using your browser's address bar — learn how. -
  6. DevDocs works offline, on mobile, and can be installed as web app. -
  7. For the latest news, follow @DevDocs. -
  8. DevDocs is free and open source. - -
  9. And if you're new to coding, check out freeCodeCamp's open source curriculum. -
-

Happy coding! -

-""" -<% end %> - -app.templates.mobileIntro = """ -
-

Welcome!

-

DevDocs combines multiple API documentations in a fast, organized, and searchable interface. - Here's what you should know before you start: -

    -
  1. Pick your docs in the Preferences. -
  2. The search supports fuzzy matching. -
  3. To search a specific documentation, type its name (or an abbr.), then Space. -
  4. For the latest news, follow @DevDocs. -
  5. DevDocs is open source. -
-

Happy coding! - Stop showing this message -

-""" - -app.templates.androidWarning = """ -
-

Hi there

-

DevDocs is running inside an Android WebView. Some features may not work properly. -

If you downloaded an app called DevDocs on the Play Store, please uninstall it — it's made by someone who is using (and profiting from) the name DevDocs without permission. -

To install DevDocs on your phone, visit devdocs.io in Chrome and select "Add to home screen" in the menu. -

-""" diff --git a/assets/javascripts/templates/pages/root_tmpl.js.erb b/assets/javascripts/templates/pages/root_tmpl.js.erb new file mode 100644 index 0000000000..b82e9a9e6a --- /dev/null +++ b/assets/javascripts/templates/pages/root_tmpl.js.erb @@ -0,0 +1,74 @@ +app.templates.splash = "
DevDocs
"; + +<% if App.development? %> +app.templates.intro = `\ +
+ Stop showing this message +

Hi there!

+

Thanks for downloading DevDocs. Here are a few things you should know: +

    +
  1. Your local version of DevDocs won't self-update. Unless you're modifying the code, + we recommend using the hosted version at devdocs.io. +
  2. Run thor docs:list to see all available documentations. +
  3. Run thor docs:download <name> to download documentations. +
  4. Run thor docs:download --installed to update all downloaded documentations. +
  5. To be notified about new versions, don't forget to watch the repository on GitHub. +
  6. The issue tracker is the preferred channel for bug reports and + feature requests. For everything else, use Discord. +
  7. Contributions are welcome. See the guidelines. +
  8. DevDocs is licensed under the terms of the Mozilla Public License v2.0. For more information, + see the COPYRIGHT and + LICENSE files. +
+

Happy coding! +

\ +`; +<% else %> +app.templates.intro = `\ +
+ Stop showing this message +

Welcome!

+

DevDocs combines multiple API documentations in a fast, organized, and searchable interface. + Here's what you should know before you start: +

    +
  1. Open the Preferences to enable more docs and customize the UI. +
  2. You don't have to use your mouse — see the list of keyboard shortcuts or press ?. +
  3. The search supports fuzzy matching (e.g. "bgcp" brings up "background-clip"). +
  4. To search a specific documentation, type its name (or an abbr.), then Tab. +
  5. You can search using your browser's address bar — learn how. +
  6. DevDocs works offline, on mobile, and can be installed as web app. +
  7. For the latest news, follow @DevDocs. +
  8. DevDocs is free and open source. + +
  9. And if you're new to coding, check out freeCodeCamp's open source curriculum. +
+

Happy coding! +

\ +`; +<% end %> + +app.templates.mobileIntro = `\ +
+

Welcome!

+

DevDocs combines multiple API documentations in a fast, organized, and searchable interface. + Here's what you should know before you start: +

    +
  1. Pick your docs in the Preferences. +
  2. The search supports fuzzy matching. +
  3. To search a specific documentation, type its name (or an abbr.), then Space. +
  4. For the latest news, follow @DevDocs. +
  5. DevDocs is open source. +
+

Happy coding! + Stop showing this message +

\ +`; + +app.templates.androidWarning = `\ +
+

Hi there

+

DevDocs is running inside an Android WebView. Some features may not work properly. +

If you downloaded an app called DevDocs on the Play Store, please uninstall it — it's made by someone who is using (and profiting from) the name DevDocs without permission. +

To install DevDocs on your phone, visit devdocs.io in Chrome and select "Add to home screen" in the menu. +

\ +`; diff --git a/assets/javascripts/templates/pages/settings_tmpl.coffee b/assets/javascripts/templates/pages/settings_tmpl.coffee deleted file mode 100644 index 048afa1a8e..0000000000 --- a/assets/javascripts/templates/pages/settings_tmpl.coffee +++ /dev/null @@ -1,81 +0,0 @@ -themeOption = ({ label, value }, settings) -> """ - -""" - -app.templates.settingsPage = (settings) -> """ -

Preferences

- -
-

Theme:

-
- #{if settings.autoSupported - themeOption label: "Automatic Matches system setting", value: "auto", settings - else - ""} - #{themeOption label: "Light", value: "default", settings} - #{themeOption label: "Dark", value: "dark", settings} -
-
- -
-

General:

- -
- - - - - - -
-
- -
-

Scrolling:

- -
- - - - - -
-
- -

- - - -

- -""" diff --git a/assets/javascripts/templates/pages/settings_tmpl.js b/assets/javascripts/templates/pages/settings_tmpl.js new file mode 100644 index 0000000000..cfd30de1b2 --- /dev/null +++ b/assets/javascripts/templates/pages/settings_tmpl.js @@ -0,0 +1,118 @@ +const themeOption = ({ label, value }, settings) => `\ +\ +`; + +app.templates.settingsPage = (settings) => `\ +

Preferences

+ +
+

Theme:

+
+ ${ + settings.autoSupported + ? themeOption( + { + label: "Automatic Matches system setting", + value: "auto", + }, + settings, + ) + : "" + } + ${themeOption({ label: "Light", value: "default" }, settings)} + ${themeOption({ label: "Dark", value: "dark" }, settings)} +
+
+ +
+

General:

+ +
+ + + + + + + +
+
+ +
+

Scrolling:

+ +
+ + + + + +
+
+ +

+ + + +

+ \ +`; diff --git a/assets/javascripts/templates/pages/type_tmpl.coffee b/assets/javascripts/templates/pages/type_tmpl.coffee deleted file mode 100644 index c419a6a8c4..0000000000 --- a/assets/javascripts/templates/pages/type_tmpl.coffee +++ /dev/null @@ -1,6 +0,0 @@ -app.templates.typePage = (type) -> - """

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

-
    #{app.templates.render 'typePageEntry', type.entries()}
""" - -app.templates.typePageEntry = (entry) -> - """
  • #{$.escape entry.name}
  • """ diff --git a/assets/javascripts/templates/pages/type_tmpl.js b/assets/javascripts/templates/pages/type_tmpl.js new file mode 100644 index 0000000000..8e0723325c --- /dev/null +++ b/assets/javascripts/templates/pages/type_tmpl.js @@ -0,0 +1,11 @@ +app.templates.typePage = (type) => { + return `

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

    +
      ${app.templates.render( + "typePageEntry", + type.entries(), + )}
    `; +}; + +app.templates.typePageEntry = (entry) => { + return `
  • ${$.escape(entry.name)}
  • `; +}; diff --git a/assets/javascripts/templates/path_tmpl.coffee b/assets/javascripts/templates/path_tmpl.coffee deleted file mode 100644 index f28925c9ca..0000000000 --- a/assets/javascripts/templates/path_tmpl.coffee +++ /dev/null @@ -1,7 +0,0 @@ -arrow = """""" - -app.templates.path = (doc, type, entry) -> - html = """#{doc.fullName}""" - html += """#{arrow}#{type.name}""" if type - html += """#{arrow}#{$.escape entry.name}""" if entry - html diff --git a/assets/javascripts/templates/path_tmpl.js b/assets/javascripts/templates/path_tmpl.js new file mode 100644 index 0000000000..9d02c042e7 --- /dev/null +++ b/assets/javascripts/templates/path_tmpl.js @@ -0,0 +1,15 @@ +app.templates.path = function (doc, type, entry) { + const arrow = ''; + let html = `${doc.fullName}`; + if (type) { + html += `${arrow}${ + type.name + }`; + } + if (entry) { + html += `${arrow}${$.escape(entry.name)}`; + } + return html; +}; diff --git a/assets/javascripts/templates/sidebar_tmpl.coffee b/assets/javascripts/templates/sidebar_tmpl.coffee deleted file mode 100644 index 46797e5658..0000000000 --- a/assets/javascripts/templates/sidebar_tmpl.coffee +++ /dev/null @@ -1,68 +0,0 @@ -templates = app.templates - -arrow = """""" - -templates.sidebarDoc = (doc, options = {}) -> - link = """""" - if options.disabled - link += """Enable""" - else - link += arrow - link += """#{doc.release}""" if doc.release - link += """#{doc.name}""" - link += " #{doc.version}" if options.fullName or options.disabled and doc.version - link + "" - -templates.sidebarType = (type) -> - """#{arrow}#{type.count}#{$.escape type.name}""" - -templates.sidebarEntry = (entry) -> - """#{$.escape entry.name}""" - -templates.sidebarResult = (entry) -> - addons = if entry.isIndex() and app.disabledDocs.contains(entry.doc) - """Enable""" - else - """""" - addons += """#{entry.doc.short_version}""" if entry.doc.version and not entry.isIndex() - """#{addons}#{$.escape entry.name}""" - -templates.sidebarNoResults = -> - html = """
    No results.
    """ - html += """ -
    Note: documentations must be enabled to appear in the search.
    - """ unless app.isSingleDoc() or app.disabledDocs.isEmpty() - html - -templates.sidebarPageLink = (count) -> - """Show more\u2026 (#{count})""" - -templates.sidebarLabel = (doc, options = {}) -> - label = """""" - -templates.sidebarVersionedDoc = (doc, versions, options = {}) -> - html = """
    #{arrow}#{doc.name}
    #{versions}
    """ - -templates.sidebarDisabled = (options) -> - """
    #{arrow}Disabled (#{options.count}) Customize
    """ - -templates.sidebarDisabledList = (html) -> - """
    #{html}
    """ - -templates.sidebarDisabledVersionedDoc = (doc, versions) -> - """#{arrow}#{doc.name}
    #{versions}
    """ - -templates.docPickerHeader = """
    Documentation Enable
    """ - -templates.docPickerNote = """ -
    Tip: for faster and better search results, select only the docs you need.
    - Vote for new documentation - """ diff --git a/assets/javascripts/templates/sidebar_tmpl.js b/assets/javascripts/templates/sidebar_tmpl.js new file mode 100644 index 0000000000..80db6aef16 --- /dev/null +++ b/assets/javascripts/templates/sidebar_tmpl.js @@ -0,0 +1,111 @@ +const { templates } = app; + +const arrow = ''; + +templates.sidebarDoc = function (doc, options) { + if (options == null) { + options = {}; + } + let link = ``; + if (options.disabled) { + link += `Enable`; + } else { + link += arrow; + } + if (doc.release) { + link += `${doc.release}`; + } + link += `${doc.name}`; + if (options.fullName || (options.disabled && doc.version)) { + link += ` ${doc.version}`; + } + return link + ""; +}; + +templates.sidebarType = (type) => + `${arrow}${ + type.count + }${$.escape(type.name)}`; + +templates.sidebarEntry = (entry) => + `${$.escape( + entry.name, + )}`; + +templates.sidebarResult = function (entry) { + let addons = + entry.isIndex() && app.disabledDocs.contains(entry.doc) + ? `Enable` + : ''; + if (entry.doc.version && !entry.isIndex()) { + addons += `${entry.doc.short_version}`; + } + return `${addons}${$.escape( + entry.name, + )}`; +}; + +templates.sidebarNoResults = function () { + let html = '
    No results.
    '; + if (!app.isSingleDoc() && !app.disabledDocs.isEmpty()) { + html += `\ +
    Note: documentations must be enabled to appear in the search.
    \ +`; + } + return html; +}; + +templates.sidebarPageLink = (count) => + `Show more\u2026 (${count})`; + +templates.sidebarLabel = function (doc, options) { + if (options == null) { + options = {}; + } + let label = '`; +}; + +templates.sidebarVersionedDoc = function (doc, versions, options) { + if (options == null) { + options = {}; + } + let html = `
    ${arrow}${doc.name}
    ${versions}
    ` + ); +}; + +templates.sidebarDisabled = (options) => + `
    ${arrow}Disabled (${options.count}) Customize
    `; + +templates.sidebarDisabledList = (html) => + `
    ${html}
    `; + +templates.sidebarDisabledVersionedDoc = (doc, versions) => + `${arrow}${doc.name}
    ${versions}
    `; + +templates.docPickerHeader = + '
    Documentation Enable
    '; + +templates.docPickerNote = `\ +
    Tip: for faster and better search results, select only the docs you need.
    +Vote for new documentation\ +`; diff --git a/assets/javascripts/templates/tip_tmpl.coffee b/assets/javascripts/templates/tip_tmpl.coffee deleted file mode 100644 index 55979fa452..0000000000 --- a/assets/javascripts/templates/tip_tmpl.coffee +++ /dev/null @@ -1,10 +0,0 @@ -app.templates.tipKeyNav = () -> """ -

    - ProTip - (click to dismiss) -

    - Hit #{if app.settings.get('arrowScroll') then 'shift +' else ''} to navigate the sidebar.
    - Hit space / shift space#{if app.settings.get('arrowScroll') then ' or ↓/↑' else ', alt ↓/↑ or shift ↓/↑'} to scroll the page. -

    - See all keyboard shortcuts -""" diff --git a/assets/javascripts/templates/tip_tmpl.js b/assets/javascripts/templates/tip_tmpl.js new file mode 100644 index 0000000000..223ffe9587 --- /dev/null +++ b/assets/javascripts/templates/tip_tmpl.js @@ -0,0 +1,16 @@ +app.templates.tipKeyNav = () => `\ +

    + ProTip + (click to dismiss) +

    + Hit ${ + app.settings.get("arrowScroll") ? 'shift +' : "" + } to navigate the sidebar.
    + Hit space / shift space${ + app.settings.get("arrowScroll") + ? ' or ↓/↑' + : ', alt ↓/↑ or shift ↓/↑' + } to scroll the page. +

    + See all keyboard shortcuts\ +`; diff --git a/assets/javascripts/tracking.js b/assets/javascripts/tracking.js index a68ca493e7..c15781f5c3 100644 --- a/assets/javascripts/tracking.js +++ b/assets/javascripts/tracking.js @@ -1,32 +1,55 @@ try { - if (app.config.env === 'production') { - if (Cookies.get('analyticsConsent') === '1') { - (function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){ - (i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o), - m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m) - })(window,document,'script','https://www.google-analytics.com/analytics.js','ga'); - ga('create', 'UA-5544833-12', 'devdocs.io'); - page.track(function() { - ga('send', 'pageview', { + if (app.config.env === "production") { + if (Cookies.get("analyticsConsent") === "1") { + (function (i, s, o, g, r, a, m) { + i["GoogleAnalyticsObject"] = r; + (i[r] = + i[r] || + function () { + (i[r].q = i[r].q || []).push(arguments); + }), + (i[r].l = 1 * new Date()); + (a = s.createElement(o)), (m = s.getElementsByTagName(o)[0]); + a.async = 1; + a.src = g; + m.parentNode.insertBefore(a, m); + })( + window, + document, + "script", + "https://www.google-analytics.com/analytics.js", + "ga", + ); + ga("create", "UA-5544833-12", "devdocs.io"); + page.track(function () { + ga("send", "pageview", { page: location.pathname + location.search + location.hash, - dimension1: app.router.context && app.router.context.doc && app.router.context.doc.slug_without_version + dimension1: + app.router.context && + app.router.context.doc && + app.router.context.doc.slug_without_version, }); }); - page.track(function() { - if (window._gauges) - _gauges.push(['track']); + page.track(function () { + if (window._gauges) _gauges.push(["track"]); else - (function() { - var _gauges=_gauges||[];!function(){var a=document.createElement("script"); - a.type="text/javascript",a.async=!0,a.id="gauges-tracker", - a.setAttribute("data-site-id","51c15f82613f5d7819000067"), - a.src="https://secure.gaug.es/track.js";var b=document.getElementsByTagName("script")[0]; - b.parentNode.insertBefore(a,b)}(); + (function () { + var _gauges = _gauges || []; + !(function () { + var a = document.createElement("script"); + (a.type = "text/javascript"), + (a.async = !0), + (a.id = "gauges-tracker"), + a.setAttribute("data-site-id", "51c15f82613f5d7819000067"), + (a.src = "https://secure.gaug.es/track.js"); + var b = document.getElementsByTagName("script")[0]; + b.parentNode.insertBefore(a, b); + })(); })(); }); } else { resetAnalytics(); } } -} catch(e) { } +} catch (e) {} diff --git a/assets/javascripts/vendor/cookies.js b/assets/javascripts/vendor/cookies.js index ad7597cdc1..e592a5de8e 100644 --- a/assets/javascripts/vendor/cookies.js +++ b/assets/javascripts/vendor/cookies.js @@ -5,171 +5,204 @@ * This is free and unencumbered software released into the public domain. */ (function (global, undefined) { - 'use strict'; + "use strict"; - var factory = function (window) { - if (typeof window.document !== 'object') { - throw new Error('Cookies.js requires a `window` with a `document` object'); + var factory = function (window) { + if (typeof window.document !== "object") { + throw new Error( + "Cookies.js requires a `window` with a `document` object", + ); + } + + var Cookies = function (key, value, options) { + return arguments.length === 1 + ? Cookies.get(key) + : Cookies.set(key, value, options); + }; + + // Allows for setter injection in unit tests + Cookies._document = window.document; + + // Used to ensure cookie keys do not collide with + // built-in `Object` properties + Cookies._cacheKeyPrefix = "cookey."; // Hurr hurr, :) + + Cookies._maxExpireDate = new Date("Fri, 31 Dec 9999 23:59:59 UTC"); + + Cookies.defaults = { + path: "/", + SameSite: "Strict", + secure: true, + }; + + Cookies.get = function (key) { + if (Cookies._cachedDocumentCookie !== Cookies._document.cookie) { + Cookies._renewCache(); + } + + var value = Cookies._cache[Cookies._cacheKeyPrefix + key]; + + return value === undefined ? undefined : decodeURIComponent(value); + }; + + Cookies.set = function (key, value, options) { + options = Cookies._getExtendedOptions(options); + options.expires = Cookies._getExpiresDate( + value === undefined ? -1 : options.expires, + ); + + Cookies._document.cookie = Cookies._generateCookieString( + key, + value, + options, + ); + + return Cookies; + }; + + Cookies.expire = function (key, options) { + return Cookies.set(key, undefined, options); + }; + + Cookies._getExtendedOptions = function (options) { + return { + path: (options && options.path) || Cookies.defaults.path, + domain: (options && options.domain) || Cookies.defaults.domain, + SameSite: (options && options.SameSite) || Cookies.defaults.SameSite, + expires: (options && options.expires) || Cookies.defaults.expires, + secure: + options && options.secure !== undefined + ? options.secure + : Cookies.defaults.secure, + }; + }; + + Cookies._isValidDate = function (date) { + return ( + Object.prototype.toString.call(date) === "[object Date]" && + !isNaN(date.getTime()) + ); + }; + + Cookies._getExpiresDate = function (expires, now) { + now = now || new Date(); + + if (typeof expires === "number") { + expires = + expires === Infinity + ? Cookies._maxExpireDate + : new Date(now.getTime() + expires * 1000); + } else if (typeof expires === "string") { + expires = new Date(expires); + } + + if (expires && !Cookies._isValidDate(expires)) { + throw new Error( + "`expires` parameter cannot be converted to a valid Date instance", + ); + } + + return expires; + }; + + Cookies._generateCookieString = function (key, value, options) { + key = key.replace(/[^#$&+\^`|]/g, encodeURIComponent); + key = key.replace(/\(/g, "%28").replace(/\)/g, "%29"); + value = (value + "").replace( + /[^!#$&-+\--:<-\[\]-~]/g, + encodeURIComponent, + ); + options = options || {}; + + var cookieString = key + "=" + value; + cookieString += options.path ? ";path=" + options.path : ""; + cookieString += options.domain ? ";domain=" + options.domain : ""; + cookieString += options.SameSite ? ";SameSite=" + options.SameSite : ""; + cookieString += options.expires + ? ";expires=" + options.expires.toUTCString() + : ""; + cookieString += options.secure ? ";secure" : ""; + + return cookieString; + }; + + Cookies._getCacheFromString = function (documentCookie) { + var cookieCache = {}; + var cookiesArray = documentCookie ? documentCookie.split("; ") : []; + + for (var i = 0; i < cookiesArray.length; i++) { + var cookieKvp = Cookies._getKeyValuePairFromCookieString( + cookiesArray[i], + ); + + if ( + cookieCache[Cookies._cacheKeyPrefix + cookieKvp.key] === undefined + ) { + cookieCache[Cookies._cacheKeyPrefix + cookieKvp.key] = + cookieKvp.value; } + } - var Cookies = function (key, value, options) { - return arguments.length === 1 ? - Cookies.get(key) : Cookies.set(key, value, options); - }; + return cookieCache; + }; - // Allows for setter injection in unit tests - Cookies._document = window.document; - - // Used to ensure cookie keys do not collide with - // built-in `Object` properties - Cookies._cacheKeyPrefix = 'cookey.'; // Hurr hurr, :) + Cookies._getKeyValuePairFromCookieString = function (cookieString) { + // "=" is a valid character in a cookie value according to RFC6265, so cannot `split('=')` + var separatorIndex = cookieString.indexOf("="); + + // IE omits the "=" when the cookie value is an empty string + separatorIndex = + separatorIndex < 0 ? cookieString.length : separatorIndex; + + var key = cookieString.substr(0, separatorIndex); + var decodedKey; + try { + decodedKey = decodeURIComponent(key); + } catch (e) { + if (console && typeof console.error === "function") { + console.error('Could not decode cookie with key "' + key + '"', e); + } + } - Cookies._maxExpireDate = new Date('Fri, 31 Dec 9999 23:59:59 UTC'); + return { + key: decodedKey, + value: cookieString.substr(separatorIndex + 1), // Defer decoding value until accessed + }; + }; - Cookies.defaults = { - path: '/', - SameSite: 'Strict', - secure: true - }; - - Cookies.get = function (key) { - if (Cookies._cachedDocumentCookie !== Cookies._document.cookie) { - Cookies._renewCache(); - } - - var value = Cookies._cache[Cookies._cacheKeyPrefix + key]; - - return value === undefined ? undefined : decodeURIComponent(value); - }; - - Cookies.set = function (key, value, options) { - options = Cookies._getExtendedOptions(options); - options.expires = Cookies._getExpiresDate(value === undefined ? -1 : options.expires); - - Cookies._document.cookie = Cookies._generateCookieString(key, value, options); - - return Cookies; - }; - - Cookies.expire = function (key, options) { - return Cookies.set(key, undefined, options); - }; - - Cookies._getExtendedOptions = function (options) { - return { - path: options && options.path || Cookies.defaults.path, - domain: options && options.domain || Cookies.defaults.domain, - SameSite: options && options.SameSite || Cookies.defaults.SameSite, - expires: options && options.expires || Cookies.defaults.expires, - secure: options && options.secure !== undefined ? options.secure : Cookies.defaults.secure - }; - }; - - Cookies._isValidDate = function (date) { - return Object.prototype.toString.call(date) === '[object Date]' && !isNaN(date.getTime()); - }; - - Cookies._getExpiresDate = function (expires, now) { - now = now || new Date(); - - if (typeof expires === 'number') { - expires = expires === Infinity ? - Cookies._maxExpireDate : new Date(now.getTime() + expires * 1000); - } else if (typeof expires === 'string') { - expires = new Date(expires); - } - - if (expires && !Cookies._isValidDate(expires)) { - throw new Error('`expires` parameter cannot be converted to a valid Date instance'); - } - - return expires; - }; - - Cookies._generateCookieString = function (key, value, options) { - key = key.replace(/[^#$&+\^`|]/g, encodeURIComponent); - key = key.replace(/\(/g, '%28').replace(/\)/g, '%29'); - value = (value + '').replace(/[^!#$&-+\--:<-\[\]-~]/g, encodeURIComponent); - options = options || {}; - - var cookieString = key + '=' + value; - cookieString += options.path ? ';path=' + options.path : ''; - cookieString += options.domain ? ';domain=' + options.domain : ''; - cookieString += options.SameSite ? ';SameSite=' + options.SameSite : ''; - cookieString += options.expires ? ';expires=' + options.expires.toUTCString() : ''; - cookieString += options.secure ? ';secure' : ''; - - return cookieString; - }; - - Cookies._getCacheFromString = function (documentCookie) { - var cookieCache = {}; - var cookiesArray = documentCookie ? documentCookie.split('; ') : []; - - for (var i = 0; i < cookiesArray.length; i++) { - var cookieKvp = Cookies._getKeyValuePairFromCookieString(cookiesArray[i]); - - if (cookieCache[Cookies._cacheKeyPrefix + cookieKvp.key] === undefined) { - cookieCache[Cookies._cacheKeyPrefix + cookieKvp.key] = cookieKvp.value; - } - } - - return cookieCache; - }; - - Cookies._getKeyValuePairFromCookieString = function (cookieString) { - // "=" is a valid character in a cookie value according to RFC6265, so cannot `split('=')` - var separatorIndex = cookieString.indexOf('='); - - // IE omits the "=" when the cookie value is an empty string - separatorIndex = separatorIndex < 0 ? cookieString.length : separatorIndex; - - var key = cookieString.substr(0, separatorIndex); - var decodedKey; - try { - decodedKey = decodeURIComponent(key); - } catch (e) { - if (console && typeof console.error === 'function') { - console.error('Could not decode cookie with key "' + key + '"', e); - } - } - - return { - key: decodedKey, - value: cookieString.substr(separatorIndex + 1) // Defer decoding value until accessed - }; - }; - - Cookies._renewCache = function () { - Cookies._cache = Cookies._getCacheFromString(Cookies._document.cookie); - Cookies._cachedDocumentCookie = Cookies._document.cookie; - }; - - Cookies._areEnabled = function () { - var testKey = 'cookies.js'; - var areEnabled = Cookies.set(testKey, 1).get(testKey) === '1'; - Cookies.expire(testKey); - return areEnabled; - }; - - Cookies.enabled = Cookies._areEnabled(); - - return Cookies; + Cookies._renewCache = function () { + Cookies._cache = Cookies._getCacheFromString(Cookies._document.cookie); + Cookies._cachedDocumentCookie = Cookies._document.cookie; }; - var cookiesExport = (global && typeof global.document === 'object') ? factory(global) : factory; - // AMD support - if (typeof define === 'function' && define.amd) { - define(function () { return cookiesExport; }); + Cookies._areEnabled = function () { + var testKey = "cookies.js"; + var areEnabled = Cookies.set(testKey, 1).get(testKey) === "1"; + Cookies.expire(testKey); + return areEnabled; + }; + + Cookies.enabled = Cookies._areEnabled(); + + return Cookies; + }; + var cookiesExport = + global && typeof global.document === "object" ? factory(global) : factory; + + // AMD support + if (typeof define === "function" && define.amd) { + define(function () { + return cookiesExport; + }); // CommonJS/Node.js support - } else if (typeof exports === 'object') { - // Support Node.js specific `module.exports` (which can be a function) - if (typeof module === 'object' && typeof module.exports === 'object') { - exports = module.exports = cookiesExport; - } - // But always support CommonJS module 1.1.1 spec (`exports` cannot be a function) - exports.Cookies = cookiesExport; - } else { - global.Cookies = cookiesExport; + } else if (typeof exports === "object") { + // Support Node.js specific `module.exports` (which can be a function) + if (typeof module === "object" && typeof module.exports === "object") { + exports = module.exports = cookiesExport; } -})(typeof window === 'undefined' ? this : window); + // But always support CommonJS module 1.1.1 spec (`exports` cannot be a function) + exports.Cookies = cookiesExport; + } else { + global.Cookies = cookiesExport; + } +})(typeof window === "undefined" ? this : window); diff --git a/assets/javascripts/vendor/mathml.js b/assets/javascripts/vendor/mathml.js index b97829581c..129726201c 100644 --- a/assets/javascripts/vendor/mathml.js +++ b/assets/javascripts/vendor/mathml.js @@ -4,17 +4,22 @@ * Adapted from: https://github.com/fred-wang/mathml.css */ (function () { - window.addEventListener("load", function() { + window.addEventListener("load", function () { var box, div, link, namespaceURI; // First check whether the page contains any element. namespaceURI = "http://www.w3.org/1998/Math/MathML"; // Create a div to test mspace, using Kuma's "offscreen" CSS - document.body.insertAdjacentHTML("afterbegin", "

    "); + document.body.insertAdjacentHTML( + "afterbegin", + "
    ", + ); div = document.body.firstChild; box = div.firstChild.firstChild.getBoundingClientRect(); document.body.removeChild(div); - if (Math.abs(box.height - 23) > 1 || Math.abs(box.width - 77) > 1) { + if (Math.abs(box.height - 23) > 1 || Math.abs(box.width - 77) > 1) { window.supportsMathML = false; } }); -}()); +})(); diff --git a/assets/javascripts/vendor/prism.js b/assets/javascripts/vendor/prism.js index 1c8591ae87..96519c4a8d 100644 --- a/assets/javascripts/vendor/prism.js +++ b/assets/javascripts/vendor/prism.js @@ -1,5 +1,5 @@ -/* PrismJS 1.29.0 -https://prismjs.com/download.html#themes=prism&languages=markup+css+clike+javascript+bash+c+cpp+cmake+coffeescript+crystal+d+dart+diff+django+elixir+erlang+go+groovy+java+json+julia+kotlin+latex+lua+markdown+markup-templating+matlab+nginx+nim+nix+ocaml+perl+php+python+qml+r+jsx+ruby+rust+scss+scala+shell-session+sql+typescript+yaml+zig */ +/* PrismJS 1.30.0 +https://prismjs.com/download.html#themes=prism&languages=markup+css+clike+javascript+bash+c+cpp+cmake+coffeescript+crystal+d+dart+diff+django+dot+elixir+erlang+go+groovy+java+json+julia+kotlin+latex+lua+markdown+markup-templating+matlab+nginx+nim+nix+ocaml+perl+php+python+qml+r+jsx+ruby+rust+scss+scala+shell-session+sql+tcl+typescript+yaml+zig */ /// var _self = (typeof window !== 'undefined') @@ -224,7 +224,7 @@ var Prism = (function (_self) { if (typeof document === 'undefined') { return null; } - if ('currentScript' in document && 1 < 2 /* hack to trip TS' flow analysis */) { + if (document.currentScript && document.currentScript.tagName === 'SCRIPT' && 1 < 2 /* hack to trip TS' flow analysis */) { return /** @type {any} */ (document.currentScript); } @@ -2929,6 +2929,83 @@ Prism.languages.insertBefore('d', 'function', { }(Prism)); +// https://www.graphviz.org/doc/info/lang.html + +(function (Prism) { + + var ID = '(?:' + [ + // an identifier + /[a-zA-Z_\x80-\uFFFF][\w\x80-\uFFFF]*/.source, + // a number + /-?(?:\.\d+|\d+(?:\.\d*)?)/.source, + // a double-quoted string + /"[^"\\]*(?:\\[\s\S][^"\\]*)*"/.source, + // HTML-like string + /<(?:[^<>]|(?!)*>/.source + ].join('|') + ')'; + + var IDInside = { + 'markup': { + pattern: /(^<)[\s\S]+(?=>$)/, + lookbehind: true, + alias: ['language-markup', 'language-html', 'language-xml'], + inside: Prism.languages.markup + } + }; + + /** + * @param {string} source + * @param {string} flags + * @returns {RegExp} + */ + function withID(source, flags) { + return RegExp(source.replace(//g, function () { return ID; }), flags); + } + + Prism.languages.dot = { + 'comment': { + pattern: /\/\/.*|\/\*[\s\S]*?\*\/|^#.*/m, + greedy: true + }, + 'graph-name': { + pattern: withID(/(\b(?:digraph|graph|subgraph)[ \t\r\n]+)/.source, 'i'), + lookbehind: true, + greedy: true, + alias: 'class-name', + inside: IDInside + }, + 'attr-value': { + pattern: withID(/(=[ \t\r\n]*)/.source), + lookbehind: true, + greedy: true, + inside: IDInside + }, + 'attr-name': { + pattern: withID(/([\[;, \t\r\n])(?=[ \t\r\n]*=)/.source), + lookbehind: true, + greedy: true, + inside: IDInside + }, + 'keyword': /\b(?:digraph|edge|graph|node|strict|subgraph)\b/i, + 'compass-point': { + pattern: /(:[ \t\r\n]*)(?:[ewc_]|[ns][ew]?)(?![\w\x80-\uFFFF])/, + lookbehind: true, + alias: 'builtin' + }, + 'node': { + pattern: withID(/(^|[^-.\w\x80-\uFFFF\\])/.source), + lookbehind: true, + greedy: true, + inside: IDInside + }, + 'operator': /[=:]|-[->]/, + 'punctuation': /[\[\]{};,]/ + }; + + Prism.languages.gv = Prism.languages.dot; + +}(Prism)); + Prism.languages.elixir = { 'doc': { pattern: /@(?:doc|moduledoc)\s+(?:("""|''')[\s\S]*?\1|("|')(?:\\(?:\r\n|[\s\S])|(?!\2)[^\\\r\n])*\2)/, @@ -5325,6 +5402,53 @@ Prism.languages.sql = { 'punctuation': /[;[\]()`,.]/ }; +Prism.languages.tcl = { + 'comment': { + pattern: /(^|[^\\])#.*/, + lookbehind: true + }, + 'string': { + pattern: /"(?:[^"\\\r\n]|\\(?:\r\n|[\s\S]))*"/, + greedy: true + }, + 'variable': [ + { + pattern: /(\$)(?:::)?(?:[a-zA-Z0-9]+::)*\w+/, + lookbehind: true + }, + { + pattern: /(\$)\{[^}]+\}/, + lookbehind: true + }, + { + pattern: /(^[\t ]*set[ \t]+)(?:::)?(?:[a-zA-Z0-9]+::)*\w+/m, + lookbehind: true + } + ], + 'function': { + pattern: /(^[\t ]*proc[ \t]+)\S+/m, + lookbehind: true + }, + 'builtin': [ + { + pattern: /(^[\t ]*)(?:break|class|continue|error|eval|exit|for|foreach|if|proc|return|switch|while)\b/m, + lookbehind: true + }, + /\b(?:else|elseif)\b/ + ], + 'scope': { + pattern: /(^[\t ]*)(?:global|upvar|variable)\b/m, + lookbehind: true, + alias: 'constant' + }, + 'keyword': { + pattern: /(^[\t ]*|\[)(?:Safe_Base|Tcl|after|append|apply|array|auto_(?:execok|import|load|mkindex|qualify|reset)|automkindex_old|bgerror|binary|catch|cd|chan|clock|close|concat|dde|dict|encoding|eof|exec|expr|fblocked|fconfigure|fcopy|file(?:event|name)?|flush|gets|glob|history|http|incr|info|interp|join|lappend|lassign|lindex|linsert|list|llength|load|lrange|lrepeat|lreplace|lreverse|lsearch|lset|lsort|math(?:func|op)|memory|msgcat|namespace|open|package|parray|pid|pkg_mkIndex|platform|puts|pwd|re_syntax|read|refchan|regexp|registry|regsub|rename|scan|seek|set|socket|source|split|string|subst|tcl(?:_endOfWord|_findLibrary|startOf(?:Next|Previous)Word|test|vars|wordBreak(?:After|Before))|tell|time|tm|trace|unknown|unload|unset|update|uplevel|vwait)\b/m, + lookbehind: true + }, + 'operator': /!=?|\*\*?|==|&&?|\|\|?|<[=<]?|>[=>]?|[-+~\/%?^]|\b(?:eq|in|ne|ni)\b/, + 'punctuation': /[{}()\[\]]/ +}; + (function (Prism) { Prism.languages.typescript = Prism.languages.extend('javascript', { diff --git a/assets/javascripts/vendor/raven.js b/assets/javascripts/vendor/raven.js index 9aadd5dbc8..a4d42a9bf0 100644 --- a/assets/javascripts/vendor/raven.js +++ b/assets/javascripts/vendor/raven.js @@ -10,2457 +10,2776 @@ * */ -(function(f){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=f()}else if(typeof define==="function"&&define.amd){define([],f)}else{var g;if(typeof window!=="undefined"){g=window}else if(typeof global!=="undefined"){g=global}else if(typeof self!=="undefined"){g=self}else{g=this}g.Raven = f()}})(function(){var define,module,exports;return (function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o this._globalOptions.maxBreadcrumbs) { - this._breadcrumbs.shift(); - } - return this; - }, + // stack[0] is `throw new Error(msg)` call itself, we are interested in the frame that was just before that, stack[1] + var initialCall = isArray(stack.stack) && stack.stack[1]; + var fileurl = (initialCall && initialCall.url) || ""; - addPlugin: function(plugin /*arg1, arg2, ... argN*/) { - var pluginArgs = [].slice.call(arguments, 1); + if ( + !!this._globalOptions.ignoreUrls.test && + this._globalOptions.ignoreUrls.test(fileurl) + ) { + return; + } - this._plugins.push([plugin, pluginArgs]); - if (this._isRavenInstalled) { - this._drainPlugins(); - } + if ( + !!this._globalOptions.whitelistUrls.test && + !this._globalOptions.whitelistUrls.test(fileurl) + ) { + return; + } - return this; - }, - - /* - * Set/clear a user to be sent along with the payload. - * - * @param {object} user An object representing user data [optional] - * @return {Raven} - */ - setUserContext: function(user) { - // Intentionally do not merge here since that's an unexpected behavior. - this._globalContext.user = user; - - return this; - }, - - /* - * Merge extra attributes to be sent along with the payload. - * - * @param {object} extra An object representing extra data [optional] - * @return {Raven} - */ - setExtraContext: function(extra) { - this._mergeContext('extra', extra); - - return this; - }, - - /* - * Merge tags to be sent along with the payload. - * - * @param {object} tags An object representing tags [optional] - * @return {Raven} - */ - setTagsContext: function(tags) { - this._mergeContext('tags', tags); - - return this; - }, - - /* - * Clear all of the context. - * - * @return {Raven} - */ - clearContext: function() { - this._globalContext = {}; - - return this; - }, - - /* - * Get a copy of the current context. This cannot be mutated. - * - * @return {object} copy of context - */ - getContext: function() { - // lol javascript - return JSON.parse(stringify(this._globalContext)); - }, - - /* - * Set environment of application - * - * @param {string} environment Typically something like 'production'. - * @return {Raven} - */ - setEnvironment: function(environment) { - this._globalOptions.environment = environment; - - return this; - }, - - /* - * Set release version of application - * - * @param {string} release Typically something like a git SHA to identify version - * @return {Raven} - */ - setRelease: function(release) { - this._globalOptions.release = release; - - return this; - }, - - /* - * Set the dataCallback option - * - * @param {function} callback The callback to run which allows the - * data blob to be mutated before sending - * @return {Raven} - */ - setDataCallback: function(callback) { - var original = this._globalOptions.dataCallback; - this._globalOptions.dataCallback = keepOriginalCallback(original, callback); - return this; - }, - - /* - * Set the breadcrumbCallback option - * - * @param {function} callback The callback to run which allows filtering - * or mutating breadcrumbs - * @return {Raven} - */ - setBreadcrumbCallback: function(callback) { - var original = this._globalOptions.breadcrumbCallback; - this._globalOptions.breadcrumbCallback = keepOriginalCallback(original, callback); - return this; - }, - - /* - * Set the shouldSendCallback option - * - * @param {function} callback The callback to run which allows - * introspecting the blob before sending - * @return {Raven} - */ - setShouldSendCallback: function(callback) { - var original = this._globalOptions.shouldSendCallback; - this._globalOptions.shouldSendCallback = keepOriginalCallback(original, callback); - return this; - }, - - /** - * Override the default HTTP transport mechanism that transmits data - * to the Sentry server. - * - * @param {function} transport Function invoked instead of the default - * `makeRequest` handler. - * - * @return {Raven} - */ - setTransport: function(transport) { - this._globalOptions.transport = transport; - - return this; - }, - - /* - * Get the latest raw exception that was captured by Raven. - * - * @return {error} - */ - lastException: function() { - return this._lastCapturedException; - }, - - /* - * Get the last event id - * - * @return {string} - */ - lastEventId: function() { - return this._lastEventId; - }, - - /* - * Determine if Raven is setup and ready to go. - * - * @return {boolean} - */ - isSetup: function() { - if (!this._hasJSON) return false; // needs JSON support - if (!this._globalServer) { - if (!this.ravenNotConfiguredError) { - this.ravenNotConfiguredError = true; - this._logDebug('error', 'Error: Raven has not been configured.'); - } - return false; - } - return true; - }, + if ( + this._globalOptions.stacktrace || + (options && options.stacktrace) + ) { + options = objectMerge( + { + // fingerprint on msg, not stack trace (legacy behavior, could be + // revisited) + fingerprint: msg, + // since we know this is a synthetic trace, the top N-most frames + // MUST be from Raven.js, so mark them as in_app later by setting + // trimHeadFrames + trimHeadFrames: (options.trimHeadFrames || 0) + 1, + }, + options, + ); + + var frames = this._prepareFrames(stack, options); + data.stacktrace = { + // Sentry expects frames oldest to newest + frames: frames.reverse(), + }; + } - afterLoad: function() { - // TODO: remove window dependence? + // Fire away! + this._send(data); - // Attempt to initialize Raven on load - var RavenConfig = _window.RavenConfig; - if (RavenConfig) { - this.config(RavenConfig.dsn, RavenConfig.config).install(); - } - }, + return this; + }, - showReportDialog: function(options) { - if ( - !_document // doesn't work without a document (React native) - ) - return; + captureBreadcrumb: function (obj) { + var crumb = objectMerge( + { + timestamp: now() / 1000, + }, + obj, + ); - options = options || {}; + if (isFunction(this._globalOptions.breadcrumbCallback)) { + var result = this._globalOptions.breadcrumbCallback(crumb); - var lastEventId = options.eventId || this.lastEventId(); - if (!lastEventId) { - throw new RavenConfigError('Missing eventId'); - } + if (isObject(result) && !isEmptyObject(result)) { + crumb = result; + } else if (result === false) { + return this; + } + } - var dsn = options.dsn || this._dsn; - if (!dsn) { - throw new RavenConfigError('Missing DSN'); - } + this._breadcrumbs.push(crumb); + if ( + this._breadcrumbs.length > this._globalOptions.maxBreadcrumbs + ) { + this._breadcrumbs.shift(); + } + return this; + }, - var encode = encodeURIComponent; - var qs = ''; - qs += '?eventId=' + encode(lastEventId); - qs += '&dsn=' + encode(dsn); + addPlugin: function (plugin /*arg1, arg2, ... argN*/) { + var pluginArgs = [].slice.call(arguments, 1); - var user = options.user || this._globalContext.user; - if (user) { - if (user.name) qs += '&name=' + encode(user.name); - if (user.email) qs += '&email=' + encode(user.email); - } + this._plugins.push([plugin, pluginArgs]); + if (this._isRavenInstalled) { + this._drainPlugins(); + } - var globalServer = this._getGlobalServer(this._parseDSN(dsn)); + return this; + }, + + /* + * Set/clear a user to be sent along with the payload. + * + * @param {object} user An object representing user data [optional] + * @return {Raven} + */ + setUserContext: function (user) { + // Intentionally do not merge here since that's an unexpected behavior. + this._globalContext.user = user; + + return this; + }, + + /* + * Merge extra attributes to be sent along with the payload. + * + * @param {object} extra An object representing extra data [optional] + * @return {Raven} + */ + setExtraContext: function (extra) { + this._mergeContext("extra", extra); + + return this; + }, + + /* + * Merge tags to be sent along with the payload. + * + * @param {object} tags An object representing tags [optional] + * @return {Raven} + */ + setTagsContext: function (tags) { + this._mergeContext("tags", tags); + + return this; + }, + + /* + * Clear all of the context. + * + * @return {Raven} + */ + clearContext: function () { + this._globalContext = {}; + + return this; + }, + + /* + * Get a copy of the current context. This cannot be mutated. + * + * @return {object} copy of context + */ + getContext: function () { + // lol javascript + return JSON.parse(stringify(this._globalContext)); + }, + + /* + * Set environment of application + * + * @param {string} environment Typically something like 'production'. + * @return {Raven} + */ + setEnvironment: function (environment) { + this._globalOptions.environment = environment; + + return this; + }, + + /* + * Set release version of application + * + * @param {string} release Typically something like a git SHA to identify version + * @return {Raven} + */ + setRelease: function (release) { + this._globalOptions.release = release; + + return this; + }, + + /* + * Set the dataCallback option + * + * @param {function} callback The callback to run which allows the + * data blob to be mutated before sending + * @return {Raven} + */ + setDataCallback: function (callback) { + var original = this._globalOptions.dataCallback; + this._globalOptions.dataCallback = keepOriginalCallback( + original, + callback, + ); + return this; + }, + + /* + * Set the breadcrumbCallback option + * + * @param {function} callback The callback to run which allows filtering + * or mutating breadcrumbs + * @return {Raven} + */ + setBreadcrumbCallback: function (callback) { + var original = this._globalOptions.breadcrumbCallback; + this._globalOptions.breadcrumbCallback = keepOriginalCallback( + original, + callback, + ); + return this; + }, + + /* + * Set the shouldSendCallback option + * + * @param {function} callback The callback to run which allows + * introspecting the blob before sending + * @return {Raven} + */ + setShouldSendCallback: function (callback) { + var original = this._globalOptions.shouldSendCallback; + this._globalOptions.shouldSendCallback = keepOriginalCallback( + original, + callback, + ); + return this; + }, + + /** + * Override the default HTTP transport mechanism that transmits data + * to the Sentry server. + * + * @param {function} transport Function invoked instead of the default + * `makeRequest` handler. + * + * @return {Raven} + */ + setTransport: function (transport) { + this._globalOptions.transport = transport; + + return this; + }, + + /* + * Get the latest raw exception that was captured by Raven. + * + * @return {error} + */ + lastException: function () { + return this._lastCapturedException; + }, + + /* + * Get the last event id + * + * @return {string} + */ + lastEventId: function () { + return this._lastEventId; + }, + + /* + * Determine if Raven is setup and ready to go. + * + * @return {boolean} + */ + isSetup: function () { + if (!this._hasJSON) return false; // needs JSON support + if (!this._globalServer) { + if (!this.ravenNotConfiguredError) { + this.ravenNotConfiguredError = true; + this._logDebug( + "error", + "Error: Raven has not been configured.", + ); + } + return false; + } + return true; + }, - var script = _document.createElement('script'); - script.async = true; - script.src = globalServer + '/api/embed/error-page/' + qs; - (_document.head || _document.body).appendChild(script); - }, + afterLoad: function () { + // TODO: remove window dependence? - /**** Private functions ****/ - _ignoreNextOnError: function() { - var self = this; - this._ignoreOnError += 1; - setTimeout(function() { - // onerror should trigger before setTimeout - self._ignoreOnError -= 1; - }); - }, + // Attempt to initialize Raven on load + var RavenConfig = _window.RavenConfig; + if (RavenConfig) { + this.config(RavenConfig.dsn, RavenConfig.config).install(); + } + }, - _triggerEvent: function(eventType, options) { - // NOTE: `event` is a native browser thing, so let's avoid conflicting with it - var evt, key; + showReportDialog: function (options) { + if ( + !_document // doesn't work without a document (React native) + ) + return; - if (!this._hasDocument) return; + options = options || {}; - options = options || {}; + var lastEventId = options.eventId || this.lastEventId(); + if (!lastEventId) { + throw new RavenConfigError("Missing eventId"); + } - eventType = 'raven' + eventType.substr(0, 1).toUpperCase() + eventType.substr(1); + var dsn = options.dsn || this._dsn; + if (!dsn) { + throw new RavenConfigError("Missing DSN"); + } - if (_document.createEvent) { - evt = _document.createEvent('HTMLEvents'); - evt.initEvent(eventType, true, true); - } else { - evt = _document.createEventObject(); - evt.eventType = eventType; - } + var encode = encodeURIComponent; + var qs = ""; + qs += "?eventId=" + encode(lastEventId); + qs += "&dsn=" + encode(dsn); - for (key in options) - if (hasKey(options, key)) { - evt[key] = options[key]; - } + var user = options.user || this._globalContext.user; + if (user) { + if (user.name) qs += "&name=" + encode(user.name); + if (user.email) qs += "&email=" + encode(user.email); + } - if (_document.createEvent) { - // IE9 if standards - _document.dispatchEvent(evt); - } else { - // IE8 regardless of Quirks or Standards - // IE9 if quirks - try { - _document.fireEvent('on' + evt.eventType.toLowerCase(), evt); - } catch (e) { - // Do nothing - } - } - }, - - /** - * Wraps addEventListener to capture UI breadcrumbs - * @param evtName the event name (e.g. "click") - * @returns {Function} - * @private - */ - _breadcrumbEventHandler: function(evtName) { - var self = this; - return function(evt) { - // reset keypress timeout; e.g. triggering a 'click' after - // a 'keypress' will reset the keypress debounce so that a new - // set of keypresses can be recorded - self._keypressTimeout = null; - - // It's possible this handler might trigger multiple times for the same - // event (e.g. event propagation through node ancestors). Ignore if we've - // already captured the event. - if (self._lastCapturedEvent === evt) return; - - self._lastCapturedEvent = evt; - - // try/catch both: - // - accessing evt.target (see getsentry/raven-js#838, #768) - // - `htmlTreeAsString` because it's complex, and just accessing the DOM incorrectly - // can throw an exception in some circumstances. - var target; - try { - target = htmlTreeAsString(evt.target); - } catch (e) { - target = ''; - } + var globalServer = this._getGlobalServer(this._parseDSN(dsn)); + + var script = _document.createElement("script"); + script.async = true; + script.src = globalServer + "/api/embed/error-page/" + qs; + (_document.head || _document.body).appendChild(script); + }, + + /**** Private functions ****/ + _ignoreNextOnError: function () { + var self = this; + this._ignoreOnError += 1; + setTimeout(function () { + // onerror should trigger before setTimeout + self._ignoreOnError -= 1; + }); + }, - self.captureBreadcrumb({ - category: 'ui.' + evtName, // e.g. ui.click, ui.input - message: target - }); - }; - }, - - /** - * Wraps addEventListener to capture keypress UI events - * @returns {Function} - * @private - */ - _keypressEventHandler: function() { - var self = this, - debounceDuration = 1000; // milliseconds - - // TODO: if somehow user switches keypress target before - // debounce timeout is triggered, we will only capture - // a single breadcrumb from the FIRST target (acceptable?) - return function(evt) { - var target; - try { - target = evt.target; - } catch (e) { - // just accessing event properties can throw an exception in some rare circumstances - // see: https://github.com/getsentry/raven-js/issues/838 - return; - } - var tagName = target && target.tagName; - - // only consider keypress events on actual input elements - // this will disregard keypresses targeting body (e.g. tabbing - // through elements, hotkeys, etc) - if ( - !tagName || - (tagName !== 'INPUT' && tagName !== 'TEXTAREA' && !target.isContentEditable) - ) - return; - - // record first keypress in a series, but ignore subsequent - // keypresses until debounce clears - var timeout = self._keypressTimeout; - if (!timeout) { - self._breadcrumbEventHandler('input')(evt); - } - clearTimeout(timeout); - self._keypressTimeout = setTimeout(function() { - self._keypressTimeout = null; - }, debounceDuration); - }; - }, - - /** - * Captures a breadcrumb of type "navigation", normalizing input URLs - * @param to the originating URL - * @param from the target URL - * @private - */ - _captureUrlChange: function(from, to) { - var parsedLoc = parseUrl(this._location.href); - var parsedTo = parseUrl(to); - var parsedFrom = parseUrl(from); - - // because onpopstate only tells you the "new" (to) value of location.href, and - // not the previous (from) value, we need to track the value of the current URL - // state ourselves - this._lastHref = to; - - // Use only the path component of the URL if the URL matches the current - // document (almost all the time when using pushState) - if (parsedLoc.protocol === parsedTo.protocol && parsedLoc.host === parsedTo.host) - to = parsedTo.relative; - if (parsedLoc.protocol === parsedFrom.protocol && parsedLoc.host === parsedFrom.host) - from = parsedFrom.relative; - - this.captureBreadcrumb({ - category: 'navigation', - data: { - to: to, - from: from - } - }); - }, - - _patchFunctionToString: function() { - var self = this; - self._originalFunctionToString = Function.prototype.toString; - // eslint-disable-next-line no-extend-native - Function.prototype.toString = function() { - if (typeof this === 'function' && this.__raven__) { - return self._originalFunctionToString.apply(this.__orig__, arguments); - } - return self._originalFunctionToString.apply(this, arguments); - }; - }, - - _unpatchFunctionToString: function() { - if (this._originalFunctionToString) { - // eslint-disable-next-line no-extend-native - Function.prototype.toString = this._originalFunctionToString; - } - }, - - /** - * Wrap timer functions and event targets to catch errors and provide - * better metadata. - */ - _instrumentTryCatch: function() { - var self = this; - - var wrappedBuiltIns = self._wrappedBuiltIns; - - function wrapTimeFn(orig) { - return function(fn, t) { - // preserve arity - // Make a copy of the arguments to prevent deoptimization - // https://github.com/petkaantonov/bluebird/wiki/Optimization-killers#32-leaking-arguments - var args = new Array(arguments.length); - for (var i = 0; i < args.length; ++i) { - args[i] = arguments[i]; - } - var originalCallback = args[0]; - if (isFunction(originalCallback)) { - args[0] = self.wrap(originalCallback); - } + _triggerEvent: function (eventType, options) { + // NOTE: `event` is a native browser thing, so let's avoid conflicting with it + var evt, key; - // IE < 9 doesn't support .call/.apply on setInterval/setTimeout, but it - // also supports only two arguments and doesn't care what this is, so we - // can just call the original function directly. - if (orig.apply) { - return orig.apply(this, args); - } else { - return orig(args[0], args[1]); - } - }; - } + if (!this._hasDocument) return; - var autoBreadcrumbs = this._globalOptions.autoBreadcrumbs; - - function wrapEventTarget(global) { - var proto = _window[global] && _window[global].prototype; - if (proto && proto.hasOwnProperty && proto.hasOwnProperty('addEventListener')) { - fill( - proto, - 'addEventListener', - function(orig) { - return function(evtName, fn, capture, secure) { - // preserve arity - try { - if (fn && fn.handleEvent) { - fn.handleEvent = self.wrap(fn.handleEvent); + options = options || {}; + + eventType = + "raven" + + eventType.substr(0, 1).toUpperCase() + + eventType.substr(1); + + if (_document.createEvent) { + evt = _document.createEvent("HTMLEvents"); + evt.initEvent(eventType, true, true); + } else { + evt = _document.createEventObject(); + evt.eventType = eventType; } - } catch (err) { - // can sometimes get 'Permission denied to access property "handle Event' - } - // More breadcrumb DOM capture ... done here and not in `_instrumentBreadcrumbs` - // so that we don't have more than one wrapper function - var before, clickHandler, keypressHandler; + for (key in options) + if (hasKey(options, key)) { + evt[key] = options[key]; + } - if ( - autoBreadcrumbs && - autoBreadcrumbs.dom && - (global === 'EventTarget' || global === 'Node') - ) { - // NOTE: generating multiple handlers per addEventListener invocation, should - // revisit and verify we can just use one (almost certainly) - clickHandler = self._breadcrumbEventHandler('click'); - keypressHandler = self._keypressEventHandler(); - before = function(evt) { - // need to intercept every DOM event in `before` argument, in case that - // same wrapped method is re-used for different events (e.g. mousemove THEN click) - // see #724 - if (!evt) return; - - var eventType; + if (_document.createEvent) { + // IE9 if standards + _document.dispatchEvent(evt); + } else { + // IE8 regardless of Quirks or Standards + // IE9 if quirks + try { + _document.fireEvent( + "on" + evt.eventType.toLowerCase(), + evt, + ); + } catch (e) { + // Do nothing + } + } + }, + + /** + * Wraps addEventListener to capture UI breadcrumbs + * @param evtName the event name (e.g. "click") + * @returns {Function} + * @private + */ + _breadcrumbEventHandler: function (evtName) { + var self = this; + return function (evt) { + // reset keypress timeout; e.g. triggering a 'click' after + // a 'keypress' will reset the keypress debounce so that a new + // set of keypresses can be recorded + self._keypressTimeout = null; + + // It's possible this handler might trigger multiple times for the same + // event (e.g. event propagation through node ancestors). Ignore if we've + // already captured the event. + if (self._lastCapturedEvent === evt) return; + + self._lastCapturedEvent = evt; + + // try/catch both: + // - accessing evt.target (see getsentry/raven-js#838, #768) + // - `htmlTreeAsString` because it's complex, and just accessing the DOM incorrectly + // can throw an exception in some circumstances. + var target; + try { + target = htmlTreeAsString(evt.target); + } catch (e) { + target = ""; + } + + self.captureBreadcrumb({ + category: "ui." + evtName, // e.g. ui.click, ui.input + message: target, + }); + }; + }, + + /** + * Wraps addEventListener to capture keypress UI events + * @returns {Function} + * @private + */ + _keypressEventHandler: function () { + var self = this, + debounceDuration = 1000; // milliseconds + + // TODO: if somehow user switches keypress target before + // debounce timeout is triggered, we will only capture + // a single breadcrumb from the FIRST target (acceptable?) + return function (evt) { + var target; try { - eventType = evt.type; + target = evt.target; } catch (e) { // just accessing event properties can throw an exception in some rare circumstances // see: https://github.com/getsentry/raven-js/issues/838 return; } - if (eventType === 'click') return clickHandler(evt); - else if (eventType === 'keypress') return keypressHandler(evt); + var tagName = target && target.tagName; + + // only consider keypress events on actual input elements + // this will disregard keypresses targeting body (e.g. tabbing + // through elements, hotkeys, etc) + if ( + !tagName || + (tagName !== "INPUT" && + tagName !== "TEXTAREA" && + !target.isContentEditable) + ) + return; + + // record first keypress in a series, but ignore subsequent + // keypresses until debounce clears + var timeout = self._keypressTimeout; + if (!timeout) { + self._breadcrumbEventHandler("input")(evt); + } + clearTimeout(timeout); + self._keypressTimeout = setTimeout(function () { + self._keypressTimeout = null; + }, debounceDuration); }; - } - return orig.call( - this, - evtName, - self.wrap(fn, undefined, before), - capture, - secure - ); - }; - }, - wrappedBuiltIns - ); - fill( - proto, - 'removeEventListener', - function(orig) { - return function(evt, fn, capture, secure) { - try { - fn = fn && (fn.__raven_wrapper__ ? fn.__raven_wrapper__ : fn); - } catch (e) { - // ignore, accessing __raven_wrapper__ will throw in some Selenium environments - } - return orig.call(this, evt, fn, capture, secure); - }; - }, - wrappedBuiltIns - ); - } - } + }, + + /** + * Captures a breadcrumb of type "navigation", normalizing input URLs + * @param to the originating URL + * @param from the target URL + * @private + */ + _captureUrlChange: function (from, to) { + var parsedLoc = parseUrl(this._location.href); + var parsedTo = parseUrl(to); + var parsedFrom = parseUrl(from); + + // because onpopstate only tells you the "new" (to) value of location.href, and + // not the previous (from) value, we need to track the value of the current URL + // state ourselves + this._lastHref = to; + + // Use only the path component of the URL if the URL matches the current + // document (almost all the time when using pushState) + if ( + parsedLoc.protocol === parsedTo.protocol && + parsedLoc.host === parsedTo.host + ) + to = parsedTo.relative; + if ( + parsedLoc.protocol === parsedFrom.protocol && + parsedLoc.host === parsedFrom.host + ) + from = parsedFrom.relative; + + this.captureBreadcrumb({ + category: "navigation", + data: { + to: to, + from: from, + }, + }); + }, + + _patchFunctionToString: function () { + var self = this; + self._originalFunctionToString = Function.prototype.toString; + // eslint-disable-next-line no-extend-native + Function.prototype.toString = function () { + if (typeof this === "function" && this.__raven__) { + return self._originalFunctionToString.apply( + this.__orig__, + arguments, + ); + } + return self._originalFunctionToString.apply(this, arguments); + }; + }, - fill(_window, 'setTimeout', wrapTimeFn, wrappedBuiltIns); - fill(_window, 'setInterval', wrapTimeFn, wrappedBuiltIns); - if (_window.requestAnimationFrame) { - fill( - _window, - 'requestAnimationFrame', - function(orig) { - return function(cb) { - return orig(self.wrap(cb)); - }; - }, - wrappedBuiltIns - ); - } + _unpatchFunctionToString: function () { + if (this._originalFunctionToString) { + // eslint-disable-next-line no-extend-native + Function.prototype.toString = this._originalFunctionToString; + } + }, + + /** + * Wrap timer functions and event targets to catch errors and provide + * better metadata. + */ + _instrumentTryCatch: function () { + var self = this; + + var wrappedBuiltIns = self._wrappedBuiltIns; + + function wrapTimeFn(orig) { + return function (fn, t) { + // preserve arity + // Make a copy of the arguments to prevent deoptimization + // https://github.com/petkaantonov/bluebird/wiki/Optimization-killers#32-leaking-arguments + var args = new Array(arguments.length); + for (var i = 0; i < args.length; ++i) { + args[i] = arguments[i]; + } + var originalCallback = args[0]; + if (isFunction(originalCallback)) { + args[0] = self.wrap(originalCallback); + } + + // IE < 9 doesn't support .call/.apply on setInterval/setTimeout, but it + // also supports only two arguments and doesn't care what this is, so we + // can just call the original function directly. + if (orig.apply) { + return orig.apply(this, args); + } else { + return orig(args[0], args[1]); + } + }; + } - // event targets borrowed from bugsnag-js: - // https://github.com/bugsnag/bugsnag-js/blob/master/src/bugsnag.js#L666 - var eventTargets = [ - 'EventTarget', - 'Window', - 'Node', - 'ApplicationCache', - 'AudioTrackList', - 'ChannelMergerNode', - 'CryptoOperation', - 'EventSource', - 'FileReader', - 'HTMLUnknownElement', - 'IDBDatabase', - 'IDBRequest', - 'IDBTransaction', - 'KeyOperation', - 'MediaController', - 'MessagePort', - 'ModalWindow', - 'Notification', - 'SVGElementInstance', - 'Screen', - 'TextTrack', - 'TextTrackCue', - 'TextTrackList', - 'WebSocket', - 'WebSocketWorker', - 'Worker', - 'XMLHttpRequest', - 'XMLHttpRequestEventTarget', - 'XMLHttpRequestUpload' - ]; - for (var i = 0; i < eventTargets.length; i++) { - wrapEventTarget(eventTargets[i]); - } - }, - - /** - * Instrument browser built-ins w/ breadcrumb capturing - * - XMLHttpRequests - * - DOM interactions (click/typing) - * - window.location changes - * - console - * - * Can be disabled or individually configured via the `autoBreadcrumbs` config option - */ - _instrumentBreadcrumbs: function() { - var self = this; - var autoBreadcrumbs = this._globalOptions.autoBreadcrumbs; - - var wrappedBuiltIns = self._wrappedBuiltIns; - - function wrapProp(prop, xhr) { - if (prop in xhr && isFunction(xhr[prop])) { - fill(xhr, prop, function(orig) { - return self.wrap(orig); - }); // intentionally don't track filled methods on XHR instances - } - } + var autoBreadcrumbs = this._globalOptions.autoBreadcrumbs; + + function wrapEventTarget(global) { + var proto = _window[global] && _window[global].prototype; + if ( + proto && + proto.hasOwnProperty && + proto.hasOwnProperty("addEventListener") + ) { + fill( + proto, + "addEventListener", + function (orig) { + return function (evtName, fn, capture, secure) { + // preserve arity + try { + if (fn && fn.handleEvent) { + fn.handleEvent = self.wrap(fn.handleEvent); + } + } catch (err) { + // can sometimes get 'Permission denied to access property "handle Event' + } + + // More breadcrumb DOM capture ... done here and not in `_instrumentBreadcrumbs` + // so that we don't have more than one wrapper function + var before, clickHandler, keypressHandler; + + if ( + autoBreadcrumbs && + autoBreadcrumbs.dom && + (global === "EventTarget" || global === "Node") + ) { + // NOTE: generating multiple handlers per addEventListener invocation, should + // revisit and verify we can just use one (almost certainly) + clickHandler = + self._breadcrumbEventHandler("click"); + keypressHandler = self._keypressEventHandler(); + before = function (evt) { + // need to intercept every DOM event in `before` argument, in case that + // same wrapped method is re-used for different events (e.g. mousemove THEN click) + // see #724 + if (!evt) return; + + var eventType; + try { + eventType = evt.type; + } catch (e) { + // just accessing event properties can throw an exception in some rare circumstances + // see: https://github.com/getsentry/raven-js/issues/838 + return; + } + if (eventType === "click") + return clickHandler(evt); + else if (eventType === "keypress") + return keypressHandler(evt); + }; + } + return orig.call( + this, + evtName, + self.wrap(fn, undefined, before), + capture, + secure, + ); + }; + }, + wrappedBuiltIns, + ); + fill( + proto, + "removeEventListener", + function (orig) { + return function (evt, fn, capture, secure) { + try { + fn = + fn && + (fn.__raven_wrapper__ + ? fn.__raven_wrapper__ + : fn); + } catch (e) { + // ignore, accessing __raven_wrapper__ will throw in some Selenium environments + } + return orig.call(this, evt, fn, capture, secure); + }; + }, + wrappedBuiltIns, + ); + } + } - if (autoBreadcrumbs.xhr && 'XMLHttpRequest' in _window) { - var xhrproto = XMLHttpRequest.prototype; - fill( - xhrproto, - 'open', - function(origOpen) { - return function(method, url) { - // preserve arity - - // if Sentry key appears in URL, don't capture - if (isString(url) && url.indexOf(self._globalKey) === -1) { - this.__raven_xhr = { - method: method, - url: url, - status_code: null - }; - } + fill(_window, "setTimeout", wrapTimeFn, wrappedBuiltIns); + fill(_window, "setInterval", wrapTimeFn, wrappedBuiltIns); + if (_requestAnimationFrame) { + fill( + _window, + "requestAnimationFrame", + function (orig) { + return function (cb) { + return orig(self.wrap(cb)); + }; + }, + wrappedBuiltIns, + ); + } - return origOpen.apply(this, arguments); - }; - }, - wrappedBuiltIns - ); - - fill( - xhrproto, - 'send', - function(origSend) { - return function(data) { - // preserve arity - var xhr = this; - - function onreadystatechangeHandler() { - if (xhr.__raven_xhr && xhr.readyState === 4) { - try { - // touching statusCode in some platforms throws - // an exception - xhr.__raven_xhr.status_code = xhr.status; - } catch (e) { - /* do nothing */ + // event targets borrowed from bugsnag-js: + // https://github.com/bugsnag/bugsnag-js/blob/master/src/bugsnag.js#L666 + var eventTargets = [ + "EventTarget", + "Window", + "Node", + "ApplicationCache", + "AudioTrackList", + "ChannelMergerNode", + "CryptoOperation", + "EventSource", + "FileReader", + "HTMLUnknownElement", + "IDBDatabase", + "IDBRequest", + "IDBTransaction", + "KeyOperation", + "MediaController", + "MessagePort", + "ModalWindow", + "Notification", + "SVGElementInstance", + "Screen", + "TextTrack", + "TextTrackCue", + "TextTrackList", + "WebSocket", + "WebSocketWorker", + "Worker", + "XMLHttpRequest", + "XMLHttpRequestEventTarget", + "XMLHttpRequestUpload", + ]; + for (var i = 0; i < eventTargets.length; i++) { + wrapEventTarget(eventTargets[i]); + } + }, + + /** + * Instrument browser built-ins w/ breadcrumb capturing + * - XMLHttpRequests + * - DOM interactions (click/typing) + * - window.location changes + * - console + * + * Can be disabled or individually configured via the `autoBreadcrumbs` config option + */ + _instrumentBreadcrumbs: function () { + var self = this; + var autoBreadcrumbs = this._globalOptions.autoBreadcrumbs; + + var wrappedBuiltIns = self._wrappedBuiltIns; + + function wrapProp(prop, xhr) { + if (prop in xhr && isFunction(xhr[prop])) { + fill(xhr, prop, function (orig) { + return self.wrap(orig); + }); // intentionally don't track filled methods on XHR instances + } } - self.captureBreadcrumb({ - type: 'http', - category: 'xhr', - data: xhr.__raven_xhr - }); - } - } + if (autoBreadcrumbs.xhr && "XMLHttpRequest" in _window) { + var xhrproto = XMLHttpRequest.prototype; + fill( + xhrproto, + "open", + function (origOpen) { + return function (method, url) { + // preserve arity + + // if Sentry key appears in URL, don't capture + if ( + isString(url) && + url.indexOf(self._globalKey) === -1 + ) { + this.__raven_xhr = { + method: method, + url: url, + status_code: null, + }; + } + + return origOpen.apply(this, arguments); + }; + }, + wrappedBuiltIns, + ); + + fill( + xhrproto, + "send", + function (origSend) { + return function (data) { + // preserve arity + var xhr = this; + + function onreadystatechangeHandler() { + if (xhr.__raven_xhr && xhr.readyState === 4) { + try { + // touching statusCode in some platforms throws + // an exception + xhr.__raven_xhr.status_code = xhr.status; + } catch (e) { + /* do nothing */ + } + + self.captureBreadcrumb({ + type: "http", + category: "xhr", + data: xhr.__raven_xhr, + }); + } + } + + var props = ["onload", "onerror", "onprogress"]; + for (var j = 0; j < props.length; j++) { + wrapProp(props[j], xhr); + } + + if ( + "onreadystatechange" in xhr && + isFunction(xhr.onreadystatechange) + ) { + fill( + xhr, + "onreadystatechange", + function (orig) { + return self.wrap( + orig, + undefined, + onreadystatechangeHandler, + ); + } /* intentionally don't track this instrumentation */, + ); + } else { + // if onreadystatechange wasn't actually set by the page on this xhr, we + // are free to set our own and capture the breadcrumb + xhr.onreadystatechange = onreadystatechangeHandler; + } + + return origSend.apply(this, arguments); + }; + }, + wrappedBuiltIns, + ); + } - var props = ['onload', 'onerror', 'onprogress']; - for (var j = 0; j < props.length; j++) { - wrapProp(props[j], xhr); - } + if (autoBreadcrumbs.xhr && "fetch" in _window) { + fill( + _window, + "fetch", + function (origFetch) { + return function (fn, t) { + // preserve arity + // Make a copy of the arguments to prevent deoptimization + // https://github.com/petkaantonov/bluebird/wiki/Optimization-killers#32-leaking-arguments + var args = new Array(arguments.length); + for (var i = 0; i < args.length; ++i) { + args[i] = arguments[i]; + } + + var fetchInput = args[0]; + var method = "GET"; + var url; + + if (typeof fetchInput === "string") { + url = fetchInput; + } else if ( + "Request" in _window && + fetchInput instanceof _window.Request + ) { + url = fetchInput.url; + if (fetchInput.method) { + method = fetchInput.method; + } + } else { + url = "" + fetchInput; + } + + if (args[1] && args[1].method) { + method = args[1].method; + } + + var fetchData = { + method: method, + url: url, + status_code: null, + }; + + self.captureBreadcrumb({ + type: "http", + category: "fetch", + data: fetchData, + }); + + return origFetch + .apply(this, args) + .then(function (response) { + fetchData.status_code = response.status; + + return response; + }); + }; + }, + wrappedBuiltIns, + ); + } - if ('onreadystatechange' in xhr && isFunction(xhr.onreadystatechange)) { - fill( - xhr, - 'onreadystatechange', - function(orig) { - return self.wrap(orig, undefined, onreadystatechangeHandler); - } /* intentionally don't track this instrumentation */ - ); - } else { - // if onreadystatechange wasn't actually set by the page on this xhr, we - // are free to set our own and capture the breadcrumb - xhr.onreadystatechange = onreadystatechangeHandler; - } + // Capture breadcrumbs from any click that is unhandled / bubbled up all the way + // to the document. Do this before we instrument addEventListener. + if (autoBreadcrumbs.dom && this._hasDocument) { + if (_document.addEventListener) { + _document.addEventListener( + "click", + self._breadcrumbEventHandler("click"), + false, + ); + _document.addEventListener( + "keypress", + self._keypressEventHandler(), + false, + ); + } else { + // IE8 Compatibility + _document.attachEvent( + "onclick", + self._breadcrumbEventHandler("click"), + ); + _document.attachEvent( + "onkeypress", + self._keypressEventHandler(), + ); + } + } - return origSend.apply(this, arguments); - }; - }, - wrappedBuiltIns - ); - } + // record navigation (URL) changes + // NOTE: in Chrome App environment, touching history.pushState, *even inside + // a try/catch block*, will cause Chrome to output an error to console.error + // borrowed from: https://github.com/angular/angular.js/pull/13945/files + var chrome = _window.chrome; + var isChromePackagedApp = + chrome && chrome.app && chrome.app.runtime; + var hasPushAndReplaceState = + !isChromePackagedApp && + _window.history && + history.pushState && + history.replaceState; + if (autoBreadcrumbs.location && hasPushAndReplaceState) { + // TODO: remove onpopstate handler on uninstall() + var oldOnPopState = _window.onpopstate; + _window.onpopstate = function () { + var currentHref = self._location.href; + self._captureUrlChange(self._lastHref, currentHref); + + if (oldOnPopState) { + return oldOnPopState.apply(this, arguments); + } + }; + + var historyReplacementFunction = function (origHistFunction) { + // note history.pushState.length is 0; intentionally not declaring + // params to preserve 0 arity + return function (/* state, title, url */) { + var url = arguments.length > 2 ? arguments[2] : undefined; + + // url argument is optional + if (url) { + // coerce to string (this is what pushState does) + self._captureUrlChange(self._lastHref, url + ""); + } + + return origHistFunction.apply(this, arguments); + }; + }; + + fill( + history, + "pushState", + historyReplacementFunction, + wrappedBuiltIns, + ); + fill( + history, + "replaceState", + historyReplacementFunction, + wrappedBuiltIns, + ); + } - if (autoBreadcrumbs.xhr && 'fetch' in _window) { - fill( - _window, - 'fetch', - function(origFetch) { - return function(fn, t) { - // preserve arity - // Make a copy of the arguments to prevent deoptimization - // https://github.com/petkaantonov/bluebird/wiki/Optimization-killers#32-leaking-arguments - var args = new Array(arguments.length); - for (var i = 0; i < args.length; ++i) { - args[i] = arguments[i]; - } + if ( + autoBreadcrumbs.console && + "console" in _window && + console.log + ) { + // console + var consoleMethodCallback = function (msg, data) { + self.captureBreadcrumb({ + message: msg, + level: data.level, + category: "console", + }); + }; + + each( + ["debug", "info", "warn", "error", "log"], + function (_, level) { + wrapConsoleMethod(console, level, consoleMethodCallback); + }, + ); + } + }, - var fetchInput = args[0]; - var method = 'GET'; - var url; + _restoreBuiltIns: function () { + // restore any wrapped builtins + var builtin; + while (this._wrappedBuiltIns.length) { + builtin = this._wrappedBuiltIns.shift(); - if (typeof fetchInput === 'string') { - url = fetchInput; - } else if ('Request' in _window && fetchInput instanceof _window.Request) { - url = fetchInput.url; - if (fetchInput.method) { - method = fetchInput.method; - } - } else { - url = '' + fetchInput; - } + var obj = builtin[0], + name = builtin[1], + orig = builtin[2]; - if (args[1] && args[1].method) { - method = args[1].method; - } + obj[name] = orig; + } + }, - var fetchData = { - method: method, - url: url, - status_code: null - }; + _drainPlugins: function () { + var self = this; - self.captureBreadcrumb({ - type: 'http', - category: 'fetch', - data: fetchData - }); + // FIX ME TODO + each(this._plugins, function (_, plugin) { + var installer = plugin[0]; + var args = plugin[1]; + installer.apply(self, [self].concat(args)); + }); + }, - return origFetch.apply(this, args).then(function(response) { - fetchData.status_code = response.status; + _parseDSN: function (str) { + var m = dsnPattern.exec(str), + dsn = {}, + i = 7; - return response; - }); - }; - }, - wrappedBuiltIns - ); - } + try { + while (i--) dsn[dsnKeys[i]] = m[i] || ""; + } catch (e) { + throw new RavenConfigError("Invalid DSN: " + str); + } - // Capture breadcrumbs from any click that is unhandled / bubbled up all the way - // to the document. Do this before we instrument addEventListener. - if (autoBreadcrumbs.dom && this._hasDocument) { - if (_document.addEventListener) { - _document.addEventListener('click', self._breadcrumbEventHandler('click'), false); - _document.addEventListener('keypress', self._keypressEventHandler(), false); - } else { - // IE8 Compatibility - _document.attachEvent('onclick', self._breadcrumbEventHandler('click')); - _document.attachEvent('onkeypress', self._keypressEventHandler()); - } - } + if (dsn.pass && !this._globalOptions.allowSecretKey) { + throw new RavenConfigError( + "Do not specify your secret key in the DSN. See: http://bit.ly/raven-secret-key", + ); + } - // record navigation (URL) changes - // NOTE: in Chrome App environment, touching history.pushState, *even inside - // a try/catch block*, will cause Chrome to output an error to console.error - // borrowed from: https://github.com/angular/angular.js/pull/13945/files - var chrome = _window.chrome; - var isChromePackagedApp = chrome && chrome.app && chrome.app.runtime; - var hasPushAndReplaceState = - !isChromePackagedApp && - _window.history && - history.pushState && - history.replaceState; - if (autoBreadcrumbs.location && hasPushAndReplaceState) { - // TODO: remove onpopstate handler on uninstall() - var oldOnPopState = _window.onpopstate; - _window.onpopstate = function() { - var currentHref = self._location.href; - self._captureUrlChange(self._lastHref, currentHref); - - if (oldOnPopState) { - return oldOnPopState.apply(this, arguments); - } - }; - - var historyReplacementFunction = function(origHistFunction) { - // note history.pushState.length is 0; intentionally not declaring - // params to preserve 0 arity - return function(/* state, title, url */) { - var url = arguments.length > 2 ? arguments[2] : undefined; - - // url argument is optional - if (url) { - // coerce to string (this is what pushState does) - self._captureUrlChange(self._lastHref, url + ''); - } + return dsn; + }, - return origHistFunction.apply(this, arguments); - }; - }; + _getGlobalServer: function (uri) { + // assemble the endpoint from the uri pieces + var globalServer = + "//" + uri.host + (uri.port ? ":" + uri.port : ""); - fill(history, 'pushState', historyReplacementFunction, wrappedBuiltIns); - fill(history, 'replaceState', historyReplacementFunction, wrappedBuiltIns); - } + if (uri.protocol) { + globalServer = uri.protocol + ":" + globalServer; + } + return globalServer; + }, - if (autoBreadcrumbs.console && 'console' in _window && console.log) { - // console - var consoleMethodCallback = function(msg, data) { - self.captureBreadcrumb({ - message: msg, - level: data.level, - category: 'console' - }); - }; - - each(['debug', 'info', 'warn', 'error', 'log'], function(_, level) { - wrapConsoleMethod(console, level, consoleMethodCallback); - }); - } - }, + _handleOnErrorStackInfo: function () { + // if we are intentionally ignoring errors via onerror, bail out + if (!this._ignoreOnError) { + this._handleStackInfo.apply(this, arguments); + } + }, - _restoreBuiltIns: function() { - // restore any wrapped builtins - var builtin; - while (this._wrappedBuiltIns.length) { - builtin = this._wrappedBuiltIns.shift(); + _handleStackInfo: function (stackInfo, options) { + var frames = this._prepareFrames(stackInfo, options); - var obj = builtin[0], - name = builtin[1], - orig = builtin[2]; + this._triggerEvent("handle", { + stackInfo: stackInfo, + options: options, + }); - obj[name] = orig; - } - }, - - _drainPlugins: function() { - var self = this; - - // FIX ME TODO - each(this._plugins, function(_, plugin) { - var installer = plugin[0]; - var args = plugin[1]; - installer.apply(self, [self].concat(args)); - }); - }, - - _parseDSN: function(str) { - var m = dsnPattern.exec(str), - dsn = {}, - i = 7; - - try { - while (i--) dsn[dsnKeys[i]] = m[i] || ''; - } catch (e) { - throw new RavenConfigError('Invalid DSN: ' + str); - } + this._processException( + stackInfo.name, + stackInfo.message, + stackInfo.url, + stackInfo.lineno, + frames, + options, + ); + }, + + _prepareFrames: function (stackInfo, options) { + var self = this; + var frames = []; + if (stackInfo.stack && stackInfo.stack.length) { + each(stackInfo.stack, function (i, stack) { + var frame = self._normalizeFrame(stack, stackInfo.url); + if (frame) { + frames.push(frame); + } + }); + + // e.g. frames captured via captureMessage throw + if (options && options.trimHeadFrames) { + for ( + var j = 0; + j < options.trimHeadFrames && j < frames.length; + j++ + ) { + frames[j].in_app = false; + } + } + } + frames = frames.slice(0, this._globalOptions.stackTraceLimit); + return frames; + }, + + _normalizeFrame: function (frame, stackInfoUrl) { + // normalize the frames data + var normalized = { + filename: frame.url, + lineno: frame.line, + colno: frame.column, + function: frame.func || "?", + }; - if (dsn.pass && !this._globalOptions.allowSecretKey) { - throw new RavenConfigError( - 'Do not specify your secret key in the DSN. See: http://bit.ly/raven-secret-key' - ); - } + // Case when we don't have any information about the error + // E.g. throwing a string or raw object, instead of an `Error` in Firefox + // Generating synthetic error doesn't add any value here + // + // We should probably somehow let a user know that they should fix their code + if (!frame.url) { + normalized.filename = stackInfoUrl; // fallback to whole stacks url from onerror handler + } - return dsn; - }, + normalized.in_app = !( + // determine if an exception came from outside of our app + // first we check the global includePaths list. + ( + (!!this._globalOptions.includePaths.test && + !this._globalOptions.includePaths.test( + normalized.filename, + )) || + // Now we check for fun, if the function name is Raven or TraceKit + /(Raven|TraceKit)\./.test(normalized["function"]) || + // finally, we do a last ditch effort and check for raven.min.js + /raven\.(min\.)?js$/.test(normalized.filename) + ) + ); + + return normalized; + }, + + _processException: function ( + type, + message, + fileurl, + lineno, + frames, + options, + ) { + var prefixedMessage = + (type ? type + ": " : "") + (message || ""); + if ( + !!this._globalOptions.ignoreErrors.test && + (this._globalOptions.ignoreErrors.test(message) || + this._globalOptions.ignoreErrors.test(prefixedMessage)) + ) { + return; + } - _getGlobalServer: function(uri) { - // assemble the endpoint from the uri pieces - var globalServer = '//' + uri.host + (uri.port ? ':' + uri.port : ''); + var stacktrace; + + if (frames && frames.length) { + fileurl = frames[0].filename || fileurl; + // Sentry expects frames oldest to newest + // and JS sends them as newest to oldest + frames.reverse(); + stacktrace = { frames: frames }; + } else if (fileurl) { + stacktrace = { + frames: [ + { + filename: fileurl, + lineno: lineno, + in_app: true, + }, + ], + }; + } - if (uri.protocol) { - globalServer = uri.protocol + ':' + globalServer; - } - return globalServer; - }, + if ( + !!this._globalOptions.ignoreUrls.test && + this._globalOptions.ignoreUrls.test(fileurl) + ) { + return; + } - _handleOnErrorStackInfo: function() { - // if we are intentionally ignoring errors via onerror, bail out - if (!this._ignoreOnError) { - this._handleStackInfo.apply(this, arguments); - } - }, - - _handleStackInfo: function(stackInfo, options) { - var frames = this._prepareFrames(stackInfo, options); - - this._triggerEvent('handle', { - stackInfo: stackInfo, - options: options - }); - - this._processException( - stackInfo.name, - stackInfo.message, - stackInfo.url, - stackInfo.lineno, - frames, - options - ); - }, - - _prepareFrames: function(stackInfo, options) { - var self = this; - var frames = []; - if (stackInfo.stack && stackInfo.stack.length) { - each(stackInfo.stack, function(i, stack) { - var frame = self._normalizeFrame(stack, stackInfo.url); - if (frame) { - frames.push(frame); - } - }); + if ( + !!this._globalOptions.whitelistUrls.test && + !this._globalOptions.whitelistUrls.test(fileurl) + ) { + return; + } - // e.g. frames captured via captureMessage throw - if (options && options.trimHeadFrames) { - for (var j = 0; j < options.trimHeadFrames && j < frames.length; j++) { - frames[j].in_app = false; - } - } - } - frames = frames.slice(0, this._globalOptions.stackTraceLimit); - return frames; - }, - - _normalizeFrame: function(frame, stackInfoUrl) { - // normalize the frames data - var normalized = { - filename: frame.url, - lineno: frame.line, - colno: frame.column, - function: frame.func || '?' - }; - - // Case when we don't have any information about the error - // E.g. throwing a string or raw object, instead of an `Error` in Firefox - // Generating synthetic error doesn't add any value here - // - // We should probably somehow let a user know that they should fix their code - if (!frame.url) { - normalized.filename = stackInfoUrl; // fallback to whole stacks url from onerror handler - } + var data = objectMerge( + { + // sentry.interfaces.Exception + exception: { + values: [ + { + type: type, + value: message, + stacktrace: stacktrace, + }, + ], + }, + culprit: fileurl, + }, + options, + ); + + // Fire away! + this._send(data); + }, + + _trimPacket: function (data) { + // For now, we only want to truncate the two different messages + // but this could/should be expanded to just trim everything + var max = this._globalOptions.maxMessageLength; + if (data.message) { + data.message = truncate(data.message, max); + } + if (data.exception) { + var exception = data.exception.values[0]; + exception.value = truncate(exception.value, max); + } - normalized.in_app = !// determine if an exception came from outside of our app - // first we check the global includePaths list. - ( - (!!this._globalOptions.includePaths.test && - !this._globalOptions.includePaths.test(normalized.filename)) || - // Now we check for fun, if the function name is Raven or TraceKit - /(Raven|TraceKit)\./.test(normalized['function']) || - // finally, we do a last ditch effort and check for raven.min.js - /raven\.(min\.)?js$/.test(normalized.filename) - ); - - return normalized; - }, - - _processException: function(type, message, fileurl, lineno, frames, options) { - var prefixedMessage = (type ? type + ': ' : '') + (message || ''); - if ( - !!this._globalOptions.ignoreErrors.test && - (this._globalOptions.ignoreErrors.test(message) || - this._globalOptions.ignoreErrors.test(prefixedMessage)) - ) { - return; - } + var request = data.request; + if (request) { + if (request.url) { + request.url = truncate( + request.url, + this._globalOptions.maxUrlLength, + ); + } + if (request.Referer) { + request.Referer = truncate( + request.Referer, + this._globalOptions.maxUrlLength, + ); + } + } - var stacktrace; - - if (frames && frames.length) { - fileurl = frames[0].filename || fileurl; - // Sentry expects frames oldest to newest - // and JS sends them as newest to oldest - frames.reverse(); - stacktrace = {frames: frames}; - } else if (fileurl) { - stacktrace = { - frames: [ - { - filename: fileurl, - lineno: lineno, - in_app: true - } - ] - }; - } + if (data.breadcrumbs && data.breadcrumbs.values) + this._trimBreadcrumbs(data.breadcrumbs); + + return data; + }, + + /** + * Truncate breadcrumb values (right now just URLs) + */ + _trimBreadcrumbs: function (breadcrumbs) { + // known breadcrumb properties with urls + // TODO: also consider arbitrary prop values that start with (https?)?:// + var urlProps = ["to", "from", "url"], + urlProp, + crumb, + data; + + for (var i = 0; i < breadcrumbs.values.length; ++i) { + crumb = breadcrumbs.values[i]; + if ( + !crumb.hasOwnProperty("data") || + !isObject(crumb.data) || + objectFrozen(crumb.data) + ) + continue; + + data = objectMerge({}, crumb.data); + for (var j = 0; j < urlProps.length; ++j) { + urlProp = urlProps[j]; + if (data.hasOwnProperty(urlProp) && data[urlProp]) { + data[urlProp] = truncate( + data[urlProp], + this._globalOptions.maxUrlLength, + ); + } + } + breadcrumbs.values[i].data = data; + } + }, - if ( - !!this._globalOptions.ignoreUrls.test && - this._globalOptions.ignoreUrls.test(fileurl) - ) { - return; - } + _getHttpData: function () { + if (!this._hasNavigator && !this._hasDocument) return; + var httpData = {}; - if ( - !!this._globalOptions.whitelistUrls.test && - !this._globalOptions.whitelistUrls.test(fileurl) - ) { - return; - } + if (this._hasNavigator && _navigator.userAgent) { + httpData.headers = { + "User-Agent": navigator.userAgent, + }; + } - var data = objectMerge( - { - // sentry.interfaces.Exception - exception: { - values: [ - { - type: type, - value: message, - stacktrace: stacktrace - } - ] - }, - culprit: fileurl - }, - options - ); - - // Fire away! - this._send(data); - }, - - _trimPacket: function(data) { - // For now, we only want to truncate the two different messages - // but this could/should be expanded to just trim everything - var max = this._globalOptions.maxMessageLength; - if (data.message) { - data.message = truncate(data.message, max); - } - if (data.exception) { - var exception = data.exception.values[0]; - exception.value = truncate(exception.value, max); - } - - var request = data.request; - if (request) { - if (request.url) { - request.url = truncate(request.url, this._globalOptions.maxUrlLength); - } - if (request.Referer) { - request.Referer = truncate(request.Referer, this._globalOptions.maxUrlLength); - } - } - - if (data.breadcrumbs && data.breadcrumbs.values) - this._trimBreadcrumbs(data.breadcrumbs); - - return data; - }, - - /** - * Truncate breadcrumb values (right now just URLs) - */ - _trimBreadcrumbs: function(breadcrumbs) { - // known breadcrumb properties with urls - // TODO: also consider arbitrary prop values that start with (https?)?:// - var urlProps = ['to', 'from', 'url'], - urlProp, - crumb, - data; - - for (var i = 0; i < breadcrumbs.values.length; ++i) { - crumb = breadcrumbs.values[i]; - if ( - !crumb.hasOwnProperty('data') || - !isObject(crumb.data) || - objectFrozen(crumb.data) - ) - continue; - - data = objectMerge({}, crumb.data); - for (var j = 0; j < urlProps.length; ++j) { - urlProp = urlProps[j]; - if (data.hasOwnProperty(urlProp) && data[urlProp]) { - data[urlProp] = truncate(data[urlProp], this._globalOptions.maxUrlLength); - } - } - breadcrumbs.values[i].data = data; - } - }, - - _getHttpData: function() { - if (!this._hasNavigator && !this._hasDocument) return; - var httpData = {}; - - if (this._hasNavigator && _navigator.userAgent) { - httpData.headers = { - 'User-Agent': navigator.userAgent - }; - } - - if (this._hasDocument) { - if (_document.location && _document.location.href) { - httpData.url = _document.location.href; - } - if (_document.referrer) { - if (!httpData.headers) httpData.headers = {}; - httpData.headers.Referer = _document.referrer; - } - } - - return httpData; - }, - - _resetBackoff: function() { - this._backoffDuration = 0; - this._backoffStart = null; - }, - - _shouldBackoff: function() { - return this._backoffDuration && now() - this._backoffStart < this._backoffDuration; - }, - - /** - * Returns true if the in-process data payload matches the signature - * of the previously-sent data - * - * NOTE: This has to be done at this level because TraceKit can generate - * data from window.onerror WITHOUT an exception object (IE8, IE9, - * other old browsers). This can take the form of an "exception" - * data object with a single frame (derived from the onerror args). - */ - _isRepeatData: function(current) { - var last = this._lastData; - - if ( - !last || - current.message !== last.message || // defined for captureMessage - current.culprit !== last.culprit // defined for captureException/onerror - ) - return false; - - // Stacktrace interface (i.e. from captureMessage) - if (current.stacktrace || last.stacktrace) { - return isSameStacktrace(current.stacktrace, last.stacktrace); - } else if (current.exception || last.exception) { - // Exception interface (i.e. from captureException/onerror) - return isSameException(current.exception, last.exception); - } - - return true; - }, - - _setBackoffState: function(request) { - // If we are already in a backoff state, don't change anything - if (this._shouldBackoff()) { - return; - } - - var status = request.status; - - // 400 - project_id doesn't exist or some other fatal - // 401 - invalid/revoked dsn - // 429 - too many requests - if (!(status === 400 || status === 401 || status === 429)) return; - - var retry; - try { - // If Retry-After is not in Access-Control-Expose-Headers, most - // browsers will throw an exception trying to access it - retry = request.getResponseHeader('Retry-After'); - retry = parseInt(retry, 10) * 1000; // Retry-After is returned in seconds - } catch (e) { - /* eslint no-empty:0 */ - } - - this._backoffDuration = retry - ? // If Sentry server returned a Retry-After value, use it - retry - : // Otherwise, double the last backoff duration (starts at 1 sec) - this._backoffDuration * 2 || 1000; + if (this._hasDocument) { + if (_document.location && _document.location.href) { + httpData.url = _document.location.href; + } + if (_document.referrer) { + if (!httpData.headers) httpData.headers = {}; + httpData.headers.Referer = _document.referrer; + } + } - this._backoffStart = now(); - }, + return httpData; + }, + + _resetBackoff: function () { + this._backoffDuration = 0; + this._backoffStart = null; + }, + + _shouldBackoff: function () { + return ( + this._backoffDuration && + now() - this._backoffStart < this._backoffDuration + ); + }, + + /** + * Returns true if the in-process data payload matches the signature + * of the previously-sent data + * + * NOTE: This has to be done at this level because TraceKit can generate + * data from window.onerror WITHOUT an exception object (IE8, IE9, + * other old browsers). This can take the form of an "exception" + * data object with a single frame (derived from the onerror args). + */ + _isRepeatData: function (current) { + var last = this._lastData; + + if ( + !last || + current.message !== last.message || // defined for captureMessage + current.culprit !== last.culprit // defined for captureException/onerror + ) + return false; + + // Stacktrace interface (i.e. from captureMessage) + if (current.stacktrace || last.stacktrace) { + return isSameStacktrace(current.stacktrace, last.stacktrace); + } else if (current.exception || last.exception) { + // Exception interface (i.e. from captureException/onerror) + return isSameException(current.exception, last.exception); + } - _send: function(data) { - var globalOptions = this._globalOptions; + return true; + }, - var baseData = { - project: this._globalProject, - logger: globalOptions.logger, - platform: 'javascript' - }, - httpData = this._getHttpData(); + _setBackoffState: function (request) { + // If we are already in a backoff state, don't change anything + if (this._shouldBackoff()) { + return; + } - if (httpData) { - baseData.request = httpData; - } + var status = request.status; - // HACK: delete `trimHeadFrames` to prevent from appearing in outbound payload - if (data.trimHeadFrames) delete data.trimHeadFrames; + // 400 - project_id doesn't exist or some other fatal + // 401 - invalid/revoked dsn + // 429 - too many requests + if (!(status === 400 || status === 401 || status === 429)) + return; - data = objectMerge(baseData, data); + var retry; + try { + // If Retry-After is not in Access-Control-Expose-Headers, most + // browsers will throw an exception trying to access it + retry = request.getResponseHeader("Retry-After"); + retry = parseInt(retry, 10) * 1000; // Retry-After is returned in seconds + } catch (e) { + /* eslint no-empty:0 */ + } - // Merge in the tags and extra separately since objectMerge doesn't handle a deep merge - data.tags = objectMerge(objectMerge({}, this._globalContext.tags), data.tags); - data.extra = objectMerge(objectMerge({}, this._globalContext.extra), data.extra); + this._backoffDuration = retry + ? // If Sentry server returned a Retry-After value, use it + retry + : // Otherwise, double the last backoff duration (starts at 1 sec) + this._backoffDuration * 2 || 1000; - // Send along our own collected metadata with extra - data.extra['session:duration'] = now() - this._startTime; + this._backoffStart = now(); + }, - if (this._breadcrumbs && this._breadcrumbs.length > 0) { - // intentionally make shallow copy so that additions - // to breadcrumbs aren't accidentally sent in this request - data.breadcrumbs = { - values: [].slice.call(this._breadcrumbs, 0) - }; - } + _send: function (data) { + var globalOptions = this._globalOptions; - // If there are no tags/extra, strip the key from the payload alltogther. - if (isEmptyObject(data.tags)) delete data.tags; + var baseData = { + project: this._globalProject, + logger: globalOptions.logger, + platform: "javascript", + }, + httpData = this._getHttpData(); - if (this._globalContext.user) { - // sentry.interfaces.User - data.user = this._globalContext.user; - } + if (httpData) { + baseData.request = httpData; + } - // Include the environment if it's defined in globalOptions - if (globalOptions.environment) data.environment = globalOptions.environment; + // HACK: delete `trimHeadFrames` to prevent from appearing in outbound payload + if (data.trimHeadFrames) delete data.trimHeadFrames; + + data = objectMerge(baseData, data); + + // Merge in the tags and extra separately since objectMerge doesn't handle a deep merge + data.tags = objectMerge( + objectMerge({}, this._globalContext.tags), + data.tags, + ); + data.extra = objectMerge( + objectMerge({}, this._globalContext.extra), + data.extra, + ); + + // Send along our own collected metadata with extra + data.extra["session:duration"] = now() - this._startTime; + + if (this._breadcrumbs && this._breadcrumbs.length > 0) { + // intentionally make shallow copy so that additions + // to breadcrumbs aren't accidentally sent in this request + data.breadcrumbs = { + values: [].slice.call(this._breadcrumbs, 0), + }; + } - // Include the release if it's defined in globalOptions - if (globalOptions.release) data.release = globalOptions.release; + // If there are no tags/extra, strip the key from the payload alltogther. + if (isEmptyObject(data.tags)) delete data.tags; - // Include server_name if it's defined in globalOptions - if (globalOptions.serverName) data.server_name = globalOptions.serverName; + if (this._globalContext.user) { + // sentry.interfaces.User + data.user = this._globalContext.user; + } - if (isFunction(globalOptions.dataCallback)) { - data = globalOptions.dataCallback(data) || data; - } + // Include the environment if it's defined in globalOptions + if (globalOptions.environment) + data.environment = globalOptions.environment; - // Why?????????? - if (!data || isEmptyObject(data)) { - return; - } + // Include the release if it's defined in globalOptions + if (globalOptions.release) data.release = globalOptions.release; - // Check if the request should be filtered or not - if ( - isFunction(globalOptions.shouldSendCallback) && - !globalOptions.shouldSendCallback(data) - ) { - return; - } + // Include server_name if it's defined in globalOptions + if (globalOptions.serverName) + data.server_name = globalOptions.serverName; - // Backoff state: Sentry server previously responded w/ an error (e.g. 429 - too many requests), - // so drop requests until "cool-off" period has elapsed. - if (this._shouldBackoff()) { - this._logDebug('warn', 'Raven dropped error due to backoff: ', data); - return; - } + if (isFunction(globalOptions.dataCallback)) { + data = globalOptions.dataCallback(data) || data; + } - if (typeof globalOptions.sampleRate === 'number') { - if (Math.random() < globalOptions.sampleRate) { - this._sendProcessedPayload(data); - } - } else { - this._sendProcessedPayload(data); - } - }, + // Why?????????? + if (!data || isEmptyObject(data)) { + return; + } - _getUuid: function() { - return uuid4(); - }, + // Check if the request should be filtered or not + if ( + isFunction(globalOptions.shouldSendCallback) && + !globalOptions.shouldSendCallback(data) + ) { + return; + } - _sendProcessedPayload: function(data, callback) { - var self = this; - var globalOptions = this._globalOptions; + // Backoff state: Sentry server previously responded w/ an error (e.g. 429 - too many requests), + // so drop requests until "cool-off" period has elapsed. + if (this._shouldBackoff()) { + this._logDebug( + "warn", + "Raven dropped error due to backoff: ", + data, + ); + return; + } - if (!this.isSetup()) return; + if (typeof globalOptions.sampleRate === "number") { + if (Math.random() < globalOptions.sampleRate) { + this._sendProcessedPayload(data); + } + } else { + this._sendProcessedPayload(data); + } + }, + + _getUuid: function () { + return uuid4(); + }, + + _sendProcessedPayload: function (data, callback) { + var self = this; + var globalOptions = this._globalOptions; + + if (!this.isSetup()) return; + + // Try and clean up the packet before sending by truncating long values + data = this._trimPacket(data); + + // ideally duplicate error testing should occur *before* dataCallback/shouldSendCallback, + // but this would require copying an un-truncated copy of the data packet, which can be + // arbitrarily deep (extra_data) -- could be worthwhile? will revisit + if ( + !this._globalOptions.allowDuplicates && + this._isRepeatData(data) + ) { + this._logDebug("warn", "Raven dropped repeat event: ", data); + return; + } - // Try and clean up the packet before sending by truncating long values - data = this._trimPacket(data); + // Send along an event_id if not explicitly passed. + // This event_id can be used to reference the error within Sentry itself. + // Set lastEventId after we know the error should actually be sent + this._lastEventId = + data.event_id || (data.event_id = this._getUuid()); - // ideally duplicate error testing should occur *before* dataCallback/shouldSendCallback, - // but this would require copying an un-truncated copy of the data packet, which can be - // arbitrarily deep (extra_data) -- could be worthwhile? will revisit - if (!this._globalOptions.allowDuplicates && this._isRepeatData(data)) { - this._logDebug('warn', 'Raven dropped repeat event: ', data); - return; - } + // Store outbound payload after trim + this._lastData = data; - // Send along an event_id if not explicitly passed. - // This event_id can be used to reference the error within Sentry itself. - // Set lastEventId after we know the error should actually be sent - this._lastEventId = data.event_id || (data.event_id = this._getUuid()); + this._logDebug("debug", "Raven about to send:", data); - // Store outbound payload after trim - this._lastData = data; + var auth = { + sentry_version: "7", + sentry_client: "raven-js/" + this.VERSION, + sentry_key: this._globalKey, + }; - this._logDebug('debug', 'Raven about to send:', data); + if (this._globalSecret) { + auth.sentry_secret = this._globalSecret; + } - var auth = { - sentry_version: '7', - sentry_client: 'raven-js/' + this.VERSION, - sentry_key: this._globalKey - }; + var exception = data.exception && data.exception.values[0]; + + // only capture 'sentry' breadcrumb is autoBreadcrumbs is truthy + if ( + this._globalOptions.autoBreadcrumbs && + this._globalOptions.autoBreadcrumbs.sentry + ) { + this.captureBreadcrumb({ + category: "sentry", + message: exception + ? (exception.type ? exception.type + ": " : "") + + exception.value + : data.message, + event_id: data.event_id, + level: data.level || "error", // presume error unless specified + }); + } - if (this._globalSecret) { - auth.sentry_secret = this._globalSecret; - } + var url = this._globalEndpoint; + (globalOptions.transport || this._makeRequest).call(this, { + url: url, + auth: auth, + data: data, + options: globalOptions, + onSuccess: function success() { + self._resetBackoff(); + + self._triggerEvent("success", { + data: data, + src: url, + }); + callback && callback(); + }, + onError: function failure(error) { + self._logDebug( + "error", + "Raven transport failed to send: ", + error, + ); + + if (error.request) { + self._setBackoffState(error.request); + } + + self._triggerEvent("failure", { + data: data, + src: url, + }); + error = + error || + new Error( + "Raven send failed (no additional details provided)", + ); + callback && callback(error); + }, + }); + }, + + _makeRequest: function (opts) { + var request = + _window.XMLHttpRequest && new _window.XMLHttpRequest(); + if (!request) return; + + // if browser doesn't support CORS (e.g. IE7), we are out of luck + var hasCORS = + "withCredentials" in request || + typeof XDomainRequest !== "undefined"; + + if (!hasCORS) return; + + var url = opts.url; + + if ("withCredentials" in request) { + request.onreadystatechange = function () { + if (request.readyState !== 4) { + return; + } else if (request.status === 200) { + opts.onSuccess && opts.onSuccess(); + } else if (opts.onError) { + var err = new Error( + "Sentry error code: " + request.status, + ); + err.request = request; + opts.onError(err); + } + }; + } else { + request = new XDomainRequest(); + // xdomainrequest cannot go http -> https (or vice versa), + // so always use protocol relative + url = url.replace(/^https?:/, ""); + + // onreadystatechange not supported by XDomainRequest + if (opts.onSuccess) { + request.onload = opts.onSuccess; + } + if (opts.onError) { + request.onerror = function () { + var err = new Error("Sentry error code: XDomainRequest"); + err.request = request; + opts.onError(err); + }; + } + } - var exception = data.exception && data.exception.values[0]; - - // only capture 'sentry' breadcrumb is autoBreadcrumbs is truthy - if ( - this._globalOptions.autoBreadcrumbs && - this._globalOptions.autoBreadcrumbs.sentry - ) { - this.captureBreadcrumb({ - category: 'sentry', - message: exception - ? (exception.type ? exception.type + ': ' : '') + exception.value - : data.message, - event_id: data.event_id, - level: data.level || 'error' // presume error unless specified - }); - } + // NOTE: auth is intentionally sent as part of query string (NOT as custom + // HTTP header) so as to avoid preflight CORS requests + request.open("POST", url + "?" + urlencode(opts.auth)); + request.send(stringify(opts.data)); + }, + + _logDebug: function (level) { + if (this._originalConsoleMethods[level] && this.debug) { + // In IE<10 console methods do not have their own 'apply' method + Function.prototype.apply.call( + this._originalConsoleMethods[level], + this._originalConsole, + [].slice.call(arguments, 1), + ); + } + }, + + _mergeContext: function (key, context) { + if (isUndefined(context)) { + delete this._globalContext[key]; + } else { + this._globalContext[key] = objectMerge( + this._globalContext[key] || {}, + context, + ); + } + }, + }; - var url = this._globalEndpoint; - (globalOptions.transport || this._makeRequest).call(this, { - url: url, - auth: auth, - data: data, - options: globalOptions, - onSuccess: function success() { - self._resetBackoff(); - - self._triggerEvent('success', { - data: data, - src: url - }); - callback && callback(); - }, - onError: function failure(error) { - self._logDebug('error', 'Raven transport failed to send: ', error); - - if (error.request) { - self._setBackoffState(error.request); - } + // Deprecations + Raven.prototype.setUser = Raven.prototype.setUserContext; + Raven.prototype.setReleaseContext = Raven.prototype.setRelease; + + module.exports = Raven; + }).call( + this, + typeof global !== "undefined" + ? global + : typeof self !== "undefined" + ? self + : typeof window !== "undefined" + ? window + : {}, + ); + }, + { 1: 1, 2: 2, 5: 5, 6: 6, 7: 7 }, + ], + 4: [ + function (_dereq_, module, exports) { + (function (global) { + /** + * Enforces a single instance of the Raven client, and the + * main entry point for Raven. If you are a consumer of the + * Raven library, you SHOULD load this file (vs raven.js). + **/ + + var RavenConstructor = _dereq_(3); + + // This is to be defensive in environments where window does not exist (see https://github.com/getsentry/raven-js/pull/785) + var _window = + typeof window !== "undefined" + ? window + : typeof global !== "undefined" + ? global + : typeof self !== "undefined" + ? self + : {}; + var _Raven = _window.Raven; + + var Raven = new RavenConstructor(); + + /* + * Allow multiple versions of Raven to be installed. + * Strip Raven from the global context and returns the instance. + * + * @return {Raven} + */ + Raven.noConflict = function () { + _window.Raven = _Raven; + return Raven; + }; - self._triggerEvent('failure', { - data: data, - src: url - }); - error = error || new Error('Raven send failed (no additional details provided)'); - callback && callback(error); - } - }); - }, - - _makeRequest: function(opts) { - var request = _window.XMLHttpRequest && new _window.XMLHttpRequest(); - if (!request) return; - - // if browser doesn't support CORS (e.g. IE7), we are out of luck - var hasCORS = 'withCredentials' in request || typeof XDomainRequest !== 'undefined'; - - if (!hasCORS) return; - - var url = opts.url; - - if ('withCredentials' in request) { - request.onreadystatechange = function() { - if (request.readyState !== 4) { - return; - } else if (request.status === 200) { - opts.onSuccess && opts.onSuccess(); - } else if (opts.onError) { - var err = new Error('Sentry error code: ' + request.status); - err.request = request; - opts.onError(err); - } - }; - } else { - request = new XDomainRequest(); - // xdomainrequest cannot go http -> https (or vice versa), - // so always use protocol relative - url = url.replace(/^https?:/, ''); - - // onreadystatechange not supported by XDomainRequest - if (opts.onSuccess) { - request.onload = opts.onSuccess; - } - if (opts.onError) { - request.onerror = function() { - var err = new Error('Sentry error code: XDomainRequest'); - err.request = request; - opts.onError(err); - }; - } - } + Raven.afterLoad(); + + module.exports = Raven; + }).call( + this, + typeof global !== "undefined" + ? global + : typeof self !== "undefined" + ? self + : typeof window !== "undefined" + ? window + : {}, + ); + }, + { 3: 3 }, + ], + 5: [ + function (_dereq_, module, exports) { + (function (global) { + var _window = + typeof window !== "undefined" + ? window + : typeof global !== "undefined" + ? global + : typeof self !== "undefined" + ? self + : {}; + + function isObject(what) { + return typeof what === "object" && what !== null; + } - // NOTE: auth is intentionally sent as part of query string (NOT as custom - // HTTP header) so as to avoid preflight CORS requests - request.open('POST', url + '?' + urlencode(opts.auth)); - request.send(stringify(opts.data)); - }, - - _logDebug: function(level) { - if (this._originalConsoleMethods[level] && this.debug) { - // In IE<10 console methods do not have their own 'apply' method - Function.prototype.apply.call( - this._originalConsoleMethods[level], - this._originalConsole, - [].slice.call(arguments, 1) - ); - } - }, + // Yanked from https://git.io/vS8DV re-used under CC0 + // with some tiny modifications + function isError(value) { + switch ({}.toString.call(value)) { + case "[object Error]": + return true; + case "[object Exception]": + return true; + case "[object DOMException]": + return true; + default: + return value instanceof Error; + } + } - _mergeContext: function(key, context) { - if (isUndefined(context)) { - delete this._globalContext[key]; - } else { - this._globalContext[key] = objectMerge(this._globalContext[key] || {}, context); - } - } -}; + function isErrorEvent(value) { + return ( + supportsErrorEvent() && + {}.toString.call(value) === "[object ErrorEvent]" + ); + } -// Deprecations -Raven.prototype.setUser = Raven.prototype.setUserContext; -Raven.prototype.setReleaseContext = Raven.prototype.setRelease; + function isUndefined(what) { + return what === void 0; + } -module.exports = Raven; + function isFunction(what) { + return typeof what === "function"; + } -}).call(this,typeof global !== "undefined" ? global : typeof self !== "undefined" ? self : typeof window !== "undefined" ? window : {}) -},{"1":1,"2":2,"5":5,"6":6,"7":7}],4:[function(_dereq_,module,exports){ -(function (global){ -/** - * Enforces a single instance of the Raven client, and the - * main entry point for Raven. If you are a consumer of the - * Raven library, you SHOULD load this file (vs raven.js). - **/ + function isString(what) { + return Object.prototype.toString.call(what) === "[object String]"; + } -var RavenConstructor = _dereq_(3); + function isArray(what) { + return Object.prototype.toString.call(what) === "[object Array]"; + } -// This is to be defensive in environments where window does not exist (see https://github.com/getsentry/raven-js/pull/785) -var _window = - typeof window !== 'undefined' - ? window - : typeof global !== 'undefined' ? global : typeof self !== 'undefined' ? self : {}; -var _Raven = _window.Raven; + function isEmptyObject(what) { + for (var _ in what) { + if (what.hasOwnProperty(_)) { + return false; + } + } + return true; + } -var Raven = new RavenConstructor(); + function supportsErrorEvent() { + try { + new ErrorEvent(""); // eslint-disable-line no-new + return true; + } catch (e) { + return false; + } + } -/* - * Allow multiple versions of Raven to be installed. - * Strip Raven from the global context and returns the instance. - * - * @return {Raven} - */ -Raven.noConflict = function() { - _window.Raven = _Raven; - return Raven; -}; - -Raven.afterLoad(); - -module.exports = Raven; - -}).call(this,typeof global !== "undefined" ? global : typeof self !== "undefined" ? self : typeof window !== "undefined" ? window : {}) -},{"3":3}],5:[function(_dereq_,module,exports){ -(function (global){ -var _window = - typeof window !== 'undefined' - ? window - : typeof global !== 'undefined' ? global : typeof self !== 'undefined' ? self : {}; - -function isObject(what) { - return typeof what === 'object' && what !== null; -} - -// Yanked from https://git.io/vS8DV re-used under CC0 -// with some tiny modifications -function isError(value) { - switch ({}.toString.call(value)) { - case '[object Error]': - return true; - case '[object Exception]': - return true; - case '[object DOMException]': - return true; - default: - return value instanceof Error; - } -} + function wrappedCallback(callback) { + function dataCallback(data, original) { + var normalizedData = callback(data) || data; + if (original) { + return original(normalizedData) || normalizedData; + } + return normalizedData; + } -function isErrorEvent(value) { - return supportsErrorEvent() && {}.toString.call(value) === '[object ErrorEvent]'; -} + return dataCallback; + } -function isUndefined(what) { - return what === void 0; -} + function each(obj, callback) { + var i, j; -function isFunction(what) { - return typeof what === 'function'; -} + if (isUndefined(obj.length)) { + for (i in obj) { + if (hasKey(obj, i)) { + callback.call(null, i, obj[i]); + } + } + } else { + j = obj.length; + if (j) { + for (i = 0; i < j; i++) { + callback.call(null, i, obj[i]); + } + } + } + } -function isString(what) { - return Object.prototype.toString.call(what) === '[object String]'; -} + function objectMerge(obj1, obj2) { + if (!obj2) { + return obj1; + } + each(obj2, function (key, value) { + obj1[key] = value; + }); + return obj1; + } -function isArray(what) { - return Object.prototype.toString.call(what) === '[object Array]'; -} + /** + * This function is only used for react-native. + * react-native freezes object that have already been sent over the + * js bridge. We need this function in order to check if the object is frozen. + * So it's ok that objectFrozen returns false if Object.isFrozen is not + * supported because it's not relevant for other "platforms". See related issue: + * https://github.com/getsentry/react-native-sentry/issues/57 + */ + function objectFrozen(obj) { + if (!Object.isFrozen) { + return false; + } + return Object.isFrozen(obj); + } -function isEmptyObject(what) { - for (var _ in what) { - if (what.hasOwnProperty(_)) { - return false; - } - } - return true; -} - -function supportsErrorEvent() { - try { - new ErrorEvent(''); // eslint-disable-line no-new - return true; - } catch (e) { - return false; - } -} + function truncate(str, max) { + return !max || str.length <= max + ? str + : str.substr(0, max) + "\u2026"; + } -function wrappedCallback(callback) { - function dataCallback(data, original) { - var normalizedData = callback(data) || data; - if (original) { - return original(normalizedData) || normalizedData; - } - return normalizedData; - } + /** + * hasKey, a better form of hasOwnProperty + * Example: hasKey(MainHostObject, property) === true/false + * + * @param {Object} host object to check property + * @param {string} key to check + */ + function hasKey(object, key) { + return Object.prototype.hasOwnProperty.call(object, key); + } - return dataCallback; -} + function joinRegExp(patterns) { + // Combine an array of regular expressions and strings into one large regexp + // Be mad. + var sources = [], + i = 0, + len = patterns.length, + pattern; + + for (; i < len; i++) { + pattern = patterns[i]; + if (isString(pattern)) { + // If it's a string, we need to escape it + // Taken from: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions + sources.push( + pattern.replace(/([.*+?^=!:${}()|\[\]\/\\])/g, "\\$1"), + ); + } else if (pattern && pattern.source) { + // If it's a regexp already, we want to extract the source + sources.push(pattern.source); + } + // Intentionally skip other cases + } + return new RegExp(sources.join("|"), "i"); + } -function each(obj, callback) { - var i, j; + function urlencode(o) { + var pairs = []; + each(o, function (key, value) { + pairs.push( + encodeURIComponent(key) + "=" + encodeURIComponent(value), + ); + }); + return pairs.join("&"); + } - if (isUndefined(obj.length)) { - for (i in obj) { - if (hasKey(obj, i)) { - callback.call(null, i, obj[i]); - } - } - } else { - j = obj.length; - if (j) { - for (i = 0; i < j; i++) { - callback.call(null, i, obj[i]); - } - } - } -} + // borrowed from https://datatracker.ietf.org/doc/html/rfc3986#appendix-B + // intentionally using regex and not href parsing trick because React Native and other + // environments where DOM might not be available + function parseUrl(url) { + var match = url.match( + /^(([^:\/?#]+):)?(\/\/([^\/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?$/, + ); + if (!match) return {}; + + // coerce to undefined values to empty string so we don't get 'undefined' + var query = match[6] || ""; + var fragment = match[8] || ""; + return { + protocol: match[2], + host: match[4], + path: match[5], + relative: match[5] + query + fragment, // everything minus origin + }; + } + function uuid4() { + var crypto = _window.crypto || _window.msCrypto; + + if (!isUndefined(crypto) && crypto.getRandomValues) { + // Use window.crypto API if available + // eslint-disable-next-line no-undef + var arr = new Uint16Array(8); + crypto.getRandomValues(arr); + + // set 4 in byte 7 + arr[3] = (arr[3] & 0xfff) | 0x4000; + // set 2 most significant bits of byte 9 to '10' + arr[4] = (arr[4] & 0x3fff) | 0x8000; + + var pad = function (num) { + var v = num.toString(16); + while (v.length < 4) { + v = "0" + v; + } + return v; + }; -function objectMerge(obj1, obj2) { - if (!obj2) { - return obj1; - } - each(obj2, function(key, value) { - obj1[key] = value; - }); - return obj1; -} - -/** - * This function is only used for react-native. - * react-native freezes object that have already been sent over the - * js bridge. We need this function in order to check if the object is frozen. - * So it's ok that objectFrozen returns false if Object.isFrozen is not - * supported because it's not relevant for other "platforms". See related issue: - * https://github.com/getsentry/react-native-sentry/issues/57 - */ -function objectFrozen(obj) { - if (!Object.isFrozen) { - return false; - } - return Object.isFrozen(obj); -} + return ( + pad(arr[0]) + + pad(arr[1]) + + pad(arr[2]) + + pad(arr[3]) + + pad(arr[4]) + + pad(arr[5]) + + pad(arr[6]) + + pad(arr[7]) + ); + } else { + // http://stackoverflow.com/questions/105034/how-to-create-a-guid-uuid-in-javascript/2117523#2117523 + return "xxxxxxxxxxxx4xxxyxxxxxxxxxxxxxxx".replace( + /[xy]/g, + function (c) { + var r = (Math.random() * 16) | 0, + v = c === "x" ? r : (r & 0x3) | 0x8; + return v.toString(16); + }, + ); + } + } -function truncate(str, max) { - return !max || str.length <= max ? str : str.substr(0, max) + '\u2026'; -} + /** + * Given a child DOM element, returns a query-selector statement describing that + * and its ancestors + * e.g. [HTMLElement] => body > div > input#foo.btn[name=baz] + * @param elem + * @returns {string} + */ + function htmlTreeAsString(elem) { + /* eslint no-extra-parens:0*/ + var MAX_TRAVERSE_HEIGHT = 5, + MAX_OUTPUT_LEN = 80, + out = [], + height = 0, + len = 0, + separator = " > ", + sepLength = separator.length, + nextStr; + + while (elem && height++ < MAX_TRAVERSE_HEIGHT) { + nextStr = htmlElementAsString(elem); + // bail out if + // - nextStr is the 'html' element + // - the length of the string that would be created exceeds MAX_OUTPUT_LEN + // (ignore this limit if we are on the first iteration) + if ( + nextStr === "html" || + (height > 1 && + len + out.length * sepLength + nextStr.length >= + MAX_OUTPUT_LEN) + ) { + break; + } -/** - * hasKey, a better form of hasOwnProperty - * Example: hasKey(MainHostObject, property) === true/false - * - * @param {Object} host object to check property - * @param {string} key to check - */ -function hasKey(object, key) { - return Object.prototype.hasOwnProperty.call(object, key); -} - -function joinRegExp(patterns) { - // Combine an array of regular expressions and strings into one large regexp - // Be mad. - var sources = [], - i = 0, - len = patterns.length, - pattern; - - for (; i < len; i++) { - pattern = patterns[i]; - if (isString(pattern)) { - // If it's a string, we need to escape it - // Taken from: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions - sources.push(pattern.replace(/([.*+?^=!:${}()|\[\]\/\\])/g, '\\$1')); - } else if (pattern && pattern.source) { - // If it's a regexp already, we want to extract the source - sources.push(pattern.source); - } - // Intentionally skip other cases - } - return new RegExp(sources.join('|'), 'i'); -} - -function urlencode(o) { - var pairs = []; - each(o, function(key, value) { - pairs.push(encodeURIComponent(key) + '=' + encodeURIComponent(value)); - }); - return pairs.join('&'); -} - -// borrowed from https://datatracker.ietf.org/doc/html/rfc3986#appendix-B -// intentionally using regex and not href parsing trick because React Native and other -// environments where DOM might not be available -function parseUrl(url) { - var match = url.match(/^(([^:\/?#]+):)?(\/\/([^\/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?$/); - if (!match) return {}; - - // coerce to undefined values to empty string so we don't get 'undefined' - var query = match[6] || ''; - var fragment = match[8] || ''; - return { - protocol: match[2], - host: match[4], - path: match[5], - relative: match[5] + query + fragment // everything minus origin - }; -} -function uuid4() { - var crypto = _window.crypto || _window.msCrypto; - - if (!isUndefined(crypto) && crypto.getRandomValues) { - // Use window.crypto API if available - // eslint-disable-next-line no-undef - var arr = new Uint16Array(8); - crypto.getRandomValues(arr); - - // set 4 in byte 7 - arr[3] = (arr[3] & 0xfff) | 0x4000; - // set 2 most significant bits of byte 9 to '10' - arr[4] = (arr[4] & 0x3fff) | 0x8000; - - var pad = function(num) { - var v = num.toString(16); - while (v.length < 4) { - v = '0' + v; - } - return v; - }; - - return ( - pad(arr[0]) + - pad(arr[1]) + - pad(arr[2]) + - pad(arr[3]) + - pad(arr[4]) + - pad(arr[5]) + - pad(arr[6]) + - pad(arr[7]) - ); - } else { - // http://stackoverflow.com/questions/105034/how-to-create-a-guid-uuid-in-javascript/2117523#2117523 - return 'xxxxxxxxxxxx4xxxyxxxxxxxxxxxxxxx'.replace(/[xy]/g, function(c) { - var r = (Math.random() * 16) | 0, - v = c === 'x' ? r : (r & 0x3) | 0x8; - return v.toString(16); - }); - } -} - -/** - * Given a child DOM element, returns a query-selector statement describing that - * and its ancestors - * e.g. [HTMLElement] => body > div > input#foo.btn[name=baz] - * @param elem - * @returns {string} - */ -function htmlTreeAsString(elem) { - /* eslint no-extra-parens:0*/ - var MAX_TRAVERSE_HEIGHT = 5, - MAX_OUTPUT_LEN = 80, - out = [], - height = 0, - len = 0, - separator = ' > ', - sepLength = separator.length, - nextStr; - - while (elem && height++ < MAX_TRAVERSE_HEIGHT) { - nextStr = htmlElementAsString(elem); - // bail out if - // - nextStr is the 'html' element - // - the length of the string that would be created exceeds MAX_OUTPUT_LEN - // (ignore this limit if we are on the first iteration) - if ( - nextStr === 'html' || - (height > 1 && len + out.length * sepLength + nextStr.length >= MAX_OUTPUT_LEN) - ) { - break; - } + out.push(nextStr); - out.push(nextStr); + len += nextStr.length; + elem = elem.parentNode; + } - len += nextStr.length; - elem = elem.parentNode; - } + return out.reverse().join(separator); + } - return out.reverse().join(separator); -} + /** + * Returns a simple, query-selector representation of a DOM element + * e.g. [HTMLElement] => input#foo.btn[name=baz] + * @param HTMLElement + * @returns {string} + */ + function htmlElementAsString(elem) { + var out = [], + className, + classes, + key, + attr, + i; + + if (!elem || !elem.tagName) { + return ""; + } -/** - * Returns a simple, query-selector representation of a DOM element - * e.g. [HTMLElement] => input#foo.btn[name=baz] - * @param HTMLElement - * @returns {string} - */ -function htmlElementAsString(elem) { - var out = [], - className, - classes, - key, - attr, - i; - - if (!elem || !elem.tagName) { - return ''; - } + out.push(elem.tagName.toLowerCase()); + if (elem.id) { + out.push("#" + elem.id); + } - out.push(elem.tagName.toLowerCase()); - if (elem.id) { - out.push('#' + elem.id); - } + className = elem.className; + if (className && isString(className)) { + classes = className.split(/\s+/); + for (i = 0; i < classes.length; i++) { + out.push("." + classes[i]); + } + } + var attrWhitelist = ["type", "name", "title", "alt"]; + for (i = 0; i < attrWhitelist.length; i++) { + key = attrWhitelist[i]; + attr = elem.getAttribute(key); + if (attr) { + out.push("[" + key + '="' + attr + '"]'); + } + } + return out.join(""); + } - className = elem.className; - if (className && isString(className)) { - classes = className.split(/\s+/); - for (i = 0; i < classes.length; i++) { - out.push('.' + classes[i]); - } - } - var attrWhitelist = ['type', 'name', 'title', 'alt']; - for (i = 0; i < attrWhitelist.length; i++) { - key = attrWhitelist[i]; - attr = elem.getAttribute(key); - if (attr) { - out.push('[' + key + '="' + attr + '"]'); - } - } - return out.join(''); -} + /** + * Returns true if either a OR b is truthy, but not both + */ + function isOnlyOneTruthy(a, b) { + return !!(!!a ^ !!b); + } -/** - * Returns true if either a OR b is truthy, but not both - */ -function isOnlyOneTruthy(a, b) { - return !!(!!a ^ !!b); -} + /** + * Returns true if the two input exception interfaces have the same content + */ + function isSameException(ex1, ex2) { + if (isOnlyOneTruthy(ex1, ex2)) return false; -/** - * Returns true if the two input exception interfaces have the same content - */ -function isSameException(ex1, ex2) { - if (isOnlyOneTruthy(ex1, ex2)) return false; + ex1 = ex1.values[0]; + ex2 = ex2.values[0]; - ex1 = ex1.values[0]; - ex2 = ex2.values[0]; + if (ex1.type !== ex2.type || ex1.value !== ex2.value) + return false; - if (ex1.type !== ex2.type || ex1.value !== ex2.value) return false; + return isSameStacktrace(ex1.stacktrace, ex2.stacktrace); + } - return isSameStacktrace(ex1.stacktrace, ex2.stacktrace); -} + /** + * Returns true if the two input stack trace interfaces have the same content + */ + function isSameStacktrace(stack1, stack2) { + if (isOnlyOneTruthy(stack1, stack2)) return false; + + var frames1 = stack1.frames; + var frames2 = stack2.frames; + + // Exit early if frame count differs + if (frames1.length !== frames2.length) return false; + + // Iterate through every frame; bail out if anything differs + var a, b; + for (var i = 0; i < frames1.length; i++) { + a = frames1[i]; + b = frames2[i]; + if ( + a.filename !== b.filename || + a.lineno !== b.lineno || + a.colno !== b.colno || + a["function"] !== b["function"] + ) + return false; + } + return true; + } -/** - * Returns true if the two input stack trace interfaces have the same content - */ -function isSameStacktrace(stack1, stack2) { - if (isOnlyOneTruthy(stack1, stack2)) return false; - - var frames1 = stack1.frames; - var frames2 = stack2.frames; - - // Exit early if frame count differs - if (frames1.length !== frames2.length) return false; - - // Iterate through every frame; bail out if anything differs - var a, b; - for (var i = 0; i < frames1.length; i++) { - a = frames1[i]; - b = frames2[i]; - if ( - a.filename !== b.filename || - a.lineno !== b.lineno || - a.colno !== b.colno || - a['function'] !== b['function'] - ) - return false; - } - return true; -} - -/** - * Polyfill a method - * @param obj object e.g. `document` - * @param name method name present on object e.g. `addEventListener` - * @param replacement replacement function - * @param track {optional} record instrumentation to an array - */ -function fill(obj, name, replacement, track) { - var orig = obj[name]; - obj[name] = replacement(orig); - obj[name].__raven__ = true; - obj[name].__orig__ = orig; - if (track) { - track.push([obj, name, orig]); - } -} - -module.exports = { - isObject: isObject, - isError: isError, - isErrorEvent: isErrorEvent, - isUndefined: isUndefined, - isFunction: isFunction, - isString: isString, - isArray: isArray, - isEmptyObject: isEmptyObject, - supportsErrorEvent: supportsErrorEvent, - wrappedCallback: wrappedCallback, - each: each, - objectMerge: objectMerge, - truncate: truncate, - objectFrozen: objectFrozen, - hasKey: hasKey, - joinRegExp: joinRegExp, - urlencode: urlencode, - uuid4: uuid4, - htmlTreeAsString: htmlTreeAsString, - htmlElementAsString: htmlElementAsString, - isSameException: isSameException, - isSameStacktrace: isSameStacktrace, - parseUrl: parseUrl, - fill: fill -}; - -}).call(this,typeof global !== "undefined" ? global : typeof self !== "undefined" ? self : typeof window !== "undefined" ? window : {}) -},{}],6:[function(_dereq_,module,exports){ -(function (global){ -var utils = _dereq_(5); + /** + * Polyfill a method + * @param obj object e.g. `document` + * @param name method name present on object e.g. `addEventListener` + * @param replacement replacement function + * @param track {optional} record instrumentation to an array + */ + function fill(obj, name, replacement, track) { + var orig = obj[name]; + obj[name] = replacement(orig); + obj[name].__raven__ = true; + obj[name].__orig__ = orig; + if (track) { + track.push([obj, name, orig]); + } + } -/* + module.exports = { + isObject: isObject, + isError: isError, + isErrorEvent: isErrorEvent, + isUndefined: isUndefined, + isFunction: isFunction, + isString: isString, + isArray: isArray, + isEmptyObject: isEmptyObject, + supportsErrorEvent: supportsErrorEvent, + wrappedCallback: wrappedCallback, + each: each, + objectMerge: objectMerge, + truncate: truncate, + objectFrozen: objectFrozen, + hasKey: hasKey, + joinRegExp: joinRegExp, + urlencode: urlencode, + uuid4: uuid4, + htmlTreeAsString: htmlTreeAsString, + htmlElementAsString: htmlElementAsString, + isSameException: isSameException, + isSameStacktrace: isSameStacktrace, + parseUrl: parseUrl, + fill: fill, + }; + }).call( + this, + typeof global !== "undefined" + ? global + : typeof self !== "undefined" + ? self + : typeof window !== "undefined" + ? window + : {}, + ); + }, + {}, + ], + 6: [ + function (_dereq_, module, exports) { + (function (global) { + var utils = _dereq_(5); + + /* TraceKit - Cross brower stack traces This was originally forked from github.com/occ/TraceKit, but has since been @@ -2470,621 +2789,666 @@ var utils = _dereq_(5); MIT license */ -var TraceKit = { - collectWindowErrors: true, - debug: false -}; - -// This is to be defensive in environments where window does not exist (see https://github.com/getsentry/raven-js/pull/785) -var _window = - typeof window !== 'undefined' - ? window - : typeof global !== 'undefined' ? global : typeof self !== 'undefined' ? self : {}; - -// global reference to slice -var _slice = [].slice; -var UNKNOWN_FUNCTION = '?'; - -// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error#Error_types -var ERROR_TYPES_RE = /^(?:[Uu]ncaught (?:exception: )?)?(?:((?:Eval|Internal|Range|Reference|Syntax|Type|URI|)Error): )?(.*)$/; - -function getLocationHref() { - if (typeof document === 'undefined' || document.location == null) return ''; - - return document.location.href; -} - -/** - * TraceKit.report: cross-browser processing of unhandled exceptions - * - * Syntax: - * TraceKit.report.subscribe(function(stackInfo) { ... }) - * TraceKit.report.unsubscribe(function(stackInfo) { ... }) - * TraceKit.report(exception) - * try { ...code... } catch(ex) { TraceKit.report(ex); } - * - * Supports: - * - Firefox: full stack trace with line numbers, plus column number - * on top frame; column number is not guaranteed - * - Opera: full stack trace with line and column numbers - * - Chrome: full stack trace with line and column numbers - * - Safari: line and column number for the top frame only; some frames - * may be missing, and column number is not guaranteed - * - IE: line and column number for the top frame only; some frames - * may be missing, and column number is not guaranteed - * - * In theory, TraceKit should work on all of the following versions: - * - IE5.5+ (only 8.0 tested) - * - Firefox 0.9+ (only 3.5+ tested) - * - Opera 7+ (only 10.50 tested; versions 9 and earlier may require - * Exceptions Have Stacktrace to be enabled in opera:config) - * - Safari 3+ (only 4+ tested) - * - Chrome 1+ (only 5+ tested) - * - Konqueror 3.5+ (untested) - * - * Requires TraceKit.computeStackTrace. - * - * Tries to catch all unhandled exceptions and report them to the - * subscribed handlers. Please note that TraceKit.report will rethrow the - * exception. This is REQUIRED in order to get a useful stack trace in IE. - * If the exception does not reach the top of the browser, you will only - * get a stack trace from the point where TraceKit.report was called. - * - * Handlers receive a stackInfo object as described in the - * TraceKit.computeStackTrace docs. - */ -TraceKit.report = (function reportModuleWrapper() { - var handlers = [], - lastArgs = null, - lastException = null, - lastExceptionStack = null; - - /** - * Add a crash handler. - * @param {Function} handler - */ - function subscribe(handler) { - installGlobalHandler(); - handlers.push(handler); - } + var TraceKit = { + collectWindowErrors: true, + debug: false, + }; - /** - * Remove a crash handler. - * @param {Function} handler - */ - function unsubscribe(handler) { - for (var i = handlers.length - 1; i >= 0; --i) { - if (handlers[i] === handler) { - handlers.splice(i, 1); - } - } - } + // This is to be defensive in environments where window does not exist (see https://github.com/getsentry/raven-js/pull/785) + var _window = + typeof window !== "undefined" + ? window + : typeof global !== "undefined" + ? global + : typeof self !== "undefined" + ? self + : {}; + + // global reference to slice + var _slice = [].slice; + var UNKNOWN_FUNCTION = "?"; + + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error#Error_types + var ERROR_TYPES_RE = + /^(?:[Uu]ncaught (?:exception: )?)?(?:((?:Eval|Internal|Range|Reference|Syntax|Type|URI|)Error): )?(.*)$/; + + function getLocationHref() { + if (typeof document === "undefined" || document.location == null) + return ""; + + return document.location.href; + } - /** - * Remove all crash handlers. - */ - function unsubscribeAll() { - uninstallGlobalHandler(); - handlers = []; - } + /** + * TraceKit.report: cross-browser processing of unhandled exceptions + * + * Syntax: + * TraceKit.report.subscribe(function(stackInfo) { ... }) + * TraceKit.report.unsubscribe(function(stackInfo) { ... }) + * TraceKit.report(exception) + * try { ...code... } catch(ex) { TraceKit.report(ex); } + * + * Supports: + * - Firefox: full stack trace with line numbers, plus column number + * on top frame; column number is not guaranteed + * - Opera: full stack trace with line and column numbers + * - Chrome: full stack trace with line and column numbers + * - Safari: line and column number for the top frame only; some frames + * may be missing, and column number is not guaranteed + * - IE: line and column number for the top frame only; some frames + * may be missing, and column number is not guaranteed + * + * In theory, TraceKit should work on all of the following versions: + * - IE5.5+ (only 8.0 tested) + * - Firefox 0.9+ (only 3.5+ tested) + * - Opera 7+ (only 10.50 tested; versions 9 and earlier may require + * Exceptions Have Stacktrace to be enabled in opera:config) + * - Safari 3+ (only 4+ tested) + * - Chrome 1+ (only 5+ tested) + * - Konqueror 3.5+ (untested) + * + * Requires TraceKit.computeStackTrace. + * + * Tries to catch all unhandled exceptions and report them to the + * subscribed handlers. Please note that TraceKit.report will rethrow the + * exception. This is REQUIRED in order to get a useful stack trace in IE. + * If the exception does not reach the top of the browser, you will only + * get a stack trace from the point where TraceKit.report was called. + * + * Handlers receive a stackInfo object as described in the + * TraceKit.computeStackTrace docs. + */ + TraceKit.report = (function reportModuleWrapper() { + var handlers = [], + lastArgs = null, + lastException = null, + lastExceptionStack = null; + + /** + * Add a crash handler. + * @param {Function} handler + */ + function subscribe(handler) { + installGlobalHandler(); + handlers.push(handler); + } - /** - * Dispatch stack information to all handlers. - * @param {Object.} stack - */ - function notifyHandlers(stack, isWindowError) { - var exception = null; - if (isWindowError && !TraceKit.collectWindowErrors) { - return; - } - for (var i in handlers) { - if (handlers.hasOwnProperty(i)) { - try { - handlers[i].apply(null, [stack].concat(_slice.call(arguments, 2))); - } catch (inner) { - exception = inner; - } - } - } + /** + * Remove a crash handler. + * @param {Function} handler + */ + function unsubscribe(handler) { + for (var i = handlers.length - 1; i >= 0; --i) { + if (handlers[i] === handler) { + handlers.splice(i, 1); + } + } + } - if (exception) { - throw exception; - } - } + /** + * Remove all crash handlers. + */ + function unsubscribeAll() { + uninstallGlobalHandler(); + handlers = []; + } - var _oldOnerrorHandler, _onErrorHandlerInstalled; - - /** - * Ensures all global unhandled exceptions are recorded. - * Supported by Gecko and IE. - * @param {string} message Error message. - * @param {string} url URL of script that generated the exception. - * @param {(number|string)} lineNo The line number at which the error - * occurred. - * @param {?(number|string)} colNo The column number at which the error - * occurred. - * @param {?Error} ex The actual Error object. - */ - function traceKitWindowOnError(message, url, lineNo, colNo, ex) { - var stack = null; - - if (lastExceptionStack) { - TraceKit.computeStackTrace.augmentStackTraceWithInitialElement( - lastExceptionStack, - url, - lineNo, - message - ); - processLastException(); - } else if (ex && utils.isError(ex)) { - // non-string `ex` arg; attempt to extract stack trace - - // New chrome and blink send along a real error object - // Let's just report that like a normal error. - // See: https://mikewest.org/2013/08/debugging-runtime-errors-with-window-onerror - stack = TraceKit.computeStackTrace(ex); - notifyHandlers(stack, true); - } else { - var location = { - url: url, - line: lineNo, - column: colNo - }; - - var name = undefined; - var msg = message; // must be new var or will modify original `arguments` - var groups; - if ({}.toString.call(message) === '[object String]') { - var groups = message.match(ERROR_TYPES_RE); - if (groups) { - name = groups[1]; - msg = groups[2]; - } - } + /** + * Dispatch stack information to all handlers. + * @param {Object.} stack + */ + function notifyHandlers(stack, isWindowError) { + var exception = null; + if (isWindowError && !TraceKit.collectWindowErrors) { + return; + } + for (var i in handlers) { + if (handlers.hasOwnProperty(i)) { + try { + handlers[i].apply( + null, + [stack].concat(_slice.call(arguments, 2)), + ); + } catch (inner) { + exception = inner; + } + } + } - location.func = UNKNOWN_FUNCTION; + if (exception) { + throw exception; + } + } - stack = { - name: name, - message: msg, - url: getLocationHref(), - stack: [location] - }; - notifyHandlers(stack, true); - } + var _oldOnerrorHandler, _onErrorHandlerInstalled; + + /** + * Ensures all global unhandled exceptions are recorded. + * Supported by Gecko and IE. + * @param {string} message Error message. + * @param {string} url URL of script that generated the exception. + * @param {(number|string)} lineNo The line number at which the error + * occurred. + * @param {?(number|string)} colNo The column number at which the error + * occurred. + * @param {?Error} ex The actual Error object. + */ + function traceKitWindowOnError(message, url, lineNo, colNo, ex) { + var stack = null; + + if (lastExceptionStack) { + TraceKit.computeStackTrace.augmentStackTraceWithInitialElement( + lastExceptionStack, + url, + lineNo, + message, + ); + processLastException(); + } else if (ex && utils.isError(ex)) { + // non-string `ex` arg; attempt to extract stack trace + + // New chrome and blink send along a real error object + // Let's just report that like a normal error. + // See: https://mikewest.org/2013/08/debugging-runtime-errors-with-window-onerror + stack = TraceKit.computeStackTrace(ex); + notifyHandlers(stack, true); + } else { + var location = { + url: url, + line: lineNo, + column: colNo, + }; + + var name = undefined; + var msg = message; // must be new var or will modify original `arguments` + var groups; + if ({}.toString.call(message) === "[object String]") { + var groups = message.match(ERROR_TYPES_RE); + if (groups) { + name = groups[1]; + msg = groups[2]; + } + } - if (_oldOnerrorHandler) { - return _oldOnerrorHandler.apply(this, arguments); - } + location.func = UNKNOWN_FUNCTION; - return false; - } + stack = { + name: name, + message: msg, + url: getLocationHref(), + stack: [location], + }; + notifyHandlers(stack, true); + } - function installGlobalHandler() { - if (_onErrorHandlerInstalled) { - return; - } - _oldOnerrorHandler = _window.onerror; - _window.onerror = traceKitWindowOnError; - _onErrorHandlerInstalled = true; - } + if (_oldOnerrorHandler) { + return _oldOnerrorHandler.apply(this, arguments); + } - function uninstallGlobalHandler() { - if (!_onErrorHandlerInstalled) { - return; - } - _window.onerror = _oldOnerrorHandler; - _onErrorHandlerInstalled = false; - _oldOnerrorHandler = undefined; - } + return false; + } - function processLastException() { - var _lastExceptionStack = lastExceptionStack, - _lastArgs = lastArgs; - lastArgs = null; - lastExceptionStack = null; - lastException = null; - notifyHandlers.apply(null, [_lastExceptionStack, false].concat(_lastArgs)); - } + function installGlobalHandler() { + if (_onErrorHandlerInstalled) { + return; + } + _oldOnerrorHandler = _window.onerror; + _window.onerror = traceKitWindowOnError; + _onErrorHandlerInstalled = true; + } - /** - * Reports an unhandled Error to TraceKit. - * @param {Error} ex - * @param {?boolean} rethrow If false, do not re-throw the exception. - * Only used for window.onerror to not cause an infinite loop of - * rethrowing. - */ - function report(ex, rethrow) { - var args = _slice.call(arguments, 1); - if (lastExceptionStack) { - if (lastException === ex) { - return; // already caught by an inner catch block, ignore - } else { - processLastException(); - } - } + function uninstallGlobalHandler() { + if (!_onErrorHandlerInstalled) { + return; + } + _window.onerror = _oldOnerrorHandler; + _onErrorHandlerInstalled = false; + _oldOnerrorHandler = undefined; + } - var stack = TraceKit.computeStackTrace(ex); - lastExceptionStack = stack; - lastException = ex; - lastArgs = args; - - // If the stack trace is incomplete, wait for 2 seconds for - // slow slow IE to see if onerror occurs or not before reporting - // this exception; otherwise, we will end up with an incomplete - // stack trace - setTimeout(function() { - if (lastException === ex) { - processLastException(); - } - }, stack.incomplete ? 2000 : 0); + function processLastException() { + var _lastExceptionStack = lastExceptionStack, + _lastArgs = lastArgs; + lastArgs = null; + lastExceptionStack = null; + lastException = null; + notifyHandlers.apply( + null, + [_lastExceptionStack, false].concat(_lastArgs), + ); + } - if (rethrow !== false) { - throw ex; // re-throw to propagate to the top level (and cause window.onerror) - } - } + /** + * Reports an unhandled Error to TraceKit. + * @param {Error} ex + * @param {?boolean} rethrow If false, do not re-throw the exception. + * Only used for window.onerror to not cause an infinite loop of + * rethrowing. + */ + function report(ex, rethrow) { + var args = _slice.call(arguments, 1); + if (lastExceptionStack) { + if (lastException === ex) { + return; // already caught by an inner catch block, ignore + } else { + processLastException(); + } + } - report.subscribe = subscribe; - report.unsubscribe = unsubscribe; - report.uninstall = unsubscribeAll; - return report; -})(); + var stack = TraceKit.computeStackTrace(ex); + lastExceptionStack = stack; + lastException = ex; + lastArgs = args; + + // If the stack trace is incomplete, wait for 2 seconds for + // slow slow IE to see if onerror occurs or not before reporting + // this exception; otherwise, we will end up with an incomplete + // stack trace + setTimeout( + function () { + if (lastException === ex) { + processLastException(); + } + }, + stack.incomplete ? 2000 : 0, + ); + + if (rethrow !== false) { + throw ex; // re-throw to propagate to the top level (and cause window.onerror) + } + } -/** - * TraceKit.computeStackTrace: cross-browser stack traces in JavaScript - * - * Syntax: - * s = TraceKit.computeStackTrace(exception) // consider using TraceKit.report instead (see below) - * Returns: - * s.name - exception name - * s.message - exception message - * s.stack[i].url - JavaScript or HTML file URL - * s.stack[i].func - function name, or empty for anonymous functions (if guessing did not work) - * s.stack[i].args - arguments passed to the function, if known - * s.stack[i].line - line number, if known - * s.stack[i].column - column number, if known - * - * Supports: - * - Firefox: full stack trace with line numbers and unreliable column - * number on top frame - * - Opera 10: full stack trace with line and column numbers - * - Opera 9-: full stack trace with line numbers - * - Chrome: full stack trace with line and column numbers - * - Safari: line and column number for the topmost stacktrace element - * only - * - IE: no line numbers whatsoever - * - * Tries to guess names of anonymous functions by looking for assignments - * in the source code. In IE and Safari, we have to guess source file names - * by searching for function bodies inside all page scripts. This will not - * work for scripts that are loaded cross-domain. - * Here be dragons: some function names may be guessed incorrectly, and - * duplicate functions may be mismatched. - * - * TraceKit.computeStackTrace should only be used for tracing purposes. - * Logging of unhandled exceptions should be done with TraceKit.report, - * which builds on top of TraceKit.computeStackTrace and provides better - * IE support by utilizing the window.onerror event to retrieve information - * about the top of the stack. - * - * Note: In IE and Safari, no stack trace is recorded on the Error object, - * so computeStackTrace instead walks its *own* chain of callers. - * This means that: - * * in Safari, some methods may be missing from the stack trace; - * * in IE, the topmost function in the stack trace will always be the - * caller of computeStackTrace. - * - * This is okay for tracing (because you are likely to be calling - * computeStackTrace from the function you want to be the topmost element - * of the stack trace anyway), but not okay for logging unhandled - * exceptions (because your catch block will likely be far away from the - * inner function that actually caused the exception). - * - */ -TraceKit.computeStackTrace = (function computeStackTraceWrapper() { - // Contents of Exception in various browsers. - // - // SAFARI: - // ex.message = Can't find variable: qq - // ex.line = 59 - // ex.sourceId = 580238192 - // ex.sourceURL = http://... - // ex.expressionBeginOffset = 96 - // ex.expressionCaretOffset = 98 - // ex.expressionEndOffset = 98 - // ex.name = ReferenceError - // - // FIREFOX: - // ex.message = qq is not defined - // ex.fileName = http://... - // ex.lineNumber = 59 - // ex.columnNumber = 69 - // ex.stack = ...stack trace... (see the example below) - // ex.name = ReferenceError - // - // CHROME: - // ex.message = qq is not defined - // ex.name = ReferenceError - // ex.type = not_defined - // ex.arguments = ['aa'] - // ex.stack = ...stack trace... - // - // INTERNET EXPLORER: - // ex.message = ... - // ex.name = ReferenceError - // - // OPERA: - // ex.message = ...message... (see the example below) - // ex.name = ReferenceError - // ex.opera#sourceloc = 11 (pretty much useless, duplicates the info in ex.message) - // ex.stacktrace = n/a; see 'opera:config#UserPrefs|Exceptions Have Stacktrace' - - /** - * Computes stack trace information from the stack property. - * Chrome and Gecko use this property. - * @param {Error} ex - * @return {?Object.} Stack trace information. - */ - function computeStackTraceFromStackProp(ex) { - if (typeof ex.stack === 'undefined' || !ex.stack) return; - - var chrome = /^\s*at (.*?) ?\(((?:file|https?|blob|chrome-extension|native|eval|webpack||[a-z]:|\/).*?)(?::(\d+))?(?::(\d+))?\)?\s*$/i, - gecko = /^\s*(.*?)(?:\((.*?)\))?(?:^|@)((?:file|https?|blob|chrome|webpack|resource|\[native).*?|[^@]*bundle)(?::(\d+))?(?::(\d+))?\s*$/i, - winjs = /^\s*at (?:((?:\[object object\])?.+) )?\(?((?:file|ms-appx|https?|webpack|blob):.*?):(\d+)(?::(\d+))?\)?\s*$/i, - // Used to additionally parse URL/line/column from eval frames - geckoEval = /(\S+) line (\d+)(?: > eval line \d+)* > eval/i, - chromeEval = /\((\S*)(?::(\d+))(?::(\d+))\)/, - lines = ex.stack.split('\n'), - stack = [], - submatch, - parts, - element, - reference = /^(.*) is undefined$/.exec(ex.message); - - for (var i = 0, j = lines.length; i < j; ++i) { - if ((parts = chrome.exec(lines[i]))) { - var isNative = parts[2] && parts[2].indexOf('native') === 0; // start of line - var isEval = parts[2] && parts[2].indexOf('eval') === 0; // start of line - if (isEval && (submatch = chromeEval.exec(parts[2]))) { - // throw out eval line/column and use top-most line/column number - parts[2] = submatch[1]; // url - parts[3] = submatch[2]; // line - parts[4] = submatch[3]; // column - } - element = { - url: !isNative ? parts[2] : null, - func: parts[1] || UNKNOWN_FUNCTION, - args: isNative ? [parts[2]] : [], - line: parts[3] ? +parts[3] : null, - column: parts[4] ? +parts[4] : null - }; - } else if ((parts = winjs.exec(lines[i]))) { - element = { - url: parts[2], - func: parts[1] || UNKNOWN_FUNCTION, - args: [], - line: +parts[3], - column: parts[4] ? +parts[4] : null - }; - } else if ((parts = gecko.exec(lines[i]))) { - var isEval = parts[3] && parts[3].indexOf(' > eval') > -1; - if (isEval && (submatch = geckoEval.exec(parts[3]))) { - // throw out eval line/column and use top-most line number - parts[3] = submatch[1]; - parts[4] = submatch[2]; - parts[5] = null; // no column when eval - } else if (i === 0 && !parts[5] && typeof ex.columnNumber !== 'undefined') { - // FireFox uses this awesome columnNumber property for its top frame - // Also note, Firefox's column number is 0-based and everything else expects 1-based, - // so adding 1 - // NOTE: this hack doesn't work if top-most frame is eval - stack[0].column = ex.columnNumber + 1; - } - element = { - url: parts[3], - func: parts[1] || UNKNOWN_FUNCTION, - args: parts[2] ? parts[2].split(',') : [], - line: parts[4] ? +parts[4] : null, - column: parts[5] ? +parts[5] : null - }; - } else { - continue; - } + report.subscribe = subscribe; + report.unsubscribe = unsubscribe; + report.uninstall = unsubscribeAll; + return report; + })(); + + /** + * TraceKit.computeStackTrace: cross-browser stack traces in JavaScript + * + * Syntax: + * s = TraceKit.computeStackTrace(exception) // consider using TraceKit.report instead (see below) + * Returns: + * s.name - exception name + * s.message - exception message + * s.stack[i].url - JavaScript or HTML file URL + * s.stack[i].func - function name, or empty for anonymous functions (if guessing did not work) + * s.stack[i].args - arguments passed to the function, if known + * s.stack[i].line - line number, if known + * s.stack[i].column - column number, if known + * + * Supports: + * - Firefox: full stack trace with line numbers and unreliable column + * number on top frame + * - Opera 10: full stack trace with line and column numbers + * - Opera 9-: full stack trace with line numbers + * - Chrome: full stack trace with line and column numbers + * - Safari: line and column number for the topmost stacktrace element + * only + * - IE: no line numbers whatsoever + * + * Tries to guess names of anonymous functions by looking for assignments + * in the source code. In IE and Safari, we have to guess source file names + * by searching for function bodies inside all page scripts. This will not + * work for scripts that are loaded cross-domain. + * Here be dragons: some function names may be guessed incorrectly, and + * duplicate functions may be mismatched. + * + * TraceKit.computeStackTrace should only be used for tracing purposes. + * Logging of unhandled exceptions should be done with TraceKit.report, + * which builds on top of TraceKit.computeStackTrace and provides better + * IE support by utilizing the window.onerror event to retrieve information + * about the top of the stack. + * + * Note: In IE and Safari, no stack trace is recorded on the Error object, + * so computeStackTrace instead walks its *own* chain of callers. + * This means that: + * * in Safari, some methods may be missing from the stack trace; + * * in IE, the topmost function in the stack trace will always be the + * caller of computeStackTrace. + * + * This is okay for tracing (because you are likely to be calling + * computeStackTrace from the function you want to be the topmost element + * of the stack trace anyway), but not okay for logging unhandled + * exceptions (because your catch block will likely be far away from the + * inner function that actually caused the exception). + * + */ + TraceKit.computeStackTrace = (function computeStackTraceWrapper() { + // Contents of Exception in various browsers. + // + // SAFARI: + // ex.message = Can't find variable: qq + // ex.line = 59 + // ex.sourceId = 580238192 + // ex.sourceURL = http://... + // ex.expressionBeginOffset = 96 + // ex.expressionCaretOffset = 98 + // ex.expressionEndOffset = 98 + // ex.name = ReferenceError + // + // FIREFOX: + // ex.message = qq is not defined + // ex.fileName = http://... + // ex.lineNumber = 59 + // ex.columnNumber = 69 + // ex.stack = ...stack trace... (see the example below) + // ex.name = ReferenceError + // + // CHROME: + // ex.message = qq is not defined + // ex.name = ReferenceError + // ex.type = not_defined + // ex.arguments = ['aa'] + // ex.stack = ...stack trace... + // + // INTERNET EXPLORER: + // ex.message = ... + // ex.name = ReferenceError + // + // OPERA: + // ex.message = ...message... (see the example below) + // ex.name = ReferenceError + // ex.opera#sourceloc = 11 (pretty much useless, duplicates the info in ex.message) + // ex.stacktrace = n/a; see 'opera:config#UserPrefs|Exceptions Have Stacktrace' + + /** + * Computes stack trace information from the stack property. + * Chrome and Gecko use this property. + * @param {Error} ex + * @return {?Object.} Stack trace information. + */ + function computeStackTraceFromStackProp(ex) { + if (typeof ex.stack === "undefined" || !ex.stack) return; + + var chrome = + /^\s*at (.*?) ?\(((?:file|https?|blob|chrome-extension|native|eval|webpack||[a-z]:|\/).*?)(?::(\d+))?(?::(\d+))?\)?\s*$/i, + gecko = + /^\s*(.*?)(?:\((.*?)\))?(?:^|@)((?:file|https?|blob|chrome|webpack|resource|\[native).*?|[^@]*bundle)(?::(\d+))?(?::(\d+))?\s*$/i, + winjs = + /^\s*at (?:((?:\[object object\])?.+) )?\(?((?:file|ms-appx|https?|webpack|blob):.*?):(\d+)(?::(\d+))?\)?\s*$/i, + // Used to additionally parse URL/line/column from eval frames + geckoEval = /(\S+) line (\d+)(?: > eval line \d+)* > eval/i, + chromeEval = /\((\S*)(?::(\d+))(?::(\d+))\)/, + lines = ex.stack.split("\n"), + stack = [], + submatch, + parts, + element, + reference = /^(.*) is undefined$/.exec(ex.message); + + for (var i = 0, j = lines.length; i < j; ++i) { + if ((parts = chrome.exec(lines[i]))) { + var isNative = parts[2] && parts[2].indexOf("native") === 0; // start of line + var isEval = parts[2] && parts[2].indexOf("eval") === 0; // start of line + if (isEval && (submatch = chromeEval.exec(parts[2]))) { + // throw out eval line/column and use top-most line/column number + parts[2] = submatch[1]; // url + parts[3] = submatch[2]; // line + parts[4] = submatch[3]; // column + } + element = { + url: !isNative ? parts[2] : null, + func: parts[1] || UNKNOWN_FUNCTION, + args: isNative ? [parts[2]] : [], + line: parts[3] ? +parts[3] : null, + column: parts[4] ? +parts[4] : null, + }; + } else if ((parts = winjs.exec(lines[i]))) { + element = { + url: parts[2], + func: parts[1] || UNKNOWN_FUNCTION, + args: [], + line: +parts[3], + column: parts[4] ? +parts[4] : null, + }; + } else if ((parts = gecko.exec(lines[i]))) { + var isEval = parts[3] && parts[3].indexOf(" > eval") > -1; + if (isEval && (submatch = geckoEval.exec(parts[3]))) { + // throw out eval line/column and use top-most line number + parts[3] = submatch[1]; + parts[4] = submatch[2]; + parts[5] = null; // no column when eval + } else if ( + i === 0 && + !parts[5] && + typeof ex.columnNumber !== "undefined" + ) { + // FireFox uses this awesome columnNumber property for its top frame + // Also note, Firefox's column number is 0-based and everything else expects 1-based, + // so adding 1 + // NOTE: this hack doesn't work if top-most frame is eval + stack[0].column = ex.columnNumber + 1; + } + element = { + url: parts[3], + func: parts[1] || UNKNOWN_FUNCTION, + args: parts[2] ? parts[2].split(",") : [], + line: parts[4] ? +parts[4] : null, + column: parts[5] ? +parts[5] : null, + }; + } else { + continue; + } - if (!element.func && element.line) { - element.func = UNKNOWN_FUNCTION; - } + if (!element.func && element.line) { + element.func = UNKNOWN_FUNCTION; + } - stack.push(element); - } + stack.push(element); + } - if (!stack.length) { - return null; - } + if (!stack.length) { + return null; + } - return { - name: ex.name, - message: ex.message, - url: getLocationHref(), - stack: stack - }; - } + return { + name: ex.name, + message: ex.message, + url: getLocationHref(), + stack: stack, + }; + } - /** - * Adds information about the first frame to incomplete stack traces. - * Safari and IE require this to get complete data on the first frame. - * @param {Object.} stackInfo Stack trace information from - * one of the compute* methods. - * @param {string} url The URL of the script that caused an error. - * @param {(number|string)} lineNo The line number of the script that - * caused an error. - * @param {string=} message The error generated by the browser, which - * hopefully contains the name of the object that caused the error. - * @return {boolean} Whether or not the stack information was - * augmented. - */ - function augmentStackTraceWithInitialElement(stackInfo, url, lineNo, message) { - var initial = { - url: url, - line: lineNo - }; - - if (initial.url && initial.line) { - stackInfo.incomplete = false; - - if (!initial.func) { - initial.func = UNKNOWN_FUNCTION; - } + /** + * Adds information about the first frame to incomplete stack traces. + * Safari and IE require this to get complete data on the first frame. + * @param {Object.} stackInfo Stack trace information from + * one of the compute* methods. + * @param {string} url The URL of the script that caused an error. + * @param {(number|string)} lineNo The line number of the script that + * caused an error. + * @param {string=} message The error generated by the browser, which + * hopefully contains the name of the object that caused the error. + * @return {boolean} Whether or not the stack information was + * augmented. + */ + function augmentStackTraceWithInitialElement( + stackInfo, + url, + lineNo, + message, + ) { + var initial = { + url: url, + line: lineNo, + }; - if (stackInfo.stack.length > 0) { - if (stackInfo.stack[0].url === initial.url) { - if (stackInfo.stack[0].line === initial.line) { - return false; // already in stack trace - } else if ( - !stackInfo.stack[0].line && - stackInfo.stack[0].func === initial.func - ) { - stackInfo.stack[0].line = initial.line; - return false; - } - } - } + if (initial.url && initial.line) { + stackInfo.incomplete = false; - stackInfo.stack.unshift(initial); - stackInfo.partial = true; - return true; - } else { - stackInfo.incomplete = true; - } + if (!initial.func) { + initial.func = UNKNOWN_FUNCTION; + } - return false; - } + if (stackInfo.stack.length > 0) { + if (stackInfo.stack[0].url === initial.url) { + if (stackInfo.stack[0].line === initial.line) { + return false; // already in stack trace + } else if ( + !stackInfo.stack[0].line && + stackInfo.stack[0].func === initial.func + ) { + stackInfo.stack[0].line = initial.line; + return false; + } + } + } - /** - * Computes stack trace information by walking the arguments.caller - * chain at the time the exception occurred. This will cause earlier - * frames to be missed but is the only way to get any stack trace in - * Safari and IE. The top frame is restored by - * {@link augmentStackTraceWithInitialElement}. - * @param {Error} ex - * @return {?Object.} Stack trace information. - */ - function computeStackTraceByWalkingCallerChain(ex, depth) { - var functionName = /function\s+([_$a-zA-Z\xA0-\uFFFF][_$a-zA-Z0-9\xA0-\uFFFF]*)?\s*\(/i, - stack = [], - funcs = {}, - recursion = false, - parts, - item, - source; - - for ( - var curr = computeStackTraceByWalkingCallerChain.caller; - curr && !recursion; - curr = curr.caller - ) { - if (curr === computeStackTrace || curr === TraceKit.report) { - // console.log('skipping internal function'); - continue; - } + stackInfo.stack.unshift(initial); + stackInfo.partial = true; + return true; + } else { + stackInfo.incomplete = true; + } - item = { - url: null, - func: UNKNOWN_FUNCTION, - line: null, - column: null - }; - - if (curr.name) { - item.func = curr.name; - } else if ((parts = functionName.exec(curr.toString()))) { - item.func = parts[1]; - } + return false; + } - if (typeof item.func === 'undefined') { - try { - item.func = parts.input.substring(0, parts.input.indexOf('{')); - } catch (e) {} - } + /** + * Computes stack trace information by walking the arguments.caller + * chain at the time the exception occurred. This will cause earlier + * frames to be missed but is the only way to get any stack trace in + * Safari and IE. The top frame is restored by + * {@link augmentStackTraceWithInitialElement}. + * @param {Error} ex + * @return {?Object.} Stack trace information. + */ + function computeStackTraceByWalkingCallerChain(ex, depth) { + var functionName = + /function\s+([_$a-zA-Z\xA0-\uFFFF][_$a-zA-Z0-9\xA0-\uFFFF]*)?\s*\(/i, + stack = [], + funcs = {}, + recursion = false, + parts, + item, + source; + + for ( + var curr = computeStackTraceByWalkingCallerChain.caller; + curr && !recursion; + curr = curr.caller + ) { + if (curr === computeStackTrace || curr === TraceKit.report) { + // console.log('skipping internal function'); + continue; + } - if (funcs['' + curr]) { - recursion = true; - } else { - funcs['' + curr] = true; - } + item = { + url: null, + func: UNKNOWN_FUNCTION, + line: null, + column: null, + }; + + if (curr.name) { + item.func = curr.name; + } else if ((parts = functionName.exec(curr.toString()))) { + item.func = parts[1]; + } - stack.push(item); - } + if (typeof item.func === "undefined") { + try { + item.func = parts.input.substring( + 0, + parts.input.indexOf("{"), + ); + } catch (e) {} + } - if (depth) { - // console.log('depth is ' + depth); - // console.log('stack is ' + stack.length); - stack.splice(0, depth); - } + if (funcs["" + curr]) { + recursion = true; + } else { + funcs["" + curr] = true; + } - var result = { - name: ex.name, - message: ex.message, - url: getLocationHref(), - stack: stack - }; - augmentStackTraceWithInitialElement( - result, - ex.sourceURL || ex.fileName, - ex.line || ex.lineNumber, - ex.message || ex.description - ); - return result; - } + stack.push(item); + } - /** - * Computes a stack trace for an exception. - * @param {Error} ex - * @param {(string|number)=} depth - */ - function computeStackTrace(ex, depth) { - var stack = null; - depth = depth == null ? 0 : +depth; - - try { - stack = computeStackTraceFromStackProp(ex); - if (stack) { - return stack; - } - } catch (e) { - if (TraceKit.debug) { - throw e; - } - } + if (depth) { + // console.log('depth is ' + depth); + // console.log('stack is ' + stack.length); + stack.splice(0, depth); + } - try { - stack = computeStackTraceByWalkingCallerChain(ex, depth + 1); - if (stack) { - return stack; - } - } catch (e) { - if (TraceKit.debug) { - throw e; - } - } - return { - name: ex.name, - message: ex.message, - url: getLocationHref() - }; - } + var result = { + name: ex.name, + message: ex.message, + url: getLocationHref(), + stack: stack, + }; + augmentStackTraceWithInitialElement( + result, + ex.sourceURL || ex.fileName, + ex.line || ex.lineNumber, + ex.message || ex.description, + ); + return result; + } - computeStackTrace.augmentStackTraceWithInitialElement = augmentStackTraceWithInitialElement; - computeStackTrace.computeStackTraceFromStackProp = computeStackTraceFromStackProp; + /** + * Computes a stack trace for an exception. + * @param {Error} ex + * @param {(string|number)=} depth + */ + function computeStackTrace(ex, depth) { + var stack = null; + depth = depth == null ? 0 : +depth; - return computeStackTrace; -})(); + try { + stack = computeStackTraceFromStackProp(ex); + if (stack) { + return stack; + } + } catch (e) { + if (TraceKit.debug) { + throw e; + } + } -module.exports = TraceKit; + try { + stack = computeStackTraceByWalkingCallerChain(ex, depth + 1); + if (stack) { + return stack; + } + } catch (e) { + if (TraceKit.debug) { + throw e; + } + } + return { + name: ex.name, + message: ex.message, + url: getLocationHref(), + }; + } -}).call(this,typeof global !== "undefined" ? global : typeof self !== "undefined" ? self : typeof window !== "undefined" ? window : {}) -},{"5":5}],7:[function(_dereq_,module,exports){ -/* + computeStackTrace.augmentStackTraceWithInitialElement = + augmentStackTraceWithInitialElement; + computeStackTrace.computeStackTraceFromStackProp = + computeStackTraceFromStackProp; + + return computeStackTrace; + })(); + + module.exports = TraceKit; + }).call( + this, + typeof global !== "undefined" + ? global + : typeof self !== "undefined" + ? self + : typeof window !== "undefined" + ? window + : {}, + ); + }, + { 5: 5 }, + ], + 7: [ + function (_dereq_, module, exports) { + /* json-stringify-safe Like JSON.stringify, but doesn't throw on circular references. @@ -3095,69 +3459,84 @@ module.exports = TraceKit; ISC license: https://github.com/isaacs/json-stringify-safe/blob/master/LICENSE */ -exports = module.exports = stringify; -exports.getSerialize = serializer; + exports = module.exports = stringify; + exports.getSerialize = serializer; -function indexOf(haystack, needle) { - for (var i = 0; i < haystack.length; ++i) { - if (haystack[i] === needle) return i; - } - return -1; -} - -function stringify(obj, replacer, spaces, cycleReplacer) { - return JSON.stringify(obj, serializer(replacer, cycleReplacer), spaces); -} - -// https://github.com/ftlabs/js-abbreviate/blob/fa709e5f139e7770a71827b1893f22418097fbda/index.js#L95-L106 -function stringifyError(value) { - var err = { - // These properties are implemented as magical getters and don't show up in for in - stack: value.stack, - message: value.message, - name: value.name - }; - - for (var i in value) { - if (Object.prototype.hasOwnProperty.call(value, i)) { - err[i] = value[i]; - } - } + function indexOf(haystack, needle) { + for (var i = 0; i < haystack.length; ++i) { + if (haystack[i] === needle) return i; + } + return -1; + } - return err; -} + function stringify(obj, replacer, spaces, cycleReplacer) { + return JSON.stringify( + obj, + serializer(replacer, cycleReplacer), + spaces, + ); + } -function serializer(replacer, cycleReplacer) { - var stack = []; - var keys = []; + // https://github.com/ftlabs/js-abbreviate/blob/fa709e5f139e7770a71827b1893f22418097fbda/index.js#L95-L106 + function stringifyError(value) { + var err = { + // These properties are implemented as magical getters and don't show up in for in + stack: value.stack, + message: value.message, + name: value.name, + }; - if (cycleReplacer == null) { - cycleReplacer = function(key, value) { - if (stack[0] === value) { - return '[Circular ~]'; - } - return '[Circular ~.' + keys.slice(0, indexOf(stack, value)).join('.') + ']'; - }; - } + for (var i in value) { + if (Object.prototype.hasOwnProperty.call(value, i)) { + err[i] = value[i]; + } + } - return function(key, value) { - if (stack.length > 0) { - var thisPos = indexOf(stack, this); - ~thisPos ? stack.splice(thisPos + 1) : stack.push(this); - ~thisPos ? keys.splice(thisPos, Infinity, key) : keys.push(key); + return err; + } - if (~indexOf(stack, value)) { - value = cycleReplacer.call(this, key, value); - } - } else { - stack.push(value); - } + function serializer(replacer, cycleReplacer) { + var stack = []; + var keys = []; - return replacer == null - ? value instanceof Error ? stringifyError(value) : value - : replacer.call(this, key, value); - }; -} + if (cycleReplacer == null) { + cycleReplacer = function (key, value) { + if (stack[0] === value) { + return "[Circular ~]"; + } + return ( + "[Circular ~." + + keys.slice(0, indexOf(stack, value)).join(".") + + "]" + ); + }; + } + + return function (key, value) { + if (stack.length > 0) { + var thisPos = indexOf(stack, this); + ~thisPos ? stack.splice(thisPos + 1) : stack.push(this); + ~thisPos ? keys.splice(thisPos, Infinity, key) : keys.push(key); + + if (~indexOf(stack, value)) { + value = cycleReplacer.call(this, key, value); + } + } else { + stack.push(value); + } -},{}]},{},[4])(4) -}); \ No newline at end of file + return replacer == null + ? value instanceof Error + ? stringifyError(value) + : value + : replacer.call(this, key, value); + }; + } + }, + {}, + ], + }, + {}, + [4], + )(4); +}); diff --git a/assets/javascripts/views/content/content.coffee b/assets/javascripts/views/content/content.coffee deleted file mode 100644 index 4e01733ebd..0000000000 --- a/assets/javascripts/views/content/content.coffee +++ /dev/null @@ -1,195 +0,0 @@ -class app.views.Content extends app.View - @el: '._content' - @loadingClass: '_content-loading' - - @events: - click: 'onClick' - - @shortcuts: - altUp: 'scrollStepUp' - altDown: 'scrollStepDown' - pageUp: 'scrollPageUp' - pageDown: 'scrollPageDown' - pageTop: 'scrollToTop' - pageBottom: 'scrollToBottom' - altF: 'onAltF' - - @routes: - before: 'beforeRoute' - after: 'afterRoute' - - init: -> - @scrollEl = if app.isMobile() - (document.scrollingElement || document.body) - else - @el - @scrollMap = {} - @scrollStack = [] - - @rootPage = new app.views.RootPage - @staticPage = new app.views.StaticPage - @settingsPage = new app.views.SettingsPage - @offlinePage = new app.views.OfflinePage - @typePage = new app.views.TypePage - @entryPage = new app.views.EntryPage - - @entryPage - .on 'loading', @onEntryLoading - .on 'loaded', @onEntryLoaded - - app - .on 'ready', @onReady - .on 'bootError', @onBootError - - return - - show: (view) -> - @hideLoading() - unless view is @view - @view?.deactivate() - @html @view = view - @view.activate() - return - - showLoading: -> - @addClass @constructor.loadingClass - return - - isLoading: -> - @el.classList.contains @constructor.loadingClass - - hideLoading: -> - @removeClass @constructor.loadingClass - return - - scrollTo: (value) -> - @scrollEl.scrollTop = value or 0 - return - - smoothScrollTo: (value) -> - if app.settings.get('fastScroll') - @scrollTo value - else - $.smoothScroll @scrollEl, value or 0 - return - - scrollBy: (n) -> - @smoothScrollTo @scrollEl.scrollTop + n - return - - scrollToTop: => - @smoothScrollTo 0 - return - - scrollToBottom: => - @smoothScrollTo @scrollEl.scrollHeight - return - - scrollStepUp: => - @scrollBy -80 - return - - scrollStepDown: => - @scrollBy 80 - return - - scrollPageUp: => - @scrollBy 40 - @scrollEl.clientHeight - return - - scrollPageDown: => - @scrollBy @scrollEl.clientHeight - 40 - return - - scrollToTarget: -> - if @routeCtx.hash and el = @findTargetByHash @routeCtx.hash - $.scrollToWithImageLock el, @scrollEl, 'top', - margin: if @scrollEl is @el then 0 else $.offset(@el).top - $.highlight el, className: '_highlight' - else - @scrollTo @scrollMap[@routeCtx.state.id] - return - - onReady: => - @hideLoading() - return - - onBootError: => - @hideLoading() - @html @tmpl('bootError') - return - - onEntryLoading: => - @showLoading() - if @scrollToTargetTimeout - clearTimeout @scrollToTargetTimeout - @scrollToTargetTimeout = null - return - - onEntryLoaded: => - @hideLoading() - if @scrollToTargetTimeout - clearTimeout @scrollToTargetTimeout - @scrollToTargetTimeout = null - @scrollToTarget() - return - - beforeRoute: (context) => - @cacheScrollPosition() - @routeCtx = context - @scrollToTargetTimeout = @delay @scrollToTarget - return - - cacheScrollPosition: -> - return if not @routeCtx or @routeCtx.hash - return if @routeCtx.path is '/' - - unless @scrollMap[@routeCtx.state.id]? - @scrollStack.push @routeCtx.state.id - while @scrollStack.length > app.config.history_cache_size - delete @scrollMap[@scrollStack.shift()] - - @scrollMap[@routeCtx.state.id] = @scrollEl.scrollTop - return - - afterRoute: (route, context) => - if route != 'entry' and route != 'type' - resetFavicon() - - switch route - when 'root' - @show @rootPage - when 'entry' - @show @entryPage - when 'type' - @show @typePage - when 'settings' - @show @settingsPage - when 'offline' - @show @offlinePage - else - @show @staticPage - - @view.onRoute(context) - app.document.setTitle @view.getTitle?() - return - - onClick: (event) => - link = $.closestLink $.eventTarget(event), @el - if link and @isExternalUrl link.getAttribute('href') - $.stopEvent(event) - $.popup(link) - return - - onAltF: (event) => - unless document.activeElement and $.hasChild @el, document.activeElement - @find('a:not(:empty)')?.focus() - $.stopEvent(event) - - findTargetByHash: (hash) -> - el = try $.id decodeURIComponent(hash) catch - el or= try $.id(hash) catch - el - - isExternalUrl: (url) -> - url?[0..5] in ['http:/', 'https:'] diff --git a/assets/javascripts/views/content/content.js b/assets/javascripts/views/content/content.js new file mode 100644 index 0000000000..875f96b4c6 --- /dev/null +++ b/assets/javascripts/views/content/content.js @@ -0,0 +1,244 @@ +app.views.Content = class Content extends app.View { + static el = "._content"; + static loadingClass = "_content-loading"; + + static events = { click: "onClick" }; + + static shortcuts = { + altUp: "scrollStepUp", + altDown: "scrollStepDown", + pageUp: "scrollPageUp", + pageDown: "scrollPageDown", + pageTop: "scrollToTop", + pageBottom: "scrollToBottom", + altF: "onAltF", + }; + + static routes = { + before: "beforeRoute", + after: "afterRoute", + }; + + init() { + this.scrollEl = app.isMobile() + ? document.scrollingElement || document.body + : this.el; + this.scrollMap = {}; + this.scrollStack = []; + + this.rootPage = new app.views.RootPage(); + this.staticPage = new app.views.StaticPage(); + this.settingsPage = new app.views.SettingsPage(); + this.offlinePage = new app.views.OfflinePage(); + this.typePage = new app.views.TypePage(); + this.entryPage = new app.views.EntryPage(); + + this.entryPage + .on("loading", () => this.onEntryLoading()) + .on("loaded", () => this.onEntryLoaded()); + + app + .on("ready", () => this.onReady()) + .on("bootError", () => this.onBootError()); + } + + show(view) { + this.hideLoading(); + if (view !== this.view) { + if (this.view != null) { + this.view.deactivate(); + } + this.html((this.view = view)); + this.view.activate(); + } + } + + showLoading() { + this.addClass(this.constructor.loadingClass); + } + + isLoading() { + return this.el.classList.contains(this.constructor.loadingClass); + } + + hideLoading() { + this.removeClass(this.constructor.loadingClass); + } + + scrollTo(value) { + this.scrollEl.scrollTop = value || 0; + } + + smoothScrollTo(value) { + if (app.settings.get("fastScroll")) { + this.scrollTo(value); + } else { + $.smoothScroll(this.scrollEl, value || 0); + } + } + + scrollBy(n) { + this.smoothScrollTo(this.scrollEl.scrollTop + n); + } + + scrollToTop() { + this.smoothScrollTo(0); + } + + scrollToBottom() { + this.smoothScrollTo(this.scrollEl.scrollHeight); + } + + scrollStepUp() { + this.scrollBy(-80); + } + + scrollStepDown() { + this.scrollBy(80); + } + + scrollPageUp() { + this.scrollBy(40 - this.scrollEl.clientHeight); + } + + scrollPageDown() { + this.scrollBy(this.scrollEl.clientHeight - 40); + } + + scrollToTarget() { + let el; + if ( + this.routeCtx.hash && + (el = this.findTargetByHash(this.routeCtx.hash)) + ) { + $.scrollToWithImageLock(el, this.scrollEl, "top", { + margin: this.scrollEl === this.el ? 0 : $.offset(this.el).top, + }); + $.openDetailsAncestors(el); + $.highlight(el, { className: "_highlight" }); + } else { + this.scrollTo(this.scrollMap[this.routeCtx.state.id]); + } + } + + onReady() { + this.hideLoading(); + } + + onBootError() { + this.hideLoading(); + this.html(this.tmpl("bootError")); + } + + onEntryLoading() { + this.showLoading(); + if (this.scrollToTargetTimeout) { + clearTimeout(this.scrollToTargetTimeout); + this.scrollToTargetTimeout = null; + } + } + + onEntryLoaded() { + this.hideLoading(); + if (this.scrollToTargetTimeout) { + clearTimeout(this.scrollToTargetTimeout); + this.scrollToTargetTimeout = null; + } + this.scrollToTarget(); + } + + beforeRoute(context) { + this.cacheScrollPosition(); + this.routeCtx = context; + this.scrollToTargetTimeout = this.delay(this.scrollToTarget); + } + + cacheScrollPosition() { + if (!this.routeCtx || this.routeCtx.hash) { + return; + } + if (this.routeCtx.path === "/") { + return; + } + + if (this.scrollMap[this.routeCtx.state.id] == null) { + this.scrollStack.push(this.routeCtx.state.id); + while (this.scrollStack.length > app.config.history_cache_size) { + delete this.scrollMap[this.scrollStack.shift()]; + } + } + + this.scrollMap[this.routeCtx.state.id] = this.scrollEl.scrollTop; + } + + afterRoute(route, context) { + if (route !== "entry" && route !== "type") { + resetFavicon(); + } + + switch (route) { + case "root": + this.show(this.rootPage); + break; + case "entry": + this.show(this.entryPage); + break; + case "type": + this.show(this.typePage); + break; + case "settings": + this.show(this.settingsPage); + break; + case "offline": + this.show(this.offlinePage); + break; + default: + this.show(this.staticPage); + } + + this.view.onRoute(context); + app.document.setTitle( + typeof this.view.getTitle === "function" + ? this.view.getTitle() + : undefined, + ); + } + + onClick(event) { + const link = $.closestLink($.eventTarget(event), this.el); + if (link && this.isExternalUrl(link.getAttribute("href"))) { + $.stopEvent(event); + $.popup(link); + } + } + + onAltF(event) { + if ( + !document.activeElement || + !$.hasChild(this.el, document.activeElement) + ) { + this.find("a:not(:empty)")?.focus(); + return $.stopEvent(event); + } + } + + findTargetByHash(hash) { + let el = (() => { + try { + return $.id(decodeURIComponent(hash)); + } catch (error) {} + })(); + if (!el) { + el = (() => { + try { + return $.id(hash); + } catch (error1) {} + })(); + } + return el; + } + + isExternalUrl(url) { + return url?.startsWith("http:") || url?.startsWith("https:"); + } +}; diff --git a/assets/javascripts/views/content/entry_page.coffee b/assets/javascripts/views/content/entry_page.coffee deleted file mode 100644 index dff008877e..0000000000 --- a/assets/javascripts/views/content/entry_page.coffee +++ /dev/null @@ -1,166 +0,0 @@ -class app.views.EntryPage extends app.View - @className: '_page' - @errorClass: '_page-error' - - @events: - click: 'onClick' - - @shortcuts: - altC: 'onAltC' - altO: 'onAltO' - - @routes: - before: 'beforeRoute' - - init: -> - @cacheMap = {} - @cacheStack = [] - return - - deactivate: -> - if super - @empty() - @entry = null - return - - loading: -> - @empty() - @trigger 'loading' - return - - render: (content = '', fromCache = false) -> - return unless @activated - @empty() - @subview = new (@subViewClass()) @el, @entry - - $.batchUpdate @el, => - @subview.render(content, fromCache) - @addCopyButtons() unless fromCache - return - - if app.disabledDocs.findBy 'slug', @entry.doc.slug - @hiddenView = new app.views.HiddenPage @el, @entry - - setFaviconForDoc(@entry.doc) - @delay @polyfillMathML - @trigger 'loaded' - return - - addCopyButtons: -> - unless @copyButton - @copyButton = document.createElement('button') - @copyButton.innerHTML = '' - @copyButton.type = 'button' - @copyButton.className = '_pre-clip' - @copyButton.title = 'Copy to clipboard' - @copyButton.setAttribute 'aria-label', 'Copy to clipboard' - el.appendChild @copyButton.cloneNode(true) for el in @findAllByTag('pre') - return - - polyfillMathML: -> - return unless window.supportsMathML is false and !@polyfilledMathML and @findByTag('math') - @polyfilledMathML = true - $.append document.head, """""" - return - - LINKS = - home: 'Homepage' - code: 'Source code' - - prepareContent: (content) -> - return content unless @entry.isIndex() and @entry.doc.links - - links = for link, url of @entry.doc.links - """#{LINKS[link]}""" - - """#{content}""" - - empty: -> - @subview?.deactivate() - @subview = null - - @hiddenView?.deactivate() - @hiddenView = null - - @resetClass() - super - return - - subViewClass: -> - app.views["#{$.classify(@entry.doc.type)}Page"] or app.views.BasePage - - getTitle: -> - @entry.doc.fullName + if @entry.isIndex() then ' documentation' else " / #{@entry.name}" - - beforeRoute: => - @cache() - @abort() - return - - onRoute: (context) -> - isSameFile = context.entry.filePath() is @entry?.filePath() - @entry = context.entry - @restore() or @load() unless isSameFile - return - - load: -> - @loading() - @xhr = @entry.loadFile @onSuccess, @onError - return - - abort: -> - if @xhr - @xhr.abort() - @xhr = @entry = null - return - - onSuccess: (response) => - return unless @activated - @xhr = null - @render @prepareContent(response) - return - - onError: => - @xhr = null - @render @tmpl('pageLoadError') - @resetClass() - @addClass @constructor.errorClass - app.serviceWorker?.update() - return - - cache: -> - return if @xhr or not @entry or @cacheMap[path = @entry.filePath()] - - @cacheMap[path] = @el.innerHTML - @cacheStack.push(path) - - while @cacheStack.length > app.config.history_cache_size - delete @cacheMap[@cacheStack.shift()] - return - - restore: -> - if @cacheMap[path = @entry.filePath()] - @render @cacheMap[path], true - true - - onClick: (event) => - target = $.eventTarget(event) - if target.hasAttribute 'data-retry' - $.stopEvent(event) - @load() - else if target.classList.contains '_pre-clip' - $.stopEvent(event) - target.classList.add if $.copyToClipboard(target.parentNode.textContent) then '_pre-clip-success' else '_pre-clip-error' - setTimeout (-> target.className = '_pre-clip'), 2000 - return - - onAltC: => - return unless link = @find('._attribution:last-child ._attribution-link') - console.log(link.href + location.hash) - navigator.clipboard.writeText(link.href + location.hash) - return - - onAltO: => - return unless link = @find('._attribution:last-child ._attribution-link') - @delay -> $.popup(link.href + location.hash) - return diff --git a/assets/javascripts/views/content/entry_page.js b/assets/javascripts/views/content/entry_page.js new file mode 100644 index 0000000000..961d90e1fa --- /dev/null +++ b/assets/javascripts/views/content/entry_page.js @@ -0,0 +1,237 @@ +app.views.EntryPage = class EntryPage extends app.View { + static className = "_page"; + static errorClass = "_page-error"; + + static events = { click: "onClick" }; + + static shortcuts = { + altC: "onAltC", + altO: "onAltO", + }; + + static routes = { before: "beforeRoute" }; + + static LINKS = { + home: "Homepage", + code: "Source code", + }; + + init() { + this.cacheMap = {}; + this.cacheStack = []; + } + + deactivate() { + if (super.deactivate(...arguments)) { + this.empty(); + this.entry = null; + } + } + + loading() { + this.empty(); + this.trigger("loading"); + } + + render(content, fromCache) { + if (content == null) { + content = ""; + } + if (fromCache == null) { + fromCache = false; + } + if (!this.activated) { + return; + } + this.empty(); + this.subview = new (this.subViewClass())(this.el, this.entry); + + $.batchUpdate(this.el, () => { + this.subview.render(content, fromCache); + if (!fromCache) { + this.addCopyButtons(); + } + }); + + if (app.disabledDocs.findBy("slug", this.entry.doc.slug)) { + this.hiddenView = new app.views.HiddenPage(this.el, this.entry); + } + + setFaviconForDoc(this.entry.doc); + this.delay(this.polyfillMathML); + this.trigger("loaded"); + } + + addCopyButtons() { + if (!this.copyButton) { + this.copyButton = document.createElement("button"); + this.copyButton.innerHTML = ''; + this.copyButton.type = "button"; + this.copyButton.className = "_pre-clip"; + this.copyButton.title = "Copy to clipboard"; + this.copyButton.setAttribute("aria-label", "Copy to clipboard"); + } + for (var el of this.findAllByTag("pre")) { + el.appendChild(this.copyButton.cloneNode(true)); + } + } + + polyfillMathML() { + if ( + window.supportsMathML !== false || + !!this.polyfilledMathML || + !this.findByTag("math") + ) { + return; + } + this.polyfilledMathML = true; + $.append( + document.head, + ``, + ); + } + + prepareContent(content) { + if (!this.entry.isIndex() || !this.entry.doc.links) { + return content; + } + + const links = Object.entries(this.entry.doc.links).map(([link, url]) => { + return `${EntryPage.LINKS[link]}`; + }); + + return `${content}`; + } + + empty() { + if (this.subview != null) { + this.subview.deactivate(); + } + this.subview = null; + + if (this.hiddenView != null) { + this.hiddenView.deactivate(); + } + this.hiddenView = null; + + this.resetClass(); + super.empty(...arguments); + } + + subViewClass() { + return ( + app.views[`${$.classify(this.entry.doc.type)}Page`] || app.views.BasePage + ); + } + + getTitle() { + return ( + this.entry.doc.fullName + + (this.entry.isIndex() ? " documentation" : ` / ${this.entry.name}`) + ); + } + + beforeRoute() { + this.cache(); + this.abort(); + } + + onRoute(context) { + const isSameFile = context.entry.filePath() === this.entry?.filePath?.(); + this.entry = context.entry; + if (!isSameFile) { + this.restore() || this.load(); + } + } + + load() { + this.loading(); + this.xhr = this.entry.loadFile( + (response) => this.onSuccess(response), + () => this.onError(), + ); + } + + abort() { + if (this.xhr) { + this.xhr.abort(); + this.xhr = this.entry = null; + } + } + + onSuccess(response) { + if (!this.activated) { + return; + } + this.xhr = null; + this.render(this.prepareContent(response)); + } + + onError() { + this.xhr = null; + this.render(this.tmpl("pageLoadError")); + this.resetClass(); + this.addClass(this.constructor.errorClass); + if (app.serviceWorker != null) { + app.serviceWorker.update(); + } + } + + cache() { + let path; + if ( + this.xhr || + !this.entry || + this.cacheMap[(path = this.entry.filePath())] + ) { + return; + } + + this.cacheMap[path] = this.el.innerHTML; + this.cacheStack.push(path); + + while (this.cacheStack.length > app.config.history_cache_size) { + delete this.cacheMap[this.cacheStack.shift()]; + } + } + + restore() { + const path = this.entry.filePath(); + if (this.cacheMap[[path]]) { + this.render(this.cacheMap[path], true); + return true; + } + } + + onClick(event) { + const target = $.eventTarget(event); + if (target.hasAttribute("data-retry")) { + $.stopEvent(event); + this.load(); + } else if (target.classList.contains("_pre-clip")) { + $.stopEvent(event); + navigator.clipboard.writeText(target.parentNode.textContent).then( + () => target.classList.add("_pre-clip-success"), + () => target.classList.add("_pre-clip-error"), + ); + setTimeout(() => (target.className = "_pre-clip"), 2000); + } + } + + onAltC() { + const link = this.find("._attribution:last-child ._attribution-link"); + if (!link) { + return; + } + console.log(link.href + location.hash); + navigator.clipboard.writeText(link.href + location.hash); + } + + onAltO() { + const link = this.find("._attribution:last-child ._attribution-link"); + if (!link) { + return; + } + this.delay(() => $.popup(link.href + location.hash)); + } +}; diff --git a/assets/javascripts/views/content/offline_page.coffee b/assets/javascripts/views/content/offline_page.coffee deleted file mode 100644 index 2f0d615f47..0000000000 --- a/assets/javascripts/views/content/offline_page.coffee +++ /dev/null @@ -1,92 +0,0 @@ -class app.views.OfflinePage extends app.View - @className: '_static' - - @events: - click: 'onClick' - change: 'onChange' - - deactivate: -> - if super - @empty() - return - - render: -> - if app.cookieBlocked - @html @tmpl('offlineError', 'cookie_blocked') - return - - app.docs.getInstallStatuses (statuses) => - return unless @activated - if statuses is false - @html @tmpl('offlineError', app.db.reason, app.db.error) - else - html = '' - html += @renderDoc(doc, statuses[doc.slug]) for doc in app.docs.all() - @html @tmpl('offlinePage', html) - @refreshLinks() - return - return - - renderDoc: (doc, status) -> - app.templates.render('offlineDoc', doc, status) - - getTitle: -> - 'Offline' - - refreshLinks: -> - for action in ['install', 'update', 'uninstall'] - @find("[data-action-all='#{action}']").classList[if @find("[data-action='#{action}']") then 'add' else 'remove']('_show') - return - - docByEl: (el) -> - el = el.parentNode until slug = el.getAttribute('data-slug') - app.docs.findBy('slug', slug) - - docEl: (doc) -> - @find("[data-slug='#{doc.slug}']") - - onRoute: (context) -> - @render() - return - - onClick: (event) => - el = $.eventTarget(event) - if action = el.getAttribute('data-action') - doc = @docByEl(el) - action = 'install' if action is 'update' - doc[action](@onInstallSuccess.bind(@, doc), @onInstallError.bind(@, doc), @onInstallProgress.bind(@, doc)) - el.parentNode.innerHTML = "#{el.textContent.replace(/e$/, '')}ing…" - else if action = el.getAttribute('data-action-all') || el.parentElement.getAttribute('data-action-all') - return unless action isnt 'uninstall' or window.confirm('Uninstall all docs?') - app.db.migrate() - $.click(el) for el in @findAll("[data-action='#{action}']") - return - - onInstallSuccess: (doc) -> - return unless @activated - doc.getInstallStatus (status) => - return unless @activated - if el = @docEl(doc) - el.outerHTML = @renderDoc(doc, status) - $.highlight el, className: '_highlight' - @refreshLinks() - return - return - - onInstallError: (doc) -> - return unless @activated - if el = @docEl(doc) - el.lastElementChild.textContent = 'Error' - return - - onInstallProgress: (doc, event) -> - return unless @activated and event.lengthComputable - if el = @docEl(doc) - percentage = Math.round event.loaded * 100 / event.total - el.lastElementChild.textContent = el.lastElementChild.textContent.replace(/(\s.+)?$/, " (#{percentage}%)") - return - - onChange: (event) -> - if event.target.name is 'autoUpdate' - app.settings.set 'manualUpdate', !event.target.checked - return diff --git a/assets/javascripts/views/content/offline_page.js b/assets/javascripts/views/content/offline_page.js new file mode 100644 index 0000000000..5fe4f21d86 --- /dev/null +++ b/assets/javascripts/views/content/offline_page.js @@ -0,0 +1,145 @@ +app.views.OfflinePage = class OfflinePage extends app.View { + static className = "_static"; + + static events = { + click: "onClick", + change: "onChange", + }; + + deactivate() { + if (super.deactivate(...arguments)) { + this.empty(); + } + } + + render() { + if (app.cookieBlocked) { + this.html(this.tmpl("offlineError", "cookie_blocked")); + return; + } + + app.docs.getInstallStatuses((statuses) => { + if (!this.activated) { + return; + } + if (statuses === false) { + this.html(this.tmpl("offlineError", app.db.reason, app.db.error)); + } else { + let html = ""; + for (var doc of app.docs.all()) { + html += this.renderDoc(doc, statuses[doc.slug]); + } + this.html(this.tmpl("offlinePage", html)); + this.refreshLinks(); + } + }); + } + + renderDoc(doc, status) { + return app.templates.render("offlineDoc", doc, status); + } + + getTitle() { + return "Offline"; + } + + refreshLinks() { + for (var action of ["install", "update", "uninstall"]) { + this.find(`[data-action-all='${action}']`).classList[ + this.find(`[data-action='${action}']`) ? "add" : "remove" + ]("_show"); + } + } + + docByEl(el) { + let slug; + while (!(slug = el.getAttribute("data-slug"))) { + el = el.parentNode; + } + return app.docs.findBy("slug", slug); + } + + docEl(doc) { + return this.find(`[data-slug='${doc.slug}']`); + } + + onRoute(context) { + this.render(); + } + + onClick(event) { + let el = $.eventTarget(event); + let action = el.getAttribute("data-action"); + if (action) { + const doc = this.docByEl(el); + if (action === "update") { + action = "install"; + } + doc[action]( + this.onInstallSuccess.bind(this, doc), + this.onInstallError.bind(this, doc), + this.onInstallProgress.bind(this, doc) + ); + el.parentNode.innerHTML = `${el.textContent.replace(/e$/, "")}ing…`; + } else if ( + (action = + el.getAttribute("data-action-all") || + el.parentElement.getAttribute("data-action-all")) + ) { + if (action === "uninstall" && !window.confirm("Uninstall all docs?")) { + return; + } + app.db.migrate(); + for (el of Array.from(this.findAll(`[data-action='${action}']`))) { + $.click(el); + } + } + } + + onInstallSuccess(doc) { + if (!this.activated) { + return; + } + doc.getInstallStatus((status) => { + if (!this.activated) { + return; + } + const el = this.docEl(doc); + if (el) { + el.outerHTML = this.renderDoc(doc, status); + $.highlight(el, { className: "_highlight" }); + this.refreshLinks(); + } + }); + } + + onInstallError(doc) { + if (!this.activated) { + return; + } + const el = this.docEl(doc); + if (el) { + el.lastElementChild.textContent = "Error"; + } + } + + onInstallProgress(doc, event) { + if (!this.activated || !event.lengthComputable) { + return; + } + const el = this.docEl(doc); + if (el) { + const percentage = Math.round((event.loaded * 100) / event.total); + el.lastElementChild.textContent = el.lastElementChild.textContent.replace( + /(\s.+)?$/, + ` (${percentage}%)` + ); + } + } + + onChange(event) { + if (event.target.name === "autoUpdate") { + app.settings.set("manualUpdate", !event.target.checked); + } + } +}; diff --git a/assets/javascripts/views/content/root_page.coffee b/assets/javascripts/views/content/root_page.coffee deleted file mode 100644 index b48a1df3ba..0000000000 --- a/assets/javascripts/views/content/root_page.coffee +++ /dev/null @@ -1,43 +0,0 @@ -class app.views.RootPage extends app.View - @events: - click: 'onClick' - - init: -> - @setHidden false unless @isHidden() # reserve space in local storage - @render() - return - - render: -> - @empty() - - tmpl = if app.isAndroidWebview() - 'androidWarning' - else if @isHidden() - 'splash' - else if app.isMobile() - 'mobileIntro' - else - 'intro' - - @append @tmpl(tmpl) - return - - hideIntro: -> - @setHidden true - @render() - return - - setHidden: (value) -> - app.settings.set 'hideIntro', value - return - - isHidden: -> - app.isSingleDoc() or app.settings.get 'hideIntro' - - onRoute: -> - - onClick: (event) => - if $.eventTarget(event).hasAttribute 'data-hide-intro' - $.stopEvent(event) - @hideIntro() - return diff --git a/assets/javascripts/views/content/root_page.js b/assets/javascripts/views/content/root_page.js new file mode 100644 index 0000000000..26bfa0b1c5 --- /dev/null +++ b/assets/javascripts/views/content/root_page.js @@ -0,0 +1,46 @@ +app.views.RootPage = class RootPage extends app.View { + static events = { click: "onClick" }; + + init() { + if (!this.isHidden()) { + this.setHidden(false); + } // reserve space in local storage + this.render(); + } + + render() { + this.empty(); + + const tmpl = app.isAndroidWebview() + ? "androidWarning" + : this.isHidden() + ? "splash" + : app.isMobile() + ? "mobileIntro" + : "intro"; + + this.append(this.tmpl(tmpl)); + } + + hideIntro() { + this.setHidden(true); + this.render(); + } + + setHidden(value) { + app.settings.set("hideIntro", value); + } + + isHidden() { + return app.isSingleDoc() || app.settings.get("hideIntro"); + } + + onRoute() {} + + onClick(event) { + if ($.eventTarget(event).hasAttribute("data-hide-intro")) { + $.stopEvent(event); + this.hideIntro(); + } + } +}; diff --git a/assets/javascripts/views/content/settings_page.coffee b/assets/javascripts/views/content/settings_page.coffee deleted file mode 100644 index 7a7b624696..0000000000 --- a/assets/javascripts/views/content/settings_page.coffee +++ /dev/null @@ -1,116 +0,0 @@ -class app.views.SettingsPage extends app.View - @className: '_static' - - @events: - click: 'onClick' - change: 'onChange' - - render: -> - @html @tmpl('settingsPage', @currentSettings()) - return - - currentSettings: -> - settings = {} - settings.theme = app.settings.get('theme') - settings.smoothScroll = !app.settings.get('fastScroll') - settings.arrowScroll = app.settings.get('arrowScroll') - settings.noAutofocus = app.settings.get('noAutofocus') - settings.autoInstall = app.settings.get('autoInstall') - settings.analyticsConsent = app.settings.get('analyticsConsent') - settings.spaceScroll = app.settings.get('spaceScroll') - settings.spaceTimeout = app.settings.get('spaceTimeout') - settings.autoSupported = app.settings.autoSupported - settings[layout] = app.settings.hasLayout(layout) for layout in app.settings.LAYOUTS - settings - - getTitle: -> - 'Preferences' - - setTheme: (value) -> - app.settings.set('theme', value) - return - - toggleLayout: (layout, enable) -> - app.settings.setLayout(layout, enable) - return - - toggleSmoothScroll: (enable) -> - app.settings.set('fastScroll', !enable) - return - - toggleAnalyticsConsent: (enable) -> - app.settings.set('analyticsConsent', if enable then '1' else '0') - resetAnalytics() unless enable - return - - toggleSpaceScroll: (enable) -> - app.settings.set('spaceScroll', if enable then 1 else 0) - return - - setScrollTimeout: (value) -> - app.settings.set('spaceTimeout', value) - - toggle: (name, enable) -> - app.settings.set(name, enable) - return - - export: -> - data = new Blob([JSON.stringify(app.settings.export())], type: 'application/json') - link = document.createElement('a') - link.href = URL.createObjectURL(data) - link.download = 'devdocs.json' - link.style.display = 'none' - document.body.appendChild(link) - link.click() - document.body.removeChild(link) - return - - import: (file, input) -> - unless file and file.type is 'application/json' - new app.views.Notif 'ImportInvalid', autoHide: false - return - - reader = new FileReader() - reader.onloadend = -> - data = try JSON.parse(reader.result) - unless data and data.constructor is Object - new app.views.Notif 'ImportInvalid', autoHide: false - return - app.settings.import(data) - $.trigger input.form, 'import' - return - reader.readAsText(file) - return - - onChange: (event) => - input = event.target - switch input.name - when 'theme' - @setTheme input.value - when 'layout' - @toggleLayout input.value, input.checked - when 'smoothScroll' - @toggleSmoothScroll input.checked - when 'import' - @import input.files[0], input - when 'analyticsConsent' - @toggleAnalyticsConsent input.checked - when 'spaceScroll' - @toggleSpaceScroll input.checked - when 'spaceTimeout' - @setScrollTimeout input.value - else - @toggle input.name, input.checked - return - - onClick: (event) => - target = $.eventTarget(event) - switch target.getAttribute('data-action') - when 'export' - $.stopEvent(event) - @export() - return - - onRoute: (context) -> - @render() - return diff --git a/assets/javascripts/views/content/settings_page.js b/assets/javascripts/views/content/settings_page.js new file mode 100644 index 0000000000..46cacb3c16 --- /dev/null +++ b/assets/javascripts/views/content/settings_page.js @@ -0,0 +1,144 @@ +app.views.SettingsPage = class SettingsPage extends app.View { + static className = "_static"; + + static events = { + click: "onClick", + change: "onChange", + }; + + render() { + this.html(this.tmpl("settingsPage", this.currentSettings())); + } + + currentSettings() { + const settings = {}; + settings.theme = app.settings.get("theme"); + settings.smoothScroll = !app.settings.get("fastScroll"); + settings.arrowScroll = app.settings.get("arrowScroll"); + settings.noAutofocus = app.settings.get("noAutofocus"); + settings.autoInstall = app.settings.get("autoInstall"); + settings.analyticsConsent = app.settings.get("analyticsConsent"); + settings.spaceScroll = app.settings.get("spaceScroll"); + settings.spaceTimeout = app.settings.get("spaceTimeout"); + settings.noDocSpecificIcon = app.settings.get("noDocSpecificIcon"); + settings.autoSupported = app.settings.autoSupported; + for (var layout of app.Settings.LAYOUTS) { + settings[layout] = app.settings.hasLayout(layout); + } + return settings; + } + + getTitle() { + return "Preferences"; + } + + setTheme(value) { + app.settings.set("theme", value); + } + + toggleLayout(layout, enable) { + app.settings.setLayout(layout, enable); + } + + toggleSmoothScroll(enable) { + app.settings.set("fastScroll", !enable); + } + + toggleAnalyticsConsent(enable) { + app.settings.set("analyticsConsent", enable ? "1" : "0"); + if (!enable) { + resetAnalytics(); + } + } + + toggleSpaceScroll(enable) { + app.settings.set("spaceScroll", enable ? 1 : 0); + } + + setScrollTimeout(value) { + return app.settings.set("spaceTimeout", value); + } + + toggle(name, enable) { + app.settings.set(name, enable); + } + + export() { + const data = new Blob([JSON.stringify(app.settings.export())], { + type: "application/json", + }); + const link = document.createElement("a"); + link.href = URL.createObjectURL(data); + link.download = "devdocs.json"; + link.style.display = "none"; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + } + + import(file, input) { + if (!file || file.type !== "application/json") { + new app.views.Notif("ImportInvalid", { autoHide: false }); + return; + } + + const reader = new FileReader(); + reader.onloadend = function () { + const data = (() => { + try { + return JSON.parse(reader.result); + } catch (error) {} + })(); + if (!data || data.constructor !== Object) { + new app.views.Notif("ImportInvalid", { autoHide: false }); + return; + } + app.settings.import(data); + $.trigger(input.form, "import"); + }; + reader.readAsText(file); + } + + onChange(event) { + const input = event.target; + switch (input.name) { + case "theme": + this.setTheme(input.value); + break; + case "layout": + this.toggleLayout(input.value, input.checked); + break; + case "smoothScroll": + this.toggleSmoothScroll(input.checked); + break; + case "import": + this.import(input.files[0], input); + break; + case "analyticsConsent": + this.toggleAnalyticsConsent(input.checked); + break; + case "spaceScroll": + this.toggleSpaceScroll(input.checked); + break; + case "spaceTimeout": + this.setScrollTimeout(input.value); + break; + default: + this.toggle(input.name, input.checked); + } + } + + onClick(event) { + const target = $.eventTarget(event); + switch (target.getAttribute("data-action")) { + case "export": + $.stopEvent(event); + this.export(); + break; + } + } + + onRoute(context) { + this.render(); + } +}; diff --git a/assets/javascripts/views/content/static_page.coffee b/assets/javascripts/views/content/static_page.coffee deleted file mode 100644 index d7bee725b6..0000000000 --- a/assets/javascripts/views/content/static_page.coffee +++ /dev/null @@ -1,26 +0,0 @@ -class app.views.StaticPage extends app.View - @className: '_static' - - @titles: - about: 'About' - news: 'News' - help: 'User Guide' - notFound: '404' - - deactivate: -> - if super - @empty() - @page = null - return - - render: (page) -> - @page = page - @html @tmpl("#{@page}Page") - return - - getTitle: -> - @constructor.titles[@page] - - onRoute: (context) -> - @render context.page or 'notFound' - return diff --git a/assets/javascripts/views/content/static_page.js b/assets/javascripts/views/content/static_page.js new file mode 100644 index 0000000000..bdada2e1fa --- /dev/null +++ b/assets/javascripts/views/content/static_page.js @@ -0,0 +1,30 @@ +app.views.StaticPage = class StaticPage extends app.View { + static className = "_static"; + + static titles = { + about: "About", + news: "News", + help: "User Guide", + notFound: "404", + }; + + deactivate() { + if (super.deactivate(...arguments)) { + this.empty(); + this.page = null; + } + } + + render(page) { + this.page = page; + this.html(this.tmpl(`${this.page}Page`)); + } + + getTitle() { + return this.constructor.titles[this.page]; + } + + onRoute(context) { + this.render(context.page || "notFound"); + } +}; diff --git a/assets/javascripts/views/content/type_page.coffee b/assets/javascripts/views/content/type_page.coffee deleted file mode 100644 index ef360c14c4..0000000000 --- a/assets/javascripts/views/content/type_page.coffee +++ /dev/null @@ -1,20 +0,0 @@ -class app.views.TypePage extends app.View - @className: '_page' - - deactivate: -> - if super - @empty() - @type = null - return - - render: (@type) -> - @html @tmpl('typePage', @type) - setFaviconForDoc(@type.doc) - return - - getTitle: -> - "#{@type.doc.fullName} / #{@type.name}" - - onRoute: (context) -> - @render context.type - return diff --git a/assets/javascripts/views/content/type_page.js b/assets/javascripts/views/content/type_page.js new file mode 100644 index 0000000000..a7dbafae75 --- /dev/null +++ b/assets/javascripts/views/content/type_page.js @@ -0,0 +1,24 @@ +app.views.TypePage = class TypePage extends app.View { + static className = "_page"; + + deactivate() { + if (super.deactivate(...arguments)) { + this.empty(); + this.type = null; + } + } + + render(type) { + this.type = type; + this.html(this.tmpl("typePage", this.type)); + setFaviconForDoc(this.type.doc); + } + + getTitle() { + return `${this.type.doc.fullName} / ${this.type.name}`; + } + + onRoute(context) { + this.render(context.type); + } +}; diff --git a/assets/javascripts/views/layout/document.coffee b/assets/javascripts/views/layout/document.coffee deleted file mode 100644 index a10d0b3ce2..0000000000 --- a/assets/javascripts/views/layout/document.coffee +++ /dev/null @@ -1,85 +0,0 @@ -class app.views.Document extends app.View - @el: document - - @events: - visibilitychange: 'onVisibilityChange' - - @shortcuts: - help: 'onHelp' - preferences: 'onPreferences' - escape: 'onEscape' - superLeft: 'onBack' - superRight: 'onForward' - - @routes: - after: 'afterRoute' - - init: -> - @addSubview @menu = new app.views.Menu, - @addSubview @sidebar = new app.views.Sidebar - @addSubview @resizer = new app.views.Resizer if app.views.Resizer.isSupported() - @addSubview @content = new app.views.Content - @addSubview @path = new app.views.Path unless app.isSingleDoc() or app.isMobile() - @settings = new app.views.Settings unless app.isSingleDoc() - - $.on document.body, 'click', @onClick - - @activate() - return - - setTitle: (title) -> - @el.title = if title then "#{title} — DevDocs" else 'DevDocs API Documentation' - - afterRoute: (route) => - if route is 'settings' - @settings?.activate() - else - @settings?.deactivate() - return - - onVisibilityChange: => - return unless @el.visibilityState is 'visible' - @delay -> - location.reload() if app.isMobile() isnt app.views.Mobile.detect() - return - , 300 - return - - onHelp: -> - app.router.show '/help#shortcuts' - return - - onPreferences: -> - app.router.show '/settings' - return - - onEscape: -> - path = if !app.isSingleDoc() or location.pathname is app.doc.fullPath() - '/' - else - app.doc.fullPath() - - app.router.show(path) - return - - onBack: -> - history.back() - return - - onForward: -> - history.forward() - return - - onClick: (event) -> - target = $.eventTarget(event) - return unless target.hasAttribute('data-behavior') - $.stopEvent(event) - switch target.getAttribute('data-behavior') - when 'back' then history.back() - when 'reload' then window.location.reload() - when 'reboot' then app.reboot() - when 'hard-reload' then app.reload() - when 'reset' then app.reset() if confirm('Are you sure you want to reset DevDocs?') - when 'accept-analytics' then Cookies.set('analyticsConsent', '1', expires: 1e8) && app.reboot() - when 'decline-analytics' then Cookies.set('analyticsConsent', '0', expires: 1e8) && app.reboot() - return diff --git a/assets/javascripts/views/layout/document.js b/assets/javascripts/views/layout/document.js new file mode 100644 index 0000000000..f3dfc448e1 --- /dev/null +++ b/assets/javascripts/views/layout/document.js @@ -0,0 +1,125 @@ +app.views.Document = class Document extends app.View { + static el = document; + + static events = { visibilitychange: "onVisibilityChange" }; + + static shortcuts = { + help: "onHelp", + preferences: "onPreferences", + escape: "onEscape", + superLeft: "onBack", + superRight: "onForward", + }; + + static routes = { after: "afterRoute" }; + + init() { + this.menu = new app.views.Menu(); + this.sidebar = new app.views.Sidebar(); + this.addSubview(this.menu, this.addSubview(this.sidebar)); + if (app.views.Resizer.isSupported()) { + this.resizer = new app.views.Resizer(); + this.addSubview(this.resizer); + } + this.content = new app.views.Content(); + this.addSubview(this.content); + if (!app.isSingleDoc() && !app.isMobile()) { + this.path = new app.views.Path(); + this.addSubview(this.path); + } + if (!app.isSingleDoc()) { + this.settings = new app.views.Settings(); + } + + $.on(document.body, "click", this.onClick); + + this.activate(); + } + + setTitle(title) { + return (this.el.title = title + ? `${title} — DevDocs` + : "DevDocs API Documentation"); + } + + afterRoute(route) { + if (route === "settings") { + if (this.settings != null) { + this.settings.activate(); + } + } else { + if (this.settings != null) { + this.settings.deactivate(); + } + } + } + + onVisibilityChange() { + if (this.el.visibilityState !== "visible") { + return; + } + this.delay(() => { + if (app.isMobile() !== app.views.Mobile.detect()) { + location.reload(); + } + }, 300); + } + + onHelp() { + app.router.show("/help#shortcuts"); + } + + onPreferences() { + app.router.show("/settings"); + } + + onEscape() { + const path = + !app.isSingleDoc() || location.pathname === app.doc.fullPath() + ? "/" + : app.doc.fullPath(); + + app.router.show(path); + } + + onBack() { + history.back(); + } + + onForward() { + history.forward(); + } + + onClick(event) { + const target = $.eventTarget(event); + if (!target.hasAttribute("data-behavior")) { + return; + } + $.stopEvent(event); + switch (target.getAttribute("data-behavior")) { + case "back": + history.back(); + break; + case "reload": + window.location.reload(); + break; + case "reboot": + app.reboot(); + break; + case "hard-reload": + app.reload(); + break; + case "reset": + if (confirm("Are you sure you want to reset DevDocs?")) { + app.reset(); + } + break; + case "accept-analytics": + Cookies.set("analyticsConsent", "1", { expires: 1e8 }) && app.reboot(); + break; + case "decline-analytics": + Cookies.set("analyticsConsent", "0", { expires: 1e8 }) && app.reboot(); + break; + } + } +}; diff --git a/assets/javascripts/views/layout/menu.coffee b/assets/javascripts/views/layout/menu.coffee deleted file mode 100644 index e2282176ec..0000000000 --- a/assets/javascripts/views/layout/menu.coffee +++ /dev/null @@ -1,23 +0,0 @@ -class app.views.Menu extends app.View - @el: '._menu' - @activeClass: 'active' - - @events: - click: 'onClick' - - init: -> - $.on document.body, 'click', @onGlobalClick - return - - onClick: (event) -> - target = $.eventTarget(event) - target.blur() if target.tagName is 'A' - return - - onGlobalClick: (event) => - return if event.which isnt 1 - if event.target.hasAttribute?('data-toggle-menu') - @toggleClass @constructor.activeClass - else if @hasClass @constructor.activeClass - @removeClass @constructor.activeClass - return diff --git a/assets/javascripts/views/layout/menu.js b/assets/javascripts/views/layout/menu.js new file mode 100644 index 0000000000..51e6648ec2 --- /dev/null +++ b/assets/javascripts/views/layout/menu.js @@ -0,0 +1,32 @@ +app.views.Menu = class Menu extends app.View { + static el = "._menu"; + static activeClass = "active"; + + static events = { click: "onClick" }; + + init() { + $.on(document.body, "click", (event) => this.onGlobalClick(event)); + } + + onClick(event) { + const target = $.eventTarget(event); + if (target.tagName === "A") { + target.blur(); + } + } + + onGlobalClick(event) { + if (event.which !== 1) { + return; + } + if ( + typeof event.target.hasAttribute === "function" + ? event.target.hasAttribute("data-toggle-menu") + : undefined + ) { + this.toggleClass(this.constructor.activeClass); + } else if (this.hasClass(this.constructor.activeClass)) { + this.removeClass(this.constructor.activeClass); + } + } +}; diff --git a/assets/javascripts/views/layout/mobile.coffee b/assets/javascripts/views/layout/mobile.coffee deleted file mode 100644 index 1fd5e28577..0000000000 --- a/assets/javascripts/views/layout/mobile.coffee +++ /dev/null @@ -1,155 +0,0 @@ -class app.views.Mobile extends app.View - @className: '_mobile' - - @elements: - body: 'body' - content: '._container' - sidebar: '._sidebar' - docPicker: '._settings ._sidebar' - - @shortcuts: - escape: 'onEscape' - - @routes: - after: 'afterRoute' - - @detect: -> - if Cookies.get('override-mobile-detect')? - return JSON.parse Cookies.get('override-mobile-detect') - try - (window.matchMedia('(max-width: 480px)').matches) or - (window.matchMedia('(max-width: 767px)').matches) or - (window.matchMedia('(max-height: 767px) and (max-width: 1024px)').matches) or - # Need to sniff the user agent because some Android and Windows Phone devices don't take - # resolution (dpi) into account when reporting device width/height. - (navigator.userAgent.indexOf('Android') isnt -1 and navigator.userAgent.indexOf('Mobile') isnt -1) or - (navigator.userAgent.indexOf('IEMobile') isnt -1) - catch - false - - @detectAndroidWebview: -> - try - /(Android).*( Version\/.\.. ).*(Chrome)/.test(navigator.userAgent) - catch - false - - constructor: -> - @el = document.documentElement - super - - init: -> - $.on $('._search'), 'touchend', @onTapSearch - - @toggleSidebar = $('button[data-toggle-sidebar]') - @toggleSidebar.removeAttribute('hidden') - $.on @toggleSidebar, 'click', @onClickToggleSidebar - - @back = $('button[data-back]') - @back.removeAttribute('hidden') - $.on @back, 'click', @onClickBack - - @forward = $('button[data-forward]') - @forward.removeAttribute('hidden') - $.on @forward, 'click', @onClickForward - - @docPickerTab = $('button[data-tab="doc-picker"]') - @docPickerTab.removeAttribute('hidden') - $.on @docPickerTab, 'click', @onClickDocPickerTab - - @settingsTab = $('button[data-tab="settings"]') - @settingsTab.removeAttribute('hidden') - $.on @settingsTab, 'click', @onClickSettingsTab - - app.document.sidebar.search - .on 'searching', @showSidebar - - @activate() - return - - showSidebar: => - if @isSidebarShown() - window.scrollTo 0, 0 - return - - @contentTop = window.scrollY - @content.style.display = 'none' - @sidebar.style.display = 'block' - - if selection = @findByClass app.views.ListSelect.activeClass - scrollContainer = if window.scrollY is @body.scrollTop then @body else document.documentElement - $.scrollTo selection, scrollContainer, 'center' - else - window.scrollTo 0, @findByClass(app.views.ListFold.activeClass) and @sidebarTop or 0 - return - - hideSidebar: => - return unless @isSidebarShown() - @sidebarTop = window.scrollY - @sidebar.style.display = 'none' - @content.style.display = 'block' - window.scrollTo 0, @contentTop or 0 - return - - isSidebarShown: -> - @sidebar.style.display isnt 'none' - - onClickBack: => - history.back() - - onClickForward: => - history.forward() - - onClickToggleSidebar: => - if @isSidebarShown() then @hideSidebar() else @showSidebar() - return - - onClickDocPickerTab: (event) => - $.stopEvent(event) - @showDocPicker() - return - - onClickSettingsTab: (event) => - $.stopEvent(event) - @showSettings() - return - - showDocPicker: -> - window.scrollTo 0, 0 - @docPickerTab.classList.add 'active' - @settingsTab.classList.remove 'active' - @docPicker.style.display = 'block' - @content.style.display = 'none' - return - - showSettings: -> - window.scrollTo 0, 0 - @docPickerTab.classList.remove 'active' - @settingsTab.classList.add 'active' - @docPicker.style.display = 'none' - @content.style.display = 'block' - return - - onTapSearch: => - window.scrollTo 0, 0 - - onEscape: => - @hideSidebar() - - afterRoute: (route) => - @hideSidebar() - - if route is 'settings' - @showDocPicker() - else - @content.style.display = 'block' - - if page.canGoBack() - @back.removeAttribute('disabled') - else - @back.setAttribute('disabled', 'disabled') - - if page.canGoForward() - @forward.removeAttribute('disabled') - else - @forward.setAttribute('disabled', 'disabled') - return diff --git a/assets/javascripts/views/layout/mobile.js b/assets/javascripts/views/layout/mobile.js new file mode 100644 index 0000000000..844c3feed0 --- /dev/null +++ b/assets/javascripts/views/layout/mobile.js @@ -0,0 +1,189 @@ +app.views.Mobile = class Mobile extends app.View { + static className = "_mobile"; + + static elements = { + body: "body", + content: "._container", + sidebar: "._sidebar", + docPicker: "._settings ._sidebar", + }; + + static shortcuts = { escape: "onEscape" }; + + static routes = { after: "afterRoute" }; + + static detect() { + if (Cookies.get("override-mobile-detect") != null) { + return JSON.parse(Cookies.get("override-mobile-detect")); + } + try { + return ( + window.matchMedia("(max-width: 480px)").matches || + window.matchMedia("(max-width: 767px)").matches || + window.matchMedia("(max-height: 767px) and (max-width: 1024px)") + .matches || + // Need to sniff the user agent because some Android and Windows Phone devices don't take + // resolution (dpi) into account when reporting device width/height. + (navigator.userAgent.includes("Android") && + navigator.userAgent.includes("Mobile")) || + navigator.userAgent.includes("IEMobile") + ); + } catch (error) { + return false; + } + } + + static detectAndroidWebview() { + try { + return /(Android).*( Version\/.\.. ).*(Chrome)/.test(navigator.userAgent); + } catch (error) { + return false; + } + } + + constructor() { + super(document.documentElement); + } + + init() { + $.on($("._search"), "touchend", () => this.onTapSearch()); + + this.toggleSidebar = $("button[data-toggle-sidebar]"); + this.toggleSidebar.removeAttribute("hidden"); + $.on(this.toggleSidebar, "click", () => this.onClickToggleSidebar()); + + this.back = $("button[data-back]"); + this.back.removeAttribute("hidden"); + $.on(this.back, "click", () => this.onClickBack()); + + this.forward = $("button[data-forward]"); + this.forward.removeAttribute("hidden"); + $.on(this.forward, "click", () => this.onClickForward()); + + this.docPickerTab = $('button[data-tab="doc-picker"]'); + this.docPickerTab.removeAttribute("hidden"); + $.on(this.docPickerTab, "click", (event) => + this.onClickDocPickerTab(event), + ); + + this.settingsTab = $('button[data-tab="settings"]'); + this.settingsTab.removeAttribute("hidden"); + $.on(this.settingsTab, "click", (event) => this.onClickSettingsTab(event)); + + app.document.sidebar.search.on("searching", () => this.showSidebar()); + + this.activate(); + } + + showSidebar() { + if (this.isSidebarShown()) { + window.scrollTo(0, 0); + return; + } + + this.contentTop = window.scrollY; + this.content.style.display = "none"; + this.sidebar.style.display = "block"; + + const selection = this.findByClass(app.views.ListSelect.activeClass); + if (selection) { + const scrollContainer = + window.scrollY === this.body.scrollTop + ? this.body + : document.documentElement; + $.scrollTo(selection, scrollContainer, "center"); + } else { + window.scrollTo( + 0, + (this.findByClass(app.views.ListFold.activeClass) && this.sidebarTop) || + 0, + ); + } + } + + hideSidebar() { + if (!this.isSidebarShown()) { + return; + } + this.sidebarTop = window.scrollY; + this.sidebar.style.display = "none"; + this.content.style.display = "block"; + window.scrollTo(0, this.contentTop || 0); + } + + isSidebarShown() { + return this.sidebar.style.display !== "none"; + } + + onClickBack() { + return history.back(); + } + + onClickForward() { + return history.forward(); + } + + onClickToggleSidebar() { + if (this.isSidebarShown()) { + this.hideSidebar(); + } else { + this.showSidebar(); + } + } + + onClickDocPickerTab(event) { + $.stopEvent(event); + this.showDocPicker(); + } + + onClickSettingsTab(event) { + $.stopEvent(event); + this.showSettings(); + } + + showDocPicker() { + window.scrollTo(0, 0); + this.docPickerTab.classList.add("active"); + this.settingsTab.classList.remove("active"); + this.docPicker.style.display = "block"; + this.content.style.display = "none"; + } + + showSettings() { + window.scrollTo(0, 0); + this.docPickerTab.classList.remove("active"); + this.settingsTab.classList.add("active"); + this.docPicker.style.display = "none"; + this.content.style.display = "block"; + } + + onTapSearch() { + return window.scrollTo(0, 0); + } + + onEscape() { + return this.hideSidebar(); + } + + afterRoute(route) { + this.hideSidebar(); + + if (route === "settings") { + this.showDocPicker(); + } else { + this.content.style.display = "block"; + } + + if (page.canGoBack()) { + this.back.removeAttribute("disabled"); + } else { + this.back.setAttribute("disabled", "disabled"); + } + + if (page.canGoForward()) { + this.forward.removeAttribute("disabled"); + } else { + this.forward.setAttribute("disabled", "disabled"); + } + } +}; diff --git a/assets/javascripts/views/layout/path.coffee b/assets/javascripts/views/layout/path.coffee deleted file mode 100644 index fb34afda61..0000000000 --- a/assets/javascripts/views/layout/path.coffee +++ /dev/null @@ -1,43 +0,0 @@ -class app.views.Path extends app.View - @className: '_path' - @attributes: - role: 'complementary' - - @events: - click: 'onClick' - - @routes: - after: 'afterRoute' - - render: (args...) -> - @html @tmpl 'path', args... - @show() - return - - show: -> - @prependTo app.el unless @el.parentNode - return - - hide: -> - $.remove @el if @el.parentNode - return - - onClick: (event) => - @clicked = true if link = $.closestLink event.target, @el - return - - afterRoute: (route, context) => - if context.type - @render context.doc, context.type - else if context.entry - if context.entry.isIndex() - @render context.doc - else - @render context.doc, context.entry.getType(), context.entry - else - @hide() - - if @clicked - @clicked = null - app.document.sidebar.reset() - return diff --git a/assets/javascripts/views/layout/path.js b/assets/javascripts/views/layout/path.js new file mode 100644 index 0000000000..1b597e7af8 --- /dev/null +++ b/assets/javascripts/views/layout/path.js @@ -0,0 +1,51 @@ +app.views.Path = class Path extends app.View { + static className = "_path"; + static attributes = { role: "complementary" }; + + static events = { click: "onClick" }; + + static routes = { after: "afterRoute" }; + + render(...args) { + this.html(this.tmpl("path", ...args)); + this.show(); + } + + show() { + if (!this.el.parentNode) { + this.prependTo(app.el); + } + } + + hide() { + if (this.el.parentNode) { + $.remove(this.el); + } + } + + onClick(event) { + const link = $.closestLink(event.target, this.el); + if (link) { + this.clicked = true; + } + } + + afterRoute(route, context) { + if (context.type) { + this.render(context.doc, context.type); + } else if (context.entry) { + if (context.entry.isIndex()) { + this.render(context.doc); + } else { + this.render(context.doc, context.entry.getType(), context.entry); + } + } else { + this.hide(); + } + + if (this.clicked) { + this.clicked = null; + app.document.sidebar.reset(); + } + } +}; diff --git a/assets/javascripts/views/layout/resizer.coffee b/assets/javascripts/views/layout/resizer.coffee deleted file mode 100644 index 5584bfbe50..0000000000 --- a/assets/javascripts/views/layout/resizer.coffee +++ /dev/null @@ -1,49 +0,0 @@ -class app.views.Resizer extends app.View - @className: '_resizer' - - @events: - dragstart: 'onDragStart' - dragend: 'onDragEnd' - - @isSupported: -> - 'ondragstart' of document.createElement('div') and !app.isMobile() - - init: -> - @el.setAttribute('draggable', 'true') - @appendTo $('._app') - return - - MIN = 260 - MAX = 600 - - resize: (value, save) -> - value -= app.el.offsetLeft - return unless value > 0 - value = Math.min(Math.max(Math.round(value), MIN), MAX) - newSize = "#{value}px" - document.documentElement.style.setProperty('--sidebarWidth', newSize) - app.settings.setSize(value) if save - return - - onDragStart: (event) => - event.dataTransfer.effectAllowed = 'link' - event.dataTransfer.setData('Text', '') - $.on(window, 'dragover', @onDrag) - return - - onDrag: (event) => - value = event.pageX - return unless value > 0 - @lastDragValue = value - return if @lastDrag and @lastDrag > Date.now() - 50 - @lastDrag = Date.now() - @resize(value, false) - return - - onDragEnd: (event) => - $.off(window, 'dragover', @onDrag) - value = event.pageX or (event.screenX - window.screenX) - if @lastDragValue and not (@lastDragValue - 5 < value < @lastDragValue + 5) # https://github.com/freeCodeCamp/devdocs/issues/265 - value = @lastDragValue - @resize(value, true) - return diff --git a/assets/javascripts/views/layout/resizer.js b/assets/javascripts/views/layout/resizer.js new file mode 100644 index 0000000000..93d7331624 --- /dev/null +++ b/assets/javascripts/views/layout/resizer.js @@ -0,0 +1,66 @@ +app.views.Resizer = class Resizer extends app.View { + static className = "_resizer"; + + static events = { + dragstart: "onDragStart", + dragend: "onDragEnd", + }; + + static MIN = 260; + static MAX = 600; + + static isSupported() { + return "ondragstart" in document.createElement("div") && !app.isMobile(); + } + + init() { + this.el.setAttribute("draggable", "true"); + this.appendTo($("._app")); + } + + resize(value, save) { + value -= app.el.offsetLeft; + if (!(value > 0)) { + return; + } + value = Math.min(Math.max(Math.round(value), Resizer.MIN), Resizer.MAX); + const newSize = `${value}px`; + document.documentElement.style.setProperty("--sidebarWidth", newSize); + if (save) { + app.settings.setSize(value); + } + } + + onDragStart(event) { + event.dataTransfer.effectAllowed = "link"; + event.dataTransfer.setData("Text", ""); + this.onDrag = this.onDrag.bind(this); + $.on(window, "dragover", this.onDrag); + } + + onDrag(event) { + const value = event.pageX; + if (!(value > 0)) { + return; + } + this.lastDragValue = value; + if (this.lastDrag && this.lastDrag > Date.now() - 50) { + return; + } + this.lastDrag = Date.now(); + this.resize(value, false); + } + + onDragEnd(event) { + $.off(window, "dragover", this.onDrag); + let value = event.pageX || event.screenX - window.screenX; + if ( + this.lastDragValue && + !(this.lastDragValue - 5 < value && value < this.lastDragValue + 5) + ) { + // https://github.com/freeCodeCamp/devdocs/issues/265 + value = this.lastDragValue; + } + this.resize(value, true); + } +}; diff --git a/assets/javascripts/views/layout/settings.coffee b/assets/javascripts/views/layout/settings.coffee deleted file mode 100644 index 6941b9cd25..0000000000 --- a/assets/javascripts/views/layout/settings.coffee +++ /dev/null @@ -1,83 +0,0 @@ -class app.views.Settings extends app.View - SIDEBAR_HIDDEN_LAYOUT = '_sidebar-hidden' - - @el: '._settings' - - @elements: - sidebar: '._sidebar' - saveBtn: 'button[type="submit"]' - backBtn: 'button[data-back]' - - @events: - import: 'onImport' - change: 'onChange' - submit: 'onSubmit' - click: 'onClick' - - @shortcuts: - enter: 'onEnter' - - init: -> - @addSubview @docPicker = new app.views.DocPicker - return - - activate: -> - if super - @render() - document.body.classList.remove(SIDEBAR_HIDDEN_LAYOUT) - return - - deactivate: -> - if super - @resetClass() - @docPicker.detach() - document.body.classList.add(SIDEBAR_HIDDEN_LAYOUT) if app.settings.hasLayout(SIDEBAR_HIDDEN_LAYOUT) - return - - render: -> - @docPicker.appendTo @sidebar - @refreshElements() - @addClass '_in' - return - - save: (options = {}) -> - unless @saving - @saving = true - - if options.import - docs = app.settings.getDocs() - else - docs = @docPicker.getSelectedDocs() - app.settings.setDocs(docs) - - @saveBtn.textContent = 'Saving\u2026' - disabledDocs = new app.collections.Docs(doc for doc in app.docs.all() when docs.indexOf(doc.slug) is -1) - disabledDocs.uninstall -> - app.db.migrate() - app.reload() - return - - onChange: => - @addClass('_dirty') - return - - onEnter: => - @save() - return - - onSubmit: (event) => - event.preventDefault() - @save() - return - - onImport: => - @addClass('_dirty') - @save(import: true) - return - - onClick: (event) => - return if event.which isnt 1 - if event.target is @backBtn - $.stopEvent(event) - app.router.show '/' - return diff --git a/assets/javascripts/views/layout/settings.js b/assets/javascripts/views/layout/settings.js new file mode 100644 index 0000000000..52546dd9c6 --- /dev/null +++ b/assets/javascripts/views/layout/settings.js @@ -0,0 +1,109 @@ +app.views.Settings = class Settings extends app.View { + static SIDEBAR_HIDDEN_LAYOUT = "_sidebar-hidden"; + + static el = "._settings"; + + static elements = { + sidebar: "._sidebar", + saveBtn: 'button[type="submit"]', + backBtn: "button[data-back]", + }; + + static events = { + import: "onImport", + change: "onChange", + submit: "onSubmit", + click: "onClick", + }; + + static shortcuts = { enter: "onEnter" }; + + init() { + this.addSubview((this.docPicker = new app.views.DocPicker())); + } + + activate() { + if (super.activate(...arguments)) { + this.render(); + document.body.classList.remove(Settings.SIDEBAR_HIDDEN_LAYOUT); + } + } + + deactivate() { + if (super.deactivate(...arguments)) { + this.resetClass(); + this.docPicker.detach(); + if (app.settings.hasLayout(Settings.SIDEBAR_HIDDEN_LAYOUT)) { + document.body.classList.add(Settings.SIDEBAR_HIDDEN_LAYOUT); + } + } + } + + render() { + this.docPicker.appendTo(this.sidebar); + this.refreshElements(); + this.addClass("_in"); + } + + save(options) { + if (options == null) { + options = {}; + } + if (!this.saving) { + let docs; + this.saving = true; + + if (options.import) { + docs = app.settings.getDocs(); + } else { + docs = this.docPicker.getSelectedDocs(); + app.settings.setDocs(docs); + } + + this.saveBtn.textContent = "Saving\u2026"; + const disabledDocs = new app.collections.Docs( + (() => { + const result = []; + for (var doc of app.docs.all()) { + if (!docs.includes(doc.slug)) { + result.push(doc); + } + } + return result; + })(), + ); + disabledDocs.uninstall(() => { + app.db.migrate(); + return app.reload(); + }); + } + } + + onChange() { + this.addClass("_dirty"); + } + + onEnter() { + this.save(); + } + + onSubmit(event) { + event.preventDefault(); + this.save(); + } + + onImport() { + this.addClass("_dirty"); + this.save({ import: true }); + } + + onClick(event) { + if (event.which !== 1) { + return; + } + if (event.target === this.backBtn) { + $.stopEvent(event); + app.router.show("/"); + } + } +}; diff --git a/assets/javascripts/views/list/list_focus.coffee b/assets/javascripts/views/list/list_focus.coffee deleted file mode 100644 index 808810196d..0000000000 --- a/assets/javascripts/views/list/list_focus.coffee +++ /dev/null @@ -1,124 +0,0 @@ -class app.views.ListFocus extends app.View - @activeClass: 'focus' - - @events: - click: 'onClick' - - @shortcuts: - up: 'onUp' - down: 'onDown' - left: 'onLeft' - enter: 'onEnter' - superEnter: 'onSuperEnter' - escape: 'blur' - - constructor: (@el) -> - super - @focusOnNextFrame = $.framify(@focus, @) - - focus: (el, options = {}) -> - if el and not el.classList.contains @constructor.activeClass - @blur() - el.classList.add @constructor.activeClass - $.trigger el, 'focus' unless options.silent is true - return - - blur: => - if cursor = @getCursor() - cursor.classList.remove @constructor.activeClass - $.trigger cursor, 'blur' - return - - getCursor: -> - @findByClass(@constructor.activeClass) or @findByClass(app.views.ListSelect.activeClass) - - findNext: (cursor) -> - if next = cursor.nextSibling - if next.tagName is 'A' - next - else if next.tagName is 'SPAN' # pagination link - $.click(next) - @findNext cursor - else if next.tagName is 'DIV' # sub-list - if cursor.className.indexOf(' open') >= 0 - @findFirst(next) or @findNext(next) - else - @findNext(next) - else if next.tagName is 'H6' # title - @findNext(next) - else if cursor.parentNode isnt @el - @findNext cursor.parentNode - - findFirst: (cursor) -> - return unless first = cursor.firstChild - - if first.tagName is 'A' - first - else if first.tagName is 'SPAN' # pagination link - $.click(first) - @findFirst cursor - - findPrev: (cursor) -> - if prev = cursor.previousSibling - if prev.tagName is 'A' - prev - else if prev.tagName is 'SPAN' # pagination link - $.click(prev) - @findPrev cursor - else if prev.tagName is 'DIV' # sub-list - if prev.previousSibling.className.indexOf('open') >= 0 - @findLast(prev) or @findPrev(prev) - else - @findPrev(prev) - else if prev.tagName is 'H6' # title - @findPrev(prev) - else if cursor.parentNode isnt @el - @findPrev cursor.parentNode - - findLast: (cursor) -> - return unless last = cursor.lastChild - - if last.tagName is 'A' - last - else if last.tagName is 'SPAN' or last.tagName is 'H6' # pagination link or title - @findPrev last - else if last.tagName is 'DIV' # sub-list - @findLast last - - onDown: => - if cursor = @getCursor() - @focusOnNextFrame @findNext(cursor) - else - @focusOnNextFrame @findByTag('a') - return - - onUp: => - if cursor = @getCursor() - @focusOnNextFrame @findPrev(cursor) - else - @focusOnNextFrame @findLastByTag('a') - return - - onLeft: => - cursor = @getCursor() - if cursor and not cursor.classList.contains(app.views.ListFold.activeClass) and cursor.parentNode isnt @el - prev = cursor.parentNode.previousSibling - @focusOnNextFrame cursor.parentNode.previousSibling if prev and prev.classList.contains(app.views.ListFold.targetClass) - return - - onEnter: => - if cursor = @getCursor() - $.click(cursor) - return - - onSuperEnter: => - if cursor = @getCursor() - $.popup(cursor) - return - - onClick: (event) => - return if event.which isnt 1 or event.metaKey or event.ctrlKey - target = $.eventTarget(event) - if target.tagName is 'A' - @focus target, silent: true - return diff --git a/assets/javascripts/views/list/list_focus.js b/assets/javascripts/views/list/list_focus.js new file mode 100644 index 0000000000..a1a010b20a --- /dev/null +++ b/assets/javascripts/views/list/list_focus.js @@ -0,0 +1,185 @@ +app.views.ListFocus = class ListFocus extends app.View { + static activeClass = "focus"; + + static events = { click: "onClick" }; + + static shortcuts = { + up: "onUp", + down: "onDown", + left: "onLeft", + enter: "onEnter", + superEnter: "onSuperEnter", + escape: "blur", + }; + + constructor(el) { + super(el); + this.focusOnNextFrame = (el) => requestAnimationFrame(() => this.focus(el)); + } + + focus(el, options) { + if (options == null) { + options = {}; + } + if (el && !el.classList.contains(this.constructor.activeClass)) { + this.blur(); + el.classList.add(this.constructor.activeClass); + if (options.silent !== true) { + $.trigger(el, "focus"); + } + } + } + + blur() { + const cursor = this.getCursor(); + if (cursor) { + cursor.classList.remove(this.constructor.activeClass); + $.trigger(cursor, "blur"); + } + } + + getCursor() { + return ( + this.findByClass(this.constructor.activeClass) || + this.findByClass(app.views.ListSelect.activeClass) + ); + } + + findNext(cursor) { + const next = cursor.nextSibling; + if (next) { + if (next.tagName === "A") { + return next; + } else if (next.tagName === "SPAN") { + // pagination link + $.click(next); + return this.findNext(cursor); + } else if (next.tagName === "DIV") { + // sub-list + if (cursor.className.includes(" open")) { + return this.findFirst(next) || this.findNext(next); + } else { + return this.findNext(next); + } + } else if (next.tagName === "H6") { + // title + return this.findNext(next); + } + } else if (cursor.parentNode !== this.el) { + return this.findNext(cursor.parentNode); + } + } + + findFirst(cursor) { + const first = cursor.firstChild; + if (!first) { + return; + } + + if (first.tagName === "A") { + return first; + } else if (first.tagName === "SPAN") { + // pagination link + $.click(first); + return this.findFirst(cursor); + } + } + + findPrev(cursor) { + const prev = cursor.previousSibling; + if (prev) { + if (prev.tagName === "A") { + return prev; + } else if (prev.tagName === "SPAN") { + // pagination link + $.click(prev); + return this.findPrev(cursor); + } else if (prev.tagName === "DIV") { + // sub-list + if (prev.previousSibling.className.includes("open")) { + return this.findLast(prev) || this.findPrev(prev); + } else { + return this.findPrev(prev); + } + } else if (prev.tagName === "H6") { + // title + return this.findPrev(prev); + } + } else if (cursor.parentNode !== this.el) { + return this.findPrev(cursor.parentNode); + } + } + + findLast(cursor) { + const last = cursor.lastChild; + if (!last) { + return; + } + + if (last.tagName === "A") { + return last; + } else if (last.tagName === "SPAN" || last.tagName === "H6") { + // pagination link or title + return this.findPrev(last); + } else if (last.tagName === "DIV") { + // sub-list + return this.findLast(last); + } + } + + onDown() { + const cursor = this.getCursor(); + if (cursor) { + this.focusOnNextFrame(this.findNext(cursor)); + } else { + this.focusOnNextFrame(this.findByTag("a")); + } + } + + onUp() { + const cursor = this.getCursor(); + if (cursor) { + this.focusOnNextFrame(this.findPrev(cursor)); + } else { + this.focusOnNextFrame(this.findLastByTag("a")); + } + } + + onLeft() { + const cursor = this.getCursor(); + if ( + cursor && + !cursor.classList.contains(app.views.ListFold.activeClass) && + cursor.parentNode !== this.el + ) { + const prev = cursor.parentNode.previousSibling; + if (prev && prev.classList.contains(app.views.ListFold.targetClass)) { + this.focusOnNextFrame(cursor.parentNode.previousSibling); + } + } + } + + onEnter() { + const cursor = this.getCursor(); + if (cursor) { + $.click(cursor); + } + } + + onSuperEnter() { + const cursor = this.getCursor(); + if (cursor) { + $.popup(cursor); + } + } + + onClick(event) { + if (event.which !== 1 || event.metaKey || event.ctrlKey) { + return; + } + const target = $.eventTarget(event); + if (target.tagName === "A") { + this.focus(target, { silent: true }); + } + } +}; diff --git a/assets/javascripts/views/list/list_fold.coffee b/assets/javascripts/views/list/list_fold.coffee deleted file mode 100644 index da6f1d5e0e..0000000000 --- a/assets/javascripts/views/list/list_fold.coffee +++ /dev/null @@ -1,71 +0,0 @@ -class app.views.ListFold extends app.View - @targetClass: '_list-dir' - @handleClass: '_list-arrow' - @activeClass: 'open' - - @events: - click: 'onClick' - - @shortcuts: - left: 'onLeft' - right: 'onRight' - - constructor: (@el) -> super - - open: (el) -> - if el and not el.classList.contains @constructor.activeClass - el.classList.add @constructor.activeClass - $.trigger el, 'open' - return - - close: (el) -> - if el and el.classList.contains @constructor.activeClass - el.classList.remove @constructor.activeClass - $.trigger el, 'close' - return - - toggle: (el) -> - if el.classList.contains @constructor.activeClass - @close el - else - @open el - return - - reset: -> - while el = @findByClass @constructor.activeClass - @close el - return - - getCursor: -> - @findByClass(app.views.ListFocus.activeClass) or @findByClass(app.views.ListSelect.activeClass) - - onLeft: => - cursor = @getCursor() - if cursor?.classList.contains @constructor.activeClass - @close cursor - return - - onRight: => - cursor = @getCursor() - if cursor?.classList.contains @constructor.targetClass - @open cursor - return - - onClick: (event) => - return if event.which isnt 1 or event.metaKey or event.ctrlKey - return unless event.pageY # ignore fabricated clicks - el = $.eventTarget(event) - el = el.parentNode if el.parentNode.tagName.toUpperCase() is 'SVG' - - if el.classList.contains @constructor.handleClass - $.stopEvent(event) - @toggle el.parentNode - else if el.classList.contains @constructor.targetClass - if el.hasAttribute('href') - if el.classList.contains(@constructor.activeClass) - @close(el) if el.classList.contains(app.views.ListSelect.activeClass) - else - @open(el) - else - @toggle(el) - return diff --git a/assets/javascripts/views/list/list_fold.js b/assets/javascripts/views/list/list_fold.js new file mode 100644 index 0000000000..3964888bc9 --- /dev/null +++ b/assets/javascripts/views/list/list_fold.js @@ -0,0 +1,96 @@ +app.views.ListFold = class ListFold extends app.View { + static targetClass = "_list-dir"; + static handleClass = "_list-arrow"; + static activeClass = "open"; + + static events = { click: "onClick" }; + + static shortcuts = { + left: "onLeft", + right: "onRight", + }; + + open(el) { + if (el && !el.classList.contains(this.constructor.activeClass)) { + el.classList.add(this.constructor.activeClass); + $.trigger(el, "open"); + } + } + + close(el) { + if (el && el.classList.contains(this.constructor.activeClass)) { + el.classList.remove(this.constructor.activeClass); + $.trigger(el, "close"); + } + } + + toggle(el) { + if (el.classList.contains(this.constructor.activeClass)) { + this.close(el); + } else { + this.open(el); + } + } + + reset() { + let el; + while ((el = this.findByClass(this.constructor.activeClass))) { + this.close(el); + } + } + + getCursor() { + return ( + this.findByClass(app.views.ListFocus.activeClass) || + this.findByClass(app.views.ListSelect.activeClass) + ); + } + + onLeft() { + const cursor = this.getCursor(); + if (cursor?.classList?.contains(this.constructor.activeClass)) { + this.close(cursor); + } + } + + onRight() { + const cursor = this.getCursor(); + if ( + cursor != null + ? cursor.classList.contains(this.constructor.targetClass) + : undefined + ) { + this.open(cursor); + } + } + + onClick(event) { + if (event.which !== 1 || event.metaKey || event.ctrlKey) { + return; + } + if (!event.pageY) { + return; + } // ignore fabricated clicks + let el = $.eventTarget(event); + if (el.parentNode.tagName.toUpperCase() === "SVG") { + el = el.parentNode; + } + + if (el.classList.contains(this.constructor.handleClass)) { + $.stopEvent(event); + this.toggle(el.parentNode); + } else if (el.classList.contains(this.constructor.targetClass)) { + if (el.hasAttribute("href")) { + if (el.classList.contains(this.constructor.activeClass)) { + if (el.classList.contains(app.views.ListSelect.activeClass)) { + this.close(el); + } + } else { + this.open(el); + } + } else { + this.toggle(el); + } + } + } +}; diff --git a/assets/javascripts/views/list/list_select.coffee b/assets/javascripts/views/list/list_select.coffee deleted file mode 100644 index fe06b70b67..0000000000 --- a/assets/javascripts/views/list/list_select.coffee +++ /dev/null @@ -1,43 +0,0 @@ -class app.views.ListSelect extends app.View - @activeClass: 'active' - - @events: - click: 'onClick' - - constructor: (@el) -> super - - deactivate: -> - @deselect() if super - return - - select: (el) -> - @deselect() - if el - el.classList.add @constructor.activeClass - $.trigger el, 'select' - return - - deselect: -> - if selection = @getSelection() - selection.classList.remove @constructor.activeClass - $.trigger selection, 'deselect' - return - - selectByHref: (href) -> - unless @getSelection()?.getAttribute('href') is href - @select @find("a[href='#{href}']") - return - - selectCurrent: -> - @selectByHref location.pathname + location.hash - return - - getSelection: -> - @findByClass @constructor.activeClass - - onClick: (event) => - return if event.which isnt 1 or event.metaKey or event.ctrlKey - target = $.eventTarget(event) - if target.tagName is 'A' - @select target - return diff --git a/assets/javascripts/views/list/list_select.js b/assets/javascripts/views/list/list_select.js new file mode 100644 index 0000000000..f0203803c6 --- /dev/null +++ b/assets/javascripts/views/list/list_select.js @@ -0,0 +1,51 @@ +app.views.ListSelect = class ListSelect extends app.View { + static activeClass = "active"; + + static events = { click: "onClick" }; + + deactivate() { + if (super.deactivate(...arguments)) { + this.deselect(); + } + } + + select(el) { + this.deselect(); + if (el) { + el.classList.add(this.constructor.activeClass); + $.trigger(el, "select"); + } + } + + deselect() { + const selection = this.getSelection(); + if (selection) { + selection.classList.remove(this.constructor.activeClass); + $.trigger(selection, "deselect"); + } + } + + selectByHref(href) { + if (this.getSelection()?.getAttribute("href") !== href) { + this.select(this.find(`a[href='${href}']`)); + } + } + + selectCurrent() { + this.selectByHref(location.pathname + location.hash); + } + + getSelection() { + return this.findByClass(this.constructor.activeClass); + } + + onClick(event) { + if (event.which !== 1 || event.metaKey || event.ctrlKey) { + return; + } + const target = $.eventTarget(event); + if (target.tagName === "A") { + this.select(target); + } + } +}; diff --git a/assets/javascripts/views/list/paginated_list.coffee b/assets/javascripts/views/list/paginated_list.coffee deleted file mode 100644 index 0c6c438565..0000000000 --- a/assets/javascripts/views/list/paginated_list.coffee +++ /dev/null @@ -1,90 +0,0 @@ -class app.views.PaginatedList extends app.View - PER_PAGE = app.config.max_results - - constructor: (@data) -> - (@constructor.events or= {}).click ?= 'onClick' - super - - renderPaginated: -> - @page = 0 - - if @totalPages() > 1 - @paginateNext() - else - @html @renderAll() - return - - # render: (dataSlice) -> implemented by subclass - - renderAll: -> - @render @data - - renderPage: (page) -> - @render @data[((page - 1) * PER_PAGE)...(page * PER_PAGE)] - - renderPageLink: (count) -> - @tmpl 'sidebarPageLink', count - - renderPrevLink: (page) -> - @renderPageLink (page - 1) * PER_PAGE - - renderNextLink: (page) -> - @renderPageLink @data.length - page * PER_PAGE - - totalPages: -> - Math.ceil @data.length / PER_PAGE - - paginate: (link) -> - $.lockScroll link.nextSibling or link.previousSibling, => - $.batchUpdate @el, => - if link.nextSibling then @paginatePrev link else @paginateNext link - return - return - return - - paginateNext: -> - @remove @el.lastChild if @el.lastChild # remove link - @hideTopPage() if @page >= 2 # keep previous page into view - @page++ - @append @renderPage(@page) - @append @renderNextLink(@page) if @page < @totalPages() - return - - paginatePrev: -> - @remove @el.firstChild # remove link - @hideBottomPage() - @page-- - @prepend @renderPage(@page - 1) # previous page is offset by one - @prepend @renderPrevLink(@page - 1) if @page >= 3 - return - - paginateTo: (object) -> - index = @data.indexOf(object) - if index >= PER_PAGE - @paginateNext() for [0...(index // PER_PAGE)] - return - - hideTopPage: -> - n = if @page <= 2 - PER_PAGE - else - PER_PAGE + 1 # remove link - @remove @el.firstChild for [0...n] - @prepend @renderPrevLink(@page) - return - - hideBottomPage: -> - n = if @page is @totalPages() - @data.length % PER_PAGE or PER_PAGE - else - PER_PAGE + 1 # remove link - @remove @el.lastChild for [0...n] - @append @renderNextLink(@page - 1) - return - - onClick: (event) => - target = $.eventTarget(event) - if target.tagName is 'SPAN' # link - $.stopEvent(event) - @paginate target - return diff --git a/assets/javascripts/views/list/paginated_list.js b/assets/javascripts/views/list/paginated_list.js new file mode 100644 index 0000000000..7dabbcfe6d --- /dev/null +++ b/assets/javascripts/views/list/paginated_list.js @@ -0,0 +1,133 @@ +app.views.PaginatedList = class PaginatedList extends app.View { + static PER_PAGE = app.config.max_results; + + constructor(data) { + super(); + this.data = data; + this.constructor.events = this.constructor.events || {}; + if (this.constructor.events.click == null) { + this.constructor.events.click = "onClick"; + } + } + + renderPaginated() { + this.page = 0; + + if (this.totalPages() > 1) { + this.paginateNext(); + } else { + this.html(this.renderAll()); + } + } + + // render: (dataSlice) -> implemented by subclass + + renderAll() { + return this.render(this.data); + } + + renderPage(page) { + return this.render( + this.data.slice( + (page - 1) * PaginatedList.PER_PAGE, + page * PaginatedList.PER_PAGE, + ), + ); + } + + renderPageLink(count) { + return this.tmpl("sidebarPageLink", count); + } + + renderPrevLink(page) { + return this.renderPageLink((page - 1) * PaginatedList.PER_PAGE); + } + + renderNextLink(page) { + return this.renderPageLink( + this.data.length - page * PaginatedList.PER_PAGE, + ); + } + + totalPages() { + return Math.ceil(this.data.length / PaginatedList.PER_PAGE); + } + + paginate(link) { + $.lockScroll(link.nextSibling || link.previousSibling, () => { + $.batchUpdate(this.el, () => { + if (link.nextSibling) { + this.paginatePrev(link); + } else { + this.paginateNext(link); + } + }); + }); + } + + paginateNext() { + if (this.el.lastChild) { + this.remove(this.el.lastChild); + } // remove link + if (this.page >= 2) { + this.hideTopPage(); + } // keep previous page into view + this.page++; + this.append(this.renderPage(this.page)); + if (this.page < this.totalPages()) { + this.append(this.renderNextLink(this.page)); + } + } + + paginatePrev() { + this.remove(this.el.firstChild); // remove link + this.hideBottomPage(); + this.page--; + this.prepend(this.renderPage(this.page - 1)); // previous page is offset by one + if (this.page >= 3) { + this.prepend(this.renderPrevLink(this.page - 1)); + } + } + + paginateTo(object) { + const index = this.data.indexOf(object); + if (index >= PaginatedList.PER_PAGE) { + for ( + let i = 0, end = Math.floor(index / PaginatedList.PER_PAGE); + i < end; + i++ + ) { + this.paginateNext(); + } + } + } + + hideTopPage() { + const n = + this.page <= 2 ? PaginatedList.PER_PAGE : PaginatedList.PER_PAGE + 1; // remove link + for (let i = 0, end = n; i < end; i++) { + this.remove(this.el.firstChild); + } + this.prepend(this.renderPrevLink(this.page)); + } + + hideBottomPage() { + const n = + this.page === this.totalPages() + ? this.data.length % PaginatedList.PER_PAGE || PaginatedList.PER_PAGE + : PaginatedList.PER_PAGE + 1; // remove link + for (let i = 0, end = n; i < end; i++) { + this.remove(this.el.lastChild); + } + this.append(this.renderNextLink(this.page - 1)); + } + + onClick(event) { + const target = $.eventTarget(event); + if (target.tagName === "SPAN") { + // link + $.stopEvent(event); + this.paginate(target); + } + } +}; diff --git a/assets/javascripts/views/misc/news.coffee b/assets/javascripts/views/misc/news.coffee deleted file mode 100644 index a39fbb15cb..0000000000 --- a/assets/javascripts/views/misc/news.coffee +++ /dev/null @@ -1,34 +0,0 @@ -#= require views/misc/notif - -class app.views.News extends app.views.Notif - @className += ' _notif-news' - - @defautOptions: - autoHide: 30000 - - init: -> - @unreadNews = @getUnreadNews() - @show() if @unreadNews.length - @markAllAsRead() - return - - render: -> - @html app.templates.notifNews(@unreadNews) - return - - getUnreadNews: -> - return [] unless time = @getLastReadTime() - - for news in app.news - break if new Date(news[0]).getTime() <= time - news - - getLastNewsTime: -> - new Date(app.news[0][0]).getTime() - - getLastReadTime: -> - app.settings.get 'news' - - markAllAsRead: -> - app.settings.set 'news', @getLastNewsTime() - return diff --git a/assets/javascripts/views/misc/news.js b/assets/javascripts/views/misc/news.js new file mode 100644 index 0000000000..23d4f1193b --- /dev/null +++ b/assets/javascripts/views/misc/news.js @@ -0,0 +1,47 @@ +//= require views/misc/notif + +app.views.News = class News extends app.views.Notif { + static className = "_notif _notif-news"; + + static defaultOptions = { autoHide: 30000 }; + + init0() { + this.unreadNews = this.getUnreadNews(); + if (this.unreadNews.length) { + this.show(); + } + this.markAllAsRead(); + } + + render() { + this.html(app.templates.notifNews(this.unreadNews)); + } + + getUnreadNews() { + const time = this.getLastReadTime(); + if (!time) { + return []; + } + + const result = []; + for (var news of app.news) { + if (new Date(news[0]).getTime() <= time) { + break; + } + result.push(news); + } + return result; + } + + getLastNewsTime() { + return new Date(app.news[0][0]).getTime(); + } + + getLastReadTime() { + return app.settings.get("news"); + } + + markAllAsRead() { + app.settings.set("news", this.getLastNewsTime()); + } +}; diff --git a/assets/javascripts/views/misc/notice.coffee b/assets/javascripts/views/misc/notice.coffee deleted file mode 100644 index 2007930ed4..0000000000 --- a/assets/javascripts/views/misc/notice.coffee +++ /dev/null @@ -1,27 +0,0 @@ -class app.views.Notice extends app.View - @className: '_notice' - @attributes: - role: 'alert' - - constructor: (@type, @args...) -> super - - init: -> - @activate() - return - - activate: -> - @show() if super - return - - deactivate: -> - @hide() if super - return - - show: -> - @html @tmpl("#{@type}Notice", @args...) - @prependTo app.el - return - - hide: -> - $.remove @el - return diff --git a/assets/javascripts/views/misc/notice.js b/assets/javascripts/views/misc/notice.js new file mode 100644 index 0000000000..1370733758 --- /dev/null +++ b/assets/javascripts/views/misc/notice.js @@ -0,0 +1,37 @@ +app.views.Notice = class Notice extends app.View { + static className = "_notice"; + static attributes = { role: "alert" }; + + constructor(type, ...args) { + super(); + this.type = type; + this.args = args || []; + this.init0(); // needs this.args + this.refreshElements(); + } + + init0() { + this.activate(); + } + + activate() { + if (super.activate(...arguments)) { + this.show(); + } + } + + deactivate() { + if (super.deactivate(...arguments)) { + this.hide(); + } + } + + show() { + this.html(this.tmpl(`${this.type}Notice`, ...this.args)); + this.prependTo(app.el); + } + + hide() { + $.remove(this.el); + } +}; diff --git a/assets/javascripts/views/misc/notif.coffee b/assets/javascripts/views/misc/notif.coffee deleted file mode 100644 index dcf2a051da..0000000000 --- a/assets/javascripts/views/misc/notif.coffee +++ /dev/null @@ -1,59 +0,0 @@ -class app.views.Notif extends app.View - @className: '_notif' - @activeClass: '_in' - @attributes: - role: 'alert' - - @defautOptions: - autoHide: 15000 - - @events: - click: 'onClick' - - constructor: (@type, @options = {}) -> - @options = $.extend {}, @constructor.defautOptions, @options - super - - init: -> - @show() - return - - show: -> - if @timeout - clearTimeout @timeout - @timeout = @delay @hide, @options.autoHide - else - @render() - @position() - @activate() - @appendTo document.body - @el.offsetWidth # force reflow - @addClass @constructor.activeClass - @timeout = @delay @hide, @options.autoHide if @options.autoHide - return - - hide: -> - clearTimeout @timeout - @timeout = null - @detach() - return - - render: -> - @html @tmpl("notif#{@type}") - return - - position: -> - notifications = $$ ".#{app.views.Notif.className}" - if notifications.length - lastNotif = notifications[notifications.length - 1] - @el.style.top = lastNotif.offsetTop + lastNotif.offsetHeight + 16 + 'px' - return - - onClick: (event) => - return if event.which isnt 1 - target = $.eventTarget(event) - return if target.hasAttribute('data-behavior') - if target.tagName isnt 'A' or target.classList.contains('_notif-close') - $.stopEvent(event) - @hide() - return diff --git a/assets/javascripts/views/misc/notif.js b/assets/javascripts/views/misc/notif.js new file mode 100644 index 0000000000..d2b1858e00 --- /dev/null +++ b/assets/javascripts/views/misc/notif.js @@ -0,0 +1,71 @@ +app.views.Notif = class Notif extends app.View { + static className = "_notif"; + static activeClass = "_in"; + static attributes = { role: "alert" }; + + static defaultOptions = { autoHide: 15000 }; + + static events = { click: "onClick" }; + + constructor(type, options) { + super(); + this.type = type; + this.options = { ...this.constructor.defaultOptions, ...(options || {}) }; + this.init0(); // needs this.options + this.refreshElements(); + } + + init0() { + this.show(); + } + + show() { + if (this.timeout) { + clearTimeout(this.timeout); + this.timeout = this.delay(this.hide, this.options.autoHide); + } else { + this.render(); + this.position(); + this.activate(); + this.appendTo(document.body); + this.el.offsetWidth; // force reflow + this.addClass(this.constructor.activeClass); + if (this.options.autoHide) { + this.timeout = this.delay(this.hide, this.options.autoHide); + } + } + } + + hide() { + clearTimeout(this.timeout); + this.timeout = null; + this.detach(); + } + + render() { + this.html(this.tmpl(`notif${this.type}`)); + } + + position() { + const notifications = $$(`.${Notif.className}`); + if (notifications.length) { + const lastNotif = notifications[notifications.length - 1]; + this.el.style.top = + lastNotif.offsetTop + lastNotif.offsetHeight + 16 + "px"; + } + } + + onClick(event) { + if (event.which !== 1) { + return; + } + const target = $.eventTarget(event); + if (target.hasAttribute("data-behavior")) { + return; + } + if (target.tagName !== "A" || target.classList.contains("_notif-close")) { + $.stopEvent(event); + this.hide(); + } + } +}; diff --git a/assets/javascripts/views/misc/tip.coffee b/assets/javascripts/views/misc/tip.coffee deleted file mode 100644 index 6fec52a2f4..0000000000 --- a/assets/javascripts/views/misc/tip.coffee +++ /dev/null @@ -1,11 +0,0 @@ -#= require views/misc/notif - -class app.views.Tip extends app.views.Notif - @className: '_notif _notif-tip' - - @defautOptions: - autoHide: false - - render: -> - @html @tmpl("tip#{@type}") - return diff --git a/assets/javascripts/views/misc/tip.js b/assets/javascripts/views/misc/tip.js new file mode 100644 index 0000000000..c9b4cf8903 --- /dev/null +++ b/assets/javascripts/views/misc/tip.js @@ -0,0 +1,11 @@ +//= require views/misc/notif + +app.views.Tip = class Tip extends app.views.Notif { + static className = "_notif _notif-tip"; + + static defautOptions = { autoHide: false }; + + render() { + this.html(this.tmpl(`tip${this.type}`)); + } +}; diff --git a/assets/javascripts/views/misc/updates.coffee b/assets/javascripts/views/misc/updates.coffee deleted file mode 100644 index 8b91ccfd98..0000000000 --- a/assets/javascripts/views/misc/updates.coffee +++ /dev/null @@ -1,34 +0,0 @@ -#= require views/misc/notif - -class app.views.Updates extends app.views.Notif - @className += ' _notif-news' - - @defautOptions: - autoHide: 30000 - - init: -> - @lastUpdateTime = @getLastUpdateTime() - @updatedDocs = @getUpdatedDocs() - @updatedDisabledDocs = @getUpdatedDisabledDocs() - @show() if @updatedDocs.length > 0 or @updatedDisabledDocs.length > 0 - @markAllAsRead() - return - - render: -> - @html app.templates.notifUpdates(@updatedDocs, @updatedDisabledDocs) - return - - getUpdatedDocs: -> - return [] unless @lastUpdateTime - doc for doc in app.docs.all() when doc.mtime > @lastUpdateTime - - getUpdatedDisabledDocs: -> - return [] unless @lastUpdateTime - doc for doc in app.disabledDocs.all() when doc.mtime > @lastUpdateTime and app.docs.findBy('slug_without_version', doc.slug_without_version) - - getLastUpdateTime: -> - app.settings.get 'version' - - markAllAsRead: -> - app.settings.set 'version', if app.config.env is 'production' then app.config.version else Math.floor(Date.now() / 1000) - return diff --git a/assets/javascripts/views/misc/updates.js b/assets/javascripts/views/misc/updates.js new file mode 100644 index 0000000000..480f8599d4 --- /dev/null +++ b/assets/javascripts/views/misc/updates.js @@ -0,0 +1,61 @@ +//= require views/misc/notif + +app.views.Updates = class Updates extends app.views.Notif { + static className = "_notif _notif-news"; + + static defautOptions = { autoHide: 30000 }; + + init0() { + this.lastUpdateTime = this.getLastUpdateTime(); + this.updatedDocs = this.getUpdatedDocs(); + this.updatedDisabledDocs = this.getUpdatedDisabledDocs(); + if (this.updatedDocs.length > 0 || this.updatedDisabledDocs.length > 0) { + this.show(); + } + this.markAllAsRead(); + } + + render() { + this.html( + app.templates.notifUpdates(this.updatedDocs, this.updatedDisabledDocs), + ); + } + + getUpdatedDocs() { + if (!this.lastUpdateTime) { + return []; + } + return Array.from(app.docs.all()).filter( + (doc) => doc.mtime > this.lastUpdateTime, + ); + } + + getUpdatedDisabledDocs() { + if (!this.lastUpdateTime) { + return []; + } + const result = []; + for (var doc of Array.from(app.disabledDocs.all())) { + if ( + doc.mtime > this.lastUpdateTime && + app.docs.findBy("slug_without_version", doc.slug_without_version) + ) { + result.push(doc); + } + } + return result; + } + + getLastUpdateTime() { + return app.settings.get("version"); + } + + markAllAsRead() { + app.settings.set( + "version", + app.config.env === "production" + ? app.config.version + : Math.floor(Date.now() / 1000), + ); + } +}; diff --git a/assets/javascripts/views/pages/base.coffee b/assets/javascripts/views/pages/base.coffee deleted file mode 100644 index e1c0b6a49e..0000000000 --- a/assets/javascripts/views/pages/base.coffee +++ /dev/null @@ -1,43 +0,0 @@ -class app.views.BasePage extends app.View - constructor: (@el, @entry) -> super - - deactivate: -> - if super - @highlightNodes = [] - - render: (content, fromCache = false) -> - @highlightNodes = [] - @previousTiming = null - @addClass "_#{@entry.doc.type}" unless @constructor.className - @html content - @highlightCode() unless fromCache - @activate() - @delay @afterRender if @afterRender - if @highlightNodes.length > 0 - $.requestAnimationFrame => $.requestAnimationFrame(@paintCode) - return - - highlightCode: -> - for el in @findAll('pre[data-language]') - language = el.getAttribute('data-language') - el.classList.add("language-#{language}") - @highlightNodes.push(el) - return - - paintCode: (timing) => - if @previousTiming - if Math.round(1000 / (timing - @previousTiming)) > 50 # fps - @nodesPerFrame = Math.round(Math.min(@nodesPerFrame * 1.25, 50)) - else - @nodesPerFrame = Math.round(Math.max(@nodesPerFrame * .8, 10)) - else - @nodesPerFrame = 10 - - for el in @highlightNodes.splice(0, @nodesPerFrame) - $.remove(clipEl) if clipEl = el.lastElementChild - Prism.highlightElement(el) - $.append(el, clipEl) if clipEl - - $.requestAnimationFrame(@paintCode) if @highlightNodes.length > 0 - @previousTiming = timing - return diff --git a/assets/javascripts/views/pages/base.js b/assets/javascripts/views/pages/base.js new file mode 100644 index 0000000000..1df5971a7d --- /dev/null +++ b/assets/javascripts/views/pages/base.js @@ -0,0 +1,73 @@ +app.views.BasePage = class BasePage extends app.View { + constructor(el, entry) { + super(el); + this.entry = entry; + } + + deactivate() { + if (super.deactivate(...arguments)) { + return (this.highlightNodes = []); + } + } + + render(content, fromCache) { + if (fromCache == null) { + fromCache = false; + } + this.highlightNodes = []; + this.previousTiming = null; + if (!this.constructor.className) { + this.addClass(`_${this.entry.doc.type}`); + } + this.html(content); + if (!fromCache) { + this.highlightCode(); + } + this.activate(); + if (this.afterRender) { + this.delay(this.afterRender); + } + if (this.highlightNodes.length > 0) { + requestAnimationFrame(() => this.paintCode()); + } + } + + highlightCode() { + for (var el of this.findAll("pre[data-language]")) { + var language = el.getAttribute("data-language"); + el.classList.add(`language-${language}`); + this.highlightNodes.push(el); + } + } + + paintCode(timing) { + if (this.previousTiming) { + if (Math.round(1000 / (timing - this.previousTiming)) > 50) { + // fps + this.nodesPerFrame = Math.round( + Math.min(this.nodesPerFrame * 1.25, 50), + ); + } else { + this.nodesPerFrame = Math.round(Math.max(this.nodesPerFrame * 0.8, 10)); + } + } else { + this.nodesPerFrame = 10; + } + + for (var el of this.highlightNodes.splice(0, this.nodesPerFrame)) { + const clipEl = el.lastElementChild; + if (clipEl) { + $.remove(clipEl); + } + Prism.highlightElement(el); + if (clipEl) { + $.append(el, clipEl); + } + } + + if (this.highlightNodes.length > 0) { + requestAnimationFrame(() => this.paintCode()); + } + this.previousTiming = timing; + } +}; diff --git a/assets/javascripts/views/pages/hidden.coffee b/assets/javascripts/views/pages/hidden.coffee deleted file mode 100644 index f17080a06b..0000000000 --- a/assets/javascripts/views/pages/hidden.coffee +++ /dev/null @@ -1,16 +0,0 @@ -class app.views.HiddenPage extends app.View - @events: - click: 'onClick' - - constructor: (@el, @entry) -> super - - init: -> - @addSubview @notice = new app.views.Notice 'disabledDoc' - @activate() - return - - onClick: (event) => - if link = $.closestLink(event.target, @el) - $.stopEvent(event) - $.popup(link) - return diff --git a/assets/javascripts/views/pages/hidden.js b/assets/javascripts/views/pages/hidden.js new file mode 100644 index 0000000000..8872bdf0cc --- /dev/null +++ b/assets/javascripts/views/pages/hidden.js @@ -0,0 +1,22 @@ +app.views.HiddenPage = class HiddenPage extends app.View { + static events = { click: "onClick" }; + + constructor(el, entry) { + super(el); + this.entry = entry; + } + + init() { + this.notice = new app.views.Notice("disabledDoc"); + this.addSubview(this.notice); + this.activate(); + } + + onClick(event) { + const link = $.closestLink(event.target, this.el); + if (link) { + $.stopEvent(event); + $.popup(link); + } + } +}; diff --git a/assets/javascripts/views/pages/jquery.coffee b/assets/javascripts/views/pages/jquery.coffee deleted file mode 100644 index 47f021959f..0000000000 --- a/assets/javascripts/views/pages/jquery.coffee +++ /dev/null @@ -1,57 +0,0 @@ -#= require views/pages/base - -class app.views.JqueryPage extends app.views.BasePage - @demoClassName: '_jquery-demo' - - afterRender: -> - # Prevent jQuery Mobile's demo iframes from scrolling the page - for iframe in @findAllByTag 'iframe' - iframe.style.display = 'none' - $.on iframe, 'load', @onIframeLoaded - - @runExamples() - - onIframeLoaded: (event) => - event.target.style.display = '' - $.off event.target, 'load', @onIframeLoaded - return - - runExamples: -> - for el in @findAllByClass 'entry-example' - try @runExample el catch - return - - runExample: (el) -> - source = el.getElementsByClassName('syntaxhighlighter')[0] - return unless source and source.innerHTML.indexOf('!doctype') isnt -1 - - unless iframe = el.getElementsByClassName(@constructor.demoClassName)[0] - iframe = document.createElement 'iframe' - iframe.className = @constructor.demoClassName - iframe.width = '100%' - iframe.height = 200 - el.appendChild(iframe) - - doc = iframe.contentDocument - doc.write @fixIframeSource(source.textContent) - doc.close() - return - - fixIframeSource: (source) -> - source = source.replace '"/resources/', '"https://api.jquery.com/resources/' # attr(), keydown() - source = source.replace '', """ - - - - """ - source.replace / +\ +`, + ); + return source.replace(/