commit 18986c1814bd7a708839130785b539ed7a35aecf Author: Thibaut Date: Thu Oct 24 20:25:52 2013 +0200 Going open source diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..8b222826 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +.DS_Store +.bundle +*.pxm +*.sketch +tmp +public/assets +public/fonts +public/docs/**/* +!public/docs/docs.json +!public/docs/**/index.json diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..4c8a7b53 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,84 @@ +# Contributing to DevDocs + +Wish to contribute? Great. Please review the following guidelines carefully and always search for existing issues before opening a new one. More time spent managing issues means less time spent improving the software. + +_Note: DevDocs is my first open source project and one which I deeply care about. Please forgive my inexperience and the fact that I may push back on certain things in order to keep the project to my liking. Feedback and advice are always welcome._ + +**Table of Contents:** + +1. [Reporting bugs](#reporting-bugs) +2. [Requesting new features](#requesting-new-features) +3. [Requesting new docs](#requesting-new-docs) +4. [Contributing code and features](#contributing-code-and-features) +5. [Contributing new docs](#contributing-new-docs) +6. [Other contributions](#other-contributions) +7. [Coding conventions](#coding-conventions) +8. [Questions?](#questions) + +## Reporting bugs + +1. Always update to the most recent master release; the bug may already be fixed. +2. Search for existing issues; it's possible someone has already encountered this bug. +3. Try to isolate the problem and include steps to reproduce it. +4. Share as much information as possible (e.g. browser/OS environment, log output, stack trace, screenshots, etc.). + +## Requesting new features + +1. Search for similar feature requests; someone may have already requested it. +2. Make sure your feature fits DevDocs's [vision and stated goals](https://github.com/Thibaut/devdocs/blob/master/README.md#vision). +3. Provide a clear and detailed explanation of the feature and why it's important to add it. + +For general feedback and ideas, please use the [mailing list](https://groups.google.com/d/forum/devdocs). + +## Requesting new docs + +Please do not open issues to request new documentations. +Use the [Trello board](https://trello.com/b/6BmTulfx/devdocs-documentation) where everyone can vote and contributors can get a feel for what's wished for. + +## 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 that I may not want to merge. This also tells other contributors that you're working on the feature. +3. Follow the [coding conventions](#coding-conventions). +4. If you're modifying the Ruby code, include tests and ensure they pass. +5. Try to keep your pull request small and simple. +6. When it makes sense, squash your commits into a single commit. +7. Describe all your changes in the commit message and/or pull request. + +## Contributing new docs + +**Note:** there is currently no documentation on how to create a scraper/documentation. I'm working on it. + +**Important:** in order to keep things fast and manageable, only the documentation of popular open source projects will be accepted into DevDocs. As more projects find their way in, the required level of popularity will gradually decrease. Additionally, the documentation's license must permit alteration, redistribution, and commercial use of the work. Software vendors that wish to add commercial software documentation to DevDocs may contact me privately. + +**Please open an issue before adding any new documentation.** + +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 a clean and official icon, in both 1x and 2x resolutions (16x16 and 32x32 pixels). This is important because icons are the only thing differentiating search results inside the app. If a project doesn't have an official icon, it won't be accepted into DevDocs—sorry. +* DevDocs favors quality over quantity. Your documentation should only include API/reference documents that most developers may wish to read semi-regularly. By reducing the number of entries you make it easier for people to find other, more relevant entries. _(Note: you're more than welcome to submit pull requests removing seldom-used entries from existing documentations.)_ +* Try to remove as much content and HTML markup as possible, particularly content which isn't associated with any entries (e.g. introduction, changelog, etc.). +* Names must be as short as possible and unique across the documentation. +* The number of types (categories) must be less than 50. +* It's ok to leave the CSS up to me. +* Don't modify the icon sprite. I'll do it when merging your pull request. +* Once your documentation is accepted into DevDocs, you'll be expected to maintain it on a regular basis. + +## Other contributions + +Besides new docs and features, here are other ways you can contribute: + +* **Improve words and sentences.** English isn't my first language so if you notice grammatical or usage errors, feel free to submit a pull request—it'll be much appreciated. (Note: American English is the preferred form) +* **Write documentation.** Although this task is mainly up to me, any documentation you can write that may help other developers understand and contribute to the code is highly appreciated. +* **Participate in the issue tracker.** Your opinion matters—feel free to add comments to existing issues. You're also welcome to participate to the [mailing list](https://groups.google.com/d/forum/devdocs). + +## Coding conventions + +* two spaces; no tabs +* no trailing whitespace; blank lines should have no spaces +* use the same coding style as the rest of the codebase + +## Questions? + +If you have any questions, please feel free to ask on the [mailing list](https://groups.google.com/d/forum/devdocs). + diff --git a/COPYRIGHT b/COPYRIGHT new file mode 100644 index 00000000..d1348ffe --- /dev/null +++ b/COPYRIGHT @@ -0,0 +1,14 @@ +Copyright 2013 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/. + +NOTE: DevDocs is considered a trademark. You may not use the name to + endorse or promote products derived from this software without + Thibaut Courouble's permission, except as may be necessary to + comply with the notice/attribution requirements. + +ADDITIONALLY: it is expected that any documentation file generated + using this software must be attributed to DevDocs. Let's be fair + to all contributors by not stealing their hard work. diff --git a/Gemfile b/Gemfile new file mode 100644 index 00000000..332d1d59 --- /dev/null +++ b/Gemfile @@ -0,0 +1,41 @@ +source 'https://rubygems.org' +ruby '2.0.0' + +gem 'thor' +gem 'pry', '~> 0.9.12' +gem 'activesupport', '~> 4.0', require: false +gem 'yajl-ruby', require: false + +group :app do + gem 'rack' + gem 'sinatra' + gem 'sinatra-contrib' + gem 'thin' + gem 'sprockets' + gem 'sprockets-helpers' + gem 'erubis' + gem 'browser' + gem 'sass' + gem 'coffee-script' +end + +group :production do + gem 'uglifier' +end + +group :development do + gem 'better_errors' +end + +group :docs do + gem 'typhoeus' + gem 'nokogiri', '~> 1.6.0' + gem 'html-pipeline' + gem 'progress_bar' + gem 'unix_utils' +end + +group :test do + gem 'minitest' + gem 'rr', require: false +end diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 00000000..47334cb4 --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,131 @@ +GEM + remote: https://rubygems.org/ + specs: + activesupport (4.0.0) + i18n (~> 0.6, >= 0.6.4) + minitest (~> 4.2) + multi_json (~> 1.3) + thread_safe (~> 0.1) + tzinfo (~> 0.3.37) + atomic (1.1.14) + backports (3.3.5) + better_errors (1.0.1) + coderay (>= 1.0.0) + erubis (>= 2.6.6) + browser (0.2.1) + coderay (1.0.9) + coffee-script (2.2.0) + coffee-script-source + execjs + coffee-script-source (1.6.3) + daemons (1.1.9) + erubis (2.7.0) + escape_utils (0.3.2) + ethon (0.6.1) + ffi (>= 1.3.0) + mime-types (~> 1.18) + eventmachine (1.0.3) + execjs (2.0.2) + fattr (2.2.1) + ffi (1.9.0) + gemoji (1.4.0) + github-markdown (0.5.5) + highline (1.6.19) + hike (1.2.3) + html-pipeline (0.2.1) + escape_utils (~> 0.3) + gemoji (~> 1.0) + github-markdown (~> 0.5) + nokogiri (~> 1.4) + rinku (~> 1.7) + sanitize (~> 2.0) + i18n (0.6.5) + method_source (0.8.2) + mime-types (1.25) + mini_portile (0.5.1) + minitest (4.7.5) + multi_json (1.8.1) + nokogiri (1.6.0) + mini_portile (~> 0.5.0) + options (2.3.0) + fattr + progress_bar (1.0.0) + highline (~> 1.6.1) + options (~> 2.3.0) + pry (0.9.12.2) + coderay (~> 1.0.5) + method_source (~> 0.8) + slop (~> 3.4) + rack (1.5.2) + rack-protection (1.5.0) + rack + rack-test (0.6.2) + rack (>= 1.0) + rinku (1.7.3) + rr (1.1.2) + sanitize (2.0.6) + nokogiri (>= 1.4.4) + sass (3.2.12) + sinatra (1.4.3) + rack (~> 1.4) + rack-protection (~> 1.4) + tilt (~> 1.3, >= 1.3.4) + sinatra-contrib (1.4.1) + backports (>= 2.0) + multi_json + rack-protection + rack-test + sinatra (~> 1.4.0) + tilt (~> 1.3) + slop (3.4.6) + sprockets (2.10.0) + hike (~> 1.2) + multi_json (~> 1.0) + rack (~> 1.0) + tilt (~> 1.1, != 1.3.0) + sprockets-helpers (1.0.1) + sprockets (~> 2.0) + thin (1.6.0) + daemons (>= 1.0.9) + eventmachine (>= 1.0.0) + rack (>= 1.5.0) + thor (0.18.1) + thread_safe (0.1.3) + atomic + tilt (1.4.1) + typhoeus (0.6.5) + ethon (~> 0.6.1) + tzinfo (0.3.38) + uglifier (2.2.1) + execjs (>= 0.3.0) + multi_json (~> 1.0, >= 1.0.2) + unix_utils (0.0.15) + yajl-ruby (1.1.0) + +PLATFORMS + ruby + +DEPENDENCIES + activesupport (~> 4.0) + better_errors + browser + coffee-script + erubis + html-pipeline + minitest + nokogiri (~> 1.6.0) + progress_bar + pry (~> 0.9.12) + rack + rr + sass + sinatra + sinatra-contrib + sprockets + sprockets-helpers + thin + thor + typhoeus + uglifier + unix_utils + yajl-ruby diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..a612ad98 --- /dev/null +++ b/LICENSE @@ -0,0 +1,373 @@ +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. diff --git a/README.md b/README.md new file mode 100644 index 00000000..871a585a --- /dev/null +++ b/README.md @@ -0,0 +1,136 @@ +# [DevDocs](http://devdocs.io) — Documentation Browser + +DevDocs combines multiple API documentations in a fast, organized, and searchable interface. + +* Created by [Thibaut Courouble](http://thibaut.me) +* Sponsored by [MaxCDN](http://www.maxcdn.com) + +Keep track of development and community news: + +* Subscribe to the [newsletter](http://eepurl.com/HnLUz) +* Follow [@DevDocs](https://twitter.com/DevDocs) on Twitter +* Join the [mailing list](https://groups.google.com/d/forum/devdocs) + +DevDocs is free and open source. If you use it and like it, please consider donating through [Gittip](https://www.gittip.com/Thibaut/) or [PayPal](https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=4PTFAGT7K6QVG). Your support helps sustain the project and is highly appreciated. + +**Table of Contents:** [Quick Start](#quick-start) · [Vision](#vision) · [App](#app) · [Scraper](#scraper) · [Commands](#available-commands) · [Contributing](#contributing) · [License](#copyright--license) · [Questions?](#questions) + +**Note:** I'm in the process of writing more documentation. As DevDocs is quite big, it'll take time. Feel free to [contact me directly](mailto:thibaut@devdocs.io) in the meantime. + +## Quick Start + +Unless you wish to use DevDocs offline or contribute to the code, I recommend using the hosted version at [devdocs.io](http://devdocs.io). It's up-to-date and requires no setup. + +DevDocs is made of two separate pieces of software: a Ruby scraper responsible for generating the documentation files and indexes, and a JavaScript front-end powered by on small Sinatra app. + +DevDocs requires Ruby 2.0. Once you have it installed, run the following commands: + +``` +gem install bundler +bundle install +thor docs:download --all +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/update individual documentations (e.g. `thor docs:download html css`), or all at the same time (using the `--all` option). You can see the list of available documentations by running `thor docs:list`. + +**Note:** there is currently no update mechanism other than using git to update the code and `thor docs:download` to download the latest version of the docs. To stay informed about new versions, be sure to subscribe to the [newsletter](http://eepurl.com/HnLUz). + +## Vision + +DevDocs aims to make reading and searching reference documentation fast, accessible and enjoyable, while aspiring to become the “one stop shop” for all open-source software documentations. + +The app's main goals are to: keep boot and page load times as fast as possible; improve the quality, speed, and order of search results; maximize the use of caching and other performance optimizations; maintain a clean, readable user interface; 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 content is pulled from third-party sources and the project does not intend to compete with full-text search engines. Its backbone is metadata: each piece of content must be identified by a unique, obvious and short string. Thus, tutorials, guides and other content that don't fit this requirement are outside the scope of the project. + +## App + +The web app is all JavaScript, written in [CoffeeScript](http://coffeescript.org), and powered by a small [Sinatra](http://www.sinatrarb.com)/[Sprockets](https://github.com/sstephenson/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 the requirement for speed. This is partially solved by maximizing caching (both `applicationCache`, which comes with its own set of constraints, and `localStorage` are used to their full extent), as well as by allowing the user to pick his/her own set of documentations. On the other hand, the search algorithm is currently not very sophisticated because it needs to be fast even searching through 100k entries. + +DevDocs being a developer tool, the browser requirements are high: + +1. On the desktop: + * Recent version of Chrome + * Recent version of Firefox + * Safari 5.1+ + * Opera 12.1+ + * Interner Explorer 10+ +2. On mobile: + * iOS 6+ + * Android 4.1+ + * Windows Phone 8+ + +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 given 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 stuff, such as the document structure (``, ``, etc.), comments, empty nodes, etc. +* fixing links (e.g. to remove duplicates) +* replacing all external (not copied) URLs with their fully qualified counterpart +* replacing all internal (copied) URLs with their unqualified and relative counterpart +* adding stuff, such as a title and link to the original document + +These modifications are applied through a set of filters, with each scraper also applying custom filters specific to the documentation. Each document is also passed through a filter whose task is to figure out its metadata, namely its _name_ and _type_ (category). + +The end result is a set of normalized HTML partials and a JSON index file. Because the index files are loaded separately by the [app](#app) following the user's preferences, the code also creates a JSON manifest file containing information about the documentations currently available on the system (such as their name, version, update date, etc.). + +## 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. + +``` +# 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 + +# Console +thor console # Start a REPL +thor console:docs # Start a REPL in the "Docs" module +Note: tests can be run quickly from within the console using the "test" command. Run "help test" +for usage instructions. + +# Tests +thor test:all # Run all tests + +# Assets +thor assets:compile # Compile assets (not required in development mode) +thor assets:clean # Clean old assets +``` + +## Contributing + +Contributions are welcome. Please read the [contributing guidelines](https://github.com/Thibaut/devdocs/blob/master/CONTRIBUTING.md). + +## Copyright / License + +Copyright 2013 Thibaut Courouble and [other contributors](https://github.com/Thibaut/devdocs/graphs/contributors) + +This software is licensed under the terms of the Mozilla Public License v2.0. See the [COPYRIGHT](https://github.com/Thibaut/devdocs/blob/master/COPYRIGHT) and [LICENSE](https://github.com/Thibaut/devdocs/blob/master/LICENSE) files. + +**Note:** I consider _DevDocs_ to be a trademark. You may not use the name to endorse or promote products derived from this software without my permission, except as may be necessary to comply with the notice/attribution requirements. + +**Additionally**, I wish that any documentation file generated using this software be attributed to DevDocs. Let's be fair to all contributors by not stealing their hard work. + +## Questions? + +If you have any questions, please feel free to ask on the [mailing list](https://groups.google.com/d/forum/devdocs). diff --git a/Rakefile b/Rakefile new file mode 100644 index 00000000..311eeca1 --- /dev/null +++ b/Rakefile @@ -0,0 +1,14 @@ +#!/usr/bin/env rake + +require 'bundler/setup' +require 'thor' + +$LOAD_PATH.unshift 'lib' + +namespace :assets do + desc 'Compile all assets' + task :precompile do + load 'tasks/assets.thor' + AssetsCLI.new.compile + end +end diff --git a/Thorfile b/Thorfile new file mode 100644 index 00000000..a0348eb6 --- /dev/null +++ b/Thorfile @@ -0,0 +1 @@ +$LOAD_PATH.unshift 'lib' diff --git a/assets/images/icons.png b/assets/images/icons.png new file mode 100644 index 00000000..9820d340 Binary files /dev/null and b/assets/images/icons.png differ diff --git a/assets/images/icons@2x.png b/assets/images/icons@2x.png new file mode 100644 index 00000000..c66c176a Binary files /dev/null and b/assets/images/icons@2x.png differ diff --git a/assets/images/maxcdn-bw.png b/assets/images/maxcdn-bw.png new file mode 100644 index 00000000..4b1a9924 Binary files /dev/null and b/assets/images/maxcdn-bw.png differ diff --git a/assets/images/maxcdn-bw@2x.png b/assets/images/maxcdn-bw@2x.png new file mode 100644 index 00000000..d3f40cdc Binary files /dev/null and b/assets/images/maxcdn-bw@2x.png differ diff --git a/assets/images/maxcdn.png b/assets/images/maxcdn.png new file mode 100644 index 00000000..43a9a307 Binary files /dev/null and b/assets/images/maxcdn.png differ diff --git a/assets/images/maxcdn@2x.png b/assets/images/maxcdn@2x.png new file mode 100644 index 00000000..53feddca Binary files /dev/null and b/assets/images/maxcdn@2x.png differ diff --git a/assets/javascripts/app/app.coffee b/assets/javascripts/app/app.coffee new file mode 100644 index 00000000..bc6ca874 --- /dev/null +++ b/assets/javascripts/app/app.coffee @@ -0,0 +1,156 @@ +@app = + $: $ + collections: {} + models: {} + templates: {} + views: {} + + init: -> + return unless @browserCheck() + @initErrorTracking() + @showLoading() + + @store = new Store + @appCache = new app.AppCache if app.AppCache.isEnabled() + @settings = new app.Settings + + @docs = new app.collections.Docs + @disabledDocs = new app.collections.Docs + @entries = new app.collections.Entries + + @router = new app.Router + @shortcuts = new app.Shortcuts + @document = new app.views.Document + @mobile = new app.views.Mobile if @isMobile() + + if @DOC + @bootOne() + else if @DOCS + @bootAll() + else + @onBootError() + return + + browserCheck: -> + return true if @isSupportedBrowser() + @hideLoading() + document.body.innerHTML = app.templates.unsupportedBrowser + false + + initErrorTracking: -> + # Show a warning message and don't track errors when the app is loaded + # from a domain other than our own, because things are likely to break. + # (e.g. cross-domain requests) + if @isInvalidLocation() + new app.views.Notif 'InvalidLocation' + else + Raven.config(@config.sentry_dsn).install() if @config.sentry_dsn + @previousErrorHandler = onerror + window.onerror = @onWindowError.bind(@) + return + + bootOne: -> + @doc = new app.models.Doc @DOC + @docs.reset [@doc] + @doc.load @start.bind(@), @onBootError.bind(@), readCache: true + new app.views.Notice 'singleDoc', @doc + delete @DOC + return + + bootAll: -> + docs = @settings.getDocs() + for doc in @DOCS + (if docs.indexOf(doc.slug) >= 0 then @docs else @disabledDocs).add(doc) + @docs.load @start.bind(@), @onBootError.bind(@), readCache: true, writeCache: true + delete @DOCS + return + + start: -> + @entries.add doc.entries.all() for doc in @docs.all() + @trigger 'ready' + @router.start() + @hideLoading() + new app.views.News() unless @doc + @removeEvent 'ready bootError' + return + + reload: -> + @docs.clearCache() + @disabledDocs.clearCache() + if @appCache then @appCache.reload() else window.location = '/' + return + + reset: -> + @store.clear() + @settings.reset() + @appCache?.update() + window.location = '/' + return + + showLoading: -> + document.body.classList.add '_loading' + return + + hideLoading: -> + document.body.classList.remove '_booting' + document.body.classList.remove '_loading' + return + + indexHost: -> + @config[if @appCache and @settings.hasDocs() then 'index_path' else 'docs_host'] + + onBootError: (args...) -> + @trigger 'bootError' + @hideLoading() + return + + onWindowError: (args...) -> + if @isInjectionError args... + @onInjectionError() + else if @isAppError args... + @previousErrorHandler? args... + @hideLoading() + @errorNotif or= new app.views.Notif 'Error' + @errorNotif.show() + return + + onInjectionError: -> + unless @injectionError + @injectionError = true + alert """ + JavaScript code has been injected in the page which prevents DevDocs from running correctly. + Please check your browser extensions/addons. """ + Raven.captureMessage 'injection error' + return + + isInjectionError: -> + # Some browser extensions expect the entire web to use jQuery. + # I gave up trying to fight back. + window.$ isnt app.$ + + isAppError: (error, file) -> + # Ignore errors from external scripts. + file and file.indexOf('devdocs') isnt -1 and file.indexOf('.js') is file.length - 3 + + isSupportedBrowser: -> + try + return true if Function::bind and + history.pushState and + window.matchMedia and + document.body.classList and + document.body.insertAdjacentHTML and + document.createEvent('CustomEvent').defaultPrevented is false and + getComputedStyle(document.querySelector('._header')).backgroundImage isnt 'none' + catch + + isMobile: -> + # Need to sniff the user agent because some Android and Windows Phone devices don't take + # resolution (dpi) into account when reporting device width/height. + @_isMobile ?= (matchMedia('(max-device-width: 767px), (max-device-height: 767px)').matches) or + (navigator.userAgent.indexOf('Android') isnt -1 and navigator.userAgent.indexOf('Mobile') isnt -1) or + (navigator.userAgent.indexOf('IEMobile') isnt -1) + + isInvalidLocation: -> + @config.env is 'production' and location.host.indexOf(app.config.production_host) isnt 0 + +$.extend app, Events diff --git a/assets/javascripts/app/appcache.coffee b/assets/javascripts/app/appcache.coffee new file mode 100644 index 00000000..044acff5 --- /dev/null +++ b/assets/javascripts/app/appcache.coffee @@ -0,0 +1,42 @@ +class app.AppCache + $.extend @prototype, Events + + @isEnabled: -> + try + applicationCache and applicationCache.status isnt applicationCache.UNCACHED + catch + + constructor: -> + @cache = applicationCache + @onUpdateReady() if @cache.status is @cache.UPDATEREADY + + $.on @cache, 'progress', @onProgress + $.on @cache, 'updateready', @onUpdateReady + + @lastCheck = Date.now() + $.on window, 'focus', @checkForUpdate + + update: -> + try @cache.update() catch + return + + reload: -> + @reloading = true + $.on @cache, 'updateready noupdate error', -> window.location = '/' + @update() + return + + checkForUpdate: => + if Date.now() - @lastCheck > 86400e3 + @lastCheck = Date.now() + @update() + return + + onProgress: (event) => + @trigger 'progress', event + return + + onUpdateReady: => + new app.views.Notif 'UpdateReady' unless @reloading + @trigger 'updateready' + return diff --git a/assets/javascripts/app/config.coffee.erb b/assets/javascripts/app/config.coffee.erb new file mode 100644 index 00000000..06cf7aa0 --- /dev/null +++ b/assets/javascripts/app/config.coffee.erb @@ -0,0 +1,10 @@ +app.config = + default_docs: ['css', 'dom', 'dom_events', 'html', 'http', 'javascript'] + docs_host: '<%= App.docs_host %>' + env: '<%= App.environment %>' + history_cache_size: 10 + index_path: '/<%= App.docs_prefix %>' + max_results: 50 + production_host: 'devdocs.io' + search_param: 'q' + sentry_dsn: '<%= App.sentry_dsn %>' diff --git a/assets/javascripts/app/router.coffee b/assets/javascripts/app/router.coffee new file mode 100644 index 00000000..33742d86 --- /dev/null +++ b/assets/javascripts/app/router.coffee @@ -0,0 +1,114 @@ +class app.Router + $.extend @prototype, Events + + @routes: [ + ['*', 'before' ] + ['/', 'root' ] + ['/about', 'about' ] + ['/news', 'news' ] + ['/help', 'help' ] + ['/:doc-:type/', 'type' ] + ['/:doc/', 'doc' ] + ['/:doc/:path(*)', 'entry' ] + ['*', 'notFound'] + ] + + constructor: -> + for [path, method] in @constructor.routes + page path, @[method].bind(@) + @setInitialPath() + + start: -> + page.start() + return + + show: (path) -> + page.show(path) + return + + triggerRoute: (name) -> + @trigger name, @context + @trigger 'after', name, @context + return + + before: (context, next) -> + @context = context + @trigger 'before', context + next() + return + + doc: (context, next) -> + if doc = app.docs.findBy('slug', context.params.doc) or app.disabledDocs.findBy('slug', context.params.doc) + context.doc = doc + context.entry = doc.indexEntry() + @triggerRoute 'entry' + else + next() + return + + type: (context, next) -> + doc = app.docs.findBy 'slug', context.params.doc + + if type = doc?.types.findBy 'slug', context.params.type + context.doc = doc + context.type = type + @triggerRoute 'type' + else + next() + return + + entry: (context, next) -> + doc = app.docs.findBy 'slug', context.params.doc + + if entry = doc?.findEntryByPathAndHash(context.params.path, context.hash) + context.doc = doc + context.entry = entry + @triggerRoute 'entry' + else + next() + return + + root: -> + @triggerRoute 'root' + return + + about: (context) -> + context.page = 'about' + @triggerRoute 'page' + return + + news: (context) -> + context.page = 'news' + @triggerRoute 'page' + return + + help: (context) -> + context.page = 'help' + @triggerRoute 'page' + return + + notFound: (context) -> + @triggerRoute 'notFound' + return + + isRoot: -> + location.pathname is '/' + + setInitialPath: -> + # Remove superfluous forward slashes at the beginning of the path + if (path = location.pathname.replace /^\/{2,}/g, '/') isnt location.pathname + page.replace path + location.search + location.hash, null, true + + # When the path is "/#/path", replace it with "/path" + if @isRoot() and path = @getInitialPath() + page.replace path + location.search, null, true + return + + getInitialPath: -> + try + (new RegExp "#/(.+)").exec(decodeURIComponent location.hash)?[1] + catch + + replaceHash: (hash) -> + page.replace location.pathname + location.search + (hash or ''), null, true + return diff --git a/assets/javascripts/app/searcher.coffee b/assets/javascripts/app/searcher.coffee new file mode 100644 index 00000000..3b97fae9 --- /dev/null +++ b/assets/javascripts/app/searcher.coffee @@ -0,0 +1,221 @@ +class app.Searcher + $.extend @prototype, Events + + CHUNK_SIZE = 10000 + SEPARATOR = '.' + + DEFAULTS = + max_results: app.config.max_results + fuzzy_min_length: 3 + + constructor: (options = {}) -> + @options = $.extend {}, DEFAULTS, options + + find: (data, attr, query) -> + @kill() + + @data = data + @attr = attr + @query = query + @setup() + + if @isValid() then @match() else @end() + return + + setup: -> + @query = @normalizeQuery @query + @queryLength = @query.length + @dataLength = @data.length + @matchers = ['exactMatch'] + @totalResults = 0 + @setupFuzzy() + return + + setupFuzzy: -> + if @queryLength >= @options.fuzzy_min_length + @fuzzyRegexp = @queryToFuzzyRegexp @query + @matchers.push 'fuzzyMatch' + return + + isValid: -> + @queryLength > 0 + + end: -> + @triggerResults [] unless @totalResults + @free() + return + + kill: -> + if @timeout + clearTimeout @timeout + @free() + return + + free: -> + @data = @attr = @query = @queryLength = @dataLength = + @fuzzyRegexp = @matchers = @totalResults = @scoreMap = + @cursor = @matcher = @timeout = null + return + + match: => + if not @foundEnough() and @matcher = @matchers.shift() + @setupMatcher() + @matchChunks() + else + @end() + return + + setupMatcher: -> + @cursor = 0 + @scoreMap = new Array(101) + return + + matchChunks: => + @matchChunk() + + if @cursor is @dataLength or @scoredEnough() + @delay @match + @sendResults() + else + @delay @matchChunks + return + + matchChunk: -> + for [0...@chunkSize()] + if score = @[@matcher](@data[@cursor][@attr]) + @addResult @data[@cursor], score + @cursor++ + return + + chunkSize: -> + if @cursor + CHUNK_SIZE > @dataLength + @dataLength % CHUNK_SIZE + else + CHUNK_SIZE + + scoredEnough: -> + @scoreMap[100]?.length >= @options.max_results + + foundEnough: -> + @totalResults >= @options.max_results + + addResult: (object, score) -> + (@scoreMap[Math.round(score)] or= []).push(object) + @totalResults++ + return + + getResults: -> + results = [] + for objects in @scoreMap by -1 when objects + results.push.apply results, objects + results[0...@options.max_results] + + sendResults: -> + results = @getResults() + @triggerResults results if results.length + return + + triggerResults: (results) -> + @trigger 'results', results + return + + delay: (fn) -> + @timeout = setTimeout(fn, 1) + + normalizeQuery: (string) -> + string.replace(/\s/g, '').toLowerCase() + + queryToFuzzyRegexp: (string) -> + chars = string.split '' + chars[i] = $.escapeRegexp(char) for char, i in chars + new RegExp chars.join('.*?') # abc -> /a.*?b.*?c.*?/ + + # + # Match functions + # + + index = # position of the query in the string being matched + match = # regexp match data + score = # score for the current match + separators = # counter + i = null # cursor + + exactMatch: (value) -> + index = value.indexOf @query + return unless index >= 0 + + # Remove one point for each unmatched character. + score = 100 - (value.length - @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[index - 1] is SEPARATOR + score += index - 1 + # Don't match a single-character query unless it's found at the beginning + # of the string or is preceded by a dot. + else if @queryLength is 1 + return + # (1) Remove one point for each unmatched character up to the nearest + # preceding dot or the beginning of the string. + # (2) Remove one point for each unmatched character following the query. + else + i = index - 2 + i-- while i >= 0 and value[i] isnt SEPARATOR + score -= (index - i) + # (1) + (value.length - @queryLength - index) # (2) + + # Remove one point for each dot preceding the query, except for the one + # immediately before the query. + separators = 0 + i = index - 2 + while i >= 0 + separators++ if value[i] is SEPARATOR + i-- + score -= separators + + # Remove five points for each dot following the query. + separators = 0 + i = value.length - @queryLength - index - 1 + while i >= 0 + separators++ if value[index + @queryLength + i] is SEPARATOR + i-- + score -= separators * 5 + + Math.max 1, score + + fuzzyMatch: (value) -> + return if value.length <= @queryLength or value.indexOf(@query) >= 0 + return unless match = @fuzzyRegexp.exec(value) + + # When the match is at the beginning of the string or preceded by a dot. + if match.index is 0 or value[match.index - 1] is SEPARATOR + Math.max 66, 100 - match[0].length + # When the match is at the end of the string. + else if match.index + match[0].length is value.length + Math.max 33, 67 - match[0].length + # When the match is in the middle of the string. + else + Math.max 1, 34 - match[0].length + +class app.SynchronousSearcher extends app.Searcher + match: => + if @matcher + @allResults or= [] + @allResults.push.apply @allResults, @getResults() + super + + free: -> + @allResults = null + super + + end: -> + @sendResults true + super + + sendResults: (end) -> + if end and @allResults?.length + @triggerResults @allResults + + delay: (fn) -> + fn() diff --git a/assets/javascripts/app/settings.coffee b/assets/javascripts/app/settings.coffee new file mode 100644 index 00000000..15c1e6e6 --- /dev/null +++ b/assets/javascripts/app/settings.coffee @@ -0,0 +1,25 @@ +class app.Settings + hasDocs: -> + try + !!Cookies.get 'docs' + catch + + getDocs: -> + try + Cookies.get('docs')?.split('/') or app.config.default_docs + catch + app.config.default_docs + + setDocs: (docs) -> + try + Cookies.set 'docs', docs.join('/'), + path: '/' + expires: 1e8 + catch + return + + reset: -> + try + Cookies.expire 'docs' + catch + return diff --git a/assets/javascripts/app/shortcuts.coffee b/assets/javascripts/app/shortcuts.coffee new file mode 100644 index 00000000..18e05d4d --- /dev/null +++ b/assets/javascripts/app/shortcuts.coffee @@ -0,0 +1,108 @@ +class app.Shortcuts + $.extend @prototype, Events + + constructor: -> + @start() + + start: -> + $.on document, 'keydown', @onKeydown + $.on document, 'keypress', @onKeypress + return + + stop: -> + $.off document, 'keydown', @onKeydown + $.off document, 'keypress', @onKeypress + return + + onKeydown: (event) => + result = if event.ctrlKey or event.metaKey + @handleKeydownSuperEvent event unless event.altKey or event.shiftKey + else if event.shiftKey + @handleKeydownShiftEvent event unless event.altKey + else if event.altKey + @handleKeydownAltEvent event + else + @handleKeydownEvent event + + event.preventDefault() if result is false + return + + onKeypress: (event) => + unless event.ctrlKey or event.metaKey + result = @handleKeypressEvent event + event.preventDefault() if result is false + return + + handleKeydownEvent: (event) -> + if not event.target.form and 65 <= event.which <= 90 + @trigger 'typing' + return + + switch event.which + when 8 + @trigger 'typing' unless event.target.form + when 13 + @trigger 'enter' + when 27 + @trigger 'escape' + when 32 + @trigger 'pageDown' + false + when 33 + @trigger 'pageUp' + when 34 + @trigger 'pageDown' + when 35 + @trigger 'end' + when 36 + @trigger 'home' + when 37 + @trigger 'left' unless event.target.value + when 38 + @trigger 'up' + false + when 39 + @trigger 'right' unless event.target.value + when 40 + @trigger 'down' + false + + handleKeydownSuperEvent: (event) -> + switch event.which + when 13 + @trigger 'superEnter' + when 37 + @trigger 'superLeft' + false + when 38 + @trigger 'home' + false + when 39 + @trigger 'superRight' + false + when 40 + @trigger 'end' + false + + handleKeydownShiftEvent: (event) -> + if not event.target.form and 65 <= event.which <= 90 + @trigger 'typing' + return + + if event.which is 32 + @trigger 'pageUp' + false + + handleKeydownAltEvent: (event) -> + switch event.which + when 38 + @trigger 'altUp' + false + when 40 + @trigger 'altDown' + false + + handleKeypressEvent: (event) -> + if event.which is 63 and not event.target.value + @trigger 'help' + false diff --git a/assets/javascripts/application.js.coffee b/assets/javascripts/application.js.coffee new file mode 100644 index 00000000..34621ad6 --- /dev/null +++ b/assets/javascripts/application.js.coffee @@ -0,0 +1,25 @@ +#= 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 + +init = -> + document.removeEventListener 'DOMContentLoaded', init + app.init() + +document.addEventListener 'DOMContentLoaded', init, false diff --git a/assets/javascripts/collections/collection.coffee b/assets/javascripts/collections/collection.coffee new file mode 100644 index 00000000..ba2f5517 --- /dev/null +++ b/assets/javascripts/collections/collection.coffee @@ -0,0 +1,43 @@ +class app.Collection + constructor: (objects = []) -> + @reset objects + + model: -> + app.models[@constructor.model] + + reset: (objects = []) -> + @models = [] + @add object for object in objects + return + + add: (object) -> + if object instanceof app.Model + @models.push object + else if object instanceof Array + @add obj for obj in object + else if object instanceof app.Collection + @models.push object.all()... + else + @models.push new (@model())(object) + return + + size: -> + @models.length + + isEmpty: -> + @models.length is 0 + + each: (fn) -> + fn(model) for model in @models + return + + all: -> + @models + + findBy: (attr, value) -> + for model in @models + return model if model[attr] is value + return + + findAllBy: (attr, value) -> + model for model in @models when model[attr] is value diff --git a/assets/javascripts/collections/docs.coffee b/assets/javascripts/collections/docs.coffee new file mode 100644 index 00000000..f40893c1 --- /dev/null +++ b/assets/javascripts/collections/docs.coffee @@ -0,0 +1,30 @@ +class app.collections.Docs extends app.Collection + @model: 'Doc' + + # Load models concurrently. + # It's not pretty but I didn't want to import a promise library only for this. + CONCURRENCY = 3 + load: (onComplete, onError, options) -> + i = 0 + + next = => + if i < @models.length + @models[i].load(next, fail, options) + else if i is @models.length + CONCURRENCY - 1 + onComplete() + i++ + return + + fail = (args...) -> + if onError + onError(args...) + onError = null + next() + return + + next() for [0...CONCURRENCY] + return + + clearCache: -> + doc.clearCache() for doc in @models + return diff --git a/assets/javascripts/collections/entries.coffee b/assets/javascripts/collections/entries.coffee new file mode 100644 index 00000000..f978b68b --- /dev/null +++ b/assets/javascripts/collections/entries.coffee @@ -0,0 +1,2 @@ +class app.collections.Entries extends app.Collection + @model: 'Entry' diff --git a/assets/javascripts/collections/types.coffee b/assets/javascripts/collections/types.coffee new file mode 100644 index 00000000..455a7ed7 --- /dev/null +++ b/assets/javascripts/collections/types.coffee @@ -0,0 +1,2 @@ +class app.collections.Types extends app.Collection + @model: 'Type' diff --git a/assets/javascripts/debug.js.coffee b/assets/javascripts/debug.js.coffee new file mode 100644 index 00000000..c3bb8f21 --- /dev/null +++ b/assets/javascripts/debug.js.coffee @@ -0,0 +1,81 @@ +# +# App +# + +_init = app.init +app.init = -> + console.time 'Init' + _init.call(app) + console.timeEnd 'Init' + console.time 'Load' + +_start = app.start +app.start = -> + _start.call(app, arguments...) + console.timeEnd 'Load' + +_super = app.Searcher +_proto = app.Searcher.prototype + +# +# Searcher +# + +app.Searcher = -> + _super.apply @, arguments + + _setup = @setup.bind(@) + @setup = -> + console.groupCollapsed "Search: #{@query}" + console.time 'Total' + _setup() + + _match = @match.bind(@) + @match = => + if @matcher + console.timeEnd @matcher + if @matcher is 'exactMatch' + for entries, score in @scoreMap by -1 when entries + console.log '' + score + ': ' + entries.map((entry) -> entry.text).join("\n ") + _match() + + _setupMatcher = @setupMatcher.bind(@) + @setupMatcher = -> + console.time @matcher + _setupMatcher() + + _end = @end.bind(@) + @end = -> + console.log "Results: #{@totalResults}" + console.groupEnd() + console.timeEnd 'Total' + _end() + + _kill = @kill.bind(@) + @kill = -> + if @timeout + console.timeEnd @matcher if @matcher + console.groupEnd() + console.timeEnd 'Total' + console.warn 'Killed' + _kill() + + return + +_proto.constructor = app.Searcher +app.Searcher.prototype = _proto + +# +# View tree +# + +@viewTree = (view = app.document, level = 0) -> + console.log "%c #{Array(level + 1).join(' ')}#{view.constructor.name}: #{view.activated}", + 'color:' + (view.activated and 'green' or 'red') + + for key, value of view when key isnt 'view' and value + if typeof value is 'object' and value.setupElement + @viewTree(value, level + 1) + else if value.constructor.toString().match(/Object\(\)/) + @viewTree(v, level + 1) for k, v of value when value and typeof value is 'object' and value.setupElement + return diff --git a/assets/javascripts/docs.js.erb b/assets/javascripts/docs.js.erb new file mode 100644 index 00000000..644a6066 --- /dev/null +++ b/assets/javascripts/docs.js.erb @@ -0,0 +1,2 @@ +//= depend_on docs.json +app.DOCS = <%= File.read App.docs_manifest_path %>; diff --git a/assets/javascripts/lib/ajax.coffee b/assets/javascripts/lib/ajax.coffee new file mode 100644 index 00000000..62f3d276 --- /dev/null +++ b/assets/javascripts/lib/ajax.coffee @@ -0,0 +1,122 @@ +MIME_TYPES = + json: 'application/json' + html: 'text/html' + +@ajax = (options) -> + applyDefaults(options) + serializeData(options) + + xhr = new XMLHttpRequest() + xhr.open(options.type, options.url, options.async) + + applyCallbacks(xhr, options) + applyHeaders(xhr, options) + + xhr.send(options.data) + + if options.async + abort: abort.bind(undefined, xhr) + else + parseResponse(xhr, options) + +ajax.defaults = + async: true + dataType: 'json' + timeout: 30000 + type: 'GET' + # contentType + # context + # data + # error + # headers + # success + # url + +applyDefaults = (options) -> + for key of ajax.defaults + options[key] ?= ajax.defaults[key] + return + +serializeData = (options) -> + return unless options.data + + if options.type is 'GET' + options.url += '?' + serializeParams(options.data) + options.data = null + else + options.data = serializeParams(options.data) + return + +serializeParams = (params) -> + ("#{encodeURIComponent key}=#{encodeURIComponent value}" for key, value of params).join '&' + +applyCallbacks = (xhr, options) -> + return unless options.async + + xhr.timer = setTimeout onTimeout.bind(undefined, xhr, options), options.timeout + xhr.onreadystatechange = -> + if xhr.readyState is 4 + clearTimeout(xhr.timer) + onComplete(xhr, options) + return + return + +applyHeaders = (xhr, options) -> + options.headers or= {} + + if options.contentType + options.headers['Content-Type'] = options.contentType + + if not options.headers['Content-Type'] and options.data and options.type isnt 'GET' + options.headers['Content-Type'] = 'application/x-www-form-urlencoded' + + if options.dataType + options.headers['Accept'] = MIME_TYPES[options.dataType] or options.dataType + + if isSameOrigin(options.url) + options.headers['X-Requested-With'] = 'XMLHttpRequest' + + for key, value of options.headers + xhr.setRequestHeader(key, value) + return + +onComplete = (xhr, options) -> + if 200 <= xhr.status < 300 + if (response = parseResponse(xhr, options))? + onSuccess response, xhr, options + else + onError 'invalid', xhr, options + else + onError 'error', xhr, options + return + +onSuccess = (response, xhr, options) -> + options.success?.call options.context, response, xhr, options + return + +onError = (type, xhr, options) -> + options.error?.call options.context, type, xhr, options + return + +onTimeout = (xhr, options) -> + xhr.abort() + onError 'timeout', xhr, options + return + +abort = (xhr) -> + clearTimeout(xhr.timer) + xhr.onreadystatechange = null + xhr.abort() + return + +isSameOrigin = (url) -> + url.indexOf('http') isnt 0 or url.indexOf(location.origin) is 0 + +parseResponse = (xhr, options) -> + if options.dataType is 'json' + parseJSON(xhr.responseText) + else + xhr.responseText + +parseJSON = (json) -> + try JSON.parse(json) catch diff --git a/assets/javascripts/lib/events.coffee b/assets/javascripts/lib/events.coffee new file mode 100644 index 00000000..feeb5498 --- /dev/null +++ b/assets/javascripts/lib/events.coffee @@ -0,0 +1,26 @@ +@Events = + on: (event, callback) -> + if event.indexOf(' ') >= 0 + @on name, callback for name in event.split(' ') + else + ((@_callbacks ?= {})[event] ?= []).push callback + @ + + off: (event, callback) -> + if event.indexOf(' ') >= 0 + @off name, callback for name in event.split(' ') + else if (callbacks = @_callbacks?[event]) and (index = callbacks.indexOf callback) >= 0 + callbacks.splice index, 1 + delete @_callbacks[event] unless callbacks.length + @ + + trigger: (event, args...) -> + if callbacks = @_callbacks?[event] + callback? args... for callback in callbacks.slice(0) + @trigger 'all', event, args... unless event is 'all' + @ + + removeEvent: (event) -> + if @_callbacks? + delete @_callbacks[name] for name in event.split(' ') + @ diff --git a/assets/javascripts/lib/license.coffee b/assets/javascripts/lib/license.coffee new file mode 100644 index 00000000..39bb806d --- /dev/null +++ b/assets/javascripts/lib/license.coffee @@ -0,0 +1,7 @@ +### + * Copyright 2013 Thibaut Courouble and other contributors + * + * This source code is licensed under the terms of the Mozilla + * Public License, v. 2.0, a copy of which may be obtained at: + * http://mozilla.org/MPL/2.0/ +### diff --git a/assets/javascripts/lib/page.coffee b/assets/javascripts/lib/page.coffee new file mode 100644 index 00000000..da58171e --- /dev/null +++ b/assets/javascripts/lib/page.coffee @@ -0,0 +1,161 @@ +### + * Based on github.com/visionmedia/page.js + * Licensed under the MIT license + * Copyright 2012 TJ Holowaychuk +### + +running = false +currentState = null +callbacks = [] + +@page = (value, fn) -> + if typeof value is 'function' + page '*', value + else if typeof fn is 'function' + route = new Route(value) + callbacks.push route.middleware(fn) + else if typeof value is 'string' + page.show(value, fn) + else + page.start(value) + return + +page.start = (options = {}) -> + unless running + running = true + addEventListener 'popstate', onpopstate + addEventListener 'click', onclick + page.replace currentPath(), null, null, true + return + +page.stop = -> + if running + running = false + removeEventListener 'click', onclick + removeEventListener 'popstate', onpopstate + return + +page.show = (path, state) -> + return if path is currentState?.path + context = new Context(path, state) + currentState = context.state + page.dispatch(context) + context.pushState() + context + +page.replace = (path, state, skipDispatch, init) -> + context = new Context(path, state or currentState) + context.init = init + currentState = context.state + page.dispatch(context) unless skipDispatch + context.replaceState() + context + +page.dispatch = (context) -> + i = 0 + next = -> + fn(context, next) if fn = callbacks[i++] + return + next() + return + +currentPath = -> + location.pathname + location.search + location.hash + +class Context + @initialPath: currentPath() + @sessionId: Date.now() + @stateId: 0 + + @isInitialPopState: (state) -> + state.path is @initialPath and @stateId is 1 + + @isSameSession: (state) -> + state.sessionId is @sessionId + + constructor: (@path = '/', @state = {}) -> + @pathname = @path.replace /(?:\?([^#]*))?(?:#(.*))?$/, (_, query, hash) => + @query = query + @hash = hash + '' + + @state.id ?= @constructor.stateId++ + @state.sessionId ?= @constructor.sessionId + @state.path = @path + + pushState: -> + history.pushState @state, '', @path + return + + replaceState: -> + history.replaceState @state, '', @path + return + +class Route + constructor: (@path, options = {}) -> + @keys = [] + @regexp = pathtoRegexp @path, @keys + + middleware: (fn) -> + (context, next) => + if @match context.pathname, params = [] + context.params = params + fn(context, next) + else + next() + return + + match: (path, params) -> + return unless matchData = @regexp.exec(path) + + for value, i in matchData[1..] + value = decodeURIComponent value if typeof value is 'string' + if key = @keys[i] + params[key.name] = value + else + params.push value + true + +pathtoRegexp = (path, keys) -> + return path if path instanceof RegExp + + path = "(#{path.join '|'})" if path instanceof Array + path = path + .replace(/\/\(/g, '(?:/') + .replace /(\/)?(\.)?:(\w+)(?:(\(.*?\)))?(\?)?/g, (_, slash = '', format = '', key, capture, optional) -> + keys.push name: key, optional: !!optional + str = if optional then '' else slash + str += '(?:' + str += slash if optional + str += format + str += capture or if format then '([^/.]+?)' else '([^/]+?)' + str += ')' + str += optional if optional + str + .replace(/([\/.])/g, '\\$1') + .replace(/\*/g, '(.*)') + + new RegExp "^#{path}$" + +onpopstate = (event) -> + return if not event.state or Context.isInitialPopState(event.state) + + if Context.isSameSession(event.state) + page.replace(event.state.path, event.state) + else + location.reload() + return + +onclick = (event) -> + return if event.which isnt 1 or event.metaKey or event.ctrlKey or event.shiftKey or event.defaultPrevented + + link = event.target + link = link.parentElement while link and link.tagName isnt 'A' + + if link and not link.target and isSameOrigin(link.href) + event.preventDefault() + page.show link.pathname + link.search + link.hash + return + +isSameOrigin = (url) -> + url.indexOf("#{location.protocol}//#{location.hostname}") is 0 diff --git a/assets/javascripts/lib/store.coffee b/assets/javascripts/lib/store.coffee new file mode 100644 index 00000000..c76d6f28 --- /dev/null +++ b/assets/javascripts/lib/store.coffee @@ -0,0 +1,23 @@ +class @Store + get: (key) -> + try + JSON.parse localStorage.getItem(key) + catch + + set: (key, value) -> + try + localStorage.setItem(key, JSON.stringify(value)) + true + catch + + del: (key) -> + try + localStorage.removeItem(key) + true + catch + + clear: -> + try + localStorage.clear() + true + catch diff --git a/assets/javascripts/lib/util.coffee b/assets/javascripts/lib/util.coffee new file mode 100644 index 00000000..faf14e07 --- /dev/null +++ b/assets/javascripts/lib/util.coffee @@ -0,0 +1,284 @@ +# +# Traversing +# + +@$ = (selector, el = document) -> + try el.querySelector(selector) catch + +@$$ = (selector, el = document) -> + try el.querySelectorAll(selector) catch + +$.id = (id) -> + document.getElementById(id) + +$.hasChild = (parent, el) -> + loop + return true if el is parent + return if el is document.body + el = el.parentElement + +$.closestLink = (el, parent = document.body) -> + loop + return el if el.tagName is 'A' + return if el is parent + el = el.parentElement + +# +# Events +# + +$.on = (el, event, callback) -> + if event.indexOf(' ') >= 0 + $.on el, name, callback for name in event.split(' ') + else + el.addEventListener(event, callback) + return + +$.off = (el, event, callback) -> + if event.indexOf(' ') >= 0 + $.off el, name, callback for name in event.split(' ') + else + el.removeEventListener(event, callback) + return + +$.trigger = (el, type, canBubble = true, cancelable = true) -> + event = document.createEvent 'Event' + event.initEvent(type, canBubble, cancelable) + el.dispatchEvent(event) + return + +$.click = (el) -> + event = document.createEvent 'MouseEvent' + event.initMouseEvent 'click', true, true, window, null, 0, 0, 0, 0, false, false, false, false, 0, null + el.dispatchEvent(event) + return + +$.stopEvent = (event) -> + event.preventDefault() + event.stopPropagation() + event.stopImmediatePropagation() + return + +# +# Manipulation +# + +buildFragment = (value) -> + fragment = document.createDocumentFragment() + + if $.isCollection(value) + fragment.appendChild(child) for child in $.makeArray(value) + else + fragment.innerHTML = value + + fragment + +$.append = (el, value) -> + if typeof value is 'string' + el.insertAdjacentHTML 'beforeend', value + else + value = buildFragment(value) if $.isCollection(value) + el.appendChild(value) + return + +$.prepend = (el, value) -> + if not el.firstChild + $.append(value) + else if typeof value is 'string' + el.insertAdjacentHTML 'afterbegin', value + else + value = buildFragment(value) if $.isCollection(value) + el.insertBefore(value, el.firstChild) + return + +$.before = (el, value) -> + if typeof value is 'string' or $.isCollection(value) + value = buildFragment(value) + + el.parentElement.insertBefore(value, el) + return + +$.after = (el, value) -> + if typeof value is 'string' or $.isCollection(value) + value = buildFragment(value) + + if el.nextSibling + el.parentElement.insertBefore(value, el.nextSibling) + else + el.parentElement.appendChild(value) + return + +$.remove = (value) -> + if $.isCollection(value) + el.parentElement.removeChild(el) for el in $.makeArray(value) + else + value.parentElement.removeChild(value) + return + +$.empty = (el) -> + el.removeChild(el.firstChild) while el.firstChild + return + +# Calls the function while the element is off the DOM to avoid triggering +# unecessary reflows and repaints. +$.batchUpdate = (el, fn) -> + parent = el.parentNode + sibling = el.nextSibling + parent.removeChild(el) + + fn(el) + + if (sibling) + parent.insertBefore(el, sibling) + else + parent.appendChild(el) + return + +# +# Offset +# + +$.rect = (el) -> + el.getBoundingClientRect() + +$.offset = (el, container = document.body) -> + top = 0 + left = 0 + + while el and el isnt container + top += el.offsetTop + left += el.offsetLeft + el = el.offsetParent + + top: top + left: left + +$.scrollParent = (el) -> + while el = el.parentElement + break if el.scrollTop > 0 + break if getComputedStyle(el).overflowY in ['auto', 'scroll'] + el + +$.scrollTo = (el, parent, position = 'center', options = {}) -> + return unless el + + parent ?= $.scrollParent(el) + return unless parent + + parentHeight = parent.clientHeight + return unless parent.scrollHeight > parentHeight + + top = $.offset(el, parent).top + + switch position + when 'top' + parent.scrollTop = top - (options.margin or 20) + when 'center' + parent.scrollTop = top - Math.round(parentHeight / 2 - el.offsetHeight / 2) + when 'continuous' + scrollTop = parent.scrollTop + height = el.offsetHeight + + # If the target element is above the visible portion of its scrollable + # ancestor, move it near the top with a gap = options.topGap * target's height. + if top <= scrollTop + height * (options.topGap or 1) + parent.scrollTop = top - height * (options.topGap or 1) + # If the target element is below the visible portion of its scrollable + # ancestor, move it near the bottom with a gap = options.bottomGap * target's height. + else if top >= scrollTop + parentHeight - height * ((options.bottomGap or 1) + 1) + parent.scrollTop = top - parentHeight + height * ((options.bottomGap or 1) + 1) + return + +$.scrollToWithImageLock = (el, parent, args...) -> + parent ?= $.scrollParent(el) + return unless parent + + $.scrollTo el, parent, args... + + # Lock the scroll position on the target element for up to 3 seconds while + # nearby images are loaded and rendered. + for image in parent.getElementsByTagName('img') when not image.complete + do -> + onLoad = (event) -> + clearTimeout(timeout) + unbind(event.target) + $.scrollTo el, parent, args... + + unbind = (target) -> + $.off target, 'load', onLoad + + $.on image, 'load', onLoad + timeout = setTimeout unbind.bind(null, image), 3000 + return + +# Calls the function while locking the element's position relative to the window. +$.lockScroll = (el, fn) -> + if parent = $.scrollParent(el) + top = $.rect(el).top + top -= $.rect(parent).top unless parent in [document.body, document.documentElement] + fn() + parent.scrollTop = $.offset(el, parent).top - top + else + fn() + return + +# +# Utilities +# + +$.extend = (target, objects...) -> + for object in objects when object + for key, value of object + target[key] = value + target + +$.makeArray = (object) -> + if Array.isArray(object) + object + else + Array::slice.apply(object) + +# Returns true if the object is an array or a collection of DOM elements. +$.isCollection = (object) -> + Array.isArray(object) or typeof object?.item is 'function' + +ESCAPE_HTML_MAP = + '&': '&' + '<': '<' + '>': '>' + '"': '"' + "'": ''' + '/': '/' + +ESCAPE_HTML_REGEXP = /[&<>"'\/]/g + +$.escape = (string) -> + string.replace ESCAPE_HTML_REGEXP, (match) -> ESCAPE_HTML_MAP[match] + +ESCAPE_REGEXP = /([.*+?^=!:${}()|\[\]\/\\])/g + +$.escapeRegexp = (string) -> + string.replace ESCAPE_REGEXP, "\\$1" + +# +# Miscellaneous +# + +$.noop = -> + +$.popup = (value) -> + open value.href or value, '_blank' + return + +$.isTouchScreen = -> + typeof ontouchstart isnt 'undefined' + +HIGHLIGHT_DEFAULTS = + className: 'highlight' + delay: 1000 + +$.highlight = (el, options = {}) -> + options = $.extend {}, HIGHLIGHT_DEFAULTS, options + el.classList.add(options.className) + setTimeout (-> el.classList.remove(options.className)), options.delay + return diff --git a/assets/javascripts/models/doc.coffee b/assets/javascripts/models/doc.coffee new file mode 100644 index 00000000..e12641b3 --- /dev/null +++ b/assets/javascripts/models/doc.coffee @@ -0,0 +1,85 @@ +class app.models.Doc extends app.Model + # Attributes: name, slug, type, version, index_path, mtime + + constructor: -> + super + @reset @ + + reset: (data) -> + @resetEntries data.entries + @resetTypes data.types + return + + resetEntries: (entries) -> + @entries = new app.collections.Entries(entries) + @entries.each (entry) => entry.doc = @ + return + + resetTypes: (types) -> + @types = new app.collections.Types(types) + @types.each (type) => type.doc = @ + return + + fullPath: (path = '') -> + path = "/#{path}" unless path[0] is '/' + "/#{@slug}#{path}" + + fileUrl: (path) -> + "#{app.config.docs_host}#{@fullPath(path)}" + + indexUrl: -> + "#{app.indexHost()}/#{@index_path}" + + indexEntry: -> + new app.models.Entry + doc: @ + name: @name + path: 'index' + + findEntryByPathAndHash: (path, hash) -> + if hash and entry = @entries.findBy 'path', "#{path}##{hash}" + entry + else if path is 'index' + @indexEntry() + else + @entries.findBy 'path', path + + load: (onSuccess, onError, options = {}) -> + return if options.readCache and @_loadFromCache(onSuccess) + + callback = (data) => + @reset data + onSuccess() + @_setCache data if options.writeCache + + ajax + url: @indexUrl() + success: callback + error: onError + + clearCache: -> + app.store.del @slug + return + + _loadFromCache: (onSuccess) -> + return unless data = @_getCache() + + callback = => + @reset data + onSuccess() + + setTimeout callback, 0 + true + + _getCache: -> + return unless data = app.store.get @slug + + if data[0] is @mtime + return data[1] + else + @clearCache() + return + + _setCache: (data) -> + app.store.set @slug, [@mtime, data] + return diff --git a/assets/javascripts/models/entry.coffee b/assets/javascripts/models/entry.coffee new file mode 100644 index 00000000..a112ae5d --- /dev/null +++ b/assets/javascripts/models/entry.coffee @@ -0,0 +1,46 @@ +class app.models.Entry extends app.Model + # Attributes: name, type, path + + SEPARATORS_REGEXP = /\ |#|::/g + PARANTHESES_REGEXP = /\(.*?\)$/ + + constructor: -> + super + @text = @searchValue() + + searchValue: -> + @name + .toLowerCase() + .replace('...', ' ') + .replace(' event', '') + .replace(SEPARATORS_REGEXP, '.') + .replace(/\.+/g, '.') + .replace(PARANTHESES_REGEXP, '') + .trim() + + fullPath: -> + @doc.fullPath if @isIndex() then '' else @path + + filePath: -> + @doc.fullPath @_filePath() + + fileUrl: -> + @doc.fileUrl @_filePath() + + _filePath: -> + result = @path.replace /#.*/, '' + result += '.html' unless result[-5..-1] is '.html' + result + + isIndex: -> + @path is 'index' + + getType: -> + @doc.types.findBy 'name', @type + + loadFile: (onSuccess, onError) -> + ajax + url: @fileUrl() + dataType: 'html' + success: onSuccess + error: onError diff --git a/assets/javascripts/models/model.coffee b/assets/javascripts/models/model.coffee new file mode 100644 index 00000000..7f157f7c --- /dev/null +++ b/assets/javascripts/models/model.coffee @@ -0,0 +1,3 @@ +class app.Model + constructor: (attributes) -> + @[key] = value for key, value of attributes diff --git a/assets/javascripts/models/type.coffee b/assets/javascripts/models/type.coffee new file mode 100644 index 00000000..c68ba0ea --- /dev/null +++ b/assets/javascripts/models/type.coffee @@ -0,0 +1,8 @@ +class app.models.Type extends app.Model + # Attributes: name, slug, count + + fullPath: -> + "/#{@doc.slug}-#{@slug}/" + + entries: -> + @doc.entries.findAllBy 'type', @name diff --git a/assets/javascripts/templates/base.coffee b/assets/javascripts/templates/base.coffee new file mode 100644 index 00000000..841d1e0b --- /dev/null +++ b/assets/javascripts/templates/base.coffee @@ -0,0 +1,11 @@ +app.templates.render = (name, value, args...) -> + template = app.templates[name] + + if Array.isArray(value) + result = '' + result += template(val, args...) for val in value + result + else if typeof template is 'function' + template(value, args...) + else + template diff --git a/assets/javascripts/templates/error_tmpl.coffee b/assets/javascripts/templates/error_tmpl.coffee new file mode 100644 index 00000000..608620db --- /dev/null +++ b/assets/javascripts/templates/error_tmpl.coffee @@ -0,0 +1,44 @@ +error = (title, text = '', links = '') -> + text = """

#{text}

""" if text + links = """""" if links + """

#{title}

#{text}#{links}
""" + +back = 'Go back' + +app.templates.notFoundPage = -> + error """ Oops, that page doesn't exist. """, + """ It may be missing from the source documentation or this could be a bug. """, + back + +app.templates.pageLoadError = -> + error """ Oops, that page failed to load. """, + """ It may be missing from the server or you could be offline.
+ If you keep seeing this, you're likely behind a proxy or firewall which blocks cross-domain requests. """, + """ #{back} · Retry """ + +app.templates.bootError = -> + error """ Oops, 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.unsupportedBrowser = """ +
+

Your browser is unsupported, sorry.

+

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

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

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

+ — Thibaut @DevDocs +

+""" diff --git a/assets/javascripts/templates/notice_tmpl.coffee b/assets/javascripts/templates/notice_tmpl.coffee new file mode 100644 index 00000000..d1aeb972 --- /dev/null +++ b/assets/javascripts/templates/notice_tmpl.coffee @@ -0,0 +1,9 @@ +notice = (text) -> """

#{text}

""" + +app.templates.singleDocNotice = (doc) -> + notice """ You're currently browsing the #{doc.name} documentation. To browse all docs, go to + #{app.config.production_host}. """ + +app.templates.disabledDocNotice = -> + notice """ This documentation is currently disabled. + To enable it, click Select documentation. """ diff --git a/assets/javascripts/templates/notif_tmpl.coffee b/assets/javascripts/templates/notif_tmpl.coffee new file mode 100644 index 00000000..9f19fb7c --- /dev/null +++ b/assets/javascripts/templates/notif_tmpl.coffee @@ -0,0 +1,23 @@ +notif = (title, html) -> + html = html.replace /#{title}#{html}
""" + +textNotif = (title, message) -> + notif title, """

#{message}""" + +app.templates.notifUpdateReady = -> + textNotif """ DevDocs has been updated. """, + """ Reload the page to use the new version. """ + +app.templates.notifError = -> + textNotif """ Oops, an error occured. """, + """ Try reloading, and if the problem persists, + resetting the app.
+ I track these errors automatically but feel free to contact me. """ + +app.templates.notifInvalidLocation = -> + textNotif """ DevDocs must be loaded from #{app.config.production_host} """, + """ Otherwise things are likely to break. """ + +app.templates.notifNews = (news) -> + notif 'Changelog', app.templates.newsList(news) diff --git a/assets/javascripts/templates/pages/about_tmpl.coffee b/assets/javascripts/templates/pages/about_tmpl.coffee new file mode 100644 index 00000000..73a34fc2 --- /dev/null +++ b/assets/javascripts/templates/pages/about_tmpl.coffee @@ -0,0 +1,145 @@ +app.templates.aboutPage = -> """ +

+

Table of Contents

+ +
+ +

API Documentation Browser

+

DevDocs combines multiple API documentations in a fast, organized, and searchable interface. +

+

To keep up-to-date with the latest development and community news: +

+

If you use and like DevDocs, please consider donating through + Gittip or + PayPal.
+ Your support helps sustain the project and is highly appreciated. + +

Credits

+ + +
Documentation + Copyright + License + #{("
#{c[0]}© #{c[1]}#{c[2]}" for c in credits).join('')} +
+ +

Special Thanks

+ + +

Questions & Answsers

+
+
Does it work offline? +
Yes! DevDocs is open source. You can run the code locally on your computer.
+ An offline version that requires no setup is planned for the future. +
Where can I suggest new docs and features? +
You can suggest and vote for new docs on the Trello board.
+ If you have a specific feature request, add it to the issue tracker.
+ Otherwise use the mailing list. +
Where can I report bugs? +
In the issue tracker. Thanks! +
+

For anything else, feel free to email me at thibaut@devdocs.io. + +

+

+ Copyright 2013 Thibaut Courouble and other contributors
+ This software is licensed under the terms of the Mozilla Public License v2.0.
+ You may obtain a copy of the source code at github.com/Thibaut/devdocs.
+ For more information, see the COPYRIGHT + and LICENSE files. +""" + +credits = [ + [ 'Angular.js', + '2010-2013 Google, Inc.', + 'CC BY', + 'http://creativecommons.org/licenses/by/3.0/' + ], [ + 'Backbone.js', + '2010-2013 Jeremy Ashkenas, DocumentCloud', + 'MIT', + 'https://raw.github.com/jashkenas/backbone/master/LICENSE' + ], [ + 'CoffeeScript', + '2009-2013 Jeremy Ashkenas', + 'MIT', + 'https://raw.github.com/jashkenas/coffee-script/master/LICENSE' + ], [ + 'CSS
DOM
HTML
JavaScript', + '2005-2013 Mozilla Developer Network and individual contributors', + 'CC BY-SA', + 'http://creativecommons.org/licenses/by-sa/2.5/' + ], [ + 'Ember.js', + '2013 Yehuda Katz, Tom Dale and Ember.js contributors', + 'MIT', + 'https://raw.github.com/emberjs/ember.js/master/LICENSE' + ], [ + 'HTTP', + '1999 The Internet Society', + 'Custom', + 'http://www.w3.org/Protocols/rfc2616/rfc2616-sec21.html#sec21' + ], [ + 'jQuery', + '2009 Packt Publishing
© 2013 jQuery Foundation', + 'MIT', + 'https://raw.github.com/jquery/api.jquery.com/master/LICENSE-MIT.txt' + ], [ + 'jQuery Mobile', + '2013 jQuery Foundation', + 'MIT', + 'https://raw.github.com/jquery/api.jquerymobile.com/master/LICENSE-MIT.txt' + ], [ + 'jQuery UI', + '2013 jQuery Foundation', + 'MIT', + 'https://raw.github.com/jquery/api.jqueryui.com/master/LICENSE-MIT.txt' + ], [ + 'Less', + '2009-2013 Alexis Sellier & The Core Less Team', + 'Apache v2', + 'https://raw.github.com/less/less.js/master/LICENSE' + ], [ + 'Lo-Dash', + '2009-2013 The Dojo Foundation', + 'MIT', + 'https://raw.github.com/lodash/lodash/master/LICENSE.txt' + ], [ + 'Node.js', + 'Joyent, Inc. and other Node contributors', + 'MIT', + 'https://raw.github.com/joyent/node/master/LICENSE' + ], [ + 'PHP', + '1997-2013 The PHP Documentation Group', + 'CC BY', + 'http://creativecommons.org/licenses/by/3.0/' + ], [ + 'Sass', + '2006-2013 Hampton Catlin, Nathan Weizenbaum, and Chris Eppstein', + 'MIT', + 'https://raw.github.com/nex3/sass/master/MIT-LICENSE' + ], [ + 'Underscore.js', + '2009-2013 Jeremy Ashkenas, DocumentCloud and Investigative Reporters & Editors', + 'MIT', + 'https://raw.github.com/jashkenas/underscore/master/LICENSE' + ] +] diff --git a/assets/javascripts/templates/pages/help_tmpl.coffee b/assets/javascripts/templates/pages/help_tmpl.coffee new file mode 100644 index 00000000..de85375f --- /dev/null +++ b/assets/javascripts/templates/pages/help_tmpl.coffee @@ -0,0 +1,89 @@ +ctrlKey = if navigator.userAgent.indexOf 'Mac OS X' then 'cmd' else 'ctrl' + +app.templates.helpPage = """ +

+

Table of Contents

+ +
+ + +

+ The search is case-insensitive, ignores spaces, and supports fuzzy matching (for queries longer than two characters). + For example, searching bgcp brings up background-clip. +

+
+ You can scope the search to a specific documentation by typing its name (or an abbreviation), + and pressing Tab (Space on mobile devices). + For example, to search the JavaScript documentation, enter javascript + or js, then Tab.
+ To clear the current scope, empty the search field and hit Backspace. +
+ The search field can be prefilled from the URL by visiting devdocs.io/#q=keyword. + Characters after #q= will be used as search string.
+ To search a specific documentation, add its name and a space before the keyword: + devdocs.io/#q=js date. +
+ DevDocs supports OpenSearch, meaning it can easily be installed as a search engine on most web browsers. +
    +
  • On Chrome, the setup is done automatically. Simply press Tab when devdocs.io is autocompleted + in the omnibox (to set a custom keyword, click Manage search engines… in Chrome's settings). +
  • On Firefox, open the search engine list (icon in the search bar) and select Add "DevDocs Search". + DevDocs is now available in the search bar. You can also search from the location bar by following + these instructions. +
+ +

Keyboard Shortcuts

+

Selection

+
+
+ + +
Move selection +
+ + +
Show/hide sub-list +
+ enter +
Open selection +
+ #{ctrlKey} + enter +
Open selection in a new tab +
+

Navigation

+
+
+ #{ctrlKey} + ← + #{ctrlKey} + → +
Go back/forward +
+ alt + ↓ + alt + ↑ +
Scroll step by step +
+ space + shift + space +
Scroll screen by screen +
+ #{ctrlKey} + ↑ + #{ctrlKey} + ↓ +
Scroll to the top/bottom +
+

Misc

+
+
+ escape +
Reset +
+ ? +
Show this page +
+

+ Tip: If the cursor is no longer in the search field, just press backspace or + continue to type and it will refocus the search field and start showing new results. """ diff --git a/assets/javascripts/templates/pages/news_tmpl.coffee b/assets/javascripts/templates/pages/news_tmpl.coffee new file mode 100644 index 00000000..7a0f63c2 --- /dev/null +++ b/assets/javascripts/templates/pages/news_tmpl.coffee @@ -0,0 +1,114 @@ +app.templates.newsPage = -> + """

Changelog

+

For the latest news and updates, + subscribe to the newsletter + or follow @DevDocs. +

#{app.templates.newsList app.news}
""" + +app.templates.newsList = (news) -> + result = '' + result += newsItem new Date(value[0]), value[1..] for value in news + result + +MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'] + +newsItem = (date, news) -> + date = """#{MONTHS[date.getUTCMonth()]} #{date.getUTCDate()}""" + result = '' + + for text, i in news + text = text.split "\n" + title = """#{text.shift()}""" + result += """
#{if i is 0 then date else ''} #{title} #{text.join '
'}
""" + + result + +app.news = [ + [ 1382572800000, # October 24, 2013 + """ DevDocs is now open source. """ + ], [ + 1381276800000, # October 9, 2013 + """ DevDocs is now available as a Chrome web app. """ + ], [ 1379808000000, # September 22, 2013 + """ New PHP documentation """ + ], [ + 1378425600000, # September 6, 2013 + """ New Lo-Dash documentation """, + """ On mobile devices you can now search a specific documentation by typing its name and Space. """ + ], [ + 1377993600000, # September 1, 2013 + """ New jQuery UI and jQuery Mobile documentations """ + ], [ + 1377648000000, # August 28, 2013 + """ New smartphone interface + Tested on iOS 6+ and Android 4.1+ """ + ], [ + 1377388800000, # August 25, 2013 + """ New Ember.js documentation """ + ], [ + 1376784000000, # August 18, 2013 + """ New CoffeeScript documentation """, + """ URL search now automatically opens the first result. """ + ], [ + 1376352000000, # August 13, 2013 + """ New Angular.js documentation """ + ], [ + 1376179200000, # August 11, 2013 + """ New Sass and Less documentations """ + ], [ + 1375660800000, # August 5, 2013 + """ New Node.js documentation """ + ], [ + 1375488000000, # August 3, 2013 + """ Added support for OpenSearch """ + ], [ + 1375142400000, # July 30, 2013 + """ New Backbone.js documentation """ + ], [ + 1374883200000, # July 27, 2013 + """ You can now customize the list of documentations. + New docs will be hidden by default, but you'll see a notification when there are new releases. """, + """ New HTTP documentation """ + ], [ + 1373846400000, # July 15, 2013 + """ URL search now works with single documentations: devdocs.io/#q=js sort """ + ], [ + 1373673600000, # July 13, 2013 + """ Added syntax highlighting """, + """ Added documentation versions """ + ], [ + 1373500800000, # July 11, 2013 + """ New Underscore.js documentation """, + """ Improved compatibility with tablets + A mobile version is planned as soon as other high priority features have been implemented. """ + ], [ + 1373414400000, # July 10, 2013 + """ You can now search specific documentations. + Simply type the documentation's name and press Tab. + The name is fuzzy matched so you can use abbreviations like js for JavaScript. """ + ], [ + 1373241600000, # July 8, 2013 + """ Improved search with fuzzy matching and better results + For example, searching jqmka now returns jQuery.makeArray(). """, + """ DevDocs finally has an icon. """, + """ space has replaced alt + space for scrolling down. """ + ], [ + 1373068800000, # July 6, 2013 + """ New DOM and DOM Events documentations + DevDocs now includes almost all reference documents available on the Mozilla Developer Network. + Big thank you to Mozilla and all the people that contributed to MDN. """, + """ Implemented URL search: devdocs.io/#q=sort """ + ], [ + 1372723200000, # July 2, 2013 + """ New JavaScript documentation """ + ], [ + 1372377600000, # June 28, 2013 + """ DevDocs made the front page of Hacker News! + Hi everyone — thanks for trying DevDocs. + Please bear with me while I fix bugs and scramble to add more docs. + This is only v1. There's a lot more to come. """ + ], [ + 1371513600000, # June 18, 2013 + """ Initial release """ + ] +] diff --git a/assets/javascripts/templates/pages/root_tmpl.coffee.erb b/assets/javascripts/templates/pages/root_tmpl.coffee.erb new file mode 100644 index 00000000..04294cd8 --- /dev/null +++ b/assets/javascripts/templates/pages/root_tmpl.coffee.erb @@ -0,0 +1,77 @@ +app.templates.splash = """ +
DevDocs
+ Sponsored by MaxCDN +""" + +<% 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 will not self-update. Unless you're offline or modifying the code, + I recommend using the hosted version at devdocs.io. +
  2. Run thor docs:list to see all available documentations. +
  3. Run thor docs:download --all to download/update all documentations. +
  4. To be notified about new versions, don't forget to subscribe to the newsletter. +
  5. The issue tracker is the preferred channel for bug reports and + feature requests. For everything else, use the mailing list. +
  6. Contributions are welcome. See the guidelines. +
  7. DevDocs is licensed under the terms of the Mozilla Public License v2.0. For more information, + see the COPYRIGHT and + LICENSE files. +
+ Sponsored by +

That's all. Happy coding! +

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

Welcome!

+

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

    +
  1. To pick your docs, click Select documentation in the bottom left corner +
  2. You don't have to use your mouse — see the list of keyboard shortcuts +
  3. The search supports fuzzy matching (e.g. "bgcp" brings up "background-clip") +
  4. To search a specific documentation, type its name (or an abbreviation), then Tab +
  5. You can search using your browser's address bar — learn how +
  6. DevDocs works on mobile and is available as a Chrome web app +
  7. For the latest news, subscribe to the newsletter or follow @DevDocs +
  8. DevDocs is free and open source + +
+ Sponsored by +

That's all. Happy coding! +

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

Welcome!

+

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

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

That's all. Happy coding! +

Sponsored by

+ Stop showing this message +
+""" diff --git a/assets/javascripts/templates/pages/type_tmpl.coffee b/assets/javascripts/templates/pages/type_tmpl.coffee new file mode 100644 index 00000000..426bda88 --- /dev/null +++ b/assets/javascripts/templates/pages/type_tmpl.coffee @@ -0,0 +1,6 @@ +app.templates.typePage = (type) -> + """

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

+ """ + +app.templates.typePageEntry = (entry) -> + """
  • #{$.escape entry.name}
  • """ diff --git a/assets/javascripts/templates/sidebar_tmpl.coffee b/assets/javascripts/templates/sidebar_tmpl.coffee new file mode 100644 index 00000000..543c9f90 --- /dev/null +++ b/assets/javascripts/templates/sidebar_tmpl.coffee @@ -0,0 +1,40 @@ +templates = app.templates + +templates.sidebarDoc = (doc, options = {}) -> + link = """""" + link += """""" unless options.disabled + link += """#{doc.version}""" if doc.version + link + "#{doc.name}" + +templates.sidebarType = (type) -> + """#{type.count}#{type.name}""" + +templates.sidebarEntry = (entry) -> + name = $.escape(entry.name) + """#{name}""" + +templates.sidebarResult = (entry) -> + name = $.escape(entry.name) + """#{name}""" + +templates.sidebarPageLink = (count) -> + """Show more… (#{count})""" + +templates.sidebarLabel = (doc, options = {}) -> + label = """" + +templates.sidebarVote = 'Vote for new documentation' + +sidebarFooter = (html) -> """""" + +templates.sidebarSettings = -> + sidebarFooter """Select documentation""" + +templates.sidebarSave = -> + sidebarFooter """Save""" diff --git a/assets/javascripts/vendor/cookies.js b/assets/javascripts/vendor/cookies.js new file mode 100644 index 00000000..975239c2 --- /dev/null +++ b/assets/javascripts/vendor/cookies.js @@ -0,0 +1,141 @@ +/*! + * Cookies.js - 0.3.1 + * Wednesday, April 24 2013 @ 2:28 AM EST + * + * Copyright (c) 2013, Scott Hamper + * Licensed under the MIT license, + * http://www.opensource.org/licenses/MIT + */ +(function (undefined) { + 'use strict'; + + 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 = document; + Cookies._navigator = navigator; + + Cookies.defaults = { + path: '/' + }; + + Cookies.get = function (key) { + if (Cookies._cachedDocumentCookie !== Cookies._document.cookie) { + Cookies._renewCache(); + } + + return Cookies._cache[key]; + }; + + 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, + 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(); + switch (typeof expires) { + case 'number': expires = new Date(now.getTime() + expires * 1000); break; + case 'string': expires = new Date(expires); break; + } + + if (expires && !Cookies._isValidDate(expires)) { + throw new Error('`expires` parameter cannot be converted to a valid Date instance'); + } + + return expires; + }; + + Cookies._generateCookieString = function (key, value, options) { + key = encodeURIComponent(key); + value = (value + '').replace(/[^!#$&-+\--:<-\[\]-~]/g, encodeURIComponent); + options = options || {}; + + var cookieString = key + '=' + value; + cookieString += options.path ? ';path=' + options.path : ''; + cookieString += options.domain ? ';domain=' + options.domain : ''; + cookieString += options.expires ? ';expires=' + options.expires.toGMTString() : ''; + cookieString += options.secure ? ';secure' : ''; + + return cookieString; + }; + + Cookies._getCookieObjectFromString = function (documentCookie) { + var cookieObject = {}; + var cookiesArray = documentCookie ? documentCookie.split('; ') : []; + + for (var i = 0; i < cookiesArray.length; i++) { + var cookieKvp = Cookies._getKeyValuePairFromCookieString(cookiesArray[i]); + + if (cookieObject[cookieKvp.key] === undefined) { + cookieObject[cookieKvp.key] = cookieKvp.value; + } + } + + return cookieObject; + }; + + 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; + + return { + key: decodeURIComponent(cookieString.substr(0, separatorIndex)), + value: decodeURIComponent(cookieString.substr(separatorIndex + 1)) + }; + }; + + Cookies._renewCache = function () { + Cookies._cache = Cookies._getCookieObjectFromString(Cookies._document.cookie); + Cookies._cachedDocumentCookie = Cookies._document.cookie; + }; + + Cookies._areEnabled = function () { + return Cookies._navigator.cookieEnabled || + Cookies.set('cookies.js', 1).get('cookies.js') === '1'; + }; + + Cookies.enabled = Cookies._areEnabled(); + + // AMD support + if (typeof define === 'function' && define.amd) { + define(function () { return Cookies; }); + // CommonJS and Node.js module support. + } else if (typeof exports !== 'undefined') { + // Support Node.js specific `module.exports` (which can be a function) + if (typeof module !== 'undefined' && module.exports) { + exports = module.exports = Cookies; + } + // But always support CommonJS module 1.1.1 spec (`exports` cannot be a function) + exports.Cookies = Cookies; + } else { + window.Cookies = Cookies; + } +})(); \ No newline at end of file diff --git a/assets/javascripts/vendor/fastclick.js b/assets/javascripts/vendor/fastclick.js new file mode 100644 index 00000000..b82882e0 --- /dev/null +++ b/assets/javascripts/vendor/fastclick.js @@ -0,0 +1,740 @@ +/** + * @preserve FastClick: polyfill to remove click delays on browsers with touch UIs. + * + * @version 0.6.9 + * @codingstandard ftlabs-jsv2 + * @copyright The Financial Times Limited [All Rights Reserved] + * @license MIT License (see LICENSE.txt) + */ + +/*jslint browser:true, node:true*/ +/*global define, Event, Node*/ + + +/** + * Instantiate fast-clicking listeners on the specificed layer. + * + * @constructor + * @param {Element} layer The layer to listen on + */ +function FastClick(layer) { + 'use strict'; + var oldOnClick, self = this; + + + /** + * Whether a click is currently being tracked. + * + * @type boolean + */ + this.trackingClick = false; + + + /** + * Timestamp for when when click tracking started. + * + * @type number + */ + this.trackingClickStart = 0; + + + /** + * The element being tracked for a click. + * + * @type EventTarget + */ + this.targetElement = null; + + + /** + * X-coordinate of touch start event. + * + * @type number + */ + this.touchStartX = 0; + + + /** + * Y-coordinate of touch start event. + * + * @type number + */ + this.touchStartY = 0; + + + /** + * ID of the last touch, retrieved from Touch.identifier. + * + * @type number + */ + this.lastTouchIdentifier = 0; + + + /** + * Touchmove boundary, beyond which a click will be cancelled. + * + * @type number + */ + this.touchBoundary = 10; + + + /** + * The FastClick layer. + * + * @type Element + */ + this.layer = layer; + + if (!layer || !layer.nodeType) { + throw new TypeError('Layer must be a document node'); + } + + /** @type function() */ + this.onClick = function() { return FastClick.prototype.onClick.apply(self, arguments); }; + + /** @type function() */ + this.onMouse = function() { return FastClick.prototype.onMouse.apply(self, arguments); }; + + /** @type function() */ + this.onTouchStart = function() { return FastClick.prototype.onTouchStart.apply(self, arguments); }; + + /** @type function() */ + this.onTouchEnd = function() { return FastClick.prototype.onTouchEnd.apply(self, arguments); }; + + /** @type function() */ + this.onTouchCancel = function() { return FastClick.prototype.onTouchCancel.apply(self, arguments); }; + + if (FastClick.notNeeded(layer)) { + return; + } + + // Set up event handlers as required + if (this.deviceIsAndroid) { + layer.addEventListener('mouseover', this.onMouse, true); + layer.addEventListener('mousedown', this.onMouse, true); + layer.addEventListener('mouseup', this.onMouse, true); + } + + layer.addEventListener('click', this.onClick, true); + layer.addEventListener('touchstart', this.onTouchStart, false); + layer.addEventListener('touchend', this.onTouchEnd, false); + layer.addEventListener('touchcancel', this.onTouchCancel, false); + + // Hack is required for browsers that don't support Event#stopImmediatePropagation (e.g. Android 2) + // which is how FastClick normally stops click events bubbling to callbacks registered on the FastClick + // layer when they are cancelled. + if (!Event.prototype.stopImmediatePropagation) { + layer.removeEventListener = function(type, callback, capture) { + var rmv = Node.prototype.removeEventListener; + if (type === 'click') { + rmv.call(layer, type, callback.hijacked || callback, capture); + } else { + rmv.call(layer, type, callback, capture); + } + }; + + layer.addEventListener = function(type, callback, capture) { + var adv = Node.prototype.addEventListener; + if (type === 'click') { + adv.call(layer, type, callback.hijacked || (callback.hijacked = function(event) { + if (!event.propagationStopped) { + callback(event); + } + }), capture); + } else { + adv.call(layer, type, callback, capture); + } + }; + } + + // If a handler is already declared in the element's onclick attribute, it will be fired before + // FastClick's onClick handler. Fix this by pulling out the user-defined handler function and + // adding it as listener. + if (typeof layer.onclick === 'function') { + + // Android browser on at least 3.2 requires a new reference to the function in layer.onclick + // - the old one won't work if passed to addEventListener directly. + oldOnClick = layer.onclick; + layer.addEventListener('click', function(event) { + oldOnClick(event); + }, false); + layer.onclick = null; + } +} + + +/** + * Android requires exceptions. + * + * @type boolean + */ +FastClick.prototype.deviceIsAndroid = navigator.userAgent.indexOf('Android') > 0; + + +/** + * iOS requires exceptions. + * + * @type boolean + */ +FastClick.prototype.deviceIsIOS = /iP(ad|hone|od)/.test(navigator.userAgent); + + +/** + * iOS 4 requires an exception for select elements. + * + * @type boolean + */ +FastClick.prototype.deviceIsIOS4 = FastClick.prototype.deviceIsIOS && (/OS 4_\d(_\d)?/).test(navigator.userAgent); + + +/** + * iOS 6.0(+?) requires the target element to be manually derived + * + * @type boolean + */ +FastClick.prototype.deviceIsIOSWithBadTarget = FastClick.prototype.deviceIsIOS && (/OS ([6-9]|\d{2})_\d/).test(navigator.userAgent); + + +/** + * Determine whether a given element requires a native click. + * + * @param {EventTarget|Element} target Target DOM element + * @returns {boolean} Returns true if the element needs a native click + */ +FastClick.prototype.needsClick = function(target) { + 'use strict'; + switch (target.nodeName.toLowerCase()) { + + // Don't send a synthetic click to disabled inputs (issue #62) + case 'button': + case 'select': + case 'textarea': + if (target.disabled) { + return true; + } + + break; + case 'input': + + // File inputs need real clicks on iOS 6 due to a browser bug (issue #68) + if ((this.deviceIsIOS && target.type === 'file') || target.disabled) { + return true; + } + + break; + case 'label': + case 'video': + return true; + } + + return (/\bneedsclick\b/).test(target.className); +}; + + +/** + * Determine whether a given element requires a call to focus to simulate click into element. + * + * @param {EventTarget|Element} target Target DOM element + * @returns {boolean} Returns true if the element requires a call to focus to simulate native click. + */ +FastClick.prototype.needsFocus = function(target) { + 'use strict'; + switch (target.nodeName.toLowerCase()) { + case 'textarea': + case 'select': + return true; + case 'input': + switch (target.type) { + case 'button': + case 'checkbox': + case 'file': + case 'image': + case 'radio': + case 'submit': + return false; + } + + // No point in attempting to focus disabled inputs + return !target.disabled && !target.readOnly; + default: + return (/\bneedsfocus\b/).test(target.className); + } +}; + + +/** + * Send a click event to the specified element. + * + * @param {EventTarget|Element} targetElement + * @param {Event} event + */ +FastClick.prototype.sendClick = function(targetElement, event) { + 'use strict'; + var clickEvent, touch; + + // On some Android devices activeElement needs to be blurred otherwise the synthetic click will have no effect (#24) + if (document.activeElement && document.activeElement !== targetElement) { + document.activeElement.blur(); + } + + touch = event.changedTouches[0]; + + // Synthesise a click event, with an extra attribute so it can be tracked + clickEvent = document.createEvent('MouseEvents'); + clickEvent.initMouseEvent('click', true, true, window, 1, touch.screenX, touch.screenY, touch.clientX, touch.clientY, false, false, false, false, 0, null); + clickEvent.forwardedTouchEvent = true; + targetElement.dispatchEvent(clickEvent); +}; + + +/** + * @param {EventTarget|Element} targetElement + */ +FastClick.prototype.focus = function(targetElement) { + 'use strict'; + var length; + + if (this.deviceIsIOS && targetElement.setSelectionRange) { + length = targetElement.value.length; + targetElement.setSelectionRange(length, length); + } else { + targetElement.focus(); + } +}; + + +/** + * Check whether the given target element is a child of a scrollable layer and if so, set a flag on it. + * + * @param {EventTarget|Element} targetElement + */ +FastClick.prototype.updateScrollParent = function(targetElement) { + 'use strict'; + var scrollParent, parentElement; + + scrollParent = targetElement.fastClickScrollParent; + + // Attempt to discover whether the target element is contained within a scrollable layer. Re-check if the + // target element was moved to another parent. + if (!scrollParent || !scrollParent.contains(targetElement)) { + parentElement = targetElement; + do { + if (parentElement.scrollHeight > parentElement.offsetHeight) { + scrollParent = parentElement; + targetElement.fastClickScrollParent = parentElement; + break; + } + + parentElement = parentElement.parentElement; + } while (parentElement); + } + + // Always update the scroll top tracker if possible. + if (scrollParent) { + scrollParent.fastClickLastScrollTop = scrollParent.scrollTop; + } +}; + + +/** + * @param {EventTarget} targetElement + * @returns {Element|EventTarget} + */ +FastClick.prototype.getTargetElementFromEventTarget = function(eventTarget) { + 'use strict'; + + // On some older browsers (notably Safari on iOS 4.1 - see issue #56) the event target may be a text node. + if (eventTarget.nodeType === Node.TEXT_NODE) { + return eventTarget.parentNode; + } + + return eventTarget; +}; + + +/** + * On touch start, record the position and scroll offset. + * + * @param {Event} event + * @returns {boolean} + */ +FastClick.prototype.onTouchStart = function(event) { + 'use strict'; + var targetElement, touch, selection; + + // Ignore multiple touches, otherwise pinch-to-zoom is prevented if both fingers are on the FastClick element (issue #111). + if (event.targetTouches.length > 1) { + return true; + } + + targetElement = this.getTargetElementFromEventTarget(event.target); + touch = event.targetTouches[0]; + + if (this.deviceIsIOS) { + + // Only trusted events will deselect text on iOS (issue #49) + selection = window.getSelection(); + if (selection.rangeCount && !selection.isCollapsed) { + return true; + } + + if (!this.deviceIsIOS4) { + + // Weird things happen on iOS when an alert or confirm dialog is opened from a click event callback (issue #23): + // when the user next taps anywhere else on the page, new touchstart and touchend events are dispatched + // with the same identifier as the touch event that previously triggered the click that triggered the alert. + // Sadly, there is an issue on iOS 4 that causes some normal touch events to have the same identifier as an + // immediately preceeding touch event (issue #52), so this fix is unavailable on that platform. + if (touch.identifier === this.lastTouchIdentifier) { + event.preventDefault(); + return false; + } + + this.lastTouchIdentifier = touch.identifier; + + // If the target element is a child of a scrollable layer (using -webkit-overflow-scrolling: touch) and: + // 1) the user does a fling scroll on the scrollable layer + // 2) the user stops the fling scroll with another tap + // then the event.target of the last 'touchend' event will be the element that was under the user's finger + // when the fling scroll was started, causing FastClick to send a click event to that layer - unless a check + // is made to ensure that a parent layer was not scrolled before sending a synthetic click (issue #42). + this.updateScrollParent(targetElement); + } + } + + this.trackingClick = true; + this.trackingClickStart = event.timeStamp; + this.targetElement = targetElement; + + this.touchStartX = touch.pageX; + this.touchStartY = touch.pageY; + + // Prevent phantom clicks on fast double-tap (issue #36) + if ((event.timeStamp - this.lastClickTime) < 200) { + event.preventDefault(); + } + + return true; +}; + + +/** + * Based on a touchmove event object, check whether the touch has moved past a boundary since it started. + * + * @param {Event} event + * @returns {boolean} + */ +FastClick.prototype.touchHasMoved = function(event) { + 'use strict'; + var touch = event.changedTouches[0], boundary = this.touchBoundary; + + if (Math.abs(touch.pageX - this.touchStartX) > boundary || Math.abs(touch.pageY - this.touchStartY) > boundary) { + return true; + } + + return false; +}; + + +/** + * Attempt to find the labelled control for the given label element. + * + * @param {EventTarget|HTMLLabelElement} labelElement + * @returns {Element|null} + */ +FastClick.prototype.findControl = function(labelElement) { + 'use strict'; + + // Fast path for newer browsers supporting the HTML5 control attribute + if (labelElement.control !== undefined) { + return labelElement.control; + } + + // All browsers under test that support touch events also support the HTML5 htmlFor attribute + if (labelElement.htmlFor) { + return document.getElementById(labelElement.htmlFor); + } + + // If no for attribute exists, attempt to retrieve the first labellable descendant element + // the list of which is defined here: http://www.w3.org/TR/html5/forms.html#category-label + return labelElement.querySelector('button, input:not([type=hidden]), keygen, meter, output, progress, select, textarea'); +}; + + +/** + * On touch end, determine whether to send a click event at once. + * + * @param {Event} event + * @returns {boolean} + */ +FastClick.prototype.onTouchEnd = function(event) { + 'use strict'; + var forElement, trackingClickStart, targetTagName, scrollParent, touch, targetElement = this.targetElement; + + // If the touch has moved, cancel the click tracking + if (this.touchHasMoved(event)) { + this.trackingClick = false; + this.targetElement = null; + } + + if (!this.trackingClick) { + return true; + } + + // Prevent phantom clicks on fast double-tap (issue #36) + if ((event.timeStamp - this.lastClickTime) < 200) { + this.cancelNextClick = true; + return true; + } + + this.lastClickTime = event.timeStamp; + + trackingClickStart = this.trackingClickStart; + this.trackingClick = false; + this.trackingClickStart = 0; + + // On some iOS devices, the targetElement supplied with the event is invalid if the layer + // is performing a transition or scroll, and has to be re-detected manually. Note that + // for this to function correctly, it must be called *after* the event target is checked! + // See issue #57; also filed as rdar://13048589 . + if (this.deviceIsIOSWithBadTarget) { + touch = event.changedTouches[0]; + + // In certain cases arguments of elementFromPoint can be negative, so prevent setting targetElement to null + targetElement = document.elementFromPoint(touch.pageX - window.pageXOffset, touch.pageY - window.pageYOffset) || targetElement; + targetElement.fastClickScrollParent = this.targetElement.fastClickScrollParent; + } + + targetTagName = targetElement.tagName.toLowerCase(); + if (targetTagName === 'label') { + forElement = this.findControl(targetElement); + if (forElement) { + this.focus(targetElement); + if (this.deviceIsAndroid) { + return false; + } + + targetElement = forElement; + } + } else if (this.needsFocus(targetElement)) { + + // Case 1: If the touch started a while ago (best guess is 100ms based on tests for issue #36) then focus will be triggered anyway. Return early and unset the target element reference so that the subsequent click will be allowed through. + // Case 2: Without this exception for input elements tapped when the document is contained in an iframe, then any inputted text won't be visible even though the value attribute is updated as the user types (issue #37). + if ((event.timeStamp - trackingClickStart) > 100 || (this.deviceIsIOS && window.top !== window && targetTagName === 'input')) { + this.targetElement = null; + return false; + } + + this.focus(targetElement); + + // Select elements need the event to go through on iOS 4, otherwise the selector menu won't open. + if (!this.deviceIsIOS4 || targetTagName !== 'select') { + this.targetElement = null; + event.preventDefault(); + } + + return false; + } + + if (this.deviceIsIOS && !this.deviceIsIOS4) { + + // Don't send a synthetic click event if the target element is contained within a parent layer that was scrolled + // and this tap is being used to stop the scrolling (usually initiated by a fling - issue #42). + scrollParent = targetElement.fastClickScrollParent; + if (scrollParent && scrollParent.fastClickLastScrollTop !== scrollParent.scrollTop) { + return true; + } + } + + // Prevent the actual click from going though - unless the target node is marked as requiring + // real clicks or if it is in the whitelist in which case only non-programmatic clicks are permitted. + if (!this.needsClick(targetElement)) { + event.preventDefault(); + this.sendClick(targetElement, event); + } + + return false; +}; + + +/** + * On touch cancel, stop tracking the click. + * + * @returns {void} + */ +FastClick.prototype.onTouchCancel = function() { + 'use strict'; + this.trackingClick = false; + this.targetElement = null; +}; + + +/** + * Determine mouse events which should be permitted. + * + * @param {Event} event + * @returns {boolean} + */ +FastClick.prototype.onMouse = function(event) { + 'use strict'; + + // If a target element was never set (because a touch event was never fired) allow the event + if (!this.targetElement) { + return true; + } + + if (event.forwardedTouchEvent) { + return true; + } + + // Programmatically generated events targeting a specific element should be permitted + if (!event.cancelable) { + return true; + } + + // Derive and check the target element to see whether the mouse event needs to be permitted; + // unless explicitly enabled, prevent non-touch click events from triggering actions, + // to prevent ghost/doubleclicks. + if (!this.needsClick(this.targetElement) || this.cancelNextClick) { + + // Prevent any user-added listeners declared on FastClick element from being fired. + if (event.stopImmediatePropagation) { + event.stopImmediatePropagation(); + } else { + + // Part of the hack for browsers that don't support Event#stopImmediatePropagation (e.g. Android 2) + event.propagationStopped = true; + } + + // Cancel the event + event.stopPropagation(); + event.preventDefault(); + + return false; + } + + // If the mouse event is permitted, return true for the action to go through. + return true; +}; + + +/** + * On actual clicks, determine whether this is a touch-generated click, a click action occurring + * naturally after a delay after a touch (which needs to be cancelled to avoid duplication), or + * an actual click which should be permitted. + * + * @param {Event} event + * @returns {boolean} + */ +FastClick.prototype.onClick = function(event) { + 'use strict'; + var permitted; + + // It's possible for another FastClick-like library delivered with third-party code to fire a click event before FastClick does (issue #44). In that case, set the click-tracking flag back to false and return early. This will cause onTouchEnd to return early. + if (this.trackingClick) { + this.targetElement = null; + this.trackingClick = false; + return true; + } + + // Very odd behaviour on iOS (issue #18): if a submit element is present inside a form and the user hits enter in the iOS simulator or clicks the Go button on the pop-up OS keyboard the a kind of 'fake' click event will be triggered with the submit-type input element as the target. + if (event.target.type === 'submit' && event.detail === 0) { + return true; + } + + permitted = this.onMouse(event); + + // Only unset targetElement if the click is not permitted. This will ensure that the check for !targetElement in onMouse fails and the browser's click doesn't go through. + if (!permitted) { + this.targetElement = null; + } + + // If clicks are permitted, return true for the action to go through. + return permitted; +}; + + +/** + * Remove all FastClick's event listeners. + * + * @returns {void} + */ +FastClick.prototype.destroy = function() { + 'use strict'; + var layer = this.layer; + + if (this.deviceIsAndroid) { + layer.removeEventListener('mouseover', this.onMouse, true); + layer.removeEventListener('mousedown', this.onMouse, true); + layer.removeEventListener('mouseup', this.onMouse, true); + } + + layer.removeEventListener('click', this.onClick, true); + layer.removeEventListener('touchstart', this.onTouchStart, false); + layer.removeEventListener('touchend', this.onTouchEnd, false); + layer.removeEventListener('touchcancel', this.onTouchCancel, false); +}; + + +/** + * Check whether FastClick is needed. + * + * @param {Element} layer The layer to listen on + */ +FastClick.notNeeded = function(layer) { + 'use strict'; + var metaViewport; + + // Devices that don't support touch don't need FastClick + if (typeof window.ontouchstart === 'undefined') { + return true; + } + + if ((/Chrome\/[0-9]+/).test(navigator.userAgent)) { + + // Chrome on Android with user-scalable="no" doesn't need FastClick (issue #89) + if (FastClick.prototype.deviceIsAndroid) { + metaViewport = document.querySelector('meta[name=viewport]'); + if (metaViewport && metaViewport.content.indexOf('user-scalable=no') !== -1) { + return true; + } + + // Chrome desktop doesn't need FastClick (issue #15) + } else { + return true; + } + } + + // IE10 with -ms-touch-action: none, which disables double-tap-to-zoom (issue #97) + if (layer.style.msTouchAction === 'none') { + return true; + } + + return false; +}; + + +/** + * Factory method for creating a FastClick object + * + * @param {Element} layer The layer to listen on + */ +FastClick.attach = function(layer) { + 'use strict'; + return new FastClick(layer); +}; + + +if (typeof define !== 'undefined' && define.amd) { + + // AMD. Register as an anonymous module. + define(function() { + 'use strict'; + return FastClick; + }); +} else if (typeof module !== 'undefined' && module.exports) { + module.exports = FastClick.attach; + module.exports.FastClick = FastClick; +} else { + window.FastClick = FastClick; +} diff --git a/assets/javascripts/vendor/prism.js b/assets/javascripts/vendor/prism.js new file mode 100644 index 00000000..0930b01e --- /dev/null +++ b/assets/javascripts/vendor/prism.js @@ -0,0 +1,510 @@ +/** + * Prism: Lightweight, robust, elegant syntax highlighting + * MIT license http://www.opensource.org/licenses/mit-license.php/ + * @author Lea Verou http://lea.verou.me + */ + +(function(){ + +// Private helper vars +var lang = /\blang(?:uage)?-(?!\*)(\w+)\b/i; + +var _ = self.Prism = { + util: { + type: function (o) { + return Object.prototype.toString.call(o).match(/\[object (\w+)\]/)[1]; + }, + + // Deep clone a language definition (e.g. to extend it) + clone: function (o) { + var type = _.util.type(o); + + switch (type) { + case 'Object': + var clone = {}; + + for (var key in o) { + if (o.hasOwnProperty(key)) { + clone[key] = _.util.clone(o[key]); + } + } + + return clone; + + case 'Array': + return o.slice(); + } + + return o; + } + }, + + languages: { + extend: function (id, redef) { + var lang = _.util.clone(_.languages[id]); + + for (var key in redef) { + lang[key] = redef[key]; + } + + return lang; + }, + + // Insert a token before another token in a language literal + insertBefore: function (inside, before, insert, root) { + root = root || _.languages; + var grammar = root[inside]; + var ret = {}; + + for (var token in grammar) { + + if (grammar.hasOwnProperty(token)) { + + if (token == before) { + + for (var newToken in insert) { + + if (insert.hasOwnProperty(newToken)) { + ret[newToken] = insert[newToken]; + } + } + } + + ret[token] = grammar[token]; + } + } + + return root[inside] = ret; + }, + + // Traverse a language definition with Depth First Search + DFS: function(o, callback) { + for (var i in o) { + callback.call(o, i, o[i]); + + if (_.util.type(o) === 'Object') { + _.languages.DFS(o[i], callback); + } + } + } + }, + + highlightAll: function(async, callback) { + var elements = document.querySelectorAll('code[class*="language-"], [class*="language-"] code, code[class*="lang-"], [class*="lang-"] code'); + + for (var i=0, element; element = elements[i++];) { + _.highlightElement(element, async === true, callback); + } + }, + + highlightElement: function(element, async, callback) { + // Find language + var language, grammar, parent = element; + + while (parent && !lang.test(parent.className)) { + parent = parent.parentNode; + } + + if (parent) { + language = (parent.className.match(lang) || [,''])[1]; + grammar = _.languages[language]; + } + + if (!grammar) { + return; + } + + // Set language on the element, if not present + element.className = element.className.replace(lang, '').replace(/\s+/g, ' ') + ' language-' + language; + + // Set language on the parent, for styling + parent = element.parentNode; + + if (/pre/i.test(parent.nodeName)) { + parent.className = parent.className.replace(lang, '').replace(/\s+/g, ' ') + ' language-' + language; + } + + var code = element.textContent; + + if(!code) { + return; + } + + code = code.replace(/&/g, '&').replace(/ text.length) { + // Something went terribly wrong, ABORT, ABORT! + break tokenloop; + } + + if (str instanceof Token) { + continue; + } + + pattern.lastIndex = 0; + + var match = pattern.exec(str); + + if (match) { + if(lookbehind) { + lookbehindLength = match[1].length; + } + + var from = match.index - 1 + lookbehindLength, + match = match[0].slice(lookbehindLength), + len = match.length, + to = from + len, + before = str.slice(0, from + 1), + after = str.slice(to + 1); + + var args = [i, 1]; + + if (before) { + args.push(before); + } + + var wrapped = new Token(token, inside? _.tokenize(match, inside) : match); + + args.push(wrapped); + + if (after) { + args.push(after); + } + + Array.prototype.splice.apply(strarr, args); + } + } + } + + return strarr; + }, + + hooks: { + all: {}, + + add: function (name, callback) { + var hooks = _.hooks.all; + + hooks[name] = hooks[name] || []; + + hooks[name].push(callback); + }, + + run: function (name, env) { + var callbacks = _.hooks.all[name]; + + if (!callbacks || !callbacks.length) { + return; + } + + for (var i=0, callback; callback = callbacks[i++];) { + callback(env); + } + } + } +}; + +var Token = _.Token = function(type, content) { + this.type = type; + this.content = content; +}; + +Token.stringify = function(o, language, parent) { + if (typeof o == 'string') { + return o; + } + + if (Object.prototype.toString.call(o) == '[object Array]') { + return o.map(function(element) { + return Token.stringify(element, language, o); + }).join(''); + } + + var env = { + type: o.type, + content: Token.stringify(o.content, language, parent), + tag: 'span', + classes: ['token', o.type], + attributes: {}, + language: language, + parent: parent + }; + + if (env.type == 'comment') { + env.attributes['spellcheck'] = 'true'; + } + + _.hooks.run('wrap', env); + + var attributes = ''; + + for (var name in env.attributes) { + attributes += name + '="' + (env.attributes[name] || '') + '"'; + } + + return '<' + env.tag + ' class="' + env.classes.join(' ') + '" ' + attributes + '>' + env.content + ''; + +}; + +// if (!self.document) { +// // In worker +// self.addEventListener('message', function(evt) { +// var message = JSON.parse(evt.data), +// lang = message.language, +// code = message.code; + +// self.postMessage(JSON.stringify(_.tokenize(code, _.languages[lang]))); +// self.close(); +// }, false); + +// return; +// } + +// Get current script and highlight +// var script = document.getElementsByTagName('script'); + +// script = script[script.length - 1]; + +// if (script) { +// _.filename = script.src; + +// if (document.addEventListener && !script.hasAttribute('data-manual')) { +// document.addEventListener('DOMContentLoaded', _.highlightAll); +// } +// } + +})(); +Prism.languages.markup = { + 'comment': /<!--[\w\W]*?-->/g, + 'prolog': /<\?.+?\?>/, + 'doctype': /<!DOCTYPE.+?>/, + 'cdata': /<!\[CDATA\[[\w\W]*?]]>/i, + 'tag': { + pattern: /<\/?[\w:-]+\s*(?:\s+[\w:-]+(?:=(?:("|')(\\?[\w\W])*?\1|\w+))?\s*)*\/?>/gi, + inside: { + 'tag': { + pattern: /^<\/?[\w:-]+/i, + inside: { + 'punctuation': /^<\/?/, + 'namespace': /^[\w-]+?:/ + } + }, + 'attr-value': { + pattern: /=(?:('|")[\w\W]*?(\1)|[^\s>]+)/gi, + inside: { + 'punctuation': /=|>|"/g + } + }, + 'punctuation': /\/?>/g, + 'attr-name': { + pattern: /[\w:-]+/g, + inside: { + 'namespace': /^[\w-]+?:/ + } + } + + } + }, + 'entity': /&#?[\da-z]{1,8};/gi +}; + +// Plugin to make entity title show the real entity, idea by Roman Komarov +Prism.hooks.add('wrap', function(env) { + + if (env.type === 'entity') { + env.attributes['title'] = env.content.replace(/&/, '&'); + } +}); +Prism.languages.css = { + 'comment': /\/\*[\w\W]*?\*\//g, + 'atrule': { + pattern: /@[\w-]+?.*?(;|(?=\s*{))/gi, + inside: { + 'punctuation': /[;:]/g + } + }, + 'url': /url\((["']?).*?\1\)/gi, + 'selector': /[^\{\}\s][^\{\};]*(?=\s*\{)/g, + 'property': /(\b|\B)[\w-]+(?=\s*:)/ig, + 'string': /("|')(\\?.)*?\1/g, + 'important': /\B!important\b/gi, + 'ignore': /&(lt|gt|amp);/gi, + 'punctuation': /[\{\};:]/g +}; + +if (Prism.languages.markup) { + Prism.languages.insertBefore('markup', 'tag', { + 'style': { + pattern: /(<|<)style[\w\W]*?(>|>)[\w\W]*?(<|<)\/style(>|>)/ig, + inside: { + 'tag': { + pattern: /(<|<)style[\w\W]*?(>|>)|(<|<)\/style(>|>)/ig, + inside: Prism.languages.markup.tag.inside + }, + rest: Prism.languages.css + } + } + }); +}; +Prism.languages.css.selector = { + pattern: /[^\{\}\s][^\{\}]*(?=\s*\{)/g, + inside: { + 'pseudo-element': /:(?:after|before|first-letter|first-line|selection)|::[-\w]+/g, + 'pseudo-class': /:[-\w]+(?:\(.*\))?/g, + 'class': /\.[-:\.\w]+/g, + 'id': /#[-:\.\w]+/g + } +}; + +Prism.languages.insertBefore('css', 'ignore', { + 'hexcode': /#[\da-f]{3,6}/gi, + 'entity': /\\[\da-f]{1,8}/gi, + 'number': /[\d%\.]+/g, + 'function': /(attr|calc|cross-fade|cycle|element|hsla?|image|lang|linear-gradient|matrix3d|matrix|perspective|radial-gradient|repeating-linear-gradient|repeating-radial-gradient|rgba?|rotatex|rotatey|rotatez|rotate3d|rotate|scalex|scaley|scalez|scale3d|scale|skewx|skewy|skew|steps|translatex|translatey|translatez|translate3d|translate|url|var)/ig +}); +Prism.languages.clike = { + 'comment': { + pattern: /(^|[^\\])(\/\*[\w\W]*?\*\/|(^|[^:])\/\/.*?(\r?\n|$))/g, + lookbehind: true + }, + 'string': /("|')(\\?.)*?\1/g, + 'class-name': { + pattern: /((?:(?:class|interface|extends|implements|trait|instanceof|new)\s+)|(?:catch\s+\())[a-z0-9_\.\\]+/ig, + lookbehind: true, + inside: { + punctuation: /(\.|\\)/ + } + }, + 'keyword': /\b(if|else|while|do|for|return|in|instanceof|function|new|try|throw|catch|finally|null|break|continue)\b/g, + 'boolean': /\b(true|false)\b/g, + 'function': { + pattern: /[a-z0-9_]+\(/ig, + inside: { + punctuation: /\(/ + } + }, + 'number': /\b-?(0x[\dA-Fa-f]+|\d*\.?\d+([Ee]-?\d+)?)\b/g, + 'operator': /[-+]{1,2}|!|<=?|>=?|={1,3}|(&){1,2}|\|?\||\?|\*|\/|\~|\^|\%/g, + 'ignore': /&(lt|gt|amp);/gi, + 'punctuation': /[{}[\];(),.:]/g +}; + +Prism.languages.javascript = Prism.languages.extend('clike', { + 'keyword': /\b(var|let|if|else|while|do|for|return|in|instanceof|function|new|with|typeof|try|throw|catch|finally|null|break|continue)\b/g, + 'number': /\b-?(0x[\dA-Fa-f]+|\d*\.?\d+([Ee]-?\d+)?|NaN|-?Infinity)\b/g +}); + +Prism.languages.insertBefore('javascript', 'keyword', { + 'regex': { + pattern: /(^|[^/])\/(?!\/)(\[.+?]|\\.|[^/\r\n])+\/[gim]{0,3}(?=\s*($|[\r\n,.;})]))/g, + lookbehind: true + } +}); + +if (Prism.languages.markup) { + Prism.languages.insertBefore('markup', 'tag', { + 'script': { + pattern: /(<|<)script[\w\W]*?(>|>)[\w\W]*?(<|<)\/script(>|>)/ig, + inside: { + 'tag': { + pattern: /(<|<)script[\w\W]*?(>|>)|(<|<)\/script(>|>)/ig, + inside: Prism.languages.markup.tag.inside + }, + rest: Prism.languages.javascript + } + } + }); +}; + +Prism.languages.coffeescript = Prism.languages.extend('javascript', { + 'block-comment': /([#]{3}\s*\r?\n(.*\s*\r*\n*)\s*?\r?\n[#]{3})/g, + 'comment': /(\s|^)([#]{1}[^#^\r^\n]{2,}?(\r?\n|$))/g, + 'keyword': /\b(this|window|delete|class|extends|namespace|extend|ar|let|if|else|while|do|for|each|of|return|in|instanceof|new|with|typeof|try|catch|finally|null|undefined|break|continue)\b/g +}); + +Prism.languages.insertBefore('coffeescript', 'keyword', { + 'function': { + pattern: /[a-z|A-z]+\s*[:|=]\s*(\([.|a-z\s|,|:|{|}|\"|\'|=]*\))?\s*->/gi, + inside: { + 'function-name': /[_?a-z-|A-Z-]+(\s*[:|=])| @[_?$?a-z-|A-Z-]+(\s*)| /g, + 'operator': /[-+]{1,2}|!|=?<|=?>|={1,2}|(&){1,2}|\|?\||\?|\*|\//g + } + }, + 'attr-name': /[_?a-z-|A-Z-]+(\s*:)| @[_?$?a-z-|A-Z-]+(\s*)| /g +}); diff --git a/assets/javascripts/vendor/raven.js b/assets/javascripts/vendor/raven.js new file mode 100755 index 00000000..d77f341a --- /dev/null +++ b/assets/javascripts/vendor/raven.js @@ -0,0 +1,1587 @@ +/*! Raven.js 1.0.8 | github.com/getsentry/raven-js */ + +/* + * Includes TraceKit + * https://github.com/getsentry/TraceKit + * + * Copyright 2013 Matt Robenolt and other contributors + * Released under the BSD license + * https://github.com/getsentry/raven-js/blob/master/LICENSE + * + */ +/* + TraceKit - Cross brower stack traces - github.com/occ/TraceKit + MIT license +*/ + +;(function(window, undefined) { + + +var TraceKit = {}; +var _oldTraceKit = window.TraceKit; + +// global reference to slice +var _slice = [].slice; +var UNKNOWN_FUNCTION = '?'; + + +/** + * _has, a better form of hasOwnProperty + * Example: _has(MainHostObject, property) === true/false + * + * @param {Object} host object to check property + * @param {string} key to check + */ +function _has(object, key) { + return Object.prototype.hasOwnProperty.call(object, key); +} + +function _isUndefined(what) { + return typeof what === 'undefined'; +} + +/** + * TraceKit.noConflict: Export TraceKit out to another variable + * Example: var TK = TraceKit.noConflict() + */ +TraceKit.noConflict = function noConflict() { + window.TraceKit = _oldTraceKit; + return TraceKit; +}; + +/** + * TraceKit.wrap: Wrap any function in a TraceKit reporter + * Example: func = TraceKit.wrap(func); + * + * @param {Function} func Function to be wrapped + * @return {Function} The wrapped func + */ +TraceKit.wrap = function traceKitWrapper(func) { + function wrapped() { + try { + return func.apply(this, arguments); + } catch (e) { + TraceKit.report(e); + throw e; + } + } + return wrapped; +}; + +/** + * TraceKit.report: cross-browser processing of unhandled exceptions + * + * Syntax: + * TraceKit.report.subscribe(function(stackInfo) { ... }) + * TraceKit.report.unsubscribe(function(stackInfo) { ... }) + * TraceKit.report(exception) + * try { ...code... } catch(ex) { TraceKit.report(ex); } + * + * Supports: + * - Firefox: full stack trace with line numbers, plus column number + * on top frame; column number is not guaranteed + * - Opera: full stack trace with line and column numbers + * - Chrome: full stack trace with line and column numbers + * - Safari: line and column number for the top frame only; some frames + * may be missing, and column number is not guaranteed + * - IE: line and column number for the top frame only; some frames + * may be missing, and column number is not guaranteed + * + * In theory, TraceKit should work on all of the following versions: + * - IE5.5+ (only 8.0 tested) + * - Firefox 0.9+ (only 3.5+ tested) + * - Opera 7+ (only 10.50 tested; versions 9 and earlier may require + * Exceptions Have Stacktrace to be enabled in opera:config) + * - Safari 3+ (only 4+ tested) + * - Chrome 1+ (only 5+ tested) + * - Konqueror 3.5+ (untested) + * + * Requires TraceKit.computeStackTrace. + * + * Tries to catch all unhandled exceptions and report them to the + * subscribed handlers. Please note that TraceKit.report will rethrow the + * exception. This is REQUIRED in order to get a useful stack trace in IE. + * If the exception does not reach the top of the browser, you will only + * get a stack trace from the point where TraceKit.report was called. + * + * Handlers receive a stackInfo object as described in the + * TraceKit.computeStackTrace docs. + */ +TraceKit.report = (function reportModuleWrapper() { + var handlers = [], + lastException = null, + lastExceptionStack = null; + + /** + * Add a crash handler. + * @param {Function} handler + */ + function subscribe(handler) { + handlers.push(handler); + } + + /** + * Remove a crash handler. + * @param {Function} handler + */ + function unsubscribe(handler) { + for (var i = handlers.length - 1; i >= 0; --i) { + if (handlers[i] === handler) { + handlers.splice(i, 1); + } + } + } + + /** + * Dispatch stack information to all handlers. + * @param {Object.} stack + */ + function notifyHandlers(stack, windowError) { + var exception = null; + if (windowError && !TraceKit.collectWindowErrors) { + return; + } + for (var i in handlers) { + if (_has(handlers, i)) { + try { + handlers[i].apply(null, [stack].concat(_slice.call(arguments, 2))); + } catch (inner) { + exception = inner; + } + } + } + + if (exception) { + throw exception; + } + } + + var _oldOnerrorHandler = window.onerror; + + /** + * Ensures all global unhandled exceptions are recorded. + * Supported by Gecko and IE. + * @param {string} message Error message. + * @param {string} url URL of script that generated the exception. + * @param {(number|string)} lineNo The line number at which the error + * occurred. + */ + window.onerror = function traceKitWindowOnError(message, url, lineNo) { + var stack = null; + + if (lastExceptionStack) { + TraceKit.computeStackTrace.augmentStackTraceWithInitialElement(lastExceptionStack, url, lineNo, message); + stack = lastExceptionStack; + lastExceptionStack = null; + lastException = null; + } else { + var location = { + 'url': url, + 'line': lineNo + }; + location.func = TraceKit.computeStackTrace.guessFunctionName(location.url, location.line); + location.context = TraceKit.computeStackTrace.gatherContext(location.url, location.line); + stack = { + 'mode': 'onerror', + 'message': message, + 'url': document.location.href, + 'stack': [location], + 'useragent': navigator.userAgent + }; + } + + notifyHandlers(stack, 'from window.onerror'); + + if (_oldOnerrorHandler) { + return _oldOnerrorHandler.apply(this, arguments); + } + + return false; + }; + + /** + * Reports an unhandled Error to TraceKit. + * @param {Error} ex + */ + function report(ex) { + var args = _slice.call(arguments, 1); + if (lastExceptionStack) { + if (lastException === ex) { + return; // already caught by an inner catch block, ignore + } else { + var s = lastExceptionStack; + lastExceptionStack = null; + lastException = null; + notifyHandlers.apply(null, [s, null].concat(args)); + } + } + + var stack = TraceKit.computeStackTrace(ex); + lastExceptionStack = stack; + lastException = ex; + + // If the stack trace is incomplete, wait for 2 seconds for + // slow slow IE to see if onerror occurs or not before reporting + // this exception; otherwise, we will end up with an incomplete + // stack trace + window.setTimeout(function () { + if (lastException === ex) { + lastExceptionStack = null; + lastException = null; + notifyHandlers.apply(null, [stack, null].concat(args)); + } + }, (stack.incomplete ? 2000 : 0)); + + throw ex; // re-throw to propagate to the top level (and cause window.onerror) + } + + report.subscribe = subscribe; + report.unsubscribe = unsubscribe; + return report; +}()); + +/** + * TraceKit.computeStackTrace: cross-browser stack traces in JavaScript + * + * Syntax: + * s = TraceKit.computeStackTrace.ofCaller([depth]) + * s = TraceKit.computeStackTrace(exception) // consider using TraceKit.report instead (see below) + * Returns: + * s.name - exception name + * s.message - exception message + * s.stack[i].url - JavaScript or HTML file URL + * s.stack[i].func - function name, or empty for anonymous functions (if guessing did not work) + * s.stack[i].args - arguments passed to the function, if known + * s.stack[i].line - line number, if known + * s.stack[i].column - column number, if known + * s.stack[i].context - an array of source code lines; the middle element corresponds to the correct line# + * s.mode - 'stack', 'stacktrace', 'multiline', 'callers', 'onerror', or 'failed' -- method used to collect the stack trace + * + * Supports: + * - Firefox: full stack trace with line numbers and unreliable column + * number on top frame + * - Opera 10: full stack trace with line and column numbers + * - Opera 9-: full stack trace with line numbers + * - Chrome: full stack trace with line and column numbers + * - Safari: line and column number for the topmost stacktrace element + * only + * - IE: no line numbers whatsoever + * + * Tries to guess names of anonymous functions by looking for assignments + * in the source code. In IE and Safari, we have to guess source file names + * by searching for function bodies inside all page scripts. This will not + * work for scripts that are loaded cross-domain. + * Here be dragons: some function names may be guessed incorrectly, and + * duplicate functions may be mismatched. + * + * TraceKit.computeStackTrace should only be used for tracing purposes. + * Logging of unhandled exceptions should be done with TraceKit.report, + * which builds on top of TraceKit.computeStackTrace and provides better + * IE support by utilizing the window.onerror event to retrieve information + * about the top of the stack. + * + * Note: In IE and Safari, no stack trace is recorded on the Error object, + * so computeStackTrace instead walks its *own* chain of callers. + * This means that: + * * in Safari, some methods may be missing from the stack trace; + * * in IE, the topmost function in the stack trace will always be the + * caller of computeStackTrace. + * + * This is okay for tracing (because you are likely to be calling + * computeStackTrace from the function you want to be the topmost element + * of the stack trace anyway), but not okay for logging unhandled + * exceptions (because your catch block will likely be far away from the + * inner function that actually caused the exception). + * + * Tracing example: + * function trace(message) { + * var stackInfo = TraceKit.computeStackTrace.ofCaller(); + * var data = message + "\n"; + * for(var i in stackInfo.stack) { + * var item = stackInfo.stack[i]; + * data += (item.func || '[anonymous]') + "() in " + item.url + ":" + (item.line || '0') + "\n"; + * } + * if (window.console) + * console.info(data); + * else + * alert(data); + * } + */ +TraceKit.computeStackTrace = (function computeStackTraceWrapper() { + var debug = false, + sourceCache = {}; + + /** + * Attempts to retrieve source code via XMLHttpRequest, which is used + * to look up anonymous function names. + * @param {string} url URL of source code. + * @return {string} Source contents. + */ + function loadSource(url) { + if (!TraceKit.remoteFetching) { //Only attempt request if remoteFetching is on. + return ''; + } + try { + function getXHR() { + try { + return new window.XMLHttpRequest(); + } catch (e) { + // explicitly bubble up the exception if not found + return new window.ActiveXObject('Microsoft.XMLHTTP'); + } + } + + var request = getXHR(); + request.open('GET', url, false); + request.send(''); + return request.responseText; + } catch (e) { + return ''; + } + } + + /** + * Retrieves source code from the source code cache. + * @param {string} url URL of source code. + * @return {Array.} Source contents. + */ + function getSource(url) { + if (!_has(sourceCache, url)) { + // URL needs to be able to fetched within the acceptable domain. Otherwise, + // cross-domain errors will be triggered. + var source = ''; + if (url.indexOf(document.domain) !== -1) { + source = loadSource(url); + } + sourceCache[url] = source ? source.split('\n') : []; + } + + return sourceCache[url]; + } + + /** + * Tries to use an externally loaded copy of source code to determine + * the name of a function by looking at the name of the variable it was + * assigned to, if any. + * @param {string} url URL of source code. + * @param {(string|number)} lineNo Line number in source code. + * @return {string} The function name, if discoverable. + */ + function guessFunctionName(url, lineNo) { + var reFunctionArgNames = /function ([^(]*)\(([^)]*)\)/, + reGuessFunction = /['"]?([0-9A-Za-z$_]+)['"]?\s*[:=]\s*(function|eval|new Function)/, + line = '', + maxLines = 10, + source = getSource(url), + m; + + if (!source.length) { + return UNKNOWN_FUNCTION; + } + + // Walk backwards from the first line in the function until we find the line which + // matches the pattern above, which is the function definition + for (var i = 0; i < maxLines; ++i) { + line = source[lineNo - i] + line; + + if (!_isUndefined(line)) { + if ((m = reGuessFunction.exec(line))) { + return m[1]; + } else if ((m = reFunctionArgNames.exec(line))) { + return m[1]; + } + } + } + + return UNKNOWN_FUNCTION; + } + + /** + * Retrieves the surrounding lines from where an exception occurred. + * @param {string} url URL of source code. + * @param {(string|number)} line Line number in source code to centre + * around for context. + * @return {?Array.} Lines of source code. + */ + function gatherContext(url, line) { + var source = getSource(url); + + if (!source.length) { + return null; + } + + var context = [], + // linesBefore & linesAfter are inclusive with the offending line. + // if linesOfContext is even, there will be one extra line + // *before* the offending line. + linesBefore = Math.floor(TraceKit.linesOfContext / 2), + // Add one extra line if linesOfContext is odd + linesAfter = linesBefore + (TraceKit.linesOfContext % 2), + start = Math.max(0, line - linesBefore - 1), + end = Math.min(source.length, line + linesAfter - 1); + + line -= 1; // convert to 0-based index + + for (var i = start; i < end; ++i) { + if (!_isUndefined(source[i])) { + context.push(source[i]); + } + } + + return context.length > 0 ? context : null; + } + + /** + * Escapes special characters, except for whitespace, in a string to be + * used inside a regular expression as a string literal. + * @param {string} text The string. + * @return {string} The escaped string literal. + */ + function escapeRegExp(text) { + return text.replace(/[\-\[\]{}()*+?.,\\\^$|#]/g, '\\$&'); + } + + /** + * Escapes special characters in a string to be used inside a regular + * expression as a string literal. Also ensures that HTML entities will + * be matched the same as their literal friends. + * @param {string} body The string. + * @return {string} The escaped string. + */ + function escapeCodeAsRegExpForMatchingInsideHTML(body) { + return escapeRegExp(body).replace('<', '(?:<|<)').replace('>', '(?:>|>)').replace('&', '(?:&|&)').replace('"', '(?:"|")').replace(/\s+/g, '\\s+'); + } + + /** + * Determines where a code fragment occurs in the source code. + * @param {RegExp} re The function definition. + * @param {Array.} urls A list of URLs to search. + * @return {?Object.} An object containing + * the url, line, and column number of the defined function. + */ + function findSourceInUrls(re, urls) { + var source, m; + for (var i = 0, j = urls.length; i < j; ++i) { + // console.log('searching', urls[i]); + if ((source = getSource(urls[i])).length) { + source = source.join('\n'); + if ((m = re.exec(source))) { + // console.log('Found function in ' + urls[i]); + + return { + 'url': urls[i], + 'line': source.substring(0, m.index).split('\n').length, + 'column': m.index - source.lastIndexOf('\n', m.index) - 1 + }; + } + } + } + + // console.log('no match'); + + return null; + } + + /** + * Determines at which column a code fragment occurs on a line of the + * source code. + * @param {string} fragment The code fragment. + * @param {string} url The URL to search. + * @param {(string|number)} line The line number to examine. + * @return {?number} The column number. + */ + function findSourceInLine(fragment, url, line) { + var source = getSource(url), + re = new RegExp('\\b' + escapeRegExp(fragment) + '\\b'), + m; + + line -= 1; + + if (source && source.length > line && (m = re.exec(source[line]))) { + return m.index; + } + + return null; + } + + /** + * Determines where a function was defined within the source code. + * @param {(Function|string)} func A function reference or serialized + * function definition. + * @return {?Object.} An object containing + * the url, line, and column number of the defined function. + */ + function findSourceByFunctionBody(func) { + var urls = [window.location.href], + scripts = document.getElementsByTagName('script'), + body, + code = '' + func, + codeRE = /^function(?:\s+([\w$]+))?\s*\(([\w\s,]*)\)\s*\{\s*(\S[\s\S]*\S)\s*\}\s*$/, + eventRE = /^function on([\w$]+)\s*\(event\)\s*\{\s*(\S[\s\S]*\S)\s*\}\s*$/, + re, + parts, + result; + + for (var i = 0; i < scripts.length; ++i) { + var script = scripts[i]; + if (script.src) { + urls.push(script.src); + } + } + + if (!(parts = codeRE.exec(code))) { + re = new RegExp(escapeRegExp(code).replace(/\s+/g, '\\s+')); + } + + // not sure if this is really necessary, but I don’t have a test + // corpus large enough to confirm that and it was in the original. + else { + var name = parts[1] ? '\\s+' + parts[1] : '', + args = parts[2].split(',').join('\\s*,\\s*'); + + body = escapeRegExp(parts[3]).replace(/;$/, ';?'); // semicolon is inserted if the function ends with a comment.replace(/\s+/g, '\\s+'); + re = new RegExp('function' + name + '\\s*\\(\\s*' + args + '\\s*\\)\\s*{\\s*' + body + '\\s*}'); + } + + // look for a normal function definition + if ((result = findSourceInUrls(re, urls))) { + return result; + } + + // look for an old-school event handler function + if ((parts = eventRE.exec(code))) { + var event = parts[1]; + body = escapeCodeAsRegExpForMatchingInsideHTML(parts[2]); + + // look for a function defined in HTML as an onXXX handler + re = new RegExp('on' + event + '=[\\\'"]\\s*' + body + '\\s*[\\\'"]', 'i'); + + if ((result = findSourceInUrls(re, urls[0]))) { + return result; + } + + // look for ??? + re = new RegExp(body); + + if ((result = findSourceInUrls(re, urls))) { + return result; + } + } + + return null; + } + + // Contents of Exception in various browsers. + // + // SAFARI: + // ex.message = Can't find variable: qq + // ex.line = 59 + // ex.sourceId = 580238192 + // ex.sourceURL = http://... + // ex.expressionBeginOffset = 96 + // ex.expressionCaretOffset = 98 + // ex.expressionEndOffset = 98 + // ex.name = ReferenceError + // + // FIREFOX: + // ex.message = qq is not defined + // ex.fileName = http://... + // ex.lineNumber = 59 + // ex.stack = ...stack trace... (see the example below) + // ex.name = ReferenceError + // + // CHROME: + // ex.message = qq is not defined + // ex.name = ReferenceError + // ex.type = not_defined + // ex.arguments = ['aa'] + // ex.stack = ...stack trace... + // + // INTERNET EXPLORER: + // ex.message = ... + // ex.name = ReferenceError + // + // OPERA: + // ex.message = ...message... (see the example below) + // ex.name = ReferenceError + // ex.opera#sourceloc = 11 (pretty much useless, duplicates the info in ex.message) + // ex.stacktrace = n/a; see 'opera:config#UserPrefs|Exceptions Have Stacktrace' + + /** + * Computes stack trace information from the stack property. + * Chrome and Gecko use this property. + * @param {Error} ex + * @return {?Object.} Stack trace information. + */ + function computeStackTraceFromStackProp(ex) { + if (!ex.stack) { + return null; + } + + var chrome = /^\s*at (?:((?:\[object object\])?\S+) )?\(?((?:file|http|https):.*?):(\d+)(?::(\d+))?\)?\s*$/i, + gecko = /^\s*(\S*)(?:\((.*?)\))?@((?:file|http|https).*?):(\d+)(?::(\d+))?\s*$/i, + lines = ex.stack.split('\n'), + stack = [], + parts, + element, + reference = /^(.*) is undefined$/.exec(ex.message); + + for (var i = 0, j = lines.length; i < j; ++i) { + if ((parts = gecko.exec(lines[i]))) { + element = { + 'url': parts[3], + 'func': parts[1] || UNKNOWN_FUNCTION, + 'args': parts[2] ? parts[2].split(',') : '', + 'line': +parts[4], + 'column': parts[5] ? +parts[5] : null + }; + } else if ((parts = chrome.exec(lines[i]))) { + element = { + 'url': parts[2], + 'func': parts[1] || UNKNOWN_FUNCTION, + 'line': +parts[3], + 'column': parts[4] ? +parts[4] : null + }; + } else { + continue; + } + + if (!element.func && element.line) { + element.func = guessFunctionName(element.url, element.line); + } + + if (element.line) { + element.context = gatherContext(element.url, element.line); + } + + stack.push(element); + } + + if (stack[0] && stack[0].line && !stack[0].column && reference) { + stack[0].column = findSourceInLine(reference[1], stack[0].url, stack[0].line); + } + + if (!stack.length) { + return null; + } + + return { + 'mode': 'stack', + 'name': ex.name, + 'message': ex.message, + 'url': document.location.href, + 'stack': stack, + 'useragent': navigator.userAgent + }; + } + + /** + * Computes stack trace information from the stacktrace property. + * Opera 10 uses this property. + * @param {Error} ex + * @return {?Object.} Stack trace information. + */ + function computeStackTraceFromStacktraceProp(ex) { + // Access and store the stacktrace property before doing ANYTHING + // else to it because Opera is not very good at providing it + // reliably in other circumstances. + var stacktrace = ex.stacktrace; + + var testRE = / line (\d+), column (\d+) in (?:]+)>|([^\)]+))\((.*)\) in (.*):\s*$/i, + lines = stacktrace.split('\n'), + stack = [], + parts; + + for (var i = 0, j = lines.length; i < j; i += 2) { + if ((parts = testRE.exec(lines[i]))) { + var element = { + 'line': +parts[1], + 'column': +parts[2], + 'func': parts[3] || parts[4], + 'args': parts[5] ? parts[5].split(',') : [], + 'url': parts[6] + }; + + if (!element.func && element.line) { + element.func = guessFunctionName(element.url, element.line); + } + if (element.line) { + try { + element.context = gatherContext(element.url, element.line); + } catch (exc) {} + } + + if (!element.context) { + element.context = [lines[i + 1]]; + } + + stack.push(element); + } + } + + if (!stack.length) { + return null; + } + + return { + 'mode': 'stacktrace', + 'name': ex.name, + 'message': ex.message, + 'url': document.location.href, + 'stack': stack, + 'useragent': navigator.userAgent + }; + } + + /** + * NOT TESTED. + * Computes stack trace information from an error message that includes + * the stack trace. + * Opera 9 and earlier use this method if the option to show stack + * traces is turned on in opera:config. + * @param {Error} ex + * @return {?Object.} Stack information. + */ + function computeStackTraceFromOperaMultiLineMessage(ex) { + // Opera includes a stack trace into the exception message. An example is: + // + // Statement on line 3: Undefined variable: undefinedFunc + // Backtrace: + // Line 3 of linked script file://localhost/Users/andreyvit/Projects/TraceKit/javascript-client/sample.js: In function zzz + // undefinedFunc(a); + // Line 7 of inline#1 script in file://localhost/Users/andreyvit/Projects/TraceKit/javascript-client/sample.html: In function yyy + // zzz(x, y, z); + // Line 3 of inline#1 script in file://localhost/Users/andreyvit/Projects/TraceKit/javascript-client/sample.html: In function xxx + // yyy(a, a, a); + // Line 1 of function script + // try { xxx('hi'); return false; } catch(ex) { TraceKit.report(ex); } + // ... + + var lines = ex.message.split('\n'); + if (lines.length < 4) { + return null; + } + + var lineRE1 = /^\s*Line (\d+) of linked script ((?:file|http|https)\S+)(?:: in function (\S+))?\s*$/i, + lineRE2 = /^\s*Line (\d+) of inline#(\d+) script in ((?:file|http|https)\S+)(?:: in function (\S+))?\s*$/i, + lineRE3 = /^\s*Line (\d+) of function script\s*$/i, + stack = [], + scripts = document.getElementsByTagName('script'), + inlineScriptBlocks = [], + parts, + i, + len, + source; + + for (i in scripts) { + if (_has(scripts, i) && !scripts[i].src) { + inlineScriptBlocks.push(scripts[i]); + } + } + + for (i = 2, len = lines.length; i < len; i += 2) { + var item = null; + if ((parts = lineRE1.exec(lines[i]))) { + item = { + 'url': parts[2], + 'func': parts[3], + 'line': +parts[1] + }; + } else if ((parts = lineRE2.exec(lines[i]))) { + item = { + 'url': parts[3], + 'func': parts[4] + }; + var relativeLine = (+parts[1]); // relative to the start of the + + """ diff --git a/assets/javascripts/views/pages/lodash.coffee b/assets/javascripts/views/pages/lodash.coffee new file mode 100644 index 00000000..31dc38c6 --- /dev/null +++ b/assets/javascripts/views/pages/lodash.coffee @@ -0,0 +1,4 @@ +#= require views/pages/base +#= require views/pages/underscore + +app.views.LodashPage = app.views.UnderscorePage diff --git a/assets/javascripts/views/pages/mdn.coffee b/assets/javascripts/views/pages/mdn.coffee new file mode 100644 index 00000000..9cd4e2c1 --- /dev/null +++ b/assets/javascripts/views/pages/mdn.coffee @@ -0,0 +1,15 @@ +#= require views/pages/base + +class app.views.MdnPage extends app.views.BasePage + @className: '_mdn' + + LANGUAGE_REGEXP = /brush: ?(\w+)/ + + afterRender: -> + for el in @findAll 'pre[class^="brush"]' + language = el.className.match(LANGUAGE_REGEXP)[1] + .replace('html', 'markup') + .replace('js', 'javascript') + el.className = '' + @highlightCode el, language + return diff --git a/assets/javascripts/views/pages/node.coffee b/assets/javascripts/views/pages/node.coffee new file mode 100644 index 00000000..06616725 --- /dev/null +++ b/assets/javascripts/views/pages/node.coffee @@ -0,0 +1,6 @@ +#= require views/pages/base + +class app.views.NodePage extends app.views.BasePage + afterRender: -> + @highlightCode @findAll('pre > code'), 'javascript' + return diff --git a/assets/javascripts/views/pages/underscore.coffee b/assets/javascripts/views/pages/underscore.coffee new file mode 100644 index 00000000..db93b03b --- /dev/null +++ b/assets/javascripts/views/pages/underscore.coffee @@ -0,0 +1,6 @@ +#= require views/pages/base + +class app.views.UnderscorePage extends app.views.BasePage + afterRender: -> + @highlightCode @findAllByTag('pre'), 'javascript' + return diff --git a/assets/javascripts/views/search/search.coffee b/assets/javascripts/views/search/search.coffee new file mode 100644 index 00000000..74e8e139 --- /dev/null +++ b/assets/javascripts/views/search/search.coffee @@ -0,0 +1,113 @@ +class app.views.Search extends app.View + SEARCH_PARAM = app.config.search_param + + @el: '._search' + @activeClass: '_search-active' + + @elements: + input: '._search-input' + resetLink: '._search-clear' + + @events: + input: 'onInput' + click: 'onClick' + submit: 'onSubmit' + + @shortcuts: + typing: 'autoFocus' + escape: 'reset' + + @routes: + root: 'onRoot' + after: 'autoFocus' + + init: -> + @addSubview @scope = new app.views.SearchScope @el + + @searcher = new app.Searcher + @searcher.on 'results', @onResults + + app.on 'ready', @onReady + $.on window, 'hashchange', @searchUrl + $.on window, 'focus', @autoFocus + return + + focus: -> + @input.focus() unless document.activeElement is @input + return + + autoFocus: => + @focus() unless $.isTouchScreen() + return + + reset: => + @el.reset() + @onInput() + @autoFocus() + return + + onReady: => + @value = '' + @delay @onInput + return + + onInput: => + return if not @value? or # ignore events pre-"ready" + @value is @input.value + @value = @input.value + + if @value.length + @search() + else + @clear() + return + + search: (url = false) -> + @addClass @constructor.activeClass + @trigger 'searching' + + @flags = urlSearch: url, initialResults: true + @searcher.find @scope.getScope().entries.all(), 'text', @value + return + + searchUrl: => + return unless app.router.isRoot() + @scope.searchUrl() + + return unless value = @extractHashValue() + @input.value = @value = value + @search true + true + + clear: -> + @removeClass @constructor.activeClass + @trigger 'clear' + + onResults: (results) => + @trigger 'results', results, @flags + @flags.initialResults = false + return + + onClick: (event) => + if event.target is @resetLink + $.stopEvent(event) + @reset() + @focus() + return + + onSubmit: (event) -> + $.stopEvent(event) + return + + onRoot: (context) => + @reset() unless context.init + @delay @searchUrl if context.hash + return + + extractHashValue: -> + if (value = @getHashValue())? + app.router.replaceHash() + value + + getHashValue: -> + try (new RegExp "##{SEARCH_PARAM}=(.*)").exec(decodeURIComponent location.hash)?[1] catch diff --git a/assets/javascripts/views/search/search_scope.coffee b/assets/javascripts/views/search/search_scope.coffee new file mode 100644 index 00000000..d90fd9eb --- /dev/null +++ b/assets/javascripts/views/search/search_scope.coffee @@ -0,0 +1,84 @@ +class app.views.SearchScope extends app.View + SEARCH_PARAM = app.config.search_param + + @elements: + input: '._search-input' + tag: '._search-tag' + + @events: + keydown: 'onKeydown' + + @shortcuts: + escape: 'reset' + + constructor: (@el) -> super + + init: -> + @placeholder = @input.getAttribute 'placeholder' + + @searcher = new app.SynchronousSearcher + fuzzy_min_length: 2 + max_results: 1 + @searcher.on 'results', @onResults + + return + + getScope: -> + @doc or app + + search: (value) -> + unless @doc + @searcher.find app.docs.all(), 'slug', value + return + + searchUrl: -> + if value = @extractHashValue() + @search value + return + + onResults: (results) => + if results.length + @selectDoc results[0] + return + + selectDoc: (doc) -> + @doc = doc + + @tag.textContent = doc.name + @tag.style.display = 'block' + + @input.removeAttribute 'placeholder' + @input.value = @input.value[@input.selectionStart..] + @input.style.paddingLeft = @tag.offsetWidth + 6 + 'px' + $.trigger @input, 'input' + + reset: => + @doc = null + + @tag.textContent = '' + @tag.style.display = 'none' + + @input.setAttribute 'placeholder', @placeholder + @input.style.paddingLeft = '' + + onKeydown: (event) => + return if event.ctrlKey or event.metaKey or event.altKey or event.shiftKey + + if event.which is 8 # backspace + if @doc and not @input.value + $.stopEvent(event) + @reset() + else if event.which is 9 or # tab + event.which is 32 and (app.isMobile() or $.isTouchScreen()) # space + $.stopEvent(event) + @search @input.value[0...@input.selectionStart] + return + + extractHashValue: -> + if value = @getHashValue() + newHash = decodeURIComponent(location.hash).replace "##{SEARCH_PARAM}=#{value} ", "##{SEARCH_PARAM}=" + app.router.replaceHash(newHash) + value + + getHashValue: -> + try (new RegExp "^##{SEARCH_PARAM}=(.+?) .").exec(decodeURIComponent location.hash)?[1] catch diff --git a/assets/javascripts/views/sidebar/doc_list.coffee b/assets/javascripts/views/sidebar/doc_list.coffee new file mode 100644 index 00000000..96a949b0 --- /dev/null +++ b/assets/javascripts/views/sidebar/doc_list.coffee @@ -0,0 +1,91 @@ +class app.views.DocList extends app.View + @className: '_list' + + @events: + open: 'onOpen' + close: 'onClose' + + @routes: + after: 'afterRoute' + + init: -> + @lists = {} + + @addSubview @listSelect = new app.views.ListSelect @el + @addSubview @listFocus = new app.views.ListFocus @el unless $.isTouchScreen() + @addSubview @listFold = new app.views.ListFold @el + + app.on 'ready', @render + return + + activate: -> + if super + list.activate() for slug, list of @lists + @listSelect.selectCurrent() + return + + deactivate: -> + if super + list.deactivate() for slug, list of @lists + return + + render: => + @html @tmpl('sidebarDoc', app.docs.all()) + unless app.doc or app.settings.hasDocs() + @append @tmpl('sidebarDoc', app.disabledDocs.all(), disabled: true) + return + + onOpen: (event) => + $.stopEvent(event) + doc = app.docs.findBy 'slug', event.target.getAttribute('data-slug') + + if doc and not @lists[doc.slug] + @lists[doc.slug] = if doc.types.isEmpty() + new app.views.EntryList doc.entries.all() + else + new app.views.TypeList doc + $.after event.target, @lists[doc.slug].el + return + + onClose: (event) => + $.stopEvent(event) + doc = app.docs.findBy 'slug', event.target.getAttribute('data-slug') + + if doc and @lists[doc.slug] + @lists[doc.slug].detach() + delete @lists[doc.slug] + return + + revealType: (type) -> + @openDoc type.doc + return + + revealEntry: (entry) -> + @openDoc entry.doc + @openType entry.getType() if entry.type + @lists[entry.doc.slug]?.revealEntry(entry) + return + + openDoc: (doc) -> + @listFold.open @find("[data-slug='#{doc.slug}']") + return + + openType: (type) -> + @listFold.open @lists[type.doc.slug].find("[data-slug='#{type.slug}']") + return + + afterRoute: (route, context) => + if context.init + switch route + when 'type' then @revealType context.type + when 'entry' then @revealEntry context.entry + + if route in ['type', 'entry'] + @listSelect.selectByHref (context.type or context.entry).fullPath() + else + @listSelect.deselect() + + if context.init + $.scrollTo @listSelect.getSelection() + + return diff --git a/assets/javascripts/views/sidebar/doc_picker.coffee b/assets/javascripts/views/sidebar/doc_picker.coffee new file mode 100644 index 00000000..ad7d65ab --- /dev/null +++ b/assets/javascripts/views/sidebar/doc_picker.coffee @@ -0,0 +1,69 @@ +class app.views.DocPicker extends app.View + @className: '_list' + + @elements: + saveLink: '._sidebar-footer-save' + + @events: + click: 'onClick' + + @shortcuts: + enter: 'onEnter' + + activate: -> + if super + @render() + app.appCache?.on 'progress', @onAppCacheProgress + return + + deactivate: -> + if super + @empty() + app.appCache?.off 'progress', @onAppCacheProgress + return + + render: -> + @html @tmpl('sidebarLabel', app.docs.all(), checked: true) + + @tmpl('sidebarLabel', app.disabledDocs.all()) + + @tmpl('sidebarVote') + + @tmpl('sidebarSave') + + @refreshElements() + + @delay -> # trigger animation + @el.offsetWidth + @addClass '_in' + return + + empty: -> + @resetClass() + super + return + + save: -> + unless @saving + @saving = true + app.settings.setDocs @getSelectedDocs() + @saveLink.textContent = if app.appCache then 'Downloading…' else 'Saving…' + app.reload() + return + + getSelectedDocs: -> + for input in @findAllByTag 'input' when input?.checked + input.name + + onClick: (event) => + if event.target is @saveLink + $.stopEvent(event) + @save() + return + + onEnter: => + @save() + return + + onAppCacheProgress: (event) => + if event.lengthComputable + percentage = Math.round event.loaded * 100 / event.total + @saveLink.textContent = "Downloading… (#{percentage}%)" + return diff --git a/assets/javascripts/views/sidebar/entry_list.coffee b/assets/javascripts/views/sidebar/entry_list.coffee new file mode 100644 index 00000000..33381cd0 --- /dev/null +++ b/assets/javascripts/views/sidebar/entry_list.coffee @@ -0,0 +1,19 @@ +#= require views/list/paginated_list + +class app.views.EntryList extends app.views.PaginatedList + @tagName: 'div' + @className: '_list _list-sub' + + constructor: (@entries) -> super + + init: -> + @renderPaginated() + @activate() + return + + render: (entries) -> + @tmpl 'sidebarEntry', entries + + revealEntry: (entry) -> + @paginateTo entry + return diff --git a/assets/javascripts/views/sidebar/results.coffee b/assets/javascripts/views/sidebar/results.coffee new file mode 100644 index 00000000..eac1f1b3 --- /dev/null +++ b/assets/javascripts/views/sidebar/results.coffee @@ -0,0 +1,48 @@ +class app.views.Results extends app.View + @className: '_list' + + @routes: + after: 'afterRoute' + + constructor: (@search) -> super + + deactivate: -> + if super + @empty() + return + + init: -> + @addSubview @listSelect = new app.views.ListSelect @el + @addSubview @listFocus = new app.views.ListFocus @el unless $.isTouchScreen() + + @search + .on('results', @onResults) + .on('clear', @onClear) + return + + onResults: (entries, flags) => + @empty() if flags.initialResults + @append @tmpl('sidebarResult', entries) + + if flags.initialResults + if flags.urlSearch then @openFirst() else @focusFirst() + return + + onClear: => + @empty() + return + + focusFirst: -> + @listFocus?.focus @el.firstChild + return + + openFirst: -> + @el.firstChild?.click() + return + + afterRoute: (route, context) => + if route is 'entry' + @listSelect.selectByHref context.entry.fullPath() + else + @listSelect.deselect() + return diff --git a/assets/javascripts/views/sidebar/sidebar.coffee b/assets/javascripts/views/sidebar/sidebar.coffee new file mode 100644 index 00000000..d15ba99d --- /dev/null +++ b/assets/javascripts/views/sidebar/sidebar.coffee @@ -0,0 +1,79 @@ +class app.views.Sidebar extends app.View + @el: '._sidebar' + + @events: + focus: 'onFocus' + + @shortcuts: + escape: 'onEscape' + + init: -> + @addSubview @search = new app.views.Search + + @search + .on('searching', @showResults) + .on('clear', @showDocList) + + @results = new app.views.Results @search + @docList = new app.views.DocList + @docPicker = new app.views.DocPicker unless app.doc + + app.on 'ready', @showDocList + $.on document, 'click', @onGlobalClick if @docPicker + return + + show: (view) -> + unless @view is view + @saveScrollPosition() + @view?.deactivate() + @html @view = view + @append @tmpl('sidebarSettings') if @view is @docList and @docPicker + @view.activate() + @restoreScrollPosition() + return + + showDocList: => + @show @docList + return + + showDocPicker: => + @show @docPicker + return + + showResults: => + @show @results + return + + saveScrollPosition: -> + if @view is @docList + @scrollTop = @el.scrollTop + return + + restoreScrollPosition: -> + if @view is @docList and @scrollTop + @el.scrollTop = @scrollTop + @scrollTop = null + else + @scrollToTop() + return + + scrollToTop: -> + @el.scrollTop = 0 + return + + onFocus: (event) => + $.scrollTo event.target, @el, 'continuous', bottomGap: 2 + return + + onEscape: => + @showDocList() + @scrollToTop() + return + + onGlobalClick: (event) => + if event.target.hasAttribute? 'data-pick-docs' + $.stopEvent(event) + @showDocPicker() + else if @view is @docPicker + @showDocList() unless $.hasChild @el, event.target + return diff --git a/assets/javascripts/views/sidebar/type_list.coffee b/assets/javascripts/views/sidebar/type_list.coffee new file mode 100644 index 00000000..7b051d46 --- /dev/null +++ b/assets/javascripts/views/sidebar/type_list.coffee @@ -0,0 +1,51 @@ +class app.views.TypeList extends app.View + @tagName: 'div' + @className: '_list _list-sub' + + @events: + open: 'onOpen' + close: 'onClose' + + constructor: (@doc) -> super + + init: -> + @lists = {} + @render() + @activate() + return + + activate: -> + if super + list.activate() for slug, list of @lists + return + + deactivate: -> + if super + list.deactivate() for slug, list of @lists + return + + render: -> + @html @tmpl('sidebarType', @doc.types.all()) + + onOpen: (event) => + $.stopEvent(event) + type = @doc.types.findBy 'slug', event.target.getAttribute('data-slug') + + if type and not @lists[type.slug] + @lists[type.slug] = new app.views.EntryList(type.entries()) + $.after event.target, @lists[type.slug].el + return + + onClose: (event) => + $.stopEvent(event) + type = @doc.types.findBy 'slug', event.target.getAttribute('data-slug') + + if type and @lists[type.slug] + @lists[type.slug].detach() + delete @lists[type.slug] + return + + revealEntry: (entry) -> + if entry.type + @lists[entry.getType().slug]?.revealEntry(entry) + return diff --git a/assets/javascripts/views/view.coffee b/assets/javascripts/views/view.coffee new file mode 100644 index 00000000..a9e8f579 --- /dev/null +++ b/assets/javascripts/views/view.coffee @@ -0,0 +1,161 @@ +class app.View + $.extend @prototype, Events + + constructor: -> + @setupElement() + @originalClassName = @el.className if @el.className + @resetClass() if @constructor.className + @refreshElements() + @init?() + @refreshElements() + + setupElement: -> + @el ?= if typeof @constructor.el is 'string' + $ @constructor.el + else if @constructor.el + @constructor.el + else + document.createElement @constructor.tagName or 'div' + return + + refreshElements: -> + if @constructor.elements + @[name] = @find selector for name, selector of @constructor.elements + return + + addClass: (name) -> + @el.classList.add(name) + return + + removeClass: (name) -> + @el.classList.remove(name) + return + + resetClass: -> + @el.className = @originalClassName or '' + if @constructor.className + @addClass name for name in @constructor.className.split ' ' + return + + find: (selector) -> + $ selector, @el + + findAll: (selector) -> + $$ selector, @el + + findByClass: (name) -> + @findAllByClass(name)[0] + + findLastByClass: (name) -> + all = @findAllByClass(name)[0] + all[all.length - 1] + + findAllByClass: (name) -> + @el.getElementsByClassName(name) + + findByTag: (tag) -> + @findAllByTag(tag)[0] + + findLastByTag: (tag) -> + all = @findAllByTag(tag) + all[all.length - 1] + + findAllByTag: (tag) -> + @el.getElementsByTagName(tag) + + append: (value) -> + $.append @el, value.el or value + return + + appendTo: (value) -> + $.append value.el or value, @el + return + + prepend: (value) -> + $.prepend @el, value.el or value + return + + prependTo: (value) -> + $.prepend value.el or value, @el + return + + before: (value) -> + $.before @el, value.el or value + return + + after: (value) -> + $.after @el, value.el or value + return + + remove: (value) -> + $.remove value.el or value + return + + empty: -> + $.empty @el + @refreshElements() + return + + html: (value) -> + @empty() + @append value + return + + tmpl: (args...) -> + app.templates.render(args...) + + delay: (fn, args...) -> + delay = if typeof args[args.length - 1] is 'number' then args.pop() else 0 + setTimeout fn.bind(@, args...), delay + + onDOM: (event, callback) -> + $.on @el, event, callback + return + + offDOM: (event, callback) -> + $.off @el, event, callback + return + + bindEvents: -> + if @constructor.events + @onDOM name, @[method] for name, method of @constructor.events + + if @constructor.routes + app.router.on name, @[method] for name, method of @constructor.routes + + if @constructor.shortcuts + app.shortcuts.on name, @[method] for name, method of @constructor.shortcuts + return + + unbindEvents: -> + if @constructor.events + @offDOM name, @[method] for name, method of @constructor.events + + if @constructor.routes + app.router.off name, @[method] for name, method of @constructor.routes + + if @constructor.shortcuts + app.shortcuts.off name, @[method] for name, method of @constructor.shortcuts + return + + addSubview: (view) -> + (@subviews or= []).push(view) + + activate: -> + return if @activated + @bindEvents() + view.activate() for view in @subviews if @subviews + @activated = true + true + + deactivate: -> + return unless @activated + @unbindEvents() + view.deactivate() for view in @subviews if @subviews + @activated = false + true + + detach: -> + @deactivate() + $.remove @el + return diff --git a/assets/stylesheets/application.css.scss b/assets/stylesheets/application.css.scss new file mode 100644 index 00000000..ecc1e02c --- /dev/null +++ b/assets/stylesheets/application.css.scss @@ -0,0 +1,41 @@ +//= depend_on icons.png +//= depend_on icons@2x.png + +//= require vendor/open-sans + +/*! + * Copyright 2013 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/ + */ + +@import 'global/variables', + 'global/icons', + 'global/classes', + 'global/base'; + +@import 'components/app', + 'components/header', + 'components/sidebar', + 'components/content', + 'components/page', + 'components/fail', + 'components/notice', + 'components/notif', + 'components/prism', + 'components/mobile'; + +@import 'pages/angular', + 'pages/coffeescript', + 'pages/ember', + 'pages/jquery', + 'pages/less', + 'pages/lodash', + 'pages/mdn', + 'pages/node', + 'pages/php', + 'pages/rfc', + 'pages/underscore', + 'pages/yard'; diff --git a/assets/stylesheets/components/_app.scss b/assets/stylesheets/components/_app.scss new file mode 100644 index 00000000..04098f9c --- /dev/null +++ b/assets/stylesheets/components/_app.scss @@ -0,0 +1,34 @@ +._app { + position: relative; + z-index: 1; + height: 100%; + padding-top: $headerHeight; + overflow: hidden; + -webkit-transition: opacity .2s; + transition: opacity .2s; + @extend %border-box; + + ._booting > & { opacity: 0; } +} + +._booting { + opacity: 0; + -webkit-transition: opacity .1s .3s; + transition: opacity .1s .3s; + + &._loading { opacity: 1; } + + &:before { + content: 'Loading…'; + position: absolute; + top: 50%; + left: 0; + right: 0; + line-height: 1; + margin-top: -.75em; + font-size: 4rem; + color: #ccc; + text-align: center; + letter-spacing: -.125rem; + } +} diff --git a/assets/stylesheets/components/_content.scss b/assets/stylesheets/components/_content.scss new file mode 100644 index 00000000..a48ca6f2 --- /dev/null +++ b/assets/stylesheets/components/_content.scss @@ -0,0 +1,325 @@ +// +// Content +// + +._container { + position: relative; + z-index: $contentZ; + height: 100%; + margin-left: $sidebarWidth; + border-top: 1px solid #b4b7bf; + box-shadow: inset 0 1px rgba(black, .04), // top inner shadow + inset 1px 0 #f4f4f4; // left inner shadow + pointer-events: none; + @extend %border-box; + + @media #{$mediumScreen} { margin-left: $sidebarMediumWidth; } +} + +._content { + position: relative; + height: 100%; + overflow-y: scroll; + margin-left: 1rem; + padding: 1.25rem 1.5rem 0; + font-size: .875rem; + pointer-events: auto; + -webkit-overflow-scrolling: touch; + @extend %border-box; + + -webkit-padding-start: .75rem; + @media (-moz-overlay-scrollbars) { padding-left: .75rem; } + @media screen and (-ms-high-contrast: active), (-ms-high-contrast: none) { margin-left: 0; } + + &:after { // padding bottom + content: ''; + display: block; + margin-bottom: 1.25rem; + } +} + +._content-loading:before { + color: #e6e6e6; + @extend ._booting:before; +} + +// +// Splash screen +// + +._splash-title { + color: #ddd; + cursor: default; + @extend ._booting:before, %user-select-none; +} + +._splash-maxcdn { + position: absolute; + bottom: 1.25rem; + left: 50%; + width: 16rem; + margin-left: -8rem; + line-height: 1rem; + color: #bbb; + text-align: center; + + &:hover { color: $linkColor; } + > ._maxcdn-logo-bw { opacity: .2; } + &:hover > ._maxcdn-logo-bw { opacity: .5; } +} + +// +// Intro +// + +._intro { text-align: center; } + +._intro-message { + position: relative; + display: inline-block; + vertical-align: top; + max-width: 37rem; + padding: 1rem 1.25rem; + text-align: left; + @extend %note, %note-green; +} + +._intro-hide { + float: right; + line-height: 1.5rem; + cursor: pointer; +} + +._intro-title { + margin: 0 0 1rem; + font-size: 1rem; + line-height: 1.5rem; +} + +._intro-list { + margin: 1rem 0; + padding-left: 2.25rem; +} + +._intro-link { cursor: pointer; } + +._intro-maxcdn { + position: absolute; + bottom: 1rem; + right: 1rem; + margin: 0; + color: $textColorLight; + + &:hover { color: $linkColor; } +} + +// +// Error +// + +._error { + position: absolute; + top: 50%; + left: 0; + right: 0; + padding: 0 2rem; + line-height: 1.5rem; + text-align: center; +} + +._error-title { + margin: -5.5rem 0 .5rem; + line-height: 2; + font-size: 1.5rem; +} + +._error-text { + margin: 0 0 1rem; + color: $textColorLight; +} + +._error-links { + font-size: 1rem; + font-weight: bold; +} + +._error-link { padding: 0 .5rem; } + +// +// Heading +// + +._lined-heading, +%lined-heading { + white-space: nowrap; + overflow: hidden; + overflow-wrap: normal; + word-wrap: normal; + + &:after { + content: ''; + display: inline-block; + vertical-align: middle; + width: 100%; + height: 1px; + line-height: 0; + margin-left: 1rem; + background: #dde3e8; + } +} + +// +// Table of contents +// + +._toc { + float: right; + max-width: 15em; + margin: .25rem 0 1.5rem 1.5rem; + padding: .75rem 1rem; + @extend %box; + + + ._lined-heading { margin-top: 0; } +} + +._toc-title { + margin: 0 0 .75em; + font-size: inherit; +} + +._toc-list { + margin: 0; + padding: 0 1em 0 0; + list-style: none; +} + +// +// Static page +// + +._static { + padding-bottom: 2em; + + > ._lined-heading:first-child { margin-top: 0; } +} + +// +// Credits table +// + +._credits { + width: 100%; + + td:first-child, td:last-child { white-space: nowrap; } +} + +// +// News +// + +._content { + ._news-row { + position: relative; + padding-left: 10em; + font-size: .8125rem; + color: $textColorLight; + + + ._news-row { margin-top: 1em; } + } + + ._news-title { + display: block; + font-size: .875rem; + color: $textColor; + } + + ._news-date { + position: absolute; + top: 0; + left: 0; + font-size: .875rem; + } +} + +// +// Keyboard shortcuts +// + +._shortcuts-title { + width: 16rem; + max-width: 40%; + margin: 2rem 0 1rem; + font-size: 1rem; + text-align: right; +} + +._shortcuts-dl { margin: 1rem 0; } + +._shortcuts-dt { + float: left; + clear: left; + margin: 0; + width: 16rem; + max-width: 40%; + font-weight: normal; + text-align: right; +} + +._shortcuts-dd { + display: block; + margin: 0 0 .75rem; + padding: 1px 0 1px .75rem; + overflow: hidden; +} + +._shortcut-code { + display: inline-block; + vertical-align: top; + padding: 0 .5em; + @extend %label; +} + +// +// Utilities +// + +._note { @extend %note; } +._note-green { @extend %note-green; } +._label { @extend %label; } +._highlight { background: #fffdcd !important; } + +._github-btn { + display: inline-block; + vertical-align: text-top; + margin-left: .25rem; +} + +%maxcdn-logo { + display: inline-block; + vertical-align: top; + width: 6.25rem; + margin-left: .375rem; + overflow: hidden; + text-indent: -20rem; + background-position: center center; + background-repeat: no-repeat; + background-size: 6.25rem 1rem; +} + +._maxcdn-logo { + background-image: image-url('maxcdn.png'); + @extend %maxcdn-logo; + + @media (-webkit-min-device-pixel-ratio: 1.5), (min-resolution: 144dpi) { + background-image: image-url('maxcdn@2x.png'); + } +} + +._maxcdn-logo-bw { + background-image: image-url('maxcdn-bw.png'); + @extend %maxcdn-logo; + + @media (-webkit-min-device-pixel-ratio: 1.5), (min-resolution: 144dpi) { + background-image: image-url('maxcdn-bw@2x.png'); + } +} diff --git a/assets/stylesheets/components/_fail.scss b/assets/stylesheets/components/_fail.scss new file mode 100644 index 00000000..e74716c0 --- /dev/null +++ b/assets/stylesheets/components/_fail.scss @@ -0,0 +1,35 @@ +._fail { + position: relative; + top: 1.5rem; + width: 24rem; + max-width: 90%; + margin: 0 auto; + padding: 1rem 1.5rem; + background: #eaefef; + border-radius: 5px; + @extend %border-box; + + &:after { // margin + content: ''; + position: relative; + top: 3rem; + float: left; + width: 1px; + height: 1px; + } +} + +._fail-title { + margin: 0 0 1rem; + font-size: 1rem; + font-weight: bold; +} + +._fail-text, ._fail-list { + margin: 0 0 1rem; + font-size: .875rem; +} + +._fail-text:last-child { margin: 0; } + +._fail-link { float: right; } diff --git a/assets/stylesheets/components/_header.scss b/assets/stylesheets/components/_header.scss new file mode 100644 index 00000000..9cd7ec20 --- /dev/null +++ b/assets/stylesheets/components/_header.scss @@ -0,0 +1,157 @@ +// +// Header +// + +._header { + position: absolute; + z-index: $headerZ; + top: 0; + left: 0; + right: 0; + height: $headerHeight; + line-height: $headerHeight; + text-shadow: 0 1px rgba(white, .5); + background: -webkit-linear-gradient(top, #f6f6f8, #e4e4e6); + background: linear-gradient(to bottom, #f6f6f8, #e4e4e6); + box-shadow: inset 0 1px rgba(white, .8), // top inner glow + inset 0 -1px rgba(white, .3); // bottom inner glow + @extend %user-select-none; +} + +// +// Navigation menu +// + +._nav { + float: right; + margin-right: .5rem; + font-size: .875rem; + color: lighten($textColor, 5%); +} + +._nav-link, +._nav-link:hover { + float: left; + padding: 0 1.25rem; + color: inherit; + text-decoration: none; + background-clip: padding-box; + border: solid transparent; + border-width: 0 1px; +} + +._nav-current, +._nav-current:hover { + color: lighten($textColor, 8%); + background: -webkit-linear-gradient(top, #e1e1e4, #f2f2f5); + background: linear-gradient(to bottom, #e1e1e4, #f2f2f5); + border-color: rgba(black, .15); + box-shadow: inset 0 1px rgba(black, .07), // top border + inset 0 1px 2px rgba(black, .1), // top inner shadow + 1px 0 rgba(white, .2), // right glow + -1px 0 rgba(white, .2); // left glow +} + +// +// Logo +// + +._logo { + position: relative; + float: left; + height: $headerHeight; + margin: 0; + line-height: inherit; + font-size: inherit; + + &:before, &:after { // left border + content: ''; + position: absolute; + top: 0; + bottom: 0; + left: -1px; + width: 1px; + background: -webkit-linear-gradient(bottom, #b4b7bf, rgba(#b4b7bf, 0) 80%); + background: linear-gradient(to top, #b4b7bf, rgba(#b4b7bf, 0) 80%); + } + + &:after { // left glow + left: -2px; + background: -webkit-linear-gradient(bottom, rgba(white, .4), rgba(white, 0) 80%); + background: linear-gradient(to top, rgba(white, .4), rgba(white, 0) 80%); + } +} + +// +// Search form +// + +._search { + position: relative; + float: left; + width: $sidebarWidth; + height: 100%; + padding: .625rem; + border-right: 1px solid transparent; + @extend %border-box; + + @media #{$mediumScreen} { width: $sidebarMediumWidth; } + + &:before { + position: absolute; + top: 1rem; + left: 1rem; + opacity: .5; + @extend %icon, %icon-search; + } +} + +._search-input { + display: block; + width: 100%; + height: 100%; + padding: 0 .75rem 1px 1.625rem; + font-size: .875rem; + border: 1px solid; + border-color: #b2b5bb #babbc5 #bebfc6; + border-radius: 1rem; + box-shadow: inset 0 1px 1px rgba(black, .1), // top inner shadow + 0 1px rgba(white, .3); // bottom glow + + &:focus { + outline: 0; + border-color: #35b5f4 #35b5f4 #30aeee; + box-shadow: inset 0 0 1px rgba(#35b5f4, .5), // inner glow + 0 0 2px rgba(#35b5f4, .8); // outer glow + } +} + +._search-clear { + display: none; + position: absolute; + top: .5em; + right: .5em; + padding: .5em; + cursor: pointer; + opacity: .3; + + &:hover { opacity: .5; } + &:before { @extend %icon, %icon-clear; } + ._search-active > & { display: block; } +} + +._search-tag { + display: none; + position: absolute; + top: .875rem; + left: .875rem; + margin: -1px 0 0 -1px; + padding: 0 .625rem; + line-height: 1.25rem; + font-size: .875rem; + background: #dfeafe; + border: 1px solid #98aed8; + border-radius: .75rem; + box-shadow: inset 0 1px rgba(white, .2), // top inner glow + 0 1px rgba(black, .05); // bottom shadow +} diff --git a/assets/stylesheets/components/_mobile.scss b/assets/stylesheets/components/_mobile.scss new file mode 100644 index 00000000..2c0fb0c9 --- /dev/null +++ b/assets/stylesheets/components/_mobile.scss @@ -0,0 +1,201 @@ +// +// Mobile overrides +// + +._mobile { + font-size: 100%; + + // Layout + + body { -ms-overflow-style: -ms-autohiding-scrollbar; } + + ._app, ._container, ._content { overflow: visible; } + + ._container { + margin: 0; + border: 0; + box-shadow: none; + } + + ._content { + position: static; + height: auto; + margin: 0; + padding: .75rem 1rem 2.5rem; + } + + ._booting:before, ._content-loading:before { + font-size: 3rem; + color: #ccc; + } + + // Header + + ._header { + position: fixed; + z-index: $contentZ + 1; + border-bottom: 1px solid #b4b7bf; + box-shadow: 0 1px rgba(black, .03); + } + + ._logo, ._nav { display: none; } + ._home-link, ._menu-link { display: block; } + + ._search { + float: none; + width: auto; + overflow: hidden; + padding-left: 0; + padding-right: 0; + border-right: 0; + + &:before { left: .5rem; } + } + + ._search-clear { padding-right: 0; } + ._search-tag { left: .325rem; } + + // Sidebar + + ._sidebar { + position: static; + min-height: 100%; + overflow: visible; + padding-bottom: 2rem; + box-shadow: none; + + > ._list { padding-bottom: 0; } + } + + ._list, ._sidebar-footer { + width: 100%; + box-shadow: none; + } + + ._list-item { border-right-width: 0; } + + ._list-link { display: none; } + + ._sidebar-footer { + position: static; + margin-top: .5rem; + font-weight: bold; + + &:before { content: none; } + } + + ._sidebar-footer-save { + margin-top: 1rem; + border-bottom: 1px solid #bac6d7; + } + + // Notice + + ._notice { + position: fixed; + left: 0; + padding: 0 .5rem; + + &:before { content: none; } + ~ ._sidebar { padding-bottom: 4rem; } + } + + ._notice-text { font-size: .75em; } + + // Notification + + ._notif { position: fixed; } + + // Table of contents + + ._toc { + float: none; + max-width: none; + margin-left: 0; + } + + // Splash + + ._splash-title { margin-top: -.5em; } +} + +// +// Fix viewport on Windows Phone +// + +@-ms-viewport { width: device-width; } +@media (orientation: portrait) and (min-device-width: 720px) and (max-device-width: 768px), + (orientation: landscape) and (device-width: 1280px) and (max-device-height: 768px) { + @-ms-viewport { width: 50%; } +} + +// +// Header buttons +// + +%mobile-link { + display: none; + position: relative; + float: left; + width: 2.5rem; + height: 100%; + + &:before { + position: absolute; + top: 50%; + left: 50%; + margin: -.5rem 0 0 -.5rem; + @extend %icon; + } +} + +._home-link { + @extend %mobile-link; + + &:before { @extend %icon-home; } +} + +._menu-link { + float: right; + @extend %mobile-link; + + &:before { @extend %icon-menu; } +} + +// +// Navigation menu +// + +._mobile-nav { + margin: .25rem 0 1.25rem; + padding: 0; + line-height: 2.8; + overflow: hidden; + @extend %box; +} + +._mobile-nav-link { + float: left; + width: 33%; + text-align: center; + font-weight: bold; + + &:nth-child(2n) { width: 34%; } +} + +// +// Intro +// + +._mobile-intro { + > ._intro-list { padding-left: 1.5rem; } + + > ._intro-hide, + > ._intro-maxcdn { + position: static; + float: none; + display: block; + margin-top: 1.25rem; + text-align: center; + } +} diff --git a/assets/stylesheets/components/_notice.scss b/assets/stylesheets/components/_notice.scss new file mode 100644 index 00000000..57998921 --- /dev/null +++ b/assets/stylesheets/components/_notice.scss @@ -0,0 +1,41 @@ +._notice { + position: absolute; + z-index: $noticeZ; + bottom: 0; + left: $sidebarWidth; + right: 0; + height: 2.5rem; + padding: 0 1.25rem; + text-shadow: 0 1px rgba(white, .5); + background: #faf9e2; + box-shadow: inset 0 1px #dddaaa, // top border + inset 0 2px rgba(white, .7), // top inner glow + inset 1px 0 rgba(black, .03); // left inner shadow + + @media #{$mediumScreen} { left: $sidebarMediumWidth; } + + ~ ._container { padding-bottom: 2.5rem; } + + &:before { + content: ''; + position: absolute; + bottom: 100%; + left: 1.5rem; + right: 1.5rem; + height: 1.5rem; + background-image: -webkit-linear-gradient(top, rgba(white, 0), rgba(white, .95)); + background-image: linear-gradient(to bottom, rgba(white, 0), rgba(white, .95)); + pointer-events: none; + } +} + +._notice-text { + display: table-cell; + vertical-align: middle; + margin: 0; + height: 2.5rem; + line-height: 1rem; + font-size: .875rem; +} + +._notice-link { cursor: pointer; } diff --git a/assets/stylesheets/components/_notif.scss b/assets/stylesheets/components/_notif.scss new file mode 100644 index 00000000..ef0af6f8 --- /dev/null +++ b/assets/stylesheets/components/_notif.scss @@ -0,0 +1,97 @@ +._notif { + position: absolute; + z-index: 2; + top: 1rem; + right: 1rem; + width: 25rem; + max-width: 90%; + padding: .75rem 1rem; + font-size: .75rem; + color: white; + text-shadow: 0 1px 1px rgba(black, .4); + background: -webkit-linear-gradient(top, rgba(#3a3a3a, .9), rgba(#202020, .9)); + background: linear-gradient(to bottom, rgba(#3a3a3a, .9), rgba(#202020, .9)); + background-clip: padding-box; + border: 1px solid black; + border-radius: .25rem; + box-shadow: inset 0 1px rgba(white, .1), // top inner glow + inset 0 0 0 1px rgba(white, .1), // inner glow + 0 1px 3px rgba(black, .5); // drop shadow + transition: opacity .2s; + opacity: 0; + cursor: default; + @extend %border-box, %user-select-none; + + &._in { opacity: 1; } +} + +._notif-title { + margin: 0 0 .375rem; + line-height: 1rem; + font-size: inherit; +} + +._notif-text { margin-bottom: 0; } + +._notif-link, +._notif-link:hover { + color: inherit; + text-decoration: underline; +} + +._notif-close { + position: absolute; + top: 0; + right: 0; + padding: .625rem; + opacity: .9; + cursor: pointer; + + &:before { @extend %icon, %icon-close-white; } +} + +._notif-news { + width: 20rem; + max-height: 85%; + overflow-y: auto; + + > ._notif-title { + margin: -.125rem 0 1em; + text-align: center; + } + + > ._news-row { + line-height: 1.125rem; + font-size: .6875rem; + color: #bbb; + + + ._news-row { margin-top: .75rem; } + } + + ._news-title { + display: block; + margin-bottom: .25rem; + font-size: .75rem; + font-weight: normal; + color: white; + } + + ._news-date { + float: right; + margin-left: 1rem; + font-weight: bold; + } + + code { + display: inline-block; + vertical-align: baseline; + line-height: 0; + margin: 0; + padding: 0; + color: inherit; + text-shadow: inherit; + background: none; + border: 0; + box-shadow: none; + } +} diff --git a/assets/stylesheets/components/_page.scss b/assets/stylesheets/components/_page.scss new file mode 100644 index 00000000..564defe4 --- /dev/null +++ b/assets/stylesheets/components/_page.scss @@ -0,0 +1,58 @@ +// +// Page +// + +._page { + > h1 { @extend ._lined-heading; } + > h1:first-child { margin-top: 0; } + + a[href^="http:"], a[href^="https:"] { @extend %external-link; } + + a:not([href]) { + color: inherit; + text-decoration: none; + } + + iframe { + display: block; + padding: 1px; + border: 1px dotted #ddd; + border-radius: 3px; + } +} + +// +// Attribution box +// + +._attribution { + clear: both; + margin: 2rem 0 1.5rem; + font-size: .75rem; + color: $textColorLight; + text-align: center; + -webkit-font-smoothing: subpixel-antialiased; + + & + & { margin-top: 1.5rem; } + & + & > ._attribution-link { display: none; } +} + +._attribution-p { + display: inline-block; + margin: 0; + padding: .25rem .75rem; + text-shadow: 0 1px rgba(white, .3); + background: #f2f2f2; + border-radius: 3px; +} + +._attribution-link { @extend %internal-link; } + +// +// Entry list +// + +._entry-list { + padding-left: 1em; + list-style: none; +} diff --git a/assets/stylesheets/components/_prism.scss b/assets/stylesheets/components/_prism.scss new file mode 100644 index 00000000..13108e85 --- /dev/null +++ b/assets/stylesheets/components/_prism.scss @@ -0,0 +1,52 @@ +.token.comment, +.token.prolog, +.token.doctype, +.token.cdata, +.token.punctuation { + color: $textColorLight; +} + +.namespace { + opacity: .7; +} + +.token.property, +.token.tag, +.token.boolean, +.token.number { + color: #905; +} + +.token.selector, +.token.attr-name, +.token.string { + color: #5e8e01; +} + +.token.operator, +.token.entity, +.token.url, +.language-css .token.string, +.style .token.string { + color: #a67f59; + background: hsla(0, 0%, 100%, .5); +} + +.token.atrule, +.token.attr-value, +.token.keyword { + color: #0070a3; +} + +.token.regex, +.token.important { + color: #e90; +} + +.token.important { + font-weight: bold; +} + +.token.entity { + cursor: help; +} diff --git a/assets/stylesheets/components/_sidebar.scss b/assets/stylesheets/components/_sidebar.scss new file mode 100644 index 00000000..4bf3f743 --- /dev/null +++ b/assets/stylesheets/components/_sidebar.scss @@ -0,0 +1,285 @@ +// +// Sidebar +// + +._sidebar { + position: absolute; + z-index: $sidebarZ; + top: $headerHeight; + bottom: 0; + left: 0; + overflow-x: hidden; + overflow-y: scroll; + text-shadow: 0 1px rgba(white, .3); + background: #e5eaf4; + box-shadow: inset 0 1px #b4b7bf, // top border + inset 0 2px rgba(black, .03); // top inner shadow + -webkit-overflow-scrolling: touch; + -ms-overflow-style: none; // IE 10 doesn't support pointer-events + @extend %user-select-none; + + &::-webkit-scrollbar { width: 10px; } + &::-webkit-scrollbar-button { display: none; } + &::-webkit-scrollbar-track { background: white; } + &::-webkit-scrollbar-thumb { + min-height: 1rem; + background: #ccc; + background-clip: padding-box; + border: 3px solid white; + border-radius: 5px; + + &:active { + background-color: #999; + border-width: 2px; + } + } +} + +// +// List +// + +._list { + margin: 0; + padding: 0; + list-style: none; + width: $sidebarWidth; + + @media #{$mediumScreen} { width: $sidebarMediumWidth; } + + ._sidebar > & { + min-height: 100%; + padding-bottom: 3.5rem; + box-shadow: inset -1px 0 #bbc1cc, // right border + inset -2px 0 rgba(white, .2); // right inner glow + @extend %border-box; + } +} + +._list-item { + display: block; + position: relative; + overflow: hidden; + padding: 0 .75rem; + line-height: 1.75rem; + font-size: .875rem; + white-space: nowrap; + text-overflow: ellipsis; + border: solid transparent; + border-width: 1px 1px 1px 0; + cursor: default; + + &, &:hover { + color: inherit; + text-decoration: none; + } + + &.focus, + &.focus:hover, + &.active, + &.active:hover { + color: white; + text-shadow: 0 1px rgba(black, .2); + background: -webkit-linear-gradient(top, #bfc6dd, #949eb8); + background: linear-gradient(to bottom, #bfc6dd, #949eb8); + border-color: #96a1c6 #8e99b7 #7f87a4; + box-shadow: inset 0 1px rgba(white, .15), // top inner glow + inset 0 0 0 1px rgba(white, .08), // inner glow + 0 1px rgba(black, .05); // drop shadow + } + + &.active, + &.active:hover { + background: -webkit-linear-gradient(top, #65b2fb, #3492e9); + background: linear-gradient(to bottom, #65b2fb, #3492e9); + border-color: #318ce4 #2b82db #2878c7; + } + + &:before { + float: left; + margin: .375rem .625rem 0 0; + @extend %icon; + } +} + +._list-count { + float: right; + font-size: .75rem; + color: $textColorLighter; + pointer-events: none; + + .focus > &, + .active > & { + color: inherit; + } +} + +// +// List hierarchy +// + +._list-dir, +%_list-dir { + line-height: 2rem; + padding-left: 2.25rem; + + &:before { margin-top: .5rem; } +} + +._list-disabled { + @extend %_list-dir; + + &, &:hover { color: $textColorLight; } + &:before { opacity: .7; } +} + +._list-arrow { + position: absolute; + top: 0; + left: .25rem; + padding: .5rem; + cursor: pointer; + opacity: .45; + + &:hover { opacity: .7; } + + &:before { + @extend %icon, %icon-dir; + + .open > & { + -webkit-transform: rotate(90deg); + transform: rotate(90deg); + } + } +} + +._list-sub { + > ._list-item { padding-left: 2.75rem; } + > ._list-item:before { content: none; } + > ._list-dir { line-height: 1.75rem; } + + ._list-arrow { + left: 1rem; + padding: .375rem; + } +} + +// +// List pagination +// + +._list-pagelink { + color: $linkColor; + cursor: pointer; + + &:hover { + color: $linkColorHover; + text-decoration: underline; + } +} + +// +// List picker +// + +._list-checkbox { + position: absolute; + top: .5rem; + right: -1rem; + width: 1rem; + height: 1rem; + transition: .2s; +} + +._list-label { + transition: .2s; + @extend %_list-dir; + + ._in > & { padding-left: .75rem; } + ._in > & > ._list-checkbox { right: .5rem } +} + +._list-label-off { + opacity: 0; + padding-left: .75rem; + + ._in > & { opacity: 1; } + > ._list-checkbox { right: .5rem; } +} + +._list-link { + display: block; + margin-top: .75rem; + font-size: .8125rem; + text-align: center; + @extend %external-link; + + &:after { visibility: hidden; } + &:hover:after { visibility: visible; } +} + +// +// Footer +// + +._sidebar-footer { + position: fixed; + bottom: 0; + left: 0; + width: $sidebarWidth; + background: #e5eaf4; + box-shadow: inset -1px 0 #bbc1cc, // right border + inset -2px 0 rgba(white, .2); // right inner glow + + @media #{$mediumScreen} { width: $sidebarMediumWidth; } + + &:before { + content: ''; + position: absolute; + bottom: 100%; + left: 0; + right: 1px; + height: 1em; + background-image: -webkit-linear-gradient(top, rgba(#e5eaf4, 0), rgba(#e5eaf4, .95)); + background-image: linear-gradient(to bottom, rgba(#e5eaf4, 0), rgba(#e5eaf4, .95)); + pointer-events: none; + } +} + +._sidebar-footer-link { + position: relative; + display: block; + height: 2.5rem; + line-height: 1rem; + padding: .75rem; + font-size: .875em; + cursor: pointer; + @extend %border-box; + + &, &:hover { + color: inherit; + text-decoration: none; + } + + &:before { + float: left; + margin-right: .625rem; + @extend %icon; + } +} + +._sidebar-footer-edit { + &:before { @extend %icon-settings; } +} + +._sidebar-footer-save { + margin-right: 1px; + font-weight: bold; + background-image: -webkit-linear-gradient(top, #fbfbfe, #f5f5f8 50%, #eeeef1 51%, #e8e8ec); + background-image: linear-gradient(to bottom, #fbfbfe, #f5f5f8 50%, #eeeef1 51%, #e8e8ec); + box-shadow: inset 0 1px white, // top inner glow + inset 0 0 0 1px rgba(white, .2), // inner glow + 0 -1px #c4cfde; // top border + + &:before { @extend %icon-check; } +} diff --git a/assets/stylesheets/global/_base.scss b/assets/stylesheets/global/_base.scss new file mode 100644 index 00000000..cc1519fa --- /dev/null +++ b/assets/stylesheets/global/_base.scss @@ -0,0 +1,183 @@ +html { + height: 100%; + font-size: 100%; + background: white; + + @media #{$mediumScreen} { font-size: 93.75%; } +} + +body { + margin: 0; + height: 100%; + font: normal 1em/1.7 $baseFont; + color: $textColor; + overflow-wrap: break-word; + word-wrap: break-word; + -webkit-tap-highlight-color: rgba(black, 0); + -webkit-touch-callout: none; + -webkit-text-size-adjust: 100%; + -ms-text-size-adjust: 100%; +} + +a { + color: $linkColor; + text-decoration: none; + + &:hover { + color: $linkColorHover; + text-decoration: underline; + } + + &:focus { outline: 0; } +} + +img { + max-width: 100%; + height: auto; + border: 0; +} + +h1, h2, h3, h4, h5, h6 { + margin: 1.5em 0 1em; + line-height: 1.3; + font-weight: bold; +} + +h1 { font-size: 1.5em; } +h2 { font-size: 1.375em; } +h3 { font-size: 1.25em; } +h4 { font-size: 1.125em; } +h5 { font-size: 1em; } +h6 { font-size: .9375em; } + +p { margin: 0 0 1em; } +p:last-child { margin-bottom: 0; } + +b, strong { font-weight: bold; } + +small { font-size: .9em; } + +ul, ol { + margin: 1.5em 0; + padding: 0 0 0 2em; + list-style: disc outside; +} + +ul ul { list-style-type: circle; } +ol { list-style-type: decimal; } +ol ol { list-style-type: lower-alpha; } +ol ol ol { list-style-type: lower-roman; } + +li + li { margin-top: .25em; } +li > ul, li > ol, dd > ul, dd > ol { margin: .5em 0; } + +dl { margin: 1.5em 0; } +dt { font-weight: bold; } +dd { + margin: .375em; + padding-left: 1em; + + + dt { margin-top: 1em; } +} + +dfn, var { font-style: normal; } + +abbr, acronym, dfn { + cursor: help; + border-bottom: 1px dotted $textColor; +} + +pre, code, samp { + font-family: $monoFont; + font-weight: normal; + font-style: normal; + color: $textColor; + white-space: pre-wrap; + -moz-tab-size: 2; + -o-tab-size: 2; + tab-size: 2; +} + +pre { + margin: 1.5em 0; + padding: .375rem .75rem; + line-height: 1.5; + overflow: auto; + font-size: .9em; + @extend %box; +} + +a > code { color: inherit; } + +table { + margin: 1.5em 0; + background: none; + border: 1px solid #d5d5d5; + border-collapse: separate; + border-spacing: 0; + border-radius: 3px; + box-shadow: 0 1px 1px rgba(black, .04); +} + +th, td { + vertical-align: top; + padding: .3em .7em; + padding-bottom: -webkit-calc(.3em + 1px); + padding-bottom: calc(.3em + 1px); + text-align: left; +} + +th { + border: 0; + border-bottom: 1px solid #d5d5d5; + border-radius: 0; + @extend %heading-box; + + &:empty { + background: none; + box-shadow: none; + } + + + th { border-left: 1px solid rgba(black, .12); } + + td { border-left: 1px solid #d5d5d5; } + + tr:first-child > &:first-child { border-top-left-radius: 3px; } + tr:first-child > &:last-child { border-top-right-radius: 3px; } + tr:last-child > &:first-child { border-bottom-left-radius: 3px; } + thead > tr:last-child > &:first-child { border-bottom-left-radius: 0; } + tr:last-child > & { border-bottom-width: 0; } + thead > tr:last-child > & { border-bottom-width: 1px; } +} + +td { + border-bottom: 1px solid #e5e5e5; + + + td { border-left: 1px solid #e5e5e5; } + tr:last-child > & { border-bottom: 0; } +} + +input { + margin: 0; + font-family: inherit; + font-size: 100%; + line-height: normal; + @extend %border-box; +} + +input[type="search"] { -webkit-appearance: textfield; } + +input[type="search"]::-webkit-search-cancel-button, +input[type="search"]::-webkit-search-decoration { + -webkit-appearance: none; +} + +::-ms-clear { display: none; } + +::-moz-focus-inner { + padding: 0 !important; + border: 0 !important; +} + +::-webkit-input-placeholder { color: #aaa; } +::-moz-placeholder { color: #aaa; opacity: 1; } +:-ms-input-placeholder { color: #aaa; } diff --git a/assets/stylesheets/global/_classes.scss b/assets/stylesheets/global/_classes.scss new file mode 100644 index 00000000..e41384c4 --- /dev/null +++ b/assets/stylesheets/global/_classes.scss @@ -0,0 +1,143 @@ +// +// Utilities +// + +%border-box { + -moz-box-sizing: border-box; + box-sizing: border-box; +} + +%user-select-none { + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} + +// +// Boxes +// + +%box { + text-shadow: 0 1px rgba(white, .3); + background: #f8f8f8; + border: 1px solid; + border-color: #d5d5d5 #d5d5d5 #d1d1d1; + border-radius: 3px; + box-shadow: inset 0 1px rgba(white, .3), // top inner glow + 0 1px 1px rgba(black, .04); // drop shadow +} + +%heading-box { + text-shadow: 0 1px rgba(white, .3); + background: -webkit-linear-gradient(top, #f7f7fa, #f0f0f2); + background: linear-gradient(to bottom, #f7f7fa, #f0f0f2); + border: 1px solid; + border-color: #d5d5d5 #d5d5d5 #d1d1d1; + border-radius: 3px; + box-shadow: inset 0 1px rgba(white, .3), // top inner glow + inset 0 0 0 1px rgba(white, .2), // inner glow + 0 1px 1px rgba(black, .04); // drop shadow +} + +%block-heading { + line-height: 1.25rem; + margin: 2em 0 1em; + padding: .5em .75em; + font-size: 1rem; + overflow: hidden; + @extend %heading-box; +} + +// +// Notes +// + +%note { + margin: 1.5rem 0; + padding: .5rem .875rem; + text-shadow: 0 1px rgba(white, .3); + background: #faf9e2; + border: 1px solid; + border-color: #dddaaa #dddaaa #d7d7a9; + border-radius: 3px; + box-shadow: inset 0 1px rgba(white, .2), // top inner glow + 0 1px 1px rgba(black, .04); // drop shadow +} + +%note-green { + background: #f1faeb; + border-color: #b3dba8 #b3dba8 #aed7a5; +} + +%note-blue { + background: #f2f2ff; + border-color: #c6cde9 #c6cde9 #c3cce7; +} + +%note-gold { + background: #fff0aa; + border-color: #ddce81 #ddce81 #d9ca7f; +} + +%note-red { + background: #fed5d3; + border-color: #dc7874 #dc7874 #d47474; +} + +// +// Labels +// + +%label { + margin: 0 1px; + padding: 0 .3em 1px; + text-shadow: 0 1px rgba(white, .3); + background: #f2f2f2; + border: 1px solid; + border-color: #d2d2d2 #d2d2d2 #ccc; + border-radius: 2px; + box-shadow: 0 1px rgba(black, .04); +} + +%block-label { + display: block; + margin: 2em 0 1em; + padding-left: .5em; + padding-right: .5em; + line-height: inherit; + font-size: inherit; + @extend %label; +} + +%label-blue { + background: #d5daff; + border-color: #a3a4d4; +} + +%label-yellow { + background: #ffdfb2; + border-color: #c2a16f; +} + +%label-red { + background: #fed5d3; + border-color: #dc7874; +} + +// +// External links +// + +%external-link { + &:after { + display: inline-block; + width: .5rem; + height: .4375rem; + margin: .125rem 0 0 .0625rem; + vertical-align: top; + @extend %icon, %icon-link; + } +} + +%internal-link:after { content: none !important; } diff --git a/assets/stylesheets/global/_icons.scss b/assets/stylesheets/global/_icons.scss new file mode 100644 index 00000000..b2417253 --- /dev/null +++ b/assets/stylesheets/global/_icons.scss @@ -0,0 +1,41 @@ +%icon { + content: ''; + display: block; + width: 1rem; + height: 1rem; + background-image: image-url('icons.png'); + background-size: 5rem 6rem; +} + +@media (-webkit-min-device-pixel-ratio: 1.5), (min-resolution: 144dpi) { + %icon { background-image: image-url('icons@2x.png'); } +} + +%icon-dir { background-position: 0 0; } +%icon-search { background-position: -1rem 0; } +%icon-link { background-position: -2.25rem -.25rem; } +%icon-clear { background-position: -3rem 0; } +%icon-close-white { background-position: -4rem 0; } +%icon-settings { background-position: 0 -1rem; } +%icon-check { background-position: -1rem -1rem; } +._icon-http:before { background-position: -2rem -1rem; } +._icon-jquery:before { background-position: -3rem -1rem; } +._icon-underscore:before { background-position: -4rem -1rem; } +._icon-html:before { background-position: 0 -2rem; } +._icon-css:before { background-position: -1rem -2rem; } +._icon-dom:before { background-position: -2rem -2rem; } +._icon-dom_events:before { background-position: -3rem -2rem; } +._icon-javascript:before { background-position: -4rem -2rem; } +._icon-backbone:before { background-position: 0 -3rem; } +._icon-node:before { background-position: -1rem -3rem; } +._icon-sass:before { background-position: -2rem -3rem; } +._icon-less:before { background-position: -3rem -3rem; } +._icon-angular:before { background-position: -4rem -3rem; } +._icon-coffeescript:before { background-position: 0 -4rem; } +._icon-ember:before { background-position: -1rem -4rem; } +%icon-menu { background-position: -2rem -4rem; } +%icon-home { background-position: -3rem -4rem; } +._icon-jqueryui:before { background-position: -4rem -4rem; } +._icon-jquerymobile:before { background-position: 0 -5rem; } +._icon-lodash:before { background-position: -1rem -5rem; } +._icon-php:before { background-position: -2rem -5rem; } diff --git a/assets/stylesheets/global/_variables.scss b/assets/stylesheets/global/_variables.scss new file mode 100644 index 00000000..57b74412 --- /dev/null +++ b/assets/stylesheets/global/_variables.scss @@ -0,0 +1,21 @@ +$baseFont: 'Open Sans', Helvetica, Arial, sans-serif; +$monoFont: 'Source Code Pro', 'Inconsolata-g', Consolas, Menlo, monospace; + +$textColor: #39393e; +$textColorLight: #6b6f78; +$textColorLighter: #9498a6; + +$linkColor: #0082c6; +$linkColorHover: #0072c5; + +$headerHeight: 3rem; + +$sidebarWidth: 18rem; +$sidebarMediumWidth: 16rem; + +$mediumScreen: '(max-width: 800px)'; + +$headerZ: 1; +$sidebarZ: 2; +$contentZ: 3; +$noticeZ: 4; diff --git a/assets/stylesheets/pages/_angular.scss b/assets/stylesheets/pages/_angular.scss new file mode 100644 index 00000000..dadc48a7 --- /dev/null +++ b/assets/stylesheets/pages/_angular.scss @@ -0,0 +1,17 @@ +._angular { + > h2 { font-size: 1.125rem; } + h3, h4 { font-size: 1rem; } + + .methods { + padding-left: 1rem; + list-style: none; + + > li > h3:first-child { + margin: 0 0 1em -1rem; + @extend %block-label, %label-blue; + } + + > li + li { margin-top: 2em; } + > li > ul { list-style-type: disc; } + } +} diff --git a/assets/stylesheets/pages/_coffeescript.scss b/assets/stylesheets/pages/_coffeescript.scss new file mode 100644 index 00000000..99f7a5ef --- /dev/null +++ b/assets/stylesheets/pages/_coffeescript.scss @@ -0,0 +1,23 @@ +._coffeescript { + padding-left: 1rem; + + > h1, > h2 { margin-left: -1rem; } + > h2 { @extend %block-heading; } + + code { @extend %label; } + + // CoffeeScript / JavaScript code blocks + > .code { + margin: 1.5em 0; + overflow: hidden; + + > pre { + float: left; + width: 49%; + margin: 0; + @extend %border-box; + + &:last-child { float: right; } + } + } +} diff --git a/assets/stylesheets/pages/_ember.scss b/assets/stylesheets/pages/_ember.scss new file mode 100644 index 00000000..cea85417 --- /dev/null +++ b/assets/stylesheets/pages/_ember.scss @@ -0,0 +1,53 @@ +._ember { + > .class-info { @extend %note, %note-blue; } + > .class-info > p { margin: 0; } + + > .description > h2, > .description > h3 { font-size: 1rem; } + + .item-entry { padding-left: 1rem; } + + .title { + margin-left: -1rem; + @extend %block-heading; + + > h2, > .args, > .flag { + display: inline-block; + vertical-align: top; + margin: 0; + line-height: inherit; + font-size: inherit; + } + + > .flag { // "static" + margin-left: .5em; + color: $textColorLight; + } + + > .type { + float: right; + font-weight: normal; + } + } + + .meta { // "defined in" + color: $textColorLight; + margin-bottom: 1em; + } + + .return, .params { + margin-top: 1.5em; + + > h3 { + display: inline-block; + vertical-align: top; + margin: 0 0 1em; + font-size: inherit; + @extend %label, %label-blue; + } + } + + dl { margin: 0 1em; } + dt + dt, dd + dt { margin-top: .5em; } + + p > code { @extend %label; } +} diff --git a/assets/stylesheets/pages/_jquery.scss b/assets/stylesheets/pages/_jquery.scss new file mode 100644 index 00000000..76a44e58 --- /dev/null +++ b/assets/stylesheets/pages/_jquery.scss @@ -0,0 +1,137 @@ +._jquery { + // + // Index page + // + + h2.entry-title { + margin: 0 0 1.5rem; + font-size: 1rem; + font-weight: normal; + } + + .entry-summary { + margin: -1rem 0 1.5rem; + padding-right: 1.5rem; + } + + .post:not(:only-of-type), + .page:not(:only-of-type) { + width: 50%; + float: left; + + &:nth-of-type(2n+1) { clear: both; } + } + + // + // Article page + // + + // Table of contents + + .toc > h4 { font-size: inherit; } + + .toc-list { + margin-top: 0; + font-weight: bold; + + > li + li { margin-top: 1em; } + > li > ul { font-weight: normal; } + > li > ul > li + li { margin-top: 0; } + } + + // Headings + + .section-title, .entry-wrapper > h3, .underline { @extend %block-heading; } + + .name > .version-details, + .section-title > .version-details, + .returns, + .option-type { + float: right; + font-weight: bold; + margin-left: 1em; + } + + // Method signatures + + .signatures { + padding: 0; + list-style: none; + } + + .signature { + + .signature { margin-top: 1em; } + + > .name { + margin-top: 1em; + @extend %block-label, %label-blue; + } + + > ul { + padding-left: 1em; + list-style: none; + + > li + li { margin-top: 1em; } + > li > ul { list-style-type: disc; } + } + } + + // Examples + + .entry-example { + > h4 { + margin: 2em 0 1.5em; + line-height: inherit; + font-size: inherit; + font-weight: normal; + } + } + + // Quick nav (jQuery UI) + + #quick-nav { + margin-bottom: 2em; + max-width: 38em; + overflow: hidden; + @extend %note, %note-blue; + + > h2 { + margin: .25rem 0 1rem; + font-size: 1rem; + + > a { float: right; } + } + } + + .quick-nav-section { + width: 33%; + float: left; + + > h3 { + margin: 0 0 .5em; + font-size: inherit; + } + } + + // Options (jQuery UI) + + .api-item { + padding-left: 1rem; + + > h3 { + margin-left: -1rem; + @extend %block-label, %label-blue; + } + } + + // Misc + + p > code, li > code { @extend %label; } + .warning { @extend %note; } + + .name > a, + .version-details > a { + color: inherit; + @extend %internal-link; + } +} diff --git a/assets/stylesheets/pages/_less.scss b/assets/stylesheets/pages/_less.scss new file mode 100644 index 00000000..3baa31b2 --- /dev/null +++ b/assets/stylesheets/pages/_less.scss @@ -0,0 +1,12 @@ +._less { + padding-left: 1rem; + + > h1, > h2, > h3 { margin-left: -1rem; } + > h2, > h3 { @extend %block-heading; } + > h4 { margin-top: 2em; } + + > .function { + margin-left: -1rem; + @extend %block-label, %label-blue; + } +} diff --git a/assets/stylesheets/pages/_lodash.scss b/assets/stylesheets/pages/_lodash.scss new file mode 100644 index 00000000..0bc986dc --- /dev/null +++ b/assets/stylesheets/pages/_lodash.scss @@ -0,0 +1,8 @@ +._lodash { + padding-left: 1rem; + + h1, h2 { margin-left: -1rem; } + h2 { @extend %block-heading; } + h3 { @extend %block-label, %label-blue; } + h4 { font-size: inherit; } +} diff --git a/assets/stylesheets/pages/_mdn.scss b/assets/stylesheets/pages/_mdn.scss new file mode 100644 index 00000000..b92b45c1 --- /dev/null +++ b/assets/stylesheets/pages/_mdn.scss @@ -0,0 +1,94 @@ +._mdn { + .index { // HTML, CSS + -webkit-columns: 16em; + -moz-columns: 16em; + columns: 16em; + + > span { + display: block; + font-size: 1rem; + font-weight: bold; + } + + ul, ol { + margin: 0 0 1em; + padding: 0; + line-height: 1.5; + list-style: none; + } + + li { padding-left: 1em; } + } + + > h2 { @extend %block-heading; } + + > .note, + .notice, + .overheadIndicator, + .syntaxbox, // CSS, JavaScript + .twopartsyntaxbox, // CSS + .inheritsbox, // JavaScript + .eval:first-of-type { // JavaScript + @extend %note; + } + + > .note { + em { + font-style: normal; + font-weight: bold; + } + + > ul { margin: 1em 0; } + > p:last-child, > ul:last-child { margin-bottom: 0; } + } + + .inlineIndicator { + white-space: nowrap; + @extend %label; + } + + .syntaxbox a, // CSS + .twopartsyntaxbox a, // CSS + .inlineIndicator > a { + color: inherit; + @extend %internal-link; + } + + .deprecated, .obsolete { @extend %label-red; } + .nonStandard, .projectSpecific, .experimental { @extend %label-yellow; } + + .htmlelt, + .cssprop { + display: table; + @extend %note, %note-blue; + + > li { + display: table-row; + margin: 0; + + > dfn { + display: table-cell; + padding: .125rem 1.5rem .125rem 0; + white-space: pre; + border: 0; + cursor: inherit; + + &:after { content: ':'; } + } + } + } + + dt > strong > code, // HTML element attribute + dl > dt > code { // CSS property value, Javascript function argument + font-family: inherit; + font-weight: bold; + } + + .eventinfo { // DOM event + > dd + dt { margin-top: 0; } + } + + .cleared { clear: both; } // CSS/box-shadow + + code > strong { font-weight: normal; } +} diff --git a/assets/stylesheets/pages/_node.scss b/assets/stylesheets/pages/_node.scss new file mode 100644 index 00000000..c01c2c8f --- /dev/null +++ b/assets/stylesheets/pages/_node.scss @@ -0,0 +1,20 @@ +._node { + .api_stability_0, .api_stability_1 { @extend %note, %note-red; } + .api_stability_2 { @extend %note, %note-gold; } + .api_stability_3, .api_stability_4 { @extend %note, %note-green; } + .api_stability_5 { @extend %note, %note-blue; } + + > h2 { @extend %block-heading; } + + > h3 { + margin: 2rem 0 1rem; + font-size: 1rem; + } + + > h2 + h2, > h3 + h3 { margin-top: 0; } + + > p > code, .type { + white-space: normal; + @extend %label; + } +} diff --git a/assets/stylesheets/pages/_php.scss b/assets/stylesheets/pages/_php.scss new file mode 100644 index 00000000..7ea8d4d4 --- /dev/null +++ b/assets/stylesheets/pages/_php.scss @@ -0,0 +1,33 @@ +._php { + h1 { + margin-top: 0; + @extend %lined-heading; + } + + h3.title { @extend %block-heading; } + + .verinfo { + float: right; + font-weight: bold; + } + + .classsynopsis, + .description > .constructorsynopsis, + .description > .methodsynopsis, + .description > .fieldsynopsis { @extend %note, %note-blue; } + + .classsynopsisinfo_comment { color: $textColorLight; } + + .classsynopsisinfo_comment, + .classsynopsis > .constructorsynopsis, + .classsynopsis > .methodsynopsis, + .classsynopsis > .fieldsynopsis { margin-left: 1em; } + + .phpcode > pre { white-space: normal; } + + blockquote.note { @extend %note; } + blockquote.note > p { margin-bottom: 0; } + + div.warning { @extend %note, %note-red; } + div.tip { @extend %note, %note-green; } +} diff --git a/assets/stylesheets/pages/_rfc.scss b/assets/stylesheets/pages/_rfc.scss new file mode 100644 index 00000000..ac2bd42d --- /dev/null +++ b/assets/stylesheets/pages/_rfc.scss @@ -0,0 +1,6 @@ +._rfc { + padding-left: 1rem; + + > h1, > h2 { margin-left: -1rem; } + > h2 { @extend %block-heading; } +} diff --git a/assets/stylesheets/pages/_underscore.scss b/assets/stylesheets/pages/_underscore.scss new file mode 100644 index 00000000..f592e390 --- /dev/null +++ b/assets/stylesheets/pages/_underscore.scss @@ -0,0 +1,25 @@ +._underscore { + padding-left: 1rem; + + > h1, > h2, .header { margin-left: -1rem; } + > h2 { @extend %block-heading; } + + .header { + display: inline-block; + vertical-align: top; + margin-bottom: 1em; + } + + > p[id] { margin-top: 2rem; } + > pre { margin-top: 1em; } + + .header + code { + margin-left: 1em; + @extend %label; + } + + .alias { + margin-left: 1em; + font-style: italic; + } +} diff --git a/assets/stylesheets/pages/_yard.scss b/assets/stylesheets/pages/_yard.scss new file mode 100644 index 00000000..6571ede7 --- /dev/null +++ b/assets/stylesheets/pages/_yard.scss @@ -0,0 +1,14 @@ +._yard { + > h2 { @extend %block-heading; } + .signature { @extend %block-label, %label-blue; } + + > .method_details { + padding-left: 1rem; + + > .signature { margin-left: -1rem; } + > .signature .overload { display: block; } + + h3 { font-size: inherit; } + ul, pre { margin: 1em 0; } + } +} diff --git a/assets/stylesheets/vendor/open-sans.css b/assets/stylesheets/vendor/open-sans.css new file mode 100644 index 00000000..b1a39486 --- /dev/null +++ b/assets/stylesheets/vendor/open-sans.css @@ -0,0 +1,32 @@ +/*! + * Copyright 2013 + * Open Sans is licensed under the Apache License version 2.0. + */ + +@font-face { + font-family: 'Open Sans'; + src: url(data:application/font-woff;charset=utf-8;base64,d09GRgABAAAAADQIABMAAAAATeQAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAABGRlRNAAABqAAAABwAAAAcYxf7GUdERUYAAAHEAAAAHQAAACAApAAER1BPUwAAAeQAAAJDAAAENjlYHbFHU1VCAAAEKAAAADgAAABQkzyCS09TLzIAAARgAAAAYAAAAGCg5X47Y21hcAAABMAAAADTAAABijB5dyBjdnQgAAAFlAAAAEYAAABGE4kNCWZwZ20AAAXcAAABsQAAAmVTtC+nZ2FzcAAAB5AAAAAIAAAACAAAABBnbHlmAAAHmAAAJgcAADiU9e+LHmhlYWQAAC2gAAAAMwAAADYCMbBEaGhlYQAALdQAAAAfAAAAJBAZBnVobXR4AAAt9AAAAVoAAAHc3oErI2xvY2EAAC9QAAAA3gAAAPC1L8S4bWF4cAAAMDAAAAAgAAAAIAGUAZduYW1lAAAwUAAAAdYAAAQoZx+MQ3Bvc3QAADIoAAABDQAAAboUdTmdcHJlcAAAMzgAAADIAAABdkDIrc53ZWJmAAA0AAAAAAYAAAAGdj9RfwAAAAEAAAAAzD2izwAAAADJNTGLAAAAAM2lJr542mNgZGBg4ANiCQYQYGJgBMIyIGYB8xgACVwAqQAAAHjajZO/b1JRFMe/7z2IQAegUQdDOhhErak22IQfpQ4GAdEYoLSllBp/pHFoQ1LSxTA38W9g8A/oQBzd2R19i0Nnc2dHn5/3AmjEwZBPzjn3nHu+9/DulSUpprbeKFSuPG/rxtv3gxNl3g2OjpU9eX3W1yOFqJHnya/9H986Phr0FfG9gJDswEZkOf2g8p5e8bvQJ32Fb1bMWofHVg3bxfsAH+2EvWKvWOv2mf1FF/al/d2JwLJ96aSIiImyTsTp8Es5HfotK+2dKqe7KkARSrqqsjdWxTtXFWpQ9yZqQBNaxNvYNnYHuwsdsLSmz4oq4420ClnYgBz9856rAvVFKIHF6lhLCpOLQYb8KqxZMfa57HODfQWqiuDviSqOl4R0kJ2QNWSNNolL2C0IzXvN+vjnGrIep0cSbjKj38FX76HeW9iRgzy9Cthi0HuisOI/fygJaWbyFX21VNA1EZxr1vl0sR/rZXQq5KpQgzo06NSEFv42to3dwe7Sq4PdZ28XDqAHh+g4KLqouSgZXSMaEY2m2mO0DdoGbYO2QddF10XXRddF16Br0DXouugadF10DboGXaPrf/1X5wsTlVGsQBVqUGft9/0YTe/HaHo/xsH9OKQmtHA+m69g+ApGV+be4txBHWeJge8N8YbYPyfzp/EnibLeIt/65+yzqvC8anar/Fkt3lmY1SXFlVBSad1SRrd1h9x9PVBWD7XB98zzWora5K1s8drLeqKKanqqZ3qhhpp03daO9rSvrg7U08tfsOl3TwB42mNgZGBg4GLwYfBjYHFx8wlhkEquLMphUEkvSs1m0MtJLMljsGBgAaph+P8fSOBnAQEAaFQPkgADA/8BkAAFAAQFmgUzAAABHwWaBTMAAAPRAGYB8QgCAgsGBgMFBAICBOAAAu9AACBbAAAAKAAAAAAxQVNDAEAADeAABmb+ZgAACGICUyAAAZ8AAAAABEgFtgAAACAAAnjaY2BgYGaAYBkGRgYQaAHyGMF8FoYMIC3GIAAUYQOyeBnqGBYwrFXgUhBR0FeIf8Dw/z9YBy+DAlicQUEALs74/+v/x/8P/d/2IOVB/APXB2IKZVDzsQBGoOkwSUYmIMGErgDoRBZWNnYOTi5uHl4+fgFBIWERUTFxCUkpaRlZOXkFRSVlFVU1dQ1NLW0dXT19A0MjYxNTM3MLSytrG1s7ewdHJ2cXVzd3D08vbx9fP/+AwKDgkNCw8IjIqOiY2Lj4hEQG6oEkMFlUTJouAPzyLSAAAAAESAW2AJgA3QBlAHUAeQCBAIcAiwCRAJMASwCqAMQAdwB7AIMAhwCUAJ0ApgCqALAAtABgAJoArgCoAJYAoQCfAEQFEQAAeNpdUbtOW0EQ3Q0PA4HE2CA52hSzmZDGe6EFCcTVjWJkO4XlCGk3cpGLcQEfQIFEDdqvGaChpEibBiEXSHxCPiESM2uIojQ7O7NzzpkzS8qRqnfpa89T5ySQwt0GzTb9Tki1swD3pOvrjYy0gwdabGb0ynX7/gsGm9GUO2oA5T1vKQ8ZTTuBWrSn/tH8Cob7/B/zOxi0NNP01DoJ6SEE5ptxS4PvGc26yw/6gtXhYjAwpJim4i4/plL+tzTnasuwtZHRvIMzEfnJNEBTa20Emv7UIdXzcRRLkMumsTaYmLL+JBPBhcl0VVO1zPjawV2ys+hggyrNgQfYw1Z5DB4ODyYU0rckyiwNEfZiq8QIEZMcCjnl3Mn+pED5SBLGvElKO+OGtQbGkdfAoDZPs/88m01tbx3C+FkcwXe/GUs6+MiG2hgRYjtiKYAJREJGVfmGGs+9LAbkUvvPQJSA5fGPf50ItO7YRDyXtXUOMVYIen7b3PLLirtWuc6LQndvqmqo0inN+17OvscDnh4Lw0FjwZvP+/5Kgfo8LK40aA4EQ3o3ev+iteqIq7wXPrIn07+xWgAAAAABAAH//wAPeNq1W3l4FFW2r1tLd/Waql7TCQlpmiRAME26CSFiAJFNRgUxoCCDCIiII4iAG6MIDGgEQbawKyJGDFGrOk2AiMiiIiogLlHGJZ/i4PTIIC4zCqQv75xb3Ulk5n3f++cRuqu6qlP3nHPP8ju/e8Px3ECO4ydLoziBM3PFOuHCV8XMYvCfEd0kfXFVTODhlNMFvCzh5ZjZ1KnlqhjB61E1qOYH1eBAPo92JuvoVGnUhR0DxaMcPJJbd+kUqZIaOCvn5MZwMRvPFWlyOM6LnCIWES0jrHFNcSmDU8Wi1KHeIXFyke7MSmjOsO7IStS7nDZnkW7PSOgKKdIdTtWly3x5OafbeNWlOcp7lJT1jEZ8Xo8p1KnAHRVC6x68sl//8p4DXcejd017YvCA/kP6SasvfonyLBJqeA3kQT2v5GIcyiNG44LIyWKRZooQkE4TmnQehucV3QwDmrISugWOZhhNJyIM3KMERyHwWtTY5S4ytLHrVKkheY5XkudwjCjHif+GMbK5juRGLpbFcUUxry8QjUZjZhgvJtvscB7nSJbZUVTPqx1yOvujOicn6j3+zOzO/khcEtktQcntiLckuGWyWB1wi2h5YS2rSQ+4E1rAkE92J2Jm2VpU398sWsC6iu6Dq1646vXhVa8brnoV3QZX7e6EHiRFWq+sxr4Hf57FeYusjX0/+/lbPNGylHo+y+yGcdm7Cd9hkHpLQIYTn1Jv9dnc+Kh6h9cOX1DYu8rePfiO3/Gz78BvZbLfgmdmp5/TIf2cHPxOfW76mx3xutBf4QVUUlHRCh1ycjsWX/ZP65+Fpi8NuoPwigrs5Q2yV8iNrzK4FSUdB9LvSdHIJSNJSeXiSiLT5gEkmx6trKqkJ0Y+MWIrCQ+gJ8gr80nlPBKn1+FrHq2bT0eSV/AF18F1wUMWXqoS7SYXl8cVcldwt3NabljLjuqiNaF1icRyRTRubo4F3Lg4rMlNetCZ0IKKnkOKYqKtcyQS0Tu4EjGHuwucah0UvRtMQKYzoYfx2A2cSVXQi8Vc8GIOvbg0l0TVYlLas1dZadTr85sLCtVcHvza7A2Vgmt7fH7VSUiv0p4FhQtvOjnm6AvvvTh/946eazZs3jTs/V3z7/3wobEzJk0hw06Oeax2U36Y7L667omF210NcWnQwj42ekPktrm3PKH7/94cEqqvG9eVLFT+2FKdu2HomO4cJ3FTL50xXSG9x9k4Lxfg8rkSbgMX86H3huBN72pOxPzovwK86W5TIm7JCAmOIt1iTsRzwuw0x5wgWgSDGl1Nsyu6ihEEpyZFz4LTAjgtUPTucJoHvhiFo2pXXfUWwRfo7C/XuxfAB39OKBM+cLrFB5+y8gq64y13Dnww2VUOPoAb9OzVGvNlHl80oiqhTiY3iVrI5XfQXnBv6pqVKzatX71045PDbtq27aZhc4WiVS2fkJNrVi57dv3qZeurhlVWjhhRWTlM5E+dPvv5t4mzzbW1pJKM3H6xUmq4MIzsPnX6+69OJc5+teOlF1/e8cIL6CMzL52RPpKOch25rlwp9xAXC6C9ctBeIXsiZkVTRS1glF7MKHnORL05D/NbN19Cy1P0HpjR4NSh6B6MWnCPMjj2APfYaRVyQgUKaKs5VC2/XPO4Yqo/qxx9JhpSXQ2c2eHPKrgibY6yYr40rbqZ9CVlUd5MQoVOkrZDGXHy6EN9iWGQmUvioyo6v/nq1sYnNpO1va/xbx9YRYr+tuf+X6o//Wfdxvk/Pkv/MGNc9/lDb55/x52jx8wgcxe8M+W2idPKq7e/uObunX+kD/V9cRL9ZjX9MjZt3MdvzKnaQLYPHDOF/2jQI7f8Yd5N198+AeOIYM4l/VjO7Whk3FS6JZqYzrW6RIpSaRVTKmZT43dH0r18EH7XwWVyzPEIVAW0pDkzoWcYv6S4yqImCBOXP1TAj9y4cuvTK1Yv2bJqA19CLOTYKwdo5JdztNfrteRt45kV8Ex7+plc+pm2Jl1se2bU51IV3hzq5SrtyVdsXbVh48otS1avkBpepSX0PPxcuX0vee/cL+SY8czR/DzRafJAlcvgNCGMlQ2rFT6qTBKiQr5fcpttpNA9Ooc8XnSwiCzLogt/q9O2aD+JAxpmkCV0zoyGjrRxPJlGq8eTwfjMqdwpsat4COJxJKdxYc0c1QnkHikS4wjmHs5qKYoRDk+JgGnIHtasTRof0S0QZWIkZrHiPYsZvma14KmVs4C7GWKVBlWo3N6gGlKnkg1LyCY6eQn/1JOkjlY+SUeTWkOvfvQ3cg93lpMhH4AMOHMWnDkLmzkJarIV3FUSIIXJrDD2gsnwm3hzv+yrHQ0ZWRNL6W9TSWScfzL99T54XiU5yffjZ4IvdMLn6URI4AtdQecIeEYGPj/tDqVBbyX5npxctw5lYViC+wnsUcyBACgLgIjUgWkPWMJkYAnjkNK1rF1GWFdRfuXVA8qj10y7ZtCga64e3M/Q0wOF+3Pmoz7wCYxhwhwDxDJkiZIo8fDBmmQz5gKGbaZcOiNeAbFv4/yAn2IWDPoMW8LIj14bKJVpZEIny4QuzIQQ3wE4uiDn6RYBQ9mbAacmrryceXI0gn4X6sS3P59y9pezv/7w7zP/3l69rWbNmppt1fyX5FFyH32UrqSL6JNkHpwfoF+SQtIHfvJpM9NpLwj5HsNehVxMbNPJFtbEJl3wJXQ7iCKICGvMBqyB+hNSe5Y5iXkvWbh0q+zt+bG4hBRdGCa6Fsz2Ftex504GfJMPdSLA3WDYSvcLiVgGam2xgtZZYc3UpLuciZjLhF7nCoADmlx4akIHzAaD6n4OXIaUaxYVcrrDxZJYtJT05Y3cZC40pgxSmDfonUzma/f2Xvborc9PvuXds8f+samJ7ufPLScLY+uevmlO1VXDZ27/KLaEnvuAHpY3MBnHw9xkg4yF3BQulo8yQtmOZaKMqjURt1ryM6FcWXGSujBxO8EkOfOaVD0b5HZmo7BOCwjbFYUV81mJBmF10qm8XLO69NyOcFTVmMebXV6eLt55qhIMlbZWHHNhX5LKx95c4vWIwU4F42/7dAKZS8c/vXLHO6senVB7T+XY7xd8cmbz01ot/Yr+a/aBPs+ES0hXYl2+dvHdD/ccfO+QGw/VLo3lyr74yhNfh9DvisD+06R9MK8uboLhdzEe0ypns/AOxLM6JyQYmHWHNUuTZo/osjehCZGYzNKAbIIJsTDIaMEJwcojW0BFBUF1amJ4VctAnywFn4h6Q+AXAPJ7loVM5iK+QTt58tlkMx+0yj26kRErhC9buq6lGhmxlpxeHp+QiqlFMAc54H9Z3J+NuqiLEB0OnANFSMTdloAD5sCNLpMN2a1J90F0dDDQab+3L2xhoNRR7NTs+yVdyT7v1NT9nG5Xi4tJvR1wYgoTEt1nBhd2ZjAwFUjPVIzYfG0zA1OiBDuZC90IFsG1RK+HC3VaNPiDO7RDtOq2Z0eX8Z8md+XPuu87YqHN9Nc+W66I1mwmkZwyvm49vdb/t8OnKAXbzwGdwuBXPq4zdxcX86BW2dZUzMuWREzCE5MtEXcGPYiInDKol8/ygB9czBbR/Iqei7AH4q8Ajrl+gDWy4PRgoVdVaLhQj2A2XOXsqs2o7goXjPi94FO8EE0FRqgTV+YxNENnc5I5ZDgZOvPq6yd9/5vdPv3sW6fOf3yK/rt2xKrxyzavXDG2egw/k7xKdriXB+jn9O26s+9/Sy+SUXvujE2tf2lFzbAFRtyAbxXBnJkgP8ekdM5AfyKaOazLGA0EPUQoN/IzCZHxwtFk7W5+vJSzftGF41IO4iLAkmJXZqdOkK2npSyVBQaS8XndERKFDasA+vErWghjT4JzKayH2CUE0mglrSvesoG5ECx1BeSzU+Y9WXlOBhG7Z8FnTrKpeQUpIJTPEHQq7jDsfgcEAQ+1x0BQm5588Pu33vnH4lWxavrlP1q2vbh2Tc2mg2sWh+9/dvkDK+Y9sozMubj0hp33Pvt647Y/xa4d9drchpNHdz+4eOnDt68d0n8jv2TcXwZc9eS4Ox54EGNzGuiNudEP9fKuVE1wgtY2PLGni0MIUpA324L+4UVLFDBLZIKumYreESJAjehmCIZC7BTA2rrNDtmmo1pvcQpepnYI/cPMudSU0hzo6/KCixeWgqIud6gN9qGqxfDJNI279MGa5geTU2cPHDHl3L9s9rKG+w5+u+3pVbeuv2XkqtuWbxKavyXyevr54ZYaz/IsSELRm27++4dPvzDsscF3xabsbu3nxcmsRyhrV4Uh08SdqULsQ20g6aSKsKakz3T/f5RjFTr3ywrz/XPbl2fhtccfZ1WaZ1hkEYwtQ97rxWlKOG5LjcjSXNxh8AeCwwL4GqGJJwVNoONWyst/B0+E1nFbgUrXB2DgKyva4RXxbzh2Cov9BFjsJMQFBxnRayHeqcKBlp8EJ79uImleQ5+iu9eijA+QA2JAOMX4hSwD4VgSDEXgPMthZBJSyIbA6wFha8t4YSs5UFVF1ldVGXmz3VhlpRZSimNltPwoHPhpLRlM5qyhwYkMf2RfOiWUg69lcwXcdI6VAD3TntA6h+O5KcMUhjVnk+5wJepVZwdnUTxozAh0G160jTehd0lRKrHM3M5gIy2o6rwLjl5XzOq2sGYjExrUmOTAOodVAVtVtzGDgBOcJIeEsGNNR5TZ3UbGZL9+7I2PC6979I6r5w+d+MSQhXOHV9++IUXOSFMn7d1x7YxJ00ffd1uw15y1lbNmj5wyI7/kYpXB2HBMx4cuDTbtluLQW/WDKqJFw3qxnMD5tkf13mZooSJaRVgPwFlhWBcx1/ZnsVQECLjIYEJ6eRJaL0UPGVSJfjUcQ71UV3+LXXQHCot7RPuwiHIXQ1brUa5X9IbWSuYUX0aoCFNyQNU6GNisczAiulBp0KzQCLDSnoDSfH7B62HZhe8c6iTyXszWZV5TKI8jcD2boFkeaiJLCfcpGb7rli1TR99vk7usmVL90pn9A+sGBRbdet9q+oPeTBteIQNI+MNv9v9C19AZfOmh4y7n0FELVvF9iEiqm3fS+pPLziyYeuPNE49q73OXAj7azRf7tG4nUVbtoS99TY/T3aMXVZLlZD4l5UTyxJkN4Z+kSHvBI51cdwOxaUKUJfa4SeYIZCET5vdUy6ITGSxhB51LAPOGhKDgDgoFhSYzP2A5309rSDbEz5GTtcGQr6u098JAcoKG+WnkrdGPTJhl8CZHoIbsgxrihCyYx92ZwoiIjFktybMn4pl+Nmwm4q8gm7AMKI6ZES1D0d0wQbZAQuvAKgK0ZJ3gQgdEyRYZ3dEPp5qtXMtUoeCCS+a5NAnBoxrMExnUElWPGMoPGoUxWGqcFJEjZDMUK3HFEnI9/fUsrSMlev2u1wA1Z8af0fZfkBpe2bvgpYC1nH7+5hcrqlY+8djTMxfPvQfi8RHI6cdZLatI1bEMwLsiw7toN7+B8gMJPRPNl+EB+VwMgnBmFYNGdGkmlqVdnaMRvxncgVMVyNiGjzzyEun32em6oTU1P9IEcZxf/+a6Zvo6fY7/8htSubty5Q30DZqgX9PDZWvKyRMwn2Bf6Rawr8ypXO+UdS32lHVVO0jkYhJZwKQWBXsgXQLh3CicijAvbS6UIlgYNY6hI2Q/GUUepg/Q5d8fJz1IBMb8269SA11MX6bVdP4qUkTySS7phDkKZBB+Axls3JC0BEJKAhGwnWS4lYTmsbcKgy0pa1ctNksR9KlGb5pq0oxm1HgdEcLJufz45FZ+kdSwlnatTp5eZ+TG9LgWrn+7Po2NKUtsTBnHtP6XMaFFTg1ou2zA1uFgsGRibXK5MRbMu1TB8uuDKfyaYW+b97jbExARv+J4HdIukKa8AMrGVEa/qm4YMSfFc8VESwA9wqfqZhM6cwbiVV+57nGDf9uhCwS30cz/xWWMSYqUqeDQKuQe8Jtd5Jbv/vHugLd30X/Rj0mQZK5bQfeQH2efe4bG6TL+82/JzTtHV1fSg/Q0/YweC5GD65Ll+QVkiWFHqSObv36pjGA2MoImReOClVlSaJs9G1iSj2g2BREg2JShk/S84QIA9o1gyFqBr61toVJDciU//cIwXkuOaJ03Mov118HL+mt8vABPw5fU+sQjtak2myOXJtB5ZCrja67gYiaU0RbWeRTOGdakJl12GrSNztvYeoBmMoqUmUE+eBjWosItQ66fdGft/vj4Xp94HpwJT79l34nstGxigtmix2W2EKO/NwBTmeFfXbCUlxuiIuUYImYQmV97JBnnJ76T/H49WKCI/yS5qOUt/q0nkod+57tSOgujhVN2MKXtEBOYtwoSeI25zcBeeDrEwoVvN7Ta0wRKcG5cVWHPMjuirTNINA97nhu6PjebMzZhWPPdKHwGwCFNVut5yYEoWjMbCNNQLSbYMsoN5YJQAAxGIAQqhnxe9QjhyHGZfERrnTK1V1FFdkoNF4eJMFnCK1v3XfhJUjZ83DI+LaPUj8k4MmVXR0pC23+T0OH9Twl13glHB5hbTIunCxkpy5NoQSFUYJzhlHD7hRyZN0vPHmz5WlZAris7rLyvn8spDLkwTHzvs3jLAVYPMa73Xc7b2NO8jdDG2wTaeJtAO94Gpz7F23Cm8lR7ZsRnHteet3mETCADSR8ykW6mb2JKjyd//Pm3X3/6Ock3kztIFb2XbqHP0elkCZlCP6VHSYR0I4WkhBprZ+iXU1muc3ED2mc7F1RQ2YhRGSuouzXbWSOY8Z0gKXKZHvRWlxWCQkxlfKiMwVAg1TRiSZzLd6Yn6Om69d+8u+8IFG06+qsfkgf4EyufW7GM2YpuY7bKgCx4ExdzoK3caVsF2lKfArZSDLyFtsJ851OgRRFsDgt6GISkFUGWA4SxIKi6zHRIVRPz/2K+b46Rcb/SU2X/mwm/o0sH0GoyjP9vhjTseBzsaIee5daUJ8qGJ+peMKXNwUxpQ1P6Whk7RySdyxGD+FMJXLeawSElFZTgdAfmG0jWXrUdAlEk0CTY3sYriIMQ+lfy1BH6DD2eiG/f8fqXUsOx4/SrKckZ/ITkFv6n5ctXPMZiBvtHHmpOZ2R22LKLCNZWUVSfYDAJAOgDztbVvzynQSOYoZDsFO2qLzeE9s5TdbeHESIhgxDxqfXE6cljJINLc7fjRaA5LEy1zAybmLyeXOJnfFUob9rYj6fW1lUsX/n+q/TEX3eV7tzx+Lrei6pOv0z/fo62hLcVdJ8/67rbR/a89p3nXnpnxOrrZt953e03lozcW33gM6aPC2w/FmzPVltN7fkETTAWW01NugRpT2JUoSQgVSi1UoVt3RLy1C5xKC2plTquXXvha6kje34jxHIAnq9CNxrLQHuZUkhIs0bTQAjSqiYobBEXZpYBTAsy1hmsJcQC0Vop0Bsba985uOedWvoh/Q1+mnlZeKVl8K4339ot7G654QL9huQZORj+iYcYvwo1zYq6seVkcxQpVuRWOZ1LBx9kK5+/VxkC6jgZcWWnzleSG3Ylf9wuNbTc8PyGrc8JdVjtCEQZZ74OnhngDhmYQ1Oj7LExIjuj0ShjV0ElAvFGjEjPgHjLNlizA5fOdWasGadogf1O+IbG7288+ME/++JVSVOLnZpnv27xnpc0GW48/88TcMOmeZR6l0d1F9W78T0G73lP5j0ZMkFfWA5QlovzFpfbwxZhyS4eqcJA6mOagnOSFH3IBRDl2Lysgrij7pTWeHCD8gSczQSxnh3/rocvu5SY4sTdI+guPR2nD22nJ709ia8H/QbN8tC+DbteFR5qmbfx0LL3hUVQXz66+m3PXza3RNFOMti+mtXsgraI5tPVzx42KjWP9UK0snphIcb/kIXI5DTts5fcTabvpH3I3w8A5L2Xb+Ebk6/z1ySva6H8wuQjqfmdy3A24AJz6/wKUbb8YW5ibD4uf5jMoDyPyhsnON0wFgGXbSQLyeLdNLMOAEE+/3nL/OQRPozrWvDsfiy3F6frd7qnEAwAy1CqbjbYWAC4qoEIo6UkiORB0DtSkJOK8EbLb0LuInHd+kUX70phgxq6l5/BYg5iwoDH1gRSEZKVcRC4TGJm6ywIaSD40p+ESDrgUtC4hhyhzSRI95ouPHUhuAye7YeE+mV6nURI26TdOglOsv95PlTTit+4KN1LWpg8gNkNFUEecxiiw5DH3ARDI0eEQpgUnUC8EsgMSlowc5o38QcZ/g1GQahmEK6sQWp+6rzJGKeELxRD0gHOBNkgBatSlCVb2cCZLyGxSeTLdXQpjfGFwoaWKXwi6UeM2ULfEjZcGgp6+XH9Dndj4KudYmYwiSg80zJx90KOkCrxpOAyBWH+8jkYBTe0OJBwgcaDNOl8RsKYPt7Mpq9HCSmNQlL1horhV9/Lu6/79KtjJrt30M7SQM/9WKPGXzojHBXHQtx35uZyMRda1o9ukWNJxBwE/cOSiPOd/EiZ86ZWTjkrM6FlKXoeYbOn+FgVdqkJyHysLGRhBFis2CarMbPDj82Hy6V5wFl5PysEmhmLGWAsKNBYj/PLGI+TXrtQQ4XtuMReFcj4jOfdjTMerno5euOB2998fW5QHvn8w8/veeWeiRv1mrdfJWFynWIaOH9u5bzukVf2Jz2b7rx2+/qx42vXTzKb7wFdNahxc0wewIUdudtTuMbJlpGsiZiMuuZY0QHYXhfQ0eNj2NutJCBRsV0EgMZxGwvrmqBysCY7S4VGACteDugVN0FploxFJUzrZmTNDVhRUBgyu9ttC9BqZHn2t5/98GPT3fpV9lB4Q+366urNtdUmD108745t9CT9CX4+GT7yKT7/u8PNJ5o+PAS+Ngvma4k4rj0ngOQuCm+1t3ECzss4AWsrJyCkGjzkAXx+czF0dKwMISfQiZtFLP8a/swV0bIFERp7fkvV09NfPEsv8jnETbp38j/ly6Gj3/2sz6pykg/2BFnEMrCnC+w5hYvZ0Z4KiuSzpkRCe1pa7el2MswNeFaTI0isY1/HtmuEmVUDYNV6SXCyrQd2FeQEo/rAqJxJcjpSCxIA0/zRMEHsXRgy4YJlyj+YUWeda2p+wGEWax73WWb97dMf6jasqV2/sXblRj5IMkj3rcOvJ/vOn1n1Iikgjg+b9hwLJQ6fYpgXdXGBXd1cFuqiohq2tGUzrYm4R1aRNvdYjFUjjrV/mieC+56wezBlsjUk3SuzTobZ2qTqDuYaNhWmwYmNBbaJHmPbDZsCVw4JIhwq86Kzc+6goQaRvjh2LumQ9mzXX75l84yf6XcaX7F0wV8289nEQnrTH7+668A7w1YXBEkX8tDmF431UVwAcZo6ch70bjfzbpRelSEJRzQV8psjEeMJQpQUL+wNa262cO/CrSCRmMvN1k0VAENutm7qRjDkQ0+yOtlSO0JQM1uCKStVgingBjownJTzJrn95jlFEwaOH0v8NFEjHP7DVRVkdWhRx0eeHDK/pVw4zHi6QuoRS8DW3aBOXI14Q0Ux+0ngHZFYBjs3J7QuBuVaCr5hh1wT1vtg9hkQ1uxNem9for6ot10GQOdlFaYIDr0VLRepO5MnAW2tnuvBS3oFzIkfvl7ir4Cv50Ovdw1c6W1nS0l6rsnoCyrUXWpWRqcuxX36ofP5jUxVWgxJLJ9j7DR8W8ty6aZc+NRH1S0d4NjPpdv9WOT9uAuG7Z9KA9qgN70EVAi41teHIDWN67CtC0JFpJOJ+W5pTzBh4WOzu5X3HzTq7s/eHj2IzH2vQ/cv9pcUTRs29mDsDfoF/funiS3VS08cmb7u8MxHxy6Y/fO/5zzaOHlllntE6VVju4W2/yl+yHNXZmjmkOf2y71HXVFUvbzhjS2rx4x75O4xg/4kXDXr/jO/Psp8RIP6PxBi1scNb+uNLWhzVyr3GRnEx3Kf5jOgnsPH8onu4xiLpznByqwZdWGal0zlbfkunb1V5sjQodTI1pKds48cqXl4wcvPQnLrOqr38Fvf+CBZyr+1+C97mxhHwXNbQbgpUjPgqgyuPJVLsPxA+oAeHCZeSS3u6CaocRkgDx6xS8pIsyLc7zdnIb7e2rtblyuv7NKtt7NGmlle2qt377KyC4fFoRdxTenSCuphY9q5TG4QdAmpyODBEL6wruCoASR6IDJ0GUZ0w4hYBWQJRlR8qD9vZZR1mvkR0ngeF8Ta7webMeiaG66toZ/c1oeoaZlo3DPqZrHlokL3BswDU9Kl5ygH5khhO0VxjuzGHLH5UY2+16hNyBEo6TngdAs7dbabDgTBBk2kbZPlolvIxLfoQPL5EfrIIyZPy+GyWRWTyAP0iuQS3vQnOoVL+wgZDeMb+6a41LgpxgzsgK82xkyrMXnOn0n9nuk9iOsQrgoxbKlmITcEksfMhLma3hGNCw1YZ/a0EDyJhwIQUrCT0LN8DFS4UqvU+bhiwhuBingCgILmwgzK6R057PFFizVdav3oeHn+/3C/Nj98PFsO75hGyHC5pPGBxj01996/cXXNvQ9sWiEOrR4xtnH05Dc+BJ88snBhbE9yEx5f+yR5oC1mQC9PK59ka6eVrliNTGogBqYPwAXc4oJrx8hMeAwloKbpIm54MaYMqaU22X8fNCBsdPfMt9+BoKnbAgLeOIZJBxHT2ARSGfVqIsiEvpvmkpR02fVZDdcFiRxOtsfPndoDhO6LXEhqDxBSJa17gKAaIZcE3sq33744i9jPniYZ9Kezq779c932559/6aUXnq/h8wloSI/Ri/QX+tGThH/5wy8+/+jEySbEWZDf5zB7BbESMe4GSmg7k+WyvoBonVpxljeSNhw6ghVEDaUMFxMVVkyzVd3sQKFzMwESiFZFFQxIkDZgK8zyeQOknQJm7XG/3L92+l//aUAt+4baZc89t3J7NU89pp7VY0fQJvqzAbUmjKYVovrd4dPvHv288T2GC0CXMtAFMc6dXBu8QTXaMI71cowD3ZkWgK7Gx1ZCzC7WdiDGsSJyBIiDGplVXWIapUCOwym1BzllKQa0DeSUlaZBztcPWTIX1tgcs777+Gzd+uqaddKmlxjIcZDi2urz75NPJg9+kXQjluMnYx/ln373VMqPhULQReH6plm91hmxY/PXmlpwLozcwjMegdPtjKDFlra9z6Z4G1WrcssVdXdee6V/YM9Fr4tD37t7tn1DxmfPJeMpbkmYBeN25u7gYl62c8WS8lTZmu4oND9AQi+DhLhLxeVN7VIBPL1TkDO82Z2wMLvUmEmR2OpwthdE8zM6mTO5cN8uYlspvUX3MkoJC3GadyooLOan/XWf/lKfFxbf/+AVk5cdqvr22F8fHr6zcsHSGc+tn99P6LN449DHBw67unvf3qVDnr5nfc2gDV2KbxrV/9a+5aP+xHJCx0tn+O3SYKijDxtYS7fjJhymE8NbMYntc5JEwFKIuVhh9TYxl8C1IW96i6SWEYl52fYoL4AtdBchK4GtIBZcwZvq8O1uxnMggswAEGY0d+7SfiTqRexluAdiyLwA6bh58ATSlx4cP7L7yMzsu7rRg8LhEYP+Qecn506a5jDPc6hkOL/UyPXbII+Ui0MhVm8xIpW1gLopFa6MzPL+B5nlYwS1AwLWEWYZDmktPQPX7x0qAF1OJ6bU1qjLiK50V7dtO2S2Z2uqMi3RnbPfOUzm8keT5YAHPuH7X9y96sbR+1I8CA+y2ZDnsrGenzDCpY1o4WytPJfb54/2ciENEDszwinb5rwfoxMOikOT9387sJRcz/eAos94BFMIntmByFysA2OVs6FG4YPriUV1dfbD03MMqiuTUV2IMVyQj3INqusgf86Xpro6GFSXsL+xgjuz1aC6soudWtZ+wInnkemqOPfDKbzuBPitZeyXNJeiufc3HhTOeRkBRpR6ngjuosaKx89OZFdMSr3ZhH+vkKHUKxnIisEX2lixGNxs9wm+AQdul5zFCyZzhpLmxkh/m+zOyu7w+6sp0gzM1gHzKuvKkTnJIhjOZYw1gxO0oQB51GQWQv5Xps2QZVuJ60j8zd4eWQy9UUdPvHXE3VPuknt8vziUTqIj/lC+s5Sfk1xSN6dzNf/Fxd38wj4nXr0tOQ/9qxDmUGZz2J4vI/83vqyQrKALXydBkvcaXUhW7KVH6bt8Ce+n48i2ZCJ5nOylA2EMyOOiCcbwckVc2kXQMV1WRqyz3T+6y8GIV42DGqy0coSGtuiRbP9SP8LHzg13y46bTsdo18GvPXHdsLKBO66tAC9a3nRb9Ff+zxfz9mxUF9n3bzK4NOEeGNfSuq5mlhMxEXOAIP8nl6YJ6uVMGj86eZz/Lhnj/zhTGD1/fkvj/PQ+8QNSA9eBm8QZ29YsBiT1GnQaQb3ARwNNuCLqYrv7Yq4Aa918+Jc4kViAdW8B7N5yWZx48c8+AhCfRGVhqVkAKNqZHUr7klIVUqYXErnH7AUc4vH5vcV4saBiwezPGk43N8+Zue+DR8lPT/HjJpDIxrolpgP05KeF9sJP6adTxvFL+doNJDx1DEfIWarwcxl2zOLaw0aoJ/gyYCN27+TsU1SRxV9bjDw0FHTGPcM5qLMTdXaAsjmMq0upTvDvYIQmzRrRs0BnNRLLElDLLNA5JmSx1cscUB/QekfU2cecKgv3FKm6bDbW86wq69cMlctKC+BQWBpJ6YylzGMyD50xe/rM5lO7P7p/wcOPHt23mx83hXS970EPqEsKD5iWvLKeHrv9Vv6pMVPpifU7eGPvFD9C7CpUQn55g8MNLZwSjbJpkyP1XkuGXKR5okg/6qI9EsGFPkaDQD45s+fgeZZPPMWaXIwASHSdR1JBcZ1v/D5x6CqWFUSlXhIhK9Sb8R15c9WjMN4c3mNwqx1vbi6PwWU8U8u5esmsug3+XJTMsqL+nj83lhMBUkMHpaaLCrYtflYrGSkE5RJ38U2d/PTAmWMGTAz3eqzXlGVDHx36h7E9SufxI9be06GgQ3b/8urpwby8zGt+t9ee+91Oeu7/755Y2e6eyP3untz+ntzuHtaXIeIQvFeiBlU8v7jnfwCPHS49AHjaY2BkYGBglJz1T/vQ0Xh+m68M8hwMIHB2qdo+GP0/4Z8ARwibIpDLwcAEEgUAgncM0gB42mNgZGDgSPq7Fkiu+J/wfwVHCANQBAWUAwCbOgagAHjaNZC/SwJhHMafe9/vnQc1RAgOEnE4ODiESINIuAg1i9gmERKHECFHRIRTCDWKIE4OEtF4U0NESzXcFCLSnyBBRDQ0BfbcqcOH5/3+et/3+apPlGwAEgdUSAp9vY22mUFOuri0enDNdzSNV7RVDWWyIw1UWXONXxRVFxXloK++EWfuiDySOqmRDGmT00XskkbU76C4iM9C1SdIxrI4N9cAcwuBuYqWOUEgHnEYjxlPEagCSc0O5IP5NIJYHoFlkwJaMlroD2t1NOQY65x7kGcg5iIpA9hyQa8d+hjiln9OUHNSRVb3Zn8yMK75Xk2m8PUbPKonLXjqHhtyiDTf9JWFobJmHclFZz/WhB/mZRL1++GMLnF+RJ9jbLJ2Iwqw8khIlnfYUPoJZW1zj67xRd0L/Ue75y41/VsOsFS1AhhXxJyDF2qeuj/vXyJ3qNgkrIX9sgv8A9eiZ0kAAHjaY2Bg0IHCEoZljF1MTExzmPWYfZjLmBcwn2LhYTFhCWJpYJnF8opVjDWD9RqbFtsUdhZ2DfYlHCIcQRwTOJZwHOO4xenD+Y5LjauP6xq3BHcIdxf3Dx4DHj+eOp5NPDd4tXjLeE/x8fDl8R3h1+GP4Z/Ff0QgQqBLYIfAM0ERQT3BJMEGwRmCB4TMhGYI3RN2EV4m/EtkhyiLqJ1ol+gp0WdiQWJzxF6JB4hvE/8kESNxQJJP0kLyg5SSVJxUl9QLaRbpFOlXQPgDO5RhkxGRUZIxkLEDQy8AJJxEIgAAAAEAAAB3AEIABQAAAAAAAgABAAIAFgAAAQABUQAAAAB42p1TzS4DURg90xYVlFhIFxYTqy50TJWkEZEUjUgaEho2NtNpVelfptNQa0/gGWy8gngANlYewQN4BOd+c1uqtZHJd3vuN+d893zfnQKYxRPCMCKTADxGgA3McxfgEGK40ziMTdxrHMES3jQeQxyfGo9jzohqPIEHI65xFAnjWeMpZIwPjadxFlrUeIb4RuMYCqF3jV+wEE5o/Ao7vIVdVFFh+IxblFGCyXC4d4hcNNFClz0o1gWzJh4Zq7CRYiQ1SmGZ2T2ym+TVWMfEDrFHtVodqd9EAxYOmSsTmThmvoE2jrivoEOdQ26WGVcYJa4eeUnGsMrENjVVqpRn5cYeyRqsfiI129qN0lmi7Sl7ulGVqrKqufjSk/JXl6pXzDVxPjQDR7owhdXlb1GynjhS1XxxE0y9Kqe5klHTD/aXdO4Jt8TV7c+xTd/Dkxo9c3VvPrMbWOFzLY/F94NqV2stQXUy/6vz2WtLuirLpCvkBlO3pGad08lLN2XpJOi/86MPnzw1qSzrOOQFu0GN+uJ+3+YqT7D/9P1dyxLPFb6tDdRsM5PHPueYwwFvPidfuKp5yrdF3rA6x9ffjY0CXfd8Kt9p5kxGmmevybqOTP//kv4CJwynrwAAeNptztdKQ2EQhdFv0ntP7L33c066PZrE3ns3oCkgIkoQX0sfUEPyX7pvFrMHhsFEM79lyvyXTxCTmDFjwYoNOw6cuHDjwYsPPwGChAgTIUqMNtrpoJMuuumhlz76GWCQIYYZYZQxxplgkimmmWGWOTR0DOIkSJIiTYYs8yywyBLLrLBKjjXWyVOgyAabbLHNDrvssc8BhxxxzAmnnHHOBZdccc0Nt9xxzwOPlMQiVrGJXRziFJe4xSNe8YlfAhKUkIT55kciEpWYrfLy9VbV7fXXmqZp+ZY5TdmcjcZCqSsNZVyZUCaVKWVamVFmlbmWurqr665yrVJ/f34qfVRblVFsmWxaaLzwBzKjRioAAAB42kXOyw7BUBgE4B7Vi1tVW7TiUmkk4iR4CLqxEas28RzWNpasxM47/LXydkw4frv5ZjGZp3idSJy1DdnbrBDikhepKbMxufmGgh3CMR+QKfeZRnq8Jl2uqByvH7pfkh8YQPkHEzBSBQswVwo2YC0VKoA9V6gClaFCDagOFOpAra/QAOrJF4IcdaWJ1pmVZKGnB9AFmyNmC3RvTA9sLZg+6M2ZAej/p9pgMGV2wPaE2QU7d2YIdhNmBIZXZg+MvB9zCuQb32JoOQABUX92PgAA) format('woff'); + font-weight: normal; + font-style: normal; +} + +@font-face { + font-family: 'Open Sans'; + src: url(data:application/font-woff;charset=utf-8;base64,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) format('woff'); + font-weight: bold; + font-style: normal; +} + +@font-face { + font-family: 'Open Sans'; + src: url(data:application/font-woff;charset=utf-8;base64,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) format('woff'); + font-weight: normal; + font-style: italic; +} + +@font-face { + font-family: 'Open Sans'; + src: url(data:application/font-woff;charset=utf-8;base64,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) format('woff'); + font-weight: bold; + font-style: italic; +} diff --git a/config.ru b/config.ru new file mode 100644 index 00000000..f05929ef --- /dev/null +++ b/config.ru @@ -0,0 +1,15 @@ +require 'bundler/setup' + +$LOAD_PATH.unshift 'lib' + +require 'app' + +map '/' do + run App +end + +if App.development? + map '/assets' do + run App.sprockets + end +end diff --git a/lib/app.rb b/lib/app.rb new file mode 100644 index 00000000..6c2e2982 --- /dev/null +++ b/lib/app.rb @@ -0,0 +1,137 @@ +require 'bundler/setup' +Bundler.require :app + +class App < Sinatra::Application + Bundler.require environment + require 'sinatra/cookies' + + configure do + set :sentry_dsn, ENV['SENTRY_DSN'] + set :protection, except: [:frame_options, :xss_header] + + set :root, Pathname.new(File.expand_path('../..', __FILE__)) + set :sprockets, Sprockets::Environment.new(root) + + set :assets_prefix, 'assets' + set :assets_path, -> { File.join(public_folder, assets_prefix) } + set :assets_manifest_path, -> { File.join(assets_path, 'manifest.json') } + set :assets_compile, %w(*.png docs.js application.js application.css) + + require 'yajl/json_gem' + set :docs_prefix, 'docs' + set :docs_host, -> { File.join('', docs_prefix) } + set :docs_path, -> { File.join(public_folder, docs_prefix) } + set :docs_manifest_path, -> { File.join(docs_path, 'docs.json') } + set :docs, -> { Hash[JSON.parse(File.read(docs_manifest_path)).map! { |doc| [doc['slug'], doc] }] } + + Dir[docs_path, root.join(assets_prefix, '*/')].each do |path| + sprockets.append_path(path) + end + + Sprockets::Helpers.configure do |config| + config.environment = sprockets + config.prefix = "/#{assets_prefix}" + config.public_path = public_folder + end + end + + configure :development do + register Sinatra::Reloader + + require 'active_support/cache' + sprockets.cache = ActiveSupport::Cache.lookup_store :file_store, root.join('tmp', 'cache', 'assets') + + use BetterErrors::Middleware + BetterErrors.application_root = File.expand_path('..', __FILE__) + BetterErrors.editor = :sublime + end + + configure :production do + set :static, false + set :docs_host, 'http://docs.devdocs.io' + + use Rack::ConditionalGet + use Rack::ETag + use Rack::Deflater + use Rack::Static, + root: 'public', + urls: %w(/assets /docs /images /favicon.ico /robots.txt /opensearch.xml), + header_rules: [ + [:all, {'Cache-Control' => 'private, max-age=0'}], + ['/assets', {'Cache-Control' => 'public, max-age=604800'}], + ['/favicon.ico', {'Cache-Control' => 'public, max-age=86400'}], + ['/images', {'Cache-Control' => 'public, max-age=86400'}] ] + + sprockets.js_compressor = Uglifier.new output: { beautify: true, indent_level: 0 } + sprockets.css_compressor = :sass + + Sprockets::Helpers.configure do |config| + config.digest = true + config.asset_host = 'maxcdn.devdocs.io' + config.manifest = Sprockets::Manifest.new(sprockets, assets_manifest_path) + end + end + + helpers do + include Sinatra::Cookies + include Sprockets::Helpers + + def browser + @browser ||= Browser.new ua: request.user_agent + end + + def unsupported_browser? + browser.ie? && browser.version.to_i <= 9 + end + + def doc_index_urls + cookie = cookies[:docs] + return [] if cookie.nil? || cookie.empty? + + cookie.split('/').inject [] do |result, slug| + if doc = settings.docs[slug] + result << File.join('', settings.docs_prefix, doc['index_path']) + end + result + end + end + end + + before do + halt erb :unsupported if unsupported_browser? + end + + get '/manifest.appcache' do + content_type 'text/cache-manifest' + expires 0, :private + erb :manifest + end + + get '/' do + return redirect '/' unless request.query_string.empty? + erb :index + end + + %w(about news help).each do |page| + get "/#{page}" do + redirect "/#/#{page}", 302 + end + end + + get %r{\A/(.+?)(?=\-|/)} do |doc| + return 404 unless @doc = settings.docs[doc] + erb :other + end + + get '/ping' do + 200 + end + + not_found do + send_file File.join(settings.public_folder, '404.html'), status: status + end + + error do + send_file File.join(settings.public_folder, '500.html'), status: status + end +end diff --git a/lib/docs.rb b/lib/docs.rb new file mode 100644 index 00000000..7cf5edc7 --- /dev/null +++ b/lib/docs.rb @@ -0,0 +1,70 @@ +require 'bundler/setup' +Bundler.setup :docs + +require 'active_support/core_ext' + +module Docs + require 'docs/core/autoload_helper' + extend AutoloadHelper + + mattr_reader :root_path + @@root_path = File.expand_path '..', __FILE__ + + autoload :URL, 'docs/core/url' + autoload_all 'docs/core' + autoload_all 'docs/filters/core', 'filter' + autoload_all 'docs/scrapers' + autoload_all 'docs/storage' + autoload_all 'docs/subscribers' + + mattr_accessor :store_class + self.store_class = FileStore + + mattr_accessor :store_path + self.store_path = File.expand_path '../public/docs', @@root_path + + class DocNotFound < NameError; end + + def self.all + Dir["#{root_path}/docs/scrapers/**/*.rb"]. + map { |file| File.basename(file, '.rb') }. + sort!. + map(&method(:find)). + reject(&:abstract) + end + + def self.find(name) + const = name.camelize + const_get(const) + rescue NameError => error + if error.name.to_s == const + raise DocNotFound.new("failed to locate doc class '#{name}'", name) + else + raise error + end + end + + def self.generate_page(name, page_id) + find(name).store_page(store, page_id) + end + + def self.generate(name) + find(name).store_pages(store) + end + + def self.generate_manifest + Manifest.new(store, all).store + end + + def self.store + store_class.new(store_path) + end + + extend Instrumentable + + def self.install_report(*names) + names.each do |name| + const_get("#{name}_subscriber".camelize).subscribe_to(self) + end + end +end diff --git a/lib/docs/core/autoload_helper.rb b/lib/docs/core/autoload_helper.rb new file mode 100644 index 00000000..3b4ac674 --- /dev/null +++ b/lib/docs/core/autoload_helper.rb @@ -0,0 +1,10 @@ +module Docs + module AutoloadHelper + def autoload_all(path, suffix = '') + Dir["#{Docs.root_path}/#{path}/**/*.rb"].each do |file| + name = File.basename(file, '.rb') + (suffix ? "_#{suffix}" : '') + autoload name.camelize, file + end + end + end +end diff --git a/lib/docs/core/doc.rb b/lib/docs/core/doc.rb new file mode 100644 index 00000000..4a4f9177 --- /dev/null +++ b/lib/docs/core/doc.rb @@ -0,0 +1,82 @@ +module Docs + class Doc + INDEX_FILENAME = 'index.json' + + class << self + attr_accessor :name, :slug, :type, :version, :abstract + + def inherited(subclass) + subclass.type = type + end + + def name + @name || super.try(:demodulize) + end + + def slug + @slug || name.try(:downcase) + end + + def path + slug + end + + def index_path + File.join path, INDEX_FILENAME + end + + def as_json + { name: name, + slug: slug, + type: type, + version: version, + index_path: index_path } + end + + def index_page(id) + if page = new.build_page(id) + yield page[:store_path], page[:output] + index = EntryIndex.new + index.add page[:entries] + end + index + end + + def index_pages + index = EntryIndex.new + new.build_pages do |page| + yield page[:store_path], page[:output] + index.add page[:entries] + end + index.empty? ? nil : index + end + + def store_page(store, id) + store.open path do + index = index_page(id, &store.method(:write)) + !!index + end + end + + def store_pages(store) + store.replace path do + index = index_pages(&store.method(:write)) + store.write INDEX_FILENAME, index.to_json if index + !!index + end + end + end + + def initialize + raise NotImplementedError, "#{self.class} is an abstract class and cannot be instantiated." if self.class.abstract + end + + def build_page(id, &block) + raise NotImplementedError + end + + def build_pages(&block) + raise NotImplementedError + end + end +end diff --git a/lib/docs/core/entry_index.rb b/lib/docs/core/entry_index.rb new file mode 100644 index 00000000..e8b085d5 --- /dev/null +++ b/lib/docs/core/entry_index.rb @@ -0,0 +1,51 @@ +require 'yajl/json_gem' + +module Docs + class EntryIndex + attr_reader :entries, :types + + def initialize + @entries = [] + @types = Hash.new { |hash, key| hash[key] = Type.new key } + end + + def add(entry) + if entry.is_a? Array + entry.each(&method(:add)) + else + add_entry(entry) unless entry.root? + end + end + + def empty? + @entries.empty? + end + + def length + @entries.length + end + + def as_json + { entries: entries_as_json, types: types_as_json } + end + + def to_json + JSON.generate(as_json) + end + + private + + def add_entry(entry) + @entries << entry.dup + @types[entry.type].count += 1 if entry.type + end + + def entries_as_json + @entries.sort!.map { |entry| entry.as_json } + end + + def types_as_json + @types.values.sort!.map { |type| type.as_json } + end + end +end diff --git a/lib/docs/core/filter.rb b/lib/docs/core/filter.rb new file mode 100644 index 00000000..031c25eb --- /dev/null +++ b/lib/docs/core/filter.rb @@ -0,0 +1,72 @@ +require 'html/pipeline' + +module Docs + class Filter < ::HTML::Pipeline::Filter + def css(*args) + doc.css(*args) + end + + def at_css(*args) + doc.at_css(*args) + end + + def xpath(*args) + doc.xpath(*args) + end + + def at_xpath(*args) + doc.at_xpath(*args) + end + + def base_url + context[:base_url] + end + + def current_url + context[:url] + end + + def root_url + context[:root_url] + end + + def root_path + context[:root_path] + end + + def subpath + @subpath ||= subpath_to(current_url) + end + + def subpath_to(url) + base_url.subpath_to url, ignore_case: true + end + + def slug + @slug ||= subpath.gsub(/\A\//, '').gsub('.html', '') + end + + def root_page? + subpath.blank? || subpath == '/' || subpath == root_path + end + + SCHEME_RGX = /\A[^:\/?#]+:/ + + def fragment_url_string?(str) + str[0] == '#' + end + + def relative_url_string?(str) + !fragment_url_string?(str) && str !~ SCHEME_RGX + end + + def absolute_url_string?(str) + str =~ SCHEME_RGX + end + + def parse_html(html) + warn "#{self.class.name} is re-parsing the document" unless ENV['RACK_ENV'] == 'test' + super + end + end +end diff --git a/lib/docs/core/filter_stack.rb b/lib/docs/core/filter_stack.rb new file mode 100644 index 00000000..7ef3d9b7 --- /dev/null +++ b/lib/docs/core/filter_stack.rb @@ -0,0 +1,58 @@ +module Docs + class FilterStack + extend Forwardable + def_delegators :@filters, :length, :inspect + + attr_reader :filters + + def initialize(filters = nil) + @filters = filters ? filters.dup : [] + end + + def push(*names) + @filters.push *filter_const(names) + end + + def insert(index, *names) + @filters.insert assert_index(index), *filter_const(names) + end + + alias_method :insert_before, :insert + + def insert_after(index, *names) + insert assert_index(index) + 1, *names + end + + def replace(index, name) + @filters[assert_index(index)] = filter_const(name) + end + + def ==(other) + other.is_a?(self.class) && filters == other.filters + end + + def to_a + @filters.dup + end + + def inheritable_copy + self.class.new @filters + end + + private + + def filter_const(name) + if name.is_a? Array + name.map &method(:filter_const) + else + Docs.const_get "#{name}_filter".camelize + end + end + + def assert_index(index) + i = index.is_a?(Integer) ? index : @filters.index(filter_const(index)) + raise "No such filter to insert: #{index}" unless i + i + end + end +end diff --git a/lib/docs/core/instrumentable.rb b/lib/docs/core/instrumentable.rb new file mode 100644 index 00000000..50257929 --- /dev/null +++ b/lib/docs/core/instrumentable.rb @@ -0,0 +1,23 @@ +require 'active_support/notifications' + +module Docs + module Instrumentable + def self.extended(base) + base.send :extend, Methods + end + + def self.included(base) + base.send :include, Methods + end + + module Methods + def instrument(*args, &block) + ActiveSupport::Notifications.instrument(*args, &block) + end + + def subscribe(*args, &block) + ActiveSupport::Notifications.subscribe(*args, &block) + end + end + end +end diff --git a/lib/docs/core/manifest.rb b/lib/docs/core/manifest.rb new file mode 100644 index 00000000..5cfc55a5 --- /dev/null +++ b/lib/docs/core/manifest.rb @@ -0,0 +1,34 @@ +require 'yajl/json_gem' + +module Docs + class Manifest + FILENAME = 'docs.json' + + def initialize(store, docs) + @store = store + @docs = docs + end + + def store + @store.write FILENAME, to_json + end + + def as_json + indexed_docs.map(&:as_json).each do |json| + json[:mtime] = @store.mtime(json[:index_path]).to_i + end + end + + def to_json + JSON.generate(as_json) + end + + private + + def indexed_docs + @docs.select do |doc| + @store.exist? doc.index_path + end + end + end +end diff --git a/lib/docs/core/models/entry.rb b/lib/docs/core/models/entry.rb new file mode 100644 index 00000000..7d5a404d --- /dev/null +++ b/lib/docs/core/models/entry.rb @@ -0,0 +1,35 @@ +module Docs + class Entry + attr_accessor :name, :type, :path + + def initialize(name = nil, path = nil, type = nil) + self.name = name + self.path = path + self.type = type + end + + def ==(other) + other.name == name && other.path == path && other.type == type + end + + def <=>(other) + name.to_s.casecmp(other.name.to_s) + end + + def name=(value) + @name = value.try :strip + end + + def type=(value) + @type = value.try :strip + end + + def root? + path == 'index' + end + + def as_json + { name: name, path: path, type: type } + end + end +end diff --git a/lib/docs/core/models/type.rb b/lib/docs/core/models/type.rb new file mode 100644 index 00000000..2460250b --- /dev/null +++ b/lib/docs/core/models/type.rb @@ -0,0 +1,22 @@ +module Docs + Type = Struct.new :name, :count do + attr_accessor :slug + + def initialize(*args) + super + self.count ||= 0 + end + + def <=>(other) + name.to_s.casecmp(other.name.to_s) + end + + def slug + name.parameterize + end + + def as_json + to_h.merge! slug: slug + end + end +end diff --git a/lib/docs/core/parser.rb b/lib/docs/core/parser.rb new file mode 100644 index 00000000..fc432604 --- /dev/null +++ b/lib/docs/core/parser.rb @@ -0,0 +1,28 @@ +require 'nokogiri' + +module Docs + class Parser + def initialize(content) + @content = content + end + + def html + @html ||= document? ? parse_as_document : parse_as_fragment + end + + private + + def document? + @content =~ /\A\s* 'devdocs.io' } + } + + def self.run(*args, &block) + request = new(*args) + request.on_complete(&block) if block + request.run + end + + def initialize(url, options = {}) + super url.to_s, DEFAULT_OPTIONS.merge(options) + end + + def response=(value) + value.extend Response if value + super + end + + def run + instrument 'response.request', url: base_url do |payload| + response = super + payload[:response] = response + response + end + end + end +end diff --git a/lib/docs/core/requester.rb b/lib/docs/core/requester.rb new file mode 100644 index 00000000..6f2d68a4 --- /dev/null +++ b/lib/docs/core/requester.rb @@ -0,0 +1,48 @@ +require 'typhoeus' + +module Docs + class Requester < Typhoeus::Hydra + attr_reader :request_options + + def self.run(url, options = {}, &block) + requester = new(options) + requester.on_response(&block) if block + requester.request(url) + requester.run + requester + end + + def initialize(options = {}) + @request_options = options.extract!(:request_options)[:request_options].try(:dup) || {} + options[:max_concurrency] ||= 5 + super + end + + def request(url, options = {}, &block) + request = Request.new(url, request_options.merge(options)) + request.on_complete(&block) if block + queue(request) + request + end + + def queue(request) + request.on_complete(&method(:handle_response)) + super + end + + def on_response(&block) + @on_response ||= [] + @on_response << block if block + @on_response + end + + private + + def handle_response(response) + on_response.each do |callback| + result = callback.call(response) + result.each { |url| request(url) } if result.is_a? Array + end + end + end +end diff --git a/lib/docs/core/response.rb b/lib/docs/core/response.rb new file mode 100644 index 00000000..2908c101 --- /dev/null +++ b/lib/docs/core/response.rb @@ -0,0 +1,35 @@ +module Docs + module Response + def success? + code == 200 + end + + def empty? + body.empty? + end + + def mime_type + @mime_type ||= headers['Content-Type'] || 'text/plain' + end + + def html? + mime_type.include? 'html' + end + + def url + @url ||= URL.parse request.base_url + end + + def path + @path ||= url.path + end + + def effective_url + @effective_url ||= URL.parse super + end + + def effective_path + @effective_path ||= effective_url.path + end + end +end diff --git a/lib/docs/core/scraper.rb b/lib/docs/core/scraper.rb new file mode 100644 index 00000000..47010b56 --- /dev/null +++ b/lib/docs/core/scraper.rb @@ -0,0 +1,136 @@ +require 'set' +require 'html/pipeline' + +module Docs + class Scraper < Doc + class << self + attr_accessor :base_url, :root_path, :html_filters, :text_filters, :options + + def inherited(subclass) + super + + subclass.class_eval do + extend AutoloadHelper + autoload_all "docs/filters/#{to_s.demodulize.underscore}", 'filter' + end + + subclass.options = options.deep_dup + subclass.html_filters = html_filters.inheritable_copy + subclass.text_filters = text_filters.inheritable_copy + end + + def filters + html_filters.to_a + text_filters.to_a + end + end + + include Instrumentable + + self.html_filters = FilterStack.new + self.text_filters = FilterStack.new + + self.options = {} + + html_filters.push 'container', 'clean_html', 'normalize_urls', 'internal_urls', 'normalize_paths' + text_filters.push 'inner_html', 'clean_text', 'attribution' + + def base_url + @base_url ||= URL.parse self.class.base_url + end + + def root_url + @root_url ||= root_path? ? URL.parse(File.join(base_url.to_s, root_path)) : base_url.normalize + end + + def root_path + self.class.root_path + end + + def root_path? + root_path.present? && root_path != '/' + end + + def build_page(path) + response = request_one url_for(path) + result = handle_response(response) + yield result if block_given? + result + end + + def build_pages + requested_urls = Set.new [root_url.to_s.downcase] + instrument 'running.scraper', urls: requested_urls.to_a + + request_all root_url.to_s do |response| + next unless data = handle_response(response) + yield data + next unless data[:internal_urls].present? + next_urls = data[:internal_urls].select { |url| requested_urls.add?(url.downcase) } + instrument 'queued.scraper', urls: next_urls + next_urls + end + end + + def options + @options ||= self.class.options.deep_dup.tap do |options| + options.merge! base_url: base_url, root_path: root_path, root_url: root_url + (options[:skip] ||= []).concat ['', '/'] if root_path? + if options[:only] || options[:only_patterns] + (options[:only] ||= []).concat root_path? ? [root_path] : ['', '/'] + end + options.freeze + end + end + + def pipeline + @pipeline ||= ::HTML::Pipeline.new(self.class.filters).tap do |pipeline| + pipeline.instrumentation_service = Docs + end + end + + private + + def request_one(url) + raise NotImplementedError + end + + def request_all(url, &block) + raise NotImplementedError + end + + def process_response?(response) + raise NotImplementedError + end + + def url_for(path) + if path.empty? || path == '/' + root_url.to_s + else + File.join(base_url.to_s, path) + end + end + + def handle_response(response) + if process_response?(response) + instrument 'process_response.scraper', response: response do + process_response(response) + end + else + instrument 'ignore_response.scraper', response: response + end + end + + def process_response(response) + pipeline.call parse(response.body), pipeline_context(response), data = {} + data + end + + def pipeline_context(response) + options.merge url: response.url + end + + def parse(string) + Parser.new(string).html + end + end +end diff --git a/lib/docs/core/scrapers/file_scraper.rb b/lib/docs/core/scrapers/file_scraper.rb new file mode 100644 index 00000000..1138f78e --- /dev/null +++ b/lib/docs/core/scrapers/file_scraper.rb @@ -0,0 +1,35 @@ +module Docs + class FileScraper < Scraper + Response = Struct.new :body, :url + + class << self + attr_accessor :dir + end + + private + + def request_one(url) + Response.new read_file(file_path_for(url)), URL.parse(url) + end + + def request_all(start_url) + queue = [start_url] + until queue.empty? + result = yield request_one(queue.shift) + queue.concat(result) if result.is_a? Array + end + end + + def process_response?(response) + response.body.present? + end + + def file_path_for(url) + File.join self.class.dir, url.sub(base_url.to_s, '') + end + + def read_file(path) + File.read(path) rescue nil + end + end +end diff --git a/lib/docs/core/scrapers/url_scraper.rb b/lib/docs/core/scrapers/url_scraper.rb new file mode 100644 index 00000000..58f8fa73 --- /dev/null +++ b/lib/docs/core/scrapers/url_scraper.rb @@ -0,0 +1,32 @@ +module Docs + class UrlScraper < Scraper + class << self + attr_accessor :params + + def inherited(subclass) + super + subclass.params = params.deep_dup + end + end + + self.params = {} + + private + + def request_one(url) + Request.run url, request_options + end + + def request_all(url, &block) + Requester.run url, request_options: request_options, &block + end + + def request_options + { params: self.class.params } + end + + def process_response?(response) + response.success? && response.html? && base_url.contains?(response.effective_url) + end + end +end diff --git a/lib/docs/core/subscriber.rb b/lib/docs/core/subscriber.rb new file mode 100644 index 00000000..b1c7a2e2 --- /dev/null +++ b/lib/docs/core/subscriber.rb @@ -0,0 +1,53 @@ +require 'active_support/subscriber' + +module Docs + class Subscriber < ActiveSupport::Subscriber + cattr_accessor :namespace + + def self.subscribe_to(notifier) + attach_to(namespace, new, notifier) + end + + private + + delegate :puts, :print, :tty?, to: :$stdout + + def log(msg) + puts "\r" + justify(msg) + end + + def format_url(url) + url.to_s.gsub %r{https?://}, '' + end + + def format_path(path) + path.to_s.gsub File.join(File.expand_path('.'), ''), '' + end + + def justify(str) + return str unless terminal_width + + max_length = if tag = str.slice!(/ \[.+\]\z/) + terminal_width - tag.length + else + terminal_width + end + + str.truncate(max_length).ljust(max_length) << tag.to_s + end + + def terminal_width + return @terminal_width if defined? @terminal_width + + @terminal_width = if !tty? + nil + elsif ENV['COLUMNS'] + ENV['COLUMNS'].to_i + else + `stty size`.scan(/\d+/).last.to_i + end + rescue + @terminal_width = nil + end + end +end diff --git a/lib/docs/core/url.rb b/lib/docs/core/url.rb new file mode 100644 index 00000000..aa897b25 --- /dev/null +++ b/lib/docs/core/url.rb @@ -0,0 +1,119 @@ +require 'uri' +require 'pathname' + +module Docs + class URL < URI::Generic + PARSER = URI::Parser.new + + def initialize(*args) + if args.empty? + super(*Array.new(9)) + elsif args.length == 1 && args.first.is_a?(Hash) + args.first.assert_valid_keys URI::Generic::COMPONENT + super(*args.first.values_at(*URI::Generic::COMPONENT)) + else + super + end + end + + def self.parse(url) + return url if url.kind_of? self + new(*PARSER.split(url), PARSER) + end + + def self.join(*args) + PARSER.join(*args) + end + + def join(*args) + self.class.join self, *args + end + + def merge!(hash) + return super unless hash.is_a? Hash + hash.assert_valid_keys URI::Generic::COMPONENT + hash.each_pair do |key, value| + send "#{key}=", value + end + self + end + + def merge(hash) + return super unless hash.is_a? Hash + dup.merge!(hash) + end + + def origin + if scheme && host + origin = "#{scheme}://#{host}" + origin.downcase! + origin << ":#{port}" if port + origin + else + nil + end + end + + def normalized_path + path == '' ? '/' : path + end + + def subpath_to(url, options = nil) + assert_absolute self, url = self.class.parse(url) + return unless origin == url.origin + + base = path + dest = url.path + + if options && options[:ignore_case] + base = base.downcase + dest = dest.downcase + end + + if base == dest + '' + elsif dest.start_with? File.join(base, '') + url.path[(path.length)..-1] + end + end + + def subpath_from(url, options = nil) + self.class.parse(url).subpath_to(self, options) + end + + def contains?(url, options = nil) + !!subpath_to(url, options) + end + + def relative_path_to(url) + assert_absolute self, url = self.class.parse(url) + return unless origin == url.origin + + base_dir = Pathname.new(normalized_path) + base_dir = base_dir.parent unless path.end_with? '/' + + dest = url.normalized_path + dest_dir = Pathname.new(dest) + + if dest.end_with? '/' + dest_dir.relative_path_from(base_dir).to_s.tap do |result| + result << '/' if result != '.' + end + else + dest_dir.parent.relative_path_from(base_dir).join(::File.basename(dest)).to_s + end + end + + def relative_path_from(url) + self.class.parse(url).relative_path_to(self) + end + + private + + def assert_absolute(*args) + args.each do |url| + raise ArgumentError, "Expected absolute URL, got: #{url.to_s}" if url.relative? + end + end + end +end diff --git a/lib/docs/filters/backbone/clean_html.rb b/lib/docs/filters/backbone/clean_html.rb new file mode 100644 index 00000000..83c8618c --- /dev/null +++ b/lib/docs/filters/backbone/clean_html.rb @@ -0,0 +1,25 @@ +module Docs + class Backbone + class CleanHtmlFilter < Filter + def call + # Remove Introduction, Upgrading, etc. + while doc.child['id'] != 'Events' + doc.child.remove + end + + # Remove Examples, FAQ, etc. + while doc.children.last['id'] != 'examples' + doc.children.last.remove + end + + css('#examples', '.run').remove + + css('tt').each do |node| + node.name = 'code' + end + + doc + end + end + end +end diff --git a/lib/docs/filters/backbone/entries.rb b/lib/docs/filters/backbone/entries.rb new file mode 100644 index 00000000..aca15d26 --- /dev/null +++ b/lib/docs/filters/backbone/entries.rb @@ -0,0 +1,62 @@ +module Docs + class Backbone + class EntriesFilter < Docs::EntriesFilter + def additional_entries + entries = [] + type = nil + + css('[id]').each do |node| + # Module + if node.name == 'h2' + type = node.content.gsub 'Backbone.', '' + if type.capitalize! # sync, history + entries << [node.content, node['id'], type] + end + next + end + + # Built-in events + if node['id'] == 'Events-catalog' + node.next_element.css('li').each do |li| + name = "#{li.at_css('b').content.delete('"').strip} event" + id = name.parameterize + li['id'] = id + entries << [name, id, type] unless name == entries.last[0] + end + next + end + + # Method + name = node.at_css('.header').content.split.first + + # Underscore methods + if name == 'Underscore' + node.next_element.css('li').each do |li| + name = [type.downcase, li.at_css('a').content.split.first].join('.') + id = name.parameterize + li['id'] = id + entries << [name, id, type] + end + next + end + + if %w(Events Sync).include?(type) + name.prepend 'Backbone.' + elsif type == 'History' + name.prepend 'Backbone.history.' + elsif name == 'extend' + name.prepend "#{type}." + elsif name.start_with? 'constructor' + name = type + elsif type != 'Utility' + name.prepend "#{type.downcase}." + end + + entries << [name, node['id'], type] + end + + entries + end + end + end +end diff --git a/lib/docs/filters/coffeescript/clean_html.rb b/lib/docs/filters/coffeescript/clean_html.rb new file mode 100644 index 00000000..dcbc030b --- /dev/null +++ b/lib/docs/filters/coffeescript/clean_html.rb @@ -0,0 +1,58 @@ +module Docs + class Coffeescript + class CleanHtmlFilter < Filter + def call + css('#top', '.minibutton', '.clear').remove + + # Set id attributes on actual elements instead of an empty + css('.bookmark').each do |node| + if node.parent.name == 'h2' + node.parent['id'] = node['id'] + elsif node.next_element.name == 'b' + node.next_element['id'] = node['id'] + end + node.remove + end + + # Remove Books, Screencasts, etc. + css('#scripts ~ *', '#scripts').remove + + # Make proper headings + css('.header').each do |node| + node.parent.before(node) + node.name = 'h3' + node['id'] ||= node.content.strip.parameterize + node.remove_attribute 'class' + end + + # Remove "Latest Version" paragraph + css('b').each do |node| + if node.content =~ /Latest Version/i + node.parent.next_element.remove + node.parent.remove + break + end + end + + # Remove "examples can be run" paragraph + css('i').each do |node| + if node.content =~ /examples can be run/i + node.parent.remove + break + end + end + + # Remove code highlighting + css('pre').each do |node| + node.content = node.content + end + + css('tt').each do |node| + node.name = 'code' + end + + doc + end + end + end +end diff --git a/lib/docs/filters/coffeescript/entries.rb b/lib/docs/filters/coffeescript/entries.rb new file mode 100644 index 00000000..a229d4f2 --- /dev/null +++ b/lib/docs/filters/coffeescript/entries.rb @@ -0,0 +1,62 @@ +module Docs + class Coffeescript + class EntriesFilter < Docs::EntriesFilter + ENTRIES = [ + ['coffee command', 'usage', 'Miscellaneous'], + ['Literate mode', 'literate', 'Miscellaneous'], + ['Functions', 'literals', 'Language'], + ['->', 'literals', 'Statements'], + ['Objects and arrays', 'objects_and_arrays', 'Language'], + ['Lexical scoping', 'lexical-scope', 'Language'], + ['if...then...else', 'conditionals', 'Statements'], + ['unless', 'conditionals', 'Statements'], + ['... splats', 'splats', 'Language'], + ['for...in', 'loops', 'Statements'], + ['for...in...by', 'loops', 'Statements'], + ['for...of', 'loops', 'Statements'], + ['while', 'loops', 'Statements'], + ['until', 'loops', 'Statements'], + ['loop', 'loops', 'Statements'], + ['do', 'loops', 'Statements'], + ['Array slicing and splicing', 'slices', 'Language'], + ['Expressions', 'expressions', 'Language'], + ['?', 'the-existential-operator', 'Operators'], + ['?=', 'the-existential-operator', 'Operators'], + ['?.', 'the-existential-operator', 'Operators'], + ['class', 'classes', 'Statements'], + ['extends', 'classes', 'Operators'], + ['super', 'classes', 'Statements'], + ['::', 'classes', 'Operators'], + ['Destructuring assignment', 'destructuring', 'Language'], + ['=>', 'fat-arrow', 'Statements'], + ['Embedded JavaScript', 'embedded', 'Language'], + ['switch...when...else', 'switch', 'Statements'], + ['try...catch...finally', 'try', 'Statements'], + ['Chained comparisons', 'comparisons', 'Language'], + ['#{} interpolation', 'strings', 'Language'], + ['Block strings', 'strings', 'Language'], + ['Block comments', 'strings', 'Language'], + ['Block regular expressions', 'regexes', 'Language'], + ['cake command', 'cake', 'Miscellaneous'], + ['Cakefile', 'cake', 'Miscellaneous'], + ['Source maps', 'source-maps', 'Miscellaneous'] + ] + + def additional_entries + entries = ENTRIES.dup + + # Operators + css('.definitions td:first-child > code').each do |node| + node.content.split(', ').each do |name| + next if %w(true false yes no on off this).include?(name) + id = name.parameterize + node['id'] = id + entries << [name, id, 'Operators'] + end + end + + entries + end + end + end +end diff --git a/lib/docs/filters/core/attribution.rb b/lib/docs/filters/core/attribution.rb new file mode 100644 index 00000000..5033d62b --- /dev/null +++ b/lib/docs/filters/core/attribution.rb @@ -0,0 +1,27 @@ +module Docs + class AttributionFilter < Filter + def call + html << attribution_info if attribution + html + end + + def attribution + context[:attribution] + end + + def attribution_url + current_url.to_s + end + + def attribution_info + <<-HTML.strip_heredoc +
    +

    + #{attribution.delete "\n"}
    + #{attribution_url} +

    +
    + HTML + end + end +end diff --git a/lib/docs/filters/core/clean_html.rb b/lib/docs/filters/core/clean_html.rb new file mode 100644 index 00000000..3c575d17 --- /dev/null +++ b/lib/docs/filters/core/clean_html.rb @@ -0,0 +1,17 @@ +module Docs + class CleanHtmlFilter < Filter + def call + css('script', 'style').remove + xpath('descendant::comment()').remove + xpath('./text()', './/text()[not(ancestor::pre) and not(ancestor::code)]').each do |node| + content = node.content + next unless content.valid_encoding? + content.gsub! %r{\A[[:space:]]+}, ' ' + content.gsub! %r{[[:space:]]+\z}, ' ' + content.gsub! %r{[[:space:]]+}, ' ' + node.content = content + end + doc + end + end +end diff --git a/lib/docs/filters/core/clean_text.rb b/lib/docs/filters/core/clean_text.rb new file mode 100644 index 00000000..e525c8a5 --- /dev/null +++ b/lib/docs/filters/core/clean_text.rb @@ -0,0 +1,11 @@ +module Docs + class CleanTextFilter < Filter + EMPTY_NODES_RGX = /<(?!td|th|iframe)(\w+)[^>]*>[[:space:]]*<\/\1>/ + + def call + html.strip! + while html.gsub!(EMPTY_NODES_RGX, ''); end + html + end + end +end diff --git a/lib/docs/filters/core/container.rb b/lib/docs/filters/core/container.rb new file mode 100644 index 00000000..26337801 --- /dev/null +++ b/lib/docs/filters/core/container.rb @@ -0,0 +1,16 @@ +module Docs + class ContainerFilter < Filter + class ContainerNotFound < StandardError; end + + def call + container = context[:container] + container = container.call self if container.is_a? Proc + + if container + doc.at_css(container) || raise(ContainerNotFound, "element '#{container}' could not be found in the document") + else + doc + end + end + end +end diff --git a/lib/docs/filters/core/entries.rb b/lib/docs/filters/core/entries.rb new file mode 100644 index 00000000..2f358a74 --- /dev/null +++ b/lib/docs/filters/core/entries.rb @@ -0,0 +1,60 @@ +module Docs + class EntriesFilter < Filter + def call + result[:entries] = entries + doc + end + + def entries + entries = [] + entries << default_entry if include_default_entry? + entries.concat(additional_entries) + build_entries(entries) + end + + def include_default_entry? + true + end + + def default_entry + [name] + end + + def additional_entries + [] + end + + def name + return @name if defined? @name + @name = root_page? ? nil : get_name + end + + def get_name + slug.to_s.gsub('_', ' ').gsub('/', '.').squish! + end + + def type + return @type if defined? @type + @type = root_page? ? nil : get_type + end + + def get_type + nil + end + + def path + result[:path] + end + + def build_entries(entries) + entries.map do |attributes| + build_entry(*attributes) + end + end + + def build_entry(name, frag = nil, type = nil) + type ||= self.type + Entry.new name, frag ? "#{path}##{frag}" : path, type + end + end +end diff --git a/lib/docs/filters/core/inner_html.rb b/lib/docs/filters/core/inner_html.rb new file mode 100644 index 00000000..bc89c99e --- /dev/null +++ b/lib/docs/filters/core/inner_html.rb @@ -0,0 +1,9 @@ +module Docs + class InnerHtmlFilter < Filter + def call + html = doc.inner_html + html = html.encode('UTF-16', invalid: :replace, replace: '').encode('UTF-8') unless html.valid_encoding? + html + end + end +end diff --git a/lib/docs/filters/core/internal_urls.rb b/lib/docs/filters/core/internal_urls.rb new file mode 100644 index 00000000..d37435a5 --- /dev/null +++ b/lib/docs/filters/core/internal_urls.rb @@ -0,0 +1,79 @@ +require 'set' + +module Docs + class InternalUrlsFilter < Filter + def call + internal_urls = Set.new + + css('a').each do |link| + next if skip_link?(link) + next unless url = parse_href(link['href']) + next unless subpath = subpath_to(url) + + normalize_subpath(subpath) + + next if skip_subpath?(subpath) + + normalize_internal_url(url, subpath) + + link['href'] = internal_path_to(url) + internal_urls << url.merge!(fragment: nil).to_s + end + + result[:internal_urls] = internal_urls.to_a + doc + end + + def skip_link?(link) + context[:skip_links] && context[:skip_links].call(link) + end + + def parse_href(str) + str && absolute_url_string?(str) && URL.parse(str) + rescue URI::InvalidURIError + nil + end + + def normalize_subpath(path) + case context[:trailing_slash] + when true + path << '/' unless path.end_with?('/') + when false + path.slice!(-1) if path.end_with?('/') + end + end + + def skip_subpath?(path) + if context[:only] || context[:only_patterns] + return true unless context[:only].try(:any?) { |value| path.casecmp(value) == 0 } || + context[:only_patterns].try(:any?) { |value| path =~ value } + end + + if context[:skip] || context[:skip_patterns] + return true if context[:skip].try(:any?) { |value| path.casecmp(value) == 0 } || + context[:skip_patterns].try(:any?) { |value| path =~ value } + end + + false + end + + def normalize_internal_url(url, path) + url.normalize! + url.merge! path: base_url.path + path + end + + def internal_path_to(url) + url = index_url if url == root_url + path = effective_url.relative_path_to(url) + URL.new(path: path, query: url.query, fragment: url.fragment).to_s + end + + def index_url + @index_url ||= base_url.merge path: File.join(base_url.path, '') + end + + def effective_url + @effective_url ||= current_url == root_url ? index_url : current_url + end + end +end diff --git a/lib/docs/filters/core/normalize_paths.rb b/lib/docs/filters/core/normalize_paths.rb new file mode 100644 index 00000000..c78ce0c2 --- /dev/null +++ b/lib/docs/filters/core/normalize_paths.rb @@ -0,0 +1,49 @@ +module Docs + class NormalizePathsFilter < Filter + def call + result[:path] = path + result[:store_path] = store_path + + css('a').each do |link| + next unless (href = link['href']) && relative_url_string?(href) + link['href'] = normalize_href(href) + end + + doc + end + + def path + @path ||= root_page? ? 'index' : normalized_subpath + end + + def store_path + File.extname(path) != '.html' ? "#{path}.html" : path + end + + def normalized_subpath + normalize_path subpath.sub(/\A\//, '') + end + + def normalize_href(href) + url = URL.parse(href) + url.path = normalize_path(url.path) + url + rescue URI::InvalidURIError + href + end + + def normalize_path(path) + path = path.downcase + + if path == '.' + 'index' + elsif path.end_with? '/' + "#{path}index" + elsif path.end_with? '.html' + path[0..-6] + else + path + end + end + end +end diff --git a/lib/docs/filters/core/normalize_urls.rb b/lib/docs/filters/core/normalize_urls.rb new file mode 100644 index 00000000..1fc28d72 --- /dev/null +++ b/lib/docs/filters/core/normalize_urls.rb @@ -0,0 +1,48 @@ +module Docs + class NormalizeUrlsFilter < Filter + ATTRIBUTES = { a: 'href', img: 'src', iframe: 'src' } + + def call + ATTRIBUTES.each_pair do |tag, attribute| + update_attribute(tag, attribute) + end + doc + end + + def update_attribute(tag, attribute) + css(tag.to_s).each do |node| + next unless value = node[attribute] + next if fragment_url_string?(value) + node[attribute] = normalize_url(value) + end + end + + def normalize_url(str) + url = to_absolute_url(str) + fix_url(url) + fix_url_string(url.to_s) + rescue URI::InvalidURIError + '#' + end + + def to_absolute_url(str) + url = URL.parse(str) + url.relative? ? current_url.join(url) : url + end + + def fix_url(url) + return unless context[:replace_paths] + path = subpath_to(url) + + if context[:replace_paths].has_key?(path) + url.path = url.path.sub %r[#{path}\z], context[:replace_paths][path] + end + end + + def fix_url_string(str) + str = context[:replace_urls][str] || str if context[:replace_urls] + str = context[:fix_urls].call(str) || str if context[:fix_urls] + str + end + end +end diff --git a/lib/docs/filters/core/title.rb b/lib/docs/filters/core/title.rb new file mode 100644 index 00000000..76860af8 --- /dev/null +++ b/lib/docs/filters/core/title.rb @@ -0,0 +1,29 @@ +module Docs + class TitleFilter < Filter + def call + title = self.title + doc.child.before node(title) if title + doc + end + + def title + if !context[:root_title].nil? && root_page? + context[:root_title] + elsif !context[:title].nil? + context[:title].is_a?(Proc) ? context[:title].call(self) : context[:title] + else + default_title + end + end + + def default_title + result[:entries].try(:first).try(:name) + end + + def node(content) + node = Nokogiri::XML::Node.new 'h1', doc + node.content = content + node + end + end +end diff --git a/lib/docs/filters/css/clean_html.rb b/lib/docs/filters/css/clean_html.rb new file mode 100644 index 00000000..261e1ed1 --- /dev/null +++ b/lib/docs/filters/css/clean_html.rb @@ -0,0 +1,24 @@ +module Docs + class Css + class CleanHtmlFilter < Filter + def call + root_page? ? root : other + doc + end + + def root + # Remove "CSS3 Tutorials" and everything after + css('#CSS3_Tutorials ~ *', '#CSS3_Tutorials').remove + end + + def other + # Remove "|" and "||" links in syntax box (e.g. animation, all, etc.) + css('.syntaxbox', '.twopartsyntaxbox').css('a').each do |node| + if node.content == '|' || node.content == '||' + node.replace node.content + end + end + end + end + end +end diff --git a/lib/docs/filters/css/entries.rb b/lib/docs/filters/css/entries.rb new file mode 100644 index 00000000..0a83826e --- /dev/null +++ b/lib/docs/filters/css/entries.rb @@ -0,0 +1,92 @@ +module Docs + class Css + class EntriesFilter < Docs::EntriesFilter + DATA_TYPE_SLUGS = %w(angle color_value counter frequency gradient image + integer length number percentage position_value ratio resolution shape + string time timing-function uri user-ident) + + FUNCTION_SLUGS = %w(attr calc cross-fade cubic-bezier cycle element + linear-gradient radial-gradient repeating-linear-gradient + repeating-radial-gradient var) + + PSEUDO_ELEMENT_SLUGS = %w(::after ::before ::first-letter ::first-line + ::selection) + + VALUE_SLUGS = %w(auto inherit initial none normal) + + ADDITIONAL_ENTRIES = { + 'shape' => [ + %w(rect() Syntax Functions) ], + 'uri' => [ + %w(url() The_url()_functional_notation Functions) ], + 'timing-function' => [ + %w(cubic-bezier() The_cubic-bezier()_class_of_timing-functions Functions), + %w(steps() The_steps()_class_of_timing-functions Functions), + %w(linear linear Values), + %w(ease ease Values), + %w(ease-in ease-in Values), + %w(ease-in-out ease-in-out Values), + %w(ease-out ease-out Values), + %w(step-start step-start Values), + %w(step-end step-end Values) ], + 'color_value' => [ + %w(transparent transparent_keyword Values), + %w(currentColor currentColor_keyword Values), + %w(rgb() rgb() Functions), + %w(hsl() hsl() Functions), + %w(rgba() rgba() Functions), + %w(hsla() hsla() Functions) ], + 'transform-function' => [ + %w(matrix() matrix() Functions), + %w(matrix3d() matrix3d() Functions), + %w(rotate() rotate() Functions), + %w(rotate3d() rotate3d() Functions), + %w(rotateX() rotateX() Functions), + %w(rotateY() rotateY() Functions), + %w(rotateZ() rotateZ() Functions), + %w(scale() scale() Functions), + %w(scale3d() scale3d() Functions), + %w(scaleX() scaleX() Functions), + %w(scaleY() scaleY() Functions), + %w(scaleZ() scaleZ() Functions), + %w(skew() skew() Functions), + %w(skewX() skewX() Functions), + %w(skewY() skewY() Functions), + %w(translate() translate() Functions), + %w(translate3d() translate3d() Functions), + %w(translateX() translateX() Functions), + %w(translateY() translateY() Functions), + %w(translateZ() translate(Z) Functions) ]} + + def get_name + case type + when 'Data Types' then "<#{super.gsub(' value', '')}>" + when 'Functions' then "#{super}()" + else super + end + end + + def get_type + if slug.end_with? 'selectors' + 'Selectors' + elsif slug.start_with? ':' + PSEUDO_ELEMENT_SLUGS.include?(slug) ? 'Pseudo-elements' : 'Pseudo-classes' + elsif slug.start_with? '@' + 'At-rules' + elsif DATA_TYPE_SLUGS.include?(slug) + 'Data Types' + elsif FUNCTION_SLUGS.include?(slug) + 'Functions' + elsif VALUE_SLUGS.include?(slug) + 'Values' + else + 'Properties' + end + end + + def additional_entries + ADDITIONAL_ENTRIES[slug] || [] + end + end + end +end diff --git a/lib/docs/filters/dom/clean_html.rb b/lib/docs/filters/dom/clean_html.rb new file mode 100644 index 00000000..a022e075 --- /dev/null +++ b/lib/docs/filters/dom/clean_html.rb @@ -0,0 +1,28 @@ +module Docs + class Dom + class CleanHtmlFilter < Filter + def call + root_page? ? root : other + doc + end + + def root + end + + def other + # Bug fix: HTMLElement.offsetWidth + css('#offsetContainer .comment').remove + + # Bug fix: CompositionEvent, DataTransfer, etc. + if (div = at_css('div[style]')) && div['style'].include?('border: solid #ddd 2px') + div.remove + end + + # Remove double heading on SVG pages + if slug.start_with? 'SVG' + at_css('h2:first-child').try(:remove) + end + end + end + end +end diff --git a/lib/docs/filters/dom/entries.rb b/lib/docs/filters/dom/entries.rb new file mode 100644 index 00000000..6df28a2e --- /dev/null +++ b/lib/docs/filters/dom/entries.rb @@ -0,0 +1,117 @@ +module Docs + class Dom + class EntriesFilter < Docs::EntriesFilter + TYPE_BY_SPEC = { + 'Battery Status' => 'Battery Status', + 'Canvas ' => 'Canvas', + 'CSS Object Model' => 'CSS', + 'Device Orientation' => 'Device Orientation', + 'Encoding' => 'Encoding', + 'File API' => 'File', + 'Geolocation' => 'Geolocation', + 'Media Capture' => 'Media', + 'Media Source' => 'Media', + 'Navigation Timing' => 'Navigation Timing', + 'Network Information' => 'Network Information', + 'Web Audio' => 'Web Audio', + 'Web Workers' => 'Web Workers' } + + TYPE_BY_NAME_STARTS_WITH = { + 'Canvas' => 'Canvas', + 'ChildNode' => 'Node', + 'console' => 'Console', + 'CSS' => 'CSS', + 'document' => 'Document', + 'DocumentFragment' => 'DocumentFragment', + 'DOM' => 'DOM', + 'element' => 'Element', + 'event' => 'Event', + 'Event' => 'Event', + 'File' => 'File', + 'GlobalEventHandlers' => 'GlobalEventHandlers', + 'history' => 'History', + 'IDB' => 'IndexedDB', + 'Location' => 'Location', + 'navigator' => 'Navigator', + 'Node' => 'Node', + 'Notification' => 'Notification', + 'ParentNode' => 'Node', + 'Range' => 'Range', + 'Selection' => 'Selection', + 'StyleSheet' => 'CSS', + 'SVG' => 'SVG', + 'Touch' => 'Touch', + 'TreeWalker' => 'TreeWalker', + 'Uint' => 'Typed Arrays', + 'URL' => 'URL', + 'window' => 'window', + 'XMLHttpRequest' => 'XMLHTTPRequest' } + + TYPE_BY_NAME_INCLUDES = { + 'WebGL' => 'Canvas', + 'Worker' => 'Web Workers' } + + TYPE_BY_NAME_MATCHES = { + /HTML\w*Element/ => 'Elements' } + + TYPE_BY_HAS_LINK_TO = { + 'DeviceOrientation specification' => 'Device Orientation', + 'File System API' => 'File', + 'Typed Array' => 'Typed Arrays', + 'WebSocket' => 'Web Sockets', + 'Web Audio API' => 'Web Audio', + 'XMLHTTPRequest' => 'XMLHTTPRequest' } + + def get_name + name = super + name.sub! 'Input.', 'HTMLInputElement.' + name.sub! 'window.navigator', 'navigator' + # Comment.Comment => Comment.constructor + name.sub! %r{\A(\w+)\.\1\z}, '\1.constructor' unless name == 'window.window' + name + end + + def get_type + TYPE_BY_NAME_STARTS_WITH.each_pair do |key, value| + return value if name.start_with?(key) + end + + TYPE_BY_NAME_INCLUDES.each_pair do |key, value| + return value if name.include?(key) + end + + TYPE_BY_NAME_MATCHES.each_pair do |key, value| + return value if name =~ key + end + + if spec = css('.standard-table').last + spec = spec.content + TYPE_BY_SPEC.each_pair do |key, value| + return value if spec.include?(key) + end + end + + links_text = css('a').map(&:content).join + TYPE_BY_HAS_LINK_TO.each_pair do |key, value| + return value if links_text.include?(key) + end + + if name.include? 'Event' + 'Events' + else + 'Miscellaneous' + end + end + + def include_default_entry? + if (node = at_css('.obsolete', '.deprecated')) && + (node.inner_html.include?('removed from the Web') || + node.inner_html.include?('Try to avoid using it')) + false + else + true + end + end + end + end +end diff --git a/lib/docs/filters/dom_events/clean_html.rb b/lib/docs/filters/dom_events/clean_html.rb new file mode 100644 index 00000000..4e23d5fc --- /dev/null +++ b/lib/docs/filters/dom_events/clean_html.rb @@ -0,0 +1,31 @@ +module Docs + class DomEvents + class CleanHtmlFilter < Filter + def call + root_page? ? root : other + doc + end + + def root + # Remove parapraph mentioning non-standard events + at_css('#Standard_events').previous_element.remove + + # Remove everything after "Standard events" + css('.standard-table ~ *').remove + + # Remove events we don't want + css('tr').each do |tr| + if td = tr.at_css('td:nth-child(3)') + tr.remove if td.content =~ /IndexedDB|Media|Audio|SVG|Battery|Gamepad|Sensor/i + end + end + end + + def other + css('#General_info + dl').each do |node| + node['class'] = 'eventinfo' + end + end + end + end +end diff --git a/lib/docs/filters/dom_events/entries.rb b/lib/docs/filters/dom_events/entries.rb new file mode 100644 index 00000000..eb23c11b --- /dev/null +++ b/lib/docs/filters/dom_events/entries.rb @@ -0,0 +1,62 @@ +module Docs + class DomEvents + class EntriesFilter < Docs::EntriesFilter + TYPE_BY_INFO = { + 'applicationCache' => 'Application Cache', + 'Clipboard' => 'Clipboard', + 'CSS' => 'CSS', + 'Drag' => 'Drag & Drop', + 'Focus' => 'Focus', + 'HashChange' => 'History', + 'Keyboard' => 'Keyboard', + 'Mouse' => 'Mouse', + 'Offline' => 'Offline', + 'Orientation' => 'Device', + 'Sensor' => 'Device', + 'Page Visibility' => 'Page Visibility', + 'Pointer' => 'Mouse', + 'PopState' => 'History', + 'Progress' => 'Progress', + 'Proximity' => 'Device', + 'Server Sent' => 'Server Sent Events', + 'Storage' => 'Web Storage', + 'Touch' => 'Touch', + 'Transition' => 'CSS', + 'PageTransition' => 'History', + 'WebSocket' => 'WebSocket', + 'Web Messaging' => 'Web Messaging', + 'Wheel' => 'Mouse', + 'Worker' => 'Web Workers' } + + FORM_SLUGS = %w(change compositionend compositionstart compositionupdate + input invalid reset select submit) + LOAD_SLUGS = %w(abort beforeunload DOMContentLoaded error load + readystatechange unload) + + APPEND_TYPE = %w(Application\ Cache Progress Server\ Sent\ Events + WebSocket Web\ Messaging Web\ Workers) + + def get_name + name = super.split.first + name << " (#{type})" if APPEND_TYPE.include?(type) + name + end + + def get_type + if FORM_SLUGS.include?(slug) + 'Form' + elsif LOAD_SLUGS.include?(slug) + 'Load' + else + if info = at_css('.eventinfo').try(:content) + TYPE_BY_INFO.each_pair do |key, value| + return value if info.include?(key) + end + end + + 'Miscellaneous' + end + end + end + end +end diff --git a/lib/docs/filters/ember/clean_html.rb b/lib/docs/filters/ember/clean_html.rb new file mode 100644 index 00000000..a00ba969 --- /dev/null +++ b/lib/docs/filters/ember/clean_html.rb @@ -0,0 +1,65 @@ +module Docs + class Ember + class CleanHtmlFilter < Filter + def call + root_page? ? root : other + doc + end + + def root + css('#back-to-top').remove + + # Remove "Projects" and "Tag" links + css('.level-1:nth-child(1)', '.level-1:nth-child(2)').remove + + # Turn section links (e.g. Modules) into headings + css('.level-1 > a').each do |node| + node.name = 'h2' + node.remove_attribute 'href' + end + + # Remove root-level list + css('.level-1').each do |node| + node.before(node.elements).remove + end + + css('ol').each do |node| + node.name = 'ul' + end + end + + def other + css(*%w(hr .edit-page #api-options .toc-anchor .inherited .protected .private .deprecated)).remove + + # Remove tabs and "Index" + css('.tabs').each do |node| + panes = node.css '#methods', '#events', '#properties' + panes.remove_attr 'style' + node.before(panes).remove + end + + css('.method', '.property', '.event').remove_attr('id') + + css('h3[data-id]').each do |node| + # Put id attributes on headings + node.name = 'h2' + node['id'] = node['data-id'] + node.remove_attribute 'data-id' + node.content = node.content + + # Move headings, span.args, etc. into a div.title + div = Nokogiri::XML::Node.new 'div', doc + div['class'] = 'title' + node.before(div).parent = div + div.add_child(div.next_element) while div.next_element.name == 'span' + end + + # Remove code highlighting + css('.highlight').each do |node| + node.content = node.at_css('.code pre').content + node.name = 'pre' + end + end + end + end +end diff --git a/lib/docs/filters/ember/entries.rb b/lib/docs/filters/ember/entries.rb new file mode 100644 index 00000000..4a915d08 --- /dev/null +++ b/lib/docs/filters/ember/entries.rb @@ -0,0 +1,59 @@ +module Docs + class Ember + class EntriesFilter < Docs::EntriesFilter + def include_default_entry? + name != 'Handlebars Helpers' + end + + def get_name + name = at_css('.api-header').content.split.first + # Remove "Ember." prefix if the next character is uppercase + name.sub! %r{\AEmber\.([A-Z])}, '\1' + name == 'Handlebars.helpers' ? 'Handlebars Helpers' : name + end + + def get_type + # Group modules together + if at_css('.api-header').content.include?('Module') + 'Modules' + # Group "Ember Data" together + elsif name.start_with? 'DS' + 'Data' + else + name + end + end + + def additional_entries + css('.item-entry').map do |node| + heading = node.at_css('h2') + name = heading.content.strip + + if self.name == 'Handlebars Helpers' + name << ' (handlebars helper)' + next [name, heading['id']] + end + + # Give their own type to "Ember.platform", "Ember.run", etc. + if self.type != 'Data' && name.include?('.') + type = "#{self.name}.#{name.split('.').first}" + end + + # "." = class method, "#" = instance method + separator = '#' + separator = '.' if self.name == 'Ember' || self.name.split('.').last =~ /\A[a-z]/ || node.at_css('.static') + + name.prepend self.name + separator + + # Fix bug in DS.Adapter / "{Array} ids" and "{DS.Model} record" + name.sub! %r[\{.+\}\s*], '' + + name << '()' if node['class'].include? 'method' + name << ' event' if node['class'].include? 'event' + + [name, heading['id'], type] + end + end + end + end +end diff --git a/lib/docs/filters/html/clean_html.rb b/lib/docs/filters/html/clean_html.rb new file mode 100644 index 00000000..60b49b29 --- /dev/null +++ b/lib/docs/filters/html/clean_html.rb @@ -0,0 +1,19 @@ +module Docs + class Html + class CleanHtmlFilter < Filter + def call + root_page? ? root : other + doc + end + + def root + css('p').each do |node| + node.remove if node.content.lstrip.start_with? 'The symbol' + end + end + + def other + end + end + end +end diff --git a/lib/docs/filters/html/entries.rb b/lib/docs/filters/html/entries.rb new file mode 100644 index 00000000..2bc34533 --- /dev/null +++ b/lib/docs/filters/html/entries.rb @@ -0,0 +1,35 @@ +module Docs + class Html + class EntriesFilter < Docs::EntriesFilter + HTML5 = %w(menuitem) + OBSOLETE = %w(frame frameset hgroup noframes) + ADDITIONAL_ENTRIES = { + 'Heading_Elements' => [%w(h1), %w(h2), %w(h3), %w(h4), %w(h5), %w(h6)] } + + def get_name + super.downcase + end + + def get_type + if at_css('.obsoleteHeader', '.deprecatedHeader', '.nonStandardHeader') || OBSOLETE.include?(slug) + 'Obsolete' + else + spec = css('.standard-table').last.try(:content) + if (spec && spec =~ /HTML\s?5/ && spec !~ /HTML\s?4/) || HTML5.include?(slug) + 'HTML5' + else + 'Standard' + end + end + end + + def include_default_entry? + slug != 'Heading_Elements' + end + + def additional_entries + ADDITIONAL_ENTRIES[slug] || [] + end + end + end +end diff --git a/lib/docs/filters/http/clean_html.rb b/lib/docs/filters/http/clean_html.rb new file mode 100644 index 00000000..fa495313 --- /dev/null +++ b/lib/docs/filters/http/clean_html.rb @@ -0,0 +1,49 @@ +module Docs + class Http + class CleanHtmlFilter < Filter + def call + root_page? ? root : other + doc + end + + def root + # Change title + title = at_css 'h2' + title.name = 'h1' + title.inner_html = 'Hypertext Transfer Protocol — HTTP/1.1' + + # Remove "..." following each link + css('span').each do |node| + node.inner_html = node.first_element_child if node.first_element_child + end + end + + def other + at_css('address').remove + + # Change title + title = at_css 'h2' + title.name = 'h1' + title.at_css('a').remove + title.content = "HTTP #{title.content}" + + # Update headings + css('h3').each do |node| + link = node.at_css('a') + node.name = "h#{link.content.count('.') + 1}" + node['id'] = link['id'] + link.remove + end + + # Merge adjacent
     tags and remove indentation
    +        css('pre').each do |node|
    +          while (sibling = node.next_element) && sibling.name == 'pre'
    +            node.inner_html += "\n#{sibling.inner_html}"
    +            sibling.remove
    +          end
    +          node.inner_html = node.inner_html.strip_heredoc
    +        end
    +      end
    +    end
    +  end
    +end
    diff --git a/lib/docs/filters/http/entries.rb b/lib/docs/filters/http/entries.rb
    new file mode 100644
    index 00000000..6bb7a606
    --- /dev/null
    +++ b/lib/docs/filters/http/entries.rb
    @@ -0,0 +1,21 @@
    +module Docs
    +  class Http
    +    class EntriesFilter < Docs::EntriesFilter
    +      def get_type
    +        at_css('h1').content.gsub(/\A\s*HTTP\s+(.+)\s+Definitions\s*\z/, '\1').pluralize
    +      end
    +
    +      def include_default_entry?
    +        false
    +      end
    +
    +      def additional_entries
    +        return [] if root_page?
    +
    +        css(type == 'Status Codes' ? 'h3' : 'h2').map do |node|
    +          [node.content, node['id']]
    +        end
    +      end
    +    end
    +  end
    +end
    diff --git a/lib/docs/filters/javascript/clean_html.rb b/lib/docs/filters/javascript/clean_html.rb
    new file mode 100644
    index 00000000..15b87225
    --- /dev/null
    +++ b/lib/docs/filters/javascript/clean_html.rb
    @@ -0,0 +1,34 @@
    +module Docs
    +  class Javascript
    +    class CleanHtmlFilter < Filter
    +      def call
    +        root_page? ? root : other
    +        doc
    +      end
    +
    +      def root
    +        css(*%w(#About_this_Reference+div #About_this_Reference
    +                #Typed_array_constructors+ul #Typed_array_constructors
    +                #Internationalization_constructors+ul #Internationalization_constructors
    +                #Comments~* #Comments)).remove
    +
    +        # Move "Global Objects" lists to the same level as the other ones
    +        div = at_css '#Global_Objects + div'
    +        div.css('h3').each { |node| node.name = 'h2' }
    +        at_css('#Global_Objects').replace(div)
    +
    +        # Remove heading links
    +        css('h2 > a').each do |node|
    +          node.before(node.content)
    +          node.remove
    +        end
    +      end
    +
    +      def other
    +        css('.inheritsbox', '.overheadIndicator').each do |node|
    +          node.remove_attribute 'style'
    +        end
    +      end
    +    end
    +  end
    +end
    diff --git a/lib/docs/filters/javascript/entries.rb b/lib/docs/filters/javascript/entries.rb
    new file mode 100644
    index 00000000..0abf6952
    --- /dev/null
    +++ b/lib/docs/filters/javascript/entries.rb
    @@ -0,0 +1,64 @@
    +module Docs
    +  class Javascript
    +    class EntriesFilter < Docs::EntriesFilter
    +      TYPES = %w(Array Boolean Date Function JSON Math Number Object RegExp String)
    +
    +      ADDITIONAL_ENTRIES = {
    +        'operators/arithmetic_operators' => [
    +          %w(++ .2B.2B_.28Increment.29),
    +          %w(-- --_.28Decrement.29) ],
    +        'operators/bitwise_operators' => [
    +          %w(& .26_(Bitwise_AND)),
    +          %w(| .7C_(Bitwise_OR)),
    +          %w(^ .5E_(Bitwise_XOR)),
    +          %w(~ .7E_(Bitwise_NOT)),
    +          %w(<< <<_(Left_shift)),
    +          %w(>> >>_(Sign-propagating_right_shift)),
    +          %w(>>> >>>_(Zero-fill_right_shift)) ]}
    +
    +      def get_name
    +        if slug.start_with? 'Global_Objects/'
    +          name, method = *slug.sub('Global_Objects/', '').split('/')
    +
    +          if method
    +            unless method == method.upcase || method == 'NaN'
    +              method = method[0].downcase + method[1..-1] # e.g. Constructor => constructor
    +            end
    +            name << ".#{method}"
    +          end
    +
    +          name
    +        else
    +          name = super
    +          name.sub! 'Functions and function scope.', ''
    +          name.sub! 'Operators.', ''
    +          name.sub! 'Statements.', ''
    +          name
    +        end
    +      end
    +
    +      def get_type
    +        if slug.start_with? 'Statements'
    +          'Statements'
    +        elsif slug.start_with? 'Operators'
    +          'Operators'
    +        elsif slug.start_with? 'Functions_and_function_scope'
    +          'Function'
    +        elsif slug.start_with? 'Global_Objects'
    +          object, method = *slug.sub('Global_Objects/', '').split('/')
    +          if object.end_with? 'Error'
    +            'Errors'
    +          elsif method || TYPES.include?(object)
    +            object
    +          else
    +            'Global Objects'
    +          end
    +        end
    +      end
    +
    +      def additional_entries
    +        ADDITIONAL_ENTRIES[slug] || []
    +      end
    +    end
    +  end
    +end
    diff --git a/lib/docs/filters/jquery/clean_html.rb b/lib/docs/filters/jquery/clean_html.rb
    new file mode 100644
    index 00000000..787021ec
    --- /dev/null
    +++ b/lib/docs/filters/jquery/clean_html.rb
    @@ -0,0 +1,41 @@
    +module Docs
    +  class Jquery
    +    class CleanHtmlFilter < Filter
    +      def call
    +        css('hr', '.icon-link', '.entry-meta').remove
    +
    +        if css('> article').length == 1
    +          doc.children = at_css('article').children
    +        end
    +
    +        if root_page?
    +          # Remove index page title
    +          at_css('.page-title').remove
    +
    +          # Change headings on index page
    +          css('h1.entry-title').each do |node|
    +            node.name = 'h2'
    +          end
    +        end
    +
    +        # Remove useless 
    + css('.entry-header > .entry-title', 'header > .underline', 'header > h2:only-child').to_a.uniq.each do |node| + node.parent.replace node + end + + # Remove code highlighting + css('div.syntaxhighlighter').each do |node| + node.name = 'pre' + node.content = node.at_css('td.code').css('div.line').map(&:content).join("\n") + end + + # jQueryMobile/jqmData, etc. + css('dd > dl').each do |node| + node.parent.replace(node) + end + + doc + end + end + end +end diff --git a/lib/docs/filters/jquery/clean_urls.rb b/lib/docs/filters/jquery/clean_urls.rb new file mode 100644 index 00000000..a7ad86c0 --- /dev/null +++ b/lib/docs/filters/jquery/clean_urls.rb @@ -0,0 +1,10 @@ +module Docs + class Jquery + class CleanUrlsFilter < Filter + def call + html.gsub! 'local.api.jquery', 'api.jquery' + html + end + end + end +end diff --git a/lib/docs/filters/jquery_core/entries.rb b/lib/docs/filters/jquery_core/entries.rb new file mode 100644 index 00000000..218be381 --- /dev/null +++ b/lib/docs/filters/jquery_core/entries.rb @@ -0,0 +1,26 @@ +module Docs + class JqueryCore + class EntriesFilter < Docs::EntriesFilter + # Ordered by precedence + TYPES = ['Ajax', 'Selectors', 'Callbacks Object', 'Deferred Object', + 'Data', 'Utilities', 'Events', 'Effects', 'Offset', 'Dimensions', + 'Traversing', 'Manipulation'] + + def get_name + name = at_css('h1').content.strip + name.gsub!(/ [A-Z]/) { |str| str.downcase! } + name + end + + def get_type + return 'Ajax' if slug == 'Ajax_Events' + categories = css 'span.category' + types = categories.map { |node| node.at_css('a').content.strip } + types.map! { |type| TYPES.index(type) } + types.compact! + types.sort! + types.empty? ? 'Miscellaneous' : TYPES[types.first] + end + end + end +end diff --git a/lib/docs/filters/jquery_mobile/entries.rb b/lib/docs/filters/jquery_mobile/entries.rb new file mode 100644 index 00000000..48218adc --- /dev/null +++ b/lib/docs/filters/jquery_mobile/entries.rb @@ -0,0 +1,25 @@ +module Docs + class JqueryMobile + class EntriesFilter < Docs::EntriesFilter + # Ordered by precedence + TYPES = %w(Widgets Events Methods) + + def get_name + name = at_css('h1').content.strip + name.sub! ' Widget', '' + name.gsub!(/ [A-Z]/) { |str| str.downcase! } + name << ' event' if type == 'Events' && !name.end_with?(' event') + name + end + + def get_type + categories = css 'span.category' + types = categories.map { |node| node.at_css('a').content.strip } + types.map! { |type| TYPES.index(type) } + types.compact! + types.sort! + types.empty? ? 'Miscellaneous' : TYPES[types.first] + end + end + end +end diff --git a/lib/docs/filters/jquery_ui/entries.rb b/lib/docs/filters/jquery_ui/entries.rb new file mode 100644 index 00000000..38e5d41e --- /dev/null +++ b/lib/docs/filters/jquery_ui/entries.rb @@ -0,0 +1,24 @@ +module Docs + class JqueryUi + class EntriesFilter < Docs::EntriesFilter + # Ordered by precedence + TYPES = ['Widgets', 'Selectors', 'Effects', 'Interactions', 'Methods'] + + def get_name + name = at_css('h1').content.strip + name.sub! ' Widget', '' + name.gsub!(/ [A-Z]/) { |str| str.downcase! } + name + end + + def get_type + categories = css 'span.category' + types = categories.map { |node| node.at_css('a').content.strip } + types.map! { |type| TYPES.index(type) } + types.compact! + types.sort! + types.empty? ? 'Miscellaneous' : TYPES[types.first] + end + end + end +end diff --git a/lib/docs/filters/less/clean_html.rb b/lib/docs/filters/less/clean_html.rb new file mode 100644 index 00000000..d246cdcc --- /dev/null +++ b/lib/docs/filters/less/clean_html.rb @@ -0,0 +1,42 @@ +module Docs + class Less + class CleanHtmlFilter < Filter + def call + # Remove everything but language and function reference + doc.children = css('#docs', '#reference').children + + # Change headings + css('h1', 'h2', 'h3').each do |node| + node.name = "h#{node.name.last.to_i + 1}" + node['id'] ||= node.content.strip.parameterize + end + + # Remove .content div + css('.content').each do |node| + node.before(node.elements) + node.remove + end + + # Remove function index + css('#function-reference').each do |node| + while node.next.content.strip != 'String functions' + node.next.remove + end + end + + # Remove duplicates + [css('[id="unit"]').last, css('[id="color"]').last].each do |node| + node.next.remove while %w(h2 h3 h4).exclude?(node.next.name) + node.remove + end + + # Differentiate function headings + css('#function-reference ~ h4').each do |node| + node['class'] = 'function' + end + + doc + end + end + end +end diff --git a/lib/docs/filters/less/entries.rb b/lib/docs/filters/less/entries.rb new file mode 100644 index 00000000..2db2e7c5 --- /dev/null +++ b/lib/docs/filters/less/entries.rb @@ -0,0 +1,52 @@ +module Docs + class Less + class EntriesFilter < Docs::EntriesFilter + SKIP_NAMES = ['Parametric Mixins', 'Mixins With Multiple Parameters', + 'Media Queries as Variables'] + + REPLACE_NAMES = { + 'The @arguments variable' => '@arguments', + 'Advanced arguments and the @rest variable' => '@rest', + 'The Keyword !important' => '!important', + 'Pattern-matching and Guard expressions' => 'Pattern-matching', + 'Advanced Usage of &' => '&', + 'Importing' => '@import', + 'JavaScript evaluation' => 'JavaScript', + '% format' => '%' + } + + def include_default_entry? + false + end + + def additional_entries + entries = [] + type = '' + + css('> [id]').each do |node| + if node.name == 'h2' + type = node.content.strip + type.sub! 'The Language', 'Language' + type.sub! 'functions', 'Functions' + next + end + + # Skip function categories (e.g. "Color definition") + next if node.name == 'h3' && type != 'Language' + + name = node.content.strip + + next if SKIP_NAMES.include?(name) + + name = REPLACE_NAMES[name] if REPLACE_NAMES[name] + name.gsub!(/ [A-Z]/) { |str| str.downcase! } + + entries << ['~', node['id'], type] if name == 'e' + entries << [name, node['id'], type] + end + + entries + end + end + end +end diff --git a/lib/docs/filters/lodash/clean_html.rb b/lib/docs/filters/lodash/clean_html.rb new file mode 100644 index 00000000..2d4286e6 --- /dev/null +++ b/lib/docs/filters/lodash/clean_html.rb @@ -0,0 +1,27 @@ +module Docs + class Lodash + class CleanHtmlFilter < Filter + def call + css('h3 + p', 'hr').remove + + # Set id attributes on

    instead of an empty + css('h3').each do |node| + node['id'] = node.at_css('a')['id'] + end + + # Remove inside headings + css('h2', 'h3').each do |node| + node.content = node.content + end + + # Remove code highlighting + css('pre').each do |node| + node.inner_html = node.inner_html.gsub('
    ', "\n").gsub(' ', ' ') + node.content = node.content + end + + doc + end + end + end +end diff --git a/lib/docs/filters/lodash/entries.rb b/lib/docs/filters/lodash/entries.rb new file mode 100644 index 00000000..540e7568 --- /dev/null +++ b/lib/docs/filters/lodash/entries.rb @@ -0,0 +1,22 @@ +module Docs + class Lodash + class EntriesFilter < Docs::EntriesFilter + def additional_entries + entries = [] + + css('h2').each do |node| + type = node.content.split.first + type.gsub! %r{\W}, '' # remove quotation marks + + node.parent.css('h3').each do |heading| + name = heading.content + name.sub! %r{\(.+?\)}, '()' + entries << [name, heading['id'], type] + end + end + + entries + end + end + end +end diff --git a/lib/docs/filters/mdn/clean_html.rb b/lib/docs/filters/mdn/clean_html.rb new file mode 100644 index 00000000..9c94f790 --- /dev/null +++ b/lib/docs/filters/mdn/clean_html.rb @@ -0,0 +1,26 @@ +module Docs + class Mdn + class CleanHtmlFilter < Filter + REMOVE_NODES = [ + '#Summary', # "Summary" heading + '.htab', # "Browser compatibility" tabs + '.breadcrumbs', # (e.g. CSS/animation) + '.Quick_links', # (e.g. CSS/animation) + '.HTMLElmNav', # (e.g. HTML/a) + '.htmlMinVerHeader', # (e.g. HTML/article) + '.geckoVersionNote', # (e.g. HTML/li) + '.todo', + '.draftHeader'] + + def call + css(*REMOVE_NODES).remove + + css('td.header').each do |node| + node.name = 'th' + end + + doc + end + end + end +end diff --git a/lib/docs/filters/mdn/contribute_link.rb b/lib/docs/filters/mdn/contribute_link.rb new file mode 100644 index 00000000..66d8ce79 --- /dev/null +++ b/lib/docs/filters/mdn/contribute_link.rb @@ -0,0 +1,16 @@ +module Docs + class Mdn + class ContributeLinkFilter < Filter + def call + html << <<-HTML.strip_heredoc +
    + HTML + html + end + end + end +end diff --git a/lib/docs/filters/node/clean_html.rb b/lib/docs/filters/node/clean_html.rb new file mode 100644 index 00000000..a8dba398 --- /dev/null +++ b/lib/docs/filters/node/clean_html.rb @@ -0,0 +1,15 @@ +module Docs + class Node + class CleanHtmlFilter < Filter + def call + # Remove "#" links + css('.mark').each do |node| + node.parent.parent['id'] = node['id'] + node.parent.remove + end + + doc + end + end + end +end diff --git a/lib/docs/filters/node/entries.rb b/lib/docs/filters/node/entries.rb new file mode 100644 index 00000000..224df584 --- /dev/null +++ b/lib/docs/filters/node/entries.rb @@ -0,0 +1,103 @@ +module Docs + class Node + class EntriesFilter < Docs::EntriesFilter + REPLACE_NAMES = { + 'debugger' => 'Debugger', + 'addons' => 'C/C++ Addons', + 'modules' => 'module' } + + REPLACE_TYPES = { + 'Addons' => 'Miscellaneous', + 'Debugger' => 'Miscellaneous', + 'os' => 'OS', + 'StringDecoder' => 'String Decoder', + 'TLS (SSL)' => 'TLS/SSL', + 'UDP / Datagram Sockets' => 'UDP/Datagram', + 'Executing JavaScript' => 'VM' } + + IGNORE_DEFAULT_ENTRY = %w(globals timers domain buffer) + + def include_default_entry? + !IGNORE_DEFAULT_ENTRY.include?(slug) + end + + def get_name + REPLACE_NAMES[slug] || slug + end + + def get_type + type = at_css('h1').content.strip + REPLACE_TYPES[type] || "#{type.first.upcase}#{type[1..-1]}" + end + + def additional_entries + return [] if type == 'Miscellaneous' + + klass = nil + entries = [] + + css('> [id]').each do |node| + next if node.name == 'h1' + + klass = nil if node.name == 'h2' + name = node.content.strip + + # Skip constructors + if name.start_with? 'new ' + next + end + + # Ignore most global objects (found elsewhere) + if type == 'Global Objects' + entries << [name, node['id']] if name.start_with?('_') || name == 'global' + next + end + + # Classes + if name.sub! 'Class: ', '' + name.sub! 'events.', '' # EventEmitter + klass = name + entries << [name, node['id']] + next + end + + # Events + if name.sub! %r{\AEvent: '(.+)'\z}, '\1' + name << " event (#{klass || type})" + entries << [name, node['id']] + next + end + + # Skip all that start with an uppercase letter ("Example", "How It Works", etc.) + next unless name.first.upcase! || name.start_with?('Class Method') + + name.gsub! %r{\(.*?\)}, '()' + name.gsub! %r{\[.+?\]}, '[]' + + # Differentiate server classes (http, https, net, etc.) + name.sub!('server.') { "#{(klass || 'https').sub('.', '_').downcase!}." } + # Differentiate socket classes (net, dgram, etc.) + name.sub!('socket.') { "#{klass.sub('.', '_').downcase!}." } + + name.sub! 'Class Method:', '' + name.sub! 'assert(), ', '' # assert/assert.ok + name.sub! 'buf.', 'buffer.' + name.sub! 'buf[', 'buffer[' + name.sub! 'child.', 'childprocess.' + name.sub! 'decoder.', 'stringdecoder.' + name.sub! 'emitter.', 'eventemitter.' + name.sub! %r{\Arl\.}, 'interface.' + name.sub! 'rs.', 'readstream.' + name.sub! 'ws.', 'writestream.' + + # Skip duplicates (listen, connect, etc.) + unless name == entries[-1].try(:first) || name == entries[-2].try(:first) + entries << [name, node['id']] + end + end + + entries + end + end + end +end diff --git a/lib/docs/filters/php/clean_html.rb b/lib/docs/filters/php/clean_html.rb new file mode 100644 index 00000000..24c02a64 --- /dev/null +++ b/lib/docs/filters/php/clean_html.rb @@ -0,0 +1,28 @@ +module Docs + class Php + class CleanHtmlFilter < Filter + def call + root_page? ? root : other + doc + end + + def root + doc.inner_html = ' ' + end + + def other + css('.manualnavbar', 'hr').remove + + # Remove top-level
    + if doc.elements.length == 1 + @doc = doc.first_element_child + end + + # Put code blocks in
     tags
    +        css('.phpcode > code').each do |node|
    +          node.name = 'pre'
    +        end
    +      end
    +    end
    +  end
    +end
    diff --git a/lib/docs/filters/php/entries.rb b/lib/docs/filters/php/entries.rb
    new file mode 100644
    index 00000000..d5110a9c
    --- /dev/null
    +++ b/lib/docs/filters/php/entries.rb
    @@ -0,0 +1,137 @@
    +module Docs
    +  class Php
    +    class EntriesFilter < Docs::EntriesFilter
    +      TYPES = {
    +      # [name-begin-with]   => [type]
    +        'AMQP'              => 'AMQP',
    +        'APCIterator'       => 'APC',
    +        'CURL'              => 'cURL',
    +        'Date'              => 'Date and Time',
    +        'DirectoryIterator' => 'Standard PHP Library',
    +        'Directory'         => 'Directories',
    +        'DOM'               => 'DOM',
    +        'Gearman'           => 'Gearman',
    +        'Gmagick'           => 'Gmagick',
    +        'Http'              => 'HTTP',
    +        'Imagick'           => 'Imagick',
    +        'Collator'          => 'Internationalization',
    +        'NumberFormatter'   => 'Internationalization',
    +        'Locale'            => 'Internationalization',
    +        'MessageFormatter'  => 'Internationalization',
    +        'Normalizer'        => 'Internationalization',
    +        'Intl'              => 'Internationalization',
    +        'intl'              => 'Internationalization',
    +        'ResourceBundle'    => 'Internationalization',
    +        'Spoofchecker'      => 'Internationalization',
    +        'Transliterator'    => 'Internationalization',
    +        'UConverter'        => 'Internationalization',
    +        'grapheme'          => 'Internationalization',
    +        'idn'               => 'Internationalization',
    +        'Json'              => 'JSON',
    +        'mysqli'            => 'mysqli',
    +        'OAuth'             => 'OAuth',
    +        'PDO'               => 'PDO',
    +        'Thread'            => 'pthreads',
    +        'Worker'            => 'pthreads',
    +        'Stackable'         => 'pthreads',
    +        'Mutex'             => 'pthreads',
    +        'Cond'              => 'pthreads',
    +        'Exception'         => 'Predefined Exceptions',
    +        'ErrorException'    => 'Predefined Exceptions',
    +        'QuickHash'         => 'QuickHash',
    +        'Reflection'        => 'Reflection',
    +        'Reflector'         => 'Reflection',
    +        'Session'           => 'Sessions',
    +        'SimpleXML'         => 'SimpleXML',
    +        'Soap'              => 'SOAP',
    +        'Solr'              => 'Solr',
    +        'Sphinx'            => 'Sphinx',
    +        'Spl'               => 'Standard PHP Library',
    +        'ArrayObject'       => 'Standard PHP Library',
    +        'Countable'         => 'Standard PHP Library',
    +        'SQLite3'           => 'SQLite3',
    +        'streamWrapper'     => 'Streams',
    +        'php_user_filter'   => 'Streams',
    +        'tidy'              => 'Tidy',
    +        'V8Js'              => 'V8js',
    +        'Varnish'           => 'Varnish',
    +        'Weakref'           => 'Weak References',
    +        'WeakRef'           => 'Weak References',
    +        'WeakMap'           => 'Weak References',
    +        'XSLTProcessor'     => 'XSLT',
    +        'XsltProcessor'     => 'XSLT',
    +        'Yaf'               => 'Yaf',
    +        'ZipArchive'        => 'Zip' }
    +
    +      REPLACE_TYPES = {
    +      # [original-type]     => [new-type]
    +        'Array'             => 'Arrays',
    +        'Bzip2'             => 'bzip2',
    +        'Classes/Object'    => 'Classes and Objects',
    +        'Date/Time'         => 'Date and Time',
    +        'Directory'         => 'Directories',
    +        'Exceptions'        => 'Standard PHP Library',
    +        'Function handling' => 'Function Handling',
    +        'GD and Image'      => 'GD',
    +        'Gettext'           => 'gettext',
    +        'Inotify'           => 'inotify',
    +        'Interfaces'        => 'Standard PHP Library',
    +        'Iterators'         => 'Standard PHP Library',
    +        'Libevent'          => 'libevent',
    +        'Mailparse'         => 'Mail',
    +        'Misc.'             => 'Miscellaneous',
    +        'Multibyte String'  => 'Multibyte Strings',
    +        'PCRE'              => 'Regular Expressions',
    +        'PHP Options/Info'  => 'Options and Info',
    +        'POSIX Regex'       => 'Regular Expressions',
    +        'Program execution' => 'Program Execution',
    +        'Session'           => 'Sessions',
    +        'Session PgSQL'     => 'PostgreSQL',
    +        'SPL'               => 'Standard PHP Library',
    +        'Statistic'         => 'Statistics',
    +        'Stream'            => 'Streams',
    +        'String'            => 'Strings',
    +        'Variable handling' => 'Variable Handling',
    +        'XMLReader'         => 'XML Reader',
    +        'XMLWriter'         => 'XML Writer',
    +        'Yaml'              => 'YAML',
    +        'Zlib'              => 'zlib' }
    +
    +      IGNORE_SLUGS = %w(reserved.exceptions reserved.interfaces
    +        reserved.variables)
    +
    +      def include_default_entry?
    +        !(slug.start_with?('book') || IGNORE_SLUGS.include?(slug))
    +      end
    +
    +      def get_name
    +        name = css('> .sect1 > .title', 'h1', 'h2').first.content
    +
    +        if name == 'Exception class for intl errors'
    +          'IntlException'
    +        else
    +          name.sub! 'The ', ''
    +          name.sub! ' class', ' (class)'
    +          name.sub! ' interface', ' (interface)'
    +          name
    +        end
    +      end
    +
    +      def get_type
    +        if key = TYPES.keys.detect { |t| name.start_with?(t) }
    +          TYPES[key]
    +        else
    +          type = at_css('.up').content.strip
    +          type.sub! ' Functions', ''
    +          type.sub! ' Obsolete Aliases and', ''
    +
    +          if type.end_with? 'Iterator'
    +            'Standard PHP Library'
    +          else
    +            REPLACE_TYPES[type] || type
    +          end
    +        end
    +      end
    +    end
    +  end
    +end
    diff --git a/lib/docs/filters/php/fix_urls.rb b/lib/docs/filters/php/fix_urls.rb
    new file mode 100644
    index 00000000..392f27cf
    --- /dev/null
    +++ b/lib/docs/filters/php/fix_urls.rb
    @@ -0,0 +1,11 @@
    +module Docs
    +  class Php
    +    class FixUrlsFilter < Filter
    +      def call
    +        html.gsub! File.join(Php.base_url, Php.root_path), Php.base_url
    +        html.gsub! %r{http://www\.php\.net/manual/en/([^"']+?)\.html}, 'http://www.php.net/manual/en/\1.php'
    +        html
    +      end
    +    end
    +  end
    +end
    diff --git a/lib/docs/filters/sass/clean_html.rb b/lib/docs/filters/sass/clean_html.rb
    new file mode 100644
    index 00000000..f183efe0
    --- /dev/null
    +++ b/lib/docs/filters/sass/clean_html.rb
    @@ -0,0 +1,47 @@
    +module Docs
    +  class Sass
    +    class CleanHtmlFilter < Filter
    +      def call
    +        css('tt').each do |node|
    +          node.name = 'code'
    +        end
    +
    +        root_page? ? root : other
    +
    +        doc
    +      end
    +
    +      def root
    +        at_css('.maruku_toc').remove
    +      end
    +
    +      def other
    +        at_css('h2').remove
    +
    +        css('.showSource', '.source_code').remove
    +
    +        # Remove "See Also"
    +        css('.see').each do |node|
    +          node.previous_element.remove
    +          node.remove
    +        end
    +
    +        # Un-indent code blocks
    +        css('pre.example').each do |node|
    +          node.inner_html = node.inner_html.strip_heredoc
    +        end
    +
    +        # Remove "- " before method names
    +        css('.signature', 'span.overload').each do |node|
    +          node.child.content = node.child.content.gsub(/\A\s*-\s*/, '')
    +        end
    +
    +        # Remove links to type classes (e.g. Number)
    +        css('.type > code > a, .signature > code > a, span.overload > code > a').each do |node|
    +          node.before(node.content)
    +          node.remove
    +        end
    +      end
    +    end
    +  end
    +end
    diff --git a/lib/docs/filters/sass/entries.rb b/lib/docs/filters/sass/entries.rb
    new file mode 100644
    index 00000000..abfb8284
    --- /dev/null
    +++ b/lib/docs/filters/sass/entries.rb
    @@ -0,0 +1,80 @@
    +module Docs
    +  class Sass
    +    class EntriesFilter < Docs::EntriesFilter
    +      TYPES = ['CSS Extensions', 'SassScript', '@-Rules and Directives',
    +        'Output Styles']
    +
    +      SKIP_NAMES = ['Interactive Shell', 'Data Types', 'Operations',
    +        'Division and /', 'Keyword Arguments']
    +
    +      REPLACE_NAMES = {
    +        '%foo'               => '%placeholder selector',
    +        '&'                  => '& parent selector',
    +        '$'                  => '$ variables',
    +        '#{}'                => '#{} interpolation',
    +        'The !optional Flag' => '!optional'
    +      }
    +
    +      def include_default_entry?
    +        false
    +      end
    +
    +      def additional_entries
    +        root_page? ? root_entries : function_entries
    +      end
    +
    +      def root_entries
    +        entries = []
    +        type = ''
    +
    +        css('> [id]').each do |node|
    +          if node.name == 'h2'
    +            type = node.content.strip
    +
    +            if type == 'Function Directives'
    +              entries << ['@function', node['id'], '@-Rules and Directives']
    +            end
    +
    +            if type.end_with? 'Directives'
    +              type = '@-Rules and Directives'
    +            elsif type == 'Output Style'
    +              type = 'Output Styles'
    +            end
    +
    +            next
    +          end
    +
    +          next unless TYPES.include?(type)
    +
    +          name = node.content.strip
    +          name.sub! %r{\A.+?: }, ''
    +
    +          next if SKIP_NAMES.include?(name)
    +
    +          name = REPLACE_NAMES[name] if REPLACE_NAMES[name]
    +          name.gsub!(/ [A-Z]/) { |str| str.downcase! }
    +
    +          if type == '@-Rules and Directives'
    +            next unless name =~ /\A@\w+\z/ || name == '!optional'
    +          end
    +
    +          entries << [name, node['id'], type]
    +        end
    +
    +        entries
    +      end
    +
    +      def function_entries
    +        css('.method_details > .signature').inject [] do |entries, node|
    +          name = node.at_css('strong').content.strip
    +
    +          unless name == entries.last.try(:first)
    +            entries << [name, node['id'], 'Functions']
    +          end
    +
    +          entries
    +        end
    +      end
    +    end
    +  end
    +end
    diff --git a/lib/docs/filters/underscore/clean_html.rb b/lib/docs/filters/underscore/clean_html.rb
    new file mode 100644
    index 00000000..a2c04401
    --- /dev/null
    +++ b/lib/docs/filters/underscore/clean_html.rb
    @@ -0,0 +1,12 @@
    +module Docs
    +  class Underscore
    +    class CleanHtmlFilter < Filter
    +      def call
    +        # Remove Links, Changelog
    +        css('#links ~ *', '#links').remove
    +
    +        doc
    +      end
    +    end
    +  end
    +end
    diff --git a/lib/docs/filters/underscore/entries.rb b/lib/docs/filters/underscore/entries.rb
    new file mode 100644
    index 00000000..fc33bda1
    --- /dev/null
    +++ b/lib/docs/filters/underscore/entries.rb
    @@ -0,0 +1,27 @@
    +module Docs
    +  class Underscore
    +    class EntriesFilter < Docs::EntriesFilter
    +      def additional_entries
    +        entries = []
    +        type = nil
    +
    +        css('[id]').each do |node|
    +          # Module
    +          if node.name == 'h2'
    +            type = node.content.split.first
    +            next
    +          end
    +
    +          # Method
    +          node.css('.header', '.alias b').each do |header|
    +            header.content.split(',').each do |name|
    +              entries << [name, node['id'], type]
    +            end
    +          end
    +        end
    +
    +        entries
    +      end
    +    end
    +  end
    +end
    diff --git a/lib/docs/scrapers/angular.rb b/lib/docs/scrapers/angular.rb
    new file mode 100644
    index 00000000..f20a588a
    --- /dev/null
    +++ b/lib/docs/scrapers/angular.rb
    @@ -0,0 +1,19 @@
    +module Docs
    +  class Angular < UrlScraper
    +    # This scraper is currently broken; the problem being that Angular's
    +    # documentation isn't available as static pages. I will try to restore it
    +    # once Angular 1.2.0 is released.
    +    #
    +    # In the past it used static-ng-doc by Sal Lara (github.com/natchiketa/static-ng-doc)
    +    # to scrape the doc's HTML partials (e.g. docs.angularjs.org/partials/api/ng.html).
    +    #
    +    # If you want to help this is what I need: a static page with links to each
    +    # HTML partial. Or better yet, a static version of Angular's documentation.
    +
    +    self.name = 'Angular.js'
    +    self.slug = 'angular'
    +    self.type = 'angular'
    +    self.version = '1.0.7'
    +    self.base_url = ''
    +  end
    +end
    diff --git a/lib/docs/scrapers/backbone.rb b/lib/docs/scrapers/backbone.rb
    new file mode 100644
    index 00000000..8a618867
    --- /dev/null
    +++ b/lib/docs/scrapers/backbone.rb
    @@ -0,0 +1,20 @@
    +module Docs
    +  class Backbone < UrlScraper
    +    self.name = 'Backbone.js'
    +    self.slug = 'backbone'
    +    self.type = 'underscore'
    +    self.version = '1.1.0'
    +    self.base_url = 'http://backbonejs.org'
    +
    +    html_filters.push 'backbone/clean_html', 'backbone/entries', 'title'
    +
    +    options[:title] = 'Backbone.js'
    +    options[:container] = '.container'
    +    options[:skip_links] = -> (_) { true }
    +
    +    options[:attribution] = <<-HTML.strip_heredoc
    +      © 2010–2013 Jeremy Ashkenas, DocumentCloud
    + Licensed under the MIT License. + HTML + end +end diff --git a/lib/docs/scrapers/coffeescript.rb b/lib/docs/scrapers/coffeescript.rb new file mode 100644 index 00000000..96fb5a5d --- /dev/null +++ b/lib/docs/scrapers/coffeescript.rb @@ -0,0 +1,19 @@ +module Docs + class Coffeescript < UrlScraper + self.name = 'CoffeeScript' + self.type = 'coffeescript' + self.version = '1.6.3' + self.base_url = 'http://coffeescript.org' + + html_filters.push 'coffeescript/clean_html', 'coffeescript/entries', 'title' + + options[:title] = 'CoffeeScript' + options[:container] = '.container' + options[:skip_links] = -> (_) { true } + + options[:attribution] = <<-HTML.strip_heredoc + © 2009–2013 Jeremy Ashkenas
    + Licensed under the MIT License. + HTML + end +end diff --git a/lib/docs/scrapers/ember.rb b/lib/docs/scrapers/ember.rb new file mode 100644 index 00000000..bd043244 --- /dev/null +++ b/lib/docs/scrapers/ember.rb @@ -0,0 +1,55 @@ +module Docs + class Ember < UrlScraper + self.name = 'Ember.js' + self.slug = 'ember' + self.type = 'ember' + self.version = '1.0.0' + self.base_url = 'http://emberjs.com/api/' + + html_filters.push 'ember/clean_html', 'ember/entries', 'title' + + options[:title] = false + options[:root_title] = 'Ember.js' + + options[:container] = ->(filter) do + filter.root_page? ? '#toc-list' : '#content' + end + + # Duplicates + options[:skip] = %w( + classes/String.html + data/classes/DS.html) + + # Empty + options[:skip].concat %w( + classes/Ember.State.html + classes/Ember.StateManager.html + data/classes/DS.AdapterPopulatedRecordArray.html + data/classes/DS.FilteredRecordArray.html) + + # Private + options[:skip].concat %w( + classes/Ember.Descriptor.html + classes/Ember.EachProxy.html + classes/Ember.EventDispatcher.html + classes/Ember.Handlebars.Compiler.html + classes/Ember.Handlebars.JavaScriptCompiler.html + classes/Ember.Map.html + classes/Ember.MapWithDefault.html + classes/Ember.OrderedSet.html + classes/Ember.TextSupport.html + data/classes/DS.AdapterPopulatedRecordArray.html + data/classes/DS.AttributeChange.html + data/classes/DS.RecordArrayManager.html + data/classes/DS.RelationshipChange.html + data/classes/DS.RelationshipChangeAdd.html + data/classes/DS.RelationshipChangeRemove.html) + + options[:skip_patterns] = [/\._/] + + options[:attribution] = <<-HTML.strip_heredoc + © 2013 Yehuda Katz, Tom Dale and Ember.js contributors
    + Licensed under the MIT License. + HTML + end +end diff --git a/lib/docs/scrapers/http.rb b/lib/docs/scrapers/http.rb new file mode 100644 index 00000000..2f517c75 --- /dev/null +++ b/lib/docs/scrapers/http.rb @@ -0,0 +1,14 @@ +module Docs + class Http < UrlScraper + self.name = 'HTTP' + self.type = 'rfc' + self.base_url = 'http://www.w3.org/Protocols/rfc2616/' + self.root_path = 'rfc2616.html' + + html_filters.push 'http/clean_html', 'http/entries' + + options[:only] = %w(rfc2616-sec10.html rfc2616-sec14.html) + options[:container] = ->(filter) { '.toc' if filter.root_page? } + options[:attribution] = "© 1999 The Internet Society" + end +end diff --git a/lib/docs/scrapers/jquery/jquery.rb b/lib/docs/scrapers/jquery/jquery.rb new file mode 100644 index 00000000..56e0b42b --- /dev/null +++ b/lib/docs/scrapers/jquery/jquery.rb @@ -0,0 +1,19 @@ +module Docs + class Jquery < UrlScraper + self.abstract = true + self.type = 'jquery' + + html_filters.push 'jquery/clean_html', 'title' + text_filters.push 'jquery/clean_urls' + + options[:title] = false + options[:container] = '#content' + options[:trailing_slash] = false + options[:skip_patterns] = [/category/] + + options[:attribution] = <<-HTML.strip_heredoc + © 2013 The jQuery Foundation
    + Licensed under the MIT License. + HTML + end +end diff --git a/lib/docs/scrapers/jquery/jquery_core.rb b/lib/docs/scrapers/jquery/jquery_core.rb new file mode 100644 index 00000000..bd838ef3 --- /dev/null +++ b/lib/docs/scrapers/jquery/jquery_core.rb @@ -0,0 +1,20 @@ +module Docs + class JqueryCore < Jquery + self.name = 'jQuery' + self.version = 'up to 2.0.3' + self.base_url = 'http://local.api.jquery.com' + + html_filters.insert_before 'jquery/clean_html', 'jquery_core/entries' + + options[:root_title] = 'jQuery' + + # Duplicates + options[:skip] = %w(/selectors/odd /selectors/even /selectors/event + /selected /checked) + + options[:fix_urls] = ->(url) do + url.sub! '.com/index/', '.com/index/index' + url + end + end +end diff --git a/lib/docs/scrapers/jquery/jquery_mobile.rb b/lib/docs/scrapers/jquery/jquery_mobile.rb new file mode 100644 index 00000000..da91770a --- /dev/null +++ b/lib/docs/scrapers/jquery/jquery_mobile.rb @@ -0,0 +1,20 @@ +module Docs + class JqueryMobile < Jquery + self.name = 'jQuery Mobile' + self.slug = 'jquerymobile' + self.version = '1.3.2' + self.base_url = 'http://local.api.jquerymobile.com' + self.root_path = '/category/all' + + html_filters.insert_before 'jquery/clean_html', 'jquery_mobile/entries' + + options[:root_title] = 'jQuery Mobile' + options[:skip_patterns].concat [/\A\/icons/] + options[:replace_paths] = { + '/select/' => '/selectmenu', + '/forms/selects' => '/selectmenu', + '/forms/checkboxes' => '/checkboxradio', + '/forms/radiobuttons' => '/checkboxradio', + '/forms/slider/' => '/slider' } + end +end diff --git a/lib/docs/scrapers/jquery/jquery_ui.rb b/lib/docs/scrapers/jquery/jquery_ui.rb new file mode 100644 index 00000000..9c4039b7 --- /dev/null +++ b/lib/docs/scrapers/jquery/jquery_ui.rb @@ -0,0 +1,15 @@ +module Docs + class JqueryUi < Jquery + self.name = 'jQuery UI' + self.slug = 'jqueryui' + self.version = '1.10.3' + self.base_url = 'http://local.api.jqueryui.com' + self.root_path = '/category/all' + + html_filters.insert_before 'jquery/clean_html', 'jquery_ui/entries' + + options[:root_title] = 'jQuery UI' + options[:skip] = %w(/theming) + options[:skip_patterns].concat [/\A\/1\./] + end +end diff --git a/lib/docs/scrapers/less.rb b/lib/docs/scrapers/less.rb new file mode 100644 index 00000000..254e8318 --- /dev/null +++ b/lib/docs/scrapers/less.rb @@ -0,0 +1,18 @@ +module Docs + class Less < UrlScraper + self.type = 'less' + self.version = '1.4.1' + self.base_url = 'http://lesscss.org' + + html_filters.push 'less/clean_html', 'less/entries', 'title' + + options[:title] = 'LESS' + options[:container] = 'section' + options[:skip_links] = -> (_) { true } + + options[:attribution] = <<-HTML.strip_heredoc + © 2009–2013 Alexis Sellier & The Core Less Team
    + Licensed under the Apache License v2.0. + HTML + end +end diff --git a/lib/docs/scrapers/lodash.rb b/lib/docs/scrapers/lodash.rb new file mode 100644 index 00000000..67ee630a --- /dev/null +++ b/lib/docs/scrapers/lodash.rb @@ -0,0 +1,20 @@ +module Docs + class Lodash < UrlScraper + self.name = 'Lo-Dash' + self.slug = 'lodash' + self.type = 'lodash' + self.version = '2.2.1' + self.base_url = 'http://lodash.com/docs' + + html_filters.push 'lodash/clean_html', 'lodash/entries', 'title' + + options[:title] = 'Lo-Dash' + options[:container] = 'h1+div+div' + options[:skip_links] = -> (_) { true } + + options[:attribution] = <<-HTML.strip_heredoc + © 2012–2013 The Dojo Foundation
    + Licensed under the MIT License. + HTML + end +end diff --git a/lib/docs/scrapers/mdn/css.rb b/lib/docs/scrapers/mdn/css.rb new file mode 100644 index 00000000..724fefd4 --- /dev/null +++ b/lib/docs/scrapers/mdn/css.rb @@ -0,0 +1,47 @@ +module Docs + class Css < Mdn + self.name = 'CSS' + self.base_url = 'https://developer.mozilla.org/en-US/docs/Web/CSS' + self.root_path = '/Reference' + + html_filters.push 'css/clean_html', 'css/entries', 'title' + + options[:root_title] = 'CSS' + + # Don't want + options[:skip] = %w( + /Syntax + /At-rule + /Comments + /Specificity + /actual_value + /initial_value + /inheritance + /specified_value + /computed_value + /used_value actual_value + /box_model + /Replaced_element + /Value_definition_syntax + /Pseudo-elements + /Layout_mode + /Visual_formatting_model + /Shorthand_properties + /margin_collapsing + /CSS3 + /Pseudo-classes + /CSS_values_syntax + /Media/Visual + /block_formatting_context) + + options[:skip_patterns] = [/\-webkit/, /\-moz/, /Extensions/, /Tools/] + + # Broken / Empty + options[:skip].concat %w(/image()) + + options[:fix_urls] = ->(url) do + url.sub! %r{https://developer\.mozilla\.org/en\-US/docs/CSS/([a-z@:])}, "#{Css.base_url}/\\1" + url + end + end +end diff --git a/lib/docs/scrapers/mdn/dom.rb b/lib/docs/scrapers/mdn/dom.rb new file mode 100644 index 00000000..0617f2e9 --- /dev/null +++ b/lib/docs/scrapers/mdn/dom.rb @@ -0,0 +1,104 @@ +module Docs + class Dom < Mdn + self.name = 'DOM' + self.base_url = 'https://developer.mozilla.org/en-US/docs/Web/API' + + html_filters.push 'dom/clean_html', 'dom/entries', 'title' + + options[:root_title] = 'DOM' + + # Don't want + options[:skip] = %w( + /App + /Apps + /CallEvent + /CanvasPixelArray + /ChromeWorker + /ContactManager + /document.createProcessingInstruction + /document.documentURIObject + /document.loadOverlay + /document.tooltipNode + /DOMErrorHandler + /DOMLocator + /DOMObject + /DOMStringList + /Event/Comparison_of_Event_Targets + /FMRadio + /IDBDatabaseException + /NamedNodeMap + /Node.baseURIObject + /Node.nodePrincipal + /Notation + /PowerManager + /PushManager + /ProcessingInstruction + /select.type + /TCPServerSocket + /TCPSocket + /WifiManager + /window.controllers + /window.crypto + /window.getAttention + /window.messageManager + /window.navigator.addIdleObserver + /window.navigator.getDeviceStorage + /window.navigator.getDeviceStorages + /window.navigator.removeIdleObserver + /window.navigator.requestWakeLock + /window.updateCommands + /window.pkcs11 + /XMLDocument + /XMLHttpRequest/Using_XMLHttpRequest) + + options[:skip_patterns] = [ + /NS/, + /XPC/, + /moz/i, + /gecko/i, + /webkit/i, + /\A\/Camera/, + /\A\/DeviceStorage/, + /\A\/document\.xml/, + /\A\/DOMCursor/, + /\A\/DOMRequest/, + /\A\/element\.on/, + /\A\/Entity/, + /\A\/HTMLIFrameElement\./, + /\A\/navigator\.id/i, + /\A\/Settings/, + /\A\/Telephony/, + /UserData/, + /\A\/Window\.\w+bar/i] + + # Broken / Empty + options[:skip].concat %w( + /Attr.isId + /document.nodePrincipal + /Event/UIEvent + /Extensions + /StyleSheetList + /SVGPoint + /Window.dispatchEvent + /Window.restore + /Window.routeEvent + /Window.QueryInterface) + + # Duplicates + options[:skip].concat %w(/Reference) + + options[:fix_urls] = ->(url) do + return if url.include?('_') || url.include?('?') + url.sub! 'https://developer.mozilla.org/en-US/docs/DOM/', "#{Dom.base_url}/" + url.sub! 'https://developer.mozilla.org/en/DOM/', "#{Dom.base_url}/" + url.sub! "#{Dom.base_url}/Document\.", "#{Dom.base_url}/document." + url.sub! "#{Dom.base_url}/Element", "#{Dom.base_url}/element" + url.sub! "#{Dom.base_url}/History", "#{Dom.base_url}/history" + url.sub! "#{Dom.base_url}/Navigator", "#{Dom.base_url}/navigator" + url.sub! "#{Dom.base_url}/notification", "#{Dom.base_url}/Notification" + url.sub! "#{Dom.base_url}/range", "#{Dom.base_url}/Range" + url.sub! "#{Dom.base_url}/Window", "#{Dom.base_url}/window" + url + end + end +end diff --git a/lib/docs/scrapers/mdn/dom_events.rb b/lib/docs/scrapers/mdn/dom_events.rb new file mode 100644 index 00000000..2ba21d31 --- /dev/null +++ b/lib/docs/scrapers/mdn/dom_events.rb @@ -0,0 +1,17 @@ +module Docs + class DomEvents < Mdn + self.name = 'DOM Events' + self.slug = 'dom_events' + self.base_url = 'https://developer.mozilla.org/en-US/docs/Web/Reference/Events' + + html_filters.insert_after 'clean_html', 'dom_events/clean_html' + html_filters.push 'dom_events/entries', 'title' + + options[:root_title] = 'DOM Events' + options[:fix_urls] = ->(url) do + url.sub! 'https://developer.mozilla.org/en-US/Mozilla_event_reference', DomEvents.base_url + url.sub! 'https://developer.mozilla.org/en-US/docs/Mozilla_event_reference', DomEvents.base_url + url + end + end +end diff --git a/lib/docs/scrapers/mdn/html.rb b/lib/docs/scrapers/mdn/html.rb new file mode 100644 index 00000000..27fa998b --- /dev/null +++ b/lib/docs/scrapers/mdn/html.rb @@ -0,0 +1,26 @@ +module Docs + class Html < Mdn + self.name = 'HTML' + self.base_url = 'https://developer.mozilla.org/en-US/docs/Web/HTML/Element' + + html_filters.push 'html/clean_html', 'html/entries', 'title' + + options[:root_title] = 'HTML' + + options[:title] = ->(filter) do + if filter.slug == 'Heading_Elements' + 'Heading Elements' + else + "<#{filter.default_title}>" + end + end + + options[:replace_paths] = { + '/h1' => '/Heading_Elements', + '/h2' => '/Heading_Elements', + '/h3' => '/Heading_Elements', + '/h4' => '/Heading_Elements', + '/h5' => '/Heading_Elements', + '/h6' => '/Heading_Elements' } + end +end diff --git a/lib/docs/scrapers/mdn/javascript.rb b/lib/docs/scrapers/mdn/javascript.rb new file mode 100644 index 00000000..1f91dd7f --- /dev/null +++ b/lib/docs/scrapers/mdn/javascript.rb @@ -0,0 +1,44 @@ +module Docs + class Javascript < Mdn + self.name = 'JavaScript' + self.base_url = 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference' + + html_filters.push 'javascript/clean_html', 'javascript/entries', 'title' + + options[:root_title] = 'JavaScript' + + # Don't want + options[:skip] = %w( + /About + /Code_comments + /Deprecated_Features + /Deprecated_and_obsolete_features + /Functions_and_function_scope + /Functions_and_function_scope/Strict_mode + /Global_Objects/Iterator + /Global_Objects/Number/toInteger + /Global_Objects/ParallelArray + /Global_Objects/Proxy + /Global_Objects/uneval + /Reserved_Words + /arrow_functions + /rest_parameters) + + options[:skip_patterns] = [/Intl/, /Collator/, /DateTimeFormat/, /NumberFormat/] + + # Duplicates + options[:skip].concat %w( + /Global_Objects + /Operators + /Statements) + + options[:fix_urls] = ->(url) do + url.sub! 'https://developer.mozilla.org/en-US/docs/JavaScript/Reference', Javascript.base_url + url.sub! 'https://developer.mozilla.org/en/JavaScript/Reference', Javascript.base_url + url.sub! 'https://developer.mozilla.org/en/Core_JavaScript_1.5_Reference', Javascript.base_url + url.sub! 'https://developer.mozilla.org/En/Core_JavaScript_1.5_Reference', Javascript.base_url + url.sub! '/Operators/Special/', '/Operators/' + url + end + end +end diff --git a/lib/docs/scrapers/mdn/mdn.rb b/lib/docs/scrapers/mdn/mdn.rb new file mode 100644 index 00000000..6e335c3e --- /dev/null +++ b/lib/docs/scrapers/mdn/mdn.rb @@ -0,0 +1,24 @@ +module Docs + class Mdn < UrlScraper + self.abstract = true + self.type = 'mdn' + + params[:raw] = 1 + params[:macros] = 1 + + html_filters.push 'mdn/clean_html' + text_filters.insert_before 'attribution', 'mdn/contribute_link' + + options[:trailing_slash] = false + options[:attribution] = <<-HTML.strip_heredoc + © 2013 Mozilla Contributors
    + Licensed under the Creative Commons Attribution-ShareAlike License v2.5 or later. + HTML + + private + + def process_response?(response) + super && response.effective_url.query == 'raw=1¯os=1' + end + end +end diff --git a/lib/docs/scrapers/node.rb b/lib/docs/scrapers/node.rb new file mode 100644 index 00000000..d98d3ee6 --- /dev/null +++ b/lib/docs/scrapers/node.rb @@ -0,0 +1,21 @@ +module Docs + class Node < UrlScraper + self.name = 'Node.js' + self.slug = 'node' + self.type = 'node' + self.version = '0.10.20' + self.base_url = 'http://nodejs.org/api/' + + html_filters.push 'node/clean_html', 'node/entries', 'title' + + options[:title] = false + options[:root_title] = 'Node.js' + options[:container] = '#apicontent' + options[:skip] = %w(index.html all.html documentation.html synopsis.html) + + options[:attribution] = <<-HTML.strip_heredoc + © Joyent, Inc. and other Node contributors
    + Licensed under the MIT License. + HTML + end +end diff --git a/lib/docs/scrapers/php.rb b/lib/docs/scrapers/php.rb new file mode 100644 index 00000000..e938d6b5 --- /dev/null +++ b/lib/docs/scrapers/php.rb @@ -0,0 +1,115 @@ +module Docs + class Php < FileScraper + # WARNING: if you are the kind of developer who likes to automate things, + # this scraper will hurt your feelings. + + self.name = 'PHP' + self.type = 'php' + self.version = 'up to 5.5.4' + self.base_url = 'http://www.php.net/manual/en/' + self.root_path = 'extensions.alphabetical.html' + + # Downloaded from php.net/download-docs.php + self.dir = '/Users/Thibaut/DevDocs/Docs/PHP' + + html_filters.push 'php/entries', 'php/clean_html', 'title' + text_filters.push 'php/fix_urls' + + options[:title] = false + options[:root_title] = 'PHP: Hypertext Preprocessor' + + options[:only] = [] # using a whitelist + + options[:only_patterns] = [/\Afunction\.\w+\.html\z/, + /\Areserved\.exceptions/, /\Areserved\.interfaces/, + /\Areserved\.variables/, /\Acontrol\-structures/] + + # TODO: MongoDB, Phar + BOOKS = %w(amqp apache apc array bc bzip2 calendar classkit classobj com + ctype curl datetime dba dir dom eio errorfunc exec fileinfo filesystem + filter ftp funchand gearman geoip gettext gmagick hash http iconv iisfunc + image imagick imap info inotify intl json ldap libevent libxml mail + mailparse math mbstring mcrypt memcached misc mysqli network oauth + openssl outcontrol password pcre pdo pgsql posix pthreads quickhash + readline regex runkit reflection session session-pgsql simplexml soap + sockets solr sphinx spl spl-types sqlite3 sqlsrv ssh2 stats stream + strings taint tidy url v8js var varnish weakref xml xmlreader xmlrpc + xmlwriter xsl yaf yaml zip zlib uodbc) + options[:only].concat BOOKS.map { |s| "book.#{s}.html" } + options[:only_patterns].concat BOOKS.map { |s| /\Afunction\.#{s}(?:\.|\-)/ } + + CLASSES = %w(apciterator curlfile dateinterval dateperiod collator + numberformatter locale normalizer messageformatter resourcebundle + spoofchecker transliterator uconverter memcached thread worker stackable + mutex cond runkit reflector sessionhandler sessionhandlerinterface + sphinxclient countable arrayobject streamwrapper xmlreader xsltprocessor + ziparchive exception errorexception) + options[:only].concat CLASSES.map { |s| "class.#{s}.html" } + options[:only_patterns].concat CLASSES.map { |s| /\A#{s}\./ } + + FUNCTION_PREFIXES = %w(assert base base64 cal call chunk class cli + connection convert count create date debug define disk dns easter ereg + eregi error event file finfo forward func gc gd get grapheme halt header + headers highlight html http idn iis in inet ini is iterator magic mb md5 + mdecrypt memory mime move mt nl ob output parse pg php preg print proc + quoted realpath register restore set sha1 shell show stream socket spl + str sys tidy time timezone unregister use utf8 variant xml) + options[:only_patterns].concat FUNCTION_PREFIXES.map { |s| /\Afunction\.#{s}\-/ } + + FUNCTIONS = %w(trigger-error user-error require-once include-once) + options[:only].concat FUNCTIONS.map { |s| "function.#{s}.html" } + + options[:only_patterns].concat [ + /function\.\w+\-exists\.html\z/, + /\A\w+iterator\./, + /\Afunction\.bz\w+\.html\z/, + /\Aclass\.\w+iterator\.html\z/, + /\Aclass\.\w+exception\.html\z/, + /\Aclass\.amqp/, /\Aamqp/, + /\Aclass\.datetime/, /\Adatetime/, + /\Aclass\.dom/, /\Adom/, + /\Aclass\.gearman/, /\Agearman/, + /\Aclass\.gmagick/, /\Agmagick/, + /\Aclass\.http/, /\Ahttp/, + /\Aclass\.imagick/, /\Aimagick/, + /\Aclass\.intl/, /\Aintl/, + /\Aclass\.json/, /\Ajson/, + /\Aclass\.mysqli/, /\Amysqli/, + /\Aclass\.oauth/, /\Aoauth/, + /\Aclass\.pdo/, /\Apdo/, + /\Aclass\.quickhash/, /\Aquickhash/, + /\Aclass\.reflection/, /\Areflection/, + /\Aclass\.simplexml/, /\Asimplexml/, + /\Aclass\.soap/, /\Asoap/, + /\Aclass\.solr/, /\Asolr/, + /\Aclass\.spl/, /\Aspl/, + /\Aclass\.sqlite3/, /\Asqlite3/, + /\Aclass\.tidy/, /\Atidy/, + /\Aclass\.v8js/, /\Av8js/, + /\Aclass\.varnish/, /\Avarnish/, + /\Aclass\.weak/, /\Aweak/, + /\Aclass\.yaf\-/, /\Ayaf\-/] + + options[:skip_patterns] = [/example/, /quickstart/, /\.setup\.html\z/, + /\.overview\.html\z/, /\.requirements\.html\z/, /\.installation\.html\z/, + /\.install\.html\z/, /\.configuration\.html\z/, /\.resources\.html\z/, + /\.constants\.html\z/, /\Amysqlinfo/, /\Adatetime\.formats/] + + options[:skip] = %w(control-structures.intro.html + control-structures.alternative-syntax.html memcached.expiration.html + memcached.callbacks.html memcached.callbacks.result.html + memcached.callbacks.read-through.html memcached.sessions.html + mysqli.persistconns.html mysqli.notes.html mysqli.summary.html + pdo.connections.html pdo.transactions.html pdo.prepared-statements.html + pdo.error-handling.html pdo.lobs.htm pdo.drivers.html + reflection.extending.html http.request.options.html + class.lapackexception.html class.snmpexception.html function.mhash.html + spl.datastructures.html spl.iterators.html spl.interfaces.html + spl.exceptions.html spl.files.html spl.misc.html) + + options[:attribution] = <<-HTML.strip_heredoc + © 1997–2013 The PHP Documentation Group
    + Licensed under the Creative Commons Attribution License v3.0 or later. + HTML + end +end diff --git a/lib/docs/scrapers/sass.rb b/lib/docs/scrapers/sass.rb new file mode 100644 index 00000000..512364ec --- /dev/null +++ b/lib/docs/scrapers/sass.rb @@ -0,0 +1,25 @@ +module Docs + class Sass < UrlScraper + self.type = 'yard' + self.version = '3.2.12' + self.base_url = 'http://sass-lang.com/docs/yardoc/' + self.root_path = 'file.SASS_REFERENCE.html' + + html_filters.push 'sass/clean_html', 'sass/entries', 'title' + + options[:only] = %w(Sass/Script/Functions.html) + + options[:container] = ->(filter) do + filter.root_page? ? '#filecontents' : '#instance_method_details' + end + + options[:title] = ->(filter) do + 'Sass Functions' if filter.slug == 'Sass/Script/Functions' + end + + options[:attribution] = <<-HTML.strip_heredoc + © 2006–2013 Hampton Catlin, Nathan Weizenbaum, and Chris Eppstein
    + Licensed under the MIT License. + HTML + end +end diff --git a/lib/docs/scrapers/underscore.rb b/lib/docs/scrapers/underscore.rb new file mode 100644 index 00000000..02647d20 --- /dev/null +++ b/lib/docs/scrapers/underscore.rb @@ -0,0 +1,20 @@ +module Docs + class Underscore < UrlScraper + self.name = 'Underscore.js' + self.slug = 'underscore' + self.type = 'underscore' + self.version = '1.5.2' + self.base_url = 'http://underscorejs.org' + + html_filters.push 'underscore/clean_html', 'underscore/entries', 'title' + + options[:title] = 'Underscore.js' + options[:container] = '#documentation' + options[:skip_links] = -> (_) { true } + + options[:attribution] = <<-HTML.strip_heredoc + © 2009–2013 Jeremy Ashkenas, DocumentCloud and Investigative Reporters & Editors
    + Licensed under the MIT License. + HTML + end +end diff --git a/lib/docs/storage/abstract_store.rb b/lib/docs/storage/abstract_store.rb new file mode 100644 index 00000000..ce628105 --- /dev/null +++ b/lib/docs/storage/abstract_store.rb @@ -0,0 +1,198 @@ +require 'pathname' + +module Docs + class AbstractStore + class InvalidPathError < StandardError; end + class LockError < StandardError; end + + include Instrumentable + + def initialize(path) + path = Pathname.new(path).cleanpath + raise ArgumentError if path.relative? + @root_path = @working_path = path.freeze + end + + def root_path + @root_path.to_s + end + + def working_path + @working_path.to_s + end + + def expand_path(path) + join_paths @working_path, path + end + + def open(path, &block) + if block_given? + open_yield_close(path, &block) + else + set_working_path join_paths(@root_path, path) + end + end + + def close + set_working_path @root_path + end + + def read(path) + path = expand_path(path) + read_file(path) if file_exist?(path) + end + + def write(path, value) + path = expand_path(path) + touch(path) + + if file_exist?(path) + update(path, value) + else + create(path, value) + end + end + + def delete(path) + path = expand_path(path) + + if file_exist?(path) + destroy(path) + true + end + end + + def exist?(path) + file_exist? expand_path(path) + end + + def mtime(path) + path = expand_path(path) + file_mtime(path) if file_exist?(path) + end + + def each(&block) + list_files(working_path, &block) + end + + def replace(path = nil, &block) + if path + return open(path) { replace(&block) } + else + lock { track_touched { yield.tap { delete_untouched } } } + end + end + + private + + def read_file(path) + raise NotImplementedError + end + + def create_file(path, value) + raise NotImplementedError + end + + def update_file(path, value) + raise NotImplementedError + end + + def delete_file(path) + raise NotImplementedError + end + + def file_exist?(path) + raise NotImplementedError + end + + def file_mtime(path) + raise NotImplementedError + end + + def list_files(path, &block) + raise NotImplementedError + end + + def set_working_path(path) + @working_path = Pathname.new(path).freeze if assert_unlocked + end + + def join_paths(base, path) + base = Pathname.new(base).cleanpath + path = Pathname.new(path).cleanpath + path = base + path unless path.absolute? + + unless File.join(path, '').start_with? File.join(base, '') + raise InvalidPathError, "Tried accessing #{path} outside #{base}" + end + + path.to_s + end + + def open_yield_close(path) + working_path_was = working_path + open(path) + yield + ensure + set_working_path working_path_was + end + + def create(path, value) + instrument 'create.store', path: path do + create_file(path, value) + end + end + + def update(path, value) + instrument 'update.store', path: path do + update_file(path, value) + end + end + + def destroy(path) + instrument 'destroy.store', path: path do + delete_file(path) + end + end + + def lock + assert_unlocked + @locked = true + yield + ensure + @locked = false + end + + def assert_unlocked + raise LockError if @locked + true + end + + def track_touched + @touched = [] + yield + ensure + @touched = nil + end + + def touch(path) + @touched << path if @touched + end + + def touched?(path) + dir = File.join(path, '') + + @touched.any? do |touched_path| + touched_path == path || touched_path.start_with?(dir) + end + end + + def delete_untouched + return if @touched.empty? + + each do |path| + destroy(path) unless touched?(path) + end + end + end +end diff --git a/lib/docs/storage/file_store.rb b/lib/docs/storage/file_store.rb new file mode 100644 index 00000000..1fd532a9 --- /dev/null +++ b/lib/docs/storage/file_store.rb @@ -0,0 +1,49 @@ +require 'fileutils' +require 'find' + +module Docs + class FileStore < AbstractStore + private + + def read_file(path) + File.read(path) + end + + def create_file(path, value) + FileUtils.mkpath File.dirname(path) + + if value.is_a? Tempfile + FileUtils.move(value, path) + else + File.write(path, value) + end + end + + alias_method :update_file, :create_file + + def delete_file(path) + if File.directory?(path) + FileUtils.rmtree(path, secure: true) + else + FileUtils.rm(path) + end + end + + def file_exist?(path) + File.exists?(path) + end + + def file_mtime(path) + File.mtime(path) + end + + def list_files(path) + Find.find path do |file| + next if file == path + Find.prune if File.basename(file)[0] == '.' + yield file + Find.prune unless File.exists?(file) + end + end + end +end diff --git a/lib/docs/storage/null_store.rb b/lib/docs/storage/null_store.rb new file mode 100644 index 00000000..688b8f61 --- /dev/null +++ b/lib/docs/storage/null_store.rb @@ -0,0 +1,21 @@ +module Docs + class NullStore < AbstractStore + def initialize + super '/' + end + + private + + def nil(*args) + nil + end + + alias_method :read_file, :nil + alias_method :create_file, :nil + alias_method :update_file, :nil + alias_method :delete_file, :nil + alias_method :file_exist?, :nil + alias_method :file_mtime, :nil + alias_method :list_files, :nil + end +end diff --git a/lib/docs/subscribers/filter_subscriber.rb b/lib/docs/subscribers/filter_subscriber.rb new file mode 100644 index 00000000..c9a7db8e --- /dev/null +++ b/lib/docs/subscribers/filter_subscriber.rb @@ -0,0 +1,9 @@ +module Docs + class FilterSubscriber < Subscriber + self.namespace = 'html_pipeline' + + def call_filter(event) + log "Filter: #{event.payload[:filter].gsub('Docs::', '').gsub('Filter', '')} [#{event.duration.round}ms]" + end + end +end diff --git a/lib/docs/subscribers/progress_bar_subscriber.rb b/lib/docs/subscribers/progress_bar_subscriber.rb new file mode 100644 index 00000000..c1b7488c --- /dev/null +++ b/lib/docs/subscribers/progress_bar_subscriber.rb @@ -0,0 +1,25 @@ +require 'progress_bar' + +module Docs + class ProgressBarSubscriber < Subscriber + self.namespace = 'scraper' + + def running(event) + @progress_bar = ::ProgressBar.new event.payload[:urls].length + @progress_bar.write + end + + def queued(event) + @progress_bar.max += event.payload[:urls].length + @progress_bar.write + end + + def process_response(event) + @progress_bar.increment! + end + + def ignore_response(event) + @progress_bar.increment! + end + end +end diff --git a/lib/docs/subscribers/request_subscriber.rb b/lib/docs/subscribers/request_subscriber.rb new file mode 100644 index 00000000..afd4c18c --- /dev/null +++ b/lib/docs/subscribers/request_subscriber.rb @@ -0,0 +1,9 @@ +module Docs + class RequestSubscriber < Subscriber + self.namespace = 'request' + + def response(event) + log "Request: #{format_url event.payload[:url]} [#{event.payload[:response].code}] [#{event.duration.round}ms]" + end + end +end diff --git a/lib/docs/subscribers/scraper_subscriber.rb b/lib/docs/subscribers/scraper_subscriber.rb new file mode 100644 index 00000000..3bbe2b44 --- /dev/null +++ b/lib/docs/subscribers/scraper_subscriber.rb @@ -0,0 +1,21 @@ +module Docs + class ScraperSubscriber < Subscriber + self.namespace = 'scraper' + + def queued(event) + event.payload[:urls].each do |url| + log "Queue: #{format_url url}" + end + end + + def ignore_response(event) + msg = "Ignore: #{format_url event.payload[:response].url}" + msg << " [#{event.payload[:response].code}]" if event.payload[:response].respond_to?(:code) + log(msg) + end + + def process_response(event) + log "Process: #{format_url event.payload[:response].url} [#{event.duration.round}ms]" + end + end +end diff --git a/lib/docs/subscribers/store_subscriber.rb b/lib/docs/subscribers/store_subscriber.rb new file mode 100644 index 00000000..d0f55631 --- /dev/null +++ b/lib/docs/subscribers/store_subscriber.rb @@ -0,0 +1,17 @@ +module Docs + class StoreSubscriber < Subscriber + self.namespace = 'store' + + def create(event) + log "Create: #{format_path event.payload[:path]}" + end + + def update(event) + log "Update: #{format_path event.payload[:path]}" + end + + def destroy(event) + log "Delete: #{format_path event.payload[:path]}" + end + end +end diff --git a/lib/tasks/assets.thor b/lib/tasks/assets.thor new file mode 100644 index 00000000..c3a4caf5 --- /dev/null +++ b/lib/tasks/assets.thor @@ -0,0 +1,47 @@ +class AssetsCLI < Thor + def self.to_s + 'Assets' + end + + def initialize(*args) + ENV['RACK_ENV'] = 'production' + require 'app' + super + end + + desc 'compile [--clean] [--keep=] [--verbose]', 'Compile all assets' + option :clean, type: :boolean, desc: 'Clean old assets after compilation' + option :keep, type: :numeric, default: 0, desc: 'Number of old assets to keep' + option :verbose, type: :boolean + def compile + manifest.compile App.assets_compile + manifest.clean(options[:keep]) if options[:clean] + end + + desc 'clean [--keep=] [--verbose]', 'Clean old assets' + option :keep, type: :numeric, default: 0, desc: 'Number of old assets to keep' + option :verbose, type: :boolean + def clean + manifest.clean(options[:keep]) + end + + private + + def sprockets + @sprockets ||= App.sprockets.tap do |sprockets| + sprockets.logger = logger + sprockets.cache = nil + end + end + + def manifest + @manifest ||= Sprockets::Manifest.new sprockets.index, App.assets_manifest_path + end + + def logger + @logger ||= Logger.new($stdout).tap do |logger| + logger.level = options[:verbose] ? Logger::DEBUG : Logger::INFO + logger.formatter = proc { |severity, datetime, progname, msg| "#{msg}\n" } + end + end +end diff --git a/lib/tasks/console.thor b/lib/tasks/console.thor new file mode 100644 index 00000000..5d37a55b --- /dev/null +++ b/lib/tasks/console.thor @@ -0,0 +1,72 @@ +require 'pry' + +class ConsoleCLI < Thor + def self.to_s + 'Console' + end + + def initialize(*args) + trap('INT') { puts; exit } # exit on ^C + super + end + + default_command :default + + desc '', 'Start a REPL' + def default + Pry.start + end + + desc 'docs', 'Start a REPL in the "Docs" module' + def docs + require 'docs' + Docs.pry + end +end + +Pry::Commands.create_command 'test' do + description 'Run tests in the "test" directory' + group 'Testing' + + banner <<-BANNER + Usage: test [] + + If is a file, run it ("_test.rb" suffix is optional). + If is a directory, run all test files inside it. + Default to all test files. + BANNER + + def process + if pattern = args.first + pattern.prepend 'test/' + + if File.directory?(pattern) + pattern << '/**/*_test.rb' + elsif File.extname(pattern).empty? + pattern << '*_test.rb' + end + else + pattern = 'test/**/*_test.rb' + end + + paths = Dir.glob(pattern).map(&File.method(:expand_path)) + + if paths.empty? + output.puts 'No test files found.' + return + end + + pid = fork do + begin + $LOAD_PATH.unshift 'test' + paths.each(&method(:require)) + rescue Exception => e + _pry_.last_exception = e + run 'wtf?' + exit! + end + end + + Process.wait(pid) + end +end diff --git a/lib/tasks/docs.thor b/lib/tasks/docs.thor new file mode 100644 index 00000000..4b87733c --- /dev/null +++ b/lib/tasks/docs.thor @@ -0,0 +1,181 @@ +class DocsCLI < Thor + include Thor::Actions + + def self.to_s + 'Docs' + end + + def initialize(*args) + require 'docs' + trap('INT') { puts; exit! } # hide backtrace on ^C + super + end + + desc 'list', 'List available documentations' + def list + max_length = 0 + Docs.all. + map { |doc| [doc.to_s.demodulize.underscore, doc] }. + each { |pair| max_length = pair.first.length if pair.first.length > max_length }. + each { |pair| puts "#{pair.first.rjust max_length + 1}: #{pair.second.base_url.gsub %r{https?://}, ''}" } + end + + desc 'page [path] [--verbose] [--debug]', 'Generate a page (no indexing)' + option :verbose, type: :boolean + option :debug, type: :boolean + def page(name, path = '') + unless path.empty? || path.start_with?('/') + return puts 'ERROR: [path] must be an absolute path.' + end + + Docs.install_report :store if options[:verbose] + if options[:debug] + GC.disable + Docs.install_report :filter, :request + end + + if Docs.generate_page(name, path) + puts 'Done' + else + puts "Failed!#{' (try running with --debug for more information)' unless options[:debug]}" + end + rescue Docs::DocNotFound + invalid_doc(name) + end + + desc 'generate [--verbose] [--debug] [--force]', 'Generate a documentation' + option :verbose, type: :boolean + option :debug, type: :boolean + option :force, type: :boolean + def generate(name) + Docs.install_report :store if options[:verbose] + Docs.install_report :scraper if options[:debug] + Docs.install_report :progress_bar if $stdout.tty? + + unless options[:force] + puts <<-TEXT.strip_heredoc + Note: this command will scrape the documentation from the source. + Some scrapers require a local setup. Others will send thousands of + HTTP requests, potentially slowing down the source site. + Please don't use it unless you are modifying the code. + + To download the latest tested version of a documentation, use: + thor docs:download #{name}\n + TEXT + return unless yes? 'Proceed? (y/n)' + end + + if Docs.generate(name) + puts 'Done' + else + puts "Failed!#{' (try running with --debug for more information)' unless options[:debug]}" + end + rescue Docs::DocNotFound + invalid_doc(name) + end + + desc 'manifest', 'Create the manifest' + def manifest + Docs.generate_manifest + puts 'Done' + end + + desc 'download ( ... | --all)', 'Download documentations' + option :all, type: :boolean + def download(*names) + require 'unix_utils' + docs = options[:all] ? Docs.all : find_docs(names) + assert_docs(docs) + download_docs(docs) + Docs.generate_manifest + puts 'Done' + rescue Docs::DocNotFound => error + invalid_doc(error.name) + end + + desc 'package ( ... | --all)', 'Package documentations' + option :all, type: :boolean + def package(*names) + require 'unix_utils' + docs = options[:all] ? Docs.all : find_docs(names) + assert_docs(docs) + docs.each(&method(:package_doc)) + puts 'Done' + rescue Docs::DocNotFound => error + invalid_doc(error.name) + end + + private + + def find_docs(names) + names.map do |name| + Docs.find(name) + end + end + + def assert_docs(docs) + if docs.empty? + puts 'ERROR: called with no arguments.' + puts 'Run "thor docs:list" for usage patterns.' + exit + end + end + + def invalid_doc(name) + puts %(ERROR: invalid doc "#{name}".) + puts 'Run "thor docs:list" to see the list of docs.' + end + + def download_docs(docs) + require 'thread' + length = docs.length + i = 0 + + (1..4).map do + Thread.new do + while doc = docs.shift + status = begin + download_doc(doc) + 'OK' + rescue OpenURI::HTTPError => error + "FAILED (#{error.message})" + end + puts "(#{i += 1}/#{length}) #{doc.name} #{status}" + end + end + end.map(&:join) + end + + def download_doc(doc) + target = File.join(Docs.store_path, "#{doc.path}.tar.gz") + open "http://dl.devdocs.io/#{doc.path}.tar.gz" do |file| + FileUtils.mkpath(Docs.store_path) + FileUtils.mv(file, target) + unpackage_doc(doc) + end + end + + def unpackage_doc(doc) + path = File.join(Docs.store_path, doc.path) + FileUtils.mkpath(path) + tar = UnixUtils.gunzip("#{path}.tar.gz") + dir = UnixUtils.untar(tar) + FileUtils.rm_rf(path) + FileUtils.mv(dir, path) + FileUtils.rm(tar) + FileUtils.rm("#{path}.tar.gz") + end + + def package_doc(doc) + path = File.join Docs.store_path, doc.path + + if File.exist?(path) + tar = UnixUtils.tar(path) + gzip = UnixUtils.gzip(tar) + FileUtils.mv(gzip, "#{path}.tar.gz") + FileUtils.rm(tar) + else + puts %(ERROR: can't find "#{doc.name}" documentation files.) + end + end +end diff --git a/lib/tasks/test.thor b/lib/tasks/test.thor new file mode 100644 index 00000000..5a654ed4 --- /dev/null +++ b/lib/tasks/test.thor @@ -0,0 +1,15 @@ +require 'pry' + +class TestCLI < Thor + def self.to_s + 'Test' + end + + default_command :all + + desc 'all', 'Run all tests' + def all + $LOAD_PATH.unshift 'test' + Dir['test/**/*_test.rb'].map(&File.method(:expand_path)).each(&method(:require)) + end +end diff --git a/public/404.html b/public/404.html new file mode 100644 index 00000000..85c2bb31 --- /dev/null +++ b/public/404.html @@ -0,0 +1,69 @@ + + + + + Page not found + + + + +
    +

    Oops!

    +

    + The page you were looking for doesn't exist.
    + Go back to devdocs.io. +

    +
    + + diff --git a/public/500.html b/public/500.html new file mode 100644 index 00000000..67f18143 --- /dev/null +++ b/public/500.html @@ -0,0 +1,70 @@ + + + + + Error + + + + +
    +

    Oops!

    +

    + Something is technically wrong.
    + Thanks for noticing—we're going to fix it up and have things back to normal soon.
    + Go back to devdocs.io. +

    +
    + + diff --git a/public/docs/docs.json b/public/docs/docs.json new file mode 100644 index 00000000..0637a088 --- /dev/null +++ b/public/docs/docs.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 00000000..136da6a9 Binary files /dev/null and b/public/favicon.ico differ diff --git a/public/icons/docs/angular/16.png b/public/icons/docs/angular/16.png new file mode 100644 index 00000000..65f79fc7 Binary files /dev/null and b/public/icons/docs/angular/16.png differ diff --git a/public/icons/docs/angular/16@2x.png b/public/icons/docs/angular/16@2x.png new file mode 100644 index 00000000..62518f7d Binary files /dev/null and b/public/icons/docs/angular/16@2x.png differ diff --git a/public/icons/docs/angular/SOURCE b/public/icons/docs/angular/SOURCE new file mode 100644 index 00000000..e25f07fc --- /dev/null +++ b/public/icons/docs/angular/SOURCE @@ -0,0 +1 @@ +https://github.com/angular/angular.js/tree/master/images/logo/AngularJS-Shield.exports diff --git a/public/icons/docs/backbone/16.png b/public/icons/docs/backbone/16.png new file mode 100644 index 00000000..4d58f116 Binary files /dev/null and b/public/icons/docs/backbone/16.png differ diff --git a/public/icons/docs/backbone/16@2x.png b/public/icons/docs/backbone/16@2x.png new file mode 100644 index 00000000..dfd6e0af Binary files /dev/null and b/public/icons/docs/backbone/16@2x.png differ diff --git a/public/icons/docs/backbone/SOURCE b/public/icons/docs/backbone/SOURCE new file mode 100644 index 00000000..3225ed39 --- /dev/null +++ b/public/icons/docs/backbone/SOURCE @@ -0,0 +1 @@ +http://backbonejs.org/docs/images/favicon.ico diff --git a/public/icons/docs/coffeescript/16.png b/public/icons/docs/coffeescript/16.png new file mode 100644 index 00000000..081dbba2 Binary files /dev/null and b/public/icons/docs/coffeescript/16.png differ diff --git a/public/icons/docs/coffeescript/16@2x.png b/public/icons/docs/coffeescript/16@2x.png new file mode 100644 index 00000000..d580a2ed Binary files /dev/null and b/public/icons/docs/coffeescript/16@2x.png differ diff --git a/public/icons/docs/coffeescript/SOURCE b/public/icons/docs/coffeescript/SOURCE new file mode 100644 index 00000000..ba74eca4 --- /dev/null +++ b/public/icons/docs/coffeescript/SOURCE @@ -0,0 +1 @@ +https://github.com/jashkenas/coffee-script/downloads diff --git a/public/icons/docs/css/16.png b/public/icons/docs/css/16.png new file mode 100644 index 00000000..538ddb0f Binary files /dev/null and b/public/icons/docs/css/16.png differ diff --git a/public/icons/docs/css/16@2x.png b/public/icons/docs/css/16@2x.png new file mode 100644 index 00000000..f06ea2d4 Binary files /dev/null and b/public/icons/docs/css/16@2x.png differ diff --git a/public/icons/docs/dom/16.png b/public/icons/docs/dom/16.png new file mode 100644 index 00000000..3fbc0dcc Binary files /dev/null and b/public/icons/docs/dom/16.png differ diff --git a/public/icons/docs/dom/16@2x.png b/public/icons/docs/dom/16@2x.png new file mode 100644 index 00000000..e09167e5 Binary files /dev/null and b/public/icons/docs/dom/16@2x.png differ diff --git a/public/icons/docs/dom_events/16.png b/public/icons/docs/dom_events/16.png new file mode 100644 index 00000000..95cd73e2 Binary files /dev/null and b/public/icons/docs/dom_events/16.png differ diff --git a/public/icons/docs/dom_events/16@2x.png b/public/icons/docs/dom_events/16@2x.png new file mode 100644 index 00000000..26dd27d3 Binary files /dev/null and b/public/icons/docs/dom_events/16@2x.png differ diff --git a/public/icons/docs/ember/16.png b/public/icons/docs/ember/16.png new file mode 100644 index 00000000..cf6f54d3 Binary files /dev/null and b/public/icons/docs/ember/16.png differ diff --git a/public/icons/docs/ember/16@2x.png b/public/icons/docs/ember/16@2x.png new file mode 100644 index 00000000..9592ce75 Binary files /dev/null and b/public/icons/docs/ember/16@2x.png differ diff --git a/public/icons/docs/ember/SOURCE b/public/icons/docs/ember/SOURCE new file mode 100644 index 00000000..87f67da0 --- /dev/null +++ b/public/icons/docs/ember/SOURCE @@ -0,0 +1 @@ +https://github.com/tschundeee/emberjsfavicon diff --git a/public/icons/docs/html/16.png b/public/icons/docs/html/16.png new file mode 100644 index 00000000..b3d04c3a Binary files /dev/null and b/public/icons/docs/html/16.png differ diff --git a/public/icons/docs/html/16@2x.png b/public/icons/docs/html/16@2x.png new file mode 100644 index 00000000..dfed0078 Binary files /dev/null and b/public/icons/docs/html/16@2x.png differ diff --git a/public/icons/docs/html/SOURCE b/public/icons/docs/html/SOURCE new file mode 100644 index 00000000..f0d9c6df --- /dev/null +++ b/public/icons/docs/html/SOURCE @@ -0,0 +1 @@ +http://www.w3.org/html/logo/ diff --git a/public/icons/docs/http/16.png b/public/icons/docs/http/16.png new file mode 100644 index 00000000..5bdaac96 Binary files /dev/null and b/public/icons/docs/http/16.png differ diff --git a/public/icons/docs/http/16@2x.png b/public/icons/docs/http/16@2x.png new file mode 100644 index 00000000..209ea722 Binary files /dev/null and b/public/icons/docs/http/16@2x.png differ diff --git a/public/icons/docs/http/SOURCE b/public/icons/docs/http/SOURCE new file mode 100644 index 00000000..ffa79609 --- /dev/null +++ b/public/icons/docs/http/SOURCE @@ -0,0 +1 @@ +http://www.entypo.com/ diff --git a/public/icons/docs/javascript/16.png b/public/icons/docs/javascript/16.png new file mode 100644 index 00000000..a3255562 Binary files /dev/null and b/public/icons/docs/javascript/16.png differ diff --git a/public/icons/docs/javascript/16@2x.png b/public/icons/docs/javascript/16@2x.png new file mode 100644 index 00000000..69f59713 Binary files /dev/null and b/public/icons/docs/javascript/16@2x.png differ diff --git a/public/icons/docs/javascript/SOURCE b/public/icons/docs/javascript/SOURCE new file mode 100644 index 00000000..c783d340 --- /dev/null +++ b/public/icons/docs/javascript/SOURCE @@ -0,0 +1 @@ +https://github.com/voodootikigod/logo.js diff --git a/public/icons/docs/jquery/16.png b/public/icons/docs/jquery/16.png new file mode 100644 index 00000000..7d18dd03 Binary files /dev/null and b/public/icons/docs/jquery/16.png differ diff --git a/public/icons/docs/jquery/16@2x.png b/public/icons/docs/jquery/16@2x.png new file mode 100644 index 00000000..84b1673c Binary files /dev/null and b/public/icons/docs/jquery/16@2x.png differ diff --git a/public/icons/docs/jquery/SOURCE b/public/icons/docs/jquery/SOURCE new file mode 100644 index 00000000..ed3fd831 --- /dev/null +++ b/public/icons/docs/jquery/SOURCE @@ -0,0 +1 @@ +http://brand.jquery.org/logos/ diff --git a/public/icons/docs/jquerymobile/16.png b/public/icons/docs/jquerymobile/16.png new file mode 100644 index 00000000..7a38c22d Binary files /dev/null and b/public/icons/docs/jquerymobile/16.png differ diff --git a/public/icons/docs/jquerymobile/16@2x.png b/public/icons/docs/jquerymobile/16@2x.png new file mode 100644 index 00000000..076ef5e5 Binary files /dev/null and b/public/icons/docs/jquerymobile/16@2x.png differ diff --git a/public/icons/docs/jquerymobile/SOURCE b/public/icons/docs/jquerymobile/SOURCE new file mode 100644 index 00000000..ed3fd831 --- /dev/null +++ b/public/icons/docs/jquerymobile/SOURCE @@ -0,0 +1 @@ +http://brand.jquery.org/logos/ diff --git a/public/icons/docs/jqueryui/16.png b/public/icons/docs/jqueryui/16.png new file mode 100644 index 00000000..c2c6c4ee Binary files /dev/null and b/public/icons/docs/jqueryui/16.png differ diff --git a/public/icons/docs/jqueryui/16@2x.png b/public/icons/docs/jqueryui/16@2x.png new file mode 100644 index 00000000..47c50334 Binary files /dev/null and b/public/icons/docs/jqueryui/16@2x.png differ diff --git a/public/icons/docs/jqueryui/SOURCE b/public/icons/docs/jqueryui/SOURCE new file mode 100644 index 00000000..ed3fd831 --- /dev/null +++ b/public/icons/docs/jqueryui/SOURCE @@ -0,0 +1 @@ +http://brand.jquery.org/logos/ diff --git a/public/icons/docs/less/16.png b/public/icons/docs/less/16.png new file mode 100644 index 00000000..be65add0 Binary files /dev/null and b/public/icons/docs/less/16.png differ diff --git a/public/icons/docs/less/16@2x.png b/public/icons/docs/less/16@2x.png new file mode 100644 index 00000000..d3b7c5fa Binary files /dev/null and b/public/icons/docs/less/16@2x.png differ diff --git a/public/icons/docs/lodash/16.png b/public/icons/docs/lodash/16.png new file mode 100644 index 00000000..fd7347ac Binary files /dev/null and b/public/icons/docs/lodash/16.png differ diff --git a/public/icons/docs/lodash/16@2x.png b/public/icons/docs/lodash/16@2x.png new file mode 100644 index 00000000..b2d0420f Binary files /dev/null and b/public/icons/docs/lodash/16@2x.png differ diff --git a/public/icons/docs/lodash/SOURCE b/public/icons/docs/lodash/SOURCE new file mode 100644 index 00000000..cc131a8e --- /dev/null +++ b/public/icons/docs/lodash/SOURCE @@ -0,0 +1 @@ +http://lodash.com/favicon.ico diff --git a/public/icons/docs/node/16.png b/public/icons/docs/node/16.png new file mode 100644 index 00000000..0fdaf40f Binary files /dev/null and b/public/icons/docs/node/16.png differ diff --git a/public/icons/docs/node/16@2x.png b/public/icons/docs/node/16@2x.png new file mode 100644 index 00000000..f8f26907 Binary files /dev/null and b/public/icons/docs/node/16@2x.png differ diff --git a/public/icons/docs/node/SOURCE b/public/icons/docs/node/SOURCE new file mode 100644 index 00000000..b76f8c98 --- /dev/null +++ b/public/icons/docs/node/SOURCE @@ -0,0 +1 @@ +http://en.wikipedia.org/wiki/File:Node.js_logo.svg diff --git a/public/icons/docs/php/16.png b/public/icons/docs/php/16.png new file mode 100644 index 00000000..53c72c9c Binary files /dev/null and b/public/icons/docs/php/16.png differ diff --git a/public/icons/docs/php/16@2x.png b/public/icons/docs/php/16@2x.png new file mode 100644 index 00000000..bcfbaa16 Binary files /dev/null and b/public/icons/docs/php/16@2x.png differ diff --git a/public/icons/docs/php/SOURCE b/public/icons/docs/php/SOURCE new file mode 100644 index 00000000..40f9fc1d --- /dev/null +++ b/public/icons/docs/php/SOURCE @@ -0,0 +1 @@ +http://php.net/download-logos.php diff --git a/public/icons/docs/sass/16.png b/public/icons/docs/sass/16.png new file mode 100644 index 00000000..99d4aebd Binary files /dev/null and b/public/icons/docs/sass/16.png differ diff --git a/public/icons/docs/sass/16@2x.png b/public/icons/docs/sass/16@2x.png new file mode 100644 index 00000000..18fa5f25 Binary files /dev/null and b/public/icons/docs/sass/16@2x.png differ diff --git a/public/icons/docs/sass/SOURCE b/public/icons/docs/sass/SOURCE new file mode 100644 index 00000000..76a4953d --- /dev/null +++ b/public/icons/docs/sass/SOURCE @@ -0,0 +1 @@ +https://github.com/nex3/sass/blob/sass-pages/resources/sass.psd diff --git a/public/icons/docs/underscore/16.png b/public/icons/docs/underscore/16.png new file mode 100644 index 00000000..633d7698 Binary files /dev/null and b/public/icons/docs/underscore/16.png differ diff --git a/public/icons/docs/underscore/16@2x.png b/public/icons/docs/underscore/16@2x.png new file mode 100644 index 00000000..43566293 Binary files /dev/null and b/public/icons/docs/underscore/16@2x.png differ diff --git a/public/icons/docs/underscore/SOURCE b/public/icons/docs/underscore/SOURCE new file mode 100644 index 00000000..8aaf6ae2 --- /dev/null +++ b/public/icons/docs/underscore/SOURCE @@ -0,0 +1 @@ +http://underscorejs.org/favicon.ico diff --git a/public/icons/ui/check/SOURCE b/public/icons/ui/check/SOURCE new file mode 100644 index 00000000..766f1fbe --- /dev/null +++ b/public/icons/ui/check/SOURCE @@ -0,0 +1 @@ +http://happytodesign.com/hicons/ diff --git a/public/icons/ui/check/check.png b/public/icons/ui/check/check.png new file mode 100644 index 00000000..702b0578 Binary files /dev/null and b/public/icons/ui/check/check.png differ diff --git a/public/icons/ui/check/check@2x.png b/public/icons/ui/check/check@2x.png new file mode 100644 index 00000000..a5e407a9 Binary files /dev/null and b/public/icons/ui/check/check@2x.png differ diff --git a/public/icons/ui/clear/SOURCE b/public/icons/ui/clear/SOURCE new file mode 100644 index 00000000..ffa79609 --- /dev/null +++ b/public/icons/ui/clear/SOURCE @@ -0,0 +1 @@ +http://www.entypo.com/ diff --git a/public/icons/ui/clear/clear.png b/public/icons/ui/clear/clear.png new file mode 100644 index 00000000..86cc57cc Binary files /dev/null and b/public/icons/ui/clear/clear.png differ diff --git a/public/icons/ui/clear/clear@2x.png b/public/icons/ui/clear/clear@2x.png new file mode 100644 index 00000000..438b02bb Binary files /dev/null and b/public/icons/ui/clear/clear@2x.png differ diff --git a/public/icons/ui/close-white/SOURCE b/public/icons/ui/close-white/SOURCE new file mode 100644 index 00000000..ffa79609 --- /dev/null +++ b/public/icons/ui/close-white/SOURCE @@ -0,0 +1 @@ +http://www.entypo.com/ diff --git a/public/icons/ui/close-white/close-white.png b/public/icons/ui/close-white/close-white.png new file mode 100644 index 00000000..d007befc Binary files /dev/null and b/public/icons/ui/close-white/close-white.png differ diff --git a/public/icons/ui/close-white/close-white@2x.png b/public/icons/ui/close-white/close-white@2x.png new file mode 100644 index 00000000..2fbfa4a8 Binary files /dev/null and b/public/icons/ui/close-white/close-white@2x.png differ diff --git a/public/icons/ui/dir/SOURCE b/public/icons/ui/dir/SOURCE new file mode 100644 index 00000000..fa3cf541 --- /dev/null +++ b/public/icons/ui/dir/SOURCE @@ -0,0 +1 @@ +http://gemicon.net/ diff --git a/public/icons/ui/dir/dir.png b/public/icons/ui/dir/dir.png new file mode 100644 index 00000000..65fd86c5 Binary files /dev/null and b/public/icons/ui/dir/dir.png differ diff --git a/public/icons/ui/dir/dir@2x.png b/public/icons/ui/dir/dir@2x.png new file mode 100644 index 00000000..5509cf96 Binary files /dev/null and b/public/icons/ui/dir/dir@2x.png differ diff --git a/public/icons/ui/home/16.png b/public/icons/ui/home/16.png new file mode 100644 index 00000000..196e238b Binary files /dev/null and b/public/icons/ui/home/16.png differ diff --git a/public/icons/ui/home/16@2x.png b/public/icons/ui/home/16@2x.png new file mode 100644 index 00000000..5faf7b3c Binary files /dev/null and b/public/icons/ui/home/16@2x.png differ diff --git a/public/icons/ui/home/SOURCE b/public/icons/ui/home/SOURCE new file mode 100644 index 00000000..ffa79609 --- /dev/null +++ b/public/icons/ui/home/SOURCE @@ -0,0 +1 @@ +http://www.entypo.com/ diff --git a/public/icons/ui/link/SOURCE b/public/icons/ui/link/SOURCE new file mode 100644 index 00000000..ffa79609 --- /dev/null +++ b/public/icons/ui/link/SOURCE @@ -0,0 +1 @@ +http://www.entypo.com/ diff --git a/public/icons/ui/link/link.png b/public/icons/ui/link/link.png new file mode 100644 index 00000000..3f252756 Binary files /dev/null and b/public/icons/ui/link/link.png differ diff --git a/public/icons/ui/link/link@2x.png b/public/icons/ui/link/link@2x.png new file mode 100644 index 00000000..47757526 Binary files /dev/null and b/public/icons/ui/link/link@2x.png differ diff --git a/public/icons/ui/menu/16.png b/public/icons/ui/menu/16.png new file mode 100644 index 00000000..49f30c31 Binary files /dev/null and b/public/icons/ui/menu/16.png differ diff --git a/public/icons/ui/menu/16@2x.png b/public/icons/ui/menu/16@2x.png new file mode 100644 index 00000000..957920c9 Binary files /dev/null and b/public/icons/ui/menu/16@2x.png differ diff --git a/public/icons/ui/search/SOURCE b/public/icons/ui/search/SOURCE new file mode 100644 index 00000000..ffa79609 --- /dev/null +++ b/public/icons/ui/search/SOURCE @@ -0,0 +1 @@ +http://www.entypo.com/ diff --git a/public/icons/ui/search/search.png b/public/icons/ui/search/search.png new file mode 100644 index 00000000..50483945 Binary files /dev/null and b/public/icons/ui/search/search.png differ diff --git a/public/icons/ui/search/search@2x.png b/public/icons/ui/search/search@2x.png new file mode 100644 index 00000000..ca48bca9 Binary files /dev/null and b/public/icons/ui/search/search@2x.png differ diff --git a/public/icons/ui/settings/SOURCE b/public/icons/ui/settings/SOURCE new file mode 100644 index 00000000..fa3cf541 --- /dev/null +++ b/public/icons/ui/settings/SOURCE @@ -0,0 +1 @@ +http://gemicon.net/ diff --git a/public/icons/ui/settings/settings.png b/public/icons/ui/settings/settings.png new file mode 100755 index 00000000..3e4925a9 Binary files /dev/null and b/public/icons/ui/settings/settings.png differ diff --git a/public/icons/ui/settings/settings@2x.png b/public/icons/ui/settings/settings@2x.png new file mode 100755 index 00000000..707390e2 Binary files /dev/null and b/public/icons/ui/settings/settings@2x.png differ diff --git a/public/images/apple-icon-114.png b/public/images/apple-icon-114.png new file mode 100644 index 00000000..13865412 Binary files /dev/null and b/public/images/apple-icon-114.png differ diff --git a/public/images/apple-icon-144.png b/public/images/apple-icon-144.png new file mode 100644 index 00000000..f35e5c45 Binary files /dev/null and b/public/images/apple-icon-144.png differ diff --git a/public/images/apple-icon-57.png b/public/images/apple-icon-57.png new file mode 100644 index 00000000..9756199b Binary files /dev/null and b/public/images/apple-icon-57.png differ diff --git a/public/images/apple-icon-72.png b/public/images/apple-icon-72.png new file mode 100644 index 00000000..61efe22a Binary files /dev/null and b/public/images/apple-icon-72.png differ diff --git a/public/images/icon-128.png b/public/images/icon-128.png new file mode 100644 index 00000000..4ace58a2 Binary files /dev/null and b/public/images/icon-128.png differ diff --git a/public/images/icon-16.png b/public/images/icon-16.png new file mode 100644 index 00000000..36bf5093 Binary files /dev/null and b/public/images/icon-16.png differ diff --git a/public/images/icon-32.png b/public/images/icon-32.png new file mode 100644 index 00000000..7b700955 Binary files /dev/null and b/public/images/icon-32.png differ diff --git a/public/images/icon-64.png b/public/images/icon-64.png new file mode 100644 index 00000000..96ebd8a3 Binary files /dev/null and b/public/images/icon-64.png differ diff --git a/public/images/icon-75.png b/public/images/icon-75.png new file mode 100644 index 00000000..5549c24e Binary files /dev/null and b/public/images/icon-75.png differ diff --git a/public/opensearch.xml b/public/opensearch.xml new file mode 100644 index 00000000..6efff909 --- /dev/null +++ b/public/opensearch.xml @@ -0,0 +1,12 @@ + + + DevDocs + Search API documentation + devdocs + + http://maxcdn.devdocs.io/favicon.ico + http://maxcdn.devdocs.io/images/icon-64.png + UTF-8 + http://devdocs.io + + diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 00000000..5af3b66e --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,7 @@ +User-agent: * +Disallow: /about +Disallow: /news +Disallow: /tips +Disallow: /help +Disallow: /*/ +Allow: / diff --git a/test/lib/docs/core/doc_test.rb b/test/lib/docs/core/doc_test.rb new file mode 100644 index 00000000..8e41b73c --- /dev/null +++ b/test/lib/docs/core/doc_test.rb @@ -0,0 +1,316 @@ +require 'test_helper' +require 'docs' + +class DocsDocTest < MiniTest::Spec + let :doc do + Class.new Docs::Doc do + self.name = 'name' + self.type = 'type' + end + end + + let :page do + { store_path: 'store_path', output: 'output', entries: [entry] } + end + + let :entry do + Docs::Entry.new + end + + let :index do + Docs::EntryIndex.new + end + + let :store do + Docs::NullStore.new + end + + describe ".inherited" do + it "sets .type" do + assert_equal doc.type, Class.new(doc).type + end + end + + describe ".name" do + it "returns 'Doc' when the class is Docs::Doc" do + assert_equal 'Doc', Docs::Doc.name + end + end + + describe ".name=" do + it "stores .name" do + doc.name = 'test' + assert_equal 'test', doc.name + end + end + + describe ".slug" do + it "returns 'doc' when the class is Docs::Doc" do + assert_equal 'doc', Docs::Doc.slug + end + end + + describe ".slug=" do + it "stores .slug" do + doc.slug = 'test' + assert_equal 'test', doc.slug + end + end + + describe ".version=" do + it "stores .version" do + doc.version = '1' + assert_equal '1', doc.version + end + end + + describe ".abstract" do + it "returns nil" do + assert_nil doc.abstract + end + end + + describe ".abstract=" do + it "stores .abstract" do + doc.abstract = true + assert doc.abstract + end + end + + describe ".path" do + it "returns .slug" do + doc.slug = 'slug' + assert_equal 'slug', doc.path + end + end + + describe ".index_path" do + it "returns .path + ::INDEX_FILENAME" do + stub(doc).path { 'path' } + assert_equal File.join('path', Docs::Doc::INDEX_FILENAME), doc.index_path + end + end + + describe ".new" do + it "raises an error when .abstract is true" do + doc.abstract = true + assert_raises NotImplementedError do + doc.new + end + end + end + + describe ".as_json" do + it "returns a hash" do + assert_instance_of Hash, doc.as_json + end + + it "includes the doc's name, slug, type, version and index_path" do + %w(name slug type version index_path).each do |attribute| + eval "stub(doc).#{attribute} { attribute }" + assert_equal attribute, doc.as_json[attribute.to_sym] + end + end + end + + describe ".index_page" do + it "builds a page" do + any_instance_of(doc) do |instance| + stub(instance).build_page('id') { @called = true; nil } + end + doc.index_page('id') {} + assert @called + end + + context "when the page builds successfully" do + before do + any_instance_of(doc) do |instance| + stub(instance).build_page { page } + end + end + + it "yields the page's :store_path and :output" do + doc.index_page('') { |*args| @args = args } + assert_equal [page[:store_path], page[:output]], @args + end + + it "returns an EntryIndex" do + assert_instance_of Docs::EntryIndex, doc.index_page('') {} + end + + describe "the index" do + it "contains the page's entries" do + index = doc.index_page('') {} + assert_equal page[:entries], index.entries + end + end + end + + context "when the page doesn't build successfully" do + before do + any_instance_of(doc) do |instance| + stub(instance).build_page { nil } + end + end + + it "doesn't yield" do + doc.index_page('') { |*_| @yield = true } + refute @yield + end + + it "returns nil" do + assert_nil doc.index_page('') {} + end + end + end + + describe ".index_pages" do + it "build the pages" do + any_instance_of(doc) do |instance| + stub(instance).build_pages { @called = true } + end + doc.index_pages {} + assert @called + end + + context "when pages are built successfully" do + let :pages do + [page, page.dup] + end + + before do + any_instance_of(doc) do |instance| + stub(instance).build_pages { |block| pages.each(&block) } + end + end + + it "yields each page's :store_path and :output" do + doc.index_pages { |*args| (@args ||= []) << args } + assert_equal pages.length, @args.length + assert_equal [page[:store_path], page[:output]], @args.first + end + + it "returns an EntryIndex" do + assert_instance_of Docs::EntryIndex, doc.index_pages {} + end + + describe "the index" do + it "contains all pages' entries" do + index = doc.index_pages {} + assert_equal pages.length, index.entries.length + assert_includes index.entries, entry + end + end + end + + context "when no pages are built successfully" do + before do + any_instance_of(doc) do |instance| + stub(instance).build_pages + end + end + + it "doesn't yield" do + doc.index_pages { |*_| @yield = true } + refute @yield + end + + it "returns nil" do + assert_nil doc.index_pages {} + end + end + end + + describe ".store_page" do + context "when the page is indexed successfully" do + before do + stub(doc).index_page('id').yields(page[:store_path], page[:output]) { index } + end + + it "returns true" do + assert doc.store_page(store, 'id') + end + + it "stores a file" do + mock(store).write(page[:store_path], page[:output]) + doc.store_page(store, 'id') + end + + it "opens the .path directory before storing the file" do + stub(doc).path { 'path' } + stub(store).write { assert false } + mock(store).open('path') do |_, block| + stub(store).write + block.call + end + doc.store_page(store, 'id') + end + end + + context "when the page isn't indexed successfully" do + before do + stub(doc).index_page('id') { nil } + end + + it "returns false" do + refute doc.store_page(store, 'id') + end + + it "doesn't store a file" do + dont_allow(store).write + doc.store_page(store, 'id') + end + end + end + + describe ".store_pages" do + context "when pages are indexed successfully" do + before do + stub(store).write + stub(doc).index_pages do |block| + 2.times { block.call page[:store_path], page[:output] } + index + end + end + + it "returns true" do + assert doc.store_pages(store) + end + + it "stores a file for each page" do + 2.times { mock(store).write(page[:store_path], page[:output]) } + doc.store_pages(store) + end + + it "stores the index" do + mock(store).write('index.json', index.to_json) + doc.store_pages(store) + end + + it "replaces the .path directory before storing the files" do + stub(doc).path { 'path' } + stub(store).write { assert false } + mock(store).replace('path') do |_, block| + stub(store).write + block.call + end + doc.store_pages(store) + end + end + + context "when no pages are indexed successfully" do + before do + stub(doc).index_pages { nil } + end + + it "returns false" do + refute doc.store_pages(store) + end + + it "doesn't store files" do + dont_allow(store).write + doc.store_pages(store) + end + end + end +end diff --git a/test/lib/docs/core/entry_index_test.rb b/test/lib/docs/core/entry_index_test.rb new file mode 100644 index 00000000..52707569 --- /dev/null +++ b/test/lib/docs/core/entry_index_test.rb @@ -0,0 +1,128 @@ +require 'test_helper' +require 'docs' + +class DocsEntryIndexTest < MiniTest::Spec + let :entry do + Docs::Entry.new 'name', 'type', 'path' + end + + let :index do + Docs::EntryIndex.new + end + + describe "#entries" do + it "returns an Array" do + assert_instance_of Array, index.entries + end + end + + describe "#types" do + it "returns a hash" do + assert_instance_of Hash, index.types + end + end + + describe "#add" do + it "stores an entry" do + index.add(entry) + assert_includes index.entries, entry + end + + it "stores an array of entries" do + entries = [entry, entry] + index.add(entries) + assert_equal entries, index.entries + end + + it "duplicates the entry" do + index.add(entry) + refute_same entry, index.entries.first + end + + it "doesn't store the root entry" do + mock(entry).root? { true } + index.add(entry) + assert_empty index.entries + assert_empty index.types + end + + it "creates and indexes the type" do + entry.type = 'one'; index.add(entry) + entry.type = 'two'; 2.times { index.add(entry) } + assert_equal ['one', 'two'], index.types.keys + assert_instance_of Docs::Type, index.types['one'] + end + + it "doesn't index the nil type" do + entry.type = nil; index.add(entry) + assert_empty index.types + end + + it "increments the type's count" do + 2.times { index.add(entry) } + assert_equal 2, index.types[entry.type].count + end + end + + describe "#empty?" do + it "returns true when entries have been added" do + assert index.empty? + end + + it "returns false when an entry has been added" do + index.add(entry) + refute index.empty? + end + end + + describe "#as_json" do + it "returns a Hash" do + assert_instance_of Hash, index.as_json + end + + describe ":entries" do + it "is an empty array by default" do + assert_instance_of Array, index.as_json[:entries] + end + + it "includes the json representation of the #entries" do + index.add [entry, entry] + assert_equal [entry.as_json, entry.as_json], index.as_json[:entries] + end + + it "is sorted by name, case-insensitive" do + entry.name = 'B'; index.add(entry) + entry.name = 'a'; index.add(entry) + entry.name = 'c'; index.add(entry) + entry.name = nil; index.add(entry) + assert_equal [nil, 'a', 'B', 'c'], index.as_json[:entries].map { |e| e[:name] } + end + end + + describe ":types" do + it "is an empty array by default" do + assert_instance_of Array, index.as_json[:types] + end + + it "includes the json representation of the #types" do + type = Docs::Type.new 'one', 1 + entry.type = 'one'; index.add(entry) + assert_equal type.as_json, index.as_json[:types].first + end + + it "is sorted by name, case-insensitive" do + entry.type = 'B'; index.add(entry) + entry.type = 'a'; index.add(entry) + entry.type = 'c'; index.add(entry) + assert_equal ['a', 'B', 'c'], index.as_json[:types].map { |e| e[:name] } + end + end + end + + describe "#to_json" do + it "returns the JSON string for #as_json" do + stub(index).as_json { { entries: [1], types: [2] } } + assert_equal '{"entries":[1],"types":[2]}', index.to_json + end + end +end diff --git a/test/lib/docs/core/filter_test.rb b/test/lib/docs/core/filter_test.rb new file mode 100644 index 00000000..107627ef --- /dev/null +++ b/test/lib/docs/core/filter_test.rb @@ -0,0 +1,154 @@ +require 'test_helper' +require 'docs' + +class DocsFilterTest < MiniTest::Spec + include FilterTestHelper + self.filter_class = Docs::Filter + + before do + context[:base_url] = 'http://example.com/path' + context[:url] = 'http://example.com/path/file' + end + + describe "#subpath" do + it "returns the #subpath_to the #current_url" do + stub(filter).subpath_to(filter.current_url) { 'subpath' } + assert_equal 'subpath', filter.subpath + end + end + + describe "#subpath_to" do + it "returns the subpath from the #base_url to the url, ignoring case" do + stub(filter.base_url).subpath_to('url', hash_including(ignore_case: true)) { 'subpath' } + assert_equal 'subpath', filter.subpath_to('url') + end + end + + describe "#slug" do + def slug(subpath) + stub(filter).subpath { subpath } + filter.slug + end + + it "returns '' when #subpath is blank" do + assert_equal '', slug('') + end + + it "returns '' when #subpath is '/'" do + assert_equal '', slug('/') + end + + it "returns 'path' when #subpath is '/path'" do + assert_equal 'path', slug('/path') + end + + it "returns 'path' when #subpath is '/path.html'" do + assert_equal 'path', slug('/path.html') + end + + it "returns 'if..else' when #subpath is '/if..else'" do + assert_equal 'if..else', slug('/if..else') + end + + it "returns 'dir/path' when #subpath is '/dir/path'" do + assert_equal 'dir/path', slug('/dir/path') + end + end + + describe "#root_page?" do + it "returns true when #subpath is blank" do + stub(filter).subpath { '' } + assert filter.root_page? + end + + it "returns true when #subpath is '/'" do + stub(filter).subpath { '/' } + assert filter.root_page? + end + + it "returns true when #subpath is the root path" do + context[:root_path] = '/path' + stub(filter).subpath { '/path' } + assert filter.root_page? + end + + it "returns false when #subpath isn't the root path" do + stub(filter).subpath { '/path' } + refute filter.root_page? + end + end + + describe "#fragment_url_string?" do + it "returns false with ''" do + refute filter.fragment_url_string?('') + end + + it "returns true with '#'" do + assert filter.fragment_url_string?('#') + end + + it "returns false with '/#'" do + refute filter.fragment_url_string?('/#') + end + + it "returns false with 'http://example.com/#'" do + refute filter.fragment_url_string?('http://example.com/#') + end + end + + describe "#relative_url_string?" do + it "returns true with ''" do + assert filter.relative_url_string?('') + end + + it "returns true with 'http'" do + assert filter.relative_url_string?('http') + end + + it "returns true with '/file'" do + assert filter.relative_url_string?('/file') + end + + it "returns true with '?file'" do + assert filter.relative_url_string?('?file') + end + + it "returns false with '#file'" do + refute filter.relative_url_string?('#file') + end + + it "returns false with 'http://example.com'" do + refute filter.relative_url_string?('http://example.com') + end + + it "returns false with 'ftp://example.com'" do + refute filter.relative_url_string?('ftp://example.com') + end + + it "returns false with 'mailto:test@example.com'" do + refute filter.relative_url_string?('mailto:test@example.com') + end + end + + describe "#absolute_url_string?" do + it "returns true with 'http://example.com'" do + assert filter.absolute_url_string?('http://example.com') + end + + it "returns true with 'ftp://example.com'" do + assert filter.absolute_url_string?('ftp://example.com') + end + + it "returns true with 'mailto:test@example.com'" do + assert filter.absolute_url_string?('mailto:test@example.com') + end + + it "returns false with ''" do + refute filter.absolute_url_string?('') + end + + it "returns false with 'http'" do + refute filter.absolute_url_string?('http') + end + end +end diff --git a/test/lib/docs/core/instrumentable_test.rb b/test/lib/docs/core/instrumentable_test.rb new file mode 100644 index 00000000..0c8fc956 --- /dev/null +++ b/test/lib/docs/core/instrumentable_test.rb @@ -0,0 +1,25 @@ +require 'test_helper' +require 'docs' + +class DocsInstrumentableTest < MiniTest::Spec + let :extended_class do + Class.new.tap { |klass| klass.send :extend, Docs::Instrumentable } + end + + let :included_class do + Class.new.tap { |klass| klass.send :include, Docs::Instrumentable } + end + + it "works when extended" do + extended_class.subscribe('test') { @called = true } + extended_class.instrument 'test' + assert @called + end + + it "works when included" do + instance = included_class.new + instance.subscribe('test') { @called = true } + instance.instrument 'test' + assert @called + end +end diff --git a/test/lib/docs/core/manifest_test.rb b/test/lib/docs/core/manifest_test.rb new file mode 100644 index 00000000..06c498a5 --- /dev/null +++ b/test/lib/docs/core/manifest_test.rb @@ -0,0 +1,87 @@ +require 'test_helper' +require 'docs' + +class ManifestTest < MiniTest::Spec + let :doc do + Class.new Docs::Doc + end + + let :store do + Docs::NullStore.new + end + + let :manifest do + Docs::Manifest.new store, [doc] + end + + describe "#store" do + before do + stub(manifest).as_json + end + + it "stores a file" do + mock(store).write.with_any_args + manifest.store + end + + describe "the file" do + it "is named ::FILENAME" do + mock(store).write Docs::Manifest::FILENAME, anything + manifest.store + end + + it "contains the manifest's JSON dump" do + stub(manifest).to_json { 'json' } + mock(store).write anything, 'json' + manifest.store + end + end + end + + describe "#as_json" do + let :index_path do + 'index_path' + end + + before do + stub(doc).index_path { index_path } + end + + it "returns an array" do + manifest = Docs::Manifest.new store, [] + assert_instance_of Array, manifest.as_json + end + + context "when the doc has an index" do + before do + stub(store).exist?(index_path) { true } + end + + it "includes the doc's JSON representation" do + json = manifest.as_json + assert_equal 1, json.length + assert_empty doc.as_json.keys - json.first.keys + end + + it "adds an :mtime attribute" do + mtime = Time.now - 1 + stub(store).mtime(index_path) { mtime } + assert_equal mtime.to_i, manifest.as_json.first[:mtime] + end + end + + context "when the doc doesn't have an index" do + it "doesn't include the doc" do + stub(store).exist?(index_path) { false } + assert_empty manifest.as_json + end + end + end + + describe "#to_json" do + it "returns the JSON string for #as_json" do + stub(manifest).as_json { { test: 'ok' } } + assert_equal '{"test":"ok"}', manifest.to_json + end + end +end diff --git a/test/lib/docs/core/models/entry_test.rb b/test/lib/docs/core/models/entry_test.rb new file mode 100644 index 00000000..bbf811ff --- /dev/null +++ b/test/lib/docs/core/models/entry_test.rb @@ -0,0 +1,108 @@ +require 'test_helper' +require 'docs' + +class DocsEntryTest < MiniTest::Spec + Entry = Docs::Entry + + let :entry do + Entry.new + end + + describe ".new" do + it "stores a name" do + assert_equal 'name', Entry.new('name').name + end + + it "stores a path" do + assert_equal 'path', Entry.new(nil, 'path').path + end + + it "stores a type" do + assert_equal 'type', Entry.new(nil, nil, 'type').type + end + end + + describe "#name=" do + it "removes surrounding whitespace" do + entry.name = " \n\rname " + assert_equal 'name', entry.name + end + + it "accepts nil" do + entry.name = nil + assert_nil entry.name + end + end + + describe "#type=" do + it "removes surrounding whitespace" do + entry.type = " \n\rtype " + assert_equal 'type', entry.type + end + + it "accepts nil" do + entry.type = nil + assert_nil entry.type + end + end + + describe "#==" do + it "returns true when the other has the same name, path and type" do + assert_equal Entry.new, Entry.new + end + + it "returns false when the other has a different name" do + entry.name = 'name' + refute_equal Entry.new, entry + end + + it "returns false when the other has a different path" do + entry.path = 'path' + refute_equal Entry.new, entry + end + + it "returns false when the other has a different type" do + entry.type = 'type' + refute_equal Entry.new, entry + end + end + + describe "#<=>" do + it "returns 1 when the other's name is less" do + assert_equal 1, Entry.new('b') <=> Entry.new('a') + end + + it "returns -1 when the other's name is greater" do + assert_equal -1, Entry.new('a') <=> Entry.new('b') + end + + it "returns 0 when the other's name is equal" do + assert_equal 0, Entry.new('a') <=> Entry.new('a') + end + + it "is case-insensitive" do + assert_equal 0, Entry.new('a') <=> Entry.new('A') + end + end + + describe "#root?" do + it "returns true when #path is 'index'" do + entry.path = 'index' + assert entry.root? + end + + it "returns false when #path is 'path'" do + entry.path = 'path' + refute entry.root? + end + end + + describe "#as_json" do + it "returns a hash with the name, path and type" do + as_json = Entry.new('name', 'path', 'type').as_json + assert_instance_of Hash, as_json + assert_equal [:name, :path, :type], as_json.keys + assert_equal %w(name path type), as_json.values + end + end +end diff --git a/test/lib/docs/core/models/type_test.rb b/test/lib/docs/core/models/type_test.rb new file mode 100644 index 00000000..31951685 --- /dev/null +++ b/test/lib/docs/core/models/type_test.rb @@ -0,0 +1,54 @@ +require 'test_helper' +require 'docs' + +class DocsTypeTest < MiniTest::Spec + Type = Docs::Type + + describe ".new" do + it "stores a name" do + assert_equal 'name', Type.new('name').name + end + + it "stores a count" do + assert_equal 10, Type.new(nil, 10).count + end + + it "defaults the count to 0" do + assert_equal 0, Type.new.count + end + end + + describe "#<=>" do + it "returns 1 when the other type's name is less" do + assert_equal 1, Type.new('b') <=> Type.new('a') + end + + it "returns -1 when the other type's name is greater" do + assert_equal -1, Type.new('a') <=> Type.new('b') + end + + it "returns 0 when the other type's name is equal" do + assert_equal 0, Type.new('a') <=> Type.new('a') + end + + it "is case-insensitive" do + assert_equal 0, Type.new('a') <=> Type.new('A') + end + end + + describe "#slug" do + it "parameterizes the #name" do + name = 'a.b c\/%?#' + assert_equal 'a-b-c', Type.new(name).slug + end + end + + describe "#as_json" do + it "returns a hash with the name, count and slug" do + as_json = Type.new('name', 10).as_json + assert_instance_of Hash, as_json + assert_equal [:name, :count, :slug], as_json.keys + assert_equal ['name', 10, 'name'], as_json.values + end + end +end diff --git a/test/lib/docs/core/parser_test.rb b/test/lib/docs/core/parser_test.rb new file mode 100644 index 00000000..6f2dce23 --- /dev/null +++ b/test/lib/docs/core/parser_test.rb @@ -0,0 +1,28 @@ +require 'test_helper' +require 'docs' + +class DocsParserTest < MiniTest::Spec + def parser(content) + Docs::Parser.new(content) + end + + describe "#html" do + it "returns a Nokogiri Node" do + assert_kind_of Nokogiri::XML::Node, parser('').html + end + + context "with an HTML fragment" do + it "returns the fragment" do + body = '
    Test
    ' + assert_equal body, parser(body).html.inner_html + end + end + + context "with an HTML document" do + it "returns the " do + body = '
    Test
    ' + assert_equal '
    Test
    ', parser(body).html.inner_html + end + end + end +end diff --git a/test/lib/docs/core/request_test.rb b/test/lib/docs/core/request_test.rb new file mode 100644 index 00000000..51ef922a --- /dev/null +++ b/test/lib/docs/core/request_test.rb @@ -0,0 +1,69 @@ +require 'test_helper' +require 'docs' + +class DocsRequestTest < MiniTest::Spec + let :url do + 'http://example.com' + end + + def request(url = url, options = {}) + Docs::Request.new(url, options).tap do |request| + request.extend FakeInstrumentation + end + end + + let :response do + Typhoeus::Response.new.tap do |response| + Typhoeus.stub(url).and_return(response) + end + end + + after do + Typhoeus::Expectation.clear + end + + describe ".run" do + before { response } + + it "makes a request and returns the response" do + assert_equal response, Docs::Request.run(url) + end + + it "calls the given block with the response" do + Docs::Request.run(url) { |arg| @arg = arg } + assert_equal response, @arg + end + end + + describe ".new" do + it "accepts a Docs::URL" do + url = Docs::URL.parse 'http://example.com' + assert_equal url.to_s, request(url).base_url + end + + it "defaults :followlocation to true" do + assert request.options[:followlocation] + refute request(url, followlocation: false).options[:followlocation] + end + end + + describe "#run" do + before { response } + + it "instruments 'response'" do + req = request.tap(&:run) + assert req.last_instrumentation + assert_equal 'response.request', req.last_instrumentation[:event] + assert_equal url, req.last_instrumentation[:payload][:url] + assert_equal response, req.last_instrumentation[:payload][:response] + end + end + + describe "#response=" do + it "extends the object with Docs::Response" do + response = Object.new + request.response = response + assert_includes response.singleton_class.ancestors, Docs::Response + end + end +end diff --git a/test/lib/docs/core/requester_test.rb b/test/lib/docs/core/requester_test.rb new file mode 100644 index 00000000..d9c81b85 --- /dev/null +++ b/test/lib/docs/core/requester_test.rb @@ -0,0 +1,124 @@ +require 'test_helper' +require 'docs' + +class DocsRequesterTest < MiniTest::Spec + def stub_request(url) + Typhoeus.stub(url).and_return(Typhoeus::Response.new) + end + + let :requester do + Docs::Requester.new(options) + end + + let :options do + Hash.new + end + + let :url do + 'http://example.com' + end + + after do + Typhoeus::Expectation.clear + end + + describe ".new" do + it "defaults the :max_concurrency to 5" do + assert_equal 5, Docs::Requester.new.max_concurrency + assert_equal 10, Docs::Requester.new(max_concurrency: 10).max_concurrency + end + + it "duplicates and stores #request_options" do + options[:request_options] = { params: 'test' } + assert_equal options[:request_options], requester.request_options + refute_same options[:request_options], requester.request_options + end + end + + describe "#request" do + it "returns a request" do + assert_instance_of Docs::Request, requester.request(url) + end + + describe "the request" do + it "is queued" do + request = requester.request(url) + assert_includes requester.queued_requests, request + end + + it "has the given url" do + request = requester.request(url) + assert_equal url, request.base_url + end + + it "has the default request options" do + options[:request_options] = { params: 'test' } + request = requester.request(url) + assert_equal 'test', request.options[:params] + end + + it "has the given options" do + options[:request_options] = { params: '' } + request = requester.request(url, params: 'test') + assert_equal 'test', request.options[:params] + end + + it "has the given block as an on_complete callback" do + block = Proc.new {} + request = requester.request(url, &block) + assert_includes request.on_complete, block + end + end + end + + describe "#on_response" do + it "returns an array" do + assert_instance_of Array, requester.on_response + end + + it "stores a callback" do + proc = Proc.new {} + requester.on_response(&proc) + assert_includes requester.on_response, proc + end + end + + describe "#run" do + before do + stub_request(url) + end + + it "calls the #on_response callbacks after each request" do + one = 0; requester.on_response { one += 1 } + two = 0; requester.on_response { two += 2 } + + 2.times do |i| + stub_request url = "example.com/#{i}" + requester.request(url) + end + + assert_difference 'one', 2 do + assert_difference 'two', 4 do + requester.run + end + end + end + + it "passes the response to the #on_response callbacks" do + requester.on_response { |arg| @arg = arg } + request = requester.request(url) + request.run + assert @arg + assert_equal request.response, @arg + end + + context "when an #on_response callback returns an array" do + it "requests the urls in the array" do + requester.on_response { ['one', 'two'] } + requester.request(url) + mock(requester).request('one').then.request('two') + requester.run + end + end + end +end diff --git a/test/lib/docs/core/response_test.rb b/test/lib/docs/core/response_test.rb new file mode 100644 index 00000000..3264ba09 --- /dev/null +++ b/test/lib/docs/core/response_test.rb @@ -0,0 +1,110 @@ +require 'test_helper' +require 'docs' +require 'typhoeus' + +class DocsResponseTest < MiniTest::Spec + let :response do + Typhoeus::Response.new(options).tap do |response| + response.extend Docs::Response + response.request = request + end + end + + let :request do + OpenStruct.new + end + + let :options do + OpenStruct.new headers: {} + end + + describe "#success?" do + it "returns true when the code is 200" do + options.code = 200 + assert response.success? + end + + it "returns false when the code is 404" do + options.code = 404 + refute response.success? + end + end + + describe "#empty?" do + it "returns true when the body is empty" do + options.body = '' + assert response.empty? + end + + it "returns false when the body isn't empty" do + options.body = 'body' + refute response.empty? + end + end + + describe "#mime_type" do + it "returns the content type" do + options.headers['Content-Type'] = 'type' + assert_equal 'type', response.mime_type + end + + it "defaults to text/plain" do + assert_equal 'text/plain', response.mime_type + end + end + + describe "#html?" do + it "returns true when the content type is 'text/html'" do + options.headers['Content-Type'] = 'text/html' + assert response.html? + end + + it "returns true when the content type is 'application/xhtml'" do + options.headers['Content-Type'] = 'application/xhtml' + assert response.html? + end + + it "returns false when the content type is 'text/plain'" do + options.headers['Content-Type'] = 'text/plain' + refute response.html? + end + end + + describe "#url" do + before { request.base_url = 'http://example.com' } + + it "returns a Docs::URL" do + assert_instance_of Docs::URL, response.url + end + + it "returns the #request's base url" do + assert_equal request.base_url, response.url.to_s + end + end + + describe "#path" do + it "returns the #url's path" do + request.base_url = 'http://example.com/path' + assert_equal '/path', response.path + end + end + + describe "#effective_url" do + before { options.effective_url = 'http://example.com' } + + it "returns a Docs::URL" do + assert_instance_of Docs::URL, response.effective_url + end + + it "returns the effective url" do + assert_equal options.effective_url, response.effective_url.to_s + end + end + + describe "#effective_path" do + it "returns the #effective_url's path" do + options.effective_url = 'http://example.com/path' + assert_equal '/path', response.effective_path + end + end +end diff --git a/test/lib/docs/core/scraper_test.rb b/test/lib/docs/core/scraper_test.rb new file mode 100644 index 00000000..1610c623 --- /dev/null +++ b/test/lib/docs/core/scraper_test.rb @@ -0,0 +1,449 @@ +require 'test_helper' +require 'docs' + +class DocsScraperTest < MiniTest::Spec + class Scraper < Docs::Scraper + self.type = 'scraper' + self.base_url = 'http://example.com/' + self.root_path = '/root' + self.html_filters = Docs::FilterStack.new + self.text_filters = Docs::FilterStack.new + end + + let :scraper do + Scraper.new.tap do |scraper| + scraper.extend FakeInstrumentation + end + end + + let :response do + Struct.new(:body, :url).new + end + + let :processed_response do + Hash.new + end + + describe ".inherited" do + let :subclass do + Class.new Scraper + end + + it "sets .type" do + assert_equal Scraper.type, subclass.type + end + + it "duplicates .options" do + stub(Scraper).options { { test: [] } } + assert_equal Scraper.options, subclass.options + refute_same Scraper.options, subclass.options + refute_same Scraper.options[:test], subclass.options[:test] + end + + it "duplicates .html_filters" do + assert_equal Scraper.html_filters, subclass.html_filters + refute_same Scraper.html_filters, subclass.html_filters + end + + it "duplicates .text_filters" do + assert_equal Scraper.text_filters, subclass.text_filters + refute_same Scraper.text_filters, subclass.text_filters + end + end + + describe ".filters" do + it "returns the union of .html_filters and .text_filters" do + stub(Scraper.html_filters).to_a { [1] } + stub(Scraper.text_filters).to_a { [2] } + assert_equal [1, 2], Scraper.filters + end + end + + describe "#root_path?" do + it "returns false when .root_path is blank" do + stub(Scraper).root_path { '' } + refute scraper.root_path? + end + + it "returns false when .root_path is '/'" do + stub(Scraper).root_path { '/' } + refute scraper.root_path? + end + + it "returns true when .root_path is '/path'" do + stub(Scraper).root_path { '/path' } + assert scraper.root_path? + end + end + + describe "#root_url" do + context "when #root_path? is false" do + before do + stub(scraper).root_path? { false } + end + + it "returns a Docs::URL" do + assert_instance_of Docs::URL, scraper.root_url + end + + it "returns the normalized base url" do + stub(Scraper).base_url { 'http://example.com' } + assert_equal 'http://example.com/', scraper.root_url.to_s + end + end + + context "when .root_path isn't blank" do + before do + stub(scraper).root_path? { true } + end + + it "returns a Docs::URL" do + assert_instance_of Docs::URL, scraper.root_url + end + + it "returns base url + root path" do + stub(Scraper).base_url { 'http://example.com/path/' } + assert_equal 'http://example.com/path/root', scraper.root_url.to_s + end + end + end + + describe "#build_page" do + before do + stub(scraper).handle_response + end + + it "requires a path" do + assert_raises ArgumentError do + scraper.build_page + end + end + + context "with a blank path" do + it "requests the root url" do + mock(scraper).request_one(scraper.root_url.to_s) + scraper.build_page '' + end + end + + context "with '/'" do + it "requests the root url" do + mock(scraper).request_one(scraper.root_url.to_s) + scraper.build_page '/' + end + end + + context "with '/file'" do + it "requests 'example.com/file' when the base url is 'example.com" do + stub(Scraper).base_url { 'http://example.com' } + mock(scraper).request_one 'http://example.com/file' + scraper.build_page '/file' + end + + it "requests 'example.com/file' when the base url is 'example.com/" do + stub(Scraper).base_url { 'http://example.com/' } + mock(scraper).request_one 'http://example.com/file' + scraper.build_page '/file' + end + end + + it "returns the processed response" do + stub(scraper).request_one { response } + mock(scraper).handle_response(response) { 'test' } + assert_equal 'test', scraper.build_page('') + end + + it "yields the processed response" do + stub(scraper).request_one { response } + stub(scraper).handle_response(response) { 'test' } + scraper.build_page('') { |arg| @arg = arg } + assert @arg + assert_equal 'test', @arg + end + end + + describe "#build_pages" do + let :block do + Proc.new {} + end + + it "requests the root url" do + mock(scraper).request_all(scraper.root_url.to_s) + scraper.build_pages(&block) + end + + it "instruments 'running'" do + stub(scraper).request_all + scraper.build_pages(&block) + assert scraper.last_instrumentation + assert_equal 'running.scraper', scraper.last_instrumentation[:event] + assert_includes scraper.last_instrumentation[:payload][:urls], scraper.root_url.to_s + end + + context "when the response is processable" do + before do + stub(scraper).request_all.with_any_args { |*args| @returned = args.last.call(response) } + stub(scraper).handle_response(response) { processed_response } + end + + it "yields the processed response" do + scraper.build_pages { |arg| @arg = arg } + assert @arg + assert_equal processed_response, @arg + end + + context "when response[:internal_urls] is empty" do + before do + processed_response[:internal_urls] = [] + end + + it "requests nothing more" do + scraper.build_pages(&block) + assert_nil @returned + end + + it "doesn't instrument 'queued'" do + scraper.build_pages(&block) + refute_equal 'queued.scraper', scraper.last_instrumentation.try(:[], :event) + end + end + + context "when response[:internal_urls] isn't empty" do + let :urls do + ['Url'] + end + + before do + processed_response[:internal_urls] = urls + end + + it "requests the urls" do + scraper.build_pages(&block) + assert_equal urls, @returned + end + + it "doesn't request the same url twice irrespective of case" do + stub(Scraper).root_path { 'PATH' } + processed_response[:internal_urls] = [scraper.root_url.to_s.swapcase] + scraper.build_pages(&block) + assert_empty @returned + end + + it "instruments 'queued'" do + scraper.build_pages(&block) + assert scraper.last_instrumentation + assert_equal 'queued.scraper', scraper.last_instrumentation[:event] + assert_equal urls, scraper.last_instrumentation[:payload][:urls] + end + end + end + + context "when the response isn't processable" do + it "doesn't yield" do + stub(scraper).request_all.yields(response) + stub(scraper).handle_response(response) { nil } + scraper.build_pages { @yield = true } + refute @yield + end + end + end + + describe "#options" do + let :options do + scraper.options + end + + it "returns a frozen, memoized Hash" do + assert_instance_of Hash, options + assert options.frozen? + assert_same options, scraper.options + end + + it "includes .options" do + stub(Scraper).options { { test: true } } + assert options[:test] + end + + it "includes #base_url" do + assert_equal scraper.base_url, options[:base_url] + end + + it "includes #root_url" do + assert_equal scraper.root_url, options[:root_url] + end + + it "includes .root_path" do + assert_equal '/root', options[:root_path] + end + + context "when #root_path? is false" do + before do + stub(scraper).root_path? { false } + end + + it "doesn't modify :skip" do + assert_nil options[:skip] + end + + context "and :only is an array" do + before do + stub(Scraper).options { { only: ['/path'] } } + end + + it "adds ['', '/']" do + assert_includes options[:only], '/path' + assert_includes options[:only], '' + assert_includes options[:only], '/' + end + + it "doesn't modify the array in place" do + assert_equal ['/path'], Scraper.options[:only] + end + end + + context "and :only_patterns is an array" do + it "assigns ['', '/'] to :only" do + stub(Scraper).options { { only_patterns: [] } } + assert_equal ['', '/'], options[:only] + end + end + end + + context "when #root_path? is true" do + before do + stub(scraper).root_path? { true } + end + + context "and :skip is nil" do + it "assigns it ['', '/']" do + assert_equal ['', '/'], options[:skip] + end + end + + context "and :skip is an array" do + before do + stub(Scraper).options { { skip: ['/path'] } } + end + + it "adds ['', '/']" do + assert_includes options[:skip], '/path' + assert_includes options[:skip], '' + assert_includes options[:skip], '/' + end + + it "doesn't modify the array in place" do + assert_equal ['/path'], Scraper.options[:skip] + end + end + + context "and :only is an array" do + it "adds .root_path" do + stub(Scraper).options { { only: [] } } + assert_includes options[:only], '/root' + end + end + + context "and :only_patterns is an array" do + it "assigns [.root_path] to :only" do + stub(Scraper).options { { only_patterns: [] } } + assert_equal ['/root'], options[:only] + end + end + end + end + + describe "#handle_response" do + let :result do + scraper.send :handle_response, response + end + + context "when the response is processable" do + before do + stub(scraper).process_response?(response) { true } + end + + it "runs the pipeline" do + mock(scraper.pipeline).call.with_any_args + result + end + + it "returns the result" do + stub(scraper.pipeline).call { |_, _, result| result[:test] = true } + assert result[:test] + end + + it "instruments 'process_response'" do + result + assert scraper.last_instrumentation + assert_equal 'process_response.scraper', scraper.last_instrumentation[:event] + assert_equal response, scraper.last_instrumentation[:payload][:response] + end + + context "the pipeline document" do + it "is the parsed response body" do + response.body = 'body' + stub(scraper.pipeline).call { |arg| @arg = arg } + mock.proxy(Docs::Parser).new('body') { |parser| stub(parser).html { 'html' } } + result + assert_equal 'html', @arg + end + end + + context "the pipeline context" do + let :context do + stub(scraper.pipeline).call { |_, arg| @arg = arg } + result + @arg + end + + it "includes #options" do + stub(scraper).options { { test: true } } + assert context[:test] + end + + it "includes the response url" do + response.url = 'url' + assert_equal 'url', context[:url] + end + end + end + + context "when the response isn't processable" do + before do + stub(scraper).process_response?(response) { false } + end + + it "doesn't run the pipeline" do + dont_allow(scraper.pipeline).call + result + end + + it "returns nil" do + assert_nil result + end + + it "instruments 'ignore_response'" do + result + assert scraper.last_instrumentation + assert_equal 'ignore_response.scraper', scraper.last_instrumentation[:event] + assert_equal response, scraper.last_instrumentation[:payload][:response] + end + end + end + + describe "#pipeline" do + it "returns an HTML::Pipeline with .filters" do + stub(Scraper).filters { [1] } + assert_instance_of ::HTML::Pipeline, scraper.pipeline + assert_equal Scraper.filters, scraper.pipeline.filters + end + + it "is memoized" do + assert_same scraper.pipeline, scraper.pipeline + end + + it "assigns Docs as the pipeline's instrumentation service" do + assert_equal Docs, scraper.pipeline.instrumentation_service + end + end +end diff --git a/test/lib/docs/core/scrapers/file_scraper_test.rb b/test/lib/docs/core/scrapers/file_scraper_test.rb new file mode 100644 index 00000000..d438934f --- /dev/null +++ b/test/lib/docs/core/scrapers/file_scraper_test.rb @@ -0,0 +1,108 @@ +require 'test_helper' +require 'docs' + +class FileScraperTest < MiniTest::Spec + class Scraper < Docs::FileScraper + self.dir = '/dir' + self.base_url = 'http://example.com' + self.html_filters = Docs::FilterStack.new + self.text_filters = Docs::FilterStack.new + end + + let :scraper do + Scraper.new + end + + let :response do + OpenStruct.new body: 'body', url: Docs::URL.parse(Scraper.base_url) + end + + describe "#request_one" do + let :url do + File.join(Scraper.base_url, 'path') + end + + let :result do + scraper.send :request_one, url + end + + before do + stub(scraper).read_file + end + + describe "the returned response object" do + it "has a #body" do + stub(scraper).read_file { 'body' } + assert_equal 'body', result.body + end + + it "has a #url" do + assert_equal url, result.url.to_s + assert_instance_of Docs::URL, result.url + end + end + + it "reads .dir/path when the url is .base_url/path" do + stub(scraper).read_file(File.join(Scraper.dir, 'path')) { 'test' } + assert_equal 'test', result.body + end + end + + describe "#request_all" do + it "requests the given url and yields the response" do + stub(scraper).request_one('url') { 'response' } + scraper.send(:request_all, 'url') { |response| @response = response } + assert_equal 'response', @response + end + + describe "when the block returns an array" do + it "requests and yields the returned urls" do + stub(scraper).request_one('one') { 1 } + stub(scraper).request_one('two') { 2 } + stub(scraper).request_one('three') { 3 } + scraper.send :request_all, 'one' do |response| + if response == 1 + ['two'] + elsif response == 2 + ['three'] + else + @response = response + end + end + assert_equal 3, @response + end + end + end + + describe "#process_response?" do + let :result do + scraper.send :process_response?, response + end + + it "returns false when the response body is blank" do + response.body = '' + refute result + end + + it "returns true when the response body isn't blank" do + response.body = 'body' + assert result + end + end + + describe "#read_file" do + let :result do + scraper.send :read_file, 'file' + end + + it "returns the file's content when the file exists" do + stub(File).read('file') { 'content' } + assert_equal 'content', result + end + + it "returns nil when the file doesn't exist" do + stub(File).read('file') { raise } + assert_nil result + end + end +end diff --git a/test/lib/docs/core/scrapers/url_scraper_test.rb b/test/lib/docs/core/scrapers/url_scraper_test.rb new file mode 100644 index 00000000..c297a5b1 --- /dev/null +++ b/test/lib/docs/core/scrapers/url_scraper_test.rb @@ -0,0 +1,107 @@ +require 'test_helper' +require 'docs' + +class DocsUrlScraperTest < MiniTest::Spec + class Scraper < Docs::UrlScraper + self.base_url = 'http://example.com' + self.html_filters = Docs::FilterStack.new + self.text_filters = Docs::FilterStack.new + end + + let :scraper do + Scraper.new + end + + describe ".inherited" do + it "duplicates .params" do + stub(Scraper).params { { test: [] } } + subclass = Class.new Scraper + assert_equal Scraper.params, subclass.params + refute_same Scraper.params, subclass.params + refute_same Scraper.params[:test], subclass.params[:test] + end + end + + describe "#request_one" do + let :result do + scraper.send :request_one, 'url' + end + + it "runs a Request with the given url" do + mock(Docs::Request).run 'url', anything + result + end + + it "runs a Request with the .params" do + stub(Scraper).params { { test: true } } + mock(Docs::Request).run anything, satisfy { |options| options[:params][:test] } + result + end + + it "returns the result" do + stub(Docs::Request).run { 'response' } + assert_equal 'response', result + end + end + + describe "#request_all" do + let :block do + Proc.new {} + end + + let :result do + scraper.send :request_all, 'url', &block + end + + it "runs a Requester with the given url" do + mock(Docs::Requester).run 'url', anything + result + end + + it "runs a Requester with .params as :request_options" do + stub(Scraper).params { { test: true } } + mock(Docs::Requester).run anything, satisfy { |options| options[:request_options][:params][:test] } + result + end + + it "runs a Requester with the given block" do + mock(Docs::Requester).run.with_any_args { |*args| @block = args.last } + result + assert_equal block, @block + end + + it "returns the result" do + stub(Docs::Requester).run { 'response' } + assert_equal 'response', result + end + end + + describe "#process_response?" do + let :response do + OpenStruct.new success?: true, html?: true, effective_url: scraper.root_url + end + + let :result do + scraper.send :process_response?, response + end + + it "returns false when the response isn't successful" do + response.send 'success?=', false + refute result + end + + it "returns false when the response isn't HTML" do + response.send 'html?=', false + refute result + end + + it "returns false when the response's effective url isn't in the base url" do + response.effective_url = 'http://not.example.com' + refute result + end + + it "returns true otherwise" do + assert result + end + end +end diff --git a/test/lib/docs/core/url_test.rb b/test/lib/docs/core/url_test.rb new file mode 100644 index 00000000..1daee310 --- /dev/null +++ b/test/lib/docs/core/url_test.rb @@ -0,0 +1,418 @@ +require 'test_helper' +require 'docs' + +class DocsUrlTest < MiniTest::Spec + URL = Docs::URL + + describe ".new" do + it "works with no arguments" do + assert_instance_of URL, URL.new + end + + it "works with a Hash of components" do + assert_equal '/path', URL.new(path: '/path').to_s + end + + it "raises an error with an invalid component" do + assert_raises(ArgumentError) { URL.new test: nil } + end + end + + describe ".parse" do + it "returns a URL when given a string" do + assert_instance_of URL, URL.parse('http://example.com') + end + + it "returns the same URL when given a URL" do + url = URL.new + assert_same url, URL.parse(url) + end + end + + describe "#join" do + it "joins urls" do + url = URL.parse 'http://example.com/path/to/' + assert_equal 'http://example.com/path/to/file', url.join('..', 'to/file').to_s + end + end + + describe "#merge!" do + it "works with a Hash of components" do + assert_equal '/path', URL.new.merge!(path: '/path').to_s + end + + it "raises an error with an invalid component" do + assert_raises(ArgumentError) { URL.new.merge! test: nil } + end + end + + describe "#origin" do + it "returns 'http://example.com' when the URL is 'http://example.com/path?#'" do + assert_equal 'http://example.com', URL.parse('http://example.com/path?#').origin + end + + it "returns 'http://example.com' when the URL is 'HTTP://EXAMPLE.COM'" do + assert_equal 'http://example.com', URL.parse('HTTP://EXAMPLE.COM').origin + end + + it "returns 'http://example.com:8080' when the URL is 'http://example.com:8080'" do + assert_equal 'http://example.com:8080', URL.parse('http://example.com:8080').origin + end + + it "returns nil when the URL is 'example.com'" do + assert_nil URL.parse('example.com').origin + end + + it "returns nil when the URL is 'mailto:test@example.com'" do + assert_nil URL.parse('mailto:test@example.com').origin + end + end + + describe "#normalized_path" do + it "returns '/' when the URL is ''" do + assert_equal '/', URL.parse('').normalized_path + end + + it "returns '/path' when the URL is '/path'" do + assert_equal '/path', URL.parse('/path').normalized_path + end + end + + describe "#subpath_to" do + context "when the URL is relative" do + let :url do + URL.parse '/' + end + + it "raises an error with a relative url" do + assert_raises(ArgumentError) { url.subpath_to '/' } + end + + it "raises an error with an absolute url" do + assert_raises(ArgumentError) { url.subpath_to 'http://example.com' } + end + end + + context "when the URL is 'http://example.com'" do + let :url do + URL.parse 'http://example.com' + end + + it "raises an error with a relative url" do + assert_raises(ArgumentError) { url.subpath_to '/' } + end + + it "returns '' with 'http://example.com'" do + assert_equal '', url.subpath_to('http://example.com') + end + + it "returns '' with 'HTTP://EXAMPLE.COM'" do + assert_equal '', url.subpath_to('HTTP://EXAMPLE.COM') + end + + it "returns '/' with 'http://example.com/'" do + assert_equal '/', url.subpath_to('http://example.com/') + end + + it "returns '/path' with 'http://example.com/path'" do + assert_equal '/path', url.subpath_to('http://example.com/path') + end + + it "returns '/' with 'http://example.com/?query'" do + assert_equal '/', url.subpath_to('http://example.com/?query') + end + + it "returns '/' with 'http://example.com/#frag'" do + assert_equal '/', url.subpath_to('http://example.com/#frag') + end + + it "returns nil with 'https://example.com/'" do + assert_nil url.subpath_to('https://example.com/') + end + + it "returns nil with 'http://not.example.com/'" do + assert_nil url.subpath_to('http://not.example.com/') + end + end + + context "when the URL is 'http://example.com/'" do + let :url do + URL.parse 'http://example.com/' + end + + it "returns nil with 'http://example.com'" do + assert_equal nil, url.subpath_to('http://example.com') + end + + it "returns '' with 'http://example.com/'" do + assert_equal '', url.subpath_to('http://example.com/') + end + end + + context "when the URL is 'http://example.com/path/to'" do + let :url do + URL.parse 'http://example.com/path/to' + end + + it "returns nil with 'http://example.com'" do + assert_nil url.subpath_to('http://example.com') + end + + it "returns nil with 'http://example.com/'" do + assert_nil url.subpath_to('http://example.com/') + end + + it "returns nil with 'http://example.com/path/'" do + assert_nil url.subpath_to('http://example.com/path/') + end + + it "returns '' with 'http://example.com/path/to'" do + assert_equal '', url.subpath_to('http://example.com/path/to') + end + + it "returns '/file' with 'http://example.com/path/to/file'" do + assert_equal '/file', url.subpath_to('http://example.com/path/to/file') + end + + it "returns nil with 'http://example.com/path/tofile'" do + assert_nil url.subpath_to('http://example.com/path/tofile') + end + + it "returns nil with 'http://example.com/PATH/to/file'" do + assert_nil url.subpath_to('http://example.com/PATH/to/file') + end + + context "and :ignore_case is true" do + it "returns '/file' with 'http://example.com/PATH/to/file'" do + assert_equal '/file', url.subpath_to('http://example.com/PATH/to/file', ignore_case: true) + end + end + end + + context "when the URL is 'http://example.com/path/to/'" do + let :url do + URL.parse 'http://example.com/path/to/' + end + + it "returns nil with 'http://example.com/path/to'" do + assert_nil url.subpath_to('http://example.com/path/to') + end + + it "returns '' with 'http://example.com/path/to/'" do + assert_equal '', url.subpath_to('http://example.com/path/to/') + end + + it "returns 'file' with 'http://example.com/path/to/file'" do + assert_equal 'file', url.subpath_to('http://example.com/path/to/file') + end + end + end + + describe "#subpath_from" do + let :url do + URL.new + end + + before do + any_instance_of URL do |instance| + stub(instance).subpath_to + end + end + + it "returns the given url's #subpath_to to self" do + any_instance_of URL do |instance| + stub(instance).subpath_to(url, nil) { 'subpath' } + end + assert_equal 'subpath', url.subpath_from('url') + end + + it "calls #subpath_to with the given options" do + any_instance_of URL do |instance| + stub(instance).subpath_to(url, 'options') { 'subpath' } + end + assert_equal 'subpath', url.subpath_from('url', 'options') + end + end + + describe "#contains?" do + let :url do + URL.new + end + + before do + stub(url).subpath_to + end + + it "calls #subpath_to with the given url" do + mock(url).subpath_to('url', nil) + url.contains?('url') + end + + it "calls #subpath_to with the given options" do + mock(url).subpath_to('url', 'options') + url.contains?('url', 'options') + end + + it "returns true when the #subpath_to the given url is a string" do + stub(url).subpath_to { '' } + assert url.contains?('url') + end + + it "returns true when the #subpath_to the given url is nil" do + stub(url).subpath_to { nil } + refute url.contains?('url') + end + end + + describe "#relative_path_to" do + context "when the URL is relative" do + let :url do + URL.parse '/' + end + + it "raises an error with a relative url" do + assert_raises(ArgumentError) { url.relative_path_to '/' } + end + + it "raises an error with an absolute url" do + assert_raises(ArgumentError) { url.relative_path_to 'http://example.com' } + end + end + + context "when the URL is 'http://example.com'" do + let :url do + URL.parse 'http://example.com' + end + + it "raises an error with a relative url" do + assert_raises(ArgumentError) { url.relative_path_to '/' } + end + + it "returns '.' with 'http://example.com'" do + assert_equal '.', url.relative_path_to('http://example.com') + end + + it "returns '.' with 'http://example.com/'" do + assert_equal '.', url.relative_path_to('http://example.com/') + end + + it "returns 'file' with 'http://example.com/file'" do + assert_equal 'file', url.relative_path_to('http://example.com/file') + end + + it "returns 'file/' with 'http://example.com/file/'" do + assert_equal 'file/', url.relative_path_to('http://example.com/file/') + end + + it "returns nil with 'https://example.com'" do + assert_nil url.relative_path_to('https://example.com') + end + + it "returns nil with 'http://not.example.com/file'" do + assert_nil url.relative_path_to('http://not.example.com/file') + end + end + + context "when the URL is 'http://example.com/'" do + let :url do + URL.parse 'http://example.com/' + end + + it "returns '.' with 'http://example.com'" do + assert_equal '.', url.relative_path_to('http://example.com') + end + end + + context "when the URL is 'http://example.com/path/to'" do + let :url do + URL.parse 'http://example.com/path/to' + end + + it "returns '../' with 'http://example.com'" do + assert_equal '../', url.relative_path_to('http://example.com') + end + + it "returns '../' with 'http://example.com/'" do + assert_equal '../', url.relative_path_to('http://example.com/') + end + + it "returns '../path' with 'http://example.com/path'" do + assert_equal '../path', url.relative_path_to('http://example.com/path') + end + + it "returns '.' with 'http://example.com/path/'" do + assert_equal '.', url.relative_path_to('http://example.com/path/') + end + + it "returns 'to' with 'http://example.com/path/to'" do + assert_equal 'to', url.relative_path_to('http://example.com/path/to') + end + + it "returns '../PATH/to' with 'http://example.com/PATH/to'" do + assert_equal '../PATH/to', url.relative_path_to('http://example.com/PATH/to') + end + + it "returns 'to/' with 'http://example.com/path/to/'" do + assert_equal 'to/', url.relative_path_to('http://example.com/path/to/') + end + + it "returns 'to/file' with 'http://example.com/path/to/file'" do + assert_equal 'to/file', url.relative_path_to('http://example.com/path/to/file') + end + + it "returns 'to/file/' with 'http://example.com/path/to/file/'" do + assert_equal 'to/file/', url.relative_path_to('http://example.com/path/to/file/') + end + end + + context "when the URL is 'http://example.com/path/to/'" do + let :url do + URL.parse 'http://example.com/path/to/' + end + + it "returns '../../' with 'http://example.com'" do + assert_equal '../../', url.relative_path_to('http://example.com') + end + + it "returns '../../' with 'http://example.com/'" do + assert_equal '../../', url.relative_path_to('http://example.com/') + end + + it "returns '../../path' with 'http://example.com/path'" do + assert_equal '../../path', url.relative_path_to('http://example.com/path') + end + + it "returns '../' with 'http://example.com/path/'" do + assert_equal '../', url.relative_path_to('http://example.com/path/') + end + + it "returns '../to' with 'http://example.com/path/to'" do + assert_equal '../to', url.relative_path_to('http://example.com/path/to') + end + + it "returns '.' with 'http://example.com/path/to/'" do + assert_equal '.', url.relative_path_to('http://example.com/path/to/') + end + + it "returns 'file' with 'http://example.com/path/to/file'" do + assert_equal 'file', url.relative_path_to('http://example.com/path/to/file') + end + + it "returns 'file/' with 'http://example.com/path/to/file/'" do + assert_equal 'file/', url.relative_path_to('http://example.com/path/to/file/') + end + end + end + + describe "#relative_path_from" do + context "when the URL is 'http://example.com/path/file'" do + let :url do + URL.parse 'http://example.com/path/file' + end + + it "returns 'path/file' with 'http://example.com/path'" do + assert_equal 'path/file', url.relative_path_from('http://example.com/path') + end + end + end +end diff --git a/test/lib/docs/filters/core/clean_html_test.rb b/test/lib/docs/filters/core/clean_html_test.rb new file mode 100644 index 00000000..656dff76 --- /dev/null +++ b/test/lib/docs/filters/core/clean_html_test.rb @@ -0,0 +1,32 @@ +require 'test_helper' +require 'docs' + +class CleanHtmlFilterTest < MiniTest::Spec + include FilterTestHelper + self.filter_class = Docs::CleanHtmlFilter + + it "removes
    ' + 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 diff --git a/test/lib/docs/filters/core/clean_text_test.rb b/test/lib/docs/filters/core/clean_text_test.rb new file mode 100644 index 00000000..ec08b6e6 --- /dev/null +++ b/test/lib/docs/filters/core/clean_text_test.rb @@ -0,0 +1,22 @@ +require 'test_helper' +require '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 diff --git a/test/lib/docs/filters/core/container_test.rb b/test/lib/docs/filters/core/container_test.rb new file mode 100644 index 00000000..fbd9771a --- /dev/null +++ b/test/lib/docs/filters/core/container_test.rb @@ -0,0 +1,63 @@ +require 'test_helper' +require 'docs' + +class ContainerFilterTest < MiniTest::Spec + include FilterTestHelper + self.filter_class = Docs::ContainerFilter + + 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 + it "returns the document" do + assert_equal @body, filter_output.inner_html + end + end +end diff --git a/test/lib/docs/filters/core/entries_test.rb b/test/lib/docs/filters/core/entries_test.rb new file mode 100644 index 00000000..68108d71 --- /dev/null +++ b/test/lib/docs/filters/core/entries_test.rb @@ -0,0 +1,147 @@ +require 'test_helper' +require '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 } + assert_equal 1, entries.length + 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 + + 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 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 diff --git a/test/lib/docs/filters/core/inner_html_test.rb b/test/lib/docs/filters/core/inner_html_test.rb new file mode 100644 index 00000000..194cc555 --- /dev/null +++ b/test/lib/docs/filters/core/inner_html_test.rb @@ -0,0 +1,18 @@ +require 'test_helper' +require '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 diff --git a/test/lib/docs/filters/core/internal_urls_test.rb b/test/lib/docs/filters/core/internal_urls_test.rb new file mode 100644 index 00000000..d67b4ba6 --- /dev/null +++ b/test/lib/docs/filters/core/internal_urls_test.rb @@ -0,0 +1,286 @@ +require 'test_helper' +require 'docs' + +class InternalUrlsFilterTest < MiniTest::Spec + include FilterTestHelper + self.filter_class = Docs::InternalUrlsFilter + + describe ":internal_urls" do + before do + context[:base_url] = context[:root_url] = 'http://example.com/dir' + context[:url] = 'http://example.com/dir' + end + + let :internal_urls do + filter_result[:internal_urls] + end + + 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 + + context "when context[:trailing_slash] is true" do + it "adds a trailing slash" do + context[:trailing_slash] = true + @body = link_to 'http://example.com/dir/path' + assert_includes internal_urls, 'http://example.com/dir/path/' + end + end + + context "when context[:trailing_slash] is false" do + before { context[:trailing_slash] = false } + + it "removes trailing slashes" do + @body = link_to 'http://example.com/dir/path/' + assert_includes internal_urls, 'http://example.com/dir/path' + end + + it "doesn't remove the leading slash" do + context[:base_url] = context[:root_url] = 'http://example.com/' + @body = link_to 'http://example.com/' + assert_includes internal_urls, 'http://example.com/' + end + end + + context "when context[:skip_links] is a block" do + let :block do + context[:skip_links] = Proc.new {} + end + + it "passes all links to the block" do + @body = link_to 'http://example.com' + context[:skip_links] = ->(arg) { @arg = arg } + internal_urls + assert_equal @body, @arg.to_s + end + + it "doesn't include urls from links where the block returns true" do + @body = link_to 'http://example.com/dir/path' + context[:skip_links] = ->(_) { true } + assert_empty internal_urls + end + + it "includes urls from links where the block returns false" do + @body = link_to 'http://example.com/dir/path' + context[:skip_links] = ->(_) { false } + assert_equal 1, internal_urls.length + end + 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 +end diff --git a/test/lib/docs/filters/core/normalize_paths_test.rb b/test/lib/docs/filters/core/normalize_paths_test.rb new file mode 100644 index 00000000..b3fc120d --- /dev/null +++ b/test/lib/docs/filters/core/normalize_paths_test.rb @@ -0,0 +1,98 @@ +require 'test_helper' +require '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 diff --git a/test/lib/docs/filters/core/normalize_urls_test.rb b/test/lib/docs/filters/core/normalize_urls_test.rb new file mode 100644 index 00000000..9698b88a --- /dev/null +++ b/test/lib/docs/filters/core/normalize_urls_test.rb @@ -0,0 +1,142 @@ +require 'test_helper' +require '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 relative urls" do + @body = link_to '%' + assert_equal link_to('#'), 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 + + 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] 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) } + @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 +end diff --git a/test/lib/docs/filters/core/title_test.rb b/test/lib/docs/filters/core/title_test.rb new file mode 100644 index 00000000..69c83fe3 --- /dev/null +++ b/test/lib/docs/filters/core/title_test.rb @@ -0,0 +1,113 @@ +require 'test_helper' +require '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 diff --git a/test/lib/docs/storage/abstract_store_test.rb b/test/lib/docs/storage/abstract_store_test.rb new file mode 100644 index 00000000..2cb91d69 --- /dev/null +++ b/test/lib/docs/storage/abstract_store_test.rb @@ -0,0 +1,436 @@ +require 'test_helper' +require '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 "#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 diff --git a/test/lib/docs/storage/file_store_test.rb b/test/lib/docs/storage/file_store_test.rb new file mode 100644 index 00000000..f1fc9de6 --- /dev/null +++ b/test/lib/docs/storage/file_store_test.rb @@ -0,0 +1,180 @@ +require 'test_helper' +require '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 "#each" do + let :paths do + paths = [] + store.each { |path| paths << path.gsub(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 diff --git a/test/support/fake_instrumentation.rb b/test/support/fake_instrumentation.rb new file mode 100644 index 00000000..46b05012 --- /dev/null +++ b/test/support/fake_instrumentation.rb @@ -0,0 +1,14 @@ +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 diff --git a/test/support/filter_test_helper.rb b/test/support/filter_test_helper.rb new file mode 100644 index 00000000..cf9b01cc --- /dev/null +++ b/test/support/filter_test_helper.rb @@ -0,0 +1,44 @@ +module FilterTestHelper + extend ActiveSupport::Concern + + included do + class_attribute :filter_class + end + + def filter + @filter ||= filter_class.new @body || '', context, result + end + + def filter_output + @filter_output ||= begin + filter.instance_variable_set :@html, @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 +end diff --git a/test/test_helper.rb b/test/test_helper.rb new file mode 100644 index 00000000..52041c89 --- /dev/null +++ b/test/test_helper.rb @@ -0,0 +1,44 @@ +ENV['RACK_ENV'] = 'test' + +require 'bundler/setup' +Bundler.require :test + +$LOAD_PATH.unshift 'lib' + +require 'minitest/autorun' +require 'minitest/pride' +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 + +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::Unit.after_tests do + rm_tmp +end diff --git a/views/app.erb b/views/app.erb new file mode 100644 index 00000000..c4626736 --- /dev/null +++ b/views/app.erb @@ -0,0 +1,28 @@ +
    +
    + + + +

    + DevDocs +

    + +
    +
    +
    +
    +
    +
    +
    +
    +<% if App.production? %><% end %> diff --git a/views/index.erb b/views/index.erb new file mode 100644 index 00000000..00524986 --- /dev/null +++ b/views/index.erb @@ -0,0 +1,29 @@ + + prefix="og: http://ogp.me/ns#"> + + + + + + + + + + + + DevDocs — API Documentation Browser + + + + + + + + <%= stylesheet_tag 'application' %> + <%= javascript_tag 'application', asset_host: false %> + <%= javascript_tag 'docs' %><% unless App.production? %> + <%= javascript_tag 'debug' %><% end %> + + +<%= erb :app %> + diff --git a/views/manifest.erb b/views/manifest.erb new file mode 100644 index 00000000..8d9dbe6d --- /dev/null +++ b/views/manifest.erb @@ -0,0 +1,19 @@ +CACHE MANIFEST + +CACHE: +/ +<%= javascript_path 'application', asset_host: false %> +<%= stylesheet_path 'application' %> +<%= image_path 'icons.png' %> +<%= image_path 'icons@2x.png' %> +<%= image_path 'maxcdn.png' %> +<%= image_path 'maxcdn@2x.png' %> +<%= image_path 'maxcdn-bw.png' %> +<%= image_path 'maxcdn-bw@2x.png' %> +<%= asset_path 'docs.js' %> +<%= doc_index_urls.join "\n" %> + +NETWORK: +* + +FALLBACK: diff --git a/views/other.erb b/views/other.erb new file mode 100644 index 00000000..891e9d0d --- /dev/null +++ b/views/other.erb @@ -0,0 +1,17 @@ + + + + + + + DevDocs + + + <%= stylesheet_tag 'application' %> + <%= javascript_tag 'application', asset_host: false %><% unless App.production? %> + <%= javascript_tag 'debug' %><% end %> + + + +<%= erb :app %> + diff --git a/views/unsupported.erb b/views/unsupported.erb new file mode 100644 index 00000000..9253d19e --- /dev/null +++ b/views/unsupported.erb @@ -0,0 +1,31 @@ + + + + + DevDocs — API Documentation Browser + <%= stylesheet_tag 'application' %> + + +
    +

    Your browser is unsupported, sorry.

    +

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

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

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

    +

    + — Thibaut @DevDocs +

    +
    + +