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.
6. Generate the docs using `thor docs:generate public/icons/*your_scraper_name*/ directory:
- [ ] `16.png`: a 16×16 pixel icon for the doc
- [ ] `16@2x.png`: a 32×32 pixel icon for the doc
- [ ] `SOURCE`: A text file containing the URL to the page the image can be found on or the URL of the original image itself
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
- [ ] 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:
- The scraper still works without errors
- The scraped documentation still looks consistent with the rest of DevDocs
- The categorization of entries is still good
================================================
FILE: .github/no-response.yml
================================================
daysUntilClose: 30
responseRequiredLabel: needs-info
closeComment: >
This issue has been automatically closed because there has been no response
to our request for more information from the original author. With only the
information that’s currently in the issue, we don’t have enough information
to take action. Please comment if you have or find the answer we need so we
can investigate further.
================================================
FILE: .github/workflows/build.yml
================================================
name: Deploy
on:
push:
branches:
- main
jobs:
deploy:
name: Deploy to Heroku
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@09a7688d3b55cf0e976497ff046b70949eeaccfd # v1.288.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@e3eb99d45a8e2ec5dca08735e089607befa4bf28 # v3.14.15
with:
heroku_api_key: ${{secrets.HEROKU_API_KEY}}
heroku_app_name: "devdocs"
heroku_email: "team@freecodecamp.com"
dontuseforce: true # --force should never be necessary
dontautocreate: true # The app exists, it should not be created
================================================
FILE: .github/workflows/docker-build.yml
================================================
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 }}
================================================
FILE: .github/workflows/schedule-doc-report.yml
================================================
name: Generate documentation versions report
on:
schedule:
- cron: '17 4 1 * *'
workflow_dispatch:
jobs:
report:
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@09a7688d3b55cf0e976497ff046b70949eeaccfd # v1.288.0
with:
bundler-cache: true # runs 'bundle install' and caches installed gems automatically
- name: Generate report
run: bundle exec thor updates:check --github-token ${{ secrets.DEVDOCS_BOT_PAT }} --upload
================================================
FILE: .github/workflows/test.yml
================================================
name: Ruby tests
on:
pull_request:
branches:
- main
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- name: Set up Ruby
uses: ruby/setup-ruby@09a7688d3b55cf0e976497ff046b70949eeaccfd # v1.288.0
with:
bundler-cache: true # runs 'bundle install' and caches installed gems automatically
- name: Run tests
run: bundle exec rake
================================================
FILE: .gitignore
================================================
.DS_Store
.bundle
log
tmp
public/assets
public/fonts
public/docs/**/*
docs/**/*
!docs/*.md
/vendor
*.tar
*.tar.bz2
*.tar.gz
*.zip
assets/stylesheets/components/_environment.scss
assets/stylesheets/global/_icons.scss
================================================
FILE: .image_optim.yml
================================================
verbose: false
skip_missing_workers: true
allow_lossy: true
threads: 1
advpng: false
gifsicle:
interlace: false
level: 3
careful: true
jhead: false
jpegoptim:
strip: all
max_quality: 100
jpegrecompress: false
jpegtran: false
optipng: false
pngcrush: false
pngout: false
pngquant:
quality: !ruby/range 80..99
speed: 3
svgo: false
================================================
FILE: .ruby-version
================================================
3.4.8
================================================
FILE: .slugignore
================================================
test
================================================
FILE: .tool-versions
================================================
ruby 3.4.8
================================================
FILE: COPYRIGHT
================================================
Copyright 2013-2026 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
file, You can obtain one at http://mozilla.org/MPL/2.0/.
Please do not use the name DevDocs to endorse or promote products
derived from this software without the maintainers' permission, except
as may be necessary to comply with the notice/attribution requirements.
We also wish that any documentation file generated using this software
be attributed to DevDocs. Let's be fair to all contributors by giving
credit where credit's due. Thanks.
================================================
FILE: Dockerfile
================================================
FROM ruby:3.4.7
ENV LANG=C.UTF-8
ENV ENABLE_SERVICE_WORKER=true
WORKDIR /devdocs
RUN apt-get update && \
apt-get -y install git nodejs libcurl4 && \
gem install bundler && \
rm -rf /var/lib/apt/lists/*
COPY Gemfile Gemfile.lock Rakefile /devdocs/
RUN bundle config set path.system true && \
bundle install && \
rm -rf ~/.gem /root/.bundle/cache /usr/local/bundle/cache
COPY . /devdocs
RUN thor docs:download --all && \
thor assets:compile && \
rm -rf /tmp
EXPOSE 9292
CMD rackup -o 0.0.0.0
================================================
FILE: Dockerfile-alpine
================================================
FROM ruby:3.4.7-alpine
ENV LANG=C.UTF-8
ENV ENABLE_SERVICE_WORKER=true
WORKDIR /devdocs
COPY . /devdocs
RUN apk --update add nodejs build-base libstdc++ gzip git zlib-dev libcurl && \
gem install bundler && \
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 && \
rm -rf /var/cache/apk/* /tmp ~/.gem /root/.bundle/cache \
/usr/local/bundle/cache /usr/lib/node_modules
EXPOSE 9292
CMD rackup -o 0.0.0.0
================================================
FILE: Gemfile
================================================
source 'https://rubygems.org'
ruby '3.4.8'
gem 'activesupport', require: false
gem 'html-pipeline'
gem 'nokogiri'
gem 'pry-byebug'
gem 'rake'
gem 'terminal-table'
gem 'thor'
gem 'typhoeus'
gem 'yajl-ruby', require: false
group :app do
gem 'browser'
gem 'chunky_png'
gem 'erubi'
gem 'dartsass-sprockets'
gem 'image_optim_pack', platforms: :ruby
gem 'image_optim'
gem 'rack-ssl-enforcer'
gem 'rack'
gem 'rss'
gem 'sinatra-contrib'
gem 'sinatra'
gem 'sprockets-helpers'
gem 'sprockets'
gem 'thin'
end
group :production do
gem 'newrelic_rpm'
gem "terser"
end
group :development do
gem 'better_errors'
end
group :docs do
gem 'progress_bar', require: false
gem 'redcarpet'
gem 'tty-pager', require: false
gem 'unix_utils', require: false
end
group :test do
gem 'minitest'
gem 'rack-test', require: false
gem 'rr', require: false
end
if ENV['SELENIUM'] == '1'
gem 'capybara'
gem 'selenium-webdriver'
end
================================================
FILE: LICENSE
================================================
Mozilla Public License Version 2.0
==================================
1. Definitions
--------------
1.1. "Contributor"
means each individual or legal entity that creates, contributes to
the creation of, or owns Covered Software.
1.2. "Contributor Version"
means the combination of the Contributions of others (if any) used
by a Contributor and that particular Contributor's Contribution.
1.3. "Contribution"
means Covered Software of a particular Contributor.
1.4. "Covered Software"
means Source Code Form to which the initial Contributor has attached
the notice in Exhibit A, the Executable Form of such Source Code
Form, and Modifications of such Source Code Form, in each case
including portions thereof.
1.5. "Incompatible With Secondary Licenses"
means
(a) that the initial Contributor has attached the notice described
in Exhibit B to the Covered Software; or
(b) that the Covered Software was made available under the terms of
version 1.1 or earlier of the License, but not also under the
terms of a Secondary License.
1.6. "Executable Form"
means any form of the work other than Source Code Form.
1.7. "Larger Work"
means a work that combines Covered Software with other material, in
a separate file or files, that is not Covered Software.
1.8. "License"
means this document.
1.9. "Licensable"
means having the right to grant, to the maximum extent possible,
whether at the time of the initial grant or subsequently, any and
all of the rights conveyed by this License.
1.10. "Modifications"
means any of the following:
(a) any file in Source Code Form that results from an addition to,
deletion from, or modification of the contents of Covered
Software; or
(b) any new file in Source Code Form that contains any Covered
Software.
1.11. "Patent Claims" of a Contributor
means any patent claim(s), including without limitation, method,
process, and apparatus claims, in any patent Licensable by such
Contributor that would be infringed, but for the grant of the
License, by the making, using, selling, offering for sale, having
made, import, or transfer of either its Contributions or its
Contributor Version.
1.12. "Secondary License"
means either the GNU General Public License, Version 2.0, the GNU
Lesser General Public License, Version 2.1, the GNU Affero General
Public License, Version 3.0, or any later versions of those
licenses.
1.13. "Source Code Form"
means the form of the work preferred for making modifications.
1.14. "You" (or "Your")
means an individual or a legal entity exercising rights under this
License. For legal entities, "You" includes any entity that
controls, is controlled by, or is under common control with You. For
purposes of this definition, "control" means (a) the power, direct
or indirect, to cause the direction or management of such entity,
whether by contract or otherwise, or (b) ownership of more than
fifty percent (50%) of the outstanding shares or beneficial
ownership of such entity.
2. License Grants and Conditions
--------------------------------
2.1. Grants
Each Contributor hereby grants You a world-wide, royalty-free,
non-exclusive license:
(a) under intellectual property rights (other than patent or trademark)
Licensable by such Contributor to use, reproduce, make available,
modify, display, perform, distribute, and otherwise exploit its
Contributions, either on an unmodified basis, with Modifications, or
as part of a Larger Work; and
(b) under Patent Claims of such Contributor to make, use, sell, offer
for sale, have made, import, and otherwise transfer either its
Contributions or its Contributor Version.
2.2. Effective Date
The licenses granted in Section 2.1 with respect to any Contribution
become effective for each Contribution on the date the Contributor first
distributes such Contribution.
2.3. Limitations on Grant Scope
The licenses granted in this Section 2 are the only rights granted under
this License. No additional rights or licenses will be implied from the
distribution or licensing of Covered Software under this License.
Notwithstanding Section 2.1(b) above, no patent license is granted by a
Contributor:
(a) for any code that a Contributor has removed from Covered Software;
or
(b) for infringements caused by: (i) Your and any other third party's
modifications of Covered Software, or (ii) the combination of its
Contributions with other software (except as part of its Contributor
Version); or
(c) under Patent Claims infringed by Covered Software in the absence of
its Contributions.
This License does not grant any rights in the trademarks, service marks,
or logos of any Contributor (except as may be necessary to comply with
the notice requirements in Section 3.4).
2.4. Subsequent Licenses
No Contributor makes additional grants as a result of Your choice to
distribute the Covered Software under a subsequent version of this
License (see Section 10.2) or under the terms of a Secondary License (if
permitted under the terms of Section 3.3).
2.5. Representation
Each Contributor represents that the Contributor believes its
Contributions are its original creation(s) or it has sufficient rights
to grant the rights to its Contributions conveyed by this License.
2.6. Fair Use
This License is not intended to limit any rights You have under
applicable copyright doctrines of fair use, fair dealing, or other
equivalents.
2.7. Conditions
Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
in Section 2.1.
3. Responsibilities
-------------------
3.1. Distribution of Source Form
All distribution of Covered Software in Source Code Form, including any
Modifications that You create or to which You contribute, must be under
the terms of this License. You must inform recipients that the Source
Code Form of the Covered Software is governed by the terms of this
License, and how they can obtain a copy of this License. You may not
attempt to alter or restrict the recipients' rights in the Source Code
Form.
3.2. Distribution of Executable Form
If You distribute Covered Software in Executable Form then:
(a) such Covered Software must also be made available in Source Code
Form, as described in Section 3.1, and You must inform recipients of
the Executable Form how they can obtain a copy of such Source Code
Form by reasonable means in a timely manner, at a charge no more
than the cost of distribution to the recipient; and
(b) You may distribute such Executable Form under the terms of this
License, or sublicense it under different terms, provided that the
license for the Executable Form does not attempt to limit or alter
the recipients' rights in the Source Code Form under this License.
3.3. Distribution of a Larger Work
You may create and distribute a Larger Work under terms of Your choice,
provided that You also comply with the requirements of this License for
the Covered Software. If the Larger Work is a combination of Covered
Software with a work governed by one or more Secondary Licenses, and the
Covered Software is not Incompatible With Secondary Licenses, this
License permits You to additionally distribute such Covered Software
under the terms of such Secondary License(s), so that the recipient of
the Larger Work may, at their option, further distribute the Covered
Software under the terms of either this License or such Secondary
License(s).
3.4. Notices
You may not remove or alter the substance of any license notices
(including copyright notices, patent notices, disclaimers of warranty,
or limitations of liability) contained within the Source Code Form of
the Covered Software, except that You may alter any license notices to
the extent required to remedy known factual inaccuracies.
3.5. Application of Additional Terms
You may choose to offer, and to charge a fee for, warranty, support,
indemnity or liability obligations to one or more recipients of Covered
Software. However, You may do so only on Your own behalf, and not on
behalf of any Contributor. You must make it absolutely clear that any
such warranty, support, indemnity, or liability obligation is offered by
You alone, and You hereby agree to indemnify every Contributor for any
liability incurred by such Contributor as a result of warranty, support,
indemnity or liability terms You offer. You may include additional
disclaimers of warranty and limitations of liability specific to any
jurisdiction.
4. Inability to Comply Due to Statute or Regulation
---------------------------------------------------
If it is impossible for You to comply with any of the terms of this
License with respect to some or all of the Covered Software due to
statute, judicial order, or regulation then You must: (a) comply with
the terms of this License to the maximum extent possible; and (b)
describe the limitations and the code they affect. Such description must
be placed in a text file included with all distributions of the Covered
Software under this License. Except to the extent prohibited by statute
or regulation, such description must be sufficiently detailed for a
recipient of ordinary skill to be able to understand it.
5. Termination
--------------
5.1. The rights granted under this License will terminate automatically
if You fail to comply with any of its terms. However, if You become
compliant, then the rights granted under this License from a particular
Contributor are reinstated (a) provisionally, unless and until such
Contributor explicitly and finally terminates Your grants, and (b) on an
ongoing basis, if such Contributor fails to notify You of the
non-compliance by some reasonable means prior to 60 days after You have
come back into compliance. Moreover, Your grants from a particular
Contributor are reinstated on an ongoing basis if such Contributor
notifies You of the non-compliance by some reasonable means, this is the
first time You have received notice of non-compliance with this License
from such Contributor, and You become compliant prior to 30 days after
Your receipt of the notice.
5.2. If You initiate litigation against any entity by asserting a patent
infringement claim (excluding declaratory judgment actions,
counter-claims, and cross-claims) alleging that a Contributor Version
directly or indirectly infringes any patent, then the rights granted to
You by any and all Contributors for the Covered Software under Section
2.1 of this License shall terminate.
5.3. In the event of termination under Sections 5.1 or 5.2 above, all
end user license agreements (excluding distributors and resellers) which
have been validly granted by You or Your distributors under this License
prior to termination shall survive termination.
************************************************************************
* *
* 6. Disclaimer of Warranty *
* ------------------------- *
* *
* Covered Software is provided under this License on an "as is" *
* basis, without warranty of any kind, either expressed, implied, or *
* statutory, including, without limitation, warranties that the *
* Covered Software is free of defects, merchantable, fit for a *
* particular purpose or non-infringing. The entire risk as to the *
* quality and performance of the Covered Software is with You. *
* Should any Covered Software prove defective in any respect, You *
* (not any Contributor) assume the cost of any necessary servicing, *
* repair, or correction. This disclaimer of warranty constitutes an *
* essential part of this License. No use of any Covered Software is *
* authorized under this License except under this disclaimer. *
* *
************************************************************************
************************************************************************
* *
* 7. Limitation of Liability *
* -------------------------- *
* *
* Under no circumstances and under no legal theory, whether tort *
* (including negligence), contract, or otherwise, shall any *
* Contributor, or anyone who distributes Covered Software as *
* permitted above, be liable to You for any direct, indirect, *
* special, incidental, or consequential damages of any character *
* including, without limitation, damages for lost profits, loss of *
* goodwill, work stoppage, computer failure or malfunction, or any *
* and all other commercial damages or losses, even if such party *
* shall have been informed of the possibility of such damages. This *
* limitation of liability shall not apply to liability for death or *
* personal injury resulting from such party's negligence to the *
* extent applicable law prohibits such limitation. Some *
* jurisdictions do not allow the exclusion or limitation of *
* incidental or consequential damages, so this exclusion and *
* limitation may not apply to You. *
* *
************************************************************************
8. Litigation
-------------
Any litigation relating to this License may be brought only in the
courts of a jurisdiction where the defendant maintains its principal
place of business and such litigation shall be governed by laws of that
jurisdiction, without reference to its conflict-of-law provisions.
Nothing in this Section shall prevent a party's ability to bring
cross-claims or counter-claims.
9. Miscellaneous
----------------
This License represents the complete agreement concerning the subject
matter hereof. If any provision of this License is held to be
unenforceable, such provision shall be reformed only to the extent
necessary to make it enforceable. Any law or regulation which provides
that the language of a contract shall be construed against the drafter
shall not be used to construe this License against a Contributor.
10. Versions of the License
---------------------------
10.1. New Versions
Mozilla Foundation is the license steward. Except as provided in Section
10.3, no one other than the license steward has the right to modify or
publish new versions of this License. Each version will be given a
distinguishing version number.
10.2. Effect of New Versions
You may distribute the Covered Software under the terms of the version
of the License under which You originally received the Covered Software,
or under the terms of any subsequent version published by the license
steward.
10.3. Modified Versions
If you create software not governed by this License, and you want to
create a new license for such software, you may create and use a
modified version of this License if you rename the license and remove
any references to the name of the license steward (except to note that
such modified license differs from this License).
10.4. Distributing Source Code Form that is Incompatible With Secondary
Licenses
If You choose to distribute Source Code Form that is Incompatible With
Secondary Licenses under the terms of this version of the License, the
notice described in Exhibit B of this License must be attached.
Exhibit A - Source Code Form License Notice
-------------------------------------------
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
file, You can obtain one at http://mozilla.org/MPL/2.0/.
If it is not possible or desirable to put the notice in a particular
file, then You may include the notice in a location (such as a LICENSE
file in a relevant directory) where a recipient would be likely to look
for such a notice.
You may add additional accurate notices of copyright ownership.
Exhibit B - "Incompatible With Secondary Licenses" Notice
---------------------------------------------------------
This Source Code Form is "Incompatible With Secondary Licenses", as
defined by the Mozilla Public License, v. 2.0.
================================================
FILE: Procfile
================================================
web: bundle exec rackup config.ru -p $PORT
================================================
FILE: README.md
================================================
# [DevDocs](https://devdocs.io) — API Documentation Browser
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 was created by [Thibaut Courouble](https://thibaut.me) and is operated by [freeCodeCamp](https://www.freecodecamp.org).
## We are currently searching for maintainers
Please reach out to the community on [Discord](https://discord.gg/PRyKn3Vbay) if you would like to join the team!
Keep track of development news:
* 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
**Table of Contents:** [Quick Start](#quick-start) · [Vision](#vision) · [App](#app) · [Scraper](#scraper) · [Commands](#available-commands) · [Contributing](#contributing) · [Documentation](#documentation) · [Related Projects](#related-projects) · [License](#copyright--license) · [Questions?](#questions)
## Quick Start
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.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
gem install bundler
bundle install
bundle exec thor docs:download --default
bundle exec rackup
```
Finally, point your browser at [localhost:9292](http://localhost:9292) (the first request will take a few seconds to compile the assets). You're all set.
The `thor docs:download` command is used to download pre-generated documentations from DevDocs's servers (e.g. `thor docs:download html css`). You can see the list of available documentations and versions by running `thor docs:list`. To update all downloaded documentations, run `thor docs:download --installed`. To download and install all documentation this project has available, run `thor docs:download --all`.
**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.
## Vision
DevDocs aims to make reading and searching reference documentation fast, easy and enjoyable.
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
* Be fully functional offline
* Support full keyboard navigation
* Reduce “context switch” by using a consistent typography and design across all documentations
* Reduce clutter by focusing on a specific category of content (API/reference) and indexing only the minimum useful to most developers.
**Note:** DevDocs is neither a programming guide nor a search engine. All our content is pulled from third-party sources and the project doesn't intend to compete with full-text search engines. Its backbone is metadata; each piece of content is identified by a unique, "obvious" and short string. Tutorials, guides and other content that don't meet this requirement are outside the scope of the project.
## App
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.
Another driving factor is performance and the fact that everything happens in the browser. A service worker (which comes with its own set of constraints) and `localStorage` are used to speed up the boot time, while memory consumption is kept in check by allowing the user to pick his/her own set of documentations. The search algorithm is kept simple because it needs to be fast even searching through 100,000 strings.
DevDocs being a developer tool, the browser requirements are high:
* Recent versions of Firefox, Chrome, or Opera
* Safari 11.1+
* Edge 17+
* iOS 11.3+
This allows the code to take advantage of the latest DOM and HTML5 APIs and make developing DevDocs a lot more fun!
## Scraper
The scraper is responsible for generating the documentation and index files (metadata) used by the [app](#app). It's written in Ruby under the `Docs` module.
There are currently two kinds of scrapers: `UrlScraper` which downloads files via HTTP and `FileScraper` which reads them from the local filesystem. They both make copies of HTML documents, recursively following links that match a set of rules and applying all sorts of modifications along the way, in addition to building an index of the files and their metadata. Documents are parsed using [Nokogiri](http://nokogiri.org).
Modifications made to each document include:
* removing content such as the document structure (``, ``, etc.), comments, empty nodes, etc.
* fixing links (e.g. to remove duplicates)
* replacing all external (not scraped) URLs with their fully qualified counterpart
* replacing all internal (scraped) URLs with their unqualified and relative counterpart
* adding content, such as a title and link to the original document
* ensuring correct syntax highlighting using [Prism](http://prismjs.com/)
These modifications are applied via a set of filters using the [HTML::Pipeline](https://github.com/jch/html-pipeline) library. Each scraper includes filters specific to itself, one of which is tasked with figuring out the pages' metadata.
The end result is a set of normalized HTML partials and two JSON files (index + offline data). Because the index files are loaded separately by the [app](#app) following the user's preferences, the scraper also creates a JSON manifest file containing information about the documentations currently available on the system (such as their name, version, update date, etc.).
More information about [scrapers](./docs/scraper-reference.md) and [filters](./docs/filter-reference.md) is available in the `docs` folder.
## Available Commands
The command-line interface uses [Thor](http://whatisthor.com). To see all commands and options, run `thor list` from the project's root.
```sh
# Server
rackup # Start the server (ctrl+c to stop)
rackup --help # List server options
# Docs
thor docs:list # List available documentations
thor docs:download # Download one or more documentations
thor docs:manifest # Create the manifest file used by the app
thor docs:generate # Generate/scrape a documentation
thor docs:page # Generate/scrape a documentation page
thor docs:package # Package a documentation for use with docs:download
thor docs:clean # Delete documentation packages
# Console
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.
# Run "help test" for usage instructions.
thor test:all # Run all tests
thor test:docs # Run "Docs" tests
thor test:app # Run "App" tests
# Assets
thor assets:compile # Compile assets (not required in development mode)
thor assets:clean # Clean old assets
```
If multiple versions of Ruby are installed on your system, commands must be run through `bundle exec`.
## Contributing
Contributions are welcome. Please read the [contributing guidelines](./.github/CONTRIBUTING.md).
## Documentation
* [Adding documentations to DevDocs](./docs/adding-docs.md)
* [Scraper Reference](./docs/scraper-reference.md)
* [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.
| Project | Description | Last commit | Stars |
| ------------------------------------------------------------------------------------------- | ------------------------------------ | -------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
| [yannickglt/alfred-devdocs](https://github.com/yannickglt/alfred-devdocs) | Alfred workflow |  |  |
| [Merith-TK/devdocs_webapp_kotlin](https://github.com/Merith-TK/devdocs_webapp_kotlin) | Android application |  |  |
| [gruehle/dev-docs-viewer](https://github.com/gruehle/dev-docs-viewer) | Brackets extension |  |  |
| [egoist/devdocs-desktop](https://github.com/egoist/devdocs-desktop) | Electron application |  |  |
| [skeeto/devdocs-lookup](https://github.com/skeeto/devdocs-lookup) | Emacs function |  |  |
| [astoff/devdocs.el](https://github.com/astoff/devdocs.el) | Emacs viewer |  |  |
| [naquad/devdocs-shell](https://github.com/naquad/devdocs-shell) | GTK shell with Vim integration |  |  |
| [hardpixel/devdocs-desktop](https://github.com/hardpixel/devdocs-desktop) | GTK application |  |  |
| [qwfy/doc-browser](https://github.com/qwfy/doc-browser) | Linux application |  |  |
| [dteoh/devdocs-macos](https://github.com/dteoh/devdocs-macos) | macOS application |  |  |
| [Sublime Text plugin](https://sublime.wbond.net/packages/DevDocs) | Sublime Text plugin |  |  |
| [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–2026 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.
Please do not use the name DevDocs to endorse or promote products derived from this software without the maintainers' permission, except as may be necessary to comply with the notice/attribution requirements.
We also wish that any documentation file generated using this software be attributed to DevDocs. Let's be fair to all contributors by giving credit where credit's due. Thanks!
## Questions?
If you have any questions, please feel free to ask them on the `#contributors` chat room on [Discord](https://discord.gg/PRyKn3Vbay).
================================================
FILE: Rakefile
================================================
#!/usr/bin/env rake
require 'bundler/setup'
require 'thor'
Bundler.require :default
$LOAD_PATH.unshift 'lib'
task :default do
$LOAD_PATH.unshift 'test'
Dir['test/**/*_test.rb'].map(&File.method(:expand_path)).each(&method(:require))
end
namespace :assets do
desc 'Compile all assets'
task :precompile do
load 'tasks/docs.thor'
DocsCLI.new.prepare_deploy
load 'tasks/assets.thor'
AssetsCLI.new.compile
end
end
================================================
FILE: Thorfile
================================================
$LOAD_PATH.unshift 'lib'
================================================
FILE: assets/images/.gitignore
================================================
sprites/**/*
================================================
FILE: assets/javascripts/app/app.js
================================================
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();
================================================
FILE: assets/javascripts/app/config.js.erb
================================================
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' %>,
}
================================================
FILE: assets/javascripts/app/db.js
================================================
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");
}
};
================================================
FILE: assets/javascripts/app/router.js
================================================
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
);
}
};
================================================
FILE: assets/javascripts/app/searcher.js
================================================
//
// 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();
}
};
================================================
FILE: assets/javascripts/app/serviceworker.js
================================================
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");
}
}
};
================================================
FILE: assets/javascripts/app/settings.js
================================================
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");
}
}
};
================================================
FILE: assets/javascripts/app/shortcuts.js
================================================
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;
}
}
};
================================================
FILE: assets/javascripts/app/update_checker.js
================================================
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();
}
}
};
================================================
FILE: assets/javascripts/application.js
================================================
//= require_tree ./vendor
//= require lib/license
//= require_tree ./lib
//= require app/app
//= require app/config
//= require_tree ./app
//= require collections/collection
//= require_tree ./collections
//= require models/model
//= require_tree ./models
//= require views/view
//= require_tree ./views
//= require_tree ./templates
//= link_tree ../images/sprites
//= require tracking
var init = function () {
document.removeEventListener("DOMContentLoaded", init, false);
if (document.body) {
return app.init();
} else {
return setTimeout(init, 42);
}
};
document.addEventListener("DOMContentLoaded", init, false);
================================================
FILE: assets/javascripts/collections/collection.js
================================================
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;
}
};
================================================
FILE: assets/javascripts/collections/docs.js
================================================
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);
}
}
});
}
};
================================================
FILE: assets/javascripts/collections/entries.js
================================================
app.collections.Entries = class Entries extends app.Collection {
static model = "Entry";
};
================================================
FILE: assets/javascripts/collections/types.js
================================================
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;
}
}
};
================================================
FILE: assets/javascripts/debug.js
================================================
//
// 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);
}
}
}
}
}
};
================================================
FILE: assets/javascripts/docs.js.erb
================================================
//= depend_on docs.json
app.DOCS = <%= File.read App.docs_manifest_path %>;
================================================
FILE: assets/javascripts/lib/ajax.js
================================================
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
};
================================================
FILE: assets/javascripts/lib/cookies_store.js
================================================
// 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;
}
}
================================================
FILE: assets/javascripts/lib/events.js
================================================
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;
}
}
================================================
FILE: assets/javascripts/lib/favicon.js
================================================
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);
}
};
================================================
FILE: assets/javascripts/lib/license.js
================================================
/*
* Copyright 2013-2026 Thibaut Courouble and other contributors
*
* This source code is licensed under the terms of the Mozilla
* Public License, v. 2.0, a copy of which may be obtained at:
* http://mozilla.org/MPL/2.0/
*/
================================================
FILE: assets/javascripts/lib/local_storage_store.js
================================================
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) {}
}
};
================================================
FILE: assets/javascripts/lib/page.js
================================================
/*
* Based on github.com/visionmedia/page.js
* Licensed under the MIT license
* Copyright 2012 TJ Holowaychuk alt + c shortcut to copy URL of original page."
],
[
"2021-02-26",
"New documentation: React Bootstrap"
],
[
"2021-01-03",
"New documentation: OCaml"
],
[
"2020-12-23",
"New documentation: GTK"
],
[
"2020-12-07",
"New documentations: Flask, Groovy, Jinja, Werkzeug"
],
[
"2020-12-04",
"New documentation: HAProxy"
],
[
"2020-11-17",
"TensorFlow has been split into TensorFlow Python, TensorFlow C++"
],
[
"2020-11-14",
"New documentations: PyTorch, Spring Boot"
],
[
"2020-01-13",
"New “Automatic” theme: match your browser or system dark mode setting. Enable it in preferences."
],
[
"2020-01-13",
"New documentation: Gnuplot"
],
[
"2019-10-26",
"New documentation: Sequelize"
], [
"2019-10-20",
"New documentations: MariaDB and ReactiveX"
], [
"2019-09-02",
"New documentations added over the last 3 weeks: Scala, WordPress, Cypress, SaltStack, Composer, Vue Router, Vuex, Pony, RxJS, Octave, Trio, Django REST Framework, Enzyme and GnuCOBOL"
], [
"2019-07-21",
"Fixed several bugs, added an option to automatically download documentation and more."
], [
"2019-07-19",
"Replaced the AppCache with a Service Worker (which makes DevDocs an installable PWA) and fixed layout preferences on Firefox."
], [
"2018-09-23",
"New documentations: Puppeteer and Handlebars.js"
], [
"2018-08-12",
"New documentations: Dart and Qt"
], [
"2018-07-29",
"New documentations: Bash, Graphite and Pygame"
], [
"2018-07-08",
"New documentations: Leaflet, Terraform and Koa"
], [
"2018-03-26",
"DevDocs is joining the freeCodeCamp community. Read the announcement here."
], [
"2018-02-04",
"New documentations: Babel, Jekyll and JSDoc"
], [
"2017-11-26",
"New documentations: Bluebird, ESLint and Homebrew"
], [
"2017-11-18",
"Added print & PDF stylesheet.\nFeedback welcome on Twitter and GitHub."
], [
"2017-09-10",
"Preferences can now be exported and imported."
], [
"2017-09-03",
"New documentations: D, Nim and Vulkan"
], [
"2017-07-23",
"New documentation: Godot"
], [
"2017-06-04",
"New documentations: Electron, Pug, and Falcon"
], [
"2017-05-14",
"New documentations: Jest, Jasmine and Liquid"
], [
"2017-04-30",
"New documentation: OpenJDK"
], [
"2017-02-26",
"Refreshed design.",
"Added Preferences."
], [
"2017-01-22",
"New HTTP documentation (thanks Mozilla)"
], [
"2016-12-04",
"New documentations: SQLite, Codeception and CodeceptJS"
], [
"2016-11-20",
"New documentations: Yarn, Immutable.js and Async"
], [
"2016-10-10",
"New documentations: scikit-learn and Statsmodels"
], [
"2016-09-18",
"New documentations: pandas and Twig"
], [
"2016-09-05",
"New documentations: Fish, Bottle and scikit-image"
], [
"2016-08-07",
"New documentation: Docker"
], [
"2016-07-31",
"New documentations: Bootstrap 3 and Bootstrap 4"
], [
"2016-07-24",
"New documentations: Julia, Crystal and Redux"
], [
"2016-07-03",
"New documentations: CMake and Matplotlib"
], [
"2016-06-19",
"New documentation: LÖVE"
], [
"2016-06-12",
"New documentation: Angular 2"
], [
"2016-06-05",
"New documentations: Kotlin and Padrino"
], [
"2016-04-24",
"New documentations: NumPy and Apache Pig"
], [
"2016-04-17",
"New documentation: Perl"
], [
"2016-04-10",
"New documentations: Support tables (caniuse.com), GCC and GNU Fortran"
], [
"2016-03-27",
"New documentation: TypeScript"
], [
"2016-03-06",
"New documentations: TensorFlow, Haxe and Ansible"
], [
"2016-02-28",
"New documentations: CodeIgniter, nginx Lua Module and InfluxData"
], [
"2016-02-15",
"New documentations: CakePHP, Chef and Ramda"
], [
"2016-01-31",
"New documentations: Erlang and Tcl/Tk"
], [
"2016-01-24",
"“Multi-version support” has landed!"
], [
"2015-11-22",
"New documentations: Phoenix, Dojo, Relay and Flow"
], [
"2015-11-08",
"New documentations: Elixir and Vagrant"
], [
"2015-10-18",
"Added a \"Copy to clipboard\" button inside each code block."
], [
"2015-09-13",
"New documentation: Phalcon"
], [
"2015-08-09",
"New documentation: React Native"
], [
"2015-08-03",
"Added an icon in the sidebar to constrain the width of the UI (visible when applicable)."
], [
"2015-08-02",
"New documentations: Q and OpenTSDB"
], [
"2015-07-26",
"Added search aliases (e.g. $ is an alias for jQuery).\nClick here to see the full list. Feel free to suggest more on GitHub.",
"Added shift + ↓/↑ shortcut for scrolling (same as alt + ↓/↑)."
], [
"2015-07-05",
"New documentations: Drupal, Vue.js, Phaser and webpack"
], [
"2015-05-24",
"New Rust documentation"
], [
"2015-04-26",
"New Apache HTTP Server and npm documentations"
], [
"2015-03-22",
"New Meteor and mocha documentations"
], [
"2015-02-22",
"Improved HTTP documentation",
"New Minitest documentation"
], [
"2015-02-16",
"The sidebar is now resizable (drag & drop)."
], [
"2015-02-15",
"New io.js, Symfony, Clojure, Lua and Yii 1.1 documentations"
], [
"2015-02-08",
"New dark theme"
], [
"2015-01-13",
"Offline mode has landed!"
], [
"2014-12-21",
"New React, RethinkDB, Socket.IO, Modernizr and Bower documentations"
], [
"2014-11-30",
"New PHPUnit and Nokogiri documentations"
], [
"2014-11-16",
"New Python 2 documentation"
], [
"2014-11-09",
"New design\nFeedback welcome on Twitter and GitHub."
], [
"2014-10-19",
"New SVG, Marionette.js, and Mongoose documentations"
], [
"2014-10-18",
"New nginx documentation"
], [
"2014-10-13",
"New XPath documentation"
], [
"2014-09-07",
"Updated the HTML, CSS, JavaScript, and DOM documentations with additional content."
], [
"2014-08-04",
"New Django documentation"
], [
"2014-07-27",
"New Markdown documentation"
], [
"2014-07-05",
"New Cordova documentation"
], [
"2014-07-01",
"New Chai and Sinon documentations"
], [
"2014-06-15",
"New RequireJS documentation"
], [
"2014-06-14",
"New Haskell documentation"
], [
"2014-05-25",
"New Laravel documentation"
], [
"2014-05-04",
"New Express, Grunt, and MaxCDN documentations"
], [
"2014-04-06",
"New Go documentation"
], [
"2014-03-30",
"New C++ documentation"
], [
"2014-03-16",
"New Yii documentation"
], [
"2014-03-08",
"Added path bar."
], [
"2014-02-22",
"New C documentation"
], [
"2014-02-16",
"New Moment.js documentation"
], [
"2014-02-12",
"The root/category pages are now included in the search index (e.g. CSS)"
], [
"2014-01-19",
"New D3.js and Knockout.js documentations"
], [
"2014-01-18",
"DevDocs is now available as a Firefox web app."
], [
"2014-01-12",
"Added alt + g shortcut for searching on Google.",
"Added alt + r shortcut for revealing the current page in the sidebar."
], [
"2013-12-14",
"New PostgreSQL documentation"
], [
"2013-12-13",
"New Git and Redis documentations"
], [
"2013-11-26",
"New Python documentation"
], [
"2013-11-19",
"New Ruby on Rails documentation"
], [
"2013-11-16",
"New Ruby documentation"
], [
"2013-10-24",
"DevDocs is now open source."
], [
"2013-10-09",
"DevDocs is now available as a Chrome web app."
], [
"2013-09-22",
"New PHP documentation"
], [
"2013-09-06",
"New Lo-Dash documentation ",
"On mobile devices you can now search a specific documentation by typing its name and Space."
], [
"2013-09-01",
"New jQuery UI and jQuery Mobile documentations"
], [
"2013-08-28",
"New smartphone interface\nTested on iOS 6+ and Android 4.1+"
], [
"2013-08-25",
"New Ember.js documentation"
], [
"2013-08-18",
"New CoffeeScript documentation",
"URL search now automatically opens the first result."
], [
"2013-08-13",
"New Angular.js documentation"
], [
"2013-08-11",
"New Sass and Less documentations"
], [
"2013-08-05",
"New Node.js documentation"
], [
"2013-08-03",
"Added support for OpenSearch"
], [
"2013-07-30",
"New Backbone.js documentation"
], [
"2013-07-27",
"You can now customize the list of documentations.\nNew docs will be hidden by default, but you'll see a notification when there are new releases.",
"New HTTP documentation"
], [
"2013-07-15",
"URL search now works with single documentations: devdocs.io/#q=js sort"
], [
"2013-07-13",
"Added syntax highlighting",
"Added documentation versions"
], [
"2013-07-11",
"New Underscore.js documentation ",
"Improved compatibility with tablets\nA mobile version is planned as soon as other high priority features have been implemented."
], [
"2013-07-10",
"You can now search specific documentations.\nSimply type the documentation's name and press Tab.\nThe name is fuzzy matched so you can use abbreviations like js for JavaScript."
], [
"2013-07-08",
"Improved search with fuzzy matching and better results\nFor example, searching jqmka now returns jQuery.makeArray().",
"DevDocs finally has an icon.",
"space has replaced alt + space for scrolling down."
], [
"2013-07-06",
"New DOM and DOM Events documentations\nDevDocs now includes almost all reference documents available on the Mozilla Developer Network.\nBig thank you to Mozilla and all the people that contributed to MDN.",
"Implemented URL search: devdocs.io/#q=sort"
], [
"2013-07-02",
"New JavaScript documentation"
], [
"2013-06-28",
"DevDocs made the front page of Hacker News!\nHi everyone — thanks for trying DevDocs.\nPlease bear with me while I fix bugs and scramble to add more docs.\nThis is only v1. There's a lot more to come."
], [
"2013-06-18",
"Initial release"
]
]
================================================
FILE: assets/javascripts/templates/base.js
================================================
app.templates.render = function (name, value, ...args) {
const template = app.templates[name];
if (Array.isArray(value)) {
let result = "";
for (var val of value) {
result += template(val, ...args);
}
return result;
} else if (typeof template === "function") {
return template(value, ...args);
} else {
return template;
}
};
================================================
FILE: assets/javascripts/templates/error_tmpl.js
================================================
const error = function (title, text, links) {
if (text == null) {
text = "";
}
if (links == null) {
links = "";
}
if (text) {
text = `${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. `);
================================================
FILE: assets/javascripts/templates/notif_tmpl.js
================================================
const notif = function (title, html) {
html = html.replace(/${title}
${html}
\
`;
};
const 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}`;
}
}
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–2026 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 + ' : ""}
↓
↑
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.\ `; }; ================================================ FILE: assets/javascripts/templates/pages/news_tmpl.js.erb ================================================ //= depend_on news.json app.templates.newsPage = () => `
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)";
}
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.
\ `; ================================================ FILE: assets/javascripts/templates/pages/type_tmpl.js ================================================ app.templates.typePage = (type) => { return `
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\ `; ================================================ FILE: assets/javascripts/tracking.js ================================================ 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", { page: location.pathname + location.search + location.hash, dimension1: app.router.context && app.router.context.doc && app.router.context.doc.slug_without_version, }); }); 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); })(); })(); }); } else { resetAnalytics(); } } } catch (e) {} ================================================ FILE: assets/javascripts/vendor/cookies.js ================================================ /* * Cookies.js - 1.2.3 (patched for SameSite=Strict and secure=true) * https://github.com/ScottHamper/Cookies * * This is free and unencumbered software released into the public domain. */ (function (global, undefined) { "use strict"; 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; } } 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; }; 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; } })(typeof window === "undefined" ? this : window); ================================================ FILE: assets/javascripts/vendor/mathml.js ================================================ /* 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 * file, You can obtain one at http://mozilla.org/MPL/2.0/. * Adapted from: https://github.com/fred-wang/mathml.css */ (function () { window.addEventListener("load", function () { var box, div, link, namespaceURI; // First check whether the page contains any