Repository: freeCodeCamp/devdocs Branch: main Commit: c8e8f32101dd Files: 1340 Total size: 2.1 MB Directory structure: gitextract_4et9rv65/ ├── .dockerignore ├── .editorconfig ├── .github/ │ ├── CODEOWNERS │ ├── CONTRIBUTING.md │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.md │ │ ├── config.yml │ │ ├── documentation_bug.md │ │ └── feature_request.md │ ├── PULL_REQUEST_TEMPLATE.md │ ├── no-response.yml │ └── workflows/ │ ├── build.yml │ ├── docker-build.yml │ ├── schedule-doc-report.yml │ └── test.yml ├── .gitignore ├── .image_optim.yml ├── .ruby-version ├── .slugignore ├── .tool-versions ├── COPYRIGHT ├── Dockerfile ├── Dockerfile-alpine ├── Gemfile ├── LICENSE ├── Procfile ├── README.md ├── Rakefile ├── Thorfile ├── assets/ │ ├── images/ │ │ └── .gitignore │ ├── javascripts/ │ │ ├── app/ │ │ │ ├── app.js │ │ │ ├── config.js.erb │ │ │ ├── db.js │ │ │ ├── router.js │ │ │ ├── searcher.js │ │ │ ├── serviceworker.js │ │ │ ├── settings.js │ │ │ ├── shortcuts.js │ │ │ └── update_checker.js │ │ ├── application.js │ │ ├── collections/ │ │ │ ├── collection.js │ │ │ ├── docs.js │ │ │ ├── entries.js │ │ │ └── types.js │ │ ├── debug.js │ │ ├── docs.js.erb │ │ ├── lib/ │ │ │ ├── ajax.js │ │ │ ├── cookies_store.js │ │ │ ├── events.js │ │ │ ├── favicon.js │ │ │ ├── license.js │ │ │ ├── local_storage_store.js │ │ │ ├── page.js │ │ │ └── util.js │ │ ├── models/ │ │ │ ├── doc.js │ │ │ ├── entry.js │ │ │ ├── model.js │ │ │ └── type.js │ │ ├── news.json │ │ ├── templates/ │ │ │ ├── base.js │ │ │ ├── error_tmpl.js │ │ │ ├── notice_tmpl.js │ │ │ ├── notif_tmpl.js │ │ │ ├── pages/ │ │ │ │ ├── about_tmpl.js │ │ │ │ ├── help_tmpl.js │ │ │ │ ├── news_tmpl.js.erb │ │ │ │ ├── offline_tmpl.js │ │ │ │ ├── root_tmpl.js.erb │ │ │ │ ├── settings_tmpl.js │ │ │ │ └── type_tmpl.js │ │ │ ├── path_tmpl.js │ │ │ ├── sidebar_tmpl.js │ │ │ └── tip_tmpl.js │ │ ├── tracking.js │ │ ├── vendor/ │ │ │ ├── cookies.js │ │ │ ├── mathml.js │ │ │ ├── prism.js │ │ │ └── raven.js │ │ └── views/ │ │ ├── content/ │ │ │ ├── content.js │ │ │ ├── entry_page.js │ │ │ ├── offline_page.js │ │ │ ├── root_page.js │ │ │ ├── settings_page.js │ │ │ ├── static_page.js │ │ │ └── type_page.js │ │ ├── layout/ │ │ │ ├── document.js │ │ │ ├── menu.js │ │ │ ├── mobile.js │ │ │ ├── path.js │ │ │ ├── resizer.js │ │ │ └── settings.js │ │ ├── list/ │ │ │ ├── list_focus.js │ │ │ ├── list_fold.js │ │ │ ├── list_select.js │ │ │ └── paginated_list.js │ │ ├── misc/ │ │ │ ├── news.js │ │ │ ├── notice.js │ │ │ ├── notif.js │ │ │ ├── tip.js │ │ │ └── updates.js │ │ ├── pages/ │ │ │ ├── base.js │ │ │ ├── hidden.js │ │ │ ├── jquery.js │ │ │ ├── rdoc.js │ │ │ ├── sqlite.js │ │ │ └── support_tables.js │ │ ├── search/ │ │ │ ├── search.js │ │ │ └── search_scope.js │ │ ├── sidebar/ │ │ │ ├── doc_list.js │ │ │ ├── doc_picker.js │ │ │ ├── entry_list.js │ │ │ ├── results.js │ │ │ ├── sidebar.js │ │ │ ├── sidebar_hover.js │ │ │ └── type_list.js │ │ └── view.js │ └── stylesheets/ │ ├── application.css.scss │ ├── components/ │ │ ├── _app.scss │ │ ├── _content.scss │ │ ├── _environment.scss.erb │ │ ├── _fail.scss │ │ ├── _header.scss │ │ ├── _mobile.scss │ │ ├── _notice.scss │ │ ├── _notif.scss │ │ ├── _page.scss │ │ ├── _path.scss │ │ ├── _prism.scss │ │ ├── _settings.scss │ │ └── _sidebar.scss │ ├── global/ │ │ ├── _base.scss │ │ ├── _classes.scss │ │ ├── _icons.scss.erb │ │ ├── _mixins.scss │ │ ├── _print.scss │ │ ├── _variables-dark.scss │ │ ├── _variables-light.scss │ │ └── _variables.scss │ └── pages/ │ ├── _angular.scss │ ├── _angularjs.scss │ ├── _apache.scss │ ├── _async.scss │ ├── _bash.scss │ ├── _bootstrap.scss │ ├── _cakephp.scss │ ├── _chef.scss │ ├── _clojure.scss │ ├── _codeception.scss │ ├── _coffeescript.scss │ ├── _cordova.scss │ ├── _cppref.scss │ ├── _crystal.scss │ ├── _cypress.scss │ ├── _d.scss │ ├── _d3.scss │ ├── _dart.scss │ ├── _dojo.scss │ ├── _drupal.scss │ ├── _eigen3.scss │ ├── _elisp.scss │ ├── _elixir.scss │ ├── _ember.scss │ ├── _erlang.scss │ ├── _express.scss │ ├── _fastapi.scss │ ├── _fluture.scss │ ├── _git.scss │ ├── _github.scss │ ├── _gnu_make.scss │ ├── _gnuplot.scss │ ├── _go.scss │ ├── _graphite.scss │ ├── _groovy.scss │ ├── _gtk.scss │ ├── _hapi.scss │ ├── _haproxy.scss │ ├── _haskell.scss │ ├── _jasmine.scss │ ├── _jekyll.scss │ ├── _joi.scss │ ├── _jq.scss │ ├── _jquery.scss │ ├── _julia.scss │ ├── _knockout.scss │ ├── _kotlin.scss │ ├── _kubectl.scss │ ├── _kubernetes.scss │ ├── _laravel.scss │ ├── _liquid.scss │ ├── _lit.scss │ ├── _love.scss │ ├── _lua.scss │ ├── _mariadb.scss │ ├── _mdn.scss │ ├── _meteor.scss │ ├── _mkdocs.scss │ ├── _modernizr.scss │ ├── _moment.scss │ ├── _nginx.scss │ ├── _node.scss │ ├── _npm.scss │ ├── _nushell.scss │ ├── _octave.scss │ ├── _openjdk.scss │ ├── _openlayers.scss │ ├── _perl.scss │ ├── _phalcon.scss │ ├── _phaser.scss │ ├── _php.scss │ ├── _phpunit.scss │ ├── _postgres.scss │ ├── _pug.scss │ ├── _pygame.scss │ ├── _python.scss │ ├── _qt.scss │ ├── _ramda.scss │ ├── _rdoc.scss │ ├── _react.scss │ ├── _react_native.scss │ ├── _reactivex.scss │ ├── _redis.scss │ ├── _rethinkdb.scss │ ├── _rfc.scss │ ├── _rubydoc.scss │ ├── _rust.scss │ ├── _rxjs.scss │ ├── _sanctuary.scss │ ├── _sanctuary_def.scss │ ├── _sanctuary_type_classes.scss │ ├── _scala.scss │ ├── _simple.scss │ ├── _sinon.scss │ ├── _sphinx.scss │ ├── _sphinx_simple.scss │ ├── _sqlite.scss │ ├── _support_tables.scss │ ├── _tailwindcss.scss │ ├── _tcl_tk.scss │ ├── _tensorflow.scss │ ├── _terraform.scss │ ├── _typescript.scss │ ├── _underscore.scss │ ├── _vue.scss │ ├── _webpack.scss │ ├── _wordpress.scss │ ├── _yard.scss │ └── _yii.scss ├── config.ru ├── docs/ │ ├── adding-docs.md │ ├── file-scrapers.md │ ├── filter-reference.md │ ├── maintainers.md │ └── scraper-reference.md ├── lib/ │ ├── app.rb │ ├── docs/ │ │ ├── core/ │ │ │ ├── autoload_helper.rb │ │ │ ├── doc.rb │ │ │ ├── entry_index.rb │ │ │ ├── filter.rb │ │ │ ├── filter_stack.rb │ │ │ ├── instrumentable.rb │ │ │ ├── manifest.rb │ │ │ ├── models/ │ │ │ │ ├── entry.rb │ │ │ │ └── type.rb │ │ │ ├── page_db.rb │ │ │ ├── parser.rb │ │ │ ├── request.rb │ │ │ ├── requester.rb │ │ │ ├── response.rb │ │ │ ├── scraper.rb │ │ │ ├── scrapers/ │ │ │ │ ├── file_scraper.rb │ │ │ │ └── url_scraper.rb │ │ │ ├── subscriber.rb │ │ │ └── url.rb │ │ ├── filters/ │ │ │ ├── angular/ │ │ │ │ ├── clean_html.rb │ │ │ │ ├── clean_html_v18.rb │ │ │ │ ├── clean_html_v2.rb │ │ │ │ ├── entries.rb │ │ │ │ └── entries_v2.rb │ │ │ ├── angularjs/ │ │ │ │ ├── clean_html.rb │ │ │ │ ├── clean_urls.rb │ │ │ │ └── entries.rb │ │ │ ├── ansible/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── apache/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── apache_pig/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── astro/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── async/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── axios/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── babel/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── backbone/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── bash/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── bazel/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── bluebird/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── bootstrap/ │ │ │ │ ├── clean_html_v3.rb │ │ │ │ ├── clean_html_v4.rb │ │ │ │ ├── clean_html_v5.rb │ │ │ │ ├── entries_v3.rb │ │ │ │ ├── entries_v4.rb │ │ │ │ └── entries_v5.rb │ │ │ ├── bottle/ │ │ │ │ └── entries.rb │ │ │ ├── bower/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── bun/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── c/ │ │ │ │ └── entries.rb │ │ │ ├── cakephp/ │ │ │ │ ├── clean_html.rb │ │ │ │ ├── clean_html_39_plus.rb │ │ │ │ ├── entries.rb │ │ │ │ └── entries_39_plus.rb │ │ │ ├── chai/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── chef/ │ │ │ │ ├── clean_html.rb │ │ │ │ ├── clean_html_old.rb │ │ │ │ ├── entries.rb │ │ │ │ └── entries_old.rb │ │ │ ├── chefclient/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── click/ │ │ │ │ ├── entries.rb │ │ │ │ └── pre_clean_html.rb │ │ │ ├── clojure/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── cmake/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── codeception/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── codeceptjs/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── codeigniter/ │ │ │ │ └── entries.rb │ │ │ ├── coffeescript/ │ │ │ │ ├── clean_html.rb │ │ │ │ ├── clean_html_v1.rb │ │ │ │ ├── entries.rb │ │ │ │ └── entries_v1.rb │ │ │ ├── composer/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── cordova/ │ │ │ │ ├── clean_html.rb │ │ │ │ ├── clean_html_core.rb │ │ │ │ └── entries.rb │ │ │ ├── core/ │ │ │ │ ├── apply_base_url.rb │ │ │ │ ├── attribution.rb │ │ │ │ ├── clean_html.rb │ │ │ │ ├── clean_local_urls.rb │ │ │ │ ├── clean_text.rb │ │ │ │ ├── container.rb │ │ │ │ ├── entries.rb │ │ │ │ ├── images.rb │ │ │ │ ├── inner_html.rb │ │ │ │ ├── internal_urls.rb │ │ │ │ ├── normalize_paths.rb │ │ │ │ ├── normalize_urls.rb │ │ │ │ ├── parse_cf_email.rb │ │ │ │ └── title.rb │ │ │ ├── couchdb/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── cpp/ │ │ │ │ └── entries.rb │ │ │ ├── cppref/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── fix_code.rb │ │ │ ├── crystal/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── css/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── cypress/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── d/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── d3/ │ │ │ │ ├── clean_html.rb │ │ │ │ ├── entries_v3.rb │ │ │ │ └── entries_v4.rb │ │ │ ├── dart/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── deno/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── django/ │ │ │ │ ├── clean_html.rb │ │ │ │ ├── entries.rb │ │ │ │ └── fix_urls.rb │ │ │ ├── django_rest_framework/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── docker/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── dojo/ │ │ │ │ ├── clean_html.rb │ │ │ │ ├── clean_urls.rb │ │ │ │ └── entries.rb │ │ │ ├── dom/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── drupal/ │ │ │ │ ├── clean_html.rb │ │ │ │ ├── entries.rb │ │ │ │ ├── internal_urls.rb │ │ │ │ └── normalize_paths.rb │ │ │ ├── duckdb/ │ │ │ │ ├── attribution.rb │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── eigen3/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── electron/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── elisp/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── elixir/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── ember/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── enzyme/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── erlang/ │ │ │ │ ├── clean_html.rb │ │ │ │ ├── entries.rb │ │ │ │ └── pre_clean_html.rb │ │ │ ├── esbuild/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── eslint/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── express/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── falcon/ │ │ │ │ └── entries.rb │ │ │ ├── fastapi/ │ │ │ │ ├── clean_html.rb │ │ │ │ ├── container.rb │ │ │ │ └── entries.rb │ │ │ ├── fish/ │ │ │ │ ├── clean_html_custom.rb │ │ │ │ ├── clean_html_sphinx.rb │ │ │ │ ├── entries_custom.rb │ │ │ │ └── entries_sphinx.rb │ │ │ ├── flask/ │ │ │ │ └── entries.rb │ │ │ ├── flow/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── fluture/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── gcc/ │ │ │ │ └── clean_html.rb │ │ │ ├── git/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── github/ │ │ │ │ └── clean_html.rb │ │ │ ├── gnu/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── gnu_cobol/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── gnu_make/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── gnuplot/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── go/ │ │ │ │ ├── attribution.rb │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── godot/ │ │ │ │ ├── clean_html.rb │ │ │ │ ├── clean_html_v2.rb │ │ │ │ ├── clean_html_v3.rb │ │ │ │ ├── entries.rb │ │ │ │ ├── entries_v2.rb │ │ │ │ └── entries_v3.rb │ │ │ ├── graphite/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── graphviz/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── groovy/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── grunt/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── gtk/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── hammerspoon/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── handlebars/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── hapi/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── haproxy/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── haskell/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── haxe/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── homebrew/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── html/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── htmx/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── http/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── i3/ │ │ │ │ └── entries.rb │ │ │ ├── immutable/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── influxdata/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── jasmine/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── javascript/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── jekyll/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── jest/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── jinja/ │ │ │ │ └── entries.rb │ │ │ ├── joi/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── jq/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── jquery/ │ │ │ │ └── clean_html.rb │ │ │ ├── jquery_core/ │ │ │ │ └── entries.rb │ │ │ ├── jquery_mobile/ │ │ │ │ └── entries.rb │ │ │ ├── jquery_ui/ │ │ │ │ └── entries.rb │ │ │ ├── jsdoc/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── julia/ │ │ │ │ ├── clean_html.rb │ │ │ │ ├── clean_html_sphinx.rb │ │ │ │ ├── entries.rb │ │ │ │ └── entries_sphinx.rb │ │ │ ├── knockout/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── koa/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── kotlin/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── kubectl/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── kubernetes/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── laravel/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── latex/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── leaflet/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── less/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── liquid/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── lit/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── lodash/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── love/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── lua/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── man/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── mariadb/ │ │ │ │ ├── clean_html.rb │ │ │ │ ├── entries.rb │ │ │ │ └── erase_invalid_pages.rb │ │ │ ├── marionette/ │ │ │ │ ├── clean_html.rb │ │ │ │ ├── entries_v2.rb │ │ │ │ └── entries_v3.rb │ │ │ ├── markdown/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── matplotlib/ │ │ │ │ └── entries.rb │ │ │ ├── mdn/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── compat_tables.rb │ │ │ ├── meteor/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── minitest/ │ │ │ │ └── entries.rb │ │ │ ├── mkdocs/ │ │ │ │ └── clean_html.rb │ │ │ ├── mocha/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── modernizr/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── moment/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── moment_timezone/ │ │ │ │ └── entries.rb │ │ │ ├── mongoose/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── nextjs/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── nginx/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── nginx_lua_module/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── nim/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── nix/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── node/ │ │ │ │ ├── clean_html.rb │ │ │ │ ├── entries.rb │ │ │ │ └── old_entries.rb │ │ │ ├── nokogiri2/ │ │ │ │ └── entries.rb │ │ │ ├── npm/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── numpy/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── nushell/ │ │ │ │ ├── clean_html.rb │ │ │ │ ├── entries.rb │ │ │ │ └── fix_links.rb │ │ │ ├── ocaml/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── octave/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── opengl/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── openjdk/ │ │ │ │ ├── clean_html.rb │ │ │ │ ├── clean_html_new.rb │ │ │ │ ├── clean_urls.rb │ │ │ │ ├── entries.rb │ │ │ │ └── entries_new.rb │ │ │ ├── openlayers/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── opentsdb/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── padrino/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── pandas/ │ │ │ │ ├── clean_html.rb │ │ │ │ ├── clean_html_old.rb │ │ │ │ ├── entries.rb │ │ │ │ └── entries_old.rb │ │ │ ├── perl/ │ │ │ │ ├── clean_html.rb │ │ │ │ ├── entries.rb │ │ │ │ └── pre_clean_html.rb │ │ │ ├── phalcon/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── phaser/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── php/ │ │ │ │ ├── clean_html.rb │ │ │ │ ├── entries.rb │ │ │ │ ├── fix_urls.rb │ │ │ │ └── internal_urls.rb │ │ │ ├── phpunit/ │ │ │ │ ├── clean_html.rb │ │ │ │ ├── clean_html_old.rb │ │ │ │ ├── entries.rb │ │ │ │ └── entries_old.rb │ │ │ ├── playwright/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── point_cloud_library/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── pony/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── postgresql/ │ │ │ │ ├── clean_html.rb │ │ │ │ ├── entries.rb │ │ │ │ ├── extract_metadata.rb │ │ │ │ └── normalize_class_names.rb │ │ │ ├── prettier/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── pug/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── puppeteer/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── pygame/ │ │ │ │ ├── clean_html.rb │ │ │ │ ├── entries.rb │ │ │ │ └── pre_clean_html.rb │ │ │ ├── python/ │ │ │ │ ├── clean_html.rb │ │ │ │ ├── entries_v2.rb │ │ │ │ └── entries_v3.rb │ │ │ ├── pytorch/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── q/ │ │ │ │ └── entries.rb │ │ │ ├── qt/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── qunit/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── r/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── rails/ │ │ │ │ ├── clean_html_guides.rb │ │ │ │ └── entries.rb │ │ │ ├── ramda/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── rdoc/ │ │ │ │ ├── clean_html.rb │ │ │ │ ├── container.rb │ │ │ │ └── entries.rb │ │ │ ├── react/ │ │ │ │ ├── clean_html.rb │ │ │ │ ├── clean_html_react_dev.rb │ │ │ │ ├── entries.rb │ │ │ │ └── entries_react_dev.rb │ │ │ ├── react_bootstrap/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── react_native/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── react_router/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── reactivex/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── redis/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── redux/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── relay/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── requests/ │ │ │ │ └── entries.rb │ │ │ ├── requirejs/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── rethinkdb/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── ruby/ │ │ │ │ └── entries.rb │ │ │ ├── rust/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── rxjs/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── salt_stack/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── sanctuary/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── sanctuary_def/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── sanctuary_type_classes/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── sass/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── scala/ │ │ │ │ ├── clean_html_v2.rb │ │ │ │ ├── clean_html_v3.rb │ │ │ │ ├── entries_v2.rb │ │ │ │ └── entries_v3.rb │ │ │ ├── scikit_image/ │ │ │ │ └── entries.rb │ │ │ ├── scikit_learn/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── sequelize/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── sinon/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── socketio/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── sphinx/ │ │ │ │ └── clean_html.rb │ │ │ ├── spring_boot/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── sqlite/ │ │ │ │ ├── clean_html.rb │ │ │ │ ├── clean_js_tables.rb │ │ │ │ └── entries.rb │ │ │ ├── statsmodels/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── svelte/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── svg/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── symfony/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── tailwindcss/ │ │ │ │ ├── clean_html.rb │ │ │ │ ├── entries.rb │ │ │ │ └── noop.rb │ │ │ ├── tcl_tk/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── tcllib/ │ │ │ │ ├── clean_html.rb │ │ │ │ ├── entries.rb │ │ │ │ └── nop.rb │ │ │ ├── tensorflow/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── terraform/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── threejs/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── trio/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── twig/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── typescript/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── underscore/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── vagrant/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── varnish/ │ │ │ │ └── entries.rb │ │ │ ├── vertx/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── vite/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── vitest/ │ │ │ │ └── entries.rb │ │ │ ├── vue/ │ │ │ │ ├── clean_html.rb │ │ │ │ ├── entries.rb │ │ │ │ └── entries_v3.rb │ │ │ ├── vue_router/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── vueuse/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── vuex/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── vulkan/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── wagtail/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── web_extensions/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── webpack/ │ │ │ │ ├── clean_html.rb │ │ │ │ ├── clean_html_old.rb │ │ │ │ ├── entries.rb │ │ │ │ └── entries_old.rb │ │ │ ├── werkzeug/ │ │ │ │ └── entries.rb │ │ │ ├── wordpress/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── xslt_xpath/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ ├── yarn/ │ │ │ │ ├── clean_html.rb │ │ │ │ ├── clean_html_berry.rb │ │ │ │ ├── entries.rb │ │ │ │ └── entries_berry.rb │ │ │ ├── yii/ │ │ │ │ ├── clean_html_v1.rb │ │ │ │ ├── clean_html_v2.rb │ │ │ │ ├── entries_v1.rb │ │ │ │ └── entries_v2.rb │ │ │ ├── zig/ │ │ │ │ ├── clean_html.rb │ │ │ │ └── entries.rb │ │ │ └── zsh/ │ │ │ ├── clean_html.rb │ │ │ └── entries.rb │ │ ├── scrapers/ │ │ │ ├── angular.rb │ │ │ ├── angularjs.rb │ │ │ ├── ansible.rb │ │ │ ├── apache.rb │ │ │ ├── apache_pig.rb │ │ │ ├── astro.rb │ │ │ ├── async.rb │ │ │ ├── axios.rb │ │ │ ├── babel.rb │ │ │ ├── backbone.rb │ │ │ ├── bash.rb │ │ │ ├── bazel.rb │ │ │ ├── bluebird.rb │ │ │ ├── bootstrap.rb │ │ │ ├── bottle.rb │ │ │ ├── bower.rb │ │ │ ├── bun.rb │ │ │ ├── cakephp.rb │ │ │ ├── chai.rb │ │ │ ├── chef.rb │ │ │ ├── click.rb │ │ │ ├── clojure.rb │ │ │ ├── cmake.rb │ │ │ ├── codeception.rb │ │ │ ├── codeceptjs.rb │ │ │ ├── codeigniter.rb │ │ │ ├── coffeescript.rb │ │ │ ├── composer.rb │ │ │ ├── cordova.rb │ │ │ ├── couchdb.rb │ │ │ ├── cppref/ │ │ │ │ ├── c.rb │ │ │ │ ├── cpp.rb │ │ │ │ └── cppref.rb │ │ │ ├── crystal.rb │ │ │ ├── cypress.rb │ │ │ ├── d.rb │ │ │ ├── d3.rb │ │ │ ├── dart.rb │ │ │ ├── date_fns.rb │ │ │ ├── deno.rb │ │ │ ├── django.rb │ │ │ ├── docker.rb │ │ │ ├── dojo.rb │ │ │ ├── drupal.rb │ │ │ ├── duckdb.rb │ │ │ ├── eigen3.rb │ │ │ ├── electron.rb │ │ │ ├── elisp.rb │ │ │ ├── elixir.rb │ │ │ ├── ember.rb │ │ │ ├── enzyme.rb │ │ │ ├── erlang.rb │ │ │ ├── es_toolkit.rb │ │ │ ├── esbuild.rb │ │ │ ├── eslint.rb │ │ │ ├── express.rb │ │ │ ├── falcon.rb │ │ │ ├── fastapi.rb │ │ │ ├── fish.rb │ │ │ ├── flask.rb │ │ │ ├── flow.rb │ │ │ ├── fluture.rb │ │ │ ├── git.rb │ │ │ ├── github.rb │ │ │ ├── gnu/ │ │ │ │ ├── gcc.rb │ │ │ │ └── gnu_fortran.rb │ │ │ ├── gnu.rb │ │ │ ├── gnu_cobol.rb │ │ │ ├── gnu_make.rb │ │ │ ├── gnuplot.rb │ │ │ ├── go.rb │ │ │ ├── godot.rb │ │ │ ├── graphite.rb │ │ │ ├── graphviz.rb │ │ │ ├── groovy.rb │ │ │ ├── grunt.rb │ │ │ ├── gtk.rb │ │ │ ├── hammerspoon.rb │ │ │ ├── handlebars.rb │ │ │ ├── hapi.rb │ │ │ ├── haproxy.rb │ │ │ ├── haskell.rb │ │ │ ├── haxe.rb │ │ │ ├── homebrew.rb │ │ │ ├── htmx.rb │ │ │ ├── http.rb │ │ │ ├── i3.rb │ │ │ ├── immutable.rb │ │ │ ├── influxdata.rb │ │ │ ├── jasmine.rb │ │ │ ├── jekyll.rb │ │ │ ├── jest.rb │ │ │ ├── jinja.rb │ │ │ ├── joi.rb │ │ │ ├── jq.rb │ │ │ ├── jquery/ │ │ │ │ ├── jquery.rb │ │ │ │ ├── jquery_core.rb │ │ │ │ ├── jquery_mobile.rb │ │ │ │ └── jquery_ui.rb │ │ │ ├── jsdoc.rb │ │ │ ├── julia.rb │ │ │ ├── knockout.rb │ │ │ ├── koa.rb │ │ │ ├── kotlin.rb │ │ │ ├── kubectl.rb │ │ │ ├── kubernetes.rb │ │ │ ├── laravel.rb │ │ │ ├── latex.rb │ │ │ ├── leaflet.rb │ │ │ ├── less.rb │ │ │ ├── liquid.rb │ │ │ ├── lit.rb │ │ │ ├── lodash.rb │ │ │ ├── love.rb │ │ │ ├── lua.rb │ │ │ ├── man.rb │ │ │ ├── mariadb.rb │ │ │ ├── marionette.rb │ │ │ ├── markdown.rb │ │ │ ├── matplotlib.rb │ │ │ ├── mdn/ │ │ │ │ ├── css.rb │ │ │ │ ├── dom.rb │ │ │ │ ├── html.rb │ │ │ │ ├── javascript.rb │ │ │ │ ├── mdn.rb │ │ │ │ ├── svg.rb │ │ │ │ ├── web_extensions.rb │ │ │ │ └── xslt_xpath.rb │ │ │ ├── meteor.rb │ │ │ ├── mkdocs/ │ │ │ │ ├── django_rest_framework.rb │ │ │ │ └── mkdocs.rb │ │ │ ├── mocha.rb │ │ │ ├── modernizr.rb │ │ │ ├── moment.rb │ │ │ ├── moment_timezone.rb │ │ │ ├── mongoose.rb │ │ │ ├── nextjs.rb │ │ │ ├── nginx.rb │ │ │ ├── nginx_lua_module.rb │ │ │ ├── nim.rb │ │ │ ├── nix.rb │ │ │ ├── node.rb │ │ │ ├── nokogiri2.rb │ │ │ ├── npm.rb │ │ │ ├── numpy.rb │ │ │ ├── nushell.rb │ │ │ ├── ocaml.rb │ │ │ ├── octave.rb │ │ │ ├── opengl.rb │ │ │ ├── openjdk.rb │ │ │ ├── openlayers.rb │ │ │ ├── opentsdb.rb │ │ │ ├── padrino.rb │ │ │ ├── pandas.rb │ │ │ ├── perl.rb │ │ │ ├── phalcon.rb │ │ │ ├── phaser.rb │ │ │ ├── phoenix.rb │ │ │ ├── php.rb │ │ │ ├── phpunit.rb │ │ │ ├── playwright.rb │ │ │ ├── point_cloud_library.rb │ │ │ ├── pony.rb │ │ │ ├── postgresql.rb │ │ │ ├── prettier.rb │ │ │ ├── pug.rb │ │ │ ├── puppeteer.rb │ │ │ ├── pygame.rb │ │ │ ├── python.rb │ │ │ ├── pytorch.rb │ │ │ ├── q.rb │ │ │ ├── qt.rb │ │ │ ├── qunit.rb │ │ │ ├── r.rb │ │ │ ├── ramda.rb │ │ │ ├── rdoc/ │ │ │ │ ├── minitest.rb │ │ │ │ ├── rails.rb │ │ │ │ ├── rdoc.rb │ │ │ │ └── ruby.rb │ │ │ ├── react.rb │ │ │ ├── react_bootstrap.rb │ │ │ ├── react_native.rb │ │ │ ├── react_router.rb │ │ │ ├── reactivex.rb │ │ │ ├── redis.rb │ │ │ ├── redux.rb │ │ │ ├── relay.rb │ │ │ ├── requests.rb │ │ │ ├── requirejs.rb │ │ │ ├── rethinkdb.rb │ │ │ ├── rust.rb │ │ │ ├── rxjs.rb │ │ │ ├── salt_stack.rb │ │ │ ├── sanctuary.rb │ │ │ ├── sanctuary_def.rb │ │ │ ├── sanctuary_type_classes.rb │ │ │ ├── sass.rb │ │ │ ├── scala.rb │ │ │ ├── scikit_image.rb │ │ │ ├── scikit_learn.rb │ │ │ ├── sequelize.rb │ │ │ ├── sinon.rb │ │ │ ├── socketio.rb │ │ │ ├── sphinx.rb │ │ │ ├── spring_boot.rb │ │ │ ├── sqlite.rb │ │ │ ├── statsmodels.rb │ │ │ ├── support_tables.rb │ │ │ ├── svelte.rb │ │ │ ├── symfony.rb │ │ │ ├── tailwindcss.rb │ │ │ ├── tcl_tk.rb │ │ │ ├── tcllib.rb │ │ │ ├── tensorflow/ │ │ │ │ ├── tensorflow.rb │ │ │ │ └── tensorflow_cpp.rb │ │ │ ├── terraform.rb │ │ │ ├── threejs.rb │ │ │ ├── trio.rb │ │ │ ├── twig.rb │ │ │ ├── typescript.rb │ │ │ ├── underscore.rb │ │ │ ├── vagrant.rb │ │ │ ├── varnish.rb │ │ │ ├── vertx.rb │ │ │ ├── vite.rb │ │ │ ├── vitest.rb │ │ │ ├── vue.rb │ │ │ ├── vue_router.rb │ │ │ ├── vueuse.rb │ │ │ ├── vuex.rb │ │ │ ├── vulkan.rb │ │ │ ├── wagtail.rb │ │ │ ├── webpack.rb │ │ │ ├── werkzeug.rb │ │ │ ├── wordpress.rb │ │ │ ├── yarn.rb │ │ │ ├── yii.rb │ │ │ ├── zig.rb │ │ │ └── zsh.rb │ │ ├── storage/ │ │ │ ├── abstract_store.rb │ │ │ ├── file_store.rb │ │ │ └── null_store.rb │ │ └── subscribers/ │ │ ├── doc_subscriber.rb │ │ ├── filter_subscriber.rb │ │ ├── image_subscriber.rb │ │ ├── progress_bar_subscriber.rb │ │ ├── request_subscriber.rb │ │ ├── requester_subscriber.rb │ │ ├── scraper_subscriber.rb │ │ └── store_subscriber.rb │ ├── docs.rb │ └── tasks/ │ ├── assets.thor │ ├── console.thor │ ├── docs.thor │ ├── sprites.thor │ ├── test.thor │ └── updates.thor ├── newrelic.yml ├── public/ │ ├── 404.html │ ├── 500.html │ ├── favicon.pxm │ ├── favicon@2x.pxm │ ├── icons/ │ │ └── docs/ │ │ ├── angular/ │ │ │ └── SOURCE │ │ ├── angularjs/ │ │ │ └── SOURCE │ │ ├── ansible/ │ │ │ └── SOURCE │ │ ├── apache_http_server/ │ │ │ └── SOURCE │ │ ├── astro/ │ │ │ └── SOURCE │ │ ├── async/ │ │ │ └── SOURCE │ │ ├── axios/ │ │ │ └── SOURCE │ │ ├── babel/ │ │ │ └── SOURCE │ │ ├── backbone/ │ │ │ └── SOURCE │ │ ├── bash/ │ │ │ └── SOURCE │ │ ├── bazel/ │ │ │ └── SOURCE │ │ ├── bluebird/ │ │ │ └── SOURCE │ │ ├── bootstrap/ │ │ │ └── SOURCE │ │ ├── bottle/ │ │ │ └── SOURCE │ │ ├── bower/ │ │ │ └── SOURCE │ │ ├── bun/ │ │ │ └── SOURCE │ │ ├── c/ │ │ │ └── SOURCE │ │ ├── cakephp/ │ │ │ └── SOURCE │ │ ├── chai/ │ │ │ └── SOURCE │ │ ├── chef/ │ │ │ └── SOURCE │ │ ├── click/ │ │ │ └── SOURCE │ │ ├── clojure/ │ │ │ └── SOURCE │ │ ├── cmake/ │ │ │ └── SOURCE │ │ ├── codeception/ │ │ │ └── SOURCE │ │ ├── codeceptjs/ │ │ │ └── SOURCE │ │ ├── codeigniter/ │ │ │ └── SOURCE │ │ ├── coffeescript/ │ │ │ └── SOURCE │ │ ├── composer/ │ │ │ └── SOURCE │ │ ├── cordova/ │ │ │ └── SOURCE │ │ ├── couchdb/ │ │ │ └── SOURCE │ │ ├── cpp/ │ │ │ └── SOURCE │ │ ├── crystal/ │ │ │ └── SOURCE │ │ ├── css/ │ │ │ └── SOURCE │ │ ├── cypress/ │ │ │ └── SOURCE │ │ ├── d/ │ │ │ └── SOURCE │ │ ├── d3/ │ │ │ └── SOURCE │ │ ├── dart/ │ │ │ └── SOURCE │ │ ├── date_fns/ │ │ │ └── SOURCE │ │ ├── deno/ │ │ │ └── SOURCE │ │ ├── django/ │ │ │ └── SOURCE │ │ ├── django_rest_framework/ │ │ │ └── SOURCE │ │ ├── docker/ │ │ │ └── SOURCE │ │ ├── dom/ │ │ │ └── DOM.sketch/ │ │ │ ├── Data │ │ │ ├── fonts │ │ │ └── version │ │ ├── dom_events/ │ │ │ └── DOM_events.sketch/ │ │ │ ├── Data │ │ │ ├── fonts │ │ │ └── version │ │ ├── drupal/ │ │ │ └── SOURCE │ │ ├── duckdb/ │ │ │ └── SOURCE │ │ ├── eigen3/ │ │ │ └── SOURCE │ │ ├── electron/ │ │ │ └── SOURCE │ │ ├── elisp/ │ │ │ └── SOURCE │ │ ├── elixir/ │ │ │ └── SOURCE │ │ ├── ember/ │ │ │ └── SOURCE │ │ ├── erlang/ │ │ │ └── SOURCE │ │ ├── es_toolkit/ │ │ │ └── SOURCE │ │ ├── esbuild/ │ │ │ └── SOURCE │ │ ├── eslint/ │ │ │ └── SOURCE │ │ ├── express/ │ │ │ ├── 16.pxm │ │ │ └── 16@2x.pxm │ │ ├── falcon/ │ │ │ └── SOURCE │ │ ├── fastapi/ │ │ │ └── SOURCE │ │ ├── fish/ │ │ │ └── SOURCE │ │ ├── flask/ │ │ │ └── SOURCE │ │ ├── flow/ │ │ │ └── SOURCE │ │ ├── fluture/ │ │ │ └── SOURCE │ │ ├── git/ │ │ │ └── SOURCE │ │ ├── gnu_cobol/ │ │ │ └── SOURCE │ │ ├── gnu_fortran/ │ │ │ └── SOURCE │ │ ├── gnu_make/ │ │ │ └── SOURCE │ │ ├── gnuplot/ │ │ │ └── SOURCE │ │ ├── go/ │ │ │ └── SOURCE │ │ ├── godot/ │ │ │ └── SOURCE │ │ ├── graphviz/ │ │ │ └── SOURCE │ │ ├── groovy/ │ │ │ └── SOURCE │ │ ├── grunt/ │ │ │ └── SOURCE │ │ ├── gtk/ │ │ │ └── SOURCE │ │ ├── hammerspoon/ │ │ │ └── SOURCE │ │ ├── handlebars/ │ │ │ └── SOURCE │ │ ├── hapi/ │ │ │ └── SOURCE │ │ ├── haproxy/ │ │ │ └── SOURCE │ │ ├── haskell/ │ │ │ └── SOURCE │ │ ├── haxe/ │ │ │ └── SOURCE │ │ ├── homebrew/ │ │ │ └── SOURCE │ │ ├── html/ │ │ │ ├── HTML5.sketch/ │ │ │ │ ├── Data │ │ │ │ ├── fonts │ │ │ │ └── version │ │ │ └── SOURCE │ │ ├── htmx/ │ │ │ └── SOURCE │ │ ├── http/ │ │ │ ├── 16.pxm │ │ │ ├── 16@2x.pxm │ │ │ └── SOURCE │ │ ├── i3/ │ │ │ └── SOURCE │ │ ├── immutable/ │ │ │ └── SOURCE │ │ ├── jasmine/ │ │ │ └── SOURCE │ │ ├── javascript/ │ │ │ ├── 16.pxm │ │ │ ├── 16@2x.pxm │ │ │ └── SOURCE │ │ ├── jekyll/ │ │ │ └── SOURCE │ │ ├── jest/ │ │ │ └── SOURCE │ │ ├── jinja/ │ │ │ └── SOURCE │ │ ├── joi/ │ │ │ └── SOURCE │ │ ├── jq/ │ │ │ └── SOURCE │ │ ├── jquery/ │ │ │ └── SOURCE │ │ ├── jquerymobile/ │ │ │ └── SOURCE │ │ ├── jqueryui/ │ │ │ └── SOURCE │ │ ├── julia/ │ │ │ └── SOURCE │ │ ├── knockout/ │ │ │ ├── 16@2x.pxm │ │ │ └── SOURCE │ │ ├── kotlin/ │ │ │ └── SOURCE │ │ ├── kubectl/ │ │ │ └── SOURCE │ │ ├── kubernetes/ │ │ │ └── SOURCE │ │ ├── laravel/ │ │ │ └── SOURCE │ │ ├── latex/ │ │ │ └── SOURCE │ │ ├── leaflet/ │ │ │ └── SOURCE │ │ ├── less/ │ │ │ └── less.pxm │ │ ├── lit/ │ │ │ └── SOURCE │ │ ├── lodash/ │ │ │ └── SOURCE │ │ ├── love/ │ │ │ └── SOURCE │ │ ├── lua/ │ │ │ └── SOURCE │ │ ├── man/ │ │ │ └── SOURCE │ │ ├── mariadb/ │ │ │ └── SOURCE │ │ ├── marionette/ │ │ │ └── SOURCE │ │ ├── markdown/ │ │ │ └── SOURCE │ │ ├── matplotlib/ │ │ │ └── SOURCE │ │ ├── meteor/ │ │ │ └── SOURCE │ │ ├── minitest/ │ │ │ └── SOURCE │ │ ├── mocha/ │ │ │ └── SOURCE │ │ ├── modernizr/ │ │ │ └── SOURCE │ │ ├── moment/ │ │ │ └── moment.sketch/ │ │ │ ├── Data │ │ │ ├── fonts │ │ │ ├── metadata │ │ │ └── version │ │ ├── moment_timezone/ │ │ │ └── SOURCE │ │ ├── nextjs/ │ │ │ └── SOURCE │ │ ├── nginx/ │ │ │ └── SOURCE │ │ ├── nim/ │ │ │ └── SOURCE │ │ ├── nix/ │ │ │ └── SOURCE │ │ ├── node/ │ │ │ └── SOURCE │ │ ├── nokogiri/ │ │ │ └── icon.pxm │ │ ├── npm/ │ │ │ └── SOURCE │ │ ├── numpy/ │ │ │ └── SOURCE │ │ ├── nushell/ │ │ │ └── SOURCE │ │ ├── ocaml/ │ │ │ └── SOURCE │ │ ├── octave/ │ │ │ └── SOURCE │ │ ├── opengl/ │ │ │ └── SOURCE │ │ ├── openlayers/ │ │ │ └── SOURCE │ │ ├── padrino/ │ │ │ └── SOURCE │ │ ├── pandas/ │ │ │ └── SOURCE │ │ ├── perl/ │ │ │ └── SOURCE │ │ ├── phalcon/ │ │ │ └── SOURCE │ │ ├── phaser/ │ │ │ └── SOURCE │ │ ├── phoenix/ │ │ │ └── SOURCE │ │ ├── php/ │ │ │ ├── 16@2x.pxm │ │ │ └── SOURCE │ │ ├── phpunit/ │ │ │ └── SOURCE │ │ ├── playwright/ │ │ │ └── SOURCE │ │ ├── point_cloud_library/ │ │ │ └── SOURCE │ │ ├── pony/ │ │ │ └── SOURCE │ │ ├── postgresql/ │ │ │ └── SOURCE │ │ ├── prettier/ │ │ │ └── SOURCE │ │ ├── pug/ │ │ │ └── SOURCE │ │ ├── puppeteer/ │ │ │ └── SOURCE │ │ ├── pygame/ │ │ │ └── SOURCE │ │ ├── python/ │ │ │ └── SOURCE │ │ ├── pytorch/ │ │ │ └── SOURCE │ │ ├── q/ │ │ │ └── SOURCE │ │ ├── qt/ │ │ │ └── SOURCE │ │ ├── qunit/ │ │ │ └── SOURCE │ │ ├── r/ │ │ │ └── SOURCE │ │ ├── rails/ │ │ │ ├── SOURCE │ │ │ └── rails.pxm │ │ ├── ramda/ │ │ │ └── SOURCE │ │ ├── react/ │ │ │ └── SOURCE │ │ ├── react_bootstrap/ │ │ │ └── SOURCE │ │ ├── react_native/ │ │ │ └── SOURCE │ │ ├── react_router/ │ │ │ └── SOURCE │ │ ├── reactivex/ │ │ │ └── SOURCE │ │ ├── redis/ │ │ │ └── SOURCE │ │ ├── redux/ │ │ │ └── SOURCE │ │ ├── requests/ │ │ │ └── SOURCE │ │ ├── requirejs/ │ │ │ └── SOURCE │ │ ├── rethinkdb/ │ │ │ └── SOURCE │ │ ├── ruby/ │ │ │ └── SOURCE │ │ ├── rust/ │ │ │ └── SOURCE │ │ ├── rxjs/ │ │ │ └── SOURCE │ │ ├── saltstack/ │ │ │ └── SOURCE │ │ ├── sanctuary/ │ │ │ └── SOURCE │ │ ├── sanctuary_def/ │ │ │ └── SOURCE │ │ ├── sanctuary_type_classes/ │ │ │ └── SOURCE │ │ ├── sass/ │ │ │ ├── SOURCE │ │ │ └── sass.pxm │ │ ├── scikit_image/ │ │ │ └── SOURCE │ │ ├── scikit_learn/ │ │ │ └── SOURCE │ │ ├── sequelize/ │ │ │ └── SOURCE │ │ ├── sinon/ │ │ │ └── SOURCE │ │ ├── socketio/ │ │ │ └── SOURCE │ │ ├── spring_boot/ │ │ │ └── SOURCE │ │ ├── sqlite/ │ │ │ └── SOURCE │ │ ├── statsmodels/ │ │ │ └── SOURCE │ │ ├── support_tables/ │ │ │ └── SOURCE │ │ ├── svelte/ │ │ │ └── SOURCE │ │ ├── svg/ │ │ │ └── SOURCE │ │ ├── symfony/ │ │ │ └── SOURCE │ │ ├── tailwindcss/ │ │ │ └── SOURCE │ │ ├── tcl_tk/ │ │ │ └── SOURCE │ │ ├── tcllib/ │ │ │ └── SOURCE │ │ ├── tensorflow/ │ │ │ └── SOURCE │ │ ├── tensorflow_cpp/ │ │ │ └── SOURCE │ │ ├── terraform/ │ │ │ └── SOURCE │ │ ├── threejs/ │ │ │ └── SOURCE │ │ ├── trio/ │ │ │ └── SOURCE │ │ ├── twig/ │ │ │ └── SOURCE │ │ ├── typescript/ │ │ │ └── SOURCE │ │ ├── underscore/ │ │ │ └── SOURCE │ │ ├── vagrant/ │ │ │ └── SOURCE │ │ ├── varnish/ │ │ │ └── SOURCE │ │ ├── vertx/ │ │ │ └── SOURCE │ │ ├── vite/ │ │ │ └── SOURCE │ │ ├── vitest/ │ │ │ └── SOURCE │ │ ├── vue/ │ │ │ └── SOURCE │ │ ├── vue_router/ │ │ │ └── SOURCE │ │ ├── vueuse/ │ │ │ └── SOURCE │ │ ├── vuex/ │ │ │ └── SOURCE │ │ ├── vulkan/ │ │ │ └── SOURCE │ │ ├── wagtail/ │ │ │ └── SOURCE │ │ ├── webpack/ │ │ │ └── SOURCE │ │ ├── werkzeug/ │ │ │ └── SOURCE │ │ ├── wordpress/ │ │ │ └── SOURCE │ │ ├── xpath/ │ │ │ └── XPath.sketch/ │ │ │ ├── Data │ │ │ ├── metadata │ │ │ └── version │ │ ├── yarn/ │ │ │ └── SOURCE │ │ ├── yii/ │ │ │ └── SOURCE │ │ ├── zig/ │ │ │ └── SOURCE │ │ └── zsh/ │ │ └── SOURCE │ ├── images/ │ │ ├── apple-icon.pxm │ │ ├── fluid-icon.pxm │ │ ├── icon.pxm │ │ └── webapp-icon-small.pxm │ ├── manifest.json │ ├── mathml.css │ ├── opensearch.xml │ └── robots.txt ├── renovate.json ├── techstack.md ├── techstack.yml ├── test/ │ ├── app_test.rb │ ├── files/ │ │ └── docs.json │ ├── lib/ │ │ └── docs/ │ │ ├── core/ │ │ │ ├── doc_test.rb │ │ │ ├── entry_index_test.rb │ │ │ ├── filter_test.rb │ │ │ ├── instrumentable_test.rb │ │ │ ├── manifest_test.rb │ │ │ ├── models/ │ │ │ │ ├── entry_test.rb │ │ │ │ └── type_test.rb │ │ │ ├── parser_test.rb │ │ │ ├── request_test.rb │ │ │ ├── requester_test.rb │ │ │ ├── response_test.rb │ │ │ ├── scraper_test.rb │ │ │ ├── scrapers/ │ │ │ │ ├── file_scraper_test.rb │ │ │ │ └── url_scraper_test.rb │ │ │ └── url_test.rb │ │ ├── filters/ │ │ │ └── core/ │ │ │ ├── apply_base_url_test.rb │ │ │ ├── clean_html_test.rb │ │ │ ├── clean_text_test.rb │ │ │ ├── container_test.rb │ │ │ ├── entries_test.rb │ │ │ ├── inner_html_test.rb │ │ │ ├── internal_urls_test.rb │ │ │ ├── normalize_paths_test.rb │ │ │ ├── normalize_urls_test.rb │ │ │ ├── parse_cf_email_test.rb │ │ │ └── title_test.rb │ │ └── storage/ │ │ ├── abstract_store_test.rb │ │ └── file_store_test.rb │ ├── support/ │ │ ├── fake_instrumentation.rb │ │ └── filter_test_helper.rb │ └── test_helper.rb └── views/ ├── app.erb ├── index.erb ├── other.erb ├── service-worker.js.erb └── unsupported.erb ================================================ FILE CONTENTS ================================================ ================================================ FILE: .dockerignore ================================================ .git test Dockerfile* .gitignore .dockerignore .travis.yml *.md ================================================ FILE: .editorconfig ================================================ root = true [*] charset = utf-8 indent_style = space indent_size = 2 insert_final_newline = true trim_trailing_whitespace = true [*.md] trim_trailing_whitespace = false ================================================ FILE: .github/CODEOWNERS ================================================ # 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 ================================================ FILE: .github/CONTRIBUTING.md ================================================ # 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). ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.md ================================================ --- 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 ================================================ FILE: .github/ISSUE_TEMPLATE/config.yml ================================================ 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" ================================================ FILE: .github/ISSUE_TEMPLATE/documentation_bug.md ================================================ --- name: Documentation bug about: Report a problem with a specific documentation title: '' labels: 'docs/improvement' assignees: '' --- # Documentation style bug ## Summary ## Actual style ## Expected style ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.md ================================================ --- name: Feature request about: Suggest a new feature title: '' labels: 'feature' assignees: '' --- # Feature request ## Summary ## Examples ================================================ FILE: .github/PULL_REQUEST_TEMPLATE.md ================================================ 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 ================================================ FILE: .github/no-response.yml ================================================ daysUntilClose: 30 responseRequiredLabel: needs-info closeComment: > This issue has been automatically closed because there has been no response to our request for more information from the original author. With only the information that’s currently in the issue, we don’t have enough information to take action. Please comment if you have or find the answer we need so we can investigate further. ================================================ FILE: .github/workflows/build.yml ================================================ name: Deploy on: push: branches: - main jobs: deploy: name: Deploy to Heroku runs-on: ubuntu-24.04 if: github.repository == 'freeCodeCamp/devdocs' steps: - uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2.7.0 - name: Set up Ruby uses: ruby/setup-ruby@09a7688d3b55cf0e976497ff046b70949eeaccfd # v1.288.0 with: bundler-cache: true # runs 'bundle install' and caches installed gems automatically - name: Run tests run: bundle exec rake - name: Install Heroku CLI run: | curl https://cli-assets.heroku.com/install.sh | sh - name: Deploy to Heroku uses: akhileshns/heroku-deploy@e3eb99d45a8e2ec5dca08735e089607befa4bf28 # v3.14.15 with: heroku_api_key: ${{secrets.HEROKU_API_KEY}} heroku_app_name: "devdocs" heroku_email: "team@freecodecamp.com" dontuseforce: true # --force should never be necessary dontautocreate: true # The app exists, it should not be created ================================================ FILE: .github/workflows/docker-build.yml ================================================ name: Build and Push Docker Images on: schedule: - cron: '0 0 1 * *' # Run monthly on the 1st workflow_dispatch: # Allow manual triggers env: REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} jobs: build-and-push: runs-on: ubuntu-latest permissions: contents: read packages: write strategy: matrix: variant: - name: regular file: Dockerfile suffix: '' - name: alpine file: Dockerfile-alpine suffix: '-alpine' steps: - name: Checkout repository uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: persist-credentials: false - name: Log in to the Container registry uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Extract metadata for Docker id: meta uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} tags: | type=raw,value=latest${{ matrix.variant.suffix }} type=raw,value={{date 'YYYYMMDD'}}${{ matrix.variant.suffix }} - name: Build and push image uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5 with: context: . file: ./${{ matrix.variant.file }} push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} ================================================ FILE: .github/workflows/schedule-doc-report.yml ================================================ name: Generate documentation versions report on: schedule: - cron: '17 4 1 * *' workflow_dispatch: jobs: report: runs-on: ubuntu-24.04 if: github.repository == 'freeCodeCamp/devdocs' steps: - uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2.7.0 - name: Set up Ruby uses: ruby/setup-ruby@09a7688d3b55cf0e976497ff046b70949eeaccfd # v1.288.0 with: bundler-cache: true # runs 'bundle install' and caches installed gems automatically - name: Generate report run: bundle exec thor updates:check --github-token ${{ secrets.DEVDOCS_BOT_PAT }} --upload ================================================ FILE: .github/workflows/test.yml ================================================ name: Ruby tests on: pull_request: branches: - main jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Set up Ruby uses: ruby/setup-ruby@09a7688d3b55cf0e976497ff046b70949eeaccfd # v1.288.0 with: bundler-cache: true # runs 'bundle install' and caches installed gems automatically - name: Run tests run: bundle exec rake ================================================ FILE: .gitignore ================================================ .DS_Store .bundle log tmp public/assets public/fonts public/docs/**/* docs/**/* !docs/*.md /vendor *.tar *.tar.bz2 *.tar.gz *.zip assets/stylesheets/components/_environment.scss assets/stylesheets/global/_icons.scss ================================================ FILE: .image_optim.yml ================================================ verbose: false skip_missing_workers: true allow_lossy: true threads: 1 advpng: false gifsicle: interlace: false level: 3 careful: true jhead: false jpegoptim: strip: all max_quality: 100 jpegrecompress: false jpegtran: false optipng: false pngcrush: false pngout: false pngquant: quality: !ruby/range 80..99 speed: 3 svgo: false ================================================ FILE: .ruby-version ================================================ 3.4.8 ================================================ FILE: .slugignore ================================================ test ================================================ FILE: .tool-versions ================================================ ruby 3.4.8 ================================================ FILE: COPYRIGHT ================================================ Copyright 2013-2026 Thibaut Courouble and other contributors This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. Please do not use the name DevDocs to endorse or promote products derived from this software without the maintainers' permission, except as may be necessary to comply with the notice/attribution requirements. We also wish that any documentation file generated using this software be attributed to DevDocs. Let's be fair to all contributors by giving credit where credit's due. Thanks. ================================================ FILE: Dockerfile ================================================ FROM ruby:3.4.7 ENV LANG=C.UTF-8 ENV ENABLE_SERVICE_WORKER=true WORKDIR /devdocs RUN apt-get update && \ apt-get -y install git nodejs libcurl4 && \ gem install bundler && \ rm -rf /var/lib/apt/lists/* COPY Gemfile Gemfile.lock Rakefile /devdocs/ RUN bundle config set path.system true && \ bundle install && \ rm -rf ~/.gem /root/.bundle/cache /usr/local/bundle/cache COPY . /devdocs RUN thor docs:download --all && \ thor assets:compile && \ rm -rf /tmp EXPOSE 9292 CMD rackup -o 0.0.0.0 ================================================ FILE: Dockerfile-alpine ================================================ FROM ruby:3.4.7-alpine ENV LANG=C.UTF-8 ENV ENABLE_SERVICE_WORKER=true WORKDIR /devdocs COPY . /devdocs RUN apk --update add nodejs build-base libstdc++ gzip git zlib-dev libcurl && \ gem install bundler && \ bundle config set path.system true && \ bundle config set without 'test' && \ bundle install && \ thor docs:download --all && \ thor assets:compile && \ apk del gzip build-base git zlib-dev && \ rm -rf /var/cache/apk/* /tmp ~/.gem /root/.bundle/cache \ /usr/local/bundle/cache /usr/lib/node_modules EXPOSE 9292 CMD rackup -o 0.0.0.0 ================================================ FILE: Gemfile ================================================ source 'https://rubygems.org' ruby '3.4.8' gem 'activesupport', require: false gem 'html-pipeline' gem 'nokogiri' gem 'pry-byebug' gem 'rake' gem 'terminal-table' gem 'thor' gem 'typhoeus' gem 'yajl-ruby', require: false group :app do gem 'browser' gem 'chunky_png' gem 'erubi' gem 'dartsass-sprockets' gem 'image_optim_pack', platforms: :ruby gem 'image_optim' gem 'rack-ssl-enforcer' gem 'rack' gem 'rss' gem 'sinatra-contrib' gem 'sinatra' gem 'sprockets-helpers' gem 'sprockets' gem 'thin' end group :production do gem 'newrelic_rpm' gem "terser" end group :development do gem 'better_errors' end group :docs do gem 'progress_bar', require: false gem 'redcarpet' gem 'tty-pager', require: false gem 'unix_utils', require: false end group :test do gem 'minitest' gem 'rack-test', require: false gem 'rr', require: false end if ENV['SELENIUM'] == '1' gem 'capybara' gem 'selenium-webdriver' end ================================================ FILE: LICENSE ================================================ Mozilla Public License Version 2.0 ================================== 1. Definitions -------------- 1.1. "Contributor" means each individual or legal entity that creates, contributes to the creation of, or owns Covered Software. 1.2. "Contributor Version" means the combination of the Contributions of others (if any) used by a Contributor and that particular Contributor's Contribution. 1.3. "Contribution" means Covered Software of a particular Contributor. 1.4. "Covered Software" means Source Code Form to which the initial Contributor has attached the notice in Exhibit A, the Executable Form of such Source Code Form, and Modifications of such Source Code Form, in each case including portions thereof. 1.5. "Incompatible With Secondary Licenses" means (a) that the initial Contributor has attached the notice described in Exhibit B to the Covered Software; or (b) that the Covered Software was made available under the terms of version 1.1 or earlier of the License, but not also under the terms of a Secondary License. 1.6. "Executable Form" means any form of the work other than Source Code Form. 1.7. "Larger Work" means a work that combines Covered Software with other material, in a separate file or files, that is not Covered Software. 1.8. "License" means this document. 1.9. "Licensable" means having the right to grant, to the maximum extent possible, whether at the time of the initial grant or subsequently, any and all of the rights conveyed by this License. 1.10. "Modifications" means any of the following: (a) any file in Source Code Form that results from an addition to, deletion from, or modification of the contents of Covered Software; or (b) any new file in Source Code Form that contains any Covered Software. 1.11. "Patent Claims" of a Contributor means any patent claim(s), including without limitation, method, process, and apparatus claims, in any patent Licensable by such Contributor that would be infringed, but for the grant of the License, by the making, using, selling, offering for sale, having made, import, or transfer of either its Contributions or its Contributor Version. 1.12. "Secondary License" means either the GNU General Public License, Version 2.0, the GNU Lesser General Public License, Version 2.1, the GNU Affero General Public License, Version 3.0, or any later versions of those licenses. 1.13. "Source Code Form" means the form of the work preferred for making modifications. 1.14. "You" (or "Your") means an individual or a legal entity exercising rights under this License. For legal entities, "You" includes any entity that controls, is controlled by, or is under common control with You. For purposes of this definition, "control" means (a) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (b) ownership of more than fifty percent (50%) of the outstanding shares or beneficial ownership of such entity. 2. License Grants and Conditions -------------------------------- 2.1. Grants Each Contributor hereby grants You a world-wide, royalty-free, non-exclusive license: (a) under intellectual property rights (other than patent or trademark) Licensable by such Contributor to use, reproduce, make available, modify, display, perform, distribute, and otherwise exploit its Contributions, either on an unmodified basis, with Modifications, or as part of a Larger Work; and (b) under Patent Claims of such Contributor to make, use, sell, offer for sale, have made, import, and otherwise transfer either its Contributions or its Contributor Version. 2.2. Effective Date The licenses granted in Section 2.1 with respect to any Contribution become effective for each Contribution on the date the Contributor first distributes such Contribution. 2.3. Limitations on Grant Scope The licenses granted in this Section 2 are the only rights granted under this License. No additional rights or licenses will be implied from the distribution or licensing of Covered Software under this License. Notwithstanding Section 2.1(b) above, no patent license is granted by a Contributor: (a) for any code that a Contributor has removed from Covered Software; or (b) for infringements caused by: (i) Your and any other third party's modifications of Covered Software, or (ii) the combination of its Contributions with other software (except as part of its Contributor Version); or (c) under Patent Claims infringed by Covered Software in the absence of its Contributions. This License does not grant any rights in the trademarks, service marks, or logos of any Contributor (except as may be necessary to comply with the notice requirements in Section 3.4). 2.4. Subsequent Licenses No Contributor makes additional grants as a result of Your choice to distribute the Covered Software under a subsequent version of this License (see Section 10.2) or under the terms of a Secondary License (if permitted under the terms of Section 3.3). 2.5. Representation Each Contributor represents that the Contributor believes its Contributions are its original creation(s) or it has sufficient rights to grant the rights to its Contributions conveyed by this License. 2.6. Fair Use This License is not intended to limit any rights You have under applicable copyright doctrines of fair use, fair dealing, or other equivalents. 2.7. Conditions Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted in Section 2.1. 3. Responsibilities ------------------- 3.1. Distribution of Source Form All distribution of Covered Software in Source Code Form, including any Modifications that You create or to which You contribute, must be under the terms of this License. You must inform recipients that the Source Code Form of the Covered Software is governed by the terms of this License, and how they can obtain a copy of this License. You may not attempt to alter or restrict the recipients' rights in the Source Code Form. 3.2. Distribution of Executable Form If You distribute Covered Software in Executable Form then: (a) such Covered Software must also be made available in Source Code Form, as described in Section 3.1, and You must inform recipients of the Executable Form how they can obtain a copy of such Source Code Form by reasonable means in a timely manner, at a charge no more than the cost of distribution to the recipient; and (b) You may distribute such Executable Form under the terms of this License, or sublicense it under different terms, provided that the license for the Executable Form does not attempt to limit or alter the recipients' rights in the Source Code Form under this License. 3.3. Distribution of a Larger Work You may create and distribute a Larger Work under terms of Your choice, provided that You also comply with the requirements of this License for the Covered Software. If the Larger Work is a combination of Covered Software with a work governed by one or more Secondary Licenses, and the Covered Software is not Incompatible With Secondary Licenses, this License permits You to additionally distribute such Covered Software under the terms of such Secondary License(s), so that the recipient of the Larger Work may, at their option, further distribute the Covered Software under the terms of either this License or such Secondary License(s). 3.4. Notices You may not remove or alter the substance of any license notices (including copyright notices, patent notices, disclaimers of warranty, or limitations of liability) contained within the Source Code Form of the Covered Software, except that You may alter any license notices to the extent required to remedy known factual inaccuracies. 3.5. Application of Additional Terms You may choose to offer, and to charge a fee for, warranty, support, indemnity or liability obligations to one or more recipients of Covered Software. However, You may do so only on Your own behalf, and not on behalf of any Contributor. You must make it absolutely clear that any such warranty, support, indemnity, or liability obligation is offered by You alone, and You hereby agree to indemnify every Contributor for any liability incurred by such Contributor as a result of warranty, support, indemnity or liability terms You offer. You may include additional disclaimers of warranty and limitations of liability specific to any jurisdiction. 4. Inability to Comply Due to Statute or Regulation --------------------------------------------------- If it is impossible for You to comply with any of the terms of this License with respect to some or all of the Covered Software due to statute, judicial order, or regulation then You must: (a) comply with the terms of this License to the maximum extent possible; and (b) describe the limitations and the code they affect. Such description must be placed in a text file included with all distributions of the Covered Software under this License. Except to the extent prohibited by statute or regulation, such description must be sufficiently detailed for a recipient of ordinary skill to be able to understand it. 5. Termination -------------- 5.1. The rights granted under this License will terminate automatically if You fail to comply with any of its terms. However, if You become compliant, then the rights granted under this License from a particular Contributor are reinstated (a) provisionally, unless and until such Contributor explicitly and finally terminates Your grants, and (b) on an ongoing basis, if such Contributor fails to notify You of the non-compliance by some reasonable means prior to 60 days after You have come back into compliance. Moreover, Your grants from a particular Contributor are reinstated on an ongoing basis if such Contributor notifies You of the non-compliance by some reasonable means, this is the first time You have received notice of non-compliance with this License from such Contributor, and You become compliant prior to 30 days after Your receipt of the notice. 5.2. If You initiate litigation against any entity by asserting a patent infringement claim (excluding declaratory judgment actions, counter-claims, and cross-claims) alleging that a Contributor Version directly or indirectly infringes any patent, then the rights granted to You by any and all Contributors for the Covered Software under Section 2.1 of this License shall terminate. 5.3. In the event of termination under Sections 5.1 or 5.2 above, all end user license agreements (excluding distributors and resellers) which have been validly granted by You or Your distributors under this License prior to termination shall survive termination. ************************************************************************ * * * 6. Disclaimer of Warranty * * ------------------------- * * * * Covered Software is provided under this License on an "as is" * * basis, without warranty of any kind, either expressed, implied, or * * statutory, including, without limitation, warranties that the * * Covered Software is free of defects, merchantable, fit for a * * particular purpose or non-infringing. The entire risk as to the * * quality and performance of the Covered Software is with You. * * Should any Covered Software prove defective in any respect, You * * (not any Contributor) assume the cost of any necessary servicing, * * repair, or correction. This disclaimer of warranty constitutes an * * essential part of this License. No use of any Covered Software is * * authorized under this License except under this disclaimer. * * * ************************************************************************ ************************************************************************ * * * 7. Limitation of Liability * * -------------------------- * * * * Under no circumstances and under no legal theory, whether tort * * (including negligence), contract, or otherwise, shall any * * Contributor, or anyone who distributes Covered Software as * * permitted above, be liable to You for any direct, indirect, * * special, incidental, or consequential damages of any character * * including, without limitation, damages for lost profits, loss of * * goodwill, work stoppage, computer failure or malfunction, or any * * and all other commercial damages or losses, even if such party * * shall have been informed of the possibility of such damages. This * * limitation of liability shall not apply to liability for death or * * personal injury resulting from such party's negligence to the * * extent applicable law prohibits such limitation. Some * * jurisdictions do not allow the exclusion or limitation of * * incidental or consequential damages, so this exclusion and * * limitation may not apply to You. * * * ************************************************************************ 8. Litigation ------------- Any litigation relating to this License may be brought only in the courts of a jurisdiction where the defendant maintains its principal place of business and such litigation shall be governed by laws of that jurisdiction, without reference to its conflict-of-law provisions. Nothing in this Section shall prevent a party's ability to bring cross-claims or counter-claims. 9. Miscellaneous ---------------- This License represents the complete agreement concerning the subject matter hereof. If any provision of this License is held to be unenforceable, such provision shall be reformed only to the extent necessary to make it enforceable. Any law or regulation which provides that the language of a contract shall be construed against the drafter shall not be used to construe this License against a Contributor. 10. Versions of the License --------------------------- 10.1. New Versions Mozilla Foundation is the license steward. Except as provided in Section 10.3, no one other than the license steward has the right to modify or publish new versions of this License. Each version will be given a distinguishing version number. 10.2. Effect of New Versions You may distribute the Covered Software under the terms of the version of the License under which You originally received the Covered Software, or under the terms of any subsequent version published by the license steward. 10.3. Modified Versions If you create software not governed by this License, and you want to create a new license for such software, you may create and use a modified version of this License if you rename the license and remove any references to the name of the license steward (except to note that such modified license differs from this License). 10.4. Distributing Source Code Form that is Incompatible With Secondary Licenses If You choose to distribute Source Code Form that is Incompatible With Secondary Licenses under the terms of this version of the License, the notice described in Exhibit B of this License must be attached. Exhibit A - Source Code Form License Notice ------------------------------------------- This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. If it is not possible or desirable to put the notice in a particular file, then You may include the notice in a location (such as a LICENSE file in a relevant directory) where a recipient would be likely to look for such a notice. You may add additional accurate notices of copyright ownership. Exhibit B - "Incompatible With Secondary Licenses" Notice --------------------------------------------------------- This Source Code Form is "Incompatible With Secondary Licenses", as defined by the Mozilla Public License, v. 2.0. ================================================ FILE: Procfile ================================================ web: bundle exec rackup config.ru -p $PORT ================================================ FILE: README.md ================================================ # [DevDocs](https://devdocs.io) — API Documentation Browser DevDocs combines multiple developer documentations in a clean and organized web UI with instant search, offline support, mobile version, dark theme, keyboard shortcuts, and more. DevDocs was created by [Thibaut Courouble](https://thibaut.me) and is operated by [freeCodeCamp](https://www.freecodecamp.org). ## We are currently searching for maintainers Please reach out to the community on [Discord](https://discord.gg/PRyKn3Vbay) if you would like to join the team! Keep track of development news: * Join the `#contributors` chat room on [Discord](https://discord.gg/PRyKn3Vbay) * Watch the repository on [GitHub](https://github.com/freeCodeCamp/devdocs/subscription) * Follow [@DevDocs](https://twitter.com/DevDocs) on Twitter **Table of Contents:** [Quick Start](#quick-start) · [Vision](#vision) · [App](#app) · [Scraper](#scraper) · [Commands](#available-commands) · [Contributing](#contributing) · [Documentation](#documentation) · [Related Projects](#related-projects) · [License](#copyright--license) · [Questions?](#questions) ## Quick Start Unless you wish to contribute to the project, we recommend using the hosted version at [devdocs.io](https://devdocs.io). It's up-to-date and works offline out-of-the-box. ### Using Docker (Recommended) The easiest way to run DevDocs locally is using Docker: ```sh docker run --name devdocs -d -p 9292:9292 ghcr.io/freecodecamp/devdocs:latest ``` This will start DevDocs at [localhost:9292](http://localhost:9292). We provide both regular and Alpine-based images: - `ghcr.io/freecodecamp/devdocs:latest` - Standard image - `ghcr.io/freecodecamp/devdocs:latest-alpine` - Alpine-based (smaller size) Images are automatically built and updated monthly with the latest documentation. Alternatively, you can build the image yourself: ```sh git clone https://github.com/freeCodeCamp/devdocs.git && cd devdocs docker build -t devdocs . docker run --name devdocs -d -p 9292:9292 devdocs ``` ### Manual Installation DevDocs is made of two pieces: a Ruby scraper that generates the documentation and metadata, and a JavaScript app powered by a small Sinatra app. DevDocs requires Ruby 3.4.1 (defined in [`Gemfile`](./Gemfile)), libcurl, and a JavaScript runtime supported by [ExecJS](https://github.com/rails/execjs#readme) (included in OS X and Windows; [Node.js](https://nodejs.org/en/) on Linux). On Arch Linux run `pacman -S ruby ruby-bundler ruby-erb ruby-irb`. Once you have these installed, run the following commands: ```sh git clone https://github.com/freeCodeCamp/devdocs.git && cd devdocs gem install bundler bundle install bundle exec thor docs:download --default bundle exec rackup ``` Finally, point your browser at [localhost:9292](http://localhost:9292) (the first request will take a few seconds to compile the assets). You're all set. The `thor docs:download` command is used to download pre-generated documentations from DevDocs's servers (e.g. `thor docs:download html css`). You can see the list of available documentations and versions by running `thor docs:list`. To update all downloaded documentations, run `thor docs:download --installed`. To download and install all documentation this project has available, run `thor docs:download --all`. **Note:** there is currently no update mechanism other than `git pull origin main` to update the code and `thor docs:download --installed` to download the latest version of the docs. To stay informed about new releases, be sure to [watch](https://github.com/freeCodeCamp/devdocs/subscription) this repository. ## Vision DevDocs aims to make reading and searching reference documentation fast, easy and enjoyable. The app's main goals are to: * Keep load times as short as possible * Improve the quality, speed, and order of search results * Maximize the use of caching and other performance optimizations * Maintain a clean and readable user interface * Be fully functional offline * Support full keyboard navigation * Reduce “context switch” by using a consistent typography and design across all documentations * Reduce clutter by focusing on a specific category of content (API/reference) and indexing only the minimum useful to most developers. **Note:** DevDocs is neither a programming guide nor a search engine. All our content is pulled from third-party sources and the project doesn't intend to compete with full-text search engines. Its backbone is metadata; each piece of content is identified by a unique, "obvious" and short string. Tutorials, guides and other content that don't meet this requirement are outside the scope of the project. ## App The web app is all client-side JavaScript, powered by a small [Sinatra](http://www.sinatrarb.com)/[Sprockets](https://github.com/rails/sprockets) application. It relies on files generated by the [scraper](#scraper). Many of the code's design decisions were driven by the fact that the app uses XHR to load content directly into the main frame. This includes stripping the original documents of most of their HTML markup (e.g. scripts and stylesheets) to avoid polluting the main frame, and prefixing all CSS class names with an underscore to prevent conflicts. Another driving factor is performance and the fact that everything happens in the browser. A service worker (which comes with its own set of constraints) and `localStorage` are used to speed up the boot time, while memory consumption is kept in check by allowing the user to pick his/her own set of documentations. The search algorithm is kept simple because it needs to be fast even searching through 100,000 strings. DevDocs being a developer tool, the browser requirements are high: * Recent versions of Firefox, Chrome, or Opera * Safari 11.1+ * Edge 17+ * iOS 11.3+ This allows the code to take advantage of the latest DOM and HTML5 APIs and make developing DevDocs a lot more fun! ## Scraper The scraper is responsible for generating the documentation and index files (metadata) used by the [app](#app). It's written in Ruby under the `Docs` module. There are currently two kinds of scrapers: `UrlScraper` which downloads files via HTTP and `FileScraper` which reads them from the local filesystem. They both make copies of HTML documents, recursively following links that match a set of rules and applying all sorts of modifications along the way, in addition to building an index of the files and their metadata. Documents are parsed using [Nokogiri](http://nokogiri.org). Modifications made to each document include: * removing content such as the document structure (``, ``, etc.), comments, empty nodes, etc. * fixing links (e.g. to remove duplicates) * replacing all external (not scraped) URLs with their fully qualified counterpart * replacing all internal (scraped) URLs with their unqualified and relative counterpart * adding content, such as a title and link to the original document * ensuring correct syntax highlighting using [Prism](http://prismjs.com/) These modifications are applied via a set of filters using the [HTML::Pipeline](https://github.com/jch/html-pipeline) library. Each scraper includes filters specific to itself, one of which is tasked with figuring out the pages' metadata. The end result is a set of normalized HTML partials and two JSON files (index + offline data). Because the index files are loaded separately by the [app](#app) following the user's preferences, the scraper also creates a JSON manifest file containing information about the documentations currently available on the system (such as their name, version, update date, etc.). More information about [scrapers](./docs/scraper-reference.md) and [filters](./docs/filter-reference.md) is available in the `docs` folder. ## Available Commands The command-line interface uses [Thor](http://whatisthor.com). To see all commands and options, run `thor list` from the project's root. ```sh # Server rackup # Start the server (ctrl+c to stop) rackup --help # List server options # Docs thor docs:list # List available documentations thor docs:download # Download one or more documentations thor docs:manifest # Create the manifest file used by the app thor docs:generate # Generate/scrape a documentation thor docs:page # Generate/scrape a documentation page thor docs:package # Package a documentation for use with docs:download thor docs:clean # Delete documentation packages # Console thor console # Start a REPL thor console:docs # Start a REPL in the "Docs" module # Tests can be run quickly from within the console using the "test" command. # Run "help test" for usage instructions. thor test:all # Run all tests thor test:docs # Run "Docs" tests thor test:app # Run "App" tests # Assets thor assets:compile # Compile assets (not required in development mode) thor assets:clean # Clean old assets ``` If multiple versions of Ruby are installed on your system, commands must be run through `bundle exec`. ## Contributing Contributions are welcome. Please read the [contributing guidelines](./.github/CONTRIBUTING.md). ## Documentation * [Adding documentations to DevDocs](./docs/adding-docs.md) * [Scraper Reference](./docs/scraper-reference.md) * [Filter Reference](./docs/filter-reference.md) * [Maintainers’ Guide](./docs/maintainers.md) ## DevDocs Quick Usage Cheatsheet Below are some helpful shortcuts and usage tips that are not immediately obvious to new users: - Press / or Ctrl + K to instantly focus the search bar. - Press ? to open DevDocs’ built-in help overlay. - Press ↑ or ↓ to navigate search results without touching the mouse. - Press Enter to open the highlighted search result. - Press Backspace to go back to the previously viewed page. - Press Shift + S to toggle the sidebar visibility. - Press A to open the list of all installed documentation sets. - Press Esc to close popups, overlays, and search. - Use the **⚡ Offline Mode toggle** to download docs for offline use. - You can pin specific documentation sets to the sidebar for quicker access. These shortcuts make DevDocs faster to navigate and more efficient for daily use. ## Related Projects Made something cool? Feel free to open a PR to add a new row to this table! You might want to discover new projects via https://github.com/topics/devdocs. | Project | Description | Last commit | Stars | | ------------------------------------------------------------------------------------------- | ------------------------------------ | -------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | | [yannickglt/alfred-devdocs](https://github.com/yannickglt/alfred-devdocs) | Alfred workflow | ![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–2026 Thibaut Courouble and [other contributors](https://github.com/freeCodeCamp/devdocs/graphs/contributors) This software is licensed under the terms of the Mozilla Public License v2.0. See the [COPYRIGHT](./COPYRIGHT) and [LICENSE](./LICENSE) files. Please do not use the name DevDocs to endorse or promote products derived from this software without the maintainers' permission, except as may be necessary to comply with the notice/attribution requirements. We also wish that any documentation file generated using this software be attributed to DevDocs. Let's be fair to all contributors by giving credit where credit's due. Thanks! ## Questions? If you have any questions, please feel free to ask them on the `#contributors` chat room on [Discord](https://discord.gg/PRyKn3Vbay). ================================================ FILE: Rakefile ================================================ #!/usr/bin/env rake require 'bundler/setup' require 'thor' Bundler.require :default $LOAD_PATH.unshift 'lib' task :default do $LOAD_PATH.unshift 'test' Dir['test/**/*_test.rb'].map(&File.method(:expand_path)).each(&method(:require)) end namespace :assets do desc 'Compile all assets' task :precompile do load 'tasks/docs.thor' DocsCLI.new.prepare_deploy load 'tasks/assets.thor' AssetsCLI.new.compile end end ================================================ FILE: Thorfile ================================================ $LOAD_PATH.unshift 'lib' ================================================ FILE: assets/images/.gitignore ================================================ sprites/**/* ================================================ FILE: assets/javascripts/app/app.js ================================================ class App extends Events { _$ = $; _$$ = $$; _page = page; collections = {}; models = {}; templates = {}; views = {}; init() { try { this.initErrorTracking(); } catch (error) {} if (!this.browserCheck()) { return; } this.el = $("._app"); this.localStorage = new LocalStorageStore(); if (app.ServiceWorker.isEnabled()) { this.serviceWorker = new app.ServiceWorker(); } this.settings = new app.Settings(); this.db = new app.DB(); this.settings.initLayout(); this.docs = new app.collections.Docs(); this.disabledDocs = new app.collections.Docs(); this.entries = new app.collections.Entries(); this.router = new app.Router(); this.shortcuts = new app.Shortcuts(); this.document = new app.views.Document(); if (this.isMobile()) { this.mobile = new app.views.Mobile(); } if (document.body.hasAttribute("data-doc")) { this.DOC = JSON.parse(document.body.getAttribute("data-doc")); this.bootOne(); } else if (this.DOCS) { this.bootAll(); } else { this.onBootError(); } } browserCheck() { if (this.isSupportedBrowser()) { return true; } document.body.innerHTML = app.templates.unsupportedBrowser; this.hideLoadingScreen(); return false; } initErrorTracking() { // Show a warning message and don't track errors when the app is loaded // from a domain other than our own, because things are likely to break. // (e.g. cross-domain requests) if (this.isInvalidLocation()) { new app.views.Notif("InvalidLocation"); } else { if (this.config.sentry_dsn) { Raven.config(this.config.sentry_dsn, { release: this.config.release, whitelistUrls: [/devdocs/], includePaths: [/devdocs/], ignoreErrors: [/NPObject/, /NS_ERROR/, /^null$/, /EvalError/], tags: { mode: this.isSingleDoc() ? "single" : "full", iframe: (window.top !== window).toString(), electron: (!!window.process?.versions?.electron).toString(), }, shouldSendCallback: () => { try { if (this.isInjectionError()) { this.onInjectionError(); return false; } if (this.isAndroidWebview()) { return false; } } catch (error) {} return true; }, dataCallback(data) { try { data.user ||= {}; Object.assign(data.user, app.settings.dump()); if (data.user.docs) { data.user.docs = data.user.docs.split("/"); } if (app.lastIDBTransaction) { data.user.lastIDBTransaction = app.lastIDBTransaction; } data.tags.scriptCount = document.scripts.length; } catch (error) {} return data; }, }).install(); } this.previousErrorHandler = onerror; window.onerror = this.onWindowError.bind(this); CookiesStore.onBlocked = this.onCookieBlocked; } } bootOne() { this.doc = new app.models.Doc(this.DOC); this.docs.reset([this.doc]); this.doc.load(this.start.bind(this), this.onBootError.bind(this), { readCache: true, }); new app.views.Notice("singleDoc", this.doc); delete this.DOC; } bootAll() { const docs = this.settings.getDocs(); for (var doc of this.DOCS) { (docs.includes(doc.slug) ? this.docs : this.disabledDocs).add(doc); } this.migrateDocs(); this.docs.load(this.start.bind(this), this.onBootError.bind(this), { readCache: true, writeCache: true, }); delete this.DOCS; } start() { let doc; for (doc of this.docs.all()) { this.entries.add(doc.toEntry()); } for (doc of this.disabledDocs.all()) { this.entries.add(doc.toEntry()); } for (doc of this.docs.all()) { this.initDoc(doc); } this.trigger("ready"); this.router.start(); this.hideLoadingScreen(); setTimeout(() => { if (!this.doc) { this.welcomeBack(); } return this.removeEvent("ready bootError"); }, 50); } initDoc(doc) { for (var type of doc.types.all()) { doc.entries.add(type.toEntry()); } this.entries.add(doc.entries.all()); } migrateDocs() { let needsSaving; for (var slug of this.settings.getDocs()) { if (!this.docs.findBy("slug", slug)) { var doc; needsSaving = true; if (slug === "webpack~2") { doc = this.disabledDocs.findBy("slug", "webpack"); } if (slug === "angular~4_typescript") { doc = this.disabledDocs.findBy("slug", "angular"); } if (slug === "angular~2_typescript") { doc = this.disabledDocs.findBy("slug", "angular~2"); } if (!doc) { doc = this.disabledDocs.findBy("slug_without_version", slug); } if (doc) { this.disabledDocs.remove(doc); this.docs.add(doc); } } } if (needsSaving) { this.saveDocs(); } } enableDoc(doc, _onSuccess, onError) { if (this.docs.contains(doc)) { return; } const onSuccess = () => { if (this.docs.contains(doc)) { return; } this.disabledDocs.remove(doc); this.docs.add(doc); this.docs.sort(); this.initDoc(doc); this.saveDocs(); if (app.settings.get("autoInstall")) { doc.install(_onSuccess, onError); } else { _onSuccess(); } }; doc.load(onSuccess, onError, { writeCache: true }); } saveDocs() { this.settings.setDocs(this.docs.all().map((doc) => doc.slug)); this.db.migrate(); return this.serviceWorker != null ? this.serviceWorker.updateInBackground() : undefined; } welcomeBack() { let visitCount = this.settings.get("count"); this.settings.set("count", ++visitCount); if (visitCount === 5) { new app.views.Notif("Share", { autoHide: null }); } new app.views.News(); new app.views.Updates(); return (this.updateChecker = new app.UpdateChecker()); } reboot() { if (location.pathname !== "/" && location.pathname !== "/settings") { window.location = `/#${location.pathname}`; } else { window.location = "/"; } } reload() { this.docs.clearCache(); this.disabledDocs.clearCache(); if (this.serviceWorker) { this.serviceWorker.reload(); } else { this.reboot(); } } reset() { this.localStorage.reset(); this.settings.reset(); if (this.db != null) { this.db.reset(); } if (this.serviceWorker != null) { this.serviceWorker.update(); } window.location = "/"; } showTip(tip) { if (this.isSingleDoc()) { return; } const tips = this.settings.getTips(); if (!tips.includes(tip)) { tips.push(tip); this.settings.setTips(tips); new app.views.Tip(tip); } } hideLoadingScreen() { if ($.overlayScrollbarsEnabled()) { document.body.classList.add("_overlay-scrollbars"); } document.documentElement.classList.remove("_booting"); } indexHost() { // Can't load the index files from the host/CDN when service worker is // enabled because it doesn't support caching URLs that use CORS. return this.config[ this.serviceWorker && this.settings.hasDocs() ? "index_path" : "docs_origin" ]; } onBootError(...args) { this.trigger("bootError"); this.hideLoadingScreen(); } onQuotaExceeded() { if (this.quotaExceeded) { return; } this.quotaExceeded = true; new app.views.Notif("QuotaExceeded", { autoHide: null }); } onCookieBlocked(key, value, actual) { if (this.cookieBlocked) { return; } this.cookieBlocked = true; new app.views.Notif("CookieBlocked", { autoHide: null }); Raven.captureMessage(`CookieBlocked/${key}`, { level: "warning", extra: { value, actual }, }); } onWindowError(...args) { if (this.cookieBlocked) { return; } if (this.isInjectionError(...args)) { this.onInjectionError(); } else if (this.isAppError(...args)) { if (typeof this.previousErrorHandler === "function") { this.previousErrorHandler(...args); } this.hideLoadingScreen(); if (!this.errorNotif) { this.errorNotif = new app.views.Notif("Error"); } this.errorNotif.show(); } } onInjectionError() { if (!this.injectionError) { this.injectionError = true; alert(`\ JavaScript code has been injected in the page which prevents DevDocs from running correctly. Please check your browser extensions/addons. `); Raven.captureMessage("injection error", { level: "info" }); } } isInjectionError() { // Some browser extensions expect the entire web to use jQuery. // I gave up trying to fight back. return ( window.$ !== app._$ || window.$$ !== app._$$ || window.page !== app._page || typeof $.empty !== "function" || typeof page.show !== "function" ); } isAppError(error, file) { // Ignore errors from external scripts. return file && file.includes("devdocs") && file.endsWith(".js"); } isSupportedBrowser() { try { const features = { bind: !!Function.prototype.bind, pushState: !!history.pushState, matchMedia: !!window.matchMedia, insertAdjacentHTML: !!document.body.insertAdjacentHTML, defaultPrevented: document.createEvent("CustomEvent").defaultPrevented === false, cssVariables: !!CSS.supports?.("(--t: 0)"), }; for (var key in features) { var value = features[key]; if (!value) { Raven.captureMessage(`unsupported/${key}`, { level: "info" }); return false; } } return true; } catch (error) { Raven.captureMessage("unsupported/exception", { level: "info", extra: { error }, }); return false; } } isSingleDoc() { return document.body.hasAttribute("data-doc"); } isMobile() { return this._isMobile != null ? this._isMobile : (this._isMobile = app.views.Mobile.detect()); } isAndroidWebview() { return this._isAndroidWebview != null ? this._isAndroidWebview : (this._isAndroidWebview = app.views.Mobile.detectAndroidWebview()); } isInvalidLocation() { return ( this.config.env === "production" && !location.host.startsWith(app.config.production_host) ); } } this.app = new App(); ================================================ FILE: assets/javascripts/app/config.js.erb ================================================ app.config = { db_filename: 'db.json', default_docs: <%= App.default_docs.to_json %>, docs_aliases: <%= App.docs_aliases.to_json %>, docs_origin: '<%= App.docs_origin %>', env: '<%= App.environment %>', history_cache_size: 10, index_filename: 'index.json', index_path: '/<%= App.docs_prefix %>', max_results: 50, production_host: 'devdocs.io', search_param: 'q', sentry_dsn: '<%= App.sentry_dsn %>', version: <%= Time.now.to_i %>, release: <%= Time.now.utc.httpdate.to_json %>, mathml_stylesheet: '/mathml.css', favicon_spritesheet: '<%= image_path('sprites/docs.png') %>', service_worker_path: '/service-worker.js', service_worker_enabled: <%= App.environment == :production || ENV['ENABLE_SERVICE_WORKER'] == 'true' %>, } ================================================ FILE: assets/javascripts/app/db.js ================================================ app.DB = class DB { static NAME = "docs"; static VERSION = 15; constructor() { this.versionMultipler = $.isIE() ? 1e5 : 1e9; this.useIndexedDB = this.useIndexedDB(); this.callbacks = []; } db(fn) { if (!this.useIndexedDB) { return fn(); } if (fn) { this.callbacks.push(fn); } if (this.open) { return; } try { this.open = true; const req = indexedDB.open( DB.NAME, DB.VERSION * this.versionMultipler + this.userVersion(), ); req.onsuccess = (event) => this.onOpenSuccess(event); req.onerror = (event) => this.onOpenError(event); req.onupgradeneeded = (event) => this.onUpgradeNeeded(event); } catch (error) { this.fail("exception", error); } } onOpenSuccess(event) { let error; const db = event.target.result; if (db.objectStoreNames.length === 0) { try { db.close(); } catch (error1) {} this.open = false; this.fail("empty"); } else if ((error = this.buggyIDB(db))) { try { db.close(); } catch (error2) {} this.open = false; this.fail("buggy", error); } else { this.runCallbacks(db); this.open = false; db.close(); } } onOpenError(event) { event.preventDefault(); this.open = false; const { error } = event.target; switch (error.name) { case "QuotaExceededError": this.onQuotaExceededError(); break; case "VersionError": this.onVersionError(); break; case "InvalidStateError": this.fail("private_mode"); break; default: this.fail("cant_open", error); } } fail(reason, error) { this.cachedDocs = null; this.useIndexedDB = false; if (!this.reason) { this.reason = reason; } if (!this.error) { this.error = error; } if (error) { if (typeof console.error === "function") { console.error("IDB error", error); } } this.runCallbacks(); if (error && reason === "cant_open") { Raven.captureMessage(`${error.name}: ${error.message}`, { level: "warning", fingerprint: [error.name], }); } } onQuotaExceededError() { this.reset(); this.db(); app.onQuotaExceeded(); Raven.captureMessage("QuotaExceededError", { level: "warning" }); } onVersionError() { const req = indexedDB.open(DB.NAME); req.onsuccess = (event) => { return this.handleVersionMismatch(event.target.result.version); }; req.onerror = function (event) { event.preventDefault(); return this.fail("cant_open", error); }; } handleVersionMismatch(actualVersion) { if (Math.floor(actualVersion / this.versionMultipler) !== DB.VERSION) { this.fail("version"); } else { this.setUserVersion(actualVersion - DB.VERSION * this.versionMultipler); this.db(); } } buggyIDB(db) { if (this.checkedBuggyIDB) { return; } this.checkedBuggyIDB = true; try { this.idbTransaction(db, { stores: $.makeArray(db.objectStoreNames).slice(0, 2), mode: "readwrite", }).abort(); // https://bugs.webkit.org/show_bug.cgi?id=136937 return; } catch (error) { return error; } } runCallbacks(db) { let fn; while ((fn = this.callbacks.shift())) { fn(db); } } onUpgradeNeeded(event) { const db = event.target.result; if (!db) { return; } const objectStoreNames = $.makeArray(db.objectStoreNames); if (!$.arrayDelete(objectStoreNames, "docs")) { try { db.createObjectStore("docs"); } catch (error) {} } for (var doc of app.docs.all()) { if (!$.arrayDelete(objectStoreNames, doc.slug)) { try { db.createObjectStore(doc.slug); } catch (error1) {} } } for (var name of objectStoreNames) { try { db.deleteObjectStore(name); } catch (error2) {} } } store(doc, data, onSuccess, onError, _retry) { if (_retry == null) { _retry = true; } this.db((db) => { if (!db) { onError(); return; } const txn = this.idbTransaction(db, { stores: ["docs", doc.slug], mode: "readwrite", ignoreError: false, }); txn.oncomplete = () => { if (this.cachedDocs != null) { this.cachedDocs[doc.slug] = doc.mtime; } onSuccess(); }; txn.onerror = (event) => { event.preventDefault(); if (txn.error?.name === "NotFoundError" && _retry) { this.migrate(); setTimeout(() => { return this.store(doc, data, onSuccess, onError, false); }, 0); } else { onError(event); } }; let store = txn.objectStore(doc.slug); store.clear(); for (var path in data) { var content = data[path]; store.add(content, path); } store = txn.objectStore("docs"); store.put(doc.mtime, doc.slug); }); } unstore(doc, onSuccess, onError, _retry) { if (_retry == null) { _retry = true; } this.db((db) => { if (!db) { onError(); return; } const txn = this.idbTransaction(db, { stores: ["docs", doc.slug], mode: "readwrite", ignoreError: false, }); txn.oncomplete = () => { if (this.cachedDocs != null) { delete this.cachedDocs[doc.slug]; } onSuccess(); }; txn.onerror = function (event) { event.preventDefault(); if (txn.error?.name === "NotFoundError" && _retry) { this.migrate(); setTimeout(() => { return this.unstore(doc, onSuccess, onError, false); }, 0); } else { onError(event); } }; let store = txn.objectStore("docs"); store.delete(doc.slug); store = txn.objectStore(doc.slug); store.clear(); }); } version(doc, fn) { const version = this.cachedVersion(doc); if (version != null) { fn(version); return; } this.db((db) => { if (!db) { fn(false); return; } const txn = this.idbTransaction(db, { stores: ["docs"], mode: "readonly", }); const store = txn.objectStore("docs"); const req = store.get(doc.slug); req.onsuccess = function () { fn(req.result); }; req.onerror = function (event) { event.preventDefault(); fn(false); }; }); } cachedVersion(doc) { if (!this.cachedDocs) { return; } return this.cachedDocs[doc.slug] || false; } versions(docs, fn) { const versions = this.cachedVersions(docs); if (versions) { fn(versions); return; } return this.db((db) => { if (!db) { fn(false); return; } const txn = this.idbTransaction(db, { stores: ["docs"], mode: "readonly", }); txn.oncomplete = function () { fn(result); }; const store = txn.objectStore("docs"); var result = {}; docs.forEach((doc) => { const req = store.get(doc.slug); req.onsuccess = function () { result[doc.slug] = req.result; }; req.onerror = function (event) { event.preventDefault(); result[doc.slug] = false; }; }); }); } cachedVersions(docs) { if (!this.cachedDocs) { return; } const result = {}; for (var doc of docs) { result[doc.slug] = this.cachedVersion(doc); } return result; } load(entry, onSuccess, onError) { if (this.shouldLoadWithIDB(entry)) { return this.loadWithIDB(entry, onSuccess, () => this.loadWithXHR(entry, onSuccess, onError) ); } else { return this.loadWithXHR(entry, onSuccess, onError); } } loadWithXHR(entry, onSuccess, onError) { return ajax({ url: entry.fileUrl(), dataType: "html", success: onSuccess, error: onError, }); } loadWithIDB(entry, onSuccess, onError) { return this.db((db) => { if (!db) { onError(); return; } if (!db.objectStoreNames.contains(entry.doc.slug)) { onError(); this.loadDocsCache(db); return; } const txn = this.idbTransaction(db, { stores: [entry.doc.slug], mode: "readonly", }); const store = txn.objectStore(entry.doc.slug); const req = store.get(entry.dbPath()); req.onsuccess = function () { if (req.result) { onSuccess(req.result); } else { onError(); } }; req.onerror = function (event) { event.preventDefault(); onError(); }; this.loadDocsCache(db); }); } loadDocsCache(db) { if (this.cachedDocs) { return; } this.cachedDocs = {}; const txn = this.idbTransaction(db, { stores: ["docs"], mode: "readonly", }); txn.oncomplete = () => { setTimeout(() => this.checkForCorruptedDocs(), 50); }; const req = txn.objectStore("docs").openCursor(); req.onsuccess = (event) => { const cursor = event.target.result; if (!cursor) { return; } this.cachedDocs[cursor.key] = cursor.value; cursor.continue(); }; req.onerror = function (event) { event.preventDefault(); }; } checkForCorruptedDocs() { this.db((db) => { let slug; this.corruptedDocs = []; const docs = (() => { const result = []; for (var key in this.cachedDocs) { var value = this.cachedDocs[key]; if (value) { result.push(key); } } return result; })(); if (docs.length === 0) { return; } for (slug of docs) { if (!app.docs.findBy("slug", slug)) { this.corruptedDocs.push(slug); } } for (slug of this.corruptedDocs) { $.arrayDelete(docs, slug); } if (docs.length === 0) { setTimeout(() => this.deleteCorruptedDocs(), 0); return; } const txn = this.idbTransaction(db, { stores: docs, mode: "readonly", ignoreError: false, }); txn.oncomplete = () => { if (this.corruptedDocs.length > 0) { setTimeout(() => this.deleteCorruptedDocs(), 0); } }; for (var doc of docs) { txn.objectStore(doc).get("index").onsuccess = (event) => { if (!event.target.result) { this.corruptedDocs.push(event.target.source.name); } }; } }); } deleteCorruptedDocs() { this.db((db) => { let doc; const txn = this.idbTransaction(db, { stores: ["docs"], mode: "readwrite", ignoreError: false, }); const store = txn.objectStore("docs"); while ((doc = this.corruptedDocs.pop())) { this.cachedDocs[doc] = false; store.delete(doc); } }); Raven.captureMessage("corruptedDocs", { level: "info", extra: { docs: this.corruptedDocs.join(",") }, }); } shouldLoadWithIDB(entry) { return ( this.useIndexedDB && (!this.cachedDocs || this.cachedDocs[entry.doc.slug]) ); } idbTransaction(db, options) { app.lastIDBTransaction = [options.stores, options.mode]; const txn = db.transaction(options.stores, options.mode); if (options.ignoreError !== false) { txn.onerror = function (event) { event.preventDefault(); }; } if (options.ignoreAbort !== false) { txn.onabort = function (event) { event.preventDefault(); }; } return txn; } reset() { try { indexedDB?.deleteDatabase(DB.NAME); } catch (error) {} } useIndexedDB() { try { if (!app.isSingleDoc() && window.indexedDB) { return true; } else { this.reason = "not_supported"; return false; } } catch (error) { return false; } } migrate() { app.settings.set("schema", this.userVersion() + 1); } setUserVersion(version) { app.settings.set("schema", version); } userVersion() { return app.settings.get("schema"); } }; ================================================ FILE: assets/javascripts/app/router.js ================================================ app.Router = class Router extends Events { static routes = [ ["*", "before"], ["/", "root"], ["/settings", "settings"], ["/offline", "offline"], ["/about", "about"], ["/news", "news"], ["/help", "help"], ["/:doc-:type/", "type"], ["/:doc/", "doc"], ["/:doc/:path(*)", "entry"], ["*", "notFound"], ]; constructor() { super(); for (var [path, method] of this.constructor.routes) { page(path, this[method].bind(this)); } this.setInitialPath(); } start() { page.start(); } show(path) { page.show(path); } triggerRoute(name) { this.trigger(name, this.context); this.trigger("after", name, this.context); } before(context, next) { const previousContext = this.context; this.context = context; this.trigger("before", context); const res = next(); if (res) { this.context = previousContext; return res; } else { return; } } doc(context, next) { let doc; if ( (doc = app.docs.findBySlug(context.params.doc) || app.disabledDocs.findBySlug(context.params.doc)) ) { context.doc = doc; context.entry = doc.toEntry(); this.triggerRoute("entry"); return; } else { return next(); } } type(context, next) { const doc = app.docs.findBySlug(context.params.doc); const type = doc?.types?.findBy("slug", context.params.type); if (type) { context.doc = doc; context.type = type; this.triggerRoute("type"); return; } else { return next(); } } entry(context, next) { const doc = app.docs.findBySlug(context.params.doc); if (!doc) { return next(); } let { path } = context.params; const { hash } = context; let entry = doc.findEntryByPathAndHash(path, hash); if (entry) { context.doc = doc; context.entry = entry; this.triggerRoute("entry"); return; } else if (path.slice(-6) === "/index") { path = path.substr(0, path.length - 6); entry = doc.findEntryByPathAndHash(path, hash); if (entry) { return entry.fullPath(); } } else { path = `${path}/index`; entry = doc.findEntryByPathAndHash(path, hash); if (entry) { return entry.fullPath(); } } return next(); } root() { if (app.isSingleDoc()) { return "/"; } this.triggerRoute("root"); } settings(context) { if (app.isSingleDoc()) { return `/#/${context.path}`; } this.triggerRoute("settings"); } offline(context) { if (app.isSingleDoc()) { return `/#/${context.path}`; } this.triggerRoute("offline"); } about(context) { if (app.isSingleDoc()) { return `/#/${context.path}`; } context.page = "about"; this.triggerRoute("page"); } news(context) { if (app.isSingleDoc()) { return `/#/${context.path}`; } context.page = "news"; this.triggerRoute("page"); } help(context) { if (app.isSingleDoc()) { return `/#/${context.path}`; } context.page = "help"; this.triggerRoute("page"); } notFound(context) { this.triggerRoute("notFound"); } isIndex() { return ( this.context?.path === "/" || (app.isSingleDoc() && this.context?.entry?.isIndex()) ); } isSettings() { return this.context?.path === "/settings"; } setInitialPath() { // Remove superfluous forward slashes at the beginning of the path let path = location.pathname.replace(/^\/{2,}/g, "/"); if (path !== location.pathname) { page.replace(path + location.search + location.hash, null, true); } if (location.pathname === "/") { if ((path = this.getInitialPathFromHash())) { page.replace(path + location.search, null, true); } else if ((path = this.getInitialPathFromCookie())) { page.replace(path + location.search + location.hash, null, true); } } } getInitialPathFromHash() { try { return new RegExp("#/(.+)").exec(decodeURIComponent(location.hash))?.[1]; } catch (error) {} } getInitialPathFromCookie() { const path = Cookies.get("initial_path"); if (path) { Cookies.expire("initial_path"); return path; } } replaceHash(hash) { page.replace( location.pathname + location.search + (hash || ""), null, true ); } }; ================================================ FILE: assets/javascripts/app/searcher.js ================================================ // // Match functions // let fuzzyRegexp, i, index, lastIndex, match, matcher, matchIndex, matchLength, queryLength, score, separators, value, valueLength; const SEPARATOR = "."; let query = (queryLength = value = valueLength = matcher = // current match function fuzzyRegexp = // query fuzzy regexp index = // position of the query in the string being matched lastIndex = // last position of the query in the string being matched match = // regexp match data matchIndex = matchLength = score = // score for the current match separators = // counter i = null); // cursor function exactMatch() { index = value.indexOf(query); if (!(index >= 0)) { return; } lastIndex = value.lastIndexOf(query); if (index !== lastIndex) { return Math.max( scoreExactMatch(), ((index = lastIndex) && scoreExactMatch()) || 0, ); } else { return scoreExactMatch(); } } function scoreExactMatch() { // Remove one point for each unmatched character. score = 100 - (valueLength - queryLength); if (index > 0) { // If the character preceding the query is a dot, assign the same score // as if the query was found at the beginning of the string, minus one. if (value.charAt(index - 1) === SEPARATOR) { score += index - 1; // Don't match a single-character query unless it's found at the beginning // of the string or is preceded by a dot. } else if (queryLength === 1) { return; // (1) Remove one point for each unmatched character up to the nearest // preceding dot or the beginning of the string. // (2) Remove one point for each unmatched character following the query. } else { i = index - 2; while (i >= 0 && value.charAt(i) !== SEPARATOR) { i--; } score -= index - i + // (1) (valueLength - queryLength - index); // (2) } // Remove one point for each dot preceding the query, except for the one // immediately before the query. separators = 0; i = index - 2; while (i >= 0) { if (value.charAt(i) === SEPARATOR) { separators++; } i--; } score -= separators; } // Remove five points for each dot following the query. separators = 0; i = valueLength - queryLength - index - 1; while (i >= 0) { if (value.charAt(index + queryLength + i) === SEPARATOR) { separators++; } i--; } score -= separators * 5; return Math.max(1, score); } function fuzzyMatch() { if (valueLength <= queryLength || value.includes(query)) { return; } if (!(match = fuzzyRegexp.exec(value))) { return; } matchIndex = match.index; matchLength = match[0].length; score = scoreFuzzyMatch(); if ( (match = fuzzyRegexp.exec( value.slice((i = value.lastIndexOf(SEPARATOR) + 1)), )) ) { matchIndex = i + match.index; matchLength = match[0].length; return Math.max(score, scoreFuzzyMatch()); } else { return score; } } function scoreFuzzyMatch() { // When the match is at the beginning of the string or preceded by a dot. if (matchIndex === 0 || value.charAt(matchIndex - 1) === SEPARATOR) { return Math.max(66, 100 - matchLength); // When the match is at the end of the string. } else if (matchIndex + matchLength === valueLength) { return Math.max(33, 67 - matchLength); // When the match is in the middle of the string. } else { return Math.max(1, 34 - matchLength); } } // // Searchers // app.Searcher = class Searcher extends Events { static CHUNK_SIZE = 20000; static DEFAULTS = { max_results: app.config.max_results, fuzzy_min_length: 3, }; static SEPARATORS_REGEXP = /#|::|:-|->|\$(?=\w)|\-(?=\w)|\:(?=\w)|\ [\/\-&]\ |:\ |\ /g; static EOS_SEPARATORS_REGEXP = /(\w)[\-:]$/; static INFO_PARANTHESES_REGEXP = /\ \(\w+?\)$/; static EMPTY_PARANTHESES_REGEXP = /\(\)/; static EVENT_REGEXP = /\ event$/; static DOT_REGEXP = /\.+/g; static WHITESPACE_REGEXP = /\s/g; static EMPTY_STRING = ""; static ELLIPSIS = "..."; static STRING = "string"; static normalizeString(string) { return string .toLowerCase() .replace(Searcher.ELLIPSIS, Searcher.EMPTY_STRING) .replace(Searcher.EVENT_REGEXP, Searcher.EMPTY_STRING) .replace(Searcher.INFO_PARANTHESES_REGEXP, Searcher.EMPTY_STRING) .replace(Searcher.SEPARATORS_REGEXP, SEPARATOR) .replace(Searcher.DOT_REGEXP, SEPARATOR) .replace(Searcher.EMPTY_PARANTHESES_REGEXP, Searcher.EMPTY_STRING) .replace(Searcher.WHITESPACE_REGEXP, Searcher.EMPTY_STRING); } static normalizeQuery(string) { string = this.normalizeString(string); return string.replace(Searcher.EOS_SEPARATORS_REGEXP, "$1."); } constructor(options) { super(); this.options = { ...Searcher.DEFAULTS, ...(options || {}) }; } find(data, attr, q) { this.kill(); this.data = data; this.attr = attr; this.query = q; this.setup(); if (this.isValid()) { this.match(); } else { this.end(); } } setup() { query = this.query = this.constructor.normalizeQuery(this.query); queryLength = query.length; this.dataLength = this.data.length; this.matchers = [exactMatch]; this.totalResults = 0; this.setupFuzzy(); } setupFuzzy() { if (queryLength >= this.options.fuzzy_min_length) { fuzzyRegexp = this.queryToFuzzyRegexp(query); this.matchers.push(fuzzyMatch); } else { fuzzyRegexp = null; } } isValid() { return queryLength > 0 && query !== SEPARATOR; } end() { if (!this.totalResults) { this.triggerResults([]); } this.trigger("end"); this.free(); } kill() { if (this.timeout) { clearTimeout(this.timeout); this.free(); } } free() { this.data = null; this.attr = null; this.dataLength = null; this.matchers = null; this.matcher = null; this.query = null; this.totalResults = null; this.scoreMap = null; this.cursor = null; this.timeout = null; } match() { if (!this.foundEnough() && (this.matcher = this.matchers.shift())) { this.setupMatcher(); this.matchChunks(); } else { this.end(); } } setupMatcher() { this.cursor = 0; this.scoreMap = new Array(101); } matchChunks() { this.matchChunk(); if (this.cursor === this.dataLength || this.scoredEnough()) { this.delay(() => this.match()); this.sendResults(); } else { this.delay(() => this.matchChunks()); } } matchChunk() { ({ matcher } = this); for (let j = 0, end = this.chunkSize(); j < end; j++) { value = this.data[this.cursor][this.attr]; if (value.split) { // string valueLength = value.length; if ((score = matcher())) { this.addResult(this.data[this.cursor], score); } } else { // array score = 0; for (value of Array.from(this.data[this.cursor][this.attr])) { valueLength = value.length; score = Math.max(score, matcher() || 0); } if (score > 0) { this.addResult(this.data[this.cursor], score); } } this.cursor++; } } chunkSize() { if (this.cursor + Searcher.CHUNK_SIZE > this.dataLength) { return this.dataLength % Searcher.CHUNK_SIZE; } else { return Searcher.CHUNK_SIZE; } } scoredEnough() { return this.scoreMap[100]?.length >= this.options.max_results; } foundEnough() { return this.totalResults >= this.options.max_results; } addResult(object, score) { let name; ( this.scoreMap[(name = Math.round(score))] || (this.scoreMap[name] = []) ).push(object); this.totalResults++; } getResults() { const results = []; for (let j = this.scoreMap.length - 1; j >= 0; j--) { var objects = this.scoreMap[j]; if (objects) { results.push(...objects); } } return results.slice(0, this.options.max_results); } sendResults() { const results = this.getResults(); if (results.length) { this.triggerResults(results); } } triggerResults(results) { this.trigger("results", results); } delay(fn) { return (this.timeout = setTimeout(fn, 1)); } queryToFuzzyRegexp(string) { const chars = string.split(""); for (i = 0; i < chars.length; i++) { var char = chars[i]; chars[i] = $.escapeRegexp(char); } return new RegExp(chars.join(".*?")); // abc -> /a.*?b.*?c.*?/ } }; app.SynchronousSearcher = class SynchronousSearcher extends app.Searcher { match() { if (this.matcher) { if (!this.allResults) { this.allResults = []; } this.allResults.push(...this.getResults()); } return super.match(...arguments); } free() { this.allResults = null; return super.free(...arguments); } end() { this.sendResults(true); return super.end(...arguments); } sendResults(end) { if (end && this.allResults?.length) { return this.triggerResults(this.allResults); } } delay(fn) { return fn(); } }; ================================================ FILE: assets/javascripts/app/serviceworker.js ================================================ app.ServiceWorker = class ServiceWorker extends Events { static isEnabled() { return !!navigator.serviceWorker && app.config.service_worker_enabled; } constructor() { super(); this.onStateChange = this.onStateChange.bind(this); this.registration = null; this.notifyUpdate = true; navigator.serviceWorker .register(app.config.service_worker_path, { scope: "/" }) .then( (registration) => this.updateRegistration(registration), (error) => console.error("Could not register service worker:", error), ); } update() { if (!this.registration) { return; } this.notifyUpdate = true; return this.registration.update().catch(() => {}); } updateInBackground() { if (!this.registration) { return; } this.notifyUpdate = false; return this.registration.update().catch(() => {}); } reload() { return this.updateInBackground().then(() => app.reboot()); } updateRegistration(registration) { this.registration = registration; $.on(this.registration, "updatefound", () => this.onUpdateFound()); } onUpdateFound() { if (this.installingRegistration) { $.off(this.installingRegistration, "statechange", this.onStateChange); } this.installingRegistration = this.registration.installing; $.on(this.installingRegistration, "statechange", this.onStateChange); } onStateChange() { if ( this.installingRegistration && this.installingRegistration.state === "installed" && navigator.serviceWorker.controller ) { this.installingRegistration = null; this.onUpdateReady(); } } onUpdateReady() { if (this.notifyUpdate) { this.trigger("updateready"); } } }; ================================================ FILE: assets/javascripts/app/settings.js ================================================ app.Settings = class Settings { static PREFERENCE_KEYS = [ "hideDisabled", "hideIntro", "manualUpdate", "fastScroll", "arrowScroll", "analyticsConsent", "docs", "dark", // legacy "theme", "layout", "size", "tips", "noAutofocus", "autoInstall", "spaceScroll", "spaceTimeout", "noDocSpecificIcon", ]; static INTERNAL_KEYS = ["count", "schema", "version", "news"]; static LAYOUTS = [ "_max-width", "_sidebar-hidden", "_native-scrollbars", "_text-justify-hyphenate", ]; static defaults = { count: 0, hideDisabled: false, hideIntro: false, news: 0, manualUpdate: false, schema: 1, analyticsConsent: false, theme: "auto", spaceScroll: 1, spaceTimeout: 0.5, noDocSpecificIcon: false, }; constructor() { this.store = new CookiesStore(); this.cache = {}; this.autoSupported = window.matchMedia("(prefers-color-scheme)").media !== "not all"; if (this.autoSupported) { this.darkModeQuery = window.matchMedia("(prefers-color-scheme: dark)"); this.darkModeQuery.addListener(() => this.setTheme(this.get("theme"))); } } get(key) { let left; if (this.cache.hasOwnProperty(key)) { return this.cache[key]; } this.cache[key] = (left = this.store.get(key)) != null ? left : this.constructor.defaults[key]; if (key === "theme" && this.cache[key] === "auto" && !this.darkModeQuery) { return (this.cache[key] = "default"); } else { return this.cache[key]; } } set(key, value) { this.store.set(key, value); delete this.cache[key]; if (key === "theme") { this.setTheme(value); } } del(key) { this.store.del(key); delete this.cache[key]; } hasDocs() { try { return !!this.store.get("docs"); } catch (error) {} } getDocs() { return this.store.get("docs")?.split("/") || app.config.default_docs; } setDocs(docs) { this.set("docs", docs.join("/")); } getTips() { return this.store.get("tips")?.split("/") || []; } setTips(tips) { this.set("tips", tips.join("/")); } setLayout(name, enable) { this.toggleLayout(name, enable); const layout = (this.store.get("layout") || "").split(" "); $.arrayDelete(layout, ""); if (enable) { if (!layout.includes(name)) { layout.push(name); } } else { $.arrayDelete(layout, name); } if (layout.length > 0) { this.set("layout", layout.join(" ")); } else { this.del("layout"); } } hasLayout(name) { const layout = (this.store.get("layout") || "").split(" "); return layout.includes(name); } setSize(value) { this.set("size", value); } dump() { return this.store.dump(); } export() { const data = this.dump(); for (var key of Settings.INTERNAL_KEYS) { delete data[key]; } return data; } import(data) { let key, value; const object = this.export(); for (key in object) { value = object[key]; if (!data.hasOwnProperty(key)) { this.del(key); } } for (key in data) { value = data[key]; if (Settings.PREFERENCE_KEYS.includes(key)) { this.set(key, value); } } } reset() { this.store.reset(); this.cache = {}; } initLayout() { if (this.get("dark") === 1) { this.set("theme", "dark"); this.del("dark"); } this.setTheme(this.get("theme")); for (var layout of app.Settings.LAYOUTS) { this.toggleLayout(layout, this.hasLayout(layout)); } this.initSidebarWidth(); } setTheme(theme) { if (theme === "auto") { theme = this.darkModeQuery.matches ? "dark" : "default"; } const { classList } = document.documentElement; classList.remove("_theme-default", "_theme-dark"); classList.add("_theme-" + theme); this.updateColorMeta(); } updateColorMeta() { const color = getComputedStyle(document.documentElement) .getPropertyValue("--headerBackground") .trim(); $("meta[name=theme-color]").setAttribute("content", color); } toggleLayout(layout, enable) { const { classList } = document.body; // sidebar is always shown for settings; its state is updated in app.views.Settings if (layout !== "_sidebar-hidden" || !app.router?.isSettings) { classList.toggle(layout, enable); } classList.toggle("_overlay-scrollbars", $.overlayScrollbarsEnabled()); } initSidebarWidth() { const size = this.get("size"); if (size) { document.documentElement.style.setProperty("--sidebarWidth", size + "px"); } } }; ================================================ FILE: assets/javascripts/app/shortcuts.js ================================================ app.Shortcuts = class Shortcuts extends Events { constructor() { super(); this.onKeydown = this.onKeydown.bind(this); this.onKeypress = this.onKeypress.bind(this); this.isMac = $.isMac(); this.start(); } start() { $.on(document, "keydown", this.onKeydown); $.on(document, "keypress", this.onKeypress); } stop() { $.off(document, "keydown", this.onKeydown); $.off(document, "keypress", this.onKeypress); } swapArrowKeysBehavior() { return app.settings.get("arrowScroll"); } spaceScroll() { return app.settings.get("spaceScroll"); } showTip() { app.showTip("KeyNav"); return (this.showTip = null); } spaceTimeout() { return app.settings.get("spaceTimeout"); } onKeydown(event) { if (this.buggyEvent(event)) { return; } const result = (() => { if (event.ctrlKey || event.metaKey) { if (!event.altKey && !event.shiftKey) { return this.handleKeydownSuperEvent(event); } } else if (event.shiftKey) { if (!event.altKey) { return this.handleKeydownShiftEvent(event); } } else if (event.altKey) { return this.handleKeydownAltEvent(event); } else { return this.handleKeydownEvent(event); } })(); if (result === false) { event.preventDefault(); } } onKeypress(event) { if ( this.buggyEvent(event) || (event.charCode === 63 && document.activeElement.tagName === "INPUT") ) { return; } if (!event.ctrlKey && !event.metaKey) { const result = this.handleKeypressEvent(event); if (result === false) { event.preventDefault(); } } } handleKeydownEvent(event, _force) { if ( !_force && [37, 38, 39, 40].includes(event.which) && this.swapArrowKeysBehavior() ) { return this.handleKeydownAltEvent(event, true); } if ( !event.target.form && ((48 <= event.which && event.which <= 57) || (65 <= event.which && event.which <= 90)) ) { this.trigger("typing"); return; } switch (event.which) { case 8: if (!event.target.form) { return this.trigger("typing"); } break; case 13: return this.trigger("enter"); case 27: this.trigger("escape"); return false; case 32: if ( event.target.type === "search" && this.spaceScroll() && (!this.lastKeypress || this.lastKeypress < Date.now() - this.spaceTimeout() * 1000) ) { this.trigger("pageDown"); return false; } break; case 33: return this.trigger("pageUp"); case 34: return this.trigger("pageDown"); case 35: if (!event.target.form) { return this.trigger("pageBottom"); } break; case 36: if (!event.target.form) { return this.trigger("pageTop"); } break; case 37: if (!event.target.value) { return this.trigger("left"); } break; case 38: this.trigger("up"); if (typeof this.showTip === "function") { this.showTip(); } return false; case 39: if (!event.target.value) { return this.trigger("right"); } break; case 40: this.trigger("down"); if (typeof this.showTip === "function") { this.showTip(); } return false; case 191: if (!event.target.form) { this.trigger("typing"); return false; } break; } } handleKeydownSuperEvent(event) { switch (event.which) { case 13: return this.trigger("superEnter"); case 37: if (this.isMac) { this.trigger("superLeft"); return false; } break; case 38: this.trigger("pageTop"); return false; case 39: if (this.isMac) { this.trigger("superRight"); return false; } break; case 40: this.trigger("pageBottom"); return false; case 188: this.trigger("preferences"); return false; } } handleKeydownShiftEvent(event, _force) { if ( !_force && [37, 38, 39, 40].includes(event.which) && this.swapArrowKeysBehavior() ) { return this.handleKeydownEvent(event, true); } if (!event.target.form && 65 <= event.which && event.which <= 90) { this.trigger("typing"); return; } switch (event.which) { case 32: this.trigger("pageUp"); return false; case 38: if (!getSelection()?.toString()) { this.trigger("altUp"); return false; } break; case 40: if (!getSelection()?.toString()) { this.trigger("altDown"); return false; } break; } } handleKeydownAltEvent(event, _force) { if ( !_force && [37, 38, 39, 40].includes(event.which) && this.swapArrowKeysBehavior() ) { return this.handleKeydownEvent(event, true); } switch (event.which) { case 9: return this.trigger("altRight", event); case 37: if (!this.isMac) { this.trigger("superLeft"); return false; } break; case 38: this.trigger("altUp"); return false; case 39: if (!this.isMac) { this.trigger("superRight"); return false; } break; case 40: this.trigger("altDown"); return false; case 67: this.trigger("altC"); return false; case 68: this.trigger("altD"); return false; case 70: return this.trigger("altF", event); case 71: this.trigger("altG"); return false; case 79: this.trigger("altO"); return false; case 82: this.trigger("altR"); return false; case 83: this.trigger("altS"); return false; } } handleKeypressEvent(event) { if (event.which === 63 && !event.target.value) { this.trigger("help"); return false; } else { return (this.lastKeypress = Date.now()); } } buggyEvent(event) { try { event.target; event.ctrlKey; event.which; return false; } catch (error) { return true; } } }; ================================================ FILE: assets/javascripts/app/update_checker.js ================================================ app.UpdateChecker = class UpdateChecker { constructor() { this.lastCheck = Date.now(); $.on(window, "focus", () => this.onFocus()); if (app.serviceWorker) { app.serviceWorker.on("updateready", () => this.onUpdateReady()); } setTimeout(() => this.checkDocs(), 0); } check() { if (app.serviceWorker) { app.serviceWorker.update(); } else { ajax({ url: $('script[src*="application"]').getAttribute("src"), dataType: "application/javascript", error: (_, xhr) => { if (xhr.status === 404) { return this.onUpdateReady(); } }, }); } } onUpdateReady() { new app.views.Notif("UpdateReady", { autoHide: null }); } checkDocs() { if (!app.settings.get("manualUpdate")) { app.docs.updateInBackground(); } else { app.docs.checkForUpdates((i) => { if (i > 0) { return this.onDocsUpdateReady(); } }); } } onDocsUpdateReady() { new app.views.Notif("UpdateDocs", { autoHide: null }); } onFocus() { if (Date.now() - this.lastCheck > 21600e3) { this.lastCheck = Date.now(); this.check(); } } }; ================================================ FILE: assets/javascripts/application.js ================================================ //= require_tree ./vendor //= require lib/license //= require_tree ./lib //= require app/app //= require app/config //= require_tree ./app //= require collections/collection //= require_tree ./collections //= require models/model //= require_tree ./models //= require views/view //= require_tree ./views //= require_tree ./templates //= link_tree ../images/sprites //= require tracking var init = function () { document.removeEventListener("DOMContentLoaded", init, false); if (document.body) { return app.init(); } else { return setTimeout(init, 42); } }; document.addEventListener("DOMContentLoaded", init, false); ================================================ FILE: assets/javascripts/collections/collection.js ================================================ app.Collection = class Collection { constructor(objects) { if (objects == null) { objects = []; } this.reset(objects); } model() { return app.models[this.constructor.model]; } reset(objects) { if (objects == null) { objects = []; } this.models = []; for (var object of objects) { this.add(object); } } add(object) { if (object instanceof app.Model) { this.models.push(object); } else if (object instanceof Array) { for (var obj of object) { this.add(obj); } } else if (object instanceof app.Collection) { this.models.push(...(object.all() || [])); } else { this.models.push(new (this.model())(object)); } } remove(model) { this.models.splice(this.models.indexOf(model), 1); } size() { return this.models.length; } isEmpty() { return this.models.length === 0; } each(fn) { for (var model of this.models) { fn(model); } } all() { return this.models; } contains(model) { return this.models.includes(model); } findBy(attr, value) { return this.models.find((model) => model[attr] === value); } findAllBy(attr, value) { return this.models.filter((model) => model[attr] === value); } countAllBy(attr, value) { let i = 0; for (var model of this.models) { if (model[attr] === value) { i += 1; } } return i; } }; ================================================ FILE: assets/javascripts/collections/docs.js ================================================ app.collections.Docs = class Docs extends app.Collection { static model = "Doc"; static NORMALIZE_VERSION_RGX = /\.(\d)$/; static NORMALIZE_VERSION_SUB = ".0$1"; // Load models concurrently. // It's not pretty but I didn't want to import a promise library only for this. static CONCURRENCY = 3; findBySlug(slug) { return ( this.findBy("slug", slug) || this.findBy("slug_without_version", slug) ); } sort() { return this.models.sort((a, b) => { if (a.name === b.name) { if ( !a.version || a.version.replace( Docs.NORMALIZE_VERSION_RGX, Docs.NORMALIZE_VERSION_SUB, ) > b.version.replace( Docs.NORMALIZE_VERSION_RGX, Docs.NORMALIZE_VERSION_SUB, ) ) { return -1; } else { return 1; } } else if (a.name.toLowerCase() > b.name.toLowerCase()) { return 1; } else { return -1; } }); } load(onComplete, onError, options) { let i = 0; var next = () => { if (i < this.models.length) { this.models[i].load(next, fail, options); } else if (i === this.models.length + Docs.CONCURRENCY - 1) { onComplete(); } i++; }; var fail = function (...args) { if (onError) { onError(args); onError = null; } next(); }; for (let j = 0, end = Docs.CONCURRENCY; j < end; j++) { next(); } } clearCache() { for (var doc of this.models) { doc.clearCache(); } } uninstall(callback) { let i = 0; var next = () => { if (i < this.models.length) { this.models[i++].uninstall(next, next); } else { callback(); } }; next(); } getInstallStatuses(callback) { app.db.versions(this.models, (statuses) => { if (statuses) { for (var key in statuses) { var value = statuses[key]; statuses[key] = { installed: !!value, mtime: value }; } } callback(statuses); }); } checkForUpdates(callback) { this.getInstallStatuses((statuses) => { let i = 0; if (statuses) { for (var slug in statuses) { var status = statuses[slug]; if (this.findBy("slug", slug).isOutdated(status)) { i += 1; } } } callback(i); }); } updateInBackground() { this.getInstallStatuses((statuses) => { if (!statuses) { return; } for (var slug in statuses) { var status = statuses[slug]; var doc = this.findBy("slug", slug); if (doc.isOutdated(status)) { doc.install($.noop, $.noop); } } }); } }; ================================================ FILE: assets/javascripts/collections/entries.js ================================================ app.collections.Entries = class Entries extends app.Collection { static model = "Entry"; }; ================================================ FILE: assets/javascripts/collections/types.js ================================================ app.collections.Types = class Types extends app.Collection { static model = "Type"; static GUIDES_RGX = /(^|\()(guides?|tutorials?|reference|book|getting\ started|manual|examples)($|[\):])/i; static APPENDIX_RGX = /appendix/i; groups() { const result = []; for (var type of this.models) { const name = this._groupFor(type); result[name] ||= []; result[name].push(type); } return result.filter((e) => e.length > 0); } _groupFor(type) { if (Types.GUIDES_RGX.test(type.name)) { return 0; } else if (Types.APPENDIX_RGX.test(type.name)) { return 2; } else { return 1; } } }; ================================================ FILE: assets/javascripts/debug.js ================================================ // // App // const _init = app.init; app.init = function () { console.time("Init"); _init.call(app); console.timeEnd("Init"); return console.time("Load"); }; const _start = app.start; app.start = function () { console.timeEnd("Load"); console.time("Start"); _start.call(app, ...arguments); return console.timeEnd("Start"); }; // // Searcher // app.Searcher = class TimingSearcher extends app.Searcher { setup() { console.groupCollapsed(`Search: ${this.query}`); console.time("Total"); return super.setup(); } match() { if (this.matcher) { console.timeEnd(this.matcher.name); } return super.match(); } setupMatcher() { console.time(this.matcher.name); return super.setupMatcher(); } end() { console.log(`Results: ${this.totalResults}`); console.timeEnd("Total"); console.groupEnd(); return super.end(); } kill() { if (this.timeout) { if (this.matcher) { console.timeEnd(this.matcher.name); } console.groupEnd(); console.timeEnd("Total"); console.warn("Killed"); } return super.kill(); } }; // // View tree // this.viewTree = function (view, level, visited) { if (view == null) { view = app.document; } if (level == null) { level = 0; } if (visited == null) { visited = []; } if (visited.includes(view)) { return; } visited.push(view); console.log( `%c ${Array(level + 1).join(" ")}${ view.constructor.name }: ${!!view.activated}`, "color:" + ((view.activated && "green") || "red"), ); for (var key of Object.keys(view || {})) { var value = view[key]; if (key !== "view" && value) { if (typeof value === "object" && value.setupElement) { this.viewTree(value, level + 1, visited); } else if (value.constructor.toString().match(/Object\(\)/)) { for (var k of Object.keys(value || {})) { var v = value[k]; if (v && typeof v === "object" && v.setupElement) { this.viewTree(v, level + 1, visited); } } } } } }; ================================================ FILE: assets/javascripts/docs.js.erb ================================================ //= depend_on docs.json app.DOCS = <%= File.read App.docs_manifest_path %>; ================================================ FILE: assets/javascripts/lib/ajax.js ================================================ const MIME_TYPES = { json: "application/json", html: "text/html", }; function ajax(options) { applyDefaults(options); serializeData(options); const xhr = new XMLHttpRequest(); xhr.open(options.type, options.url, options.async); applyCallbacks(xhr, options); applyHeaders(xhr, options); xhr.send(options.data); if (options.async) { return { abort: abort.bind(undefined, xhr) }; } else { return parseResponse(xhr, options); } function applyDefaults(options) { for (var key in ajax.defaults) { if (options[key] == null) { options[key] = ajax.defaults[key]; } } } function serializeData(options) { if (!options.data) { return; } if (options.type === "GET") { options.url += "?" + serializeParams(options.data); options.data = null; } else { options.data = serializeParams(options.data); } } function serializeParams(params) { return Object.entries(params) .map( ([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`, ) .join("&"); } function applyCallbacks(xhr, options) { if (!options.async) { return; } xhr.timer = setTimeout( onTimeout.bind(undefined, xhr, options), options.timeout * 1000, ); if (options.progress) { xhr.onprogress = options.progress; } xhr.onreadystatechange = function () { if (xhr.readyState === 4) { clearTimeout(xhr.timer); onComplete(xhr, options); } }; } function applyHeaders(xhr, options) { if (!options.headers) { options.headers = {}; } if (options.contentType) { options.headers["Content-Type"] = options.contentType; } if ( !options.headers["Content-Type"] && options.data && options.type !== "GET" ) { options.headers["Content-Type"] = "application/x-www-form-urlencoded"; } if (options.dataType) { options.headers["Accept"] = MIME_TYPES[options.dataType] || options.dataType; } for (var key in options.headers) { var value = options.headers[key]; xhr.setRequestHeader(key, value); } } function onComplete(xhr, options) { if (200 <= xhr.status && xhr.status < 300) { const response = parseResponse(xhr, options); if (response != null) { onSuccess(response, xhr, options); } else { onError("invalid", xhr, options); } } else { onError("error", xhr, options); } } function onSuccess(response, xhr, options) { if (options.success != null) { options.success.call(options.context, response, xhr, options); } } function onError(type, xhr, options) { if (options.error != null) { options.error.call(options.context, type, xhr, options); } } function onTimeout(xhr, options) { xhr.abort(); onError("timeout", xhr, options); } function abort(xhr) { clearTimeout(xhr.timer); xhr.onreadystatechange = null; xhr.abort(); } function parseResponse(xhr, options) { if (options.dataType === "json") { return parseJSON(xhr.responseText); } else { return xhr.responseText; } } function parseJSON(json) { try { return JSON.parse(json); } catch (error) {} } } ajax.defaults = { async: true, dataType: "json", timeout: 30, type: "GET", // contentType // context // data // error // headers // progress // success // url }; ================================================ FILE: assets/javascripts/lib/cookies_store.js ================================================ // Intentionally called CookiesStore instead of CookieStore // Calling it CookieStore causes issues when the Experimental Web Platform features flag is enabled in Chrome // Related issue: https://github.com/freeCodeCamp/devdocs/issues/932 class CookiesStore { static INT = /^\d+$/; static onBlocked() {} get(key) { let value = Cookies.get(key); if (value != null && CookiesStore.INT.test(value)) { value = parseInt(value, 10); } return value; } set(key, value) { if (value === false) { this.del(key); return; } if (value === true) { value = 1; } if ( value && (typeof CookiesStore.INT.test === "function" ? CookiesStore.INT.test(value) : undefined) ) { value = parseInt(value, 10); } Cookies.set(key, "" + value, { path: "/", expires: 1e8 }); if (this.get(key) !== value) { CookiesStore.onBlocked(key, value, this.get(key)); } } del(key) { Cookies.expire(key); } reset() { try { for (var cookie of document.cookie.split(/;\s?/)) { Cookies.expire(cookie.split("=")[0]); } return; } catch (error) {} } dump() { const result = {}; for (var cookie of document.cookie.split(/;\s?/)) { if (cookie[0] !== "_") { cookie = cookie.split("="); result[cookie[0]] = cookie[1]; } } return result; } } ================================================ FILE: assets/javascripts/lib/events.js ================================================ class Events { on(event, callback) { if (event.includes(" ")) { for (var name of event.split(" ")) { this.on(name, callback); } } else { this._callbacks ||= {}; this._callbacks[event] ||= []; this._callbacks[event].push(callback); } return this; } off(event, callback) { let callbacks, index; if (event.includes(" ")) { for (var name of event.split(" ")) { this.off(name, callback); } } else if ( (callbacks = this._callbacks?.[event]) && (index = callbacks.indexOf(callback)) >= 0 ) { callbacks.splice(index, 1); if (!callbacks.length) { delete this._callbacks[event]; } } return this; } trigger(event, ...args) { this.eventInProgress = { name: event, args }; const callbacks = this._callbacks?.[event]; if (callbacks) { for (const callback of callbacks.slice(0)) { if (typeof callback === "function") { callback(...args); } } } this.eventInProgress = null; if (event !== "all") { this.trigger("all", event, ...args); } return this; } removeEvent(event) { if (this._callbacks != null) { for (var name of event.split(" ")) { delete this._callbacks[name]; } } return this; } } ================================================ FILE: assets/javascripts/lib/favicon.js ================================================ let defaultUrl = null; let currentSlug = null; const imageCache = {}; const urlCache = {}; const withImage = function (url, action) { if (imageCache[url]) { return action(imageCache[url]); } else { const img = new Image(); img.crossOrigin = "anonymous"; img.src = url; return (img.onload = () => { imageCache[url] = img; return action(img); }); } }; this.setFaviconForDoc = function (doc) { if (currentSlug === doc.slug || app.settings.get("noDocSpecificIcon")) { return; } const favicon = $('link[rel="icon"]'); if (defaultUrl === null) { defaultUrl = favicon.href; } if (urlCache[doc.slug]) { favicon.href = urlCache[doc.slug]; currentSlug = doc.slug; return; } const iconEl = $(`._icon-${doc.slug.split("~")[0]}`); if (iconEl === null) { return; } const styles = window.getComputedStyle(iconEl, ":before"); const backgroundPositionX = styles["background-position-x"]; const backgroundPositionY = styles["background-position-y"]; if (backgroundPositionX === undefined || backgroundPositionY === undefined) { return; } const bgUrl = app.config.favicon_spritesheet; const sourceSize = 16; const sourceX = Math.abs(parseInt(backgroundPositionX.slice(0, -2))); const sourceY = Math.abs(parseInt(backgroundPositionY.slice(0, -2))); return withImage(bgUrl, (docImg) => withImage(defaultUrl, function (defaultImg) { const size = defaultImg.width; const canvas = document.createElement("canvas"); const ctx = canvas.getContext("2d"); canvas.width = size; canvas.height = size; ctx.drawImage(defaultImg, 0, 0); const docIconPercentage = 65; const destinationCoords = (size / 100) * (100 - docIconPercentage); const destinationSize = (size / 100) * docIconPercentage; ctx.drawImage( docImg, sourceX, sourceY, sourceSize, sourceSize, destinationCoords, destinationCoords, destinationSize, destinationSize, ); try { urlCache[doc.slug] = canvas.toDataURL(); favicon.href = urlCache[doc.slug]; return (currentSlug = doc.slug); } catch (error) { Raven.captureException(error, { level: "info" }); return this.resetFavicon(); } }), ); }; this.resetFavicon = function () { if (defaultUrl !== null && currentSlug !== null) { $('link[rel="icon"]').href = defaultUrl; return (currentSlug = null); } }; ================================================ FILE: assets/javascripts/lib/license.js ================================================ /* * Copyright 2013-2026 Thibaut Courouble and other contributors * * This source code is licensed under the terms of the Mozilla * Public License, v. 2.0, a copy of which may be obtained at: * http://mozilla.org/MPL/2.0/ */ ================================================ FILE: assets/javascripts/lib/local_storage_store.js ================================================ this.LocalStorageStore = class LocalStorageStore { get(key) { try { return JSON.parse(localStorage.getItem(key)); } catch (error) {} } set(key, value) { try { localStorage.setItem(key, JSON.stringify(value)); return true; } catch (error) {} } del(key) { try { localStorage.removeItem(key); return true; } catch (error) {} } reset() { try { localStorage.clear(); return true; } catch (error) {} } }; ================================================ FILE: assets/javascripts/lib/page.js ================================================ /* * Based on github.com/visionmedia/page.js * Licensed under the MIT license * Copyright 2012 TJ Holowaychuk */ 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); } } }; ================================================ FILE: assets/javascripts/lib/util.js ================================================ // // 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); }; ================================================ FILE: assets/javascripts/models/doc.js ================================================ 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; } }; ================================================ FILE: assets/javascripts/models/entry.js ================================================ //= 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); } }; ================================================ FILE: assets/javascripts/models/model.js ================================================ app.Model = class Model { constructor(attributes) { for (var key in attributes) { var value = attributes[key]; this[key] = value; } } }; ================================================ FILE: assets/javascripts/models/type.js ================================================ 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(), }); } }; ================================================ FILE: assets/javascripts/news.json ================================================ [ [ "2026-02-14", "New documentation: CouchDB" ], [ "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" ], [ "2016-09-18", "New documentations: pandas and Twig" ], [ "2016-09-05", "New documentations: Fish, Bottle and scikit-image" ], [ "2016-08-07", "New documentation: Docker" ], [ "2016-07-31", "New documentations: Bootstrap 3 and Bootstrap 4" ], [ "2016-07-24", "New documentations: Julia, Crystal and Redux" ], [ "2016-07-03", "New documentations: CMake and Matplotlib" ], [ "2016-06-19", "New documentation: LÖVE" ], [ "2016-06-12", "New documentation: Angular 2" ], [ "2016-06-05", "New documentations: Kotlin and Padrino" ], [ "2016-04-24", "New documentations: NumPy and Apache Pig" ], [ "2016-04-17", "New documentation: Perl" ], [ "2016-04-10", "New documentations: Support tables (caniuse.com), GCC and GNU Fortran" ], [ "2016-03-27", "New documentation: TypeScript" ], [ "2016-03-06", "New documentations: TensorFlow, Haxe and Ansible" ], [ "2016-02-28", "New documentations: CodeIgniter, nginx Lua Module and InfluxData" ], [ "2016-02-15", "New documentations: CakePHP, Chef and Ramda" ], [ "2016-01-31", "New documentations: Erlang and Tcl/Tk" ], [ "2016-01-24", "“Multi-version support” has landed!" ], [ "2015-11-22", "New documentations: Phoenix, Dojo, Relay and Flow" ], [ "2015-11-08", "New documentations: Elixir and Vagrant" ], [ "2015-10-18", "Added a \"Copy to clipboard\" button inside each code block." ], [ "2015-09-13", "New documentation: Phalcon" ], [ "2015-08-09", "New documentation: React Native" ], [ "2015-08-03", "Added an icon in the sidebar to constrain the width of the UI (visible when applicable)." ], [ "2015-08-02", "New documentations: Q and OpenTSDB" ], [ "2015-07-26", "Added search aliases (e.g. $ is an alias for jQuery).\nClick here to see the full list. Feel free to suggest more on GitHub.", "Added shift + ↓/↑ shortcut for scrolling (same as alt + ↓/↑)." ], [ "2015-07-05", "New documentations: Drupal, Vue.js, Phaser and webpack" ], [ "2015-05-24", "New Rust documentation" ], [ "2015-04-26", "New Apache HTTP Server and npm documentations" ], [ "2015-03-22", "New Meteor and mocha documentations" ], [ "2015-02-22", "Improved HTTP documentation", "New Minitest documentation" ], [ "2015-02-16", "The sidebar is now resizable (drag & drop)." ], [ "2015-02-15", "New io.js, Symfony, Clojure, Lua and Yii 1.1 documentations" ], [ "2015-02-08", "New dark theme" ], [ "2015-01-13", "Offline mode has landed!" ], [ "2014-12-21", "New React, RethinkDB, Socket.IO, Modernizr and Bower documentations" ], [ "2014-11-30", "New PHPUnit and Nokogiri documentations" ], [ "2014-11-16", "New Python 2 documentation" ], [ "2014-11-09", "New design\nFeedback welcome on Twitter and GitHub." ], [ "2014-10-19", "New SVG, Marionette.js, and Mongoose documentations" ], [ "2014-10-18", "New nginx documentation" ], [ "2014-10-13", "New XPath documentation" ], [ "2014-09-07", "Updated the HTML, CSS, JavaScript, and DOM documentations with additional content." ], [ "2014-08-04", "New Django documentation" ], [ "2014-07-27", "New Markdown documentation" ], [ "2014-07-05", "New Cordova documentation" ], [ "2014-07-01", "New Chai and Sinon documentations" ], [ "2014-06-15", "New RequireJS documentation" ], [ "2014-06-14", "New Haskell documentation" ], [ "2014-05-25", "New Laravel documentation" ], [ "2014-05-04", "New Express, Grunt, and MaxCDN documentations" ], [ "2014-04-06", "New Go documentation" ], [ "2014-03-30", "New C++ documentation" ], [ "2014-03-16", "New Yii documentation" ], [ "2014-03-08", "Added path bar." ], [ "2014-02-22", "New C documentation" ], [ "2014-02-16", "New Moment.js documentation" ], [ "2014-02-12", "The root/category pages are now included in the search index (e.g. CSS)" ], [ "2014-01-19", "New D3.js and Knockout.js documentations" ], [ "2014-01-18", "DevDocs is now available as a Firefox web app." ], [ "2014-01-12", "Added alt + g shortcut for searching on Google.", "Added alt + r shortcut for revealing the current page in the sidebar." ], [ "2013-12-14", "New PostgreSQL documentation" ], [ "2013-12-13", "New Git and Redis documentations" ], [ "2013-11-26", "New Python documentation" ], [ "2013-11-19", "New Ruby on Rails documentation" ], [ "2013-11-16", "New Ruby documentation" ], [ "2013-10-24", "DevDocs is now open source." ], [ "2013-10-09", "DevDocs is now available as a Chrome web app." ], [ "2013-09-22", "New PHP documentation" ], [ "2013-09-06", "New Lo-Dash documentation ", "On mobile devices you can now search a specific documentation by typing its name and Space." ], [ "2013-09-01", "New jQuery UI and jQuery Mobile documentations" ], [ "2013-08-28", "New smartphone interface\nTested on iOS 6+ and Android 4.1+" ], [ "2013-08-25", "New Ember.js documentation" ], [ "2013-08-18", "New CoffeeScript documentation", "URL search now automatically opens the first result." ], [ "2013-08-13", "New Angular.js documentation" ], [ "2013-08-11", "New Sass and Less documentations" ], [ "2013-08-05", "New Node.js documentation" ], [ "2013-08-03", "Added support for OpenSearch" ], [ "2013-07-30", "New Backbone.js documentation" ], [ "2013-07-27", "You can now customize the list of documentations.\nNew docs will be hidden by default, but you'll see a notification when there are new releases.", "New HTTP documentation" ], [ "2013-07-15", "URL search now works with single documentations: devdocs.io/#q=js sort" ], [ "2013-07-13", "Added syntax highlighting", "Added documentation versions" ], [ "2013-07-11", "New Underscore.js documentation ", "Improved compatibility with tablets\nA mobile version is planned as soon as other high priority features have been implemented." ], [ "2013-07-10", "You can now search specific documentations.\nSimply type the documentation's name and press Tab.\nThe name is fuzzy matched so you can use abbreviations like js for JavaScript." ], [ "2013-07-08", "Improved search with fuzzy matching and better results\nFor example, searching jqmka now returns jQuery.makeArray().", "DevDocs finally has an icon.", "space has replaced alt + space for scrolling down." ], [ "2013-07-06", "New DOM and DOM Events documentations\nDevDocs now includes almost all reference documents available on the Mozilla Developer Network.\nBig thank you to Mozilla and all the people that contributed to MDN.", "Implemented URL search: devdocs.io/#q=sort" ], [ "2013-07-02", "New JavaScript documentation" ], [ "2013-06-28", "DevDocs made the front page of Hacker News!\nHi everyone — thanks for trying DevDocs.\nPlease bear with me while I fix bugs and scramble to add more docs.\nThis is only v1. There's a lot more to come." ], [ "2013-06-18", "Initial release" ] ] ================================================ FILE: assets/javascripts/templates/base.js ================================================ app.templates.render = function (name, value, ...args) { const template = app.templates[name]; if (Array.isArray(value)) { let result = ""; for (var val of value) { result += template(val, ...args); } return result; } else if (typeof template === "function") { return template(value, ...args); } else { return template; } }; ================================================ FILE: assets/javascripts/templates/error_tmpl.js ================================================ const error = function (title, text, links) { if (text == null) { text = ""; } if (links == null) { links = ""; } if (text) { text = `

${text}

`; } if (links) { links = `

${links}

`; } return `

${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} · 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 = 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

\ `; ================================================ FILE: assets/javascripts/templates/notice_tmpl.js ================================================ 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. `); ================================================ FILE: assets/javascripts/templates/notif_tmpl.js ================================================ const notif = function (title, html) { html = html.replace(/${title} ${html}
${docs}
Documentation Size Status Action

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

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 + ""; }; ================================================ FILE: assets/javascripts/templates/pages/root_tmpl.js.erb ================================================ 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.

\ `; ================================================ FILE: assets/javascripts/templates/pages/settings_tmpl.js ================================================ 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:

\ `; ================================================ FILE: assets/javascripts/templates/pages/type_tmpl.js ================================================ app.templates.typePage = (type) => { return `

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

    ${app.templates.render( "typePageEntry", type.entries(), )}
`; }; app.templates.typePageEntry = (entry) => { return `
  • ${$.escape(entry.name)}
  • `; }; ================================================ FILE: assets/javascripts/templates/path_tmpl.js ================================================ 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; }; ================================================ FILE: assets/javascripts/templates/sidebar_tmpl.js ================================================ 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\ `; ================================================ FILE: assets/javascripts/templates/tip_tmpl.js ================================================ 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\ `; ================================================ FILE: assets/javascripts/tracking.js ================================================ try { if (app.config.env === "production") { if (Cookies.get("analyticsConsent") === "1") { (function (i, s, o, g, r, a, m) { i["GoogleAnalyticsObject"] = r; (i[r] = i[r] || function () { (i[r].q = i[r].q || []).push(arguments); }), (i[r].l = 1 * new Date()); (a = s.createElement(o)), (m = s.getElementsByTagName(o)[0]); a.async = 1; a.src = g; m.parentNode.insertBefore(a, m); })( window, document, "script", "https://www.google-analytics.com/analytics.js", "ga", ); ga("create", "UA-5544833-12", "devdocs.io"); page.track(function () { ga("send", "pageview", { page: location.pathname + location.search + location.hash, dimension1: app.router.context && app.router.context.doc && app.router.context.doc.slug_without_version, }); }); page.track(function () { if (window._gauges) _gauges.push(["track"]); else (function () { var _gauges = _gauges || []; !(function () { var a = document.createElement("script"); (a.type = "text/javascript"), (a.async = !0), (a.id = "gauges-tracker"), a.setAttribute("data-site-id", "51c15f82613f5d7819000067"), (a.src = "https://secure.gaug.es/track.js"); var b = document.getElementsByTagName("script")[0]; b.parentNode.insertBefore(a, b); })(); })(); }); } else { resetAnalytics(); } } } catch (e) {} ================================================ FILE: assets/javascripts/vendor/cookies.js ================================================ /* * Cookies.js - 1.2.3 (patched for SameSite=Strict and secure=true) * https://github.com/ScottHamper/Cookies * * This is free and unencumbered software released into the public domain. */ (function (global, undefined) { "use strict"; var factory = function (window) { if (typeof window.document !== "object") { throw new Error( "Cookies.js requires a `window` with a `document` object", ); } var Cookies = function (key, value, options) { return arguments.length === 1 ? Cookies.get(key) : Cookies.set(key, value, options); }; // Allows for setter injection in unit tests Cookies._document = window.document; // Used to ensure cookie keys do not collide with // built-in `Object` properties Cookies._cacheKeyPrefix = "cookey."; // Hurr hurr, :) Cookies._maxExpireDate = new Date("Fri, 31 Dec 9999 23:59:59 UTC"); Cookies.defaults = { path: "/", SameSite: "Strict", secure: true, }; Cookies.get = function (key) { if (Cookies._cachedDocumentCookie !== Cookies._document.cookie) { Cookies._renewCache(); } var value = Cookies._cache[Cookies._cacheKeyPrefix + key]; return value === undefined ? undefined : decodeURIComponent(value); }; Cookies.set = function (key, value, options) { options = Cookies._getExtendedOptions(options); options.expires = Cookies._getExpiresDate( value === undefined ? -1 : options.expires, ); Cookies._document.cookie = Cookies._generateCookieString( key, value, options, ); return Cookies; }; Cookies.expire = function (key, options) { return Cookies.set(key, undefined, options); }; Cookies._getExtendedOptions = function (options) { return { path: (options && options.path) || Cookies.defaults.path, domain: (options && options.domain) || Cookies.defaults.domain, SameSite: (options && options.SameSite) || Cookies.defaults.SameSite, expires: (options && options.expires) || Cookies.defaults.expires, secure: options && options.secure !== undefined ? options.secure : Cookies.defaults.secure, }; }; Cookies._isValidDate = function (date) { return ( Object.prototype.toString.call(date) === "[object Date]" && !isNaN(date.getTime()) ); }; Cookies._getExpiresDate = function (expires, now) { now = now || new Date(); if (typeof expires === "number") { expires = expires === Infinity ? Cookies._maxExpireDate : new Date(now.getTime() + expires * 1000); } else if (typeof expires === "string") { expires = new Date(expires); } if (expires && !Cookies._isValidDate(expires)) { throw new Error( "`expires` parameter cannot be converted to a valid Date instance", ); } return expires; }; Cookies._generateCookieString = function (key, value, options) { key = key.replace(/[^#$&+\^`|]/g, encodeURIComponent); key = key.replace(/\(/g, "%28").replace(/\)/g, "%29"); value = (value + "").replace( /[^!#$&-+\--:<-\[\]-~]/g, encodeURIComponent, ); options = options || {}; var cookieString = key + "=" + value; cookieString += options.path ? ";path=" + options.path : ""; cookieString += options.domain ? ";domain=" + options.domain : ""; cookieString += options.SameSite ? ";SameSite=" + options.SameSite : ""; cookieString += options.expires ? ";expires=" + options.expires.toUTCString() : ""; cookieString += options.secure ? ";secure" : ""; return cookieString; }; Cookies._getCacheFromString = function (documentCookie) { var cookieCache = {}; var cookiesArray = documentCookie ? documentCookie.split("; ") : []; for (var i = 0; i < cookiesArray.length; i++) { var cookieKvp = Cookies._getKeyValuePairFromCookieString( cookiesArray[i], ); if ( cookieCache[Cookies._cacheKeyPrefix + cookieKvp.key] === undefined ) { cookieCache[Cookies._cacheKeyPrefix + cookieKvp.key] = cookieKvp.value; } } return cookieCache; }; Cookies._getKeyValuePairFromCookieString = function (cookieString) { // "=" is a valid character in a cookie value according to RFC6265, so cannot `split('=')` var separatorIndex = cookieString.indexOf("="); // IE omits the "=" when the cookie value is an empty string separatorIndex = separatorIndex < 0 ? cookieString.length : separatorIndex; var key = cookieString.substr(0, separatorIndex); var decodedKey; try { decodedKey = decodeURIComponent(key); } catch (e) { if (console && typeof console.error === "function") { console.error('Could not decode cookie with key "' + key + '"', e); } } return { key: decodedKey, value: cookieString.substr(separatorIndex + 1), // Defer decoding value until accessed }; }; Cookies._renewCache = function () { Cookies._cache = Cookies._getCacheFromString(Cookies._document.cookie); Cookies._cachedDocumentCookie = Cookies._document.cookie; }; Cookies._areEnabled = function () { var testKey = "cookies.js"; var areEnabled = Cookies.set(testKey, 1).get(testKey) === "1"; Cookies.expire(testKey); return areEnabled; }; Cookies.enabled = Cookies._areEnabled(); return Cookies; }; var cookiesExport = global && typeof global.document === "object" ? factory(global) : factory; // AMD support if (typeof define === "function" && define.amd) { define(function () { return cookiesExport; }); // CommonJS/Node.js support } else if (typeof exports === "object") { // Support Node.js specific `module.exports` (which can be a function) if (typeof module === "object" && typeof module.exports === "object") { exports = module.exports = cookiesExport; } // But always support CommonJS module 1.1.1 spec (`exports` cannot be a function) exports.Cookies = cookiesExport; } else { global.Cookies = cookiesExport; } })(typeof window === "undefined" ? this : window); ================================================ FILE: assets/javascripts/vendor/mathml.js ================================================ /* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. * Adapted from: https://github.com/fred-wang/mathml.css */ (function () { window.addEventListener("load", function () { var box, div, link, namespaceURI; // First check whether the page contains any element. namespaceURI = "http://www.w3.org/1998/Math/MathML"; // Create a div to test mspace, using Kuma's "offscreen" CSS 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) { window.supportsMathML = false; } }); })(); ================================================ FILE: assets/javascripts/vendor/prism.js ================================================ /* 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 ); /** * Prism: Lightweight, robust, elegant syntax highlighting * * @license MIT * @author Lea Verou * @namespace * @public */ var Prism = (function (_self) { // Private helper vars var lang = /(?:^|\s)lang(?:uage)?-([\w-]+)(?=\s|$)/i; var uniqueId = 0; // The grammar object for plaintext var plainTextGrammar = {}; var _ = { /** * By default, Prism will attempt to highlight all code elements (by calling {@link Prism.highlightAll}) on the * current page after the page finished loading. This might be a problem if e.g. you wanted to asynchronously load * additional languages or plugins yourself. * * By setting this value to `true`, Prism will not automatically highlight all code elements on the page. * * You obviously have to change this value before the automatic highlighting started. 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.manual = true; * // add a new \ `, ); return source.replace(/' assert_equal '
    ', filter_output_string end it "removes comments" do @body = '
    Test
    ' assert_equal '
    Test
    ', filter_output_string end it "removes extraneous whitespace" do @body = "

    \nTest \n

    \n
    \r
    \n\n " assert_equal '

    Test

    ', filter_output_string end it "doesn't remove whitespace from
     and  nodes" do
        @body = "
     \nTest\r 
    \nTest " assert_equal @body, filter_output_string end it "doesn't remove invalid strings" do @body = Nokogiri::HTML.parse "\x92" assert_equal @body.to_s, filter_output_string end end ================================================ FILE: test/lib/docs/filters/core/clean_text_test.rb ================================================ require_relative '../../../../test_helper' require_relative '../../../../../lib/docs' class CleanTextFilterTest < Minitest::Spec include FilterTestHelper self.filter_class = Docs::CleanTextFilter it "removes empty nodes" do @body = "

    \u00A0\n\r

    " assert_empty filter_output end it "doesn't remove empty " assert_equal @body, filter_output end it "strips leading and trailing whitespace" do @body = "\n\r Test \r\n" assert_equal 'Test', filter_output end end ================================================ FILE: test/lib/docs/filters/core/container_test.rb ================================================ require_relative '../../../../test_helper' require_relative '../../../../../lib/docs' class ContainerFilterTest < Minitest::Spec include FilterTestHelper self.filter_class = Docs::ContainerFilter self.filter_type = 'html' before do @body = '
    Test
    ' end context "when context[:container] is a CSS selector" do before { context[:container] = '.main' } it "returns the element when it exists" do @body = '
    Main
    ' assert_equal 'Main', filter_output.inner_html end it "raises an error when the element doesn't exist" do assert_raises Docs::ContainerFilter::ContainerNotFound do filter.call end end end context "when context[:container] is a block" do it "calls the block with itself" do context[:container] = ->(arg) { @arg = arg; nil } filter.call assert_equal filter, @arg end context "and the block returns a CSS selector" do before { context[:container] = ->(_) { '.main' } } it "returns the element when it exists" do @body = '
    Main
    ' assert_equal 'Main', filter_output.inner_html end it "raises an error when the element doesn't exist" do assert_raises Docs::ContainerFilter::ContainerNotFound do filter.call end end end context "and the block returns nil" do before { context[:container] = ->(_) { nil } } it "returns the document" do assert_equal @body, filter_output.inner_html end end end context "when context[:container] is nil" do context "and the document is an HTML fragment" do it "returns the document" do assert_equal @body, filter_output.inner_html end end context "and the document is an HTML document" do it "returns the " do @body = '
    Test
    ' assert_equal '
    Test
    ', filter_output.inner_html end end end end ================================================ FILE: test/lib/docs/filters/core/entries_test.rb ================================================ require_relative '../../../../test_helper' require_relative '../../../../../lib/docs' class EntriesFilterTest < Minitest::Spec include FilterTestHelper self.filter_class = Docs::EntriesFilter before do stub(filter).root_page? { false } end describe ":entries" do before do stub(filter).name { 'name' } stub(filter).path { 'path' } stub(filter).type { 'type' } end let :entries do filter_result[:entries] end it "is an array" do assert_instance_of Array, entries end it "includes the default entry when #include_default_entry? is true" do stub(filter).include_default_entry? { true } refute_empty entries end it "doesn't include the default entry when #include_default_entry? is false" do stub(filter).include_default_entry? { false } assert_empty entries end it "always includes the default entry when #root_page? is true" do stub(filter).include_default_entry? { false } stub(filter).root_page? { true } refute_empty entries end describe "the default entry" do it "has the #name, #path and #type" do assert_equal 'name', entries.first.name assert_equal 'path', entries.first.path assert_equal 'type', entries.first.type end end it "includes the #additional_entries" do stub(filter).additional_entries { [['name']] } assert_equal 2, entries.length end describe "an additional entry" do it "has the given name" do stub(filter).additional_entries { [['test']] } assert_equal 'test', entries.last.name end it "has a default path equal to #path" do stub(filter).additional_entries { [['test']] } assert_equal 'path', entries.last.path end it "has a path with the given fragment" do stub(filter).additional_entries { [['test', 'frag']] } assert_equal 'path#frag', entries.last.path end it "has a path with the given path" do stub(filter).additional_entries { [['test', 'custom_path#frag']] } assert_equal 'custom_path#frag', entries.last.path end it "has the given type" do stub(filter).additional_entries { [['test', nil, 'test']] } assert_equal 'test', entries.last.type end it "has a default type equal to #type" do stub(filter).additional_entries { [['test']] } assert_equal 'type', entries.last.type end it "has a type equal to #type when the given type is nil" do stub(filter).additional_entries { [['test', nil, nil]] } assert_equal 'type', entries.last.type end end end describe "#name" do context "when #root_page? is true" do it "returns nil" do stub(filter).root_page? { true } assert_nil filter.name end end context "when #root_page? is false" do before do stub(filter).root_page? { false } stub(filter).get_name { 'name' } end it "returns #get_name" do assert_equal 'name', filter.name end it "is memoized" do assert_same filter.name, filter.name end end end describe "#get_name" do it "returns 'file-name' when #slug is 'file-name'" do stub(filter).slug { 'file-name' } assert_equal 'file-name', filter.get_name end it "returns 'file name' when #slug is '_file__name_'" do stub(filter).slug { '_file__name_' } assert_equal 'file name', filter.get_name end it "returns 'file.name' when #slug is 'file/name'" do stub(filter).slug { 'file/name' } assert_equal 'file.name', filter.get_name end end describe "#type" do context "when #root_page? is true" do it "returns nil" do stub(filter).root_page? { true } assert_nil filter.type end end context "when #root_page? is false" do before do stub(filter).root_page? { false } stub(filter).get_type { 'type' } end it "returns #get_type" do assert_equal 'type', filter.type end it "is memoized" do assert_same filter.type, filter.type end end end end ================================================ FILE: test/lib/docs/filters/core/inner_html_test.rb ================================================ require_relative '../../../../test_helper' require_relative '../../../../../lib/docs' class InnerHtmlFilterTest < Minitest::Spec include FilterTestHelper self.filter_class = Docs::InnerHtmlFilter it "returns the document as a string" do @body = Nokogiri::HTML.fragment('

    Test

    ') assert_equal '

    Test

    ', filter_output end it "returns a valid string" do invalid_string = "\x92" @body = Nokogiri::HTML.parse(invalid_string) assert filter_output.valid_encoding? end end ================================================ FILE: test/lib/docs/filters/core/internal_urls_test.rb ================================================ require_relative '../../../../test_helper' require_relative '../../../../../lib/docs' class InternalUrlsFilterTest < Minitest::Spec include FilterTestHelper self.filter_class = Docs::InternalUrlsFilter before do context[:base_url] = context[:root_url] = context[:url] = 'http://example.com/dir' end let :internal_urls do filter_result[:internal_urls] end describe ":internal_urls" do it "is an array" do assert_instance_of Array, internal_urls end it "includes urls contained in the base url" do @body = link_to(url = 'http://example.com/dir/path') assert_includes internal_urls, url end it "doesn't include urls not contained in the base url" do @body = link_to 'http://example.com/dir-2/path' assert_empty internal_urls end it "includes urls irrespective of case" do context[:base_url] = 'http://example.com/Dir' @body = link_to 'HTTP://example.com/diR/path' assert_equal 1, internal_urls.length end it "doesn't include relative urls" do @body = link_to 'http' assert_empty internal_urls end it "doesn't include ftp urls" do @body = link_to 'ftp://example.com/dir/path' assert_empty internal_urls end it "doesn't include invalid urls" do @body = link_to 'http://example.com/dir/%path' assert_empty internal_urls end it "retains query strings" do @body = link_to(url = 'http://example.com/dir?query') assert_includes internal_urls, url end it "removes fragments" do @body = link_to 'http://example.com/dir#frag' assert_includes internal_urls, 'http://example.com/dir' end it "doesn't have duplicates" do @body = link_to('http://example.com/dir/path') * 2 assert_equal 1, internal_urls.length end it "normalizes the urls" do @body = link_to(url = 'HTTP://EXAMPLE.COM/dir') assert_includes internal_urls, url.downcase end it "doesn't include urls included in context[:skip]" do context[:skip] = ['/path'] @body = link_to 'http://example.com/dir/Path' assert_empty internal_urls end it "doesn't include urls matching context[:skip_patterns]" do context[:skip_patterns] = [/\A\/path.*/] @body = link_to 'http://example.com/dir/path.html' assert_empty internal_urls end it "includes urls that don't match context[:skip_patterns]" do context[:skip_patterns] = [/\A\/path.*/] @body = link_to(url = 'http://example.com/dir/file') assert_includes internal_urls, url end it "includes urls included in context[:only]" do context[:only] = ['/path'] @body = link_to(url = 'http://example.com/dir/Path') assert_includes internal_urls, url end it "doesn't include urls not included in context[:only]" do context[:only] = [] @body = link_to 'http://example.com/dir/Path' assert_empty internal_urls end it "includes urls matching context[:only_patterns]" do context[:only_patterns] = [/file/] @body = link_to(url = 'http://example.com/dir/file') assert_includes internal_urls, url end it "doesn't include urls that don't match context[:only_patterns]" do context[:only_patterns] = [] @body = link_to 'http://example.com/dir/file' assert_empty internal_urls end end context "when the base url is 'example.com'" do before do context[:base_url] = 'http://example.com' context[:root_url] = 'http://example.com/' end context "and the url is 'example.com/file'" do before { context[:url] = 'http://example.com/file' } it "replaces 'example.com' with '.'" do @body = link_to 'http://example.com' assert_equal link_to('.'), filter_output_string end it "replaces 'example.com/' with '.'" do @body = link_to 'http://example.com/' assert_equal link_to('.'), filter_output_string end it "replaces 'example.com/test' with 'test'" do @body = link_to 'http://example.com/test' assert_equal link_to('test'), filter_output_string end it "replaces 'example.com/test/' with 'test/'" do @body = link_to 'http://example.com/test/' assert_equal link_to('test/'), filter_output_string end it "retains query strings" do @body = link_to 'http://example.com/?query' assert_equal link_to('.?query'), filter_output_string end it "retains fragments" do @body = link_to 'http://example.com/#frag' assert_equal link_to('.#frag'), filter_output_string end it "doesn't replace 'https://example.com'" do @body = link_to 'https://example.com' assert_equal @body, filter_output_string end it "doesn't replace 'http://not.example.com'" do @body = link_to 'http://not.example.com' assert_equal @body, filter_output_string end context "and the root url is 'example.com/root/path'" do it "replaces 'example.com/root/path' with '.'" do context[:root_url] = 'http://example.com/root/path' @body = link_to 'http://example.com/root/path' assert_equal link_to('.'), filter_output_string end end end end context "when the base url is 'example.com/dir'" do before do context[:base_url] = context[:root_url] = 'http://example.com/dir' end context "and the url is 'example.com/dir'" do before { context[:url] = 'http://example.com/dir' } it "replaces 'example.com/dir' with '.'" do @body = link_to 'http://example.com/dir' assert_equal link_to('.'), filter_output_string end it "replaces 'example.com/dir/' with '.'" do @body = link_to 'http://example.com/dir/' assert_equal link_to('.'), filter_output_string end it "replaces 'example.com/dir/test' with 'test'" do @body = link_to 'http://example.com/dir/test' assert_equal link_to('test'), filter_output_string end it "doesn't replace 'example.com/'" do @body = link_to 'http://example.com/' assert_equal @body, filter_output_string end end context "and the url is 'example.com/dir/file'" do before { context[:url] = 'http://example.com/dir/file' } it "replaces 'example.com/dir' with '.'" do @body = link_to 'http://example.com/dir' assert_equal link_to('.'), filter_output_string end it "replaces 'example.com/dir/' with '.'" do @body = link_to 'http://example.com/dir/' assert_equal link_to('.'), filter_output_string end end end context "when the base url is 'example.com/dir/'" do before do context[:base_url] = context[:root_url] = 'http://example.com/dir/' end context "and the url is 'example.com/dir/file'" do before { context[:url] = 'http://example.com/dir/file' } it "replaces 'example.com/dir/' with '.'" do @body = link_to 'http://example.com/dir/' assert_equal link_to('.'), filter_output_string end it "doesn't replace 'example.com/dir'" do @body = link_to 'http://example.com/dir' assert_equal @body, filter_output_string end end end context "context[:trailing_slash]" do before do @body = link_to('http://example.com/dir/path/') + link_to('http://example.com/dir/path') end context "when it is true" do before do context[:trailing_slash] = true end it "adds a trailing slash to :internal_urls" do assert_equal ['http://example.com/dir/path/'], internal_urls end it "adds a trailing slash to replaced urls" do assert_equal link_to('path/') * 2, filter_output_string end end context "when it is false" do before do context[:trailing_slash] = false end it "removes the trailing slash from :internal_urls" do assert_equal ['http://example.com/dir/path'], internal_urls end it "removes the trailing slash from replaced urls" do assert_equal link_to('path') * 2, filter_output_string end it "doesn't remove the leading slash" do url = context[:base_url] = context[:root_url] = 'http://example.com/' @body = link_to(url) assert_equal [url], internal_urls end end end context "context[:skip_links]" do before do @body = link_to context[:url] end context "when it is true" do before do context[:skip_links] = true end it "doesn't set :internal_urls" do refute internal_urls end it "doesn't replace urls" do assert_equal @body, filter_output_string end end context "when it is a block" do it "calls the block with the filter instance" do context[:skip_links] = ->(arg) { @arg = arg; nil } filter.call assert_equal filter, @arg end context "and the block returns true" do before do context[:skip_links] = ->(_) { true } end it "doesn't set :internal_urls" do refute internal_urls end it "doesn't replace urls" do assert_equal @body, filter_output_string end end context "and the block returns false" do before do context[:skip_links] = ->(_) { false } end it "sets :internal_urls" do assert internal_urls end it "replaces urls" do refute_equal @body, filter_output_string end end end end context "context[:follow_links]" do before do @body = link_to context[:url] end context "when it is false" do before do context[:follow_links] = false end it "doesn't set :internal_urls" do refute internal_urls end it "replaces urls" do refute_equal @body, filter_output_string end end context "when it is a block" do it "calls the block with the filter instance" do context[:follow_links] = ->(arg) { @arg = arg; nil } filter.call assert_equal filter, @arg end context "and the block returns false" do before do context[:follow_links] = ->(_) { false } end it "doesn't set :internal_urls" do refute internal_urls end it "replaces urls" do refute_equal @body, filter_output_string end end context "and the block returns true" do before do context[:follow_links] = ->(_) { true } end it "sets :internal_urls" do assert internal_urls end end end end context "context[:skip_link] is a block" do before do @body = link_to context[:url] end it "calls the block with each link" do context[:skip_link] = ->(arg) { @arg = arg.try(:to_html); nil } filter.call assert_equal @body, @arg end context "and the block returns true" do before do context[:skip_link] = ->(_) { true } end it "doesn't include the link's url in :internal_urls" do assert internal_urls.empty? end it "doesn't replace the link's url" do assert_equal @body, filter_output_string end end context "and the block returns false" do before do context[:skip_link] = ->(_) { false } end it "includes the link's url in :internal_urls" do refute internal_urls.empty? end it "replaces the link's url" do refute_equal @body, filter_output_string end end end end ================================================ FILE: test/lib/docs/filters/core/normalize_paths_test.rb ================================================ require_relative '../../../../test_helper' require_relative '../../../../../lib/docs' class NormalizePathsFilterTest < Minitest::Spec include FilterTestHelper self.filter_class = Docs::NormalizePathsFilter describe "#path" do it "returns 'index' when the page is the root page" do mock(filter).root_page? { true } assert_equal 'index', filter.path end it "returns 'test/index' when #subpath is 'test/'" do stub(filter).subpath { 'test/' } assert_equal 'test/index', filter.path end it "returns 'test' when #subpath is '/test'" do stub(filter).subpath { '/test' } assert_equal 'test', filter.path end end describe "#store_path" do it "returns 'index.html' when #path is 'index'" do stub(filter).path { 'index' } assert_equal 'index.html', filter.store_path end it "returns 'index.html' when #path is 'index.html'" do stub(filter).path { 'index.html' } assert_equal 'index.html', filter.store_path end it "returns 'page.ext.html' when #path is 'page.ext'" do stub(filter).path { 'page.ext' } assert_equal 'page.ext.html', filter.store_path end end describe "#normalize_path" do it "returns 'index' with '.'" do assert_equal 'index', filter.normalize_path('.') end it "returns 'test' with 'TEST'" do assert_equal 'test', filter.normalize_path('TEST') end it "returns 'test/index' with 'test/'" do assert_equal 'test/index', filter.normalize_path('test/') end it "returns 'test' with 'test.html'" do assert_equal 'test', filter.normalize_path('test.html') end end before do stub(filter).subpath { '' } end it "rewrites relative urls" do @body = link_to 'TEST/' assert_equal link_to('test/index'), filter_output_string end it "doesn't rewrite absolute urls" do @body = link_to 'http://example.com' assert_equal @body, filter_output_string end it "retains query strings" do @body = link_to 'TEST/?query' assert_equal link_to('test/index?query'), filter_output_string end it "retains fragments" do @body = link_to 'TEST/#frag' assert_equal link_to('test/index#frag'), filter_output_string end it "doesn't rewrite mailto urls" do @body = link_to 'mailto:' assert_equal @body, filter_output_string end it "doesn't rewrite ftp urls" do @body = link_to 'ftp://example.com' assert_equal @body, filter_output_string end it "doesn't rewrite invalid urls" do @body = link_to '.%' assert_equal @body, filter_output_string end end ================================================ FILE: test/lib/docs/filters/core/normalize_urls_test.rb ================================================ require_relative '../../../../test_helper' require_relative '../../../../../lib/docs' class NormalizeUrlsFilterTest < Minitest::Spec include FilterTestHelper self.filter_class = Docs::NormalizeUrlsFilter before do context[:url] = 'http://example.com/dir/file' end it "rewrites relative urls" do @body = link_to './path' assert_equal link_to('http://example.com/dir/path'), filter_output_string end it "rewrites root-relative urls" do @body = link_to '/path' assert_equal link_to('http://example.com/path'), filter_output_string end it "rewrites relative image urls" do @body = '' assert_equal '', filter_output_string end it "rewrites relative iframe urls" do @body = '' assert_equal '', filter_output_string end it "rewrites protocol-less urls" do @body = link_to '//example.com/' assert_equal link_to('http://example.com/'), filter_output_string end it "rewrites empty urls" do @body = link_to '' assert_equal link_to(context[:url]), filter_output_string end it "rewrites invalid link urls" do @body = link_to '%' assert_equal link_to('#'), filter_output_string end it "rewrites invalid image urls" do @body = '' assert_equal '', filter_output_string end it "doesn't rewrite invalid iframe urls" do @body = '' assert_equal @body, filter_output_string end it "repairs un-encoded spaces" do @body = link_to 'http://example.com/#foo bar ' assert_equal link_to('http://example.com/#foo%20bar'), filter_output_string end it "retains query strings" do @body = link_to'path?query' assert_equal link_to('http://example.com/dir/path?query'), filter_output_string end it "retains fragments" do @body = link_to 'path#frag' assert_equal link_to('http://example.com/dir/path#frag'), filter_output_string end it "doesn't rewrite absolute urls" do @body = link_to 'http://not.example.com/path' assert_equal @body, filter_output_string end it "doesn't rewrite fragment-only urls" do @body = link_to '#frag' assert_equal @body, filter_output_string end it "doesn't rewrite email urls" do @body = link_to 'mailto:test@example.com' assert_equal @body, filter_output_string end it "doesn't rewrite data image urls" do @body = '' assert_equal @body, filter_output_string end context "when context[:replace_paths] is a hash" do before do context[:base_url] = 'http://example.com/dir/' @body = link_to 'http://example.com/dir/path?query#frag' end it "fixes each absolute url whose subpath is found in the hash" do context[:replace_paths] = { 'path' => 'fixed' } @body += link_to 'path?query#frag' assert_equal link_to('http://example.com/dir/fixed?query#frag') * 2, filter_output_string end it "doesn't fix urls whose subpath isn't found in the hash" do context[:replace_paths] = { 'dir/path' => 'fixed', '/dir/path' => 'fixed' } assert_equal @body, filter_output_string end it "doesn't fix urls whose subpath isn't found in the hash" do context[:replace_paths] = {} @body = link_to 'http://example.com/dir/path' assert_equal @body, filter_output_string end end context "when context[:replace_urls] is a hash" do before do @body = link_to 'http://example.com/path?#' end it "replaces each absolute url found in the hash" do context[:replace_urls] = { 'http://example.com/path?#' => 'fixed' } @body += link_to '/path?#' assert_equal link_to('fixed') * 2, filter_output_string end it "doesn't replace urls not found in the hash" do context[:replace_urls] = {} assert_equal @body, filter_output_string end end context "when context[:fix_urls_before_parse] is a block" do before do @body = link_to 'foo[bar]' end it "calls the block with each absolute url" do context[:fix_urls_before_parse] = ->(arg) { (@args ||= []).push(arg); nil } @body += link_to 'foo[bar]' filter.call assert_equal ['foo[bar]'] * 2, @args end it "replaces the url with the block's return value" do context[:fix_urls_before_parse] = ->(url) { '/fixed' } assert_equal link_to('http://example.com/fixed'), filter_output_string end end context "when context[:fix_urls] is a block" do before do @body = link_to 'http://example.com/path?#' end it "calls the block with each absolute url" do context[:fix_urls] = ->(arg) { (@args ||= []).push(arg); nil } @body += link_to '/path?#' filter.call assert_equal ['http://example.com/path?#'] * 2, @args end it "replaces the url with the block's return value" do context[:fix_urls] = ->(url) { url == 'http://example.com/path?#' ? 'fixed' : url } assert_equal link_to('fixed'), filter_output_string end it "doesn't replace the url when the block returns nil" do context[:fix_urls] = ->(_) { nil } assert_equal @body, filter_output_string end it "skips fragment-only urls" do context[:fix_urls] = ->(_) { @called = true } @body = link_to '#frag' filter.call refute @called end end context "when context[:redirections] is a hash" do before do @body = link_to 'http://example.com/path?query#frag' end it "replaces the path of matching urls, case-insensitive" do @body = link_to('http://example.com/PATH?query#frag') + link_to('http://example.com/path/two') context[:redirections] = { '/path' => '/fixed' } expected = link_to('http://example.com/fixed?query#frag') + link_to('http://example.com/path/two') assert_equal expected, filter_output_string end it "does a multi pass with context[:fix_urls]" do @body = link_to('http://example.com/path') context[:fix_urls] = ->(url) do url.sub! 'example.com', 'example.org' url.sub! '/Fixed', '/fixed' url end context[:redirections] = { '/path' => '/Fixed' } assert_equal link_to('http://example.org/fixed'), filter_output_string end end end ================================================ FILE: test/lib/docs/filters/core/parse_cf_email_test.rb ================================================ require_relative '../../../../test_helper' require_relative '../../../../../lib/docs' class ParseCfEmailFilterTest < Minitest::Spec include FilterTestHelper self.filter_class = Docs::ParseCfEmailFilter before do context[:url] = 'http://example.com/dir/file' end it 'rewrites parses CloudFlare mail addresses' do href = 'b3dddad0d6d2ddd7c0dadec3dfd6f3d6cbd2dec3dfd69dd0dcde' @body = %(Link) assert_equal 'niceandsimple@example.com', filter_output_string end end ================================================ FILE: test/lib/docs/filters/core/title_test.rb ================================================ require_relative '../../../../test_helper' require_relative '../../../../../lib/docs' class TitleFilterTest < Minitest::Spec include FilterTestHelper self.filter_class = Docs::TitleFilter before do @body = '
    Test
    ' end def output_with_title(title) "

    #{title}

    #{@body}" end context "when result[:entries] is empty" do it "does nothing" do assert_equal @body, filter_output.inner_html end context "and context[:title] is a string" do it "prepends a heading containing the title" do context[:title] = 'title' assert_equal output_with_title('title'), filter_output.inner_html end end end context "when result[:entries] is an array" do before do result[:entries] = [OpenStruct.new(name: 'name'), OpenStruct.new(name: 'name2')] end it "prepends a heading containing the first entry's name" do assert_equal output_with_title('name'), filter_output.inner_html end context "and context[:title] is a string" do it "prepends a heading containing the title" do context[:title] = 'title' assert_equal output_with_title('title'), filter_output.inner_html end end context "and context[:title] is nil" do it "prepends a heading containing the first entry's name" do context[:title] = nil assert_equal output_with_title('name'), filter_output.inner_html end end context "and context[:title] is false" do it "does nothing" do context[:title] = false assert_equal @body, filter_output.inner_html end end end context "when context[:root_title] is a string" do before do context[:root_title] = 'root' end context "and context[:title] is a string" do before do context[:title] = 'title' end it "prepends a heading containing the root title when #root_page? is true" do stub(filter).root_page? { true } assert_equal output_with_title('root'), filter_output.inner_html end it "prepends a heading containing the title when #root_page? is false" do stub(filter).root_page? { false } assert_equal output_with_title('title'), filter_output.inner_html end end end context "when context[:title] is a string" do before do context[:title] = 'title' end context "and context[:root_title] is false" do it "does nothing when #root_page? is true" do context[:root_title] = false stub(filter).root_page? { true } assert_equal @body, filter_output.inner_html end end end context "when context[:title] is a block" do it "calls the block with itself" do context[:title] = ->(arg) { @arg = arg; nil } filter.call assert_equal filter, @arg end it "prepends a heading tag containing the title returned by the block" do context[:title] = ->(_) { 'title' } assert_equal output_with_title('title'), filter_output.inner_html end it "does nothing when the block returns nil" do context[:title] = ->(_) { nil } assert_equal @body, filter_output.inner_html end end end ================================================ FILE: test/lib/docs/storage/abstract_store_test.rb ================================================ require_relative '../../../test_helper' require_relative '../../../../lib/docs' class DocsAbstractStoreTest < Minitest::Spec InvalidPathError = Docs::AbstractStore::InvalidPathError LockError = Docs::AbstractStore::LockError let :path do '/' end let :store do Docs::AbstractStore.new(@path || path).tap do |store| store.extend FakeInstrumentation end end describe ".new" do it "raises an error with a relative path" do assert_raises ArgumentError do Docs::AbstractStore.new 'path' end end it "sets #root_path" do @path = '/path' assert_equal @path, store.root_path end it "expands #root_path" do @path = '/path/..' assert_equal '/', store.root_path end it "sets #working_path" do assert_equal store.root_path, store.working_path end end describe "#root_path" do it "can't be overwritten" do @path = '/path' store.root_path << '/..' assert_equal '/path', store.root_path end end describe "#working_path" do it "can't be overwritten" do @path = '/path' store.working_path << '/..' assert_equal '/path', store.working_path end end describe "#open" do it "raises an error when the store is locked" do assert_raises LockError do store.send :lock, &-> { store.open 'dir' } end end context "with a relative path" do it "updates #working_path relative to #root_path" do 2.times { store.open 'dir' } assert_equal File.join(path, 'dir'), store.working_path end it "expands the new #working_path" do store.open './dir/../' assert_equal path, store.working_path end it "raises an error when the new #working_path is outside of #root_path" do @path = '/dir' assert_raises InvalidPathError do store.open '../dir2' end end end context "with an absolute path" do it "updates #working_path" do store.open File.join(path, 'dir') assert_equal File.join(path, 'dir'), store.working_path end it "expands the new #working_path" do store.open File.join(path, 'dir/..') assert_equal path, store.working_path end it "raises an error when the new #working_path is outside of #root_path" do @path = '/dir' assert_raises InvalidPathError do store.open '/dir2' end end end context "with a block" do it "calls the block" do store.open('dir') { @called = true } assert @called end it "returns the block's return value" do assert_equal 1, store.open('dir') { 1 } end it "updates #working_path while calling the block" do store.open 'dir' do assert_equal File.join(path, 'dir'), store.working_path end end it "resets #working_path to its previous value afterward" do store.open('dir') store.open('dir2') {} assert_equal File.join(path, 'dir'), store.working_path end it "resets #working_path even when the block fails" do assert_raises RuntimeError do store.open('dir') { raise } end assert_equal path, store.working_path end end end describe "#close" do it "resets #working_path to #root_path" do 2.times { store.open 'dir' } store.close assert_equal path, store.working_path end it "raises an error when the store is locked" do assert_raises LockError do store.send :lock, &-> { store.close } end end end describe "#expand_path" do context "when #working_path is '/'" do before do store.open '/' end it "returns '/path' with './path'" do assert_equal '/path', store.expand_path('./path') end it "returns '/path' with '/path'" do assert_equal '/path', store.expand_path('/path') end end context "when #working_path is '/dir'" do before do store.open '/dir' end it "returns '/dir/path' with './path'" do assert_equal '/dir/path', store.expand_path('./path') end it "returns '/dir/path' with 'path/../path'" do assert_equal '/dir/path', store.expand_path('path/../path') end it "returns '/dir/path' with '/dir/path'" do assert_equal '/dir/path', store.expand_path('/dir/path') end it "raises an error with '..'" do assert_raises InvalidPathError do store.expand_path '..' end end it "raises an error with '/'" do assert_raises InvalidPathError do store.expand_path '/' end end end end describe "#read" do it "raises an error with a path outside of #working_path" do @path = '/path' assert_raises InvalidPathError do store.read '../file' end end it "returns nil when the file doesn't exist" do dont_allow(store).read_file stub(store).file_exist?('/file') { false } assert_nil store.read('file') end it "returns #read_file when the file exists" do stub(store).read_file('/file') { 1 } stub(store).file_exist?('/file') { true } assert_equal 1, store.read('file') end end describe "#write" do it "raises an error with a path outside of #working_path" do @path = '/path' assert_raises InvalidPathError do store.write '../file', '' end end context "when the file doesn't exist" do before do stub(store).file_exist?('/file') { false } stub(store).create_file end it "returns #create_file" do stub(store).create_file('/file', '') { 1 } assert_equal 1, store.write('file', '') end it "instrument 'create'" do store.write 'file', '' assert store.last_instrumentation assert_equal 'create.store', store.last_instrumentation[:event] assert_equal '/file', store.last_instrumentation[:payload][:path] end end context "when the file exists" do before do stub(store).file_exist?('/file') { true } stub(store).update_file end it "returns #update_file" do stub(store).update_file('/file', '') { 1 } assert_equal 1, store.write('file', '') end it "instruments 'update'" do store.write 'file', '' assert store.last_instrumentation assert_equal 'update.store', store.last_instrumentation[:event] assert_equal '/file', store.last_instrumentation[:payload][:path] end end end describe "#delete" do it "raises an error with a path outside og #working_path" do @path = '/path' assert_raises InvalidPathError do store.delete '../file' end end it "returns nil when the file doesn't exist" do dont_allow(store).delete_file stub(store).file_exist?('/file') { false } assert_nil store.delete('file') end context "when the file exists" do before do stub(store).file_exist?('/file') { true } stub(store).delete_file end it "calls #delete_file" do mock(store).delete_file('/file') store.delete 'file' end it "returns true" do assert store.delete('file') end it "instruments 'destroy'" do store.delete 'file' assert store.last_instrumentation assert_equal 'destroy.store', store.last_instrumentation[:event] assert_equal '/file', store.last_instrumentation[:payload][:path] end end end describe "exist?" do it "raises an error with a path outside of #working_path" do @path = '/path' assert_raises InvalidPathError do store.exist? '../file' end end it "returns #file_exist?" do stub(store).file_exist?('/file') { 1 } assert_equal 1, store.exist?('file') end end describe "mtime" do it "raises an error with a path outside of #working_path" do @path = '/path' assert_raises InvalidPathError do store.mtime '../file' end end it "returns nil when the file doesn't exist" do stub(store).file_exist?('/file') { false } dont_allow(store).file_mtime assert_nil store.mtime('file') end it "returns #file_mtime when the file exists" do stub(store).file_exist?('/file') { true } stub(store).file_mtime('/file') { 1 } assert_equal 1, store.mtime('file') end end describe "#size" do it "raises an error with a path outside of #working_path" do @path = '/path' assert_raises InvalidPathError do store.size '../file' end end it "returns nil when the file doesn't exist" do stub(store).file_exist?('/file') { false } dont_allow(store).file_size assert_nil store.size('file') end it "returns #file_size when the file exists" do stub(store).file_exist?('/file') { true } stub(store).file_size('/file') { 1 } assert_equal 1, store.size('file') end end describe "#each" do it "calls #list_files with #working_path" do store.open 'dir' block = Proc.new {} mock(store).list_files(File.join(path, 'dir'), &block) store.each(&block) end end describe "#replace" do before do stub(store).file_exist? stub(store).create_file stub(store).delete_file end def stub_paths(*paths) stub(store).each { |&block| paths.each(&block) } end it "calls the block" do store.replace { @called = true } assert @called end it "returns the block's return value" do assert_equal 1, store.replace { 1 } end it "locks the store while calling the block" do assert_raises LockError do store.replace { store.open('dir') } end store.open 'dir' end context "with a path" do it "opens the path while calling the block" do store.replace 'dir' do assert_equal File.join(path, 'dir'), store.working_path end end end context "when the block writes no files" do it "doesn't delete files" do stub_paths '/', '/file' dont_allow(store).delete_file store.replace {} end end context "when the block writes files" do it "deletes untouched files" do stub_paths '/', '/dir', '/dir/file', '/dir/file2', '/dir2' mock(store).delete_file('/dir/file2').then.delete_file('/dir2') store.replace { store.write 'dir/file', '' } end it "doesn't delete touched files" do stub_paths '/', '/dir', '/dir/(file)' dont_allow(store).delete_file store.replace { store.write 'dir/(file)', '' } end end context "when the block fails" do it "doesn't delete files" do stub_paths '/', '/file' dont_allow(store).delete_file assert_raises RuntimeError do store.replace { store.write 'file2', ''; raise } end end it "unlocks the store afterward" do assert_raises RuntimeError do store.replace { raise } end store.open 'dir' end end context "when called multiple times" do before do stub_paths '/', '/file' end it "deletes untouched files that were touched the previous time" do store.replace { store.write 'file', '' } mock(store).delete_file '/file' store.replace { store.write 'file2', '' } end it "deletes untouched files that were touched and failed the previous time" do assert_raises RuntimeError do store.replace { store.write 'file', ''; raise } end mock(store).delete_file '/file' store.replace { store.write 'file2', '' } end end end end ================================================ FILE: test/lib/docs/storage/file_store_test.rb ================================================ require_relative '../../../test_helper' require_relative '../../../../lib/docs' class DocsFileStoreTest < Minitest::Spec let :store do Docs::FileStore.new(tmp_path) end after do FileUtils.rm_rf "#{tmp_path}/." end def expand_path(path) File.join(tmp_path, path) end def read(path) File.read expand_path(path) end def write(path, content) File.write expand_path(path), content end def exists?(path) File.exist? expand_path(path) end def touch(path) FileUtils.touch expand_path(path) end def mkpath(path) FileUtils.mkpath expand_path(path) end describe "#read" do it "reads a file" do write 'file', 'content' assert_equal 'content', store.read('file') end end describe "#write" do context "with a string" do it "creates the file when it doesn't exist" do store.write 'file', 'content' assert exists?('file') assert_equal 'content', read('file') end it "updates the file when it exists" do touch 'file' store.write 'file', 'content' assert_equal 'content', read('file') end end context "with a Tempfile" do let :file do Tempfile.new('tmp').tap do |file| file.write 'content' file.close end end it "creates the file when it doesn't exist" do store.write 'file', file assert exists?('file') assert_equal 'content', read('file') end it "updates the file when it exists" do touch 'file' store.write 'file', file assert_equal 'content', read('file') end end it "recursively creates directories" do store.write '1/2/file', '' assert exists?('1/2/file') end end describe "#delete" do it "deletes a file" do touch 'file' store.delete 'file' refute exists?('file') end it "deletes a directory" do mkpath '1/2' touch '1/2/file' store.delete '1' refute exists?('1/2/exist') refute exists?('1/2') refute exists?('1') end end describe "#exist?" do it "returns true when the file exists" do touch 'file' assert store.exist?('file') end it "returns false when the file doesn't exist" do refute store.exist?('file') end end describe "#mtime" do it "returns the file modification time" do touch 'file' created_at = Time.now.round - 86400 modified_at = created_at + 1 File.utime created_at, modified_at, expand_path('file') assert_equal modified_at, store.mtime('file') end end describe "#size" do it "returns the file's size" do write 'file', 'content' assert_equal File.size(expand_path('file')), store.size('file') end end describe "#each" do let :paths do paths = [] store.each { |path| paths << path.remove(tmp_path) } paths end it "yields file paths" do touch 'file' assert_equal ['/file'], paths end it "yields directory paths" do mkpath 'dir' assert_equal ['/dir'], paths end it "yields file paths recursively" do mkpath 'dir' touch 'dir/file' assert_includes paths, '/dir/file' end it "yields directory paths recursively" do mkpath 'dir/dir' assert_includes paths, '/dir/dir' end it "doesn't yield file paths that start with '.'" do touch '.file' assert_empty paths end it "doesn't yield directory paths that start with '.'" do mkpath '.dir' assert_empty paths end it "yields directories before what's inside them" do mkpath 'dir' touch 'dir/file' assert paths.index('/dir') < paths.index('/dir/file') end context "when the block deletes the directory" do it "stops yielding what was inside it" do mkpath 'dir' touch 'dir/file' store.each do |path| (@paths ||= []) << path FileUtils.rm_rf(path) if path == expand_path('dir') end refute_includes @paths, expand_path('dir/file') end end end end ================================================ FILE: test/support/fake_instrumentation.rb ================================================ module FakeInstrumentation def instrument(event, payload = nil) (@instrumentations ||= []) << { event: event, payload: payload } yield payload if block_given? end def instrumentations @instrumentations end def last_instrumentation @instrumentations.try :last end end ================================================ FILE: test/support/filter_test_helper.rb ================================================ module FilterTestHelper extend ActiveSupport::Concern included do class_attribute :filter_class class_attribute :filter_type end def filter @filter ||= filter_class.new prepare_body(@body || ''), context, result end def filter_output @filter_output ||= begin filter.instance_variable_set :@html, prepare_body(@body) if @body filter.call end end def filter_output_string @filter_output_string ||= filter_output.to_s end def filter_result @filter_result ||= filter_output && result end class Context < Hash def []=(key, value) super key, key.to_s.end_with?('url') ? Docs::URL.parse(value) : value end end def context @context ||= Context.new end def result @result ||= {} end def link_to(href) %(Link) end def prepare_body(body) if self.class.filter_type == 'html' Docs::Parser.new(body).html else body end end end ================================================ FILE: test/test_helper.rb ================================================ ENV['RACK_ENV'] = 'test' require 'bundler/setup' Bundler.require :test $LOAD_PATH.unshift 'lib' require 'minitest/autorun' require 'minitest/pride' require 'active_support' require 'active_support/core_ext' require 'active_support/testing/assertions' require 'rr' Dir[File.dirname(__FILE__) + '/support/*.rb'].each do |file| autoload File.basename(file, '.rb').camelize, file end ActiveSupport::TestCase.test_order = :random class Minitest::Spec include ActiveSupport::Testing::Assertions module DSL def context(*args, &block) describe(*args, &block) end end end def tmp_path $tmp_path ||= mk_tmp end def mk_tmp File.expand_path('../tmp', __FILE__).tap do |path| FileUtils.mkdir(path) end end def rm_tmp FileUtils.rm_rf $tmp_path if $tmp_path end Minitest.after_run do rm_tmp end ================================================ FILE: views/app.erb ================================================

    DevDocs

    Preferences Offline Data Changelog Guide About Report a bug
    ================================================ FILE: views/index.erb ================================================ DevDocs API Documentation <%= stylesheet_tag 'application' %> <%= erb :app -%> <%= javascript_tag 'application' %> <%= javascript_tag 'docs' %><% unless App.production? %> <%= javascript_tag 'debug' %><% end %> ================================================ FILE: views/other.erb ================================================ <% if doc_index_page? %><% else %><% end %> DevDocs<%= " — #{@doc['full_name']} documentation" if doc_index_page? %> <%= stylesheet_tag 'application' %> <%= erb :app -%> <%= javascript_tag 'application' %><% unless App.production? %> <%= javascript_tag 'debug' %><% end %> ================================================ FILE: views/service-worker.js.erb ================================================ <%# The name of the cache to store responses in %> <%# If the cache name changes DevDocs is assumed to be updated %> const cacheName = '<%= service_worker_cache_name %>'; <%# Url's to cache when the service worker is installed %> const urlsToCache = [ '/', '/favicon.ico', '/manifest.json', '<%= service_worker_asset_urls.join "',\n '" %>', '<%= doc_index_urls.join "',\n '" %>', ]; <%# Set-up the cache %> self.addEventListener('install', event => { self.skipWaiting(); event.waitUntil( caches.open(cacheName).then(cache => cache.addAll(urlsToCache)), ); }); <%# Remove old caches %> self.addEventListener('activate', event => { event.waitUntil((async () => { const keys = await caches.keys(); const jobs = keys.map(key => key !== cacheName ? caches.delete(key) : Promise.resolve()); return Promise.all(jobs); })()); }); <%# Handle HTTP requests %> self.addEventListener('fetch', event => { event.respondWith((async () => { const cachedResponse = await caches.match(event.request); if (cachedResponse) return cachedResponse; try { const response = await fetch(event.request); return response; } catch (err) { const url = new URL(event.request.url); const pathname = url.pathname; const filename = pathname.substr(1 + pathname.lastIndexOf('/')).split(/\#|\?/g)[0]; const extensions = ['.html', '.css', '.js', '.json', '.png', '.ico', '.svg', '.xml']; <%# Attempt to return the index page from the cache if the user is visiting a url like devdocs.io/offline or devdocs.io/javascript/global_objects/array/find %> <%# The index page will make sure the correct documentation or a proper offline page is shown %> if (url.origin === location.origin && !extensions.some(ext => filename.endsWith(ext))) { const cachedIndex = await caches.match('/'); if (cachedIndex) return cachedIndex; } throw err; } })()); }); ================================================ FILE: views/unsupported.erb ================================================ DevDocs — API Documentation Browser <%= stylesheet_tag 'application' %>

    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.

    — @DevDocs