diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000..64b8f28ccf --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +.git +test +Dockerfile* +.gitignore +.dockerignore +.travis.yml +*.md diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000000..03c17bf8d5 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,11 @@ +root = true + +[*] +charset = utf-8 +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000000..dd371d4eeb --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,4 @@ +# This controls who gets notified for review and allows branches to be protected. +# Protected branches can only be merged into after being approved by a codeowner. + +* @freeCodeCamp/devdocs diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 0000000000..fc0dac2e3d --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,87 @@ +# Contributing to DevDocs + +Want to contribute? Great. Please review the following guidelines carefully and search for existing issues before opening a new one. + +**Table of Contents:** + +1. [Reporting bugs](#reporting-bugs) +2. [Requesting new features](#requesting-new-features) +3. [Requesting new documentations](#requesting-new-documentations) +4. [Contributing code and features](#contributing-code-and-features) +5. [Contributing new documentations](#contributing-new-documentations) +6. [Updating existing documentations](#updating-existing-documentations) +7. [Coding conventions](#coding-conventions) +8. [Questions?](#questions) + +## Reporting bugs + +1. Update to the most recent main release; the bug may already be fixed. +2. Search for existing issues; it's possible someone has already encountered this bug. +3. Try to isolate the problem and include steps to reproduce it. +4. Share as much information as possible (e.g. browser/OS environment, log output, stack trace, screenshots, etc.). + +## Requesting new features + +1. Search for similar feature requests; someone may have already requested it. +2. Make sure your feature fits DevDocs's [vision](../README.md#vision). +3. Provide a clear and detailed explanation of the feature and why it's important to add it. + +## Requesting new documentations + +Please don't open issues to request new documentations. +Use the [Trello board](https://trello.com/b/6BmTulfx/devdocs-documentation) where everyone can vote. + +## Contributing code and features + +1. Search for existing issues; someone may already be working on a similar feature. +2. Before embarking on any significant pull request, please open an issue describing the changes you intend to make. Otherwise you risk spending a lot of time working on something we may not want to merge. This also tells other contributors that you're working on the feature. +3. Follow the [coding conventions](#coding-conventions). +4. If you're modifying the Ruby code, include tests and ensure they pass. +5. Try to keep your pull request small and simple. +6. When it makes sense, squash your commits into a single commit. +7. Describe all your changes in the commit message and/or pull request. + +## Contributing new documentations + +See the [`docs` folder](https://github.com/freeCodeCamp/devdocs/tree/main/docs) to learn how to add new documentations. + +**Important:** the documentation's license must permit alteration, redistribution and commercial use, and the documented software must be released under an open source license. Feel free to get in touch if you're not sure if a documentation meets those requirements. + +In addition to the [guidelines for contributing code](#contributing-code-and-features), the following guidelines apply to pull requests that add a new documentation: + +* Your documentation must come with an official icon, in both 1x and 2x resolutions (16x16 and 32x32 pixels). This is important because icons are the only thing differentiating search results in the UI. +* DevDocs favors quality over quantity. Your documentation should only include documents that most developers may want to read semi-regularly. By reducing the number of entries, we make it easier to find other, more relevant entries. +* Remove as much content and HTML markup as possible, particularly content not associated with any entry (e.g. introduction, changelog, etc.). +* Names must be as short as possible and unique across the documentation. +* The number of types (categories) should ideally be less than 100. + +## Updating existing documentations + +If the latest [documentation versions report](https://github.com/freeCodeCamp/devdocs/issues?utf8=%E2%9C%93&q=Documentation+versions+report+is%3Aissue+author%3Adevdocs-bot+sort%3Acreated-desc) wrongly shows a documentation to be up-to-date, please open an issue or a PR to fix it. + +**Important:** PR's that update documentation versions that do not contain the checklist shown to you in section B of the PR template may be closed without review. + +Follow the following steps to update documentations to their latest version: + +1. Make version/release changes in the scraper file. +2. Check if the license is still correct. Update `options[:attribution]` if needed. +3. If the documentation has a custom icon, ensure the icons in public/icons/*your_scraper_name*/ are up-to-date. If you pull the updated icon from a place different than the one specified in the `SOURCE` file, make sure to replace the old link with the new one. +4. If `self.links` is defined, check if the urls are still correct. +5. If the scraper inherits from `FileScraper` rather than `URLScraper`, follow the instructions for that scraper in [`file-scrapers.md`](../docs/file-scrapers.md) to obtain the source material for the scraper. +6. Generate the docs using `thor docs:generate `. +7. Make sure `thor docs:generate` doesn't show errors and that the documentation still works well. Verify locally that everything works and that the categorization of entries is still good. Often, updates will require code changes in the scraper or its filters to tweak some new markup in the source website or to categorize new entries. +8. Repeat steps 5 and 6 for all versions that you updated. +9. Create a PR and make sure to fill the checklist in section B of the PR template (remove the other sections). + +## Coding conventions + +* two spaces; no tabs +* no trailing whitespace; blank lines should have no spaces; new line at end-of-file +* use the same coding style as the rest of the codebase + +These conventions are formalized in [our `.editorconfig` file](../.editorconfig). +Check out [EditorConfig.org](https://editorconfig.org/) to learn how to make your tools adhere to it. + +## Questions? + +If you have any questions, please feel free to ask them on the contributor chat room on [Discord](https://discord.gg/PRyKn3Vbay). diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000000..88ac89f918 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,44 @@ +--- +name: Bug report +about: Create a report to help us improve DevDocs +title: '' +labels: 'bug' +assignees: '' +--- + + + +# Bug report + + + +## OS information + + + +## Steps to reproduce + + + +## More resources + + + +## Possible fix + + diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000000..01222eb21f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: Question + about: "Ask questions and have discussions on Discord" + url: "https://discord.gg/PRyKn3Vbay" + - name: New Documentation + about: "Request a new documentation on Trello" + url: "https://trello.com/b/6BmTulfx/devdocs-documentation" diff --git a/.github/ISSUE_TEMPLATE/documentation_bug.md b/.github/ISSUE_TEMPLATE/documentation_bug.md new file mode 100644 index 0000000000..5f56d0984a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/documentation_bug.md @@ -0,0 +1,33 @@ +--- +name: Documentation bug +about: Report a problem with a specific documentation +title: '' +labels: 'docs/improvement' +assignees: '' +--- + + + +# Documentation style bug + + + +## Summary + + +## Actual style + + +## Expected style + diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000000..664a6540e9 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,36 @@ +--- +name: Feature request +about: Suggest a new feature +title: '' +labels: 'feature' +assignees: '' +--- + + + +# Feature request + + + +## Summary + + + +## Examples + + diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000000..029babb22a --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,31 @@ + + + + + + + + +If you’re adding a new scraper, please ensure that you have: + +- [ ] Tested the scraper on a local copy of DevDocs +- [ ] Ensured that the docs are styled similarly to other docs on DevDocs + +- [ ] Added these files to the 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 diff --git a/.github/no-response.yml b/.github/no-response.yml new file mode 100644 index 0000000000..ab687134c8 --- /dev/null +++ b/.github/no-response.yml @@ -0,0 +1,8 @@ +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. diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000000..e83dfeb3df --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,31 @@ +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@8aeb6ff8030dd539317f8e1769a044873b56ea71 # v1.268.0 + with: + bundler-cache: true # runs 'bundle install' and caches installed gems automatically + - name: Run tests + run: bundle exec rake + - name: Install Heroku CLI + run: | + curl https://cli-assets.heroku.com/install.sh | sh + - name: Deploy to Heroku + uses: akhileshns/heroku-deploy@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 diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml new file mode 100644 index 0000000000..dd0ac8a062 --- /dev/null +++ b/.github/workflows/docker-build.yml @@ -0,0 +1,54 @@ +name: Build and Push Docker Images +on: + schedule: + - cron: '0 0 1 * *' # Run monthly on the 1st + workflow_dispatch: # Allow manual triggers +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} +jobs: + build-and-push: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + strategy: + matrix: + variant: + - name: regular + file: Dockerfile + suffix: '' + - name: alpine + file: Dockerfile-alpine + suffix: '-alpine' + + steps: + - name: Checkout repository + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + + - name: Log in to the Container registry + uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata for Docker + id: meta + uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=raw,value=latest${{ matrix.variant.suffix }} + type=raw,value={{date 'YYYYMMDD'}}${{ matrix.variant.suffix }} + + - name: Build and push image + uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5 + with: + context: . + file: ./${{ matrix.variant.file }} + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/.github/workflows/schedule-doc-report.yml b/.github/workflows/schedule-doc-report.yml new file mode 100644 index 0000000000..f5d1eb1213 --- /dev/null +++ b/.github/workflows/schedule-doc-report.yml @@ -0,0 +1,18 @@ +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@8aeb6ff8030dd539317f8e1769a044873b56ea71 # v1.268.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 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000000..953d3e0256 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,18 @@ +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@8aeb6ff8030dd539317f8e1769a044873b56ea71 # v1.268.0 + with: + bundler-cache: true # runs 'bundle install' and caches installed gems automatically + - name: Run tests + run: bundle exec rake diff --git a/.gitignore b/.gitignore index 8b22282640..bbf749a452 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,14 @@ .DS_Store .bundle -*.pxm -*.sketch +log tmp public/assets public/fonts public/docs/**/* -!public/docs/docs.json -!public/docs/**/index.json +docs/**/* +!docs/*.md +/vendor +*.tar +*.tar.bz2 +*.tar.gz +*.zip diff --git a/.image_optim.yml b/.image_optim.yml new file mode 100644 index 0000000000..f846feb462 --- /dev/null +++ b/.image_optim.yml @@ -0,0 +1,22 @@ +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 diff --git a/.ruby-version b/.ruby-version index a6254504e4..2aa5131992 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -2.3.1 \ No newline at end of file +3.4.7 diff --git a/.slugignore b/.slugignore new file mode 100644 index 0000000000..9daeafb986 --- /dev/null +++ b/.slugignore @@ -0,0 +1 @@ +test diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000000..3f03c7a73d --- /dev/null +++ b/.tool-versions @@ -0,0 +1 @@ +ruby 3.4.7 diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index f819a513ae..0000000000 --- a/.travis.yml +++ /dev/null @@ -1 +0,0 @@ -language: ruby diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index fd4bd5349c..0000000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,83 +0,0 @@ -# Contributing to DevDocs - -Want to contribute? Great. Please review the following guidelines carefully and search for existing issues before opening a new one. - -**Table of Contents:** - -1. [Reporting bugs](#reporting-bugs) -2. [Requesting new features](#requesting-new-features) -3. [Requesting new documentations](#requesting-new-documentations) -4. [Contributing code and features](#contributing-code-and-features) -5. [Contributing new documentations](#contributing-new-documentations) -6. [Updating existing documentations](#updating-existing-documentations) -7. [Other contributions](#other-contributions) -8. [Coding conventions](#coding-conventions) -9. [Questions?](#questions) - -## Reporting bugs - -1. Update to the most recent master release; the bug may already be fixed. -2. Search for existing issues; it's possible someone has already encountered this bug. -3. Try to isolate the problem and include steps to reproduce it. -4. Share as much information as possible (e.g. browser/OS environment, log output, stack trace, screenshots, etc.). - -## Requesting new features - -1. Search for similar feature requests; someone may have already requested it. -2. Make sure your feature fits DevDocs's [vision](https://github.com/Thibaut/devdocs/blob/master/README.md#vision). -3. Provide a clear and detailed explanation of the feature and why it's important to add it. - -For general feedback and ideas, please use the [mailing list](https://groups.google.com/d/forum/devdocs). - -## Requesting new documentations - -Please don't open issues to request new documentations. -Use the [Trello board](https://trello.com/b/6BmTulfx/devdocs-documentation) where everyone can vote. - -## Contributing code and features - -1. Search for existing issues; someone may already be working on a similar feature. -2. Before embarking on any significant pull request, please open an issue describing the changes you intend to make. Otherwise you risk spending a lot of time working on something I may not want to merge. This also tells other contributors that you're working on the feature. -3. Follow the [coding conventions](#coding-conventions). -4. If you're modifying the Ruby code, include tests and ensure they pass. -5. Try to keep your pull request small and simple. -6. When it makes sense, squash your commits into a single commit. -7. Describe all your changes in the commit message and/or pull request. - -## Contributing new documentations - -See the [wiki](https://github.com/Thibaut/devdocs/wiki) to learn how to add new documentations. - -**Important:** the documentation's license must permit alteration, redistribution and commercial use, and the documented software must be released under an open source license. Feel free to get in touch if you're not sure if a documentation meets those requirements. - -In addition to the [guidelines for contributing code](#contributing-code-and-features), the following guidelines apply to pull requests that add a new documentation: - -* Your documentation must come with an official icon, in both 1x and 2x resolutions (16x16 and 32x32 pixels). This is important because icons are the only thing differentiating search results in the UI. -* DevDocs favors quality over quantity. Your documentation should only include documents that most developers may want to read semi-regularly. By reducing the number of entries, we make it easier to find other, more relevant entries. -* Remove as much content and HTML markup as possible, particularly content not associated with any entry (e.g. introduction, changelog, etc.). -* Names must be as short as possible and unique across the documentation. -* The number of types (categories) should ideally be less than 100. -* Don't modify the icon sprite. I'll do it after your pull request is merged. - -## Updating existing documentations - -Please don't submit a pull request updating the version number of a documentation, unless a change is required in the scraper and you've verified that it works. - -To ask that an existing documentation be updated, please use the [Trello board](https://trello.com/c/2B0hmW7M/52-request-updates-here). - -## Other contributions - -Besides new docs and features, here are other ways you can contribute: - -* **Improve our copy.** English isn't my first language so if you notice grammatical or usage errors, feel free to submit a pull request — it'll be much appreciated. -* **Participate in the issue tracker.** Your opinion matters — feel free to add comments to existing issues. You're also welcome to participate to the [mailing list](https://groups.google.com/d/forum/devdocs). - -## Coding conventions - -* two spaces; no tabs -* no trailing whitespace; blank lines should have no spaces; new line at end-of-file -* use the same coding style as the rest of the codebase - -## Questions? - -If you have any questions, please feel free to ask on the [mailing list](https://groups.google.com/d/forum/devdocs). diff --git a/COPYRIGHT b/COPYRIGHT index c9f04ecb61..374054bdbf 100644 --- a/COPYRIGHT +++ b/COPYRIGHT @@ -1,13 +1,13 @@ -Copyright 2013-2016 Thibaut Courouble and other contributors +Copyright 2013-2025 Thibaut Courouble and other contributors This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of the MPL was not distributed with this 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 my permission, except as may be -necessary to comply with the notice/attribution requirements. +derived from this software without the maintainers' permission, except +as may be necessary to comply with the notice/attribution requirements. -I also wish that any documentation file generated using this software +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. \ No newline at end of file +credit where credit's due. Thanks. diff --git a/Dockerfile b/Dockerfile index 3e873d25f6..aff0ae395a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,17 +1,25 @@ +FROM ruby:3.4.7 +ENV LANG=C.UTF-8 +ENV ENABLE_SERVICE_WORKER=true -FROM ruby:2.3.1 -MAINTAINER Conor Heine +WORKDIR /devdocs -RUN apt-get update -RUN apt-get -y install git nodejs -COPY . /devdocs -RUN gem install bundler +RUN apt-get update && \ + apt-get -y install git nodejs libcurl4 && \ + gem install bundler && \ + rm -rf /var/lib/apt/lists/* -WORKDIR /devdocs +COPY Gemfile Gemfile.lock Rakefile /devdocs/ -RUN bundle install --system -RUN thor docs:download --all +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 - diff --git a/Dockerfile-alpine b/Dockerfile-alpine new file mode 100644 index 0000000000..b82d4dc49a --- /dev/null +++ b/Dockerfile-alpine @@ -0,0 +1,22 @@ +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 diff --git a/Gemfile b/Gemfile index 879d00bb3f..9893bb5774 100644 --- a/Gemfile +++ b/Gemfile @@ -1,27 +1,37 @@ source 'https://rubygems.org' -ruby '2.3.1' +ruby '3.4.7' +gem 'activesupport', require: false +gem 'html-pipeline' +gem 'nokogiri' +gem 'pry-byebug' gem 'rake' +gem 'terminal-table' gem 'thor' -gem 'pry', '~> 0.10.0' -gem 'activesupport', '~> 4.2', require: false +gem 'typhoeus' gem 'yajl-ruby', require: false group :app do + gem 'browser' + gem 'chunky_png' + gem 'erubi' + gem 'image_optim_pack', platforms: :ruby + gem 'image_optim' + gem 'rack-ssl-enforcer' gem 'rack' - gem 'sinatra' + gem 'rss' + gem 'sass' gem 'sinatra-contrib' - gem 'thin' - gem 'sprockets' + gem 'sinatra' gem 'sprockets-helpers' - gem 'erubis' - gem 'browser' - gem 'sass' - gem 'coffee-script' + gem 'sprockets-sass' + gem 'sprockets' + gem 'thin' end group :production do - gem 'uglifier' + gem 'newrelic_rpm' + gem "terser" end group :development do @@ -29,18 +39,16 @@ group :development do end group :docs do - gem 'typhoeus' - gem 'nokogiri' - gem 'html-pipeline' gem 'progress_bar', require: false - gem 'unix_utils', require: false + gem 'redcarpet' gem 'tty-pager', require: false + gem 'unix_utils', require: false end group :test do gem 'minitest' - gem 'rr', require: false gem 'rack-test', require: false + gem 'rr', require: false end if ENV['SELENIUM'] == '1' diff --git a/Gemfile.lock b/Gemfile.lock index 203168a734..0dc3ed1f8e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,136 +1,197 @@ GEM remote: https://rubygems.org/ specs: - activesupport (4.2.7.1) - i18n (~> 0.7) - json (~> 1.7, >= 1.7.7) - minitest (~> 5.1) - thread_safe (~> 0.3, >= 0.3.4) - tzinfo (~> 1.1) - backports (3.6.8) - better_errors (2.1.1) - coderay (>= 1.0.0) - erubis (>= 2.6.6) + activesupport (7.2.3) + base64 + benchmark (>= 0.3) + bigdecimal + concurrent-ruby (~> 1.0, >= 1.3.1) + connection_pool (>= 2.2.5) + drb + i18n (>= 1.6, < 2) + logger (>= 1.4.2) + minitest (>= 5.1) + securerandom (>= 0.3) + tzinfo (~> 2.0, >= 2.0.5) + base64 (0.2.0) + benchmark (0.3.0) + better_errors (2.10.1) + erubi (>= 1.0.0) rack (>= 0.9.0) - browser (2.2.0) - coderay (1.1.1) - coffee-script (2.4.1) - coffee-script-source - execjs - coffee-script-source (1.10.0) - concurrent-ruby (1.0.2) - daemons (1.2.4) - erubis (2.7.0) - ethon (0.9.1) - ffi (>= 1.3.0) - eventmachine (1.2.0.1) - execjs (2.7.0) - ffi (1.9.14) - highline (1.7.8) - html-pipeline (2.4.2) + rouge (>= 1.0.0) + bigdecimal (3.1.9) + browser (5.3.1) + byebug (12.0.0) + chunky_png (1.4.0) + coderay (1.1.3) + concurrent-ruby (1.3.5) + connection_pool (2.4.1) + daemons (1.4.1) + drb (2.2.3) + erubi (1.13.1) + ethon (0.17.0) + ffi (>= 1.15.0) + eventmachine (1.2.7) + execjs (2.9.1) + exifr (1.4.0) + ffi (1.17.2) + fspath (3.1.2) + highline (3.1.2) + reline + html-pipeline (2.14.3) activesupport (>= 2) nokogiri (>= 1.4) - i18n (0.7.0) - json (1.8.3) - method_source (0.8.2) - mini_portile2 (2.1.0) - minitest (5.9.1) - multi_json (1.12.1) - nokogiri (1.6.8) - mini_portile2 (~> 2.1.0) - pkg-config (~> 1.1.7) + i18n (1.14.7) + concurrent-ruby (~> 1.0) + image_optim (0.31.3) + exifr (~> 1.2, >= 1.2.2) + fspath (~> 3.0) + image_size (>= 1.5, < 4) + in_threads (~> 1.3) + progress (~> 3.0, >= 3.0.1) + image_optim_pack (0.10.1) + fspath (>= 2.1, < 4) + image_optim (~> 0.19) + image_size (3.3.0) + in_threads (1.6.0) + io-console (0.8.0) + logger (1.6.6) + method_source (1.1.0) + mini_portile2 (2.8.9) + minitest (5.27.0) + multi_json (1.15.0) + mustermann (3.0.3) + ruby2_keywords (~> 0.0.1) + newrelic_rpm (8.16.0) + nokogiri (1.18.10) + mini_portile2 (~> 2.8.2) + racc (~> 1.4) options (2.3.2) - pkg-config (1.1.7) - progress_bar (1.0.5) - highline (~> 1.6) + progress (3.6.0) + progress_bar (1.3.4) + highline (>= 1.6) options (~> 2.3.0) - pry (0.10.4) - coderay (~> 1.1.0) - method_source (~> 0.8.1) - slop (~> 3.4) - rack (1.6.4) - rack-protection (1.5.3) - rack - rack-test (0.6.3) - rack (>= 1.0) - rake (11.3.0) - rr (1.2.0) - sass (3.4.22) - sinatra (1.4.7) - rack (~> 1.5) - rack-protection (~> 1.4) - tilt (>= 1.3, < 3) - sinatra-contrib (1.4.7) - backports (>= 2.0) - multi_json - rack-protection - rack-test - sinatra (~> 1.4.0) - tilt (>= 1.3, < 3) - slop (3.6.0) - sprockets (3.7.0) + pry (0.15.2) + coderay (~> 1.1) + method_source (~> 1.0) + pry-byebug (3.11.0) + byebug (~> 12.0) + pry (>= 0.13, < 0.16) + racc (1.8.1) + rack (2.2.21) + rack-protection (3.2.0) + base64 (>= 0.1.0) + rack (~> 2.2, >= 2.2.4) + rack-ssl-enforcer (0.2.9) + rack-test (2.2.0) + rack (>= 1.3) + rake (13.3.1) + rb-fsevent (0.11.2) + rb-inotify (0.10.1) + ffi (~> 1.0) + redcarpet (3.6.1) + reline (0.6.0) + io-console (~> 0.5) + rexml (3.3.9) + rouge (1.11.1) + rr (3.1.2) + rss (0.3.1) + rexml + ruby2_keywords (0.0.5) + sass (3.7.4) + sass-listen (~> 4.0.0) + sass-listen (4.0.0) + rb-fsevent (~> 0.9, >= 0.9.4) + rb-inotify (~> 0.9, >= 0.9.7) + securerandom (0.3.2) + sinatra (3.2.0) + mustermann (~> 3.0) + rack (~> 2.2, >= 2.2.4) + rack-protection (= 3.2.0) + tilt (~> 2.0) + sinatra-contrib (3.2.0) + multi_json (>= 0.0.2) + mustermann (~> 3.0) + rack-protection (= 3.2.0) + sinatra (= 3.2.0) + tilt (~> 2.0) + sprockets (3.7.5) + base64 concurrent-ruby (~> 1.0) rack (> 1, < 3) - sprockets-helpers (1.2.1) + sprockets-helpers (1.4.0) sprockets (>= 2.2) - thin (1.7.0) + sprockets-sass (2.0.0.beta2) + sprockets (>= 2.0, < 4.0) + strings (0.2.1) + strings-ansi (~> 0.2) + unicode-display_width (>= 1.5, < 3.0) + unicode_utils (~> 1.4) + strings-ansi (0.2.0) + terminal-table (3.0.2) + unicode-display_width (>= 1.1.1, < 3) + terser (1.2.6) + execjs (>= 0.3.0, < 3) + thin (1.8.2) daemons (~> 1.0, >= 1.0.9) eventmachine (~> 1.0, >= 1.0.4) rack (>= 1, < 3) - thor (0.19.1) - thread_safe (0.3.5) - tilt (2.0.5) - tty-pager (0.4.0) - tty-screen (~> 0.5.0) - tty-which (~> 0.1.0) - verse (~> 0.4.0) - tty-screen (0.5.0) - tty-which (0.1.0) - typhoeus (1.1.0) + thor (1.4.0) + tilt (2.6.0) + tty-pager (0.14.0) + strings (~> 0.2.0) + tty-screen (~> 0.8) + tty-screen (0.8.1) + typhoeus (1.4.1) ethon (>= 0.9.0) - tzinfo (1.2.2) - thread_safe (~> 0.1) - uglifier (3.0.2) - execjs (>= 0.3.0, < 3) + tzinfo (2.0.6) + concurrent-ruby (~> 1.0) + unicode-display_width (2.3.0) unicode_utils (1.4.0) unix_utils (0.0.15) - verse (0.4.0) - unicode_utils (~> 1.4.0) - yajl-ruby (1.2.1) + yajl-ruby (1.4.3) PLATFORMS ruby DEPENDENCIES - activesupport (~> 4.2) + activesupport better_errors browser - coffee-script - erubis + chunky_png + erubi html-pipeline + image_optim + image_optim_pack minitest + newrelic_rpm nokogiri progress_bar - pry (~> 0.10.0) + pry-byebug rack + rack-ssl-enforcer rack-test rake + redcarpet rr + rss sass sinatra sinatra-contrib sprockets sprockets-helpers + sprockets-sass + terminal-table + terser thin thor tty-pager typhoeus - uglifier unix_utils yajl-ruby RUBY VERSION - ruby 2.3.1p112 + ruby 3.4.7p58 BUNDLED WITH - 1.13.2 + 2.4.6 diff --git a/Procfile b/Procfile new file mode 100644 index 0000000000..8c9955855c --- /dev/null +++ b/Procfile @@ -0,0 +1 @@ +web: bundle exec rackup config.ru -p $PORT diff --git a/README.md b/README.md index 1fc465afdf..fcdb677d84 100644 --- a/README.md +++ b/README.md @@ -1,78 +1,100 @@ -# [DevDocs](http://devdocs.io) [![Build Status](https://travis-ci.org/Thibaut/devdocs.svg?branch=master)](https://travis-ci.org/Thibaut/devdocs) +# [DevDocs](https://devdocs.io) — API Documentation Browser -DevDocs combines multiple API documentations in a fast, organized, and searchable interface. +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. -* Created by [Thibaut Courouble](http://thibaut.me) +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: -* Watch the repository on [GitHub](https://github.com/Thibaut/devdocs/subscription) +* 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 -* Join the [mailing list](https://groups.google.com/d/forum/devdocs) - -DevDocs is free and open source. If you like it, please consider supporting my work on [Gratipay](https://gratipay.com/devdocs/). Thanks! -**Table of Contents:** [Quick Start](#quick-start) · [Vision](#vision) · [App](#app) · [Scraper](#scraper) · [Commands](#available-commands) · [Contributing](#contributing) · [License](#copyright--license) · [Questions?](#questions) +**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, I recommend using the hosted version at [devdocs.io](http://devdocs.io). It's up-to-date and works offline out-of-the-box. +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. -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. +### Using Docker (Recommended) -DevDocs requires Ruby 2.3.1, libcurl, and a JavaScript runtime supported by [ExecJS](https://github.com/rails/execjs#readme) (included in OS X and Windows; [Node.js](https://nodejs.org/en/) on Linux). Once you have these installed, run the following commands: +The easiest way to run DevDocs locally is using Docker: +```sh +docker run --name devdocs -d -p 9292:9292 ghcr.io/freecodecamp/devdocs:latest ``` -git clone https://github.com/Thibaut/devdocs.git && cd devdocs -gem install bundler -bundle install -thor docs:download --default -rackup + +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 ``` -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. +### Manual Installation -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`. +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. -**Note:** there is currently no update mechanism other than `git pull origin master` 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/Thibaut/devdocs/subscription) this repository. +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`. -Alternatively, DevDocs may be started as a Docker container: +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 ``` -# First, build the image -git clone https://github.com/Thibaut/devdocs.git && cd devdocs -docker build -t thibaut/devdocs . -# Finally, start a DevDocs container (access http://localhost:9292) -docker run --name devdocs -d -p 9292:9292 thibaut/devdocs -``` +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. +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, written in [CoffeeScript](http://coffeescript.org), and powered by a small [Sinatra](http://www.sinatrarb.com)/[Sprockets](https://github.com/rails/sprockets) application. It relies on files generated by the [scraper](#scraper). +The web app is all client-side JavaScript, powered by a small [Sinatra](http://www.sinatrarb.com)/[Sprockets](https://github.com/rails/sprockets) application. It relies on files generated by the [scraper](#scraper). Many of the code's design decisions were driven by the fact that the app uses XHR to load content directly into the main frame. This includes stripping the original documents of most of their HTML markup (e.g. scripts and stylesheets) to avoid polluting the main frame, and prefixing all CSS class names with an underscore to prevent conflicts. -Another driving factor is performance and the fact that everything happens in the browser. `applicationCache` (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. +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: -1. On the desktop: - * Recent version of Chrome, Firefox, or Opera - * Safari 8+ - * IE / Edge 10+ -2. On mobile: - * iOS 8+ - * Android 4.1+ - * Windows Phone 8+ +* 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! @@ -89,18 +111,19 @@ Modifications made to each document include: * 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 and filters is available on the [wiki](https://github.com/Thibaut/devdocs/wiki). +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 @@ -117,10 +140,9 @@ thor docs:clean # Delete documentation packages # Console thor console # Start a REPL thor console:docs # Start a REPL in the "Docs" module -Note: tests can be run quickly from within the console using the "test" command. Run "help test" -for usage instructions. -# Tests +# 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 @@ -130,22 +152,77 @@ thor assets:compile # Compile assets (not required in development mode) thor assets:clean # Clean old assets ``` -## Contributing +If multiple versions of Ruby are installed on your system, commands must be run through `bundle exec`. -Contributions are welcome. Please read the [contributing guidelines](https://github.com/Thibaut/devdocs/blob/master/CONTRIBUTING.md). +## Contributing -DevDocs's own documentation is available on the [wiki](https://github.com/Thibaut/devdocs/wiki). +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 | ![Latest GitHub commit](https://img.shields.io/github/last-commit/yannickglt/alfred-devdocs?logo=github&label) | ![GitHub stars](https://img.shields.io/github/stars/yannickglt/alfred-devdocs?logo=github&label) | +| [Merith-TK/devdocs_webapp_kotlin](https://github.com/Merith-TK/devdocs_webapp_kotlin) | Android application | ![Latest GitHub commit](https://img.shields.io/github/last-commit/Merith-TK/devdocs_webapp_kotlin?logo=github&label) | ![GitHub stars](https://img.shields.io/github/stars/Merith-TK/devdocs_webapp_kotlin?logo=github&label) | +| [gruehle/dev-docs-viewer](https://github.com/gruehle/dev-docs-viewer) | Brackets extension | ![Latest GitHub commit](https://img.shields.io/github/last-commit/gruehle/dev-docs-viewer?logo=github&label) | ![GitHub stars](https://img.shields.io/github/stars/gruehle/dev-docs-viewer?logo=github&label) | +| [egoist/devdocs-desktop](https://github.com/egoist/devdocs-desktop) | Electron application | ![Latest GitHub commit](https://img.shields.io/github/last-commit/egoist/devdocs-desktop?logo=github&label) | ![GitHub stars](https://img.shields.io/github/stars/egoist/devdocs-desktop?logo=github&label) | +| [skeeto/devdocs-lookup](https://github.com/skeeto/devdocs-lookup) | Emacs function | ![Latest GitHub commit](https://img.shields.io/github/last-commit/skeeto/devdocs-lookup?logo=github&label) | ![GitHub stars](https://img.shields.io/github/stars/skeeto/devdocs-lookup?logo=github&label) | +| [astoff/devdocs.el](https://github.com/astoff/devdocs.el) | Emacs viewer | ![Latest GitHub commit](https://img.shields.io/github/last-commit/astoff/devdocs.el?logo=github&label) | ![GitHub stars](https://img.shields.io/github/stars/astoff/devdocs.el?logo=github&label) | +| [naquad/devdocs-shell](https://github.com/naquad/devdocs-shell) | GTK shell with Vim integration | ![Latest GitHub commit](https://img.shields.io/github/last-commit/naquad/devdocs-shell?logo=github&label) | ![GitHub stars](https://img.shields.io/github/stars/naquad/devdocs-shell?logo=github&label) | +| [hardpixel/devdocs-desktop](https://github.com/hardpixel/devdocs-desktop) | GTK application | ![Latest GitHub commit](https://img.shields.io/github/last-commit/hardpixel/devdocs-desktop?logo=github&label) | ![GitHub stars](https://img.shields.io/github/stars/hardpixel/devdocs-desktop?logo=github&label) | +| [qwfy/doc-browser](https://github.com/qwfy/doc-browser) | Linux application | ![Latest GitHub commit](https://img.shields.io/github/last-commit/qwfy/doc-browser?logo=github&label) | ![GitHub stars](https://img.shields.io/github/stars/qwfy/doc-browser?logo=github&label) | +| [dteoh/devdocs-macos](https://github.com/dteoh/devdocs-macos) | macOS application | ![Latest GitHub commit](https://img.shields.io/github/last-commit/dteoh/devdocs-macos?logo=github&label) | ![GitHub stars](https://img.shields.io/github/stars/dteoh/devdocs-macos?logo=github&label) | +| [Sublime Text plugin](https://sublime.wbond.net/packages/DevDocs) | Sublime Text plugin | ![Latest GitHub commit](https://img.shields.io/github/last-commit/vitorbritto/sublime-devdocs?logo=github&label) | ![GitHub stars](https://img.shields.io/github/stars/vitorbritto/sublime-devdocs?logo=github&label) | +| [mohamed3nan/DevDocs-Tab](https://github.com/mohamed3nan/DevDocs-Tab) | VS Code extension (view as tab) | ![Latest GitHub commit](https://img.shields.io/github/last-commit/mohamed3nan/DevDocs-Tab?logo=github&label) | ![GitHub stars](https://img.shields.io/github/stars/mohamed3nan/DevDocs-Tab?logo=github&label) | +| [deibit/vscode-devdocs](https://marketplace.visualstudio.com/items?itemName=deibit.devdocs) | VS Code extension (open the browser) | ![Latest GitHub commit](https://img.shields.io/github/last-commit/deibit/vscode-devdocs?logo=github&label) | ![GitHub stars](https://img.shields.io/github/stars/deibit/vscode-devdocs?logo=github&label) | +| [mdh34/quickDocs](https://github.com/mdh34/quickDocs) | Vala/Python based viewer | ![Latest GitHub commit](https://img.shields.io/github/last-commit/mdh34/quickDocs?logo=github&label) | ![GitHub stars](https://img.shields.io/github/stars/mdh34/quickDocs?logo=github&label) | +| [girishji/devdocs.vim](https://github.com/girishji/devdocs.vim) | Vim plugin & TUI (browse inside Vim) | ![Latest GitHub commit](https://img.shields.io/github/last-commit/girishji/devdocs.vim?logo=github&label) | ![GitHub stars](https://img.shields.io/github/stars/girishji/devdocs.vim?logo=github&label) | +| [romainl/vim-devdocs](https://github.com/romainl/vim-devdocs) | Vim plugin | ![Latest GitHub commit](https://img.shields.io/github/last-commit/romainl/vim-devdocs?logo=github&label) | ![GitHub stars](https://img.shields.io/github/stars/romainl/vim-devdocs?logo=github&label) | +| [waiting-for-dev/vim-www](https://github.com/waiting-for-dev/vim-www) | Vim plugin | ![Latest GitHub commit](https://img.shields.io/github/last-commit/waiting-for-dev/vim-www?logo=github&label) | ![GitHub stars](https://img.shields.io/github/stars/waiting-for-dev/vim-www?logo=github&label) | +| [emmanueltouzery/apidocs.nvim](https://github.com/emmanueltouzery/apidocs.nvim) | Neovim plugin | ![Latest GitHub commit](https://img.shields.io/github/last-commit/emmanueltouzery/apidocs.nvim?logo=github&label) | ![GitHub stars](https://img.shields.io/github/stars/emmanueltouzery/apidocs.nvim?logo=github&label) | +| [toiletbril/dedoc](https://github.com/toiletbril/dedoc) | Terminal based viewer | ![Latest GitHub commit](https://img.shields.io/github/last-commit/toiletbril/dedoc?logo=github&label) | ![GitHub stars](https://img.shields.io/github/stars/toiletbril/dedoc?logo=github&label) | +| [Raycast Devdocs](https://www.raycast.com/djpowers/devdocs) | Raycast extension | Unavailable | Unavailable | +| [chrisgrieser/alfred-docs-searches](https://github.com/chrisgrieser/alfred-docs-searches) | Alfred workflow | ![Latest GitHub commit](https://img.shields.io/github/last-commit/chrisgrieser/alfred-docs-searches?logo=github&label) | ![GitHub stars](https://img.shields.io/github/stars/chrisgrieser/alfred-docs-searches?logo=github&label) | ## Copyright / License -Copyright 2013-2016 Thibaut Courouble and [other contributors](https://github.com/Thibaut/devdocs/graphs/contributors) +Copyright 2013–2025 Thibaut Courouble and [other contributors](https://github.com/freeCodeCamp/devdocs/graphs/contributors) -This software is licensed under the terms of the Mozilla Public License v2.0. See the [COPYRIGHT](https://github.com/Thibaut/devdocs/blob/master/COPYRIGHT) and [LICENSE](https://github.com/Thibaut/devdocs/blob/master/LICENSE) files. +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 my permission, except as may be necessary to comply with the notice/attribution requirements. +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. -I 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! +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 [mailing list](https://groups.google.com/d/forum/devdocs). +If you have any questions, please feel free to ask them on the `#contributors` chat room on [Discord](https://discord.gg/PRyKn3Vbay). diff --git a/Rakefile b/Rakefile index 3904cbd7c4..705c6ec1ba 100644 --- a/Rakefile +++ b/Rakefile @@ -3,6 +3,7 @@ require 'bundler/setup' require 'thor' +Bundler.require :default $LOAD_PATH.unshift 'lib' task :default do @@ -13,6 +14,9 @@ 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 diff --git a/assets/images/.gitignore b/assets/images/.gitignore new file mode 100644 index 0000000000..4a72ec741e --- /dev/null +++ b/assets/images/.gitignore @@ -0,0 +1 @@ +sprites/**/* diff --git a/assets/images/docs.png b/assets/images/docs.png deleted file mode 100644 index d84bec93b6..0000000000 Binary files a/assets/images/docs.png and /dev/null differ diff --git a/assets/images/docs@2x.png b/assets/images/docs@2x.png deleted file mode 100644 index de70a95afb..0000000000 Binary files a/assets/images/docs@2x.png and /dev/null differ diff --git a/assets/images/icons.png b/assets/images/icons.png deleted file mode 100644 index dcd73c9f23..0000000000 Binary files a/assets/images/icons.png and /dev/null differ diff --git a/assets/images/icons@2x.png b/assets/images/icons@2x.png deleted file mode 100644 index ceef01d752..0000000000 Binary files a/assets/images/icons@2x.png and /dev/null differ diff --git a/assets/javascripts/app/app.coffee b/assets/javascripts/app/app.coffee deleted file mode 100644 index b700d61143..0000000000 --- a/assets/javascripts/app/app.coffee +++ /dev/null @@ -1,286 +0,0 @@ -@app = - _$: $ - _$$: $$ - _page: page - collections: {} - models: {} - templates: {} - views: {} - - init: -> - try @initErrorTracking() catch - return unless @browserCheck() - @showLoading() - - @el = $('._app') - @localStorage = new LocalStorageStore - @appCache = new app.AppCache if app.AppCache.isEnabled() - @settings = new app.Settings @localStorage - @db = new app.DB() - - @docs = new app.collections.Docs - @disabledDocs = new app.collections.Docs - @entries = new app.collections.Entries - - @router = new app.Router - @shortcuts = new app.Shortcuts - @document = new app.views.Document - @mobile = new app.views.Mobile if @isMobile() - - if document.body.hasAttribute('data-doc') - @DOC = JSON.parse(document.body.getAttribute('data-doc')) - @bootOne() - else if @DOCS - @bootAll() - else - @onBootError() - return - - browserCheck: -> - return true if @isSupportedBrowser() - document.body.className = '' - document.body.innerHTML = app.templates.unsupportedBrowser - false - - initErrorTracking: -> - # Show a warning message and don't track errors when the app is loaded - # from a domain other than our own, because things are likely to break. - # (e.g. cross-domain requests) - if @isInvalidLocation() - new app.views.Notif 'InvalidLocation' - else - if @config.sentry_dsn - Raven.config @config.sentry_dsn, - release: @config.release - whitelistUrls: [/devdocs/] - includePaths: [/devdocs/] - ignoreErrors: [/NPObject/, /NS_ERROR/, /^null$/] - tags: - mode: if @isSingleDoc() then 'single' else 'full' - iframe: (window.top isnt window).toString() - shouldSendCallback: => - try - if @isInjectionError() - @onInjectionError() - return false - if @isAndroidWebview() - return false - true - dataCallback: (data) -> - try - $.extend(data.user ||= {}, app.settings.dump()) - data.user.docs = data.user.docs.split('/') if data.user.docs - data.user.lastIDBTransaction = app.lastIDBTransaction if app.lastIDBTransaction - data - .install() - @previousErrorHandler = onerror - window.onerror = @onWindowError.bind(@) - CookieStore.onBlocked = @onCookieBlocked - return - - bootOne: -> - @doc = new app.models.Doc @DOC - @docs.reset [@doc] - @doc.load @start.bind(@), @onBootError.bind(@), readCache: true - new app.views.Notice 'singleDoc', @doc - delete @DOC - return - - bootAll: -> - docs = @settings.getDocs() - for doc in @DOCS - (if docs.indexOf(doc.slug) >= 0 then @docs else @disabledDocs).add(doc) - @migrateDocs() - @docs.sort() - @disabledDocs.sort() - @docs.load @start.bind(@), @onBootError.bind(@), readCache: true, writeCache: true - delete @DOCS - return - - start: -> - @entries.add doc.toEntry() for doc in @docs.all() - @entries.add doc.toEntry() for doc in @disabledDocs.all() - @initDoc(doc) for doc in @docs.all() - @trigger 'ready' - @router.start() - @hideLoading() - @welcomeBack() unless @doc - @removeEvent 'ready bootError' - try navigator.mozApps?.getSelf().onsuccess = -> app.mozApp = true catch - return - - initDoc: (doc) -> - @entries.add type.toEntry() for type in doc.types.all() - @entries.add doc.entries.all() - return - - migrateDocs: -> - for slug in @settings.getDocs() when not @docs.findBy('slug', slug) - needsSaving = true - doc = @disabledDocs.findBy('slug', 'codeigniter~3') if slug == 'codeigniter~3.0' - doc = @disabledDocs.findBy('slug', 'node~4_lts') if slug == 'node~4.2_lts' - doc = @disabledDocs.findBy('slug', 'xslt_xpath') if slug == 'xpath' - doc = @disabledDocs.findBy('slug', "angularjs~#{match[1]}") if match = /^angular~(1\.\d)$/.exec(slug) - doc ||= @disabledDocs.findBy('slug_without_version', slug) - if doc - @disabledDocs.remove(doc) - @docs.add(doc) - - @saveDocs() if needsSaving - return - - enableDoc: (doc, _onSuccess, onError) -> - return if @docs.contains(doc) - - onSuccess = => - return if @docs.contains(doc) - @disabledDocs.remove(doc) - @docs.add(doc) - @docs.sort() - @initDoc(doc) - @saveDocs() - _onSuccess() - return - - doc.load onSuccess, onError, writeCache: true - return - - saveDocs: -> - @settings.setDocs(doc.slug for doc in @docs.all()) - @db.migrate() - @appCache?.updateInBackground() - - welcomeBack: -> - visitCount = @settings.get('count') - @settings.set 'count', ++visitCount - new app.views.Notif 'Share', autoHide: null if visitCount is 5 - new app.views.News() - new app.views.Updates() - @updateChecker = new app.UpdateChecker() - - reload: -> - @docs.clearCache() - @disabledDocs.clearCache() - if @appCache then @appCache.reload() else window.location = '/' - return - - reset: -> - @localStorage.reset() - @settings.reset() - @db?.reset() - @appCache?.update() - window.location = '/' - return - - showTip: (tip) -> - return if @isSingleDoc() - tips = @settings.getTips() - if tips.indexOf(tip) is -1 - tips.push(tip) - @settings.setTips(tips) - new app.views.Tip(tip) - return - - showLoading: -> - document.body.classList.remove '_noscript' - document.body.classList.add '_loading' - document.body.insertAdjacentHTML 'beforeend', '' # Chrome - return - - hideLoading: -> - document.body.classList.remove '_booting' - document.body.classList.remove '_loading' - try $.remove document.getElementById('fontLoader') - return - - indexHost: -> - # Can't load the index files from the host/CDN when applicationCache is - # enabled because it doesn't support caching URLs that use CORS. - @config[if @appCache and @settings.hasDocs() then 'index_path' else 'docs_host'] - - onBootError: (args...) -> - @trigger 'bootError' - @hideLoading() - return - - onQuotaExceeded: -> - return if @quotaExceeded - @quotaExceeded = true - new app.views.Notif 'QuotaExceeded', autoHide: null - Raven.captureMessage 'QuotaExceededError', level: 'warning' - return - - onCookieBlocked: (key, value, actual) -> - return if @cookieBlocked - @cookieBlocked = true - new app.views.Notif 'CookieBlocked', autoHide: null - Raven.captureMessage "CookieBlocked/#{key}", level: 'warning', extra: {value, actual} - return - - onWindowError: (args...) -> - return if @cookieBlocked - if @isInjectionError args... - @onInjectionError() - else if @isAppError args... - @previousErrorHandler? args... - @hideLoading() - @errorNotif or= new app.views.Notif 'Error' - @errorNotif.show() - return - - onInjectionError: -> - unless @injectionError - @injectionError = true - alert """ - JavaScript code has been injected in the page which prevents DevDocs from running correctly. - Please check your browser extensions/addons. """ - Raven.captureMessage 'injection error', level: 'info' - return - - isInjectionError: -> - # Some browser extensions expect the entire web to use jQuery. - # I gave up trying to fight back. - window.$ isnt app._$ or window.$$ isnt app._$$ or window.page isnt app._page or typeof $.empty isnt 'function' or typeof page.show isnt 'function' - - isAppError: (error, file) -> - # Ignore errors from external scripts. - file and file.indexOf('devdocs') isnt -1 and file.indexOf('.js') is file.length - 3 - - isSupportedBrowser: -> - try - features = - bind: !!Function::bind - pushState: !!history.pushState - matchMedia: !!window.matchMedia - classList: !!document.body.classList - insertAdjacentHTML: !!document.body.insertAdjacentHTML - defaultPrevented: document.createEvent('CustomEvent').defaultPrevented is false - cssGradients: supportsCssGradients() - - for key, value of features when not value - Raven.captureMessage "unsupported/#{key}", level: 'info' - return false - - true - catch error - Raven.captureMessage 'unsupported/exception', level: 'info', extra: { error: error } - false - - isSingleDoc: -> - document.body.hasAttribute('data-doc') - - isMobile: -> - @_isMobile ?= app.views.Mobile.detect() - - isAndroidWebview: -> - @_isAndroidWebview ?= app.views.Mobile.detectAndroidWebview() - - isInvalidLocation: -> - @config.env is 'production' and location.host.indexOf(app.config.production_host) isnt 0 - -supportsCssGradients = -> - el = document.createElement('div') - el.style.cssText = "background-image: -webkit-linear-gradient(top, #000, #fff); background-image: linear-gradient(to top, #000, #fff);" - el.style.backgroundImage.indexOf('gradient') >= 0 - -$.extend app, Events diff --git a/assets/javascripts/app/app.js b/assets/javascripts/app/app.js new file mode 100644 index 0000000000..06d5327e60 --- /dev/null +++ b/assets/javascripts/app/app.js @@ -0,0 +1,419 @@ +class App extends Events { + _$ = $; + _$$ = $$; + _page = page; + collections = {}; + models = {}; + templates = {}; + views = {}; + + init() { + try { + this.initErrorTracking(); + } catch (error) {} + if (!this.browserCheck()) { + return; + } + + this.el = $("._app"); + this.localStorage = new LocalStorageStore(); + if (app.ServiceWorker.isEnabled()) { + this.serviceWorker = new app.ServiceWorker(); + } + this.settings = new app.Settings(); + this.db = new app.DB(); + + this.settings.initLayout(); + + this.docs = new app.collections.Docs(); + this.disabledDocs = new app.collections.Docs(); + this.entries = new app.collections.Entries(); + + this.router = new app.Router(); + this.shortcuts = new app.Shortcuts(); + this.document = new app.views.Document(); + if (this.isMobile()) { + this.mobile = new app.views.Mobile(); + } + + if (document.body.hasAttribute("data-doc")) { + this.DOC = JSON.parse(document.body.getAttribute("data-doc")); + this.bootOne(); + } else if (this.DOCS) { + this.bootAll(); + } else { + this.onBootError(); + } + } + + browserCheck() { + if (this.isSupportedBrowser()) { + return true; + } + document.body.innerHTML = app.templates.unsupportedBrowser; + this.hideLoadingScreen(); + return false; + } + + initErrorTracking() { + // Show a warning message and don't track errors when the app is loaded + // from a domain other than our own, because things are likely to break. + // (e.g. cross-domain requests) + if (this.isInvalidLocation()) { + new app.views.Notif("InvalidLocation"); + } else { + if (this.config.sentry_dsn) { + Raven.config(this.config.sentry_dsn, { + release: this.config.release, + whitelistUrls: [/devdocs/], + includePaths: [/devdocs/], + ignoreErrors: [/NPObject/, /NS_ERROR/, /^null$/, /EvalError/], + tags: { + mode: this.isSingleDoc() ? "single" : "full", + iframe: (window.top !== window).toString(), + electron: (!!window.process?.versions?.electron).toString(), + }, + shouldSendCallback: () => { + try { + if (this.isInjectionError()) { + this.onInjectionError(); + return false; + } + if (this.isAndroidWebview()) { + return false; + } + } catch (error) {} + return true; + }, + dataCallback(data) { + try { + data.user ||= {}; + Object.assign(data.user, app.settings.dump()); + if (data.user.docs) { + data.user.docs = data.user.docs.split("/"); + } + if (app.lastIDBTransaction) { + data.user.lastIDBTransaction = app.lastIDBTransaction; + } + data.tags.scriptCount = document.scripts.length; + } catch (error) {} + return data; + }, + }).install(); + } + this.previousErrorHandler = onerror; + window.onerror = this.onWindowError.bind(this); + CookiesStore.onBlocked = this.onCookieBlocked; + } + } + + bootOne() { + this.doc = new app.models.Doc(this.DOC); + this.docs.reset([this.doc]); + this.doc.load(this.start.bind(this), this.onBootError.bind(this), { + readCache: true, + }); + new app.views.Notice("singleDoc", this.doc); + delete this.DOC; + } + + bootAll() { + const docs = this.settings.getDocs(); + for (var doc of this.DOCS) { + (docs.includes(doc.slug) ? this.docs : this.disabledDocs).add(doc); + } + this.migrateDocs(); + this.docs.load(this.start.bind(this), this.onBootError.bind(this), { + readCache: true, + writeCache: true, + }); + delete this.DOCS; + } + + start() { + let doc; + for (doc of this.docs.all()) { + this.entries.add(doc.toEntry()); + } + for (doc of this.disabledDocs.all()) { + this.entries.add(doc.toEntry()); + } + for (doc of this.docs.all()) { + this.initDoc(doc); + } + this.trigger("ready"); + this.router.start(); + this.hideLoadingScreen(); + setTimeout(() => { + if (!this.doc) { + this.welcomeBack(); + } + return this.removeEvent("ready bootError"); + }, 50); + } + + initDoc(doc) { + for (var type of doc.types.all()) { + doc.entries.add(type.toEntry()); + } + this.entries.add(doc.entries.all()); + } + + migrateDocs() { + let needsSaving; + for (var slug of this.settings.getDocs()) { + if (!this.docs.findBy("slug", slug)) { + var doc; + + needsSaving = true; + if (slug === "webpack~2") { + doc = this.disabledDocs.findBy("slug", "webpack"); + } + if (slug === "angular~4_typescript") { + doc = this.disabledDocs.findBy("slug", "angular"); + } + if (slug === "angular~2_typescript") { + doc = this.disabledDocs.findBy("slug", "angular~2"); + } + if (!doc) { + doc = this.disabledDocs.findBy("slug_without_version", slug); + } + if (doc) { + this.disabledDocs.remove(doc); + this.docs.add(doc); + } + } + } + + if (needsSaving) { + this.saveDocs(); + } + } + + enableDoc(doc, _onSuccess, onError) { + if (this.docs.contains(doc)) { + return; + } + + const onSuccess = () => { + if (this.docs.contains(doc)) { + return; + } + this.disabledDocs.remove(doc); + this.docs.add(doc); + this.docs.sort(); + this.initDoc(doc); + this.saveDocs(); + if (app.settings.get("autoInstall")) { + doc.install(_onSuccess, onError); + } else { + _onSuccess(); + } + }; + + doc.load(onSuccess, onError, { writeCache: true }); + } + + saveDocs() { + this.settings.setDocs(this.docs.all().map((doc) => doc.slug)); + this.db.migrate(); + return this.serviceWorker != null + ? this.serviceWorker.updateInBackground() + : undefined; + } + + welcomeBack() { + let visitCount = this.settings.get("count"); + this.settings.set("count", ++visitCount); + if (visitCount === 5) { + new app.views.Notif("Share", { autoHide: null }); + } + new app.views.News(); + new app.views.Updates(); + return (this.updateChecker = new app.UpdateChecker()); + } + + reboot() { + if (location.pathname !== "/" && location.pathname !== "/settings") { + window.location = `/#${location.pathname}`; + } else { + window.location = "/"; + } + } + + reload() { + this.docs.clearCache(); + this.disabledDocs.clearCache(); + if (this.serviceWorker) { + this.serviceWorker.reload(); + } else { + this.reboot(); + } + } + + reset() { + this.localStorage.reset(); + this.settings.reset(); + if (this.db != null) { + this.db.reset(); + } + if (this.serviceWorker != null) { + this.serviceWorker.update(); + } + window.location = "/"; + } + + showTip(tip) { + if (this.isSingleDoc()) { + return; + } + const tips = this.settings.getTips(); + if (!tips.includes(tip)) { + tips.push(tip); + this.settings.setTips(tips); + new app.views.Tip(tip); + } + } + + hideLoadingScreen() { + if ($.overlayScrollbarsEnabled()) { + document.body.classList.add("_overlay-scrollbars"); + } + document.documentElement.classList.remove("_booting"); + } + + indexHost() { + // Can't load the index files from the host/CDN when service worker is + // enabled because it doesn't support caching URLs that use CORS. + return this.config[ + this.serviceWorker && this.settings.hasDocs() + ? "index_path" + : "docs_origin" + ]; + } + + onBootError(...args) { + this.trigger("bootError"); + this.hideLoadingScreen(); + } + + onQuotaExceeded() { + if (this.quotaExceeded) { + return; + } + this.quotaExceeded = true; + new app.views.Notif("QuotaExceeded", { autoHide: null }); + } + + onCookieBlocked(key, value, actual) { + if (this.cookieBlocked) { + return; + } + this.cookieBlocked = true; + new app.views.Notif("CookieBlocked", { autoHide: null }); + Raven.captureMessage(`CookieBlocked/${key}`, { + level: "warning", + extra: { value, actual }, + }); + } + + onWindowError(...args) { + if (this.cookieBlocked) { + return; + } + if (this.isInjectionError(...args)) { + this.onInjectionError(); + } else if (this.isAppError(...args)) { + if (typeof this.previousErrorHandler === "function") { + this.previousErrorHandler(...args); + } + this.hideLoadingScreen(); + if (!this.errorNotif) { + this.errorNotif = new app.views.Notif("Error"); + } + this.errorNotif.show(); + } + } + + onInjectionError() { + if (!this.injectionError) { + this.injectionError = true; + alert(`\ +JavaScript code has been injected in the page which prevents DevDocs from running correctly. +Please check your browser extensions/addons. `); + Raven.captureMessage("injection error", { level: "info" }); + } + } + + isInjectionError() { + // Some browser extensions expect the entire web to use jQuery. + // I gave up trying to fight back. + return ( + window.$ !== app._$ || + window.$$ !== app._$$ || + window.page !== app._page || + typeof $.empty !== "function" || + typeof page.show !== "function" + ); + } + + isAppError(error, file) { + // Ignore errors from external scripts. + return file && file.includes("devdocs") && file.endsWith(".js"); + } + + isSupportedBrowser() { + try { + const features = { + bind: !!Function.prototype.bind, + pushState: !!history.pushState, + matchMedia: !!window.matchMedia, + insertAdjacentHTML: !!document.body.insertAdjacentHTML, + defaultPrevented: + document.createEvent("CustomEvent").defaultPrevented === false, + cssVariables: !!CSS.supports?.("(--t: 0)"), + }; + + for (var key in features) { + var value = features[key]; + if (!value) { + Raven.captureMessage(`unsupported/${key}`, { level: "info" }); + return false; + } + } + + return true; + } catch (error) { + Raven.captureMessage("unsupported/exception", { + level: "info", + extra: { error }, + }); + return false; + } + } + + isSingleDoc() { + return document.body.hasAttribute("data-doc"); + } + + isMobile() { + return this._isMobile != null + ? this._isMobile + : (this._isMobile = app.views.Mobile.detect()); + } + + isAndroidWebview() { + return this._isAndroidWebview != null + ? this._isAndroidWebview + : (this._isAndroidWebview = app.views.Mobile.detectAndroidWebview()); + } + + isInvalidLocation() { + return ( + this.config.env === "production" && + !location.host.startsWith(app.config.production_host) + ); + } +} + +this.app = new App(); diff --git a/assets/javascripts/app/appcache.coffee b/assets/javascripts/app/appcache.coffee deleted file mode 100644 index d79af1ea9f..0000000000 --- a/assets/javascripts/app/appcache.coffee +++ /dev/null @@ -1,38 +0,0 @@ -class app.AppCache - $.extend @prototype, Events - - @isEnabled: -> - try - applicationCache and applicationCache.status isnt applicationCache.UNCACHED - catch - - constructor: -> - @cache = applicationCache - @notifyUpdate = true - @onUpdateReady() if @cache.status is @cache.UPDATEREADY - - $.on @cache, 'progress', @onProgress - $.on @cache, 'updateready', @onUpdateReady - - update: -> - @notifyUpdate = true - try @cache.update() catch - return - - updateInBackground: -> - @notifyUpdate = false - try @cache.update() catch - return - - reload: -> - $.on @cache, 'updateready noupdate error', -> window.location = '/' - @updateInBackground() - return - - onProgress: (event) => - @trigger 'progress', event - return - - onUpdateReady: => - @trigger 'updateready' if @notifyUpdate - return diff --git a/assets/javascripts/app/config.coffee.erb b/assets/javascripts/app/config.coffee.erb deleted file mode 100644 index 56ac35126f..0000000000 --- a/assets/javascripts/app/config.coffee.erb +++ /dev/null @@ -1,15 +0,0 @@ -app.config = - db_filename: 'db.json' - default_docs: <%= App.default_docs.to_json %> - docs_host: '<%= App.docs_host %>' - 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: 'https://cdn.devdocs.io/mathml.css' diff --git a/assets/javascripts/app/config.js.erb b/assets/javascripts/app/config.js.erb new file mode 100644 index 0000000000..04bc8cbaf4 --- /dev/null +++ b/assets/javascripts/app/config.js.erb @@ -0,0 +1,20 @@ +app.config = { + db_filename: 'db.json', + default_docs: <%= App.default_docs.to_json %>, + docs_aliases: <%= App.docs_aliases.to_json %>, + docs_origin: '<%= App.docs_origin %>', + env: '<%= App.environment %>', + history_cache_size: 10, + index_filename: 'index.json', + index_path: '/<%= App.docs_prefix %>', + max_results: 50, + production_host: 'devdocs.io', + search_param: 'q', + sentry_dsn: '<%= App.sentry_dsn %>', + version: <%= Time.now.to_i %>, + release: <%= Time.now.utc.httpdate.to_json %>, + mathml_stylesheet: '/mathml.css', + favicon_spritesheet: '<%= image_path('sprites/docs.png') %>', + service_worker_path: '/service-worker.js', + service_worker_enabled: <%= App.environment == :production || ENV['ENABLE_SERVICE_WORKER'] == 'true' %>, +} diff --git a/assets/javascripts/app/db.coffee b/assets/javascripts/app/db.coffee deleted file mode 100644 index da0b55b35e..0000000000 --- a/assets/javascripts/app/db.coffee +++ /dev/null @@ -1,344 +0,0 @@ -class app.DB - NAME = 'docs' - - constructor: -> - @useIndexedDB = @useIndexedDB() - @appVersion = @appVersion() - @callbacks = [] - - db: (fn) -> - return fn() unless @useIndexedDB - @callbacks.push(fn) if fn - return if @open - - try - @open = true - req = indexedDB.open(NAME, @schemaVersion()) - req.onsuccess = @onOpenSuccess - req.onerror = @onOpenError - req.onupgradeneeded = @onUpgradeNeeded - catch - @onOpenError() - return - - onOpenSuccess: (event) => - db = event.target.result - - if db.objectStoreNames.length is 0 - try db.close() - @reason = 'empty' - @onOpenError() - return - - unless @checkedBuggyIDB - @checkedBuggyIDB = true - try - @idbTransaction(db, stores: $.makeArray(db.objectStoreNames)[0..1], mode: 'readwrite').abort() # https://bugs.webkit.org/show_bug.cgi?id=136937 - catch - try db.close() - @reason = 'apple' - @onOpenError() - return - - @runCallbacks(db) - @open = false - db.close() - return - - onOpenError: (event) => - event?.preventDefault() - @open = false - - if event?.target?.error?.name is 'QuotaExceededError' - @reset() - @db() - app.onQuotaExceeded() - else - @useIndexedDB = false - @reason or= 'cant_open' - @runCallbacks() - return - - runCallbacks: (db) -> - fn(db) while fn = @callbacks.shift() - return - - onUpgradeNeeded: (event) -> - return unless db = event.target.result - - objectStoreNames = $.makeArray(db.objectStoreNames) - - unless $.arrayDelete(objectStoreNames, 'docs') - try db.createObjectStore('docs') - - for doc in app.docs.all() when not $.arrayDelete(objectStoreNames, doc.slug) - try db.createObjectStore(doc.slug) - - for name in objectStoreNames - try db.deleteObjectStore(name) - return - - store: (doc, data, onSuccess, onError, _retry = true) -> - @db (db) => - unless db - onError() - return - - txn = @idbTransaction db, stores: ['docs', doc.slug], mode: 'readwrite', ignoreError: false - txn.oncomplete = => - @cachedDocs?[doc.slug] = doc.mtime - onSuccess() - return - txn.onerror = (event) => - event.preventDefault() - if txn.error?.name is 'NotFoundError' and _retry - @migrate() - setTimeout => - @store(doc, data, onSuccess, onError, false) - , 0 - else - onError(event) - return - - store = txn.objectStore(doc.slug) - store.clear() - store.add(content, path) for path, content of data - - store = txn.objectStore('docs') - store.put(doc.mtime, doc.slug) - return - return - - unstore: (doc, onSuccess, onError, _retry = true) -> - @db (db) => - unless db - onError() - return - - txn = @idbTransaction db, stores: ['docs', doc.slug], mode: 'readwrite', ignoreError: false - txn.oncomplete = => - delete @cachedDocs?[doc.slug] - onSuccess() - return - txn.onerror = (event) -> - event.preventDefault() - if txn.error?.name is 'NotFoundError' and _retry - @migrate() - setTimeout => - @unstore(doc, onSuccess, onError, false) - , 0 - else - onError(event) - return - - store = txn.objectStore('docs') - store.delete(doc.slug) - - store = txn.objectStore(doc.slug) - store.clear() - return - return - - version: (doc, fn) -> - if (version = @cachedVersion(doc))? - fn(version) - return - - @db (db) => - unless db - fn(false) - return - - txn = @idbTransaction db, stores: ['docs'], mode: 'readonly' - store = txn.objectStore('docs') - - req = store.get(doc.slug) - req.onsuccess = -> - fn(req.result) - return - req.onerror = (event) -> - event.preventDefault() - fn(false) - return - return - return - - cachedVersion: (doc) -> - return unless @cachedDocs - @cachedDocs[doc.slug] or false - - versions: (docs, fn) -> - if versions = @cachedVersions(docs) - fn(versions) - return - - @db (db) => - unless db - fn(false) - return - - txn = @idbTransaction db, stores: ['docs'], mode: 'readonly' - txn.oncomplete = -> - fn(result) - return - store = txn.objectStore('docs') - result = {} - - docs.forEach (doc) -> - req = store.get(doc.slug) - req.onsuccess = -> - result[doc.slug] = req.result - return - req.onerror = (event) -> - event.preventDefault() - result[doc.slug] = false - return - return - return - - cachedVersions: (docs) -> - return unless @cachedDocs - result = {} - result[doc.slug] = @cachedVersion(doc) for doc in docs - result - - load: (entry, onSuccess, onError) -> - if @shouldLoadWithIDB(entry) - onError = @loadWithXHR.bind(@, entry, onSuccess, onError) - @loadWithIDB entry, onSuccess, onError - else - @loadWithXHR entry, onSuccess, onError - - loadWithXHR: (entry, onSuccess, onError) -> - ajax - url: entry.fileUrl() - dataType: 'html' - success: onSuccess - error: onError - - loadWithIDB: (entry, onSuccess, onError) -> - @db (db) => - unless db - onError() - return - - unless db.objectStoreNames.contains(entry.doc.slug) - onError() - @loadDocsCache(db) - return - - txn = @idbTransaction db, stores: [entry.doc.slug], mode: 'readonly' - store = txn.objectStore(entry.doc.slug) - - req = store.get(entry.dbPath()) - req.onsuccess = -> - if req.result then onSuccess(req.result) else onError() - return - req.onerror = (event) -> - event.preventDefault() - onError() - return - @loadDocsCache(db) - return - - loadDocsCache: (db) -> - return if @cachedDocs - @cachedDocs = {} - - txn = @idbTransaction db, stores: ['docs'], mode: 'readonly' - txn.oncomplete = => - setTimeout(@checkForCorruptedDocs, 50) - return - - req = txn.objectStore('docs').openCursor() - req.onsuccess = (event) => - return unless cursor = event.target.result - @cachedDocs[cursor.key] = cursor.value - cursor.continue() - return - req.onerror = (event) -> - event.preventDefault() - return - return - - checkForCorruptedDocs: => - @db (db) => - @corruptedDocs = [] - docs = (key for key, value of @cachedDocs when value) - return if docs.length is 0 - - for slug in docs when not app.docs.findBy('slug', slug) - @corruptedDocs.push(slug) - - for slug in @corruptedDocs - $.arrayDelete(docs, slug) - - if docs.length is 0 - setTimeout(@deleteCorruptedDocs, 0) - return - - txn = @idbTransaction(db, stores: docs, mode: 'readonly', ignoreError: false) - txn.oncomplete = => - setTimeout(@deleteCorruptedDocs, 0) if @corruptedDocs.length > 0 - return - - for doc in docs - txn.objectStore(doc).get('index').onsuccess = (event) => - @corruptedDocs.push(event.target.source.name) unless event.target.result - return - return - return - - deleteCorruptedDocs: => - @db (db) => - txn = @idbTransaction(db, stores: ['docs'], mode: 'readwrite', ignoreError: false) - store = txn.objectStore('docs') - while doc = @corruptedDocs.pop() - @cachedDocs[doc] = false - store.delete(doc) - return - Raven.captureMessage 'corruptedDocs', level: 'info', extra: { docs: @corruptedDocs.join(',') } - return - - shouldLoadWithIDB: (entry) -> - @useIndexedDB and (not @cachedDocs or @cachedDocs[entry.doc.slug]) - - idbTransaction: (db, options) -> - app.lastIDBTransaction = [options.stores, options.mode] - txn = db.transaction(options.stores, options.mode) - unless options.ignoreError is false - txn.onerror = (event) -> - event.preventDefault() - return - unless options.ignoreAbort is false - txn.onabort = (event) -> - event.preventDefault() - return - txn - - reset: -> - try indexedDB?.deleteDatabase(NAME) catch - return - - useIndexedDB: -> - try - if !app.isSingleDoc() and window.indexedDB - true - else - @reason = 'not_supported' - false - catch - false - - migrate: -> - app.settings.set('schema', @userVersion() + 1) - return - - schemaVersion: -> - @appVersion * 10 + @userVersion() - - userVersion: -> - app.settings.get('schema') - - appVersion: -> - if app.config.env is 'production' then app.config.version else Math.floor(Date.now() / 1000) diff --git a/assets/javascripts/app/db.js b/assets/javascripts/app/db.js new file mode 100644 index 0000000000..64e69d9542 --- /dev/null +++ b/assets/javascripts/app/db.js @@ -0,0 +1,559 @@ +app.DB = class DB { + static NAME = "docs"; + static VERSION = 15; + + constructor() { + this.versionMultipler = $.isIE() ? 1e5 : 1e9; + this.useIndexedDB = this.useIndexedDB(); + this.callbacks = []; + } + + db(fn) { + if (!this.useIndexedDB) { + return fn(); + } + if (fn) { + this.callbacks.push(fn); + } + if (this.open) { + return; + } + + try { + this.open = true; + const req = indexedDB.open( + DB.NAME, + DB.VERSION * this.versionMultipler + this.userVersion(), + ); + req.onsuccess = (event) => this.onOpenSuccess(event); + req.onerror = (event) => this.onOpenError(event); + req.onupgradeneeded = (event) => this.onUpgradeNeeded(event); + } catch (error) { + this.fail("exception", error); + } + } + + onOpenSuccess(event) { + let error; + const db = event.target.result; + + if (db.objectStoreNames.length === 0) { + try { + db.close(); + } catch (error1) {} + this.open = false; + this.fail("empty"); + } else if ((error = this.buggyIDB(db))) { + try { + db.close(); + } catch (error2) {} + this.open = false; + this.fail("buggy", error); + } else { + this.runCallbacks(db); + this.open = false; + db.close(); + } + } + + onOpenError(event) { + event.preventDefault(); + this.open = false; + const { error } = event.target; + + switch (error.name) { + case "QuotaExceededError": + this.onQuotaExceededError(); + break; + case "VersionError": + this.onVersionError(); + break; + case "InvalidStateError": + this.fail("private_mode"); + break; + default: + this.fail("cant_open", error); + } + } + + fail(reason, error) { + this.cachedDocs = null; + this.useIndexedDB = false; + if (!this.reason) { + this.reason = reason; + } + if (!this.error) { + this.error = error; + } + if (error) { + if (typeof console.error === "function") { + console.error("IDB error", error); + } + } + this.runCallbacks(); + if (error && reason === "cant_open") { + Raven.captureMessage(`${error.name}: ${error.message}`, { + level: "warning", + fingerprint: [error.name], + }); + } + } + + onQuotaExceededError() { + this.reset(); + this.db(); + app.onQuotaExceeded(); + Raven.captureMessage("QuotaExceededError", { level: "warning" }); + } + + onVersionError() { + const req = indexedDB.open(DB.NAME); + req.onsuccess = (event) => { + return this.handleVersionMismatch(event.target.result.version); + }; + req.onerror = function (event) { + event.preventDefault(); + return this.fail("cant_open", error); + }; + } + + handleVersionMismatch(actualVersion) { + if (Math.floor(actualVersion / this.versionMultipler) !== DB.VERSION) { + this.fail("version"); + } else { + this.setUserVersion(actualVersion - DB.VERSION * this.versionMultipler); + this.db(); + } + } + + buggyIDB(db) { + if (this.checkedBuggyIDB) { + return; + } + this.checkedBuggyIDB = true; + try { + this.idbTransaction(db, { + stores: $.makeArray(db.objectStoreNames).slice(0, 2), + mode: "readwrite", + }).abort(); // https://bugs.webkit.org/show_bug.cgi?id=136937 + return; + } catch (error) { + return error; + } + } + + runCallbacks(db) { + let fn; + while ((fn = this.callbacks.shift())) { + fn(db); + } + } + + onUpgradeNeeded(event) { + const db = event.target.result; + if (!db) { + return; + } + + const objectStoreNames = $.makeArray(db.objectStoreNames); + + if (!$.arrayDelete(objectStoreNames, "docs")) { + try { + db.createObjectStore("docs"); + } catch (error) {} + } + + for (var doc of app.docs.all()) { + if (!$.arrayDelete(objectStoreNames, doc.slug)) { + try { + db.createObjectStore(doc.slug); + } catch (error1) {} + } + } + + for (var name of objectStoreNames) { + try { + db.deleteObjectStore(name); + } catch (error2) {} + } + } + + store(doc, data, onSuccess, onError, _retry) { + if (_retry == null) { + _retry = true; + } + this.db((db) => { + if (!db) { + onError(); + return; + } + + const txn = this.idbTransaction(db, { + stores: ["docs", doc.slug], + mode: "readwrite", + ignoreError: false, + }); + txn.oncomplete = () => { + if (this.cachedDocs != null) { + this.cachedDocs[doc.slug] = doc.mtime; + } + onSuccess(); + }; + txn.onerror = (event) => { + event.preventDefault(); + if (txn.error?.name === "NotFoundError" && _retry) { + this.migrate(); + setTimeout(() => { + return this.store(doc, data, onSuccess, onError, false); + }, 0); + } else { + onError(event); + } + }; + + let store = txn.objectStore(doc.slug); + store.clear(); + for (var path in data) { + var content = data[path]; + store.add(content, path); + } + + store = txn.objectStore("docs"); + store.put(doc.mtime, doc.slug); + }); + } + + unstore(doc, onSuccess, onError, _retry) { + if (_retry == null) { + _retry = true; + } + this.db((db) => { + if (!db) { + onError(); + return; + } + + const txn = this.idbTransaction(db, { + stores: ["docs", doc.slug], + mode: "readwrite", + ignoreError: false, + }); + txn.oncomplete = () => { + if (this.cachedDocs != null) { + delete this.cachedDocs[doc.slug]; + } + onSuccess(); + }; + txn.onerror = function (event) { + event.preventDefault(); + if (txn.error?.name === "NotFoundError" && _retry) { + this.migrate(); + setTimeout(() => { + return this.unstore(doc, onSuccess, onError, false); + }, 0); + } else { + onError(event); + } + }; + + let store = txn.objectStore("docs"); + store.delete(doc.slug); + + store = txn.objectStore(doc.slug); + store.clear(); + }); + } + + version(doc, fn) { + const version = this.cachedVersion(doc); + if (version != null) { + fn(version); + return; + } + + this.db((db) => { + if (!db) { + fn(false); + return; + } + + const txn = this.idbTransaction(db, { + stores: ["docs"], + mode: "readonly", + }); + const store = txn.objectStore("docs"); + + const req = store.get(doc.slug); + req.onsuccess = function () { + fn(req.result); + }; + req.onerror = function (event) { + event.preventDefault(); + fn(false); + }; + }); + } + + cachedVersion(doc) { + if (!this.cachedDocs) { + return; + } + return this.cachedDocs[doc.slug] || false; + } + + versions(docs, fn) { + const versions = this.cachedVersions(docs); + if (versions) { + fn(versions); + return; + } + + return this.db((db) => { + if (!db) { + fn(false); + return; + } + + const txn = this.idbTransaction(db, { + stores: ["docs"], + mode: "readonly", + }); + txn.oncomplete = function () { + fn(result); + }; + const store = txn.objectStore("docs"); + var result = {}; + + docs.forEach((doc) => { + const req = store.get(doc.slug); + req.onsuccess = function () { + result[doc.slug] = req.result; + }; + req.onerror = function (event) { + event.preventDefault(); + result[doc.slug] = false; + }; + }); + }); + } + + cachedVersions(docs) { + if (!this.cachedDocs) { + return; + } + const result = {}; + for (var doc of docs) { + result[doc.slug] = this.cachedVersion(doc); + } + return result; + } + + load(entry, onSuccess, onError) { + if (this.shouldLoadWithIDB(entry)) { + return this.loadWithIDB(entry, onSuccess, () => + this.loadWithXHR(entry, onSuccess, onError) + ); + } else { + return this.loadWithXHR(entry, onSuccess, onError); + } + } + + loadWithXHR(entry, onSuccess, onError) { + return ajax({ + url: entry.fileUrl(), + dataType: "html", + success: onSuccess, + error: onError, + }); + } + + loadWithIDB(entry, onSuccess, onError) { + return this.db((db) => { + if (!db) { + onError(); + return; + } + + if (!db.objectStoreNames.contains(entry.doc.slug)) { + onError(); + this.loadDocsCache(db); + return; + } + + const txn = this.idbTransaction(db, { + stores: [entry.doc.slug], + mode: "readonly", + }); + const store = txn.objectStore(entry.doc.slug); + + const req = store.get(entry.dbPath()); + req.onsuccess = function () { + if (req.result) { + onSuccess(req.result); + } else { + onError(); + } + }; + req.onerror = function (event) { + event.preventDefault(); + onError(); + }; + this.loadDocsCache(db); + }); + } + + loadDocsCache(db) { + if (this.cachedDocs) { + return; + } + this.cachedDocs = {}; + + const txn = this.idbTransaction(db, { + stores: ["docs"], + mode: "readonly", + }); + txn.oncomplete = () => { + setTimeout(() => this.checkForCorruptedDocs(), 50); + }; + + const req = txn.objectStore("docs").openCursor(); + req.onsuccess = (event) => { + const cursor = event.target.result; + if (!cursor) { + return; + } + this.cachedDocs[cursor.key] = cursor.value; + cursor.continue(); + }; + req.onerror = function (event) { + event.preventDefault(); + }; + } + + checkForCorruptedDocs() { + this.db((db) => { + let slug; + this.corruptedDocs = []; + const docs = (() => { + const result = []; + for (var key in this.cachedDocs) { + var value = this.cachedDocs[key]; + if (value) { + result.push(key); + } + } + return result; + })(); + if (docs.length === 0) { + return; + } + + for (slug of docs) { + if (!app.docs.findBy("slug", slug)) { + this.corruptedDocs.push(slug); + } + } + + for (slug of this.corruptedDocs) { + $.arrayDelete(docs, slug); + } + + if (docs.length === 0) { + setTimeout(() => this.deleteCorruptedDocs(), 0); + return; + } + + const txn = this.idbTransaction(db, { + stores: docs, + mode: "readonly", + ignoreError: false, + }); + txn.oncomplete = () => { + if (this.corruptedDocs.length > 0) { + setTimeout(() => this.deleteCorruptedDocs(), 0); + } + }; + + for (var doc of docs) { + txn.objectStore(doc).get("index").onsuccess = (event) => { + if (!event.target.result) { + this.corruptedDocs.push(event.target.source.name); + } + }; + } + }); + } + + deleteCorruptedDocs() { + this.db((db) => { + let doc; + const txn = this.idbTransaction(db, { + stores: ["docs"], + mode: "readwrite", + ignoreError: false, + }); + const store = txn.objectStore("docs"); + while ((doc = this.corruptedDocs.pop())) { + this.cachedDocs[doc] = false; + store.delete(doc); + } + }); + Raven.captureMessage("corruptedDocs", { + level: "info", + extra: { docs: this.corruptedDocs.join(",") }, + }); + } + + shouldLoadWithIDB(entry) { + return ( + this.useIndexedDB && (!this.cachedDocs || this.cachedDocs[entry.doc.slug]) + ); + } + + idbTransaction(db, options) { + app.lastIDBTransaction = [options.stores, options.mode]; + const txn = db.transaction(options.stores, options.mode); + if (options.ignoreError !== false) { + txn.onerror = function (event) { + event.preventDefault(); + }; + } + if (options.ignoreAbort !== false) { + txn.onabort = function (event) { + event.preventDefault(); + }; + } + return txn; + } + + reset() { + try { + indexedDB?.deleteDatabase(DB.NAME); + } catch (error) {} + } + + useIndexedDB() { + try { + if (!app.isSingleDoc() && window.indexedDB) { + return true; + } else { + this.reason = "not_supported"; + return false; + } + } catch (error) { + return false; + } + } + + migrate() { + app.settings.set("schema", this.userVersion() + 1); + } + + setUserVersion(version) { + app.settings.set("schema", version); + } + + userVersion() { + return app.settings.get("schema"); + } +}; diff --git a/assets/javascripts/app/router.coffee b/assets/javascripts/app/router.coffee deleted file mode 100644 index 02b0babc00..0000000000 --- a/assets/javascripts/app/router.coffee +++ /dev/null @@ -1,132 +0,0 @@ -class app.Router - $.extend @prototype, Events - - @routes: [ - ['*', 'before' ] - ['/', 'root' ] - ['/offline', 'offline' ] - ['/about', 'about' ] - ['/news', 'news' ] - ['/help', 'help' ] - ['/:doc-:type/', 'type' ] - ['/:doc/', 'doc' ] - ['/:doc/:path(*)', 'entry' ] - ['*', 'notFound'] - ] - - constructor: -> - for [path, method] in @constructor.routes - page path, @[method].bind(@) - @setInitialPath() - - start: -> - page.start() - return - - show: (path) -> - page.show(path) - return - - triggerRoute: (name) -> - @trigger name, @context - @trigger 'after', name, @context - return - - before: (context, next) -> - @context = context - @trigger 'before', context - next() - return - - doc: (context, next) -> - if doc = app.docs.findBySlug(context.params.doc) or app.disabledDocs.findBySlug(context.params.doc) - context.doc = doc - context.entry = doc.toEntry() - @triggerRoute 'entry' - else - next() - return - - type: (context, next) -> - doc = app.docs.findBySlug(context.params.doc) - - if type = doc?.types.findBy 'slug', context.params.type - context.doc = doc - context.type = type - @triggerRoute 'type' - else - next() - return - - entry: (context, next) -> - doc = app.docs.findBySlug(context.params.doc) - - if entry = doc?.findEntryByPathAndHash(context.params.path, context.hash) - context.doc = doc - context.entry = entry - @triggerRoute 'entry' - else - next() - return - - root: -> - if app.isSingleDoc() - setTimeout (-> window.location = '/'), 0 - else - @triggerRoute 'root' - return - - offline: -> - @triggerRoute 'offline' - return - - about: (context) -> - context.page = 'about' - @triggerRoute 'page' - return - - news: (context) -> - context.page = 'news' - @triggerRoute 'page' - return - - help: (context) -> - context.page = 'help' - @triggerRoute 'page' - return - - notFound: (context) -> - @triggerRoute 'notFound' - return - - isRoot: -> - location.pathname is '/' - - isDocIndex: -> - @context and @context.doc and @context.entry is @context.doc.toEntry() - - setInitialPath: -> - # Remove superfluous forward slashes at the beginning of the path - if (path = location.pathname.replace /^\/{2,}/g, '/') isnt location.pathname - page.replace path + location.search + location.hash, null, true - - if @isRoot() - if path = @getInitialPathFromHash() - page.replace path + location.search, null, true - else if path = @getInitialPathFromCookie() - page.replace path + location.search + location.hash, null, true - return - - getInitialPathFromHash: -> - try - (new RegExp "#/(.+)").exec(decodeURIComponent location.hash)?[1] - catch - - getInitialPathFromCookie: -> - if path = Cookies.get('initial_path') - Cookies.expire('initial_path') - path - - replaceHash: (hash) -> - page.replace location.pathname + location.search + (hash or ''), null, true - return diff --git a/assets/javascripts/app/router.js b/assets/javascripts/app/router.js new file mode 100644 index 0000000000..3c243341fe --- /dev/null +++ b/assets/javascripts/app/router.js @@ -0,0 +1,209 @@ +app.Router = class Router extends Events { + static routes = [ + ["*", "before"], + ["/", "root"], + ["/settings", "settings"], + ["/offline", "offline"], + ["/about", "about"], + ["/news", "news"], + ["/help", "help"], + ["/:doc-:type/", "type"], + ["/:doc/", "doc"], + ["/:doc/:path(*)", "entry"], + ["*", "notFound"], + ]; + + constructor() { + super(); + for (var [path, method] of this.constructor.routes) { + page(path, this[method].bind(this)); + } + this.setInitialPath(); + } + + start() { + page.start(); + } + + show(path) { + page.show(path); + } + + triggerRoute(name) { + this.trigger(name, this.context); + this.trigger("after", name, this.context); + } + + before(context, next) { + const previousContext = this.context; + this.context = context; + this.trigger("before", context); + + const res = next(); + if (res) { + this.context = previousContext; + return res; + } else { + return; + } + } + + doc(context, next) { + let doc; + if ( + (doc = + app.docs.findBySlug(context.params.doc) || + app.disabledDocs.findBySlug(context.params.doc)) + ) { + context.doc = doc; + context.entry = doc.toEntry(); + this.triggerRoute("entry"); + return; + } else { + return next(); + } + } + + type(context, next) { + const doc = app.docs.findBySlug(context.params.doc); + const type = doc?.types?.findBy("slug", context.params.type); + + if (type) { + context.doc = doc; + context.type = type; + this.triggerRoute("type"); + return; + } else { + return next(); + } + } + + entry(context, next) { + const doc = app.docs.findBySlug(context.params.doc); + if (!doc) { + return next(); + } + let { path } = context.params; + const { hash } = context; + + let entry = doc.findEntryByPathAndHash(path, hash); + if (entry) { + context.doc = doc; + context.entry = entry; + this.triggerRoute("entry"); + return; + } else if (path.slice(-6) === "/index") { + path = path.substr(0, path.length - 6); + entry = doc.findEntryByPathAndHash(path, hash); + if (entry) { + return entry.fullPath(); + } + } else { + path = `${path}/index`; + entry = doc.findEntryByPathAndHash(path, hash); + if (entry) { + return entry.fullPath(); + } + } + + return next(); + } + + root() { + if (app.isSingleDoc()) { + return "/"; + } + this.triggerRoute("root"); + } + + settings(context) { + if (app.isSingleDoc()) { + return `/#/${context.path}`; + } + this.triggerRoute("settings"); + } + + offline(context) { + if (app.isSingleDoc()) { + return `/#/${context.path}`; + } + this.triggerRoute("offline"); + } + + about(context) { + if (app.isSingleDoc()) { + return `/#/${context.path}`; + } + context.page = "about"; + this.triggerRoute("page"); + } + + news(context) { + if (app.isSingleDoc()) { + return `/#/${context.path}`; + } + context.page = "news"; + this.triggerRoute("page"); + } + + help(context) { + if (app.isSingleDoc()) { + return `/#/${context.path}`; + } + context.page = "help"; + this.triggerRoute("page"); + } + + notFound(context) { + this.triggerRoute("notFound"); + } + + isIndex() { + return ( + this.context?.path === "/" || + (app.isSingleDoc() && this.context?.entry?.isIndex()) + ); + } + + isSettings() { + return this.context?.path === "/settings"; + } + + setInitialPath() { + // Remove superfluous forward slashes at the beginning of the path + let path = location.pathname.replace(/^\/{2,}/g, "/"); + if (path !== location.pathname) { + page.replace(path + location.search + location.hash, null, true); + } + + if (location.pathname === "/") { + if ((path = this.getInitialPathFromHash())) { + page.replace(path + location.search, null, true); + } else if ((path = this.getInitialPathFromCookie())) { + page.replace(path + location.search + location.hash, null, true); + } + } + } + + getInitialPathFromHash() { + try { + return new RegExp("#/(.+)").exec(decodeURIComponent(location.hash))?.[1]; + } catch (error) {} + } + + getInitialPathFromCookie() { + const path = Cookies.get("initial_path"); + if (path) { + Cookies.expire("initial_path"); + return path; + } + } + + replaceHash(hash) { + page.replace( + location.pathname + location.search + (hash || ""), + null, + true + ); + } +}; diff --git a/assets/javascripts/app/searcher.coffee b/assets/javascripts/app/searcher.coffee deleted file mode 100644 index 96f1214a5c..0000000000 --- a/assets/javascripts/app/searcher.coffee +++ /dev/null @@ -1,287 +0,0 @@ -# -# Match functions -# - -SEPARATOR = '.' - -query = -queryLength = -value = -valueLength = -matcher = # current match function -fuzzyRegexp = # query fuzzy regexp -index = # position of the query in the string being matched -lastIndex = # last position of the query in the string being matched -match = # regexp match data -matchIndex = -matchLength = -score = # score for the current match -separators = # counter -i = null # cursor - -`function exactMatch() {` -index = value.indexOf(query) -return unless index >= 0 - -lastIndex = value.lastIndexOf(query) - -if index isnt lastIndex - return Math.max(scoreExactMatch(), ((index = lastIndex) and scoreExactMatch()) or 0) -else - return scoreExactMatch() -`}` - -`function scoreExactMatch() {` -# Remove one point for each unmatched character. -score = 100 - (valueLength - queryLength) - -if index > 0 - # If the character preceding the query is a dot, assign the same score - # as if the query was found at the beginning of the string, minus one. - if value.charAt(index - 1) is SEPARATOR - score += index - 1 - # Don't match a single-character query unless it's found at the beginning - # of the string or is preceded by a dot. - else if queryLength is 1 - return - # (1) Remove one point for each unmatched character up to the nearest - # preceding dot or the beginning of the string. - # (2) Remove one point for each unmatched character following the query. - else - i = index - 2 - i-- while i >= 0 and value.charAt(i) isnt SEPARATOR - score -= (index - i) + # (1) - (valueLength - queryLength - index) # (2) - - # Remove one point for each dot preceding the query, except for the one - # immediately before the query. - separators = 0 - i = index - 2 - while i >= 0 - separators++ if value.charAt(i) is SEPARATOR - i-- - score -= separators - -# Remove five points for each dot following the query. -separators = 0 -i = valueLength - queryLength - index - 1 -while i >= 0 - separators++ if value.charAt(index + queryLength + i) is SEPARATOR - i-- -score -= separators * 5 - -return Math.max 1, score -`}` - -`function fuzzyMatch() {` -return if valueLength <= queryLength or value.indexOf(query) >= 0 -return unless match = fuzzyRegexp.exec(value) -matchIndex = match.index -matchLength = match[0].length -score = scoreFuzzyMatch() -if match = fuzzyRegexp.exec(value.slice(i = value.lastIndexOf(SEPARATOR) + 1)) - matchIndex = i + match.index - matchLength = match[0].length - return Math.max(score, scoreFuzzyMatch()) -else - return score -`}` - -`function scoreFuzzyMatch() {` -# When the match is at the beginning of the string or preceded by a dot. -if matchIndex is 0 or value.charAt(matchIndex - 1) is SEPARATOR - return Math.max 66, 100 - matchLength -# When the match is at the end of the string. -else if matchIndex + matchLength is valueLength - return Math.max 33, 67 - matchLength -# When the match is in the middle of the string. -else - return Math.max 1, 34 - matchLength -`}` - -# -# Searchers -# - -class app.Searcher - $.extend @prototype, Events - - CHUNK_SIZE = 20000 - - DEFAULTS = - max_results: app.config.max_results - fuzzy_min_length: 3 - - SEPARATORS_REGEXP = /#|::|:-|->|\$(?=\w)|\-(?=\w)|\:(?=\w)|\ [\/\-&]\ |:\ |\ /g - INFO_PARANTHESES_REGEXP = /\ \(\w+?\)$/ - EMPTY_PARANTHESES_REGEXP = /\(\)/ - EVENT_REGEXP = /\ event$/ - DOT_REGEXP = /\.+/g - WHITESPACE_REGEXP = /\s/g - - EMPTY_STRING = '' - ELLIPSIS = '...' - STRING = 'string' - - @normalizeString: (string) -> - string - .toLowerCase() - .replace ELLIPSIS, EMPTY_STRING - .replace EVENT_REGEXP, EMPTY_STRING - .replace INFO_PARANTHESES_REGEXP, EMPTY_STRING - .replace SEPARATORS_REGEXP, SEPARATOR - .replace DOT_REGEXP, SEPARATOR - .replace EMPTY_PARANTHESES_REGEXP, EMPTY_STRING - .replace WHITESPACE_REGEXP, EMPTY_STRING - - constructor: (options = {}) -> - @options = $.extend {}, DEFAULTS, options - - find: (data, attr, q) -> - @kill() - - @data = data - @attr = attr - @query = q - @setup() - - if @isValid() then @match() else @end() - return - - setup: -> - query = @query = @constructor.normalizeString(@query) - queryLength = query.length - @dataLength = @data.length - @matchers = [exactMatch] - @totalResults = 0 - @setupFuzzy() - return - - setupFuzzy: -> - if queryLength >= @options.fuzzy_min_length - fuzzyRegexp = @queryToFuzzyRegexp(query) - @matchers.push(fuzzyMatch) - else - fuzzyRegexp = null - return - - isValid: -> - queryLength > 0 and query isnt SEPARATOR - - end: -> - @triggerResults [] unless @totalResults - @trigger 'end' - @free() - return - - kill: -> - if @timeout - clearTimeout @timeout - @free() - return - - free: -> - @data = @attr = @dataLength = @matchers = @matcher = @query = - @totalResults = @scoreMap = @cursor = @timeout = null - return - - match: => - if not @foundEnough() and @matcher = @matchers.shift() - @setupMatcher() - @matchChunks() - else - @end() - return - - setupMatcher: -> - @cursor = 0 - @scoreMap = new Array(101) - return - - matchChunks: => - @matchChunk() - - if @cursor is @dataLength or @scoredEnough() - @delay @match - @sendResults() - else - @delay @matchChunks - return - - matchChunk: -> - matcher = @matcher - for [0...@chunkSize()] - value = @data[@cursor][@attr] - if value.split # string - valueLength = value.length - @addResult(@data[@cursor], score) if score = matcher() - else # array - score = 0 - for value in @data[@cursor][@attr] - valueLength = value.length - score = Math.max(score, matcher() || 0) - @addResult(@data[@cursor], score) if score > 0 - @cursor++ - return - - chunkSize: -> - if @cursor + CHUNK_SIZE > @dataLength - @dataLength % CHUNK_SIZE - else - CHUNK_SIZE - - scoredEnough: -> - @scoreMap[100]?.length >= @options.max_results - - foundEnough: -> - @totalResults >= @options.max_results - - addResult: (object, score) -> - (@scoreMap[Math.round(score)] or= []).push(object) - @totalResults++ - return - - getResults: -> - results = [] - for objects in @scoreMap by -1 when objects - results.push.apply results, objects - results[0...@options.max_results] - - sendResults: -> - results = @getResults() - @triggerResults results if results.length - return - - triggerResults: (results) -> - @trigger 'results', results - return - - delay: (fn) -> - @timeout = setTimeout(fn, 1) - - queryToFuzzyRegexp: (string) -> - chars = string.split '' - chars[i] = $.escapeRegexp(char) for char, i in chars - new RegExp chars.join('.*?') # abc -> /a.*?b.*?c.*?/ - -class app.SynchronousSearcher extends app.Searcher - match: => - if @matcher - @allResults or= [] - @allResults.push.apply @allResults, @getResults() - super - - free: -> - @allResults = null - super - - end: -> - @sendResults true - super - - sendResults: (end) -> - if end and @allResults?.length - @triggerResults @allResults - - delay: (fn) -> - fn() diff --git a/assets/javascripts/app/searcher.js b/assets/javascripts/app/searcher.js new file mode 100644 index 0000000000..7cd6e82614 --- /dev/null +++ b/assets/javascripts/app/searcher.js @@ -0,0 +1,396 @@ +// +// Match functions +// + +let fuzzyRegexp, + i, + index, + lastIndex, + match, + matcher, + matchIndex, + matchLength, + queryLength, + score, + separators, + value, + valueLength; +const SEPARATOR = "."; + +let query = + (queryLength = + value = + valueLength = + matcher = // current match function + fuzzyRegexp = // query fuzzy regexp + index = // position of the query in the string being matched + lastIndex = // last position of the query in the string being matched + match = // regexp match data + matchIndex = + matchLength = + score = // score for the current match + separators = // counter + i = + null); // cursor + +function exactMatch() { + index = value.indexOf(query); + if (!(index >= 0)) { + return; + } + + lastIndex = value.lastIndexOf(query); + + if (index !== lastIndex) { + return Math.max( + scoreExactMatch(), + ((index = lastIndex) && scoreExactMatch()) || 0, + ); + } else { + return scoreExactMatch(); + } +} + +function scoreExactMatch() { + // Remove one point for each unmatched character. + score = 100 - (valueLength - queryLength); + + if (index > 0) { + // If the character preceding the query is a dot, assign the same score + // as if the query was found at the beginning of the string, minus one. + if (value.charAt(index - 1) === SEPARATOR) { + score += index - 1; + // Don't match a single-character query unless it's found at the beginning + // of the string or is preceded by a dot. + } else if (queryLength === 1) { + return; + // (1) Remove one point for each unmatched character up to the nearest + // preceding dot or the beginning of the string. + // (2) Remove one point for each unmatched character following the query. + } else { + i = index - 2; + while (i >= 0 && value.charAt(i) !== SEPARATOR) { + i--; + } + score -= + index - + i + // (1) + (valueLength - queryLength - index); // (2) + } + + // Remove one point for each dot preceding the query, except for the one + // immediately before the query. + separators = 0; + i = index - 2; + while (i >= 0) { + if (value.charAt(i) === SEPARATOR) { + separators++; + } + i--; + } + score -= separators; + } + + // Remove five points for each dot following the query. + separators = 0; + i = valueLength - queryLength - index - 1; + while (i >= 0) { + if (value.charAt(index + queryLength + i) === SEPARATOR) { + separators++; + } + i--; + } + score -= separators * 5; + + return Math.max(1, score); +} + +function fuzzyMatch() { + if (valueLength <= queryLength || value.includes(query)) { + return; + } + if (!(match = fuzzyRegexp.exec(value))) { + return; + } + matchIndex = match.index; + matchLength = match[0].length; + score = scoreFuzzyMatch(); + if ( + (match = fuzzyRegexp.exec( + value.slice((i = value.lastIndexOf(SEPARATOR) + 1)), + )) + ) { + matchIndex = i + match.index; + matchLength = match[0].length; + return Math.max(score, scoreFuzzyMatch()); + } else { + return score; + } +} + +function scoreFuzzyMatch() { + // When the match is at the beginning of the string or preceded by a dot. + if (matchIndex === 0 || value.charAt(matchIndex - 1) === SEPARATOR) { + return Math.max(66, 100 - matchLength); + // When the match is at the end of the string. + } else if (matchIndex + matchLength === valueLength) { + return Math.max(33, 67 - matchLength); + // When the match is in the middle of the string. + } else { + return Math.max(1, 34 - matchLength); + } +} + +// +// Searchers +// + +app.Searcher = class Searcher extends Events { + static CHUNK_SIZE = 20000; + + static DEFAULTS = { + max_results: app.config.max_results, + fuzzy_min_length: 3, + }; + + static SEPARATORS_REGEXP = + /#|::|:-|->|\$(?=\w)|\-(?=\w)|\:(?=\w)|\ [\/\-&]\ |:\ |\ /g; + static EOS_SEPARATORS_REGEXP = /(\w)[\-:]$/; + static INFO_PARANTHESES_REGEXP = /\ \(\w+?\)$/; + static EMPTY_PARANTHESES_REGEXP = /\(\)/; + static EVENT_REGEXP = /\ event$/; + static DOT_REGEXP = /\.+/g; + static WHITESPACE_REGEXP = /\s/g; + + static EMPTY_STRING = ""; + static ELLIPSIS = "..."; + static STRING = "string"; + + static normalizeString(string) { + return string + .toLowerCase() + .replace(Searcher.ELLIPSIS, Searcher.EMPTY_STRING) + .replace(Searcher.EVENT_REGEXP, Searcher.EMPTY_STRING) + .replace(Searcher.INFO_PARANTHESES_REGEXP, Searcher.EMPTY_STRING) + .replace(Searcher.SEPARATORS_REGEXP, SEPARATOR) + .replace(Searcher.DOT_REGEXP, SEPARATOR) + .replace(Searcher.EMPTY_PARANTHESES_REGEXP, Searcher.EMPTY_STRING) + .replace(Searcher.WHITESPACE_REGEXP, Searcher.EMPTY_STRING); + } + + static normalizeQuery(string) { + string = this.normalizeString(string); + return string.replace(Searcher.EOS_SEPARATORS_REGEXP, "$1."); + } + + constructor(options) { + super(); + this.options = { ...Searcher.DEFAULTS, ...(options || {}) }; + } + + find(data, attr, q) { + this.kill(); + + this.data = data; + this.attr = attr; + this.query = q; + this.setup(); + + if (this.isValid()) { + this.match(); + } else { + this.end(); + } + } + + setup() { + query = this.query = this.constructor.normalizeQuery(this.query); + queryLength = query.length; + this.dataLength = this.data.length; + this.matchers = [exactMatch]; + this.totalResults = 0; + this.setupFuzzy(); + } + + setupFuzzy() { + if (queryLength >= this.options.fuzzy_min_length) { + fuzzyRegexp = this.queryToFuzzyRegexp(query); + this.matchers.push(fuzzyMatch); + } else { + fuzzyRegexp = null; + } + } + + isValid() { + return queryLength > 0 && query !== SEPARATOR; + } + + end() { + if (!this.totalResults) { + this.triggerResults([]); + } + this.trigger("end"); + this.free(); + } + + kill() { + if (this.timeout) { + clearTimeout(this.timeout); + this.free(); + } + } + + free() { + this.data = null; + this.attr = null; + this.dataLength = null; + this.matchers = null; + this.matcher = null; + this.query = null; + this.totalResults = null; + this.scoreMap = null; + this.cursor = null; + this.timeout = null; + } + + match() { + if (!this.foundEnough() && (this.matcher = this.matchers.shift())) { + this.setupMatcher(); + this.matchChunks(); + } else { + this.end(); + } + } + + setupMatcher() { + this.cursor = 0; + this.scoreMap = new Array(101); + } + + matchChunks() { + this.matchChunk(); + + if (this.cursor === this.dataLength || this.scoredEnough()) { + this.delay(() => this.match()); + this.sendResults(); + } else { + this.delay(() => this.matchChunks()); + } + } + + matchChunk() { + ({ matcher } = this); + for (let j = 0, end = this.chunkSize(); j < end; j++) { + value = this.data[this.cursor][this.attr]; + if (value.split) { + // string + valueLength = value.length; + if ((score = matcher())) { + this.addResult(this.data[this.cursor], score); + } + } else { + // array + score = 0; + for (value of Array.from(this.data[this.cursor][this.attr])) { + valueLength = value.length; + score = Math.max(score, matcher() || 0); + } + if (score > 0) { + this.addResult(this.data[this.cursor], score); + } + } + this.cursor++; + } + } + + chunkSize() { + if (this.cursor + Searcher.CHUNK_SIZE > this.dataLength) { + return this.dataLength % Searcher.CHUNK_SIZE; + } else { + return Searcher.CHUNK_SIZE; + } + } + + scoredEnough() { + return this.scoreMap[100]?.length >= this.options.max_results; + } + + foundEnough() { + return this.totalResults >= this.options.max_results; + } + + addResult(object, score) { + let name; + ( + this.scoreMap[(name = Math.round(score))] || (this.scoreMap[name] = []) + ).push(object); + this.totalResults++; + } + + getResults() { + const results = []; + for (let j = this.scoreMap.length - 1; j >= 0; j--) { + var objects = this.scoreMap[j]; + if (objects) { + results.push(...objects); + } + } + return results.slice(0, this.options.max_results); + } + + sendResults() { + const results = this.getResults(); + if (results.length) { + this.triggerResults(results); + } + } + + triggerResults(results) { + this.trigger("results", results); + } + + delay(fn) { + return (this.timeout = setTimeout(fn, 1)); + } + + queryToFuzzyRegexp(string) { + const chars = string.split(""); + for (i = 0; i < chars.length; i++) { + var char = chars[i]; + chars[i] = $.escapeRegexp(char); + } + return new RegExp(chars.join(".*?")); // abc -> /a.*?b.*?c.*?/ + } +}; + +app.SynchronousSearcher = class SynchronousSearcher extends app.Searcher { + match() { + if (this.matcher) { + if (!this.allResults) { + this.allResults = []; + } + this.allResults.push(...this.getResults()); + } + return super.match(...arguments); + } + + free() { + this.allResults = null; + return super.free(...arguments); + } + + end() { + this.sendResults(true); + return super.end(...arguments); + } + + sendResults(end) { + if (end && this.allResults?.length) { + return this.triggerResults(this.allResults); + } + } + + delay(fn) { + return fn(); + } +}; diff --git a/assets/javascripts/app/serviceworker.js b/assets/javascripts/app/serviceworker.js new file mode 100644 index 0000000000..4c35a32c32 --- /dev/null +++ b/assets/javascripts/app/serviceworker.js @@ -0,0 +1,69 @@ +app.ServiceWorker = class ServiceWorker extends Events { + static isEnabled() { + return !!navigator.serviceWorker && app.config.service_worker_enabled; + } + + constructor() { + super(); + this.onStateChange = this.onStateChange.bind(this); + this.registration = null; + this.notifyUpdate = true; + + navigator.serviceWorker + .register(app.config.service_worker_path, { scope: "/" }) + .then( + (registration) => this.updateRegistration(registration), + (error) => console.error("Could not register service worker:", error), + ); + } + + update() { + if (!this.registration) { + return; + } + this.notifyUpdate = true; + return this.registration.update().catch(() => {}); + } + + updateInBackground() { + if (!this.registration) { + return; + } + this.notifyUpdate = false; + return this.registration.update().catch(() => {}); + } + + reload() { + return this.updateInBackground().then(() => app.reboot()); + } + + updateRegistration(registration) { + this.registration = registration; + $.on(this.registration, "updatefound", () => this.onUpdateFound()); + } + + onUpdateFound() { + if (this.installingRegistration) { + $.off(this.installingRegistration, "statechange", this.onStateChange); + } + this.installingRegistration = this.registration.installing; + $.on(this.installingRegistration, "statechange", this.onStateChange); + } + + onStateChange() { + if ( + this.installingRegistration && + this.installingRegistration.state === "installed" && + navigator.serviceWorker.controller + ) { + this.installingRegistration = null; + this.onUpdateReady(); + } + } + + onUpdateReady() { + if (this.notifyUpdate) { + this.trigger("updateready"); + } + } +}; diff --git a/assets/javascripts/app/settings.coffee b/assets/javascripts/app/settings.coffee deleted file mode 100644 index a2aad2870f..0000000000 --- a/assets/javascripts/app/settings.coffee +++ /dev/null @@ -1,88 +0,0 @@ -class app.Settings - DOCS_KEY = 'docs' - DARK_KEY = 'dark' - LAYOUT_KEY = 'layout' - SIZE_KEY = 'size' - TIPS_KEY = 'tips' - - @defaults: - count: 0 - hideDisabled: false - hideIntro: false - news: 0 - manualUpdate: false - schema: 1 - - constructor: (legacyStore) -> - @store = new CookieStore - @importLegacyValues(legacyStore) - - importLegacyValues: (legacyStore) -> - return unless settings = legacyStore.get('settings') - for key, value of settings - if key == 'autoUpdate' - key = 'manualUpdate' - value = !value - else if key == 'tips' - value = value.join('/') - @store.set(key, value) - legacyStore.del('settings') - return - - set: (key, value) -> - @store.set(key, value) - return - - get: (key) -> - @store.get(key) ? @constructor.defaults[key] - - hasDocs: -> - try !!@store.get(DOCS_KEY) - - getDocs: -> - @store.get(DOCS_KEY)?.split('/') or app.config.default_docs - - setDocs: (docs) -> - @store.set DOCS_KEY, docs.join('/') - return - - getTips: -> - @store.get(TIPS_KEY)?.split('/') or [] - - setTips: (tips) -> - @store.set TIPS_KEY, tips.join('/') - return - - setDark: (value) -> - @store.set DARK_KEY, !!value - return - - setLayout: (name, enable) -> - layout = (@store.get(LAYOUT_KEY) || '').split(' ') - $.arrayDelete(layout, '') - - if enable - layout.push(name) if layout.indexOf(name) is -1 - else - $.arrayDelete(layout, name) - - if layout.length > 0 - @store.set LAYOUT_KEY, layout.join(' ') - else - @store.del LAYOUT_KEY - return - - hasLayout: (name) -> - layout = (@store.get(LAYOUT_KEY) || '').split(' ') - layout.indexOf(name) isnt -1 - - setSize: (value) -> - @store.set SIZE_KEY, value - return - - dump: -> - @store.dump() - - reset: -> - @store.reset() - return diff --git a/assets/javascripts/app/settings.js b/assets/javascripts/app/settings.js new file mode 100644 index 0000000000..617830ecc9 --- /dev/null +++ b/assets/javascripts/app/settings.js @@ -0,0 +1,215 @@ +app.Settings = class Settings { + static PREFERENCE_KEYS = [ + "hideDisabled", + "hideIntro", + "manualUpdate", + "fastScroll", + "arrowScroll", + "analyticsConsent", + "docs", + "dark", // legacy + "theme", + "layout", + "size", + "tips", + "noAutofocus", + "autoInstall", + "spaceScroll", + "spaceTimeout", + "noDocSpecificIcon", + ]; + + static INTERNAL_KEYS = ["count", "schema", "version", "news"]; + + static LAYOUTS = [ + "_max-width", + "_sidebar-hidden", + "_native-scrollbars", + "_text-justify-hyphenate", + ]; + + static defaults = { + count: 0, + hideDisabled: false, + hideIntro: false, + news: 0, + manualUpdate: false, + schema: 1, + analyticsConsent: false, + theme: "auto", + spaceScroll: 1, + spaceTimeout: 0.5, + noDocSpecificIcon: false, + }; + + constructor() { + this.store = new CookiesStore(); + this.cache = {}; + this.autoSupported = + window.matchMedia("(prefers-color-scheme)").media !== "not all"; + if (this.autoSupported) { + this.darkModeQuery = window.matchMedia("(prefers-color-scheme: dark)"); + this.darkModeQuery.addListener(() => this.setTheme(this.get("theme"))); + } + } + + get(key) { + let left; + if (this.cache.hasOwnProperty(key)) { + return this.cache[key]; + } + this.cache[key] = + (left = this.store.get(key)) != null + ? left + : this.constructor.defaults[key]; + if (key === "theme" && this.cache[key] === "auto" && !this.darkModeQuery) { + return (this.cache[key] = "default"); + } else { + return this.cache[key]; + } + } + + set(key, value) { + this.store.set(key, value); + delete this.cache[key]; + if (key === "theme") { + this.setTheme(value); + } + } + + del(key) { + this.store.del(key); + delete this.cache[key]; + } + + hasDocs() { + try { + return !!this.store.get("docs"); + } catch (error) {} + } + + getDocs() { + return this.store.get("docs")?.split("/") || app.config.default_docs; + } + + setDocs(docs) { + this.set("docs", docs.join("/")); + } + + getTips() { + return this.store.get("tips")?.split("/") || []; + } + + setTips(tips) { + this.set("tips", tips.join("/")); + } + + setLayout(name, enable) { + this.toggleLayout(name, enable); + + const layout = (this.store.get("layout") || "").split(" "); + $.arrayDelete(layout, ""); + + if (enable) { + if (!layout.includes(name)) { + layout.push(name); + } + } else { + $.arrayDelete(layout, name); + } + + if (layout.length > 0) { + this.set("layout", layout.join(" ")); + } else { + this.del("layout"); + } + } + + hasLayout(name) { + const layout = (this.store.get("layout") || "").split(" "); + return layout.includes(name); + } + + setSize(value) { + this.set("size", value); + } + + dump() { + return this.store.dump(); + } + + export() { + const data = this.dump(); + for (var key of Settings.INTERNAL_KEYS) { + delete data[key]; + } + return data; + } + + import(data) { + let key, value; + const object = this.export(); + for (key in object) { + value = object[key]; + if (!data.hasOwnProperty(key)) { + this.del(key); + } + } + for (key in data) { + value = data[key]; + if (Settings.PREFERENCE_KEYS.includes(key)) { + this.set(key, value); + } + } + } + + reset() { + this.store.reset(); + this.cache = {}; + } + + initLayout() { + if (this.get("dark") === 1) { + this.set("theme", "dark"); + this.del("dark"); + } + this.setTheme(this.get("theme")); + for (var layout of app.Settings.LAYOUTS) { + this.toggleLayout(layout, this.hasLayout(layout)); + } + this.initSidebarWidth(); + } + + setTheme(theme) { + if (theme === "auto") { + theme = this.darkModeQuery.matches ? "dark" : "default"; + } + const { classList } = document.documentElement; + classList.remove("_theme-default", "_theme-dark"); + classList.add("_theme-" + theme); + this.updateColorMeta(); + } + + updateColorMeta() { + const color = getComputedStyle(document.documentElement) + .getPropertyValue("--headerBackground") + .trim(); + $("meta[name=theme-color]").setAttribute("content", color); + } + + toggleLayout(layout, enable) { + const { classList } = document.body; + // sidebar is always shown for settings; its state is updated in app.views.Settings + if (layout !== "_sidebar-hidden" || !app.router?.isSettings) { + classList.toggle(layout, enable); + } + classList.toggle("_overlay-scrollbars", $.overlayScrollbarsEnabled()); + } + + initSidebarWidth() { + const size = this.get("size"); + if (size) { + document.documentElement.style.setProperty("--sidebarWidth", size + "px"); + } + } +}; diff --git a/assets/javascripts/app/shortcuts.coffee b/assets/javascripts/app/shortcuts.coffee deleted file mode 100644 index 74f7b0f5db..0000000000 --- a/assets/javascripts/app/shortcuts.coffee +++ /dev/null @@ -1,161 +0,0 @@ -class app.Shortcuts - $.extend @prototype, Events - - constructor: -> - @isWindows = $.isWindows() - @start() - - start: -> - $.on document, 'keydown', @onKeydown - $.on document, 'keypress', @onKeypress - return - - stop: -> - $.off document, 'keydown', @onKeydown - $.off document, 'keypress', @onKeypress - return - - showTip: -> - app.showTip('KeyNav') - @showTip = null - - onKeydown: (event) => - return if @buggyEvent(event) - result = if event.ctrlKey or event.metaKey - @handleKeydownSuperEvent event unless event.altKey or event.shiftKey - else if event.shiftKey - @handleKeydownShiftEvent event unless event.altKey - else if event.altKey - @handleKeydownAltEvent event - else - @handleKeydownEvent event - - event.preventDefault() if result is false - return - - onKeypress: (event) => - return if @buggyEvent(event) - unless event.ctrlKey or event.metaKey - result = @handleKeypressEvent event - event.preventDefault() if result is false - return - - handleKeydownEvent: (event) -> - if not event.target.form and (48 <= event.which <= 57 or 65 <= event.which <= 90) - @trigger 'typing' - return - - switch event.which - when 8 - @trigger 'typing' unless event.target.form - when 13 - @trigger 'enter' - when 27 - @trigger 'escape' - when 32 - if not @lastKeypress or @lastKeypress < Date.now() - 500 - @trigger 'pageDown' - false - when 33 - @trigger 'pageUp' - when 34 - @trigger 'pageDown' - when 35 - @trigger 'end' - when 36 - @trigger 'home' - when 37 - @trigger 'left' unless event.target.value - when 38 - @trigger 'up' - @showTip?() - false - when 39 - @trigger 'right' unless event.target.value - when 40 - @trigger 'down' - @showTip?() - false - - handleKeydownSuperEvent: (event) -> - switch event.which - when 13 - @trigger 'superEnter' - when 37 - unless @isWindows - @trigger 'superLeft' - false - when 38 - @trigger 'home' - false - when 39 - unless @isWindows - @trigger 'superRight' - false - when 40 - @trigger 'end' - false - - handleKeydownShiftEvent: (event) -> - if not event.target.form and 65 <= event.which <= 90 - @trigger 'typing' - return - - switch event.which - when 32 - @trigger 'pageUp' - false - when 38 - unless getSelection()?.toString() - @trigger 'altUp' - false - when 40 - unless getSelection()?.toString() - @trigger 'altDown' - false - - handleKeydownAltEvent: (event) -> - switch event.which - when 9 - @trigger 'altRight', event - when 37 - if @isWindows - @trigger 'superLeft' - false - when 38 - @trigger 'altUp' - false - when 39 - if @isWindows - @trigger 'superRight' - false - when 40 - @trigger 'altDown' - false - when 70 - @trigger 'altF', event - when 71 - @trigger 'altG' - false - when 82 - @trigger 'altR' - false - when 83 - @trigger 'altS' - false - - handleKeypressEvent: (event) -> - if event.which is 63 and not event.target.value - @trigger 'help' - false - else - @lastKeypress = Date.now() - - buggyEvent: (event) -> - try - event.target - event.ctrlKey - event.which - return false - catch - return true diff --git a/assets/javascripts/app/shortcuts.js b/assets/javascripts/app/shortcuts.js new file mode 100644 index 0000000000..05e09cf7bd --- /dev/null +++ b/assets/javascripts/app/shortcuts.js @@ -0,0 +1,295 @@ +app.Shortcuts = class Shortcuts extends Events { + constructor() { + super(); + this.onKeydown = this.onKeydown.bind(this); + this.onKeypress = this.onKeypress.bind(this); + this.isMac = $.isMac(); + this.start(); + } + + start() { + $.on(document, "keydown", this.onKeydown); + $.on(document, "keypress", this.onKeypress); + } + + stop() { + $.off(document, "keydown", this.onKeydown); + $.off(document, "keypress", this.onKeypress); + } + + swapArrowKeysBehavior() { + return app.settings.get("arrowScroll"); + } + + spaceScroll() { + return app.settings.get("spaceScroll"); + } + + showTip() { + app.showTip("KeyNav"); + return (this.showTip = null); + } + + spaceTimeout() { + return app.settings.get("spaceTimeout"); + } + + onKeydown(event) { + if (this.buggyEvent(event)) { + return; + } + const result = (() => { + if (event.ctrlKey || event.metaKey) { + if (!event.altKey && !event.shiftKey) { + return this.handleKeydownSuperEvent(event); + } + } else if (event.shiftKey) { + if (!event.altKey) { + return this.handleKeydownShiftEvent(event); + } + } else if (event.altKey) { + return this.handleKeydownAltEvent(event); + } else { + return this.handleKeydownEvent(event); + } + })(); + + if (result === false) { + event.preventDefault(); + } + } + + onKeypress(event) { + if ( + this.buggyEvent(event) || + (event.charCode === 63 && document.activeElement.tagName === "INPUT") + ) { + return; + } + if (!event.ctrlKey && !event.metaKey) { + const result = this.handleKeypressEvent(event); + if (result === false) { + event.preventDefault(); + } + } + } + + handleKeydownEvent(event, _force) { + if ( + !_force && + [37, 38, 39, 40].includes(event.which) && + this.swapArrowKeysBehavior() + ) { + return this.handleKeydownAltEvent(event, true); + } + + if ( + !event.target.form && + ((48 <= event.which && event.which <= 57) || + (65 <= event.which && event.which <= 90)) + ) { + this.trigger("typing"); + return; + } + + switch (event.which) { + case 8: + if (!event.target.form) { + return this.trigger("typing"); + } + break; + case 13: + return this.trigger("enter"); + case 27: + this.trigger("escape"); + return false; + case 32: + if ( + event.target.type === "search" && + this.spaceScroll() && + (!this.lastKeypress || + this.lastKeypress < Date.now() - this.spaceTimeout() * 1000) + ) { + this.trigger("pageDown"); + return false; + } + break; + case 33: + return this.trigger("pageUp"); + case 34: + return this.trigger("pageDown"); + case 35: + if (!event.target.form) { + return this.trigger("pageBottom"); + } + break; + case 36: + if (!event.target.form) { + return this.trigger("pageTop"); + } + break; + case 37: + if (!event.target.value) { + return this.trigger("left"); + } + break; + case 38: + this.trigger("up"); + if (typeof this.showTip === "function") { + this.showTip(); + } + return false; + case 39: + if (!event.target.value) { + return this.trigger("right"); + } + break; + case 40: + this.trigger("down"); + if (typeof this.showTip === "function") { + this.showTip(); + } + return false; + case 191: + if (!event.target.form) { + this.trigger("typing"); + return false; + } + break; + } + } + + handleKeydownSuperEvent(event) { + switch (event.which) { + case 13: + return this.trigger("superEnter"); + case 37: + if (this.isMac) { + this.trigger("superLeft"); + return false; + } + break; + case 38: + this.trigger("pageTop"); + return false; + case 39: + if (this.isMac) { + this.trigger("superRight"); + return false; + } + break; + case 40: + this.trigger("pageBottom"); + return false; + case 188: + this.trigger("preferences"); + return false; + } + } + + handleKeydownShiftEvent(event, _force) { + if ( + !_force && + [37, 38, 39, 40].includes(event.which) && + this.swapArrowKeysBehavior() + ) { + return this.handleKeydownEvent(event, true); + } + + if (!event.target.form && 65 <= event.which && event.which <= 90) { + this.trigger("typing"); + return; + } + + switch (event.which) { + case 32: + this.trigger("pageUp"); + return false; + case 38: + if (!getSelection()?.toString()) { + this.trigger("altUp"); + return false; + } + break; + case 40: + if (!getSelection()?.toString()) { + this.trigger("altDown"); + return false; + } + break; + } + } + + handleKeydownAltEvent(event, _force) { + if ( + !_force && + [37, 38, 39, 40].includes(event.which) && + this.swapArrowKeysBehavior() + ) { + return this.handleKeydownEvent(event, true); + } + + switch (event.which) { + case 9: + return this.trigger("altRight", event); + case 37: + if (!this.isMac) { + this.trigger("superLeft"); + return false; + } + break; + case 38: + this.trigger("altUp"); + return false; + case 39: + if (!this.isMac) { + this.trigger("superRight"); + return false; + } + break; + case 40: + this.trigger("altDown"); + return false; + case 67: + this.trigger("altC"); + return false; + case 68: + this.trigger("altD"); + return false; + case 70: + return this.trigger("altF", event); + case 71: + this.trigger("altG"); + return false; + case 79: + this.trigger("altO"); + return false; + case 82: + this.trigger("altR"); + return false; + case 83: + this.trigger("altS"); + return false; + } + } + + handleKeypressEvent(event) { + if (event.which === 63 && !event.target.value) { + this.trigger("help"); + return false; + } else { + return (this.lastKeypress = Date.now()); + } + } + + buggyEvent(event) { + try { + event.target; + event.ctrlKey; + event.which; + return false; + } catch (error) { + return true; + } + } +}; diff --git a/assets/javascripts/app/update_checker.coffee b/assets/javascripts/app/update_checker.coffee deleted file mode 100644 index 444661f92b..0000000000 --- a/assets/javascripts/app/update_checker.coffee +++ /dev/null @@ -1,39 +0,0 @@ -class app.UpdateChecker - constructor: -> - @lastCheck = Date.now() - - $.on window, 'focus', @checkForUpdate - app.appCache.on 'updateready', @onUpdateReady if app.appCache - - @checkDocs() - - check: -> - if app.appCache - app.appCache.update() - else - ajax - url: $('script[src*="application"]').getAttribute('src') - dataType: 'application/javascript' - error: (_, xhr) => @onUpdateReady() if xhr.status is 404 - return - - onUpdateReady: -> - new app.views.Notif 'UpdateReady', autoHide: null - return - - checkDocs: -> - unless app.settings.get('manualUpdate') - app.docs.updateInBackground() - else - app.docs.checkForUpdates (i) => @onDocsUpdateReady() if i > 0 - return - - onDocsUpdateReady: -> - new app.views.Notif 'UpdateDocs', autoHide: null - return - - onFocus: => - if Date.now() - @lastCheck > 21600e3 - @lastCheck = Date.now() - @check() - return diff --git a/assets/javascripts/app/update_checker.js b/assets/javascripts/app/update_checker.js new file mode 100644 index 0000000000..82d3cc92c6 --- /dev/null +++ b/assets/javascripts/app/update_checker.js @@ -0,0 +1,55 @@ +app.UpdateChecker = class UpdateChecker { + constructor() { + this.lastCheck = Date.now(); + + $.on(window, "focus", () => this.onFocus()); + if (app.serviceWorker) { + app.serviceWorker.on("updateready", () => this.onUpdateReady()); + } + + setTimeout(() => this.checkDocs(), 0); + } + + check() { + if (app.serviceWorker) { + app.serviceWorker.update(); + } else { + ajax({ + url: $('script[src*="application"]').getAttribute("src"), + dataType: "application/javascript", + error: (_, xhr) => { + if (xhr.status === 404) { + return this.onUpdateReady(); + } + }, + }); + } + } + + onUpdateReady() { + new app.views.Notif("UpdateReady", { autoHide: null }); + } + + checkDocs() { + if (!app.settings.get("manualUpdate")) { + app.docs.updateInBackground(); + } else { + app.docs.checkForUpdates((i) => { + if (i > 0) { + return this.onDocsUpdateReady(); + } + }); + } + } + + onDocsUpdateReady() { + new app.views.Notif("UpdateDocs", { autoHide: null }); + } + + onFocus() { + if (Date.now() - this.lastCheck > 21600e3) { + this.lastCheck = Date.now(); + this.check(); + } + } +}; diff --git a/assets/javascripts/application.js b/assets/javascripts/application.js new file mode 100644 index 0000000000..0bfa45687b --- /dev/null +++ b/assets/javascripts/application.js @@ -0,0 +1,33 @@ +//= require_tree ./vendor + +//= require lib/license +//= require_tree ./lib + +//= require app/app +//= require app/config +//= require_tree ./app + +//= require collections/collection +//= require_tree ./collections + +//= require models/model +//= require_tree ./models + +//= require views/view +//= require_tree ./views + +//= require_tree ./templates + +//= require tracking + +var init = function () { + document.removeEventListener("DOMContentLoaded", init, false); + + if (document.body) { + return app.init(); + } else { + return setTimeout(init, 42); + } +}; + +document.addEventListener("DOMContentLoaded", init, false); diff --git a/assets/javascripts/application.js.coffee b/assets/javascripts/application.js.coffee deleted file mode 100644 index 6bf87f1ce8..0000000000 --- a/assets/javascripts/application.js.coffee +++ /dev/null @@ -1,31 +0,0 @@ -#= require_tree ./vendor - -#= require lib/license -#= require_tree ./lib - -#= require app/app -#= require app/config -#= require_tree ./app - -#= require collections/collection -#= require_tree ./collections - -#= require models/model -#= require_tree ./models - -#= require views/view -#= require_tree ./views - -#= require_tree ./templates - -#= require tracking - -init = -> - document.removeEventListener 'DOMContentLoaded', init, false - - if document.body - app.init() - else - setTimeout(init, 42) - -document.addEventListener 'DOMContentLoaded', init, false diff --git a/assets/javascripts/collections/collection.coffee b/assets/javascripts/collections/collection.coffee deleted file mode 100644 index a5628d8abc..0000000000 --- a/assets/javascripts/collections/collection.coffee +++ /dev/null @@ -1,50 +0,0 @@ -class app.Collection - constructor: (objects = []) -> - @reset objects - - model: -> - app.models[@constructor.model] - - reset: (objects = []) -> - @models = [] - @add object for object in objects - return - - add: (object) -> - if object instanceof app.Model - @models.push object - else if object instanceof Array - @add obj for obj in object - else if object instanceof app.Collection - @models.push object.all()... - else - @models.push new (@model())(object) - return - - remove: (model) -> - @models.splice @models.indexOf(model), 1 - return - - size: -> - @models.length - - isEmpty: -> - @models.length is 0 - - each: (fn) -> - fn(model) for model in @models - return - - all: -> - @models - - contains: (model) -> - @models.indexOf(model) >= 0 - - findBy: (attr, value) -> - for model in @models - return model if model[attr] is value - return - - findAllBy: (attr, value) -> - model for model in @models when model[attr] is value diff --git a/assets/javascripts/collections/collection.js b/assets/javascripts/collections/collection.js new file mode 100644 index 0000000000..79bca0be69 --- /dev/null +++ b/assets/javascripts/collections/collection.js @@ -0,0 +1,80 @@ +app.Collection = class Collection { + constructor(objects) { + if (objects == null) { + objects = []; + } + this.reset(objects); + } + + model() { + return app.models[this.constructor.model]; + } + + reset(objects) { + if (objects == null) { + objects = []; + } + this.models = []; + for (var object of objects) { + this.add(object); + } + } + + add(object) { + if (object instanceof app.Model) { + this.models.push(object); + } else if (object instanceof Array) { + for (var obj of object) { + this.add(obj); + } + } else if (object instanceof app.Collection) { + this.models.push(...(object.all() || [])); + } else { + this.models.push(new (this.model())(object)); + } + } + + remove(model) { + this.models.splice(this.models.indexOf(model), 1); + } + + size() { + return this.models.length; + } + + isEmpty() { + return this.models.length === 0; + } + + each(fn) { + for (var model of this.models) { + fn(model); + } + } + + all() { + return this.models; + } + + contains(model) { + return this.models.includes(model); + } + + findBy(attr, value) { + return this.models.find((model) => model[attr] === value); + } + + findAllBy(attr, value) { + return this.models.filter((model) => model[attr] === value); + } + + countAllBy(attr, value) { + let i = 0; + for (var model of this.models) { + if (model[attr] === value) { + i += 1; + } + } + return i; + } +}; diff --git a/assets/javascripts/collections/docs.coffee b/assets/javascripts/collections/docs.coffee deleted file mode 100644 index c4b2e3687a..0000000000 --- a/assets/javascripts/collections/docs.coffee +++ /dev/null @@ -1,77 +0,0 @@ -class app.collections.Docs extends app.Collection - @model: 'Doc' - - findBySlug: (slug) -> - @findBy('slug', slug) or @findBy('slug_without_version', slug) - - sort: -> - @models.sort (a, b) -> - a = a.name.toLowerCase() - b = b.name.toLowerCase() - if a < b then -1 else if a > b then 1 else 0 - - # Load models concurrently. - # It's not pretty but I didn't want to import a promise library only for this. - CONCURRENCY = 3 - load: (onComplete, onError, options) -> - i = 0 - - next = => - if i < @models.length - @models[i].load(next, fail, options) - else if i is @models.length + CONCURRENCY - 1 - onComplete() - i++ - return - - fail = (args...) -> - if onError - onError(args...) - onError = null - next() - return - - next() for [0...CONCURRENCY] - return - - clearCache: -> - doc.clearCache() for doc in @models - return - - uninstall: (callback) -> - i = 0 - next = => - if i < @models.length - @models[i++].uninstall(next, next) - else - callback() - return - next() - return - - getInstallStatuses: (callback) -> - app.db.versions @models, (statuses) -> - if statuses - for key, value of statuses - statuses[key] = installed: !!value, mtime: value - callback(statuses) - return - return - - checkForUpdates: (callback) -> - @getInstallStatuses (statuses) => - i = 0 - if statuses - i += 1 for slug, status of statuses when @findBy('slug', slug).isOutdated(status) - callback(i) - return - return - - updateInBackground: -> - @getInstallStatuses (statuses) => - return unless statuses - for slug, status of statuses - doc = @findBy 'slug', slug - doc.install($.noop, $.noop) if doc.isOutdated(status) - return - return diff --git a/assets/javascripts/collections/docs.js b/assets/javascripts/collections/docs.js new file mode 100644 index 0000000000..d4aa9c8400 --- /dev/null +++ b/assets/javascripts/collections/docs.js @@ -0,0 +1,124 @@ +app.collections.Docs = class Docs extends app.Collection { + static model = "Doc"; + static NORMALIZE_VERSION_RGX = /\.(\d)$/; + static NORMALIZE_VERSION_SUB = ".0$1"; + + // Load models concurrently. + // It's not pretty but I didn't want to import a promise library only for this. + static CONCURRENCY = 3; + + findBySlug(slug) { + return ( + this.findBy("slug", slug) || this.findBy("slug_without_version", slug) + ); + } + sort() { + return this.models.sort((a, b) => { + if (a.name === b.name) { + if ( + !a.version || + a.version.replace( + Docs.NORMALIZE_VERSION_RGX, + Docs.NORMALIZE_VERSION_SUB, + ) > + b.version.replace( + Docs.NORMALIZE_VERSION_RGX, + Docs.NORMALIZE_VERSION_SUB, + ) + ) { + return -1; + } else { + return 1; + } + } else if (a.name.toLowerCase() > b.name.toLowerCase()) { + return 1; + } else { + return -1; + } + }); + } + load(onComplete, onError, options) { + let i = 0; + + var next = () => { + if (i < this.models.length) { + this.models[i].load(next, fail, options); + } else if (i === this.models.length + Docs.CONCURRENCY - 1) { + onComplete(); + } + i++; + }; + + var fail = function (...args) { + if (onError) { + onError(args); + onError = null; + } + next(); + }; + + for (let j = 0, end = Docs.CONCURRENCY; j < end; j++) { + next(); + } + } + + clearCache() { + for (var doc of this.models) { + doc.clearCache(); + } + } + + uninstall(callback) { + let i = 0; + var next = () => { + if (i < this.models.length) { + this.models[i++].uninstall(next, next); + } else { + callback(); + } + }; + next(); + } + + getInstallStatuses(callback) { + app.db.versions(this.models, (statuses) => { + if (statuses) { + for (var key in statuses) { + var value = statuses[key]; + statuses[key] = { installed: !!value, mtime: value }; + } + } + callback(statuses); + }); + } + + checkForUpdates(callback) { + this.getInstallStatuses((statuses) => { + let i = 0; + if (statuses) { + for (var slug in statuses) { + var status = statuses[slug]; + if (this.findBy("slug", slug).isOutdated(status)) { + i += 1; + } + } + } + callback(i); + }); + } + + updateInBackground() { + this.getInstallStatuses((statuses) => { + if (!statuses) { + return; + } + for (var slug in statuses) { + var status = statuses[slug]; + var doc = this.findBy("slug", slug); + if (doc.isOutdated(status)) { + doc.install($.noop, $.noop); + } + } + }); + } +}; diff --git a/assets/javascripts/collections/entries.coffee b/assets/javascripts/collections/entries.coffee deleted file mode 100644 index f978b68be4..0000000000 --- a/assets/javascripts/collections/entries.coffee +++ /dev/null @@ -1,2 +0,0 @@ -class app.collections.Entries extends app.Collection - @model: 'Entry' diff --git a/assets/javascripts/collections/entries.js b/assets/javascripts/collections/entries.js new file mode 100644 index 0000000000..2ea74707c1 --- /dev/null +++ b/assets/javascripts/collections/entries.js @@ -0,0 +1,3 @@ +app.collections.Entries = class Entries extends app.Collection { + static model = "Entry"; +}; diff --git a/assets/javascripts/collections/types.coffee b/assets/javascripts/collections/types.coffee deleted file mode 100644 index 8e76eeab34..0000000000 --- a/assets/javascripts/collections/types.coffee +++ /dev/null @@ -1,19 +0,0 @@ -class app.collections.Types extends app.Collection - @model: 'Type' - - groups: -> - result = [] - for type in @models - (result[@_groupFor(type)] ||= []).push(type) - result.filter (e) -> e.length > 0 - - GUIDES_RGX = /(^|\()(guides?|tutorials?|reference|book|getting\ started|manual|examples)($|[\):])/i - APPENDIX_RGX = /appendix/i - - _groupFor: (type) -> - if GUIDES_RGX.test(type.name) - 0 - else if APPENDIX_RGX.test(type.name) - 2 - else - 1 diff --git a/assets/javascripts/collections/types.js b/assets/javascripts/collections/types.js new file mode 100644 index 0000000000..0d23be0982 --- /dev/null +++ b/assets/javascripts/collections/types.js @@ -0,0 +1,26 @@ +app.collections.Types = class Types extends app.Collection { + static model = "Type"; + static GUIDES_RGX = + /(^|\()(guides?|tutorials?|reference|book|getting\ started|manual|examples)($|[\):])/i; + static APPENDIX_RGX = /appendix/i; + + groups() { + const result = []; + for (var type of this.models) { + const name = this._groupFor(type); + result[name] ||= []; + result[name].push(type); + } + return result.filter((e) => e.length > 0); + } + + _groupFor(type) { + if (Types.GUIDES_RGX.test(type.name)) { + return 0; + } else if (Types.APPENDIX_RGX.test(type.name)) { + return 2; + } else { + return 1; + } + } +}; diff --git a/assets/javascripts/debug.js b/assets/javascripts/debug.js new file mode 100644 index 0000000000..8fcba75b45 --- /dev/null +++ b/assets/javascripts/debug.js @@ -0,0 +1,105 @@ +// +// App +// + +const _init = app.init; +app.init = function () { + console.time("Init"); + _init.call(app); + console.timeEnd("Init"); + return console.time("Load"); +}; + +const _start = app.start; +app.start = function () { + console.timeEnd("Load"); + console.time("Start"); + _start.call(app, ...arguments); + return console.timeEnd("Start"); +}; + +// +// Searcher +// + +app.Searcher = class TimingSearcher extends app.Searcher { + setup() { + console.groupCollapsed(`Search: ${this.query}`); + console.time("Total"); + return super.setup(); + } + + match() { + if (this.matcher) { + console.timeEnd(this.matcher.name); + } + return super.match(); + } + + setupMatcher() { + console.time(this.matcher.name); + return super.setupMatcher(); + } + + end() { + console.log(`Results: ${this.totalResults}`); + console.timeEnd("Total"); + console.groupEnd(); + return super.end(); + } + + kill() { + if (this.timeout) { + if (this.matcher) { + console.timeEnd(this.matcher.name); + } + console.groupEnd(); + console.timeEnd("Total"); + console.warn("Killed"); + } + return super.kill(); + } +}; + +// +// View tree +// + +this.viewTree = function (view, level, visited) { + if (view == null) { + view = app.document; + } + if (level == null) { + level = 0; + } + if (visited == null) { + visited = []; + } + if (visited.includes(view)) { + return; + } + visited.push(view); + + console.log( + `%c ${Array(level + 1).join(" ")}${ + view.constructor.name + }: ${!!view.activated}`, + "color:" + ((view.activated && "green") || "red"), + ); + + for (var key of Object.keys(view || {})) { + var value = view[key]; + if (key !== "view" && value) { + if (typeof value === "object" && value.setupElement) { + this.viewTree(value, level + 1, visited); + } else if (value.constructor.toString().match(/Object\(\)/)) { + for (var k of Object.keys(value || {})) { + var v = value[k]; + if (v && typeof v === "object" && v.setupElement) { + this.viewTree(v, level + 1, visited); + } + } + } + } + } +}; diff --git a/assets/javascripts/debug.js.coffee b/assets/javascripts/debug.js.coffee deleted file mode 100644 index 032d93ac15..0000000000 --- a/assets/javascripts/debug.js.coffee +++ /dev/null @@ -1,85 +0,0 @@ -return unless console?.time and console.groupCollapsed - -# -# App -# - -_init = app.init -app.init = -> - console.time 'Init' - _init.call(app) - console.timeEnd 'Init' - console.time 'Load' - -_start = app.start -app.start = -> - console.timeEnd 'Load' - console.time 'Start' - _start.call(app, arguments...) - console.timeEnd 'Start' - -# -# Searcher -# - -_super = app.Searcher -_proto = app.Searcher.prototype - -app.Searcher = -> - _super.apply @, arguments - - _setup = @setup.bind(@) - @setup = -> - console.groupCollapsed "Search: #{@query}" - console.time 'Total' - _setup() - - _match = @match.bind(@) - @match = => - console.timeEnd @matcher.name if @matcher - _match() - - _setupMatcher = @setupMatcher.bind(@) - @setupMatcher = -> - console.time @matcher.name - _setupMatcher() - - _end = @end.bind(@) - @end = -> - console.log "Results: #{@totalResults}" - console.timeEnd 'Total' - console.groupEnd() - _end() - - _kill = @kill.bind(@) - @kill = -> - if @timeout - console.timeEnd @matcher.name if @matcher - console.groupEnd() - console.timeEnd 'Total' - console.warn 'Killed' - _kill() - - return - -$.extend(app.Searcher, _super) -_proto.constructor = app.Searcher -app.Searcher.prototype = _proto - -# -# View tree -# - -@viewTree = (view = app.document, level = 0, visited = []) -> - return if visited.indexOf(view) >= 0 - visited.push(view) - - console.log "%c #{Array(level + 1).join(' ')}#{view.constructor.name}: #{!!view.activated}", - 'color:' + (view.activated and 'green' or 'red') - - for own key, value of view when key isnt 'view' and value - if typeof value is 'object' and value.setupElement - @viewTree(value, level + 1, visited) - else if value.constructor.toString().match(/Object\(\)/) - @viewTree(v, level + 1, visited) for own k, v of value when v and typeof v is 'object' and v.setupElement - return diff --git a/assets/javascripts/lib/ajax.coffee b/assets/javascripts/lib/ajax.coffee deleted file mode 100644 index 33ab2d9bd4..0000000000 --- a/assets/javascripts/lib/ajax.coffee +++ /dev/null @@ -1,124 +0,0 @@ -MIME_TYPES = - json: 'application/json' - html: 'text/html' - -@ajax = (options) -> - applyDefaults(options) - serializeData(options) - - xhr = new XMLHttpRequest() - xhr.open(options.type, options.url, options.async) - - applyCallbacks(xhr, options) - applyHeaders(xhr, options) - - xhr.send(options.data) - - if options.async - abort: abort.bind(undefined, xhr) - else - parseResponse(xhr, options) - -ajax.defaults = - async: true - dataType: 'json' - timeout: 30 - type: 'GET' - # contentType - # context - # data - # error - # headers - # progress - # success - # url - -applyDefaults = (options) -> - for key of ajax.defaults - options[key] ?= ajax.defaults[key] - return - -serializeData = (options) -> - return unless options.data - - if options.type is 'GET' - options.url += '?' + serializeParams(options.data) - options.data = null - else - options.data = serializeParams(options.data) - return - -serializeParams = (params) -> - ("#{encodeURIComponent key}=#{encodeURIComponent value}" for key, value of params).join '&' - -applyCallbacks = (xhr, options) -> - return unless options.async - - xhr.timer = setTimeout onTimeout.bind(undefined, xhr, options), options.timeout * 1000 - xhr.onprogress = options.progress if options.progress - xhr.onreadystatechange = -> - if xhr.readyState is 4 - clearTimeout(xhr.timer) - onComplete(xhr, options) - return - return - -applyHeaders = (xhr, options) -> - options.headers or= {} - - if options.contentType - options.headers['Content-Type'] = options.contentType - - if not options.headers['Content-Type'] and options.data and options.type isnt 'GET' - options.headers['Content-Type'] = 'application/x-www-form-urlencoded' - - if options.dataType - options.headers['Accept'] = MIME_TYPES[options.dataType] or options.dataType - - if isSameOrigin(options.url) - options.headers['X-Requested-With'] = 'XMLHttpRequest' - - for key, value of options.headers - xhr.setRequestHeader(key, value) - return - -onComplete = (xhr, options) -> - if 200 <= xhr.status < 300 - if (response = parseResponse(xhr, options))? - onSuccess response, xhr, options - else - onError 'invalid', xhr, options - else - onError 'error', xhr, options - return - -onSuccess = (response, xhr, options) -> - options.success?.call options.context, response, xhr, options - return - -onError = (type, xhr, options) -> - options.error?.call options.context, type, xhr, options - return - -onTimeout = (xhr, options) -> - xhr.abort() - onError 'timeout', xhr, options - return - -abort = (xhr) -> - clearTimeout(xhr.timer) - xhr.onreadystatechange = null - xhr.abort() - return - -isSameOrigin = (url) -> - url.indexOf('http') isnt 0 or url.indexOf(location.origin) is 0 - -parseResponse = (xhr, options) -> - if options.dataType is 'json' - parseJSON(xhr.responseText) - else - xhr.responseText - -parseJSON = (json) -> - try JSON.parse(json) catch diff --git a/assets/javascripts/lib/ajax.js b/assets/javascripts/lib/ajax.js new file mode 100644 index 0000000000..dca1dc7483 --- /dev/null +++ b/assets/javascripts/lib/ajax.js @@ -0,0 +1,166 @@ +const MIME_TYPES = { + json: "application/json", + html: "text/html", +}; + +function ajax(options) { + applyDefaults(options); + serializeData(options); + + const xhr = new XMLHttpRequest(); + xhr.open(options.type, options.url, options.async); + + applyCallbacks(xhr, options); + applyHeaders(xhr, options); + + xhr.send(options.data); + + if (options.async) { + return { abort: abort.bind(undefined, xhr) }; + } else { + return parseResponse(xhr, options); + } + + function applyDefaults(options) { + for (var key in ajax.defaults) { + if (options[key] == null) { + options[key] = ajax.defaults[key]; + } + } + } + + function serializeData(options) { + if (!options.data) { + return; + } + + if (options.type === "GET") { + options.url += "?" + serializeParams(options.data); + options.data = null; + } else { + options.data = serializeParams(options.data); + } + } + + function serializeParams(params) { + return Object.entries(params) + .map( + ([key, value]) => + `${encodeURIComponent(key)}=${encodeURIComponent(value)}`, + ) + .join("&"); + } + + function applyCallbacks(xhr, options) { + if (!options.async) { + return; + } + + xhr.timer = setTimeout( + onTimeout.bind(undefined, xhr, options), + options.timeout * 1000, + ); + if (options.progress) { + xhr.onprogress = options.progress; + } + xhr.onreadystatechange = function () { + if (xhr.readyState === 4) { + clearTimeout(xhr.timer); + onComplete(xhr, options); + } + }; + } + + function applyHeaders(xhr, options) { + if (!options.headers) { + options.headers = {}; + } + + if (options.contentType) { + options.headers["Content-Type"] = options.contentType; + } + + if ( + !options.headers["Content-Type"] && + options.data && + options.type !== "GET" + ) { + options.headers["Content-Type"] = "application/x-www-form-urlencoded"; + } + + if (options.dataType) { + options.headers["Accept"] = + MIME_TYPES[options.dataType] || options.dataType; + } + + for (var key in options.headers) { + var value = options.headers[key]; + xhr.setRequestHeader(key, value); + } + } + + function onComplete(xhr, options) { + if (200 <= xhr.status && xhr.status < 300) { + const response = parseResponse(xhr, options); + if (response != null) { + onSuccess(response, xhr, options); + } else { + onError("invalid", xhr, options); + } + } else { + onError("error", xhr, options); + } + } + + function onSuccess(response, xhr, options) { + if (options.success != null) { + options.success.call(options.context, response, xhr, options); + } + } + + function onError(type, xhr, options) { + if (options.error != null) { + options.error.call(options.context, type, xhr, options); + } + } + + function onTimeout(xhr, options) { + xhr.abort(); + onError("timeout", xhr, options); + } + + function abort(xhr) { + clearTimeout(xhr.timer); + xhr.onreadystatechange = null; + xhr.abort(); + } + + function parseResponse(xhr, options) { + if (options.dataType === "json") { + return parseJSON(xhr.responseText); + } else { + return xhr.responseText; + } + } + + function parseJSON(json) { + try { + return JSON.parse(json); + } catch (error) {} + } +} + +ajax.defaults = { + async: true, + dataType: "json", + timeout: 30, + type: "GET", + // contentType + // context + // data + // error + // headers + // progress + // success + // url +}; diff --git a/assets/javascripts/lib/cookie_store.coffee b/assets/javascripts/lib/cookie_store.coffee deleted file mode 100644 index 9cfb409932..0000000000 --- a/assets/javascripts/lib/cookie_store.coffee +++ /dev/null @@ -1,37 +0,0 @@ -class @CookieStore - INT = /^\d+$/ - - @onBlocked: -> - - get: (key) -> - value = Cookies.get(key) - value = parseInt(value, 10) if value? and INT.test(value) - value - - set: (key, value) -> - if value == false - @del(key) - return - - value = 1 if value == true - Cookies.set(key, '' + value, path: '/', expires: 1e8) - @constructor.onBlocked(key, value, @get(key)) if @get(key) != value - return - - del: (key) -> - Cookies.expire(key) - return - - reset: -> - try - for cookie in document.cookie.split(/;\s?/) - Cookies.expire(cookie.split('=')[0]) - return - catch - - dump: -> - result = {} - for cookie in document.cookie.split(/;\s?/) when cookie[0] isnt '_' - cookie = cookie.split('=') - result[cookie[0]] = cookie[1] - result diff --git a/assets/javascripts/lib/cookies_store.js b/assets/javascripts/lib/cookies_store.js new file mode 100644 index 0000000000..7878855c0c --- /dev/null +++ b/assets/javascripts/lib/cookies_store.js @@ -0,0 +1,63 @@ +// Intentionally called CookiesStore instead of CookieStore +// Calling it CookieStore causes issues when the Experimental Web Platform features flag is enabled in Chrome +// Related issue: https://github.com/freeCodeCamp/devdocs/issues/932 +class CookiesStore { + static INT = /^\d+$/; + + static onBlocked() {} + + get(key) { + let value = Cookies.get(key); + if (value != null && CookiesStore.INT.test(value)) { + value = parseInt(value, 10); + } + return value; + } + + set(key, value) { + if (value === false) { + this.del(key); + return; + } + + if (value === true) { + value = 1; + } + if ( + value && + (typeof CookiesStore.INT.test === "function" + ? CookiesStore.INT.test(value) + : undefined) + ) { + value = parseInt(value, 10); + } + Cookies.set(key, "" + value, { path: "/", expires: 1e8 }); + if (this.get(key) !== value) { + CookiesStore.onBlocked(key, value, this.get(key)); + } + } + + del(key) { + Cookies.expire(key); + } + + reset() { + try { + for (var cookie of document.cookie.split(/;\s?/)) { + Cookies.expire(cookie.split("=")[0]); + } + return; + } catch (error) {} + } + + dump() { + const result = {}; + for (var cookie of document.cookie.split(/;\s?/)) { + if (cookie[0] !== "_") { + cookie = cookie.split("="); + result[cookie[0]] = cookie[1]; + } + } + return result; + } +} diff --git a/assets/javascripts/lib/events.coffee b/assets/javascripts/lib/events.coffee deleted file mode 100644 index feeb549818..0000000000 --- a/assets/javascripts/lib/events.coffee +++ /dev/null @@ -1,26 +0,0 @@ -@Events = - on: (event, callback) -> - if event.indexOf(' ') >= 0 - @on name, callback for name in event.split(' ') - else - ((@_callbacks ?= {})[event] ?= []).push callback - @ - - off: (event, callback) -> - if event.indexOf(' ') >= 0 - @off name, callback for name in event.split(' ') - else if (callbacks = @_callbacks?[event]) and (index = callbacks.indexOf callback) >= 0 - callbacks.splice index, 1 - delete @_callbacks[event] unless callbacks.length - @ - - trigger: (event, args...) -> - if callbacks = @_callbacks?[event] - callback? args... for callback in callbacks.slice(0) - @trigger 'all', event, args... unless event is 'all' - @ - - removeEvent: (event) -> - if @_callbacks? - delete @_callbacks[name] for name in event.split(' ') - @ diff --git a/assets/javascripts/lib/events.js b/assets/javascripts/lib/events.js new file mode 100644 index 0000000000..d735a3d55d --- /dev/null +++ b/assets/javascripts/lib/events.js @@ -0,0 +1,58 @@ +class Events { + on(event, callback) { + if (event.includes(" ")) { + for (var name of event.split(" ")) { + this.on(name, callback); + } + } else { + this._callbacks ||= {}; + this._callbacks[event] ||= []; + this._callbacks[event].push(callback); + } + return this; + } + + off(event, callback) { + let callbacks, index; + if (event.includes(" ")) { + for (var name of event.split(" ")) { + this.off(name, callback); + } + } else if ( + (callbacks = this._callbacks?.[event]) && + (index = callbacks.indexOf(callback)) >= 0 + ) { + callbacks.splice(index, 1); + if (!callbacks.length) { + delete this._callbacks[event]; + } + } + return this; + } + + trigger(event, ...args) { + this.eventInProgress = { name: event, args }; + const callbacks = this._callbacks?.[event]; + if (callbacks) { + for (const callback of callbacks.slice(0)) { + if (typeof callback === "function") { + callback(...args); + } + } + } + this.eventInProgress = null; + if (event !== "all") { + this.trigger("all", event, ...args); + } + return this; + } + + removeEvent(event) { + if (this._callbacks != null) { + for (var name of event.split(" ")) { + delete this._callbacks[name]; + } + } + return this; + } +} diff --git a/assets/javascripts/lib/favicon.js b/assets/javascripts/lib/favicon.js new file mode 100644 index 0000000000..6b58016c64 --- /dev/null +++ b/assets/javascripts/lib/favicon.js @@ -0,0 +1,101 @@ +let defaultUrl = null; +let currentSlug = null; + +const imageCache = {}; +const urlCache = {}; + +const withImage = function (url, action) { + if (imageCache[url]) { + return action(imageCache[url]); + } else { + const img = new Image(); + img.crossOrigin = "anonymous"; + img.src = url; + return (img.onload = () => { + imageCache[url] = img; + return action(img); + }); + } +}; + +this.setFaviconForDoc = function (doc) { + if (currentSlug === doc.slug || app.settings.get("noDocSpecificIcon")) { + return; + } + + const favicon = $('link[rel="icon"]'); + + if (defaultUrl === null) { + defaultUrl = favicon.href; + } + + if (urlCache[doc.slug]) { + favicon.href = urlCache[doc.slug]; + currentSlug = doc.slug; + return; + } + + const iconEl = $(`._icon-${doc.slug.split("~")[0]}`); + if (iconEl === null) { + return; + } + + const styles = window.getComputedStyle(iconEl, ":before"); + + const backgroundPositionX = styles["background-position-x"]; + const backgroundPositionY = styles["background-position-y"]; + if (backgroundPositionX === undefined || backgroundPositionY === undefined) { + return; + } + + const bgUrl = app.config.favicon_spritesheet; + const sourceSize = 16; + const sourceX = Math.abs(parseInt(backgroundPositionX.slice(0, -2))); + const sourceY = Math.abs(parseInt(backgroundPositionY.slice(0, -2))); + + return withImage(bgUrl, (docImg) => + withImage(defaultUrl, function (defaultImg) { + const size = defaultImg.width; + + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext("2d"); + + canvas.width = size; + canvas.height = size; + ctx.drawImage(defaultImg, 0, 0); + + const docIconPercentage = 65; + const destinationCoords = (size / 100) * (100 - docIconPercentage); + const destinationSize = (size / 100) * docIconPercentage; + + ctx.drawImage( + docImg, + sourceX, + sourceY, + sourceSize, + sourceSize, + destinationCoords, + destinationCoords, + destinationSize, + destinationSize, + ); + + try { + urlCache[doc.slug] = canvas.toDataURL(); + favicon.href = urlCache[doc.slug]; + + return (currentSlug = doc.slug); + } catch (error) { + Raven.captureException(error, { level: "info" }); + return this.resetFavicon(); + } + }), + ); +}; + +this.resetFavicon = function () { + if (defaultUrl !== null && currentSlug !== null) { + $('link[rel="icon"]').href = defaultUrl; + return (currentSlug = null); + } +}; diff --git a/assets/javascripts/lib/license.coffee b/assets/javascripts/lib/license.coffee deleted file mode 100644 index 18061d0cb4..0000000000 --- a/assets/javascripts/lib/license.coffee +++ /dev/null @@ -1,7 +0,0 @@ -### - * Copyright 2013-2016 Thibaut Courouble and other contributors - * - * This source code is licensed under the terms of the Mozilla - * Public License, v. 2.0, a copy of which may be obtained at: - * http://mozilla.org/MPL/2.0/ -### diff --git a/assets/javascripts/lib/license.js b/assets/javascripts/lib/license.js new file mode 100644 index 0000000000..15b42c98f4 --- /dev/null +++ b/assets/javascripts/lib/license.js @@ -0,0 +1,7 @@ +/* + * Copyright 2013-2025 Thibaut Courouble and other contributors + * + * This source code is licensed under the terms of the Mozilla + * Public License, v. 2.0, a copy of which may be obtained at: + * http://mozilla.org/MPL/2.0/ + */ diff --git a/assets/javascripts/lib/local_storage_store.coffee b/assets/javascripts/lib/local_storage_store.coffee deleted file mode 100644 index f4438c86a7..0000000000 --- a/assets/javascripts/lib/local_storage_store.coffee +++ /dev/null @@ -1,23 +0,0 @@ -class @LocalStorageStore - get: (key) -> - try - JSON.parse localStorage.getItem(key) - catch - - set: (key, value) -> - try - localStorage.setItem(key, JSON.stringify(value)) - true - catch - - del: (key) -> - try - localStorage.removeItem(key) - true - catch - - reset: -> - try - localStorage.clear() - true - catch diff --git a/assets/javascripts/lib/local_storage_store.js b/assets/javascripts/lib/local_storage_store.js new file mode 100644 index 0000000000..25a4ee90f7 --- /dev/null +++ b/assets/javascripts/lib/local_storage_store.js @@ -0,0 +1,28 @@ +this.LocalStorageStore = class LocalStorageStore { + get(key) { + try { + return JSON.parse(localStorage.getItem(key)); + } catch (error) {} + } + + set(key, value) { + try { + localStorage.setItem(key, JSON.stringify(value)); + return true; + } catch (error) {} + } + + del(key) { + try { + localStorage.removeItem(key); + return true; + } catch (error) {} + } + + reset() { + try { + localStorage.clear(); + return true; + } catch (error) {} + } +}; diff --git a/assets/javascripts/lib/page.coffee b/assets/javascripts/lib/page.coffee deleted file mode 100644 index 546f5f8dc9..0000000000 --- a/assets/javascripts/lib/page.coffee +++ /dev/null @@ -1,183 +0,0 @@ -### - * Based on github.com/visionmedia/page.js - * Licensed under the MIT license - * Copyright 2012 TJ Holowaychuk -### - -running = false -currentState = null -callbacks = [] - -@page = (value, fn) -> - if typeof value is 'function' - page '*', value - else if typeof fn is 'function' - route = new Route(value) - callbacks.push route.middleware(fn) - else if typeof value is 'string' - page.show(value, fn) - else - page.start(value) - return - -page.start = (options = {}) -> - unless running - running = true - addEventListener 'popstate', onpopstate - addEventListener 'click', onclick - page.replace currentPath(), null, null, true - return - -page.stop = -> - if running - running = false - removeEventListener 'click', onclick - removeEventListener 'popstate', onpopstate - return - -page.show = (path, state) -> - return if path is currentState?.path - context = new Context(path, state) - currentState = context.state - page.dispatch(context) - context.pushState() - track() - context - -page.replace = (path, state, skipDispatch, init) -> - context = new Context(path, state or currentState) - context.init = init - currentState = context.state - page.dispatch(context) unless skipDispatch - context.replaceState() - track() unless init or skipDispatch - context - -page.dispatch = (context) -> - i = 0 - next = -> - fn(context, next) if fn = callbacks[i++] - return - next() - return - -page.canGoBack = -> - not Context.isIntialState(currentState) - -page.canGoForward = -> - not Context.isLastState(currentState) - -currentPath = -> - location.pathname + location.search + location.hash - -class Context - @initialPath: currentPath() - @sessionId: Date.now() - @stateId: 0 - - @isIntialState: (state) -> - state.id == 0 - - @isLastState: (state) -> - state.id == @stateId - 1 - - @isInitialPopState: (state) -> - state.path is @initialPath and @stateId is 1 - - @isSameSession: (state) -> - state.sessionId is @sessionId - - constructor: (@path = '/', @state = {}) -> - @pathname = @path.replace /(?:\?([^#]*))?(?:#(.*))?$/, (_, query, hash) => - @query = query - @hash = hash - '' - - @state.id ?= @constructor.stateId++ - @state.sessionId ?= @constructor.sessionId - @state.path = @path - - pushState: -> - history.pushState @state, '', @path - return - - replaceState: -> - try history.replaceState @state, '', @path # NS_ERROR_FAILURE in Firefox - return - -class Route - constructor: (@path, options = {}) -> - @keys = [] - @regexp = pathtoRegexp @path, @keys - - middleware: (fn) -> - (context, next) => - if @match context.pathname, params = [] - context.params = params - fn(context, next) - else - next() - return - - match: (path, params) -> - return unless matchData = @regexp.exec(path) - - for value, i in matchData[1..] - value = decodeURIComponent value if typeof value is 'string' - if key = @keys[i] - params[key.name] = value - else - params.push value - true - -pathtoRegexp = (path, keys) -> - return path if path instanceof RegExp - - path = "(#{path.join '|'})" if path instanceof Array - path = path - .replace /\/\(/g, '(?:/' - .replace /(\/)?(\.)?:(\w+)(?:(\(.*?\)))?(\?)?/g, (_, slash = '', format = '', key, capture, optional) -> - keys.push name: key, optional: !!optional - str = if optional then '' else slash - str += '(?:' - str += slash if optional - str += format - str += capture or if format then '([^/.]+?)' else '([^/]+?)' - str += ')' - str += optional if optional - str - .replace /([\/.])/g, '\\$1' - .replace /\*/g, '(.*)' - - new RegExp "^#{path}$" - -onpopstate = (event) -> - return if not event.state or Context.isInitialPopState(event.state) - - if Context.isSameSession(event.state) - page.replace(event.state.path, event.state) - else - location.reload() - return - -onclick = (event) -> - try - return if event.which isnt 1 or event.metaKey or event.ctrlKey or event.shiftKey or event.defaultPrevented - catch - return - - link = event.target - link = link.parentElement while link and link.tagName isnt 'A' - - if link and not link.target and isSameOrigin(link.href) - event.preventDefault() - page.show link.pathname + link.search + link.hash - return - -isSameOrigin = (url) -> - url.indexOf("#{location.protocol}//#{location.hostname}") is 0 - -track = -> - ga?('send', 'pageview', location.pathname + location.search + location.hash) - _gauges?.push(['track']) - return diff --git a/assets/javascripts/lib/page.js b/assets/javascripts/lib/page.js new file mode 100644 index 0000000000..0043e76612 --- /dev/null +++ b/assets/javascripts/lib/page.js @@ -0,0 +1,347 @@ +/* + * Based on github.com/visionmedia/page.js + * Licensed under the MIT license + * Copyright 2012 TJ Holowaychuk + */ + +let running = false; +let currentState = null; +const callbacks = []; + +this.page = function (value, fn) { + if (typeof value === "function") { + page("*", value); + } else if (typeof fn === "function") { + const route = new Route(value); + callbacks.push(route.middleware(fn)); + } else if (typeof value === "string") { + page.show(value, fn); + } else { + page.start(value); + } +}; + +page.start = function (options) { + if (options == null) { + options = {}; + } + if (!running) { + running = true; + addEventListener("popstate", onpopstate); + addEventListener("click", onclick); + page.replace(currentPath(), null, null, true); + } +}; + +page.stop = function () { + if (running) { + running = false; + removeEventListener("click", onclick); + removeEventListener("popstate", onpopstate); + } +}; + +page.show = function (path, state) { + if (path === currentState?.path) { + return; + } + const context = new Context(path, state); + const previousState = currentState; + currentState = context.state; + const res = page.dispatch(context); + if (res) { + currentState = previousState; + location.assign(res); + } else { + context.pushState(); + updateCanonicalLink(); + track(); + } + return context; +}; + +page.replace = function (path, state, skipDispatch, init) { + let result; + let context = new Context(path, state || currentState); + context.init = init; + currentState = context.state; + if (!skipDispatch) { + result = page.dispatch(context); + } + if (result) { + context = new Context(result); + context.init = init; + currentState = context.state; + page.dispatch(context); + } + context.replaceState(); + updateCanonicalLink(); + if (!skipDispatch) { + track(); + } + return context; +}; + +page.dispatch = function (context) { + let i = 0; + const next = function () { + let fn = callbacks[i++]; + return fn?.(context, next); + }; + return next(); +}; + +page.canGoBack = () => !Context.isIntialState(currentState); + +page.canGoForward = () => !Context.isLastState(currentState); + +const currentPath = () => location.pathname + location.search + location.hash; + +class Context { + static isIntialState(state) { + return state.id === 0; + } + + static isLastState(state) { + return state.id === this.stateId - 1; + } + + static isInitialPopState(state) { + return state.path === this.initialPath && this.stateId === 1; + } + + static isSameSession(state) { + return state.sessionId === this.sessionId; + } + + constructor(path, state) { + this.initialPath = currentPath(); + this.sessionId = Date.now(); + this.stateId = 0; + if (path == null) { + path = "/"; + } + this.path = path; + if (state == null) { + state = {}; + } + this.state = state; + this.pathname = this.path.replace( + /(?:\?([^#]*))?(?:#(.*))?$/, + (_, query, hash) => { + this.query = query; + this.hash = hash; + return ""; + }, + ); + + if (this.state.id == null) { + this.state.id = this.constructor.stateId++; + } + if (this.state.sessionId == null) { + this.state.sessionId = this.constructor.sessionId; + } + this.state.path = this.path; + } + + pushState() { + history.pushState(this.state, "", this.path); + } + + replaceState() { + try { + history.replaceState(this.state, "", this.path); + } catch (error) {} // NS_ERROR_FAILURE in Firefox + } +} + +class Route { + constructor(path, options) { + this.path = path; + if (options == null) { + options = {}; + } + this.keys = []; + this.regexp = pathToRegexp(this.path, this.keys); + } + + middleware(fn) { + return (context, next) => { + let params = []; + if (this.match(context.pathname, params)) { + context.params = params; + return fn(context, next); + } else { + return next(); + } + }; + } + + match(path, params) { + const matchData = this.regexp.exec(path); + if (!matchData) { + return; + } + + const iterable = matchData.slice(1); + for (let i = 0; i < iterable.length; i++) { + var key = this.keys[i]; + var value = iterable[i]; + if (typeof value === "string") { + value = decodeURIComponent(value); + } + if (key) { + params[key.name] = value; + } else { + params.push(value); + } + } + return true; + } +} + +var pathToRegexp = function (path, keys) { + if (path instanceof RegExp) { + return path; + } + + if (path instanceof Array) { + path = `(${path.join("|")})`; + } + path = path + .replace(/\/\(/g, "(?:/") + .replace( + /(\/)?(\.)?:(\w+)(?:(\(.*?\)))?(\?)?/g, + (_, slash, format, key, capture, optional) => { + if (slash == null) { + slash = ""; + } + if (format == null) { + format = ""; + } + keys.push({ name: key, optional: !!optional }); + let str = optional ? "" : slash; + str += "(?:"; + if (optional) { + str += slash; + } + str += format; + str += capture || (format ? "([^/.]+?)" : "([^/]+?)"); + str += ")"; + if (optional) { + str += optional; + } + return str; + }, + ) + .replace(/([\/.])/g, "\\$1") + .replace(/\*/g, "(.*)"); + + return new RegExp(`^${path}$`); +}; + +var onpopstate = function (event) { + if (!event.state || Context.isInitialPopState(event.state)) { + return; + } + + if (Context.isSameSession(event.state)) { + page.replace(event.state.path, event.state); + } else { + location.reload(); + } +}; + +var onclick = function (event) { + try { + if ( + event.which !== 1 || + event.metaKey || + event.ctrlKey || + event.shiftKey || + event.defaultPrevented + ) { + return; + } + } catch (error) { + return; + } + + let link = $.eventTarget(event); + while (link && !(link.tagName === "A" || link.tagName === "a")) { + link = link.parentNode; + } + + if (!link) return; + + // If the `` is in an SVG, its attributes are `SVGAnimatedString`s + // instead of strings + let href = link.href instanceof SVGAnimatedString + ? new URL(link.href.baseVal, location.href).href + : link.href; + let target = link.target instanceof SVGAnimatedString + ? link.target.baseVal + : link.target; + + if (!target && isSameOrigin(href)) { + event.preventDefault(); + let parsedHref = new URL(href); + let path = parsedHref.pathname + parsedHref.search + parsedHref.hash; + path = path.replace(/^\/\/+/, "/"); // IE11 bug + page.show(path); + } +}; + +var isSameOrigin = (url) => + url.startsWith(`${location.protocol}//${location.hostname}`); + +var updateCanonicalLink = function () { + if (!this.canonicalLink) { + this.canonicalLink = document.head.querySelector('link[rel="canonical"]'); + } + return this.canonicalLink.setAttribute( + "href", + `https://${location.host}${location.pathname}`, + ); +}; + +const trackers = []; + +page.track = function (fn) { + trackers.push(fn); +}; + +var track = function () { + if (app.config.env !== "production") { + return; + } + if (navigator.doNotTrack === "1") { + return; + } + if (navigator.globalPrivacyControl) { + return; + } + + const consentGiven = Cookies.get("analyticsConsent"); + const consentAsked = Cookies.get("analyticsConsentAsked"); + + if (consentGiven === "1") { + for (var tracker of trackers) { + tracker.call(); + } + } else if (consentGiven === undefined && consentAsked === undefined) { + // Only ask for consent once per browser session + Cookies.set("analyticsConsentAsked", "1"); + + new app.views.Notif("AnalyticsConsent", { autoHide: null }); + } +}; + +this.resetAnalytics = function () { + for (var cookie of document.cookie.split(/;\s?/)) { + var name = cookie.split("=")[0]; + if (name[0] === "_" && name[1] !== "_") { + Cookies.expire(name); + } + } +}; diff --git a/assets/javascripts/lib/util.coffee b/assets/javascripts/lib/util.coffee deleted file mode 100644 index 06e3d1cbd0..0000000000 --- a/assets/javascripts/lib/util.coffee +++ /dev/null @@ -1,371 +0,0 @@ -# -# Traversing -# - -@$ = (selector, el = document) -> - try el.querySelector(selector) catch - -@$$ = (selector, el = document) -> - try el.querySelectorAll(selector) catch - -$.id = (id) -> - document.getElementById(id) - -$.hasChild = (parent, el) -> - return unless parent - while el - return true if el is parent - return if el is document.body - el = el.parentElement - -$.closestLink = (el, parent = document.body) -> - while el - return el if el.tagName is 'A' - return if el is parent - el = el.parentElement - -# -# Events -# - -$.on = (el, event, callback, useCapture = false) -> - if event.indexOf(' ') >= 0 - $.on el, name, callback for name in event.split(' ') - else - el.addEventListener(event, callback, useCapture) - return - -$.off = (el, event, callback, useCapture = false) -> - if event.indexOf(' ') >= 0 - $.off el, name, callback for name in event.split(' ') - else - el.removeEventListener(event, callback, useCapture) - return - -$.trigger = (el, type, canBubble = true, cancelable = true) -> - event = document.createEvent 'Event' - event.initEvent(type, canBubble, cancelable) - el.dispatchEvent(event) - return - -$.click = (el) -> - event = document.createEvent 'MouseEvent' - event.initMouseEvent 'click', true, true, window, null, 0, 0, 0, 0, false, false, false, false, 0, null - el.dispatchEvent(event) - return - -$.stopEvent = (event) -> - event.preventDefault() - event.stopPropagation() - event.stopImmediatePropagation() - return - -# -# Manipulation -# - -buildFragment = (value) -> - fragment = document.createDocumentFragment() - - if $.isCollection(value) - fragment.appendChild(child) for child in $.makeArray(value) - else - fragment.innerHTML = value - - fragment - -$.append = (el, value) -> - if typeof value is 'string' - el.insertAdjacentHTML 'beforeend', value - else - value = buildFragment(value) if $.isCollection(value) - el.appendChild(value) - return - -$.prepend = (el, value) -> - if not el.firstChild - $.append(value) - else if typeof value is 'string' - el.insertAdjacentHTML 'afterbegin', value - else - value = buildFragment(value) if $.isCollection(value) - el.insertBefore(value, el.firstChild) - return - -$.before = (el, value) -> - if typeof value is 'string' or $.isCollection(value) - value = buildFragment(value) - - el.parentElement.insertBefore(value, el) - return - -$.after = (el, value) -> - if typeof value is 'string' or $.isCollection(value) - value = buildFragment(value) - - if el.nextSibling - el.parentElement.insertBefore(value, el.nextSibling) - else - el.parentElement.appendChild(value) - return - -$.remove = (value) -> - if $.isCollection(value) - el.parentElement?.removeChild(el) for el in $.makeArray(value) - else - value.parentElement?.removeChild(value) - return - -$.empty = (el) -> - el.removeChild(el.firstChild) while el.firstChild - return - -# Calls the function while the element is off the DOM to avoid triggering -# unecessary reflows and repaints. -$.batchUpdate = (el, fn) -> - parent = el.parentNode - sibling = el.nextSibling - parent.removeChild(el) - - fn(el) - - if (sibling) - parent.insertBefore(el, sibling) - else - parent.appendChild(el) - return - -# -# Offset -# - -$.rect = (el) -> - el.getBoundingClientRect() - -$.offset = (el, container = document.body) -> - top = 0 - left = 0 - - while el and el isnt container - top += el.offsetTop - left += el.offsetLeft - el = el.offsetParent - - top: top - left: left - -$.scrollParent = (el) -> - while el = el.parentElement - break if el.scrollTop > 0 - break if getComputedStyle(el)?.overflowY in ['auto', 'scroll'] - el - -$.scrollTo = (el, parent, position = 'center', options = {}) -> - return unless el - - parent ?= $.scrollParent(el) - return unless parent - - parentHeight = parent.clientHeight - return unless parent.scrollHeight > parentHeight - - top = $.offset(el, parent).top - - switch position - when 'top' - parent.scrollTop = top - (if options.margin? then options.margin else 20) - when 'center' - parent.scrollTop = top - Math.round(parentHeight / 2 - el.offsetHeight / 2) - when 'continuous' - scrollTop = parent.scrollTop - height = el.offsetHeight - - # If the target element is above the visible portion of its scrollable - # ancestor, move it near the top with a gap = options.topGap * target's height. - if top <= scrollTop + height * (options.topGap or 1) - parent.scrollTop = top - height * (options.topGap or 1) - # If the target element is below the visible portion of its scrollable - # ancestor, move it near the bottom with a gap = options.bottomGap * target's height. - else if top >= scrollTop + parentHeight - height * ((options.bottomGap or 1) + 1) - parent.scrollTop = top - parentHeight + height * ((options.bottomGap or 1) + 1) - return - -$.scrollToWithImageLock = (el, parent, args...) -> - parent ?= $.scrollParent(el) - return unless parent - - $.scrollTo el, parent, args... - - # Lock the scroll position on the target element for up to 3 seconds while - # nearby images are loaded and rendered. - for image in parent.getElementsByTagName('img') when not image.complete - do -> - onLoad = (event) -> - clearTimeout(timeout) - unbind(event.target) - $.scrollTo el, parent, args... - - unbind = (target) -> - $.off target, 'load', onLoad - - $.on image, 'load', onLoad - timeout = setTimeout unbind.bind(null, image), 3000 - return - -# Calls the function while locking the element's position relative to the window. -$.lockScroll = (el, fn) -> - if parent = $.scrollParent(el) - top = $.rect(el).top - top -= $.rect(parent).top unless parent in [document.body, document.documentElement] - fn() - parent.scrollTop = $.offset(el, parent).top - top - else - fn() - return - -smoothScroll = smoothStart = smoothEnd = smoothDistance = smoothDuration = null - -$.smoothScroll = (el, end) -> - unless window.requestAnimationFrame - el.scrollTop = end - return - - smoothEnd = end - - if smoothScroll - newDistance = smoothEnd - smoothStart - smoothDuration += Math.min 300, Math.abs(smoothDistance - newDistance) - smoothDistance = newDistance - return - - smoothStart = el.scrollTop - smoothDistance = smoothEnd - smoothStart - smoothDuration = Math.min 300, Math.abs(smoothDistance) - startTime = Date.now() - - smoothScroll = -> - p = Math.min 1, (Date.now() - startTime) / smoothDuration - y = Math.max 0, Math.floor(smoothStart + smoothDistance * (if p < 0.5 then 2 * p * p else p * (4 - p * 2) - 1)) - el.scrollTop = y - if p is 1 - smoothScroll = null - else - requestAnimationFrame(smoothScroll) - requestAnimationFrame(smoothScroll) - -# -# Utilities -# - -$.extend = (target, objects...) -> - for object in objects when object - for key, value of object - target[key] = value - target - -$.makeArray = (object) -> - if Array.isArray(object) - object - else - Array::slice.apply(object) - -$.arrayDelete = (array, object) -> - index = array.indexOf(object) - if index >= 0 - array.splice(index, 1) - true - else - false - -# Returns true if the object is an array or a collection of DOM elements. -$.isCollection = (object) -> - Array.isArray(object) or typeof object?.item is 'function' - -ESCAPE_HTML_MAP = - '&': '&' - '<': '<' - '>': '>' - '"': '"' - "'": ''' - '/': '/' - -ESCAPE_HTML_REGEXP = /[&<>"'\/]/g - -$.escape = (string) -> - string.replace ESCAPE_HTML_REGEXP, (match) -> ESCAPE_HTML_MAP[match] - -ESCAPE_REGEXP = /([.*+?^=!:${}()|\[\]\/\\])/g - -$.escapeRegexp = (string) -> - string.replace ESCAPE_REGEXP, "\\$1" - -$.urlDecode = (string) -> - decodeURIComponent string.replace(/\+/g, '%20') - -$.classify = (string) -> - string = string.split('_') - for substr, i in string - string[i] = substr[0].toUpperCase() + substr[1..] - string.join('') - -$.framify = (fn, obj) -> - if window.requestAnimationFrame - (args...) -> requestAnimationFrame(fn.bind(obj, args...)) - else - fn - -$.requestAnimationFrame = (fn) -> - if window.requestAnimationFrame - requestAnimationFrame(fn) - else - setTimeout(fn, 0) - return - -# -# Miscellaneous -# - -$.noop = -> - -$.popup = (value) -> - win = window.open() - if win - win.opener = null if win.opener - win.location = value.href or value - else - window.open value.href or value, '_blank' - return - -$.isTouchScreen = -> - typeof ontouchstart isnt 'undefined' - -$.isWindows = -> - navigator.platform?.indexOf('Win') >= 0 - -$.isMac = -> - navigator.userAgent?.indexOf('Mac') >= 0 - -HIGHLIGHT_DEFAULTS = - className: 'highlight' - delay: 1000 - -$.highlight = (el, options = {}) -> - options = $.extend {}, HIGHLIGHT_DEFAULTS, options - el.classList.add(options.className) - setTimeout (-> el.classList.remove(options.className)), options.delay - return - -$.copyToClipboard = (string) -> - textarea = document.createElement('textarea') - textarea.style.position = 'fixed' - textarea.style.opacity = 0 - textarea.value = string - document.body.appendChild(textarea) - try - textarea.select() - result = !!document.execCommand('copy') - catch - result = false - finally - document.body.removeChild(textarea) - result diff --git a/assets/javascripts/lib/util.js b/assets/javascripts/lib/util.js new file mode 100644 index 0000000000..ca977da113 --- /dev/null +++ b/assets/javascripts/lib/util.js @@ -0,0 +1,538 @@ +// +// Traversing +// + +let smoothDistance, smoothDuration, smoothEnd, smoothStart; +this.$ = function (selector, el) { + if (el == null) { + el = document; + } + try { + return el.querySelector(selector); + } catch (error) {} +}; + +this.$$ = function (selector, el) { + if (el == null) { + el = document; + } + try { + return el.querySelectorAll(selector); + } catch (error) {} +}; + +$.id = (id) => document.getElementById(id); + +$.hasChild = function (parent, el) { + if (!parent) { + return; + } + while (el) { + if (el === parent) { + return true; + } + if (el === document.body) { + return; + } + el = el.parentNode; + } +}; + +$.closestLink = function (el, parent) { + if (parent == null) { + parent = document.body; + } + while (el) { + if (el.tagName === "A") { + return el; + } + if (el === parent) { + return; + } + el = el.parentNode; + } +}; + +// +// Events +// + +$.on = function (el, event, callback, useCapture) { + if (useCapture == null) { + useCapture = false; + } + if (event.includes(" ")) { + for (var name of event.split(" ")) { + $.on(el, name, callback); + } + } else { + el.addEventListener(event, callback, useCapture); + } +}; + +$.off = function (el, event, callback, useCapture) { + if (useCapture == null) { + useCapture = false; + } + if (event.includes(" ")) { + for (var name of event.split(" ")) { + $.off(el, name, callback); + } + } else { + el.removeEventListener(event, callback, useCapture); + } +}; + +$.trigger = function (el, type, canBubble, cancelable) { + const event = new Event(type, { + bubbles: canBubble ?? true, + cancelable: cancelable ?? true, + }); + el.dispatchEvent(event); +}; + +$.click = function (el) { + const event = new MouseEvent("click", { + bubbles: true, + cancelable: true, + }); + el.dispatchEvent(event); +}; + +$.stopEvent = function (event) { + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); +}; + +$.eventTarget = (event) => event.target.correspondingUseElement || event.target; + +// +// Manipulation +// + +const buildFragment = function (value) { + const fragment = document.createDocumentFragment(); + + if ($.isCollection(value)) { + for (var child of $.makeArray(value)) { + fragment.appendChild(child); + } + } else { + fragment.innerHTML = value; + } + + return fragment; +}; + +$.append = function (el, value) { + if (typeof value === "string") { + el.insertAdjacentHTML("beforeend", value); + } else { + if ($.isCollection(value)) { + value = buildFragment(value); + } + el.appendChild(value); + } +}; + +$.prepend = function (el, value) { + if (!el.firstChild) { + $.append(value); + } else if (typeof value === "string") { + el.insertAdjacentHTML("afterbegin", value); + } else { + if ($.isCollection(value)) { + value = buildFragment(value); + } + el.insertBefore(value, el.firstChild); + } +}; + +$.before = function (el, value) { + if (typeof value === "string" || $.isCollection(value)) { + value = buildFragment(value); + } + + el.parentNode.insertBefore(value, el); +}; + +$.after = function (el, value) { + if (typeof value === "string" || $.isCollection(value)) { + value = buildFragment(value); + } + + if (el.nextSibling) { + el.parentNode.insertBefore(value, el.nextSibling); + } else { + el.parentNode.appendChild(value); + } +}; + +$.remove = function (value) { + if ($.isCollection(value)) { + for (var el of $.makeArray(value)) { + if (el.parentNode != null) { + el.parentNode.removeChild(el); + } + } + } else { + if (value.parentNode != null) { + value.parentNode.removeChild(value); + } + } +}; + +$.empty = function (el) { + while (el.firstChild) { + el.removeChild(el.firstChild); + } +}; + +// Calls the function while the element is off the DOM to avoid triggering +// unnecessary reflows and repaints. +$.batchUpdate = function (el, fn) { + const parent = el.parentNode; + const sibling = el.nextSibling; + parent.removeChild(el); + + fn(el); + + if (sibling) { + parent.insertBefore(el, sibling); + } else { + parent.appendChild(el); + } +}; + +// +// Offset +// + +$.rect = (el) => el.getBoundingClientRect(); + +$.offset = function (el, container) { + if (container == null) { + container = document.body; + } + let top = 0; + let left = 0; + + while (el && el !== container) { + top += el.offsetTop; + left += el.offsetLeft; + el = el.offsetParent; + } + + return { + top, + left, + }; +}; + +$.scrollParent = function (el) { + while ((el = el.parentNode) && el.nodeType === 1) { + if (el.scrollTop > 0) { + break; + } + if (["auto", "scroll"].includes(getComputedStyle(el)?.overflowY ?? "")) { + break; + } + } + return el; +}; + +$.scrollTo = function (el, parent, position, options) { + if (position == null) { + position = "center"; + } + if (options == null) { + options = {}; + } + if (!el) { + return; + } + + if (parent == null) { + parent = $.scrollParent(el); + } + if (!parent) { + return; + } + + const parentHeight = parent.clientHeight; + const parentScrollHeight = parent.scrollHeight; + if (!(parentScrollHeight > parentHeight)) { + return; + } + + const { top } = $.offset(el, parent); + const { offsetTop } = parent.firstElementChild; + + switch (position) { + case "top": + parent.scrollTop = top - offsetTop - (options.margin || 0); + break; + case "center": + parent.scrollTop = + top - Math.round(parentHeight / 2 - el.offsetHeight / 2); + break; + case "continuous": + var { scrollTop } = parent; + var height = el.offsetHeight; + + var lastElementOffset = + parent.lastElementChild.offsetTop + + parent.lastElementChild.offsetHeight; + var offsetBottom = + lastElementOffset > 0 ? parentScrollHeight - lastElementOffset : 0; + + // If the target element is above the visible portion of its scrollable + // ancestor, move it near the top with a gap = options.topGap * target's height. + if (top - offsetTop <= scrollTop + height * (options.topGap || 1)) { + parent.scrollTop = top - offsetTop - height * (options.topGap || 1); + // If the target element is below the visible portion of its scrollable + // ancestor, move it near the bottom with a gap = options.bottomGap * target's height. + } else if ( + top + offsetBottom >= + scrollTop + parentHeight - height * ((options.bottomGap || 1) + 1) + ) { + parent.scrollTop = + top + + offsetBottom - + parentHeight + + height * ((options.bottomGap || 1) + 1); + } + break; + } +}; + +$.scrollToWithImageLock = function (el, parent, ...args) { + if (parent == null) { + parent = $.scrollParent(el); + } + if (!parent) { + return; + } + + $.scrollTo(el, parent, ...args); + + // Lock the scroll position on the target element for up to 3 seconds while + // nearby images are loaded and rendered. + for (var image of parent.getElementsByTagName("img")) { + if (!image.complete) { + (function () { + let timeout; + const onLoad = function (event) { + clearTimeout(timeout); + unbind(event.target); + return $.scrollTo(el, parent, ...args); + }; + + var unbind = (target) => $.off(target, "load", onLoad); + + $.on(image, "load", onLoad); + return (timeout = setTimeout(unbind.bind(null, image), 3000)); + })(); + } + } +}; + +// Calls the function while locking the element's position relative to the window. +$.lockScroll = function (el, fn) { + const parent = $.scrollParent(el); + if (parent) { + let { top } = $.rect(el); + if (![document.body, document.documentElement].includes(parent)) { + top -= $.rect(parent).top; + } + fn(); + parent.scrollTop = $.offset(el, parent).top - top; + } else { + fn(); + } +}; + +// If `el` is inside any `
` elements, expand them. +$.openDetailsAncestors = function (el) { + while (el) { + if (el.tagName === "DETAILS") { + el.open = true; + } + el = el.parentElement; + } +} + +let smoothScroll = + (smoothStart = + smoothEnd = + smoothDistance = + smoothDuration = + null); + +$.smoothScroll = function (el, end) { + smoothEnd = end; + + if (smoothScroll) { + const newDistance = smoothEnd - smoothStart; + smoothDuration += Math.min(300, Math.abs(smoothDistance - newDistance)); + smoothDistance = newDistance; + return; + } + + smoothStart = el.scrollTop; + smoothDistance = smoothEnd - smoothStart; + smoothDuration = Math.min(300, Math.abs(smoothDistance)); + const startTime = Date.now(); + + smoothScroll = function () { + const p = Math.min(1, (Date.now() - startTime) / smoothDuration); + const y = Math.max( + 0, + Math.floor( + smoothStart + + smoothDistance * (p < 0.5 ? 2 * p * p : p * (4 - p * 2) - 1), + ), + ); + el.scrollTop = y; + if (p === 1) { + return (smoothScroll = null); + } else { + return requestAnimationFrame(smoothScroll); + } + }; + return requestAnimationFrame(smoothScroll); +}; + +// +// Utilities +// + +$.makeArray = function (object) { + if (Array.isArray(object)) { + return object; + } else { + return Array.prototype.slice.apply(object); + } +}; + +$.arrayDelete = function (array, object) { + const index = array.indexOf(object); + if (index >= 0) { + array.splice(index, 1); + return true; + } else { + return false; + } +}; + +// Returns true if the object is an array or a collection of DOM elements. +$.isCollection = (object) => + Array.isArray(object) || typeof object?.item === "function"; + +const ESCAPE_HTML_MAP = { + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", + "/": "/", +}; + +const ESCAPE_HTML_REGEXP = /[&<>"'\/]/g; + +$.escape = (string) => + string.replace(ESCAPE_HTML_REGEXP, (match) => ESCAPE_HTML_MAP[match]); + +const ESCAPE_REGEXP = /([.*+?^=!:${}()|\[\]\/\\])/g; + +$.escapeRegexp = (string) => string.replace(ESCAPE_REGEXP, "\\$1"); + +$.urlDecode = (string) => decodeURIComponent(string.replace(/\+/g, "%20")); + +$.classify = function (string) { + string = string.split("_"); + for (let i = 0; i < string.length; i++) { + var substr = string[i]; + string[i] = substr[0].toUpperCase() + substr.slice(1); + } + return string.join(""); +}; + +// +// Miscellaneous +// + +$.noop = function () {}; + +$.popup = function (value) { + try { + window.open(value.href || value, "_blank", "noopener"); + } catch (error) { + const win = window.open(); + if (win.opener) { + win.opener = null; + } + win.location = value.href || value; + } +}; + +let isMac = null; +$.isMac = () => + isMac != null ? isMac : (isMac = navigator.userAgent.includes("Mac")); + +let isIE = null; +$.isIE = () => + isIE != null + ? isIE + : (isIE = + navigator.userAgent.includes("MSIE") || + navigator.userAgent.includes("rv:11.0")); + +let isChromeForAndroid = null; +$.isChromeForAndroid = () => + isChromeForAndroid != null + ? isChromeForAndroid + : (isChromeForAndroid = + navigator.userAgent.includes("Android") && + /Chrome\/([.0-9])+ Mobile/.test(navigator.userAgent)); + +let isAndroid = null; +$.isAndroid = () => + isAndroid != null + ? isAndroid + : (isAndroid = navigator.userAgent.includes("Android")); + +let isIOS = null; +$.isIOS = () => + isIOS != null + ? isIOS + : (isIOS = + navigator.userAgent.includes("iPhone") || + navigator.userAgent.includes("iPad")); + +$.overlayScrollbarsEnabled = function () { + if (!$.isMac()) { + return false; + } + const div = document.createElement("div"); + div.setAttribute( + "style", + "width: 100px; height: 100px; overflow: scroll; position: absolute", + ); + document.body.appendChild(div); + const result = div.offsetWidth === div.clientWidth; + document.body.removeChild(div); + return result; +}; + +const HIGHLIGHT_DEFAULTS = { + className: "highlight", + delay: 1000, +}; + +$.highlight = function (el, options) { + options = { ...HIGHLIGHT_DEFAULTS, ...(options || {}) }; + el.classList.add(options.className); + setTimeout(() => el.classList.remove(options.className), options.delay); +}; diff --git a/assets/javascripts/models/doc.coffee b/assets/javascripts/models/doc.coffee deleted file mode 100644 index 642fa1ff87..0000000000 --- a/assets/javascripts/models/doc.coffee +++ /dev/null @@ -1,145 +0,0 @@ -class app.models.Doc extends app.Model - # Attributes: name, slug, type, version, release, db_size, mtime, links - - constructor: -> - super - @reset @ - @slug_without_version = @slug.split('~')[0] - @fullName = "#{@name}" + if @version then " #{@version}" else '' - @icon = @slug_without_version - @short_version = @version.split(' ')[0] if @version - @text = @toEntry().text - - reset: (data) -> - @resetEntries data.entries - @resetTypes data.types - return - - resetEntries: (entries) -> - @entries = new app.collections.Entries(entries) - @entries.each (entry) => entry.doc = @ - return - - resetTypes: (types) -> - @types = new app.collections.Types(types) - @types.each (type) => type.doc = @ - return - - fullPath: (path = '') -> - path = "/#{path}" unless path[0] is '/' - "/#{@slug}#{path}" - - fileUrl: (path) -> - "#{app.config.docs_host}#{@fullPath(path)}?#{@mtime}" - - dbUrl: -> - "#{app.config.docs_host}/#{@slug}/#{app.config.db_filename}?#{@mtime}" - - indexUrl: -> - "#{app.indexHost()}/#{@slug}/#{app.config.index_filename}?#{@mtime}" - - toEntry: -> - return @entry if @entry - @entry = new app.models.Entry - doc: @ - name: @fullName - path: 'index' - @entry.addAlias(@name) if @version - @entry - - findEntryByPathAndHash: (path, hash) -> - if hash and entry = @entries.findBy 'path', "#{path}##{hash}" - entry - else if path is 'index' - @toEntry() - else - @entries.findBy 'path', path - - load: (onSuccess, onError, options = {}) -> - return if options.readCache and @_loadFromCache(onSuccess) - - callback = (data) => - @reset data - onSuccess() - @_setCache data if options.writeCache - return - - ajax - url: @indexUrl() - success: callback - error: onError - - clearCache: -> - app.localStorage.del @slug - return - - _loadFromCache: (onSuccess) -> - return unless data = @_getCache() - - callback = => - @reset data - onSuccess() - return - - setTimeout callback, 0 - true - - _getCache: -> - return unless data = app.localStorage.get @slug - - if data[0] is @mtime - return data[1] - else - @clearCache() - return - - _setCache: (data) -> - app.localStorage.set @slug, [@mtime, data] - return - - install: (onSuccess, onError, onProgress) -> - return if @installing - @installing = true - - error = => - @installing = null - onError() - return - - success = (data) => - @installing = null - app.db.store @, data, onSuccess, error - return - - ajax - url: @dbUrl() - success: success - error: error - progress: onProgress - timeout: 3600 - return - - uninstall: (onSuccess, onError) -> - return if @installing - @installing = true - - success = => - @installing = null - onSuccess() - return - - error = => - @installing = null - onError() - return - - app.db.unstore @, success, error - return - - getInstallStatus: (callback) -> - app.db.version @, (value) -> - callback installed: !!value, mtime: value - return - - isOutdated: (status) -> - status and status.installed and @mtime isnt status.mtime diff --git a/assets/javascripts/models/doc.js b/assets/javascripts/models/doc.js new file mode 100644 index 0000000000..a900c61ea1 --- /dev/null +++ b/assets/javascripts/models/doc.js @@ -0,0 +1,202 @@ +app.models.Doc = class Doc extends app.Model { + // Attributes: name, slug, type, version, release, db_size, mtime, links + + constructor() { + super(...arguments); + this.reset(this); + this.slug_without_version = this.slug.split("~")[0]; + this.fullName = `${this.name}` + (this.version ? ` ${this.version}` : ""); + this.icon = this.slug_without_version; + if (this.version) { + this.short_version = this.version.split(" ")[0]; + } + this.text = this.toEntry().text; + } + + reset(data) { + this.resetEntries(data.entries); + this.resetTypes(data.types); + } + + resetEntries(entries) { + this.entries = new app.collections.Entries(entries); + this.entries.each((entry) => { + return (entry.doc = this); + }); + } + + resetTypes(types) { + this.types = new app.collections.Types(types); + this.types.each((type) => { + return (type.doc = this); + }); + } + + fullPath(path) { + if (path == null) { + path = ""; + } + if (path[0] !== "/") { + path = `/${path}`; + } + return `/${this.slug}${path}`; + } + + fileUrl(path) { + return `${app.config.docs_origin}${this.fullPath(path)}?${this.mtime}`; + } + + dbUrl() { + return `${app.config.docs_origin}/${this.slug}/${app.config.db_filename}?${this.mtime}`; + } + + indexUrl() { + return `${app.indexHost()}/${this.slug}/${app.config.index_filename}?${ + this.mtime + }`; + } + + toEntry() { + if (this.entry) { + return this.entry; + } + this.entry = new app.models.Entry({ + doc: this, + name: this.fullName, + path: "index", + }); + if (this.version) { + this.entry.addAlias(this.name); + } + return this.entry; + } + + findEntryByPathAndHash(path, hash) { + const entry = hash && this.entries.findBy("path", `${path}#${hash}`); + if (entry) { + return entry; + } else if (path === "index") { + return this.toEntry(); + } else { + return this.entries.findBy("path", path); + } + } + + load(onSuccess, onError, options) { + if (options == null) { + options = {}; + } + if (options.readCache && this._loadFromCache(onSuccess)) { + return; + } + + const callback = (data) => { + this.reset(data); + onSuccess(); + if (options.writeCache) { + this._setCache(data); + } + }; + + return ajax({ + url: this.indexUrl(), + success: callback, + error: onError, + }); + } + + clearCache() { + app.localStorage.del(this.slug); + } + + _loadFromCache(onSuccess) { + const data = this._getCache(); + if (!data) { + return; + } + + const callback = () => { + this.reset(data); + onSuccess(); + }; + + setTimeout(callback, 0); + return true; + } + + _getCache() { + const data = app.localStorage.get(this.slug); + if (!data) { + return; + } + + if (data[0] === this.mtime) { + return data[1]; + } else { + this.clearCache(); + return; + } + } + + _setCache(data) { + app.localStorage.set(this.slug, [this.mtime, data]); + } + + install(onSuccess, onError, onProgress) { + if (this.installing) { + return; + } + this.installing = true; + + const error = () => { + this.installing = null; + onError(); + }; + + const success = (data) => { + this.installing = null; + app.db.store(this, data, onSuccess, error); + }; + + ajax({ + url: this.dbUrl(), + success, + error, + progress: onProgress, + timeout: 3600, + }); + } + + uninstall(onSuccess, onError) { + if (this.installing) { + return; + } + this.installing = true; + + const success = () => { + this.installing = null; + onSuccess(); + }; + + const error = () => { + this.installing = null; + onError(); + }; + + app.db.unstore(this, success, error); + } + + getInstallStatus(callback) { + app.db.version(this, (value) => + callback({ installed: !!value, mtime: value }), + ); + } + + isOutdated(status) { + if (!status) { + return false; + } + const isInstalled = status.installed || app.settings.get("autoInstall"); + return isInstalled && this.mtime !== status.mtime; + } +}; diff --git a/assets/javascripts/models/entry.coffee b/assets/javascripts/models/entry.coffee deleted file mode 100644 index 2288ecd568..0000000000 --- a/assets/javascripts/models/entry.coffee +++ /dev/null @@ -1,78 +0,0 @@ -#= require app/searcher - -class app.models.Entry extends app.Model - # Attributes: name, type, path - - constructor: -> - super - @text = applyAliases(app.Searcher.normalizeString(@name)) - - addAlias: (name) -> - text = applyAliases(app.Searcher.normalizeString(name)) - @text = [@text] unless Array.isArray(@text) - @text.push(if Array.isArray(text) then text[1] else text) - return - - fullPath: -> - @doc.fullPath if @isIndex() then '' else @path - - dbPath: -> - @path.replace /#.*/, '' - - filePath: -> - @doc.fullPath @_filePath() - - fileUrl: -> - @doc.fileUrl @_filePath() - - _filePath: -> - result = @path.replace /#.*/, '' - result += '.html' unless result[-5..-1] is '.html' - result - - isIndex: -> - @path is 'index' - - getType: -> - @doc.types.findBy 'name', @type - - loadFile: (onSuccess, onError) -> - app.db.load(@, onSuccess, onError) - - applyAliases = (string) -> - if ALIASES.hasOwnProperty(string) - return [string, ALIASES[string]] - else - words = string.split('.') - for word, i in words when ALIASES.hasOwnProperty(word) - words[i] = ALIASES[word] - return [string, words.join('.')] - return string - - @ALIASES = ALIASES = - 'angular': 'ng' - 'angular.js': 'ng' - 'backbone.js': 'bb' - 'c++': 'cpp' - 'coffeescript': 'cs' - 'elixir': 'ex' - 'javascript': 'js' - 'jquery': '$' - 'knockout.js': 'ko' - 'less': 'ls' - 'lodash': '_' - 'marionette': 'mn' - 'markdown': 'md' - 'modernizr': 'mdr' - 'moment.js': 'mt' - 'nginx': 'ngx' - 'numpy': 'np' - 'pandas': 'pd' - 'postgresql': 'pg' - 'python': 'py' - 'ruby.on.rails': 'ror' - 'ruby': 'rb' - 'sass': 'scss' - 'tensorflow': 'tf' - 'typescript': 'ts' - 'underscore.js': '_' diff --git a/assets/javascripts/models/entry.js b/assets/javascripts/models/entry.js new file mode 100644 index 0000000000..98822bc97e --- /dev/null +++ b/assets/javascripts/models/entry.js @@ -0,0 +1,70 @@ +//= require app/searcher + +app.models.Entry = class Entry extends app.Model { + static applyAliases(string) { + const aliases = app.config.docs_aliases; + if (aliases.hasOwnProperty(string)) { + return [string, aliases[string]]; + } else { + const words = string.split("."); + for (let i = 0; i < words.length; i++) { + var word = words[i]; + if (aliases.hasOwnProperty(word)) { + words[i] = aliases[word]; + return [string, words.join(".")]; + } + } + } + return string; + } + + // Attributes: name, type, path + constructor() { + super(...arguments); + this.text = Entry.applyAliases(app.Searcher.normalizeString(this.name)); + } + + addAlias(name) { + const text = Entry.applyAliases(app.Searcher.normalizeString(name)); + if (!Array.isArray(this.text)) { + this.text = [this.text]; + } + this.text.push(Array.isArray(text) ? text[1] : text); + } + + fullPath() { + return this.doc.fullPath(this.isIndex() ? "" : this.path); + } + + dbPath() { + return this.path.replace(/#.*/, ""); + } + + filePath() { + return this.doc.fullPath(this._filePath()); + } + + fileUrl() { + return this.doc.fileUrl(this._filePath()); + } + + _filePath() { + let result = this.path.replace(/#.*/, ""); + if (result.slice(-5) !== ".html") { + result += ".html"; + } + return result; + } + + isIndex() { + return this.path === "index"; + } + + getType() { + return this.doc.types.findBy("name", this.type); + } + + loadFile(onSuccess, onError) { + return app.db.load(this, onSuccess, onError); + } +}; diff --git a/assets/javascripts/models/model.coffee b/assets/javascripts/models/model.coffee deleted file mode 100644 index 7f157f7c4f..0000000000 --- a/assets/javascripts/models/model.coffee +++ /dev/null @@ -1,3 +0,0 @@ -class app.Model - constructor: (attributes) -> - @[key] = value for key, value of attributes diff --git a/assets/javascripts/models/model.js b/assets/javascripts/models/model.js new file mode 100644 index 0000000000..def06e55ce --- /dev/null +++ b/assets/javascripts/models/model.js @@ -0,0 +1,8 @@ +app.Model = class Model { + constructor(attributes) { + for (var key in attributes) { + var value = attributes[key]; + this[key] = value; + } + } +}; diff --git a/assets/javascripts/models/type.coffee b/assets/javascripts/models/type.coffee deleted file mode 100644 index 6351ad16d7..0000000000 --- a/assets/javascripts/models/type.coffee +++ /dev/null @@ -1,14 +0,0 @@ -class app.models.Type extends app.Model - # Attributes: name, slug, count - - fullPath: -> - "/#{@doc.slug}-#{@slug}/" - - entries: -> - @doc.entries.findAllBy 'type', @name - - toEntry: -> - new app.models.Entry - doc: @doc - name: "#{@doc.name} / #{@name}" - path: '..' + @fullPath() diff --git a/assets/javascripts/models/type.js b/assets/javascripts/models/type.js new file mode 100644 index 0000000000..bc264ac189 --- /dev/null +++ b/assets/javascripts/models/type.js @@ -0,0 +1,19 @@ +app.models.Type = class Type extends app.Model { + // Attributes: name, slug, count + + fullPath() { + return `/${this.doc.slug}-${this.slug}/`; + } + + entries() { + return this.doc.entries.findAllBy("type", this.name); + } + + toEntry() { + return new app.models.Entry({ + doc: this.doc, + name: `${this.doc.name} / ${this.name}`, + path: ".." + this.fullPath(), + }); + } +}; diff --git a/assets/javascripts/news.json b/assets/javascripts/news.json index d67f87eb84..1f821abae4 100644 --- a/assets/javascripts/news.json +++ b/assets/javascripts/news.json @@ -1,5 +1,280 @@ [ [ + "2025-10-19", + "New documentations: Lit, Graphviz, Bun" + ], + [ + "2025-07-14", + "New documentation: Tcllib" + ], + [ + "2025-06-27", + "New documentation: Zsh" + ], + [ + "2025-06-04", + "New documentation: es-toolkit" + ], + [ + "2025-05-28", + "New documentation: Vert.x" + ], + [ + "2025-02-23", + "New documentation: Three.js" + ], + [ + "2025-02-16", + "New documentation: OpenLayers" + ], + [ + "2024-11-23", + "New documentation: DuckDB" + ], + [ + "2024-08-20", + "New documentation: Linux man pages" + ], + [ + "2024-07-28", + "New documentation: OpenGL" + ], + [ + "2024-06-12", + "New documentations: Next.js, click" + ], + [ + "2024-01-24", + "New documentation: Playwright" + ], + [ + "2024-01-20", + "New documentation: htmx" + ], + [ + "2024-01-12", + "New documentation: Hammerspoon" + ], + [ + "2024-01-05", + "New documentation: Bazel" + ], + [ + "2023-10-09", + "New documentations: hapi, joi, Nushell, Varnish" + ], + [ + "2023-08-24", + "New documentation: Fluture" + ], + [ + "2022-12-20", + "New documentations: QUnit, Wagtail" + ], + [ + "2022-11-04", + "New documentation: VueUse" + ], + [ + "2022-10-10", + "New documentation: Astro" + ], + [ + "2022-10-09", + "New documentations: FastAPI, Vitest" + ], + [ + "2022-10-02", + "New documentation: Svelte" + ], + [ + "2022-09-21", + "Added HTTP/3 to HTTP" + ], + [ + "2022-09-06", + "New documentation: date-fns" + ], + [ + "2022-08-27", + "New documentations: Sanctuary, Requests, Axios" + ], + [ + "2022-05-03", + "New documentations: Kubernetes, Kubectl" + ], + [ + "2022-04-25", + "New documentation: Nix" + ], + [ + "2022-03-31", + "New documentation: Eigen3" + ], + [ + "2022-02-21", + "New documentation: Tailwind CSS" + ], + [ + "2022-01-12", + "New documentation: React Router" + ], + [ + "2022-01-09", + "New documentation: Deno" + ], + [ + "2021-12-29", + "New documentation: PointCloudLibrary" + ], + [ + "2021-12-27", + "New documentation: Zig" + ], + [ + "2021-12-26", + "New documentation: GNU Make" + ], + [ + "2021-12-07", + "New documentation: Prettier", + "Renamed documentation: Web APIs" + ], + [ + "2021-12-05", + "New documentation: esbuild" + ], + [ + "2021-12-04", + "New documentation: Vite" + ], + [ + "2021-11-29", + "New documentation: i3" + ], + [ + "2021-06-09", + "New documentation: R" + ], + [ + "2021-05-31", + "New documentation: Web Extensions" + ], + [ + "2021-05-26", + "New documentations: LaTeX, jq" + ], + [ + "2021-04-29", + "Added 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" ], [ @@ -55,7 +330,7 @@ "New documentations: Erlang and Tcl/Tk" ], [ "2016-01-24", - "“Multi-version support” has landed!\nClick Select documentation to pick which versions to use. More versions will be added in the coming weeks.\nIf you notice any bugs, please report them on GitHub." + "“Multi-version support” has landed!" ], [ "2015-11-22", "New documentations: Phoenix, Dojo, Relay and Flow" @@ -79,11 +354,8 @@ "New documentations: Q and OpenTSDB" ], [ "2015-07-26", - "Added search abbreviations (e.g. $ is an alias for jQuery).\nClick here to see the full list. Feel free to suggest more on GitHub.", + "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-12", - "New sponsors: JetBrains and Code School\nIf you like DevDocs, please take a moment to check out their products — they're awesome!" ], [ "2015-07-05", "New documentations: Drupal, Vue.js, Phaser and webpack" @@ -108,10 +380,10 @@ "New io.js, Symfony, Clojure, Lua and Yii 1.1 documentations" ], [ "2015-02-08", - "New dark theme\nClick the icon in the bottom left corner to activate.\nFeedback welcome :)" + "New dark theme" ], [ "2015-01-13", - "Offline mode has landed!\nIf you notice any bugs, please report them on GitHub." + "Offline mode has landed!" ], [ "2014-12-21", "New React, RethinkDB, Socket.IO, Modernizr and Bower documentations" @@ -123,7 +395,7 @@ "New Python 2 documentation" ], [ "2014-11-09", - "New design\nFeedback welcome on Twitter and GitHub." + "New design\nFeedback welcome on Twitter and GitHub." ], [ "2014-10-19", "New SVG, Marionette.js, and Mongoose documentations" @@ -208,7 +480,7 @@ "New Ruby documentation" ], [ "2013-10-24", - "DevDocs is now open source." + "DevDocs is now open source." ], [ "2013-10-09", "DevDocs is now available as a Chrome web app." diff --git a/assets/javascripts/templates/base.coffee b/assets/javascripts/templates/base.coffee deleted file mode 100644 index 841d1e0bb7..0000000000 --- a/assets/javascripts/templates/base.coffee +++ /dev/null @@ -1,11 +0,0 @@ -app.templates.render = (name, value, args...) -> - template = app.templates[name] - - if Array.isArray(value) - result = '' - result += template(val, args...) for val in value - result - else if typeof template is 'function' - template(value, args...) - else - template diff --git a/assets/javascripts/templates/base.js b/assets/javascripts/templates/base.js new file mode 100644 index 0000000000..fc445ef19c --- /dev/null +++ b/assets/javascripts/templates/base.js @@ -0,0 +1,15 @@ +app.templates.render = function (name, value, ...args) { + const template = app.templates[name]; + + if (Array.isArray(value)) { + let result = ""; + for (var val of value) { + result += template(val, ...args); + } + return result; + } else if (typeof template === "function") { + return template(value, ...args); + } else { + return template; + } +}; diff --git a/assets/javascripts/templates/error_tmpl.coffee b/assets/javascripts/templates/error_tmpl.coffee deleted file mode 100644 index a6a8e97458..0000000000 --- a/assets/javascripts/templates/error_tmpl.coffee +++ /dev/null @@ -1,67 +0,0 @@ -error = (title, text = '', links = '') -> - text = """

#{text}

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

#{title}

#{text}#{links}
""" - -back = 'Go back' - -app.templates.notFoundPage = -> - error """ Page not found. """, - """ It may be missing from the source documentation or this could be a bug. """, - back - -app.templates.pageLoadError = -> - error """ The page failed to load. """, - """ It may be missing from the server (try reloading the app) or you could be offline.
- If you keep seeing this, you're likely behind a proxy or firewall that blocks cross-domain requests. """, - """ #{back} · Reload - · Retry """ - -app.templates.bootError = -> - error """ The app failed to load. """, - """ Check your Internet connection and try reloading.
- If you keep seeing this, you're likely behind a proxy or firewall that blocks cross-domain requests. """ - -app.templates.offlineError = (reason) -> - if reason is 'cookie_blocked' - return error """ Cookies must be enabled to use offline mode. """ - - reason = switch reason - when 'not_supported' - """ Unfortunately your browser either doesn't support IndexedDB or does not make it available. """ - when 'cant_open' - """ Although your browser supports IndexedDB, DevDocs couldn't open the database.
- This could be because you're browsing in private mode or have disallowed offline storage on the domain. """ - when 'empty' - """ Although your browser supports IndexedDB, DevDocs couldn't properly set up the database.
- This could be because the database is corrupted. Try resetting the app. """ - when 'apple' - """ Unfortunately Safari's implementation of IndexedDB is badly broken.
- This message will automatically go away when Apple fix their code. """ - - error """ Offline mode is unavailable. """, - """ DevDocs requires IndexedDB to cache documentations for offline access.
#{reason} """ - -app.templates.unsupportedBrowser = """ -
-

Your browser is unsupported, sorry.

-

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

    -
  • Recent versions of Chrome and Firefox -
  • Safari 5.1+ -
  • Opera 12.1+ -
  • Internet Explorer 10+ -
  • iOS 6+ -
  • Android 4.1+ -
  • Windows Phone 8+ -
-

- If you're unable to upgrade, I apologize. - I 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. -

- — Thibaut @DevDocs -

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

${text}

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

${title}

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

Your browser is unsupported, sorry.

+

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

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

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

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

+ — @DevDocs +

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

#{text}

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

${text}

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

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

Questions & Answers

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

Hi there!

-

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

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

Happy coding! -

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

Welcome!

-

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

    -
  1. To enable more docs, click Select documentation in the bottom left corner -
  2. You don't have to use your mouse — see the list of keyboard shortcuts -
  3. The search supports fuzzy matching (e.g. "bgcp" brings up "background-clip") -
  4. To search a specific documentation, type its name (or an abbreviation), then Tab -
  5. You can search using your browser's address bar — learn how -
  6. DevDocs works offline, on mobile, and can be installed on Chrome. -
  7. For the latest news, follow @DevDocs -
  8. DevDocs is free and open source - -
  9. If you like the app, please consider supporting the project on Gratipay. Thanks! -
-

Happy coding! -

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

Welcome!

-

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

    -
  1. To pick your docs, click Select documentation at the bottom of the menu -
  2. The search supports fuzzy matching (e.g. "bgcp" matches "background-clip") -
  3. To search a specific documentation, type its name (or an abbreviation), then Space -
  4. For the latest news, follow @DevDocs -
  5. DevDocs is open source -
-

Happy coding! - Stop showing this message -

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

Hi there

-

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

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

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

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

Hi there!

+

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

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

Happy coding! +

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

Welcome!

+

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

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

Happy coding! +

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

Welcome!

+

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

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

Happy coding! + Stop showing this message +

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

Hi there

+

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

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

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

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

Preferences

+ +
+

Theme:

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

General:

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

Scrolling:

+ +
+ + + + + +
+
+ +

+ + + +

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

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

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

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

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

    - ProTip - (click to dismiss) -

    - Hit to navigate the sidebar.
    - Hit space / shift space, alt ↓/↑ or shift ↓/↑ to scroll the page. -

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

    + ProTip + (click to dismiss) +

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

    + See all keyboard shortcuts\ +`; diff --git a/assets/javascripts/tracking.js b/assets/javascripts/tracking.js index 6f505c5d4b..c15781f5c3 100644 --- a/assets/javascripts/tracking.js +++ b/assets/javascripts/tracking.js @@ -1,20 +1,55 @@ try { - if (app.config.env == 'production') { - (function() { - (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'); - ga('send', 'pageview'); - })(); + if (app.config.env === "production") { + if (Cookies.get("analyticsConsent") === "1") { + (function (i, s, o, g, r, a, m) { + i["GoogleAnalyticsObject"] = r; + (i[r] = + i[r] || + function () { + (i[r].q = i[r].q || []).push(arguments); + }), + (i[r].l = 1 * new Date()); + (a = s.createElement(o)), (m = s.getElementsByTagName(o)[0]); + a.async = 1; + a.src = g; + m.parentNode.insertBefore(a, m); + })( + window, + document, + "script", + "https://www.google-analytics.com/analytics.js", + "ga", + ); + ga("create", "UA-5544833-12", "devdocs.io"); + page.track(function () { + ga("send", "pageview", { + page: location.pathname + location.search + location.hash, + dimension1: + app.router.context && + app.router.context.doc && + app.router.context.doc.slug_without_version, + }); + }); - (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)}(); - })(); + 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) { } +} catch (e) {} diff --git a/assets/javascripts/vendor/cookies.js b/assets/javascripts/vendor/cookies.js index 975239c21d..e592a5de8e 100644 --- a/assets/javascripts/vendor/cookies.js +++ b/assets/javascripts/vendor/cookies.js @@ -1,141 +1,208 @@ -/*! - * Cookies.js - 0.3.1 - * Wednesday, April 24 2013 @ 2:28 AM EST +/* + * Cookies.js - 1.2.3 (patched for SameSite=Strict and secure=true) + * https://github.com/ScottHamper/Cookies * - * Copyright (c) 2013, Scott Hamper - * Licensed under the MIT license, - * http://www.opensource.org/licenses/MIT + * This is free and unencumbered software released into the public domain. */ -(function (undefined) { - 'use strict'; +(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); + return arguments.length === 1 + ? Cookies.get(key) + : Cookies.set(key, value, options); }; // Allows for setter injection in unit tests - Cookies._document = document; - Cookies._navigator = navigator; + 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: '/' + path: "/", + SameSite: "Strict", + secure: true, }; Cookies.get = function (key) { - if (Cookies._cachedDocumentCookie !== Cookies._document.cookie) { - Cookies._renewCache(); - } + if (Cookies._cachedDocumentCookie !== Cookies._document.cookie) { + Cookies._renewCache(); + } - return Cookies._cache[key]; + 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; + 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); + 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, - expires: options && options.expires || Cookies.defaults.expires, - secure: options && options.secure !== undefined ? options.secure : Cookies.defaults.secure - }; + 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()); + return ( + Object.prototype.toString.call(date) === "[object Date]" && + !isNaN(date.getTime()) + ); }; Cookies._getExpiresDate = function (expires, now) { - now = now || new Date(); - switch (typeof expires) { - case 'number': expires = new Date(now.getTime() + expires * 1000); break; - case 'string': expires = new Date(expires); break; - } - - if (expires && !Cookies._isValidDate(expires)) { - throw new Error('`expires` parameter cannot be converted to a valid Date instance'); - } - - return expires; + 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 = encodeURIComponent(key); - 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.expires ? ';expires=' + options.expires.toGMTString() : ''; - cookieString += options.secure ? ';secure' : ''; - - return cookieString; + 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._getCookieObjectFromString = function (documentCookie) { - var cookieObject = {}; - var cookiesArray = documentCookie ? documentCookie.split('; ') : []; + 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]); + for (var i = 0; i < cookiesArray.length; i++) { + var cookieKvp = Cookies._getKeyValuePairFromCookieString( + cookiesArray[i], + ); - if (cookieObject[cookieKvp.key] === undefined) { - cookieObject[cookieKvp.key] = cookieKvp.value; - } + if ( + cookieCache[Cookies._cacheKeyPrefix + cookieKvp.key] === undefined + ) { + cookieCache[Cookies._cacheKeyPrefix + cookieKvp.key] = + cookieKvp.value; } + } - return cookieObject; + 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; + // "=" 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: decodeURIComponent(cookieString.substr(0, separatorIndex)), - value: decodeURIComponent(cookieString.substr(separatorIndex + 1)) - }; + return { + key: decodedKey, + value: cookieString.substr(separatorIndex + 1), // Defer decoding value until accessed + }; }; Cookies._renewCache = function () { - Cookies._cache = Cookies._getCookieObjectFromString(Cookies._document.cookie); - Cookies._cachedDocumentCookie = Cookies._document.cookie; + Cookies._cache = Cookies._getCacheFromString(Cookies._document.cookie); + Cookies._cachedDocumentCookie = Cookies._document.cookie; }; Cookies._areEnabled = function () { - return Cookies._navigator.cookieEnabled || - Cookies.set('cookies.js', 1).get('cookies.js') === '1'; + var testKey = "cookies.js"; + var areEnabled = Cookies.set(testKey, 1).get(testKey) === "1"; + Cookies.expire(testKey); + return areEnabled; }; Cookies.enabled = Cookies._areEnabled(); - // AMD support - if (typeof define === 'function' && define.amd) { - define(function () { return Cookies; }); - // CommonJS and Node.js module support. - } else if (typeof exports !== 'undefined') { - // Support Node.js specific `module.exports` (which can be a function) - if (typeof module !== 'undefined' && module.exports) { - exports = module.exports = Cookies; - } - // But always support CommonJS module 1.1.1 spec (`exports` cannot be a function) - exports.Cookies = Cookies; - } else { - window.Cookies = Cookies; + 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; } -})(); \ No newline at end of file + // But always support CommonJS module 1.1.1 spec (`exports` cannot be a function) + exports.Cookies = cookiesExport; + } else { + global.Cookies = cookiesExport; + } +})(typeof window === "undefined" ? this : window); diff --git a/assets/javascripts/vendor/fastclick.js b/assets/javascripts/vendor/fastclick.js deleted file mode 100755 index 3af4f9d6f1..0000000000 --- a/assets/javascripts/vendor/fastclick.js +++ /dev/null @@ -1,841 +0,0 @@ -;(function () { - 'use strict'; - - /** - * @preserve FastClick: polyfill to remove click delays on browsers with touch UIs. - * - * @codingstandard ftlabs-jsv2 - * @copyright The Financial Times Limited [All Rights Reserved] - * @license MIT License (see LICENSE.txt) - */ - - /*jslint browser:true, node:true*/ - /*global define, Event, Node*/ - - - /** - * Instantiate fast-clicking listeners on the specified layer. - * - * @constructor - * @param {Element} layer The layer to listen on - * @param {Object} [options={}] The options to override the defaults - */ - function FastClick(layer, options) { - var oldOnClick; - - options = options || {}; - - /** - * Whether a click is currently being tracked. - * - * @type boolean - */ - this.trackingClick = false; - - - /** - * Timestamp for when click tracking started. - * - * @type number - */ - this.trackingClickStart = 0; - - - /** - * The element being tracked for a click. - * - * @type EventTarget - */ - this.targetElement = null; - - - /** - * X-coordinate of touch start event. - * - * @type number - */ - this.touchStartX = 0; - - - /** - * Y-coordinate of touch start event. - * - * @type number - */ - this.touchStartY = 0; - - - /** - * ID of the last touch, retrieved from Touch.identifier. - * - * @type number - */ - this.lastTouchIdentifier = 0; - - - /** - * Touchmove boundary, beyond which a click will be cancelled. - * - * @type number - */ - this.touchBoundary = options.touchBoundary || 10; - - - /** - * The FastClick layer. - * - * @type Element - */ - this.layer = layer; - - /** - * The minimum time between tap(touchstart and touchend) events - * - * @type number - */ - this.tapDelay = options.tapDelay || 200; - - /** - * The maximum time for a tap - * - * @type number - */ - this.tapTimeout = options.tapTimeout || 700; - - if (FastClick.notNeeded(layer)) { - return; - } - - // Some old versions of Android don't have Function.prototype.bind - function bind(method, context) { - return function() { return method.apply(context, arguments); }; - } - - - var methods = ['onMouse', 'onClick', 'onTouchStart', 'onTouchMove', 'onTouchEnd', 'onTouchCancel']; - var context = this; - for (var i = 0, l = methods.length; i < l; i++) { - context[methods[i]] = bind(context[methods[i]], context); - } - - // Set up event handlers as required - if (deviceIsAndroid) { - layer.addEventListener('mouseover', this.onMouse, true); - layer.addEventListener('mousedown', this.onMouse, true); - layer.addEventListener('mouseup', this.onMouse, true); - } - - layer.addEventListener('click', this.onClick, true); - layer.addEventListener('touchstart', this.onTouchStart, false); - layer.addEventListener('touchmove', this.onTouchMove, false); - layer.addEventListener('touchend', this.onTouchEnd, false); - layer.addEventListener('touchcancel', this.onTouchCancel, false); - - // Hack is required for browsers that don't support Event#stopImmediatePropagation (e.g. Android 2) - // which is how FastClick normally stops click events bubbling to callbacks registered on the FastClick - // layer when they are cancelled. - if (!Event.prototype.stopImmediatePropagation) { - layer.removeEventListener = function(type, callback, capture) { - var rmv = Node.prototype.removeEventListener; - if (type === 'click') { - rmv.call(layer, type, callback.hijacked || callback, capture); - } else { - rmv.call(layer, type, callback, capture); - } - }; - - layer.addEventListener = function(type, callback, capture) { - var adv = Node.prototype.addEventListener; - if (type === 'click') { - adv.call(layer, type, callback.hijacked || (callback.hijacked = function(event) { - if (!event.propagationStopped) { - callback(event); - } - }), capture); - } else { - adv.call(layer, type, callback, capture); - } - }; - } - - // If a handler is already declared in the element's onclick attribute, it will be fired before - // FastClick's onClick handler. Fix this by pulling out the user-defined handler function and - // adding it as listener. - if (typeof layer.onclick === 'function') { - - // Android browser on at least 3.2 requires a new reference to the function in layer.onclick - // - the old one won't work if passed to addEventListener directly. - oldOnClick = layer.onclick; - layer.addEventListener('click', function(event) { - oldOnClick(event); - }, false); - layer.onclick = null; - } - } - - /** - * Windows Phone 8.1 fakes user agent string to look like Android and iPhone. - * - * @type boolean - */ - var deviceIsWindowsPhone = navigator.userAgent.indexOf("Windows Phone") >= 0; - - /** - * Android requires exceptions. - * - * @type boolean - */ - var deviceIsAndroid = navigator.userAgent.indexOf('Android') > 0 && !deviceIsWindowsPhone; - - - /** - * iOS requires exceptions. - * - * @type boolean - */ - var deviceIsIOS = /iP(ad|hone|od)/.test(navigator.userAgent) && !deviceIsWindowsPhone; - - - /** - * iOS 4 requires an exception for select elements. - * - * @type boolean - */ - var deviceIsIOS4 = deviceIsIOS && (/OS 4_\d(_\d)?/).test(navigator.userAgent); - - - /** - * iOS 6.0-7.* requires the target element to be manually derived - * - * @type boolean - */ - var deviceIsIOSWithBadTarget = deviceIsIOS && (/OS [6-7]_\d/).test(navigator.userAgent); - - /** - * BlackBerry requires exceptions. - * - * @type boolean - */ - var deviceIsBlackBerry10 = navigator.userAgent.indexOf('BB10') > 0; - - /** - * Determine whether a given element requires a native click. - * - * @param {EventTarget|Element} target Target DOM element - * @returns {boolean} Returns true if the element needs a native click - */ - FastClick.prototype.needsClick = function(target) { - switch (target.nodeName.toLowerCase()) { - - // Don't send a synthetic click to disabled inputs (issue #62) - case 'button': - case 'select': - case 'textarea': - if (target.disabled) { - return true; - } - - break; - case 'input': - - // File inputs need real clicks on iOS 6 due to a browser bug (issue #68) - if ((deviceIsIOS && target.type === 'file') || target.disabled) { - return true; - } - - break; - case 'label': - case 'iframe': // iOS8 homescreen apps can prevent events bubbling into frames - case 'video': - return true; - } - - return (/\bneedsclick\b/).test(target.className); - }; - - - /** - * Determine whether a given element requires a call to focus to simulate click into element. - * - * @param {EventTarget|Element} target Target DOM element - * @returns {boolean} Returns true if the element requires a call to focus to simulate native click. - */ - FastClick.prototype.needsFocus = function(target) { - switch (target.nodeName.toLowerCase()) { - case 'textarea': - return true; - case 'select': - return !deviceIsAndroid; - case 'input': - switch (target.type) { - case 'button': - case 'checkbox': - case 'file': - case 'image': - case 'radio': - case 'submit': - return false; - } - - // No point in attempting to focus disabled inputs - return !target.disabled && !target.readOnly; - default: - return (/\bneedsfocus\b/).test(target.className); - } - }; - - - /** - * Send a click event to the specified element. - * - * @param {EventTarget|Element} targetElement - * @param {Event} event - */ - FastClick.prototype.sendClick = function(targetElement, event) { - var clickEvent, touch; - - // On some Android devices activeElement needs to be blurred otherwise the synthetic click will have no effect (#24) - if (document.activeElement && document.activeElement !== targetElement) { - document.activeElement.blur(); - } - - touch = event.changedTouches[0]; - - // Synthesise a click event, with an extra attribute so it can be tracked - clickEvent = document.createEvent('MouseEvents'); - clickEvent.initMouseEvent(this.determineEventType(targetElement), true, true, window, 1, touch.screenX, touch.screenY, touch.clientX, touch.clientY, false, false, false, false, 0, null); - clickEvent.forwardedTouchEvent = true; - targetElement.dispatchEvent(clickEvent); - }; - - FastClick.prototype.determineEventType = function(targetElement) { - - //Issue #159: Android Chrome Select Box does not open with a synthetic click event - if (deviceIsAndroid && targetElement.tagName.toLowerCase() === 'select') { - return 'mousedown'; - } - - return 'click'; - }; - - - /** - * @param {EventTarget|Element} targetElement - */ - FastClick.prototype.focus = function(targetElement) { - var length; - - // Issue #160: on iOS 7, some input elements (e.g. date datetime month) throw a vague TypeError on setSelectionRange. These elements don't have an integer value for the selectionStart and selectionEnd properties, but unfortunately that can't be used for detection because accessing the properties also throws a TypeError. Just check the type instead. Filed as Apple bug #15122724. - if (deviceIsIOS && targetElement.setSelectionRange && targetElement.type.indexOf('date') !== 0 && targetElement.type !== 'time' && targetElement.type !== 'month') { - length = targetElement.value.length; - targetElement.setSelectionRange(length, length); - } else { - targetElement.focus(); - } - }; - - - /** - * Check whether the given target element is a child of a scrollable layer and if so, set a flag on it. - * - * @param {EventTarget|Element} targetElement - */ - FastClick.prototype.updateScrollParent = function(targetElement) { - var scrollParent, parentElement; - - scrollParent = targetElement.fastClickScrollParent; - - // Attempt to discover whether the target element is contained within a scrollable layer. Re-check if the - // target element was moved to another parent. - if (!scrollParent || !scrollParent.contains(targetElement)) { - parentElement = targetElement; - do { - if (parentElement.scrollHeight > parentElement.offsetHeight) { - scrollParent = parentElement; - targetElement.fastClickScrollParent = parentElement; - break; - } - - parentElement = parentElement.parentElement; - } while (parentElement); - } - - // Always update the scroll top tracker if possible. - if (scrollParent) { - scrollParent.fastClickLastScrollTop = scrollParent.scrollTop; - } - }; - - - /** - * @param {EventTarget} targetElement - * @returns {Element|EventTarget} - */ - FastClick.prototype.getTargetElementFromEventTarget = function(eventTarget) { - - // On some older browsers (notably Safari on iOS 4.1 - see issue #56) the event target may be a text node. - if (eventTarget.nodeType === Node.TEXT_NODE) { - return eventTarget.parentNode; - } - - return eventTarget; - }; - - - /** - * On touch start, record the position and scroll offset. - * - * @param {Event} event - * @returns {boolean} - */ - FastClick.prototype.onTouchStart = function(event) { - var targetElement, touch, selection; - - // Ignore multiple touches, otherwise pinch-to-zoom is prevented if both fingers are on the FastClick element (issue #111). - if (event.targetTouches.length > 1) { - return true; - } - - targetElement = this.getTargetElementFromEventTarget(event.target); - touch = event.targetTouches[0]; - - if (deviceIsIOS) { - - // Only trusted events will deselect text on iOS (issue #49) - selection = window.getSelection(); - if (selection.rangeCount && !selection.isCollapsed) { - return true; - } - - if (!deviceIsIOS4) { - - // Weird things happen on iOS when an alert or confirm dialog is opened from a click event callback (issue #23): - // when the user next taps anywhere else on the page, new touchstart and touchend events are dispatched - // with the same identifier as the touch event that previously triggered the click that triggered the alert. - // Sadly, there is an issue on iOS 4 that causes some normal touch events to have the same identifier as an - // immediately preceeding touch event (issue #52), so this fix is unavailable on that platform. - // Issue 120: touch.identifier is 0 when Chrome dev tools 'Emulate touch events' is set with an iOS device UA string, - // which causes all touch events to be ignored. As this block only applies to iOS, and iOS identifiers are always long, - // random integers, it's safe to to continue if the identifier is 0 here. - if (touch.identifier && touch.identifier === this.lastTouchIdentifier) { - event.preventDefault(); - return false; - } - - this.lastTouchIdentifier = touch.identifier; - - // If the target element is a child of a scrollable layer (using -webkit-overflow-scrolling: touch) and: - // 1) the user does a fling scroll on the scrollable layer - // 2) the user stops the fling scroll with another tap - // then the event.target of the last 'touchend' event will be the element that was under the user's finger - // when the fling scroll was started, causing FastClick to send a click event to that layer - unless a check - // is made to ensure that a parent layer was not scrolled before sending a synthetic click (issue #42). - this.updateScrollParent(targetElement); - } - } - - this.trackingClick = true; - this.trackingClickStart = event.timeStamp; - this.targetElement = targetElement; - - this.touchStartX = touch.pageX; - this.touchStartY = touch.pageY; - - // Prevent phantom clicks on fast double-tap (issue #36) - if ((event.timeStamp - this.lastClickTime) < this.tapDelay) { - event.preventDefault(); - } - - return true; - }; - - - /** - * Based on a touchmove event object, check whether the touch has moved past a boundary since it started. - * - * @param {Event} event - * @returns {boolean} - */ - FastClick.prototype.touchHasMoved = function(event) { - var touch = event.changedTouches[0], boundary = this.touchBoundary; - - if (Math.abs(touch.pageX - this.touchStartX) > boundary || Math.abs(touch.pageY - this.touchStartY) > boundary) { - return true; - } - - return false; - }; - - - /** - * Update the last position. - * - * @param {Event} event - * @returns {boolean} - */ - FastClick.prototype.onTouchMove = function(event) { - if (!this.trackingClick) { - return true; - } - - // If the touch has moved, cancel the click tracking - if (this.targetElement !== this.getTargetElementFromEventTarget(event.target) || this.touchHasMoved(event)) { - this.trackingClick = false; - this.targetElement = null; - } - - return true; - }; - - - /** - * Attempt to find the labelled control for the given label element. - * - * @param {EventTarget|HTMLLabelElement} labelElement - * @returns {Element|null} - */ - FastClick.prototype.findControl = function(labelElement) { - - // Fast path for newer browsers supporting the HTML5 control attribute - if (labelElement.control !== undefined) { - return labelElement.control; - } - - // All browsers under test that support touch events also support the HTML5 htmlFor attribute - if (labelElement.htmlFor) { - return document.getElementById(labelElement.htmlFor); - } - - // If no for attribute exists, attempt to retrieve the first labellable descendant element - // the list of which is defined here: http://www.w3.org/TR/html5/forms.html#category-label - return labelElement.querySelector('button, input:not([type=hidden]), keygen, meter, output, progress, select, textarea'); - }; - - - /** - * On touch end, determine whether to send a click event at once. - * - * @param {Event} event - * @returns {boolean} - */ - FastClick.prototype.onTouchEnd = function(event) { - var forElement, trackingClickStart, targetTagName, scrollParent, touch, targetElement = this.targetElement; - - if (!this.trackingClick) { - return true; - } - - // Prevent phantom clicks on fast double-tap (issue #36) - if ((event.timeStamp - this.lastClickTime) < this.tapDelay) { - this.cancelNextClick = true; - return true; - } - - if ((event.timeStamp - this.trackingClickStart) > this.tapTimeout) { - return true; - } - - // Reset to prevent wrong click cancel on input (issue #156). - this.cancelNextClick = false; - - this.lastClickTime = event.timeStamp; - - trackingClickStart = this.trackingClickStart; - this.trackingClick = false; - this.trackingClickStart = 0; - - // On some iOS devices, the targetElement supplied with the event is invalid if the layer - // is performing a transition or scroll, and has to be re-detected manually. Note that - // for this to function correctly, it must be called *after* the event target is checked! - // See issue #57; also filed as rdar://13048589 . - if (deviceIsIOSWithBadTarget) { - touch = event.changedTouches[0]; - - // In certain cases arguments of elementFromPoint can be negative, so prevent setting targetElement to null - targetElement = document.elementFromPoint(touch.pageX - window.pageXOffset, touch.pageY - window.pageYOffset) || targetElement; - targetElement.fastClickScrollParent = this.targetElement.fastClickScrollParent; - } - - targetTagName = targetElement.tagName.toLowerCase(); - if (targetTagName === 'label') { - forElement = this.findControl(targetElement); - if (forElement) { - this.focus(targetElement); - if (deviceIsAndroid) { - return false; - } - - targetElement = forElement; - } - } else if (this.needsFocus(targetElement)) { - - // Case 1: If the touch started a while ago (best guess is 100ms based on tests for issue #36) then focus will be triggered anyway. Return early and unset the target element reference so that the subsequent click will be allowed through. - // Case 2: Without this exception for input elements tapped when the document is contained in an iframe, then any inputted text won't be visible even though the value attribute is updated as the user types (issue #37). - if ((event.timeStamp - trackingClickStart) > 100 || (deviceIsIOS && window.top !== window && targetTagName === 'input')) { - this.targetElement = null; - return false; - } - - this.focus(targetElement); - this.sendClick(targetElement, event); - - // Select elements need the event to go through on iOS 4, otherwise the selector menu won't open. - // Also this breaks opening selects when VoiceOver is active on iOS6, iOS7 (and possibly others) - if (!deviceIsIOS || targetTagName !== 'select') { - this.targetElement = null; - event.preventDefault(); - } - - return false; - } - - if (deviceIsIOS && !deviceIsIOS4) { - - // Don't send a synthetic click event if the target element is contained within a parent layer that was scrolled - // and this tap is being used to stop the scrolling (usually initiated by a fling - issue #42). - scrollParent = targetElement.fastClickScrollParent; - if (scrollParent && scrollParent.fastClickLastScrollTop !== scrollParent.scrollTop) { - return true; - } - } - - // Prevent the actual click from going though - unless the target node is marked as requiring - // real clicks or if it is in the whitelist in which case only non-programmatic clicks are permitted. - if (!this.needsClick(targetElement)) { - event.preventDefault(); - this.sendClick(targetElement, event); - } - - return false; - }; - - - /** - * On touch cancel, stop tracking the click. - * - * @returns {void} - */ - FastClick.prototype.onTouchCancel = function() { - this.trackingClick = false; - this.targetElement = null; - }; - - - /** - * Determine mouse events which should be permitted. - * - * @param {Event} event - * @returns {boolean} - */ - FastClick.prototype.onMouse = function(event) { - - // If a target element was never set (because a touch event was never fired) allow the event - if (!this.targetElement) { - return true; - } - - if (event.forwardedTouchEvent) { - return true; - } - - // Programmatically generated events targeting a specific element should be permitted - if (!event.cancelable) { - return true; - } - - // Derive and check the target element to see whether the mouse event needs to be permitted; - // unless explicitly enabled, prevent non-touch click events from triggering actions, - // to prevent ghost/doubleclicks. - if (!this.needsClick(this.targetElement) || this.cancelNextClick) { - - // Prevent any user-added listeners declared on FastClick element from being fired. - if (event.stopImmediatePropagation) { - event.stopImmediatePropagation(); - } else { - - // Part of the hack for browsers that don't support Event#stopImmediatePropagation (e.g. Android 2) - event.propagationStopped = true; - } - - // Cancel the event - event.stopPropagation(); - event.preventDefault(); - - return false; - } - - // If the mouse event is permitted, return true for the action to go through. - return true; - }; - - - /** - * On actual clicks, determine whether this is a touch-generated click, a click action occurring - * naturally after a delay after a touch (which needs to be cancelled to avoid duplication), or - * an actual click which should be permitted. - * - * @param {Event} event - * @returns {boolean} - */ - FastClick.prototype.onClick = function(event) { - var permitted; - - // It's possible for another FastClick-like library delivered with third-party code to fire a click event before FastClick does (issue #44). In that case, set the click-tracking flag back to false and return early. This will cause onTouchEnd to return early. - if (this.trackingClick) { - this.targetElement = null; - this.trackingClick = false; - return true; - } - - // Very odd behaviour on iOS (issue #18): if a submit element is present inside a form and the user hits enter in the iOS simulator or clicks the Go button on the pop-up OS keyboard the a kind of 'fake' click event will be triggered with the submit-type input element as the target. - if (event.target.type === 'submit' && event.detail === 0) { - return true; - } - - permitted = this.onMouse(event); - - // Only unset targetElement if the click is not permitted. This will ensure that the check for !targetElement in onMouse fails and the browser's click doesn't go through. - if (!permitted) { - this.targetElement = null; - } - - // If clicks are permitted, return true for the action to go through. - return permitted; - }; - - - /** - * Remove all FastClick's event listeners. - * - * @returns {void} - */ - FastClick.prototype.destroy = function() { - var layer = this.layer; - - if (deviceIsAndroid) { - layer.removeEventListener('mouseover', this.onMouse, true); - layer.removeEventListener('mousedown', this.onMouse, true); - layer.removeEventListener('mouseup', this.onMouse, true); - } - - layer.removeEventListener('click', this.onClick, true); - layer.removeEventListener('touchstart', this.onTouchStart, false); - layer.removeEventListener('touchmove', this.onTouchMove, false); - layer.removeEventListener('touchend', this.onTouchEnd, false); - layer.removeEventListener('touchcancel', this.onTouchCancel, false); - }; - - - /** - * Check whether FastClick is needed. - * - * @param {Element} layer The layer to listen on - */ - FastClick.notNeeded = function(layer) { - var metaViewport; - var chromeVersion; - var blackberryVersion; - var firefoxVersion; - - // Devices that don't support touch don't need FastClick - if (typeof window.ontouchstart === 'undefined') { - return true; - } - - // Chrome version - zero for other browsers - chromeVersion = +(/Chrome\/([0-9]+)/.exec(navigator.userAgent) || [,0])[1]; - - if (chromeVersion) { - - if (deviceIsAndroid) { - metaViewport = document.querySelector('meta[name=viewport]'); - - if (metaViewport) { - // Chrome on Android with user-scalable="no" doesn't need FastClick (issue #89) - if (metaViewport.content.indexOf('user-scalable=no') !== -1) { - return true; - } - // Chrome 32 and above with width=device-width or less don't need FastClick - if (chromeVersion > 31 && document.documentElement.scrollWidth <= window.outerWidth) { - return true; - } - } - - // Chrome desktop doesn't need FastClick (issue #15) - } else { - return true; - } - } - - if (deviceIsBlackBerry10) { - blackberryVersion = navigator.userAgent.match(/Version\/([0-9]*)\.([0-9]*)/); - - // BlackBerry 10.3+ does not require Fastclick library. - // https://github.com/ftlabs/fastclick/issues/251 - if (blackberryVersion[1] >= 10 && blackberryVersion[2] >= 3) { - metaViewport = document.querySelector('meta[name=viewport]'); - - if (metaViewport) { - // user-scalable=no eliminates click delay. - if (metaViewport.content.indexOf('user-scalable=no') !== -1) { - return true; - } - // width=device-width (or less than device-width) eliminates click delay. - if (document.documentElement.scrollWidth <= window.outerWidth) { - return true; - } - } - } - } - - // IE10 with -ms-touch-action: none or manipulation, which disables double-tap-to-zoom (issue #97) - if (layer.style.msTouchAction === 'none' || layer.style.touchAction === 'manipulation') { - return true; - } - - // Firefox version - zero for other browsers - firefoxVersion = +(/Firefox\/([0-9]+)/.exec(navigator.userAgent) || [,0])[1]; - - if (firefoxVersion >= 27) { - // Firefox 27+ does not have tap delay if the content is not zoomable - https://bugzilla.mozilla.org/show_bug.cgi?id=922896 - - metaViewport = document.querySelector('meta[name=viewport]'); - if (metaViewport && (metaViewport.content.indexOf('user-scalable=no') !== -1 || document.documentElement.scrollWidth <= window.outerWidth)) { - return true; - } - } - - // IE11: prefixed -ms-touch-action is no longer supported and it's recomended to use non-prefixed version - // http://msdn.microsoft.com/en-us/library/windows/apps/Hh767313.aspx - if (layer.style.touchAction === 'none' || layer.style.touchAction === 'manipulation') { - return true; - } - - return false; - }; - - - /** - * Factory method for creating a FastClick object - * - * @param {Element} layer The layer to listen on - * @param {Object} [options={}] The options to override the defaults - */ - FastClick.attach = function(layer, options) { - return new FastClick(layer, options); - }; - - - if (typeof define === 'function' && typeof define.amd === 'object' && define.amd) { - - // AMD. Register as an anonymous module. - define(function() { - return FastClick; - }); - } else if (typeof module !== 'undefined' && module.exports) { - module.exports = FastClick.attach; - module.exports.FastClick = FastClick; - } else { - window.FastClick = FastClick; - } -}()); diff --git a/assets/javascripts/vendor/mathml.js b/assets/javascripts/vendor/mathml.js index b97829581c..129726201c 100644 --- a/assets/javascripts/vendor/mathml.js +++ b/assets/javascripts/vendor/mathml.js @@ -4,17 +4,22 @@ * Adapted from: https://github.com/fred-wang/mathml.css */ (function () { - window.addEventListener("load", function() { + window.addEventListener("load", function () { var box, div, link, namespaceURI; // First check whether the page contains any element. namespaceURI = "http://www.w3.org/1998/Math/MathML"; // Create a div to test mspace, using Kuma's "offscreen" CSS - document.body.insertAdjacentHTML("afterbegin", "

    "); + document.body.insertAdjacentHTML( + "afterbegin", + "
    ", + ); div = document.body.firstChild; box = div.firstChild.firstChild.getBoundingClientRect(); document.body.removeChild(div); - if (Math.abs(box.height - 23) > 1 || Math.abs(box.width - 77) > 1) { + if (Math.abs(box.height - 23) > 1 || Math.abs(box.width - 77) > 1) { window.supportsMathML = false; } }); -}()); +})(); diff --git a/assets/javascripts/vendor/prism.js b/assets/javascripts/vendor/prism.js index 109beac493..96519c4a8d 100644 --- a/assets/javascripts/vendor/prism.js +++ b/assets/javascripts/vendor/prism.js @@ -1,505 +1,1210 @@ -/* http://prismjs.com/download.html?themes=prism&languages=markup+css+clike+javascript+c+cpp+coffeescript+ruby+elixir+go+json+kotlin+lua+nginx+perl+php+python+crystal+rust+scss+sql+typescript */ +/* PrismJS 1.30.0 +https://prismjs.com/download.html#themes=prism&languages=markup+css+clike+javascript+bash+c+cpp+cmake+coffeescript+crystal+d+dart+diff+django+dot+elixir+erlang+go+groovy+java+json+julia+kotlin+latex+lua+markdown+markup-templating+matlab+nginx+nim+nix+ocaml+perl+php+python+qml+r+jsx+ruby+rust+scss+scala+shell-session+sql+tcl+typescript+yaml+zig */ +/// + var _self = (typeof window !== 'undefined') ? window // if in browser : ( (typeof WorkerGlobalScope !== 'undefined' && self instanceof WorkerGlobalScope) - ? self // if in worker - : {} // if in node js + ? self // if in worker + : {} // if in node js ); /** * Prism: Lightweight, robust, elegant syntax highlighting - * MIT license http://www.opensource.org/licenses/mit-license.php/ - * @author Lea Verou http://lea.verou.me + * + * @license MIT + * @author Lea Verou + * @namespace + * @public */ +var Prism = (function (_self) { -var Prism = (function(){ + // Private helper vars + var lang = /(?:^|\s)lang(?:uage)?-([\w-]+)(?=\s|$)/i; + var uniqueId = 0; -// Private helper vars -var lang = /\blang(?:uage)?-(\w+)\b/i; -var uniqueId = 0; + // The grammar object for plaintext + var plainTextGrammar = {}; -var _ = _self.Prism = { - util: { - encode: function (tokens) { - if (tokens instanceof Token) { - return new Token(tokens.type, _.util.encode(tokens.content), tokens.alias); - } else if (_.util.type(tokens) === 'Array') { - return tokens.map(_.util.encode); - } else { - return tokens.replace(/&/g, '&').replace(/ to load Prism's script + * ``` + * + * @default false + * @type {boolean} + * @memberof Prism + * @public + */ + manual: _self.Prism && _self.Prism.manual, + /** + * By default, if Prism is in a web worker, it assumes that it is in a worker it created itself, so it uses + * `addEventListener` to communicate with its parent instance. However, if you're using Prism manually in your + * own worker, you don't want it to do this. + * + * By setting this value to `true`, Prism will not add its own listeners to the worker. + * + * You obviously have to change this value before Prism executes. To do this, you can add an + * empty Prism object into the global scope before loading the Prism script like this: + * + * ```js + * window.Prism = window.Prism || {}; + * Prism.disableWorkerMessageHandler = true; + * // Load Prism's script + * ``` + * + * @default false + * @type {boolean} + * @memberof Prism + * @public + */ + disableWorkerMessageHandler: _self.Prism && _self.Prism.disableWorkerMessageHandler, - objId: function (obj) { - if (!obj['__id']) { - Object.defineProperty(obj, '__id', { value: ++uniqueId }); - } - return obj['__id']; - }, + /** + * A namespace for utility methods. + * + * All function in this namespace that are not explicitly marked as _public_ are for __internal use only__ and may + * change or disappear at any time. + * + * @namespace + * @memberof Prism + */ + util: { + encode: function encode(tokens) { + if (tokens instanceof Token) { + return new Token(tokens.type, encode(tokens.content), tokens.alias); + } else if (Array.isArray(tokens)) { + return tokens.map(encode); + } else { + return tokens.replace(/&/g, '&').replace(/} [visited] + * @returns {T} + * @template T + */ + clone: function deepClone(o, visited) { + visited = visited || {}; + + var clone; var id; + switch (_.util.type(o)) { + case 'Object': + id = _.util.objId(o); + if (visited[id]) { + return visited[id]; } - } + clone = /** @type {Record} */ ({}); + visited[id] = clone; - return clone; + for (var key in o) { + if (o.hasOwnProperty(key)) { + clone[key] = deepClone(o[key], visited); + } + } - case 'Array': - // Check for existence for IE8 - return o.map && o.map(function(v) { return _.util.clone(v); }); - } + return /** @type {any} */ (clone); - return o; - } - }, + case 'Array': + id = _.util.objId(o); + if (visited[id]) { + return visited[id]; + } + clone = []; + visited[id] = clone; - languages: { - extend: function (id, redef) { - var lang = _.util.clone(_.languages[id]); + (/** @type {Array} */(/** @type {any} */(o))).forEach(function (v, i) { + clone[i] = deepClone(v, visited); + }); - for (var key in redef) { - lang[key] = redef[key]; - } + return /** @type {any} */ (clone); + + default: + return o; + } + }, + + /** + * Returns the Prism language of the given element set by a `language-xxxx` or `lang-xxxx` class. + * + * If no language is set for the element or the element is `null` or `undefined`, `none` will be returned. + * + * @param {Element} element + * @returns {string} + */ + getLanguage: function (element) { + while (element) { + var m = lang.exec(element.className); + if (m) { + return m[1].toLowerCase(); + } + element = element.parentElement; + } + return 'none'; + }, + + /** + * Sets the Prism `language-xxxx` class of the given element. + * + * @param {Element} element + * @param {string} language + * @returns {void} + */ + setLanguage: function (element, language) { + // remove all `language-xxxx` classes + // (this might leave behind a leading space) + element.className = element.className.replace(RegExp(lang, 'gi'), ''); + + // add the new `language-xxxx` class + // (using `classList` will automatically clean up spaces for us) + element.classList.add('language-' + language); + }, + + /** + * Returns the script element that is currently executing. + * + * This does __not__ work for line script element. + * + * @returns {HTMLScriptElement | null} + */ + currentScript: function () { + if (typeof document === 'undefined') { + return null; + } + if (document.currentScript && document.currentScript.tagName === 'SCRIPT' && 1 < 2 /* hack to trip TS' flow analysis */) { + return /** @type {any} */ (document.currentScript); + } - return lang; + // IE11 workaround + // we'll get the src of the current script by parsing IE11's error stack trace + // this will not work for inline scripts + + try { + throw new Error(); + } catch (err) { + // Get file src url from stack. Specifically works with the format of stack traces in IE. + // A stack will look like this: + // + // Error + // at _.util.currentScript (http://localhost/components/prism-core.js:119:5) + // at Global code (http://localhost/components/prism-core.js:606:1) + + var src = (/at [^(\r\n]*\((.*):[^:]+:[^:]+\)$/i.exec(err.stack) || [])[1]; + if (src) { + var scripts = document.getElementsByTagName('script'); + for (var i in scripts) { + if (scripts[i].src == src) { + return scripts[i]; + } + } + } + return null; + } + }, + + /** + * Returns whether a given class is active for `element`. + * + * The class can be activated if `element` or one of its ancestors has the given class and it can be deactivated + * if `element` or one of its ancestors has the negated version of the given class. The _negated version_ of the + * given class is just the given class with a `no-` prefix. + * + * Whether the class is active is determined by the closest ancestor of `element` (where `element` itself is + * closest ancestor) that has the given class or the negated version of it. If neither `element` nor any of its + * ancestors have the given class or the negated version of it, then the default activation will be returned. + * + * In the paradoxical situation where the closest ancestor contains __both__ the given class and the negated + * version of it, the class is considered active. + * + * @param {Element} element + * @param {string} className + * @param {boolean} [defaultActivation=false] + * @returns {boolean} + */ + isActive: function (element, className, defaultActivation) { + var no = 'no-' + className; + + while (element) { + var classList = element.classList; + if (classList.contains(className)) { + return true; + } + if (classList.contains(no)) { + return false; + } + element = element.parentElement; + } + return !!defaultActivation; + } }, /** - * Insert a token before another token in a language literal - * As this needs to recreate the object (we cannot actually insert before keys in object literals), - * we cannot just provide an object, we need anobject and a key. - * @param inside The key (or language id) of the parent - * @param before The key to insert before. If not provided, the function appends instead. - * @param insert Object with the key/value pairs to insert - * @param root The object that contains `inside`. If equal to Prism.languages, it can be omitted. + * This namespace contains all currently loaded languages and the some helper functions to create and modify languages. + * + * @namespace + * @memberof Prism + * @public */ - insertBefore: function (inside, before, insert, root) { - root = root || _.languages; - var grammar = root[inside]; + languages: { + /** + * The grammar for plain, unformatted text. + */ + plain: plainTextGrammar, + plaintext: plainTextGrammar, + text: plainTextGrammar, + txt: plainTextGrammar, + + /** + * Creates a deep copy of the language with the given id and appends the given tokens. + * + * If a token in `redef` also appears in the copied language, then the existing token in the copied language + * will be overwritten at its original position. + * + * ## Best practices + * + * Since the position of overwriting tokens (token in `redef` that overwrite tokens in the copied language) + * doesn't matter, they can technically be in any order. However, this can be confusing to others that trying to + * understand the language definition because, normally, the order of tokens matters in Prism grammars. + * + * Therefore, it is encouraged to order overwriting tokens according to the positions of the overwritten tokens. + * Furthermore, all non-overwriting tokens should be placed after the overwriting ones. + * + * @param {string} id The id of the language to extend. This has to be a key in `Prism.languages`. + * @param {Grammar} redef The new tokens to append. + * @returns {Grammar} The new language created. + * @public + * @example + * Prism.languages['css-with-colors'] = Prism.languages.extend('css', { + * // Prism.languages.css already has a 'comment' token, so this token will overwrite CSS' 'comment' token + * // at its original position + * 'comment': { ... }, + * // CSS doesn't have a 'color' token, so this token will be appended + * 'color': /\b(?:red|green|blue)\b/ + * }); + */ + extend: function (id, redef) { + var lang = _.util.clone(_.languages[id]); + + for (var key in redef) { + lang[key] = redef[key]; + } + + return lang; + }, - if (arguments.length == 2) { - insert = arguments[1]; + /** + * Inserts tokens _before_ another token in a language definition or any other grammar. + * + * ## Usage + * + * This helper method makes it easy to modify existing languages. For example, the CSS language definition + * not only defines CSS highlighting for CSS documents, but also needs to define highlighting for CSS embedded + * in HTML through ` - - - """ - source.replace / +\ +`, + ); + return source.replace(/