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) |  |  |
| [deibit/vscode-devdocs](https://marketplace.visualstudio.com/items?itemName=deibit.devdocs) | VS Code extension (open the browser) |  |  |
| [mdh34/quickDocs](https://github.com/mdh34/quickDocs) | Vala/Python based viewer |  |  |
+| [girishji/devdocs.vim](https://github.com/girishji/devdocs.vim) | Vim plugin & TUI (browse inside Vim) |  |  |
| [romainl/vim-devdocs](https://github.com/romainl/vim-devdocs) | Vim plugin |  |  |
| [waiting-for-dev/vim-www](https://github.com/waiting-for-dev/vim-www) | Vim plugin |  |  |
+| [emmanueltouzery/apidocs.nvim](https://github.com/emmanueltouzery/apidocs.nvim) | Neovim plugin |  |  |
+| [toiletbril/dedoc](https://github.com/toiletbril/dedoc) | Terminal based viewer |  |  |
+| [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 |  |  |
## 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 #{text}
""" if text - links = """#{links}
""" if links - """#{exception.name}: #{exception.message} """
- when 'cant_open'
- """ An error occurred when trying to open the IndexedDB database:#{exception.name}: #{exception.message}DevDocs is an API documentation browser which supports the following browsers: -
- 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 -
${text}
`; + } + if (links) { + links = `${links}
`; + } + return `${exception.name}: ${exception.message} `;
+ case "cant_open":
+ return ` An error occurred when trying to open the IndexedDB database:${exception.name}: ${exception.message}DevDocs is an API documentation browser which supports the following browsers: +
+ 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 +
#{text}
""" - -app.templates.singleDocNotice = (doc) -> - notice """ You're browsing the #{doc.fullName} documentation. To browse all docs, go to - #{app.config.production_host} (or pressesc). """
-
-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 pressesc). `);
+
+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}
-
- """
-
-textNotif = (title, message) ->
- notif title, """#{message}"""
-
-app.templates.notifUpdateReady = ->
- textNotif """DevDocs has been updated.""",
- """Reload the page to use the new version."""
-
-app.templates.notifError = ->
- textNotif """ Oops, an error occurred. """,
- """ Try reloading, and if the problem persists,
- resetting the app.
- You can also report this issue on GitHub. """
-
-app.templates.notifQuotaExceeded = ->
- textNotif """ The offline database has exceeded its size limitation. """,
- """ Unfortunately this quota can't be detected programmatically, and the database can't be opened while over the quota, so it had to be reset. """
-
-app.templates.notifCookieBlocked = ->
- textNotif """ Please enable cookies. """,
- """ DevDocs will not work properly if cookies are disabled. """
-
-app.templates.notifInvalidLocation = ->
- textNotif """ DevDocs must be loaded from #{app.config.production_host} """,
- """ Otherwise things are likely to break. """
-
-app.templates.notifImportInvalid = ->
- textNotif """ Oops, an error occurred. """,
- """ The file you selected is invalid. """
-
-app.templates.notifNews = (news) ->
- notif 'Changelog', """
→ #{doc.release}" if doc.release
- html += 'Disabled:' - html += '
→ #{doc.release}" if doc.release
- html += """Enable"""
- html += '${message}`);
+
+app.templates.notifUpdateReady = () =>
+ textNotif(
+ 'DevDocs has been updated.',
+ 'Reload the page to use the new version.',
+ );
+
+app.templates.notifError = () =>
+ textNotif(
+ " Oops, an error occurred. ",
+ ` Try reloading, and if the problem persists,
+resetting the app.
+You can also report this issue on GitHub. `,
+ );
+
+app.templates.notifQuotaExceeded = () =>
+ textNotif(
+ " The offline database has exceeded its size limitation. ",
+ " Unfortunately this quota can't be detected programmatically, and the database can't be opened while over the quota, so it had to be reset. ",
+ );
+
+app.templates.notifCookieBlocked = () =>
+ textNotif(
+ " Please enable cookies. ",
+ " DevDocs will not work properly if cookies are disabled. ",
+ );
+
+app.templates.notifInvalidLocation = () =>
+ textNotif(
+ ` DevDocs must be loaded from ${app.config.production_host} `,
+ " Otherwise things are likely to break. ",
+ );
+
+app.templates.notifImportInvalid = () =>
+ textNotif(
+ " Oops, an error occurred. ",
+ " The file you selected is invalid. ",
+ );
+
+app.templates.notifNews = (news) =>
+ notif(
+ "Changelog",
+ `
→ ${doc.release}`;
+ }
+ }
+ html += "Disabled:'; + html += '
→ ${doc.release}`;
+ }
+ html += 'Enable';
+ }
+ html += "DevDocs combines multiple developer documentations in a clean and organized web UI with instant search, offline support, mobile version, dark theme, keyboard shortcuts, and more. -
DevDocs is free and open source. It was created by Thibaut Courouble and is operated by freeCodeCamp. -
To keep up-to-date with the latest news: -
- -
- Copyright 2013–2023 Thibaut Courouble and other contributors
- This software is licensed under the terms of the Mozilla Public License v2.0.
- You may obtain a copy of the source code at github.com/freeCodeCamp/devdocs.
- For more information, see the COPYRIGHT
- and LICENSE files.
-
-
Special thanks to: -
| Documentation - | Copyright/License - | Source code - #{( - " |
|---|---|---|
| #{doc.name} | -#{doc.attribution} | -Source code | -
DevDocs combines multiple developer documentations in a clean and organized web UI with instant search, offline support, mobile version, dark theme, keyboard shortcuts, and more. +
DevDocs is free and open source. It was created by Thibaut Courouble and is operated by freeCodeCamp. +
To keep up-to-date with the latest news: +
+ +
+ Copyright 2013–2025 Thibaut Courouble and other contributors
+ This software is licensed under the terms of the Mozilla Public License v2.0.
+ You may obtain a copy of the source code at github.com/freeCodeCamp/devdocs.
+ For more information, see the COPYRIGHT
+ and LICENSE files.
+
+
Special thanks to: +
| Documentation + | Copyright/License + | Source code + ${docs + .map( + (doc) => + ` |
|---|---|---|
| ${doc.name} | ${doc.attribution} | Source code |
- Documentations can be enabled and disabled in the Preferences. - Alternatively, you can enable a documentation by searching for it in the main search - and clicking the "Enable" link in the results. - For faster and better search, only enable the documentations you plan on actively using. -
- Once a documentation is enabled, it becomes part of the search and its content can be downloaded for offline access — and faster page loads when online — in the Offline area. - -
- The search is case-insensitive and ignores whitespace. It supports fuzzy matching
- (e.g. bgcp matches background-clip)
- as well as aliases (full list below).
-
tab (space on mobile).
- For example, to search the JavaScript documentation, enter javascript
- or js, then tab.backspace or
- esc.
- #q= will be used as search query.tab when devdocs.io is autocompleted
- in the omnibox (to set a custom keyword, click Manage search engines\u2026 in Chrome's settings).
- - Note: the above search features only work for documentations that are enabled. - -
shift + ' else ''}
- ↓
- ↑
- shift + ' else ''}
- →
- ←
- enter
- #{ctrlKey} + enter
- alt + r
- #{navKey} + ←
- #{navKey} + →
- ↓ ' +
- '↑'
- else
- 'alt + ↓ ' +
- 'alt + ↑' +
- 'shift + ↓ ' +
- 'shift + ↑'}
- space
- shift + space
- #{ctrlKey} + ↑
- #{ctrlKey} + ↓
- alt + f
- ctrl + ,
- esc
- ?
- alt + c
- alt + o
- alt + g
- alt + s
- alt + d
-
- Tip: If the cursor is no longer in the search field, press / or
- continue to type and it will refocus the search field and start showing new results.
-
-
| Word - | Alias - #{(" |
|---|---|
| #{key} | #{value}" for key, value of aliases_one).join('')} - |
| Word - | Alias - #{(" |
|---|---|
| #{key} | #{value}" for key, value of aliases_two).join('')} - |
Feel free to suggest new aliases on GitHub. -""" diff --git a/assets/javascripts/templates/pages/help_tmpl.js b/assets/javascripts/templates/pages/help_tmpl.js new file mode 100644 index 0000000000..e155d82999 --- /dev/null +++ b/assets/javascripts/templates/pages/help_tmpl.js @@ -0,0 +1,179 @@ +app.templates.helpPage = function () { + const ctrlKey = $.isMac() ? "cmd" : "ctrl"; + const navKey = $.isMac() ? "cmd" : "alt"; + const arrowScroll = app.settings.get("arrowScroll"); + + const aliases = Object.entries(app.config.docs_aliases); + const middle = Math.ceil(aliases.length / 2); + const aliases_one = aliases.slice(0, middle); + const aliases_two = aliases.slice(middle); + + return `\ + + +
+ Documentations can be enabled and disabled in the Preferences. + Alternatively, you can enable a documentation by searching for it in the main search + and clicking the "Enable" link in the results. + For faster and better search, only enable the documentations you plan on actively using. +
+ Once a documentation is enabled, it becomes part of the search and its content can be downloaded for offline access — and faster page loads when online — in the Offline area. + +
+ The search is case-insensitive and ignores whitespace. It supports fuzzy matching
+ (e.g. bgcp matches background-clip)
+ as well as aliases (full list below).
+
tab (space on mobile).
+ For example, to search the JavaScript documentation, enter javascript
+ or js, then tab.backspace or
+ esc.
+ #q= will be used as search query.tab when devdocs.io is autocompleted
+ in the omnibox (to set a custom keyword, click Manage search engines\u2026 in Chrome's settings).
+ + Note: the above search features only work for documentations that are enabled. + +
shift + ' : ""}
+ ↓
+ ↑
+ shift + ' : ""}
+ →
+ ←
+ enter
+ ${ctrlKey} + enter
+ alt + r
+ ${navKey} + ←
+ ${navKey} + →
+ ↓ ' +
+ '↑'
+ : 'alt + ↓ ' +
+ 'alt + ↑' +
+ "shift + ↓ ' +
+ 'shift + ↑'
+ }
+ space
+ shift + space
+ ${ctrlKey} + ↑
+ ${ctrlKey} + ↓
+ alt + f
+ ctrl + ,
+ esc
+ ?
+ alt + c
+ alt + o
+ alt + g
+ alt + s
+ alt + d
+
+ Tip: If the cursor is no longer in the search field, press / or
+ continue to type and it will refocus the search field and start showing new results.
+
+
| Word + | Alias + ${aliases_one + .map( + ([key, value]) => + ` |
|---|---|
| ${key} | ${value}`, + ) + .join("")} + |
| Word + | Alias + ${aliases_two + .map( + ([key, value]) => + ` |
|---|---|
| ${key} | ${value}`, + ) + .join("")} + |
Feel free to suggest new aliases on GitHub.\ +`; +}; diff --git a/assets/javascripts/templates/pages/news_tmpl.coffee.erb b/assets/javascripts/templates/pages/news_tmpl.coffee.erb deleted file mode 100644 index f6760a61e4..0000000000 --- a/assets/javascripts/templates/pages/news_tmpl.coffee.erb +++ /dev/null @@ -1,36 +0,0 @@ -#= depend_on news.json - -app.templates.newsPage = -> - """
- For the latest news, follow @DevDocs.
- For development updates, follow the project on GitHub.
-
+For the latest news, follow @DevDocs.
+For development updates, follow the project on GitHub.
+
| Documentation | -Size | -Status | -Action | -
|---|
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. -
ENABLE_SERVICE_WORKER environment variable to true)"
-
- """ No. Service Workers #{reason}, so loading devdocs.io offline won't work.| Documentation | +Size | +Status | +Action | +
|---|
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. +
ENABLE_SERVICE_WORKER environment variable to true)";
+ }
+
+ return ` No. Service Workers ${reason}, so loading devdocs.io offline won't work.DevDocs combines multiple API documentations in a fast, organized, and searchable interface. - Here's what you should know before you start: -
Happy coding! - Stop showing this message -
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. -
DevDocs combines multiple API documentations in a fast, organized, and searchable interface. + Here's what you should know before you start: +
Happy coding! + Stop showing this message +
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.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) => `\ +
+ + + +
+ \ +`; 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) -> - """
- 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