diff --git a/.eslintignore b/.eslintignore index 9599d9a86..c6e8e2155 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1 +1,2 @@ -theme/**/* +docs/**/* +test/node_modules/**/* diff --git a/.gitbook b/.gitbook new file mode 100644 index 000000000..ffcee8577 --- /dev/null +++ b/.gitbook @@ -0,0 +1 @@ +./docs \ No newline at end of file diff --git a/.gitignore b/.gitignore index 9550e4fec..27dec83d9 100644 --- a/.gitignore +++ b/.gitignore @@ -22,7 +22,14 @@ build/Release # Dependency directory # Deployed apps should consider commenting this line out: # see https://npmjs.org/doc/faq.html#Should-I-check-my-node_modules-folder-into-git -node_modules +/node_modules # vim swapfile *.swp + +# Output of documentation +docs/_book + +book.pdf +book.epub +book.mobi diff --git a/.travis.yml b/.travis.yml index 6326f6795..8de84e728 100644 --- a/.travis.yml +++ b/.travis.yml @@ -6,3 +6,5 @@ node_js: - "0.12" before_install: - npm install svgexport -g +after_success: + - npm run lint diff --git a/CHANGES.md b/CHANGES.md index e1dbf9f94..af432ea70 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,6 +2,20 @@ All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/). +## 3.x.x (unreleased) +- Summary can contain external links and anchors (Fix [#776](https://github.com/GitbookIO/gitbook/issues/776)) +- Summary can contain differents entitled sections +- Glossary is generated as a normal page +- Headings are no longer annotated with glossary terms +- Themes are now published as a plugin, with ability to extend it from the book source +- `links.sidebar` configuration is no longer supported, use summary sections instead +- `pdf.headerTemplate` and `pdf.footerTemplate` have been replaced by a template in theme/book: `_layout/ebook/pdf_header.html` and `_layout/ebook/pdf_footer.html` +- Markdown parser is now using CommonMark +- Root folder for the book can be specified in a `.gitbook` file +- Multi-lingual books share assets folder +- YAML front matter is parsed and page's description can be specified in it +- Fix `uk` translation + ## 2.6.7 - Fix bug with filenames including spaces - Add Turkish and Catalan translations diff --git a/bin/gitbook.js b/bin/gitbook.js index 5f474201b..5cadbc995 100755 --- a/bin/gitbook.js +++ b/bin/gitbook.js @@ -1,4 +1,5 @@ #! /usr/bin/env node +/* eslint-disable no-console */ var color = require('bash-color'); diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 000000000..6febb4324 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,22 @@ +# GitBook Format Documentation + +> This documentation is for GitBook version **{{ book.version }}** + +GitBook is a command line tool (and Node.js library) for building beautiful books using GitHub/Git and Markdown (or AsciiDoc). Here is an example: [Learn Javascript](https://www.gitbook.com/book/GitBookIO/javascript). This documentation has been generated using GitBook. + +GitBook can output your content as a website ([customizable](themes.md) and [extensibles](plugins.md)) or as an ebook (PDF, ePub or Mobi). + +[GitBook.com](https://www.gitbook.com) is the online platform to create and host books built using the GitBook format. It offers hosting, collaboration features and an [easy-to-use editor](https://www.gitbook.com/editor). + +### Help and Support + +We're always happy to help out with your books or any other questions you might have. You can ask a question on the following contact form at [gitbook.com/contact](https://www.gitbook.com/contact) or signal an issue on [GitHub](https://github.com/GitbookIO/gitbook). + +### FAQ + +There are questions that are asked quite often, [check this out before creating an issue](faq.md). + +### Contribute to this documentation + +You can contribute to improve this documentation on [GitHub](https://github.com/GitbookIO/gitbook) by signaling issues or proposing changes. + diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md new file mode 100644 index 000000000..a304801e1 --- /dev/null +++ b/docs/SUMMARY.md @@ -0,0 +1,33 @@ +# Summary + +### Getting Started + +* [About this documentation](README.md) +* [Installation and Setup](setup.md) + +### Your Content + +* [Directory structure](structure.md) +* [Pages and Summary](pages.md) +* [Glossary](lexicon.md) +* [Multi-Lingual](languages.md) +* [Configuration](config.md) +* [AsciiDoc](asciidoc.md) + +### Miscellaneous + +* [Templating](templating.md) +* [Content References](conrefs.md) +* [Variables](variables.md) + +### Customization + +* [Plugins](plugins.md) +* [Theming](themes.md) + +-- + +* [FAQ](faq.md) +* [Release notes](https://github.com/GitbookIO/gitbook/blob/master/CHANGES.md) + + diff --git a/docs/_layouts/website/page.html b/docs/_layouts/website/page.html new file mode 100644 index 000000000..a673a488e --- /dev/null +++ b/docs/_layouts/website/page.html @@ -0,0 +1,23 @@ +{% extends template.theme %} + +{% block header_nav %} + + +{% endblock %} + +{% block page %} +{{ super() }} +
+
+ {% if page.previous and page.previous.path %} + Previous: {{ page.previous.title }} + {% endif %} + {% if page.next and page.next.path %} + Next: {{ page.next.title }} + {% endif %} +
+{% endblock %} diff --git a/docs/asciidoc.md b/docs/asciidoc.md new file mode 100644 index 000000000..6fe0dec5e --- /dev/null +++ b/docs/asciidoc.md @@ -0,0 +1,60 @@ +# AsciiDoc + +Since version `2.0.0`, GitBook can also accept AsciiDoc as an input format. + +Please refer to the [AsciiDoc Syntax Quick Reference](http://asciidoctor.org/docs/asciidoc-syntax-quick-reference/) for more informations about the format. + +Just like for markdown, GitBook is using some special files to extract structures: `README.adoc`, `SUMMARY.adoc`, `LANGS.adoc` and `GLOSSARY.adoc`. + +### README.adoc + +This is the main entry of your book: the introduction. This file is **non optional**. + +### SUMMARY.adoc + +This file defines the list of chapters and subchapters. Just like [for markdown](./pages.md), the `SUMMARY.adoc`'s format is simply a list of links, the name of the link is used as the chapter's name, and the target is a path to that chapter's file. + +Subchapters are defined simply by adding a nested list to a parent chapter. + +```asciidoc += Summary + +. link:chapter-1/README.adoc[Chapter 1] +.. link:chapter-1/ARTICLE1.adoc[Article 1] +.. link:chapter-1/ARTICLE2.adoc[Article 2] +... link:chapter-1/ARTICLE-1-2-1.adoc[Article 1.2.1] +. link:chapter-2/README.adoc[Chapter 2] +. link:chapter-3/README.adoc[Chapter 3] +. link:chapter-4/README.adoc[Chapter 4] +.. Unfinished article +. Unfinished Chapter +``` + +### LANGS.adoc + +For [Multi-Languages](./languages.md) books, this file is used to define the different supported languages and translations. + +This file is following the same syntax as the `SUMMARY.adoc`: + +```asciidoc += Languages + +. link:en/[English] +. link:fr/[French] +``` + +### GLOSSARY.adoc + +This file is used to define terms. [See the glossary section](./lexicon.md). + +```asciidoc += Glossary + +== Magic +Sufficiently advanced technology, beyond the understanding of the observer producing a sense of wonder. + +== PHP +An atrocious language, invented for the sole purpose of inflicting pain and suffering amongst the programming wizards of this world. +``` + + diff --git a/docs/book.js b/docs/book.js new file mode 100644 index 000000000..988abb26c --- /dev/null +++ b/docs/book.js @@ -0,0 +1,11 @@ +var pkg = require('../package.json'); + +module.exports = { + title: 'GitBook Documentation', + + plugins: ['theme-official'], + theme: 'official', + variables: { + version: pkg.version + } +}; diff --git a/docs/config.md b/docs/config.md new file mode 100644 index 000000000..060a9d0fe --- /dev/null +++ b/docs/config.md @@ -0,0 +1,37 @@ +# Configuration + +GitBook allows you to customize your book using a flexible configuration. These options are specified in a `book.json` file. + +### Configuration Settings + +| Variable | Description | +| -------- | ----------- | +| `title` | Title of your book, default value is extracted from the README. On GitBook.com this field is pre-filled. | +| `description` | Description of your book, default value is extracted from the README. On GitBook.com this field is pre-filled. | +| `author` | Name of the author. On GitBook.com this field is pre-filled. | +| `isbn` | ISBN of the book | +| `language` | ISO code of the book's language, default value is `en` | +| `direction` | `rtl` or `ltr`, default value depends on the value of `language` | +| `gitbook` | [SemVer](http://semver.org) condition to validate which GitBook version should be used | +| `plugins` | List of plugins to load, See [the plugins section](plugins.md) for more details | +| `pluginsConfig` |Configuration for plugins, See [the plugins section](plugins.md) for more details | + +### PDF Options + +PDF Output can be customized using a set of options in the `book.json`: + +| Variable | Description | +| -------- | ----------- | +| `pdf.pageNumbers` | Add page numbers to the bottom of every page (default is `true`) | +| `pdf.fontSize` | Base font size (default is `12`) | +| `pdf.fontFamily` | Base font family (default is `Arial`) | +| `pdf.paperSize` | Paper size, options are `'a0', 'a1', 'a2', 'a3', 'a4', 'a5', 'a6', 'b0', 'b1', 'b2', 'b3', 'b4', 'b5', 'b6', 'legal', 'letter'` (default is `a4`) | +| `pdf.margin.top` | Top margin (default is `56`) | +| `pdf.margin.bottom` | Bottom margin (default is `56`) | +| `pdf.margin.right` | Right margin (default is `62`) | +| `pdf.margin.left` | Left margin (default is `62`) | + +### Plugins + +Plugins and their configurations are specified in the `book.json`. See [the plugins section](plugins.md) for more details. + diff --git a/docs/conrefs.md b/docs/conrefs.md new file mode 100644 index 000000000..7844e9c44 --- /dev/null +++ b/docs/conrefs.md @@ -0,0 +1,55 @@ +# Content References + +Content referencing (conref) is a convenient mechanism for reuse of content from other files or books. + +### Importing local files + +Importing an other file's content is really easy using the `include` tag: + +``` +{% include "./test.md" %} +``` + +### Importing file from another book + +GitBook can also resolve the include path by using git: + +``` +{% include "git+https://github.com/GitbookIO/documentation.git/README.md#0.0.1" %} +``` + +The format of git url is: + +``` +git+https://user@hostname/project/blah.git/file#commit-ish +``` + +The real git url part should finish with `.git`, the filename to import is extracted after the `.git` till the fragment of the url. + +The `commit-ish` can be any tag, sha, or branch which can be supplied as an argument to `git checkout`. The default is `master`. + +### Inheritance + +Template inheritance is a way to make it easy to reuse templates. When writing a template, you can define "blocks" that child templates can override. The inheritance chain can be as long as you like. + +`block` defines a section on the template and identifies it with a name. Base templates can specify blocks and child templates can override them with new content. + +``` +{% extends "./mypage.md" %} + +{% block pageContent %} +# This is my page content +{% endblock %} +``` + +In the file `mypage.md`, you should specify the blocks that can be extent: + +``` +{% block pageContent %} +This is the default content +{% endblock %} + +# License + +{% import "./LICENSE" %} +``` diff --git a/docs/faq.md b/docs/faq.md new file mode 100644 index 000000000..42de8fead --- /dev/null +++ b/docs/faq.md @@ -0,0 +1,33 @@ +# GitBook FAQ + +#### How can I host/publish my book? + +Books can easily be published and hosted on [GitBook.com](https://www.gitbook.com). But GitBook output can be hosted on any static file hosting solution. + +#### What can I use to edit my content? + +Any text editor should work! But we advise using the [GitBook Editor](https://www.gitbook.com/editor). [GitBook.com](https://www.gitbook.com) also provides a web version of this editor. + +--- + +#### Should I use an `.html` or `.md` extensions in my links? + +You should always use `.md` extensions for your relative links, GitBook will automatically replace these links by the right value when the pointing file is referenced in the Table of Contents. + +#### Can I create a GitBook in a sub-directory of my repository? + +Yes, GitBooks can be created in [sub-directories](structure.md#subdirectory). GitBook.com and the CLI also looks by default in a serie of [folders](structure.md). + +--- + +#### Does GitBook support Math equations? + +GitBook supports math equations and TeX thanks to plugins. There are currently 2 official plugins to display math: [mathjax](https://plugins.gitbook.com/plugin/mathjax) and [katex](https://plugins.gitbook.com/plugin/katex). + +#### Can I customize/theme the output? + +Yes, both the website and ebook outputs can be customized using [themes](themes.md). + +#### Can I add interactive content (videos, etc)? + +GitBook is very [extensible](plugins.md). You can use [existing plugins](https://plugins.gitbook.com) or create your own! diff --git a/docs/languages.md b/docs/languages.md new file mode 100644 index 000000000..9a866bec5 --- /dev/null +++ b/docs/languages.md @@ -0,0 +1,15 @@ +# Multi-Languages + +GitBook supports building books written in multiple languages. Each language should be a sub-directory following the normal GitBook format, and a file named `LANGS.md` should be present at the root of the repository with the following format: + +```markdown +* [English](en/) +* [French](fr/) +* [Español](es/) +``` + +### Configuration for each language + +When a language book (ex: `en`) has a `book.json`, its configuration will extend the main configuration. + +The only exception is plugins, plugins are specify globally relative to the book, and language specific plugins can not be specified. diff --git a/docs/lexicon.md b/docs/lexicon.md new file mode 100644 index 000000000..5d356b440 --- /dev/null +++ b/docs/lexicon.md @@ -0,0 +1,13 @@ +# Glossary + +Allows you to specify terms and their respective definitions to be displayed as annotations. Based on those terms, gitbook will automatically build an index and highlight those terms in pages. + +The `GLOSSARY.md` format is very simple : + +```markdown +# Term +Definition for this term + +# Another term +With it's definition, this can contain bold text and all other kinds of inline markup ... +``` \ No newline at end of file diff --git a/docs/pages.md b/docs/pages.md new file mode 100644 index 000000000..8ffa0546a --- /dev/null +++ b/docs/pages.md @@ -0,0 +1,50 @@ +# Pages and Summary + +GitBook uses a `SUMMARY.md` file to define the structure of chapters and subchapters of the book. The `SUMMARY.md` file is used to generate the book's table of contents. + +### Summary + +The `SUMMARY.md`'s format is simply a list of links, the title of the link is used as the chapter's title, and the target is a path to that chapter's file. + +Subchapters are defined simply by adding a nested list to a parent chapter. + +##### Simple example + +```markdown +# Summary + +* [Part I](part1/README.md) + * [Writing is nice](part1/writing.md) + * [GitBook is nice](part1/gitbook.md) +* [Part II](part2/README.md) + * [We love feedback](part2/feedback_please.md) + * [Better tools for authors](part2/better_tools.md) +``` + +##### Example with subchapters split into parts + +```markdown +# Summary + +### Part 1 + +* [Writing is nice](part1/writing.md) +* [GitBook is nice](part1/gitbook.md) + +### Part 2 + +* [We love feedback](part2/feedback_please.md) +* [Better tools for authors](part2/better_tools.md) +``` + +### Front Matter + +Pages can contain an optional front matter. It can be used to define the page's description. The front matter must be the first thing in the file and must take the form of valid YAML set between triple-dashed lines. Here is a basic example: + +```yaml +--- +description: This is a short description of my page +--- + +# The content of my page +``` diff --git a/docs/plugins.md b/docs/plugins.md new file mode 100644 index 000000000..ff56b7bc5 --- /dev/null +++ b/docs/plugins.md @@ -0,0 +1,26 @@ +# Plugins + +Plugins are the best way to extend GitBook functionalities (ebook and website). There exist plugins to do a lot of things: bring math formulas display support, track visits using Google Analytic, etc. + +### How to find plugins? + +Plugins can be easily searched on [plugins.gitbook.com](https://plugins.gitbook.com). + + +### How to install a plugin? + +Once you find a plugin that you want to install, you need to add it to your `book.json`: + +``` +{ + "plugins": ["myPlugin", "anotherPlugin"] +} +``` + +You can also specify a specific version using: `"myPlugin@0.3.1"`. By default GitBook will resolve the latest version of the plugin compatbile with the current GitBook version. + +Plugins are automatically installed on [GitBook.com](https://www.gitbook.com). Locally, run `gitbook install` to install and prepare all plugins for your books. + +### Configuring plugins + +PLugins specific configurations are stored in `pluginsConfig`. You have to refer to the documentation of the plugin itself for details about the available options. diff --git a/docs/setup.md b/docs/setup.md new file mode 100644 index 000000000..3ce416ea4 --- /dev/null +++ b/docs/setup.md @@ -0,0 +1,45 @@ +# Setup and Installation of GitBook + +Getting GitBook installed and ready-to-go should only take a few minutes. + +### GitBook.com + +[GitBook.com](https://www.gitbook.com) is an easy to use solution to write, publish and host books. It's best and easier solution for publishing your content and collaborate on it. + +It integrates well with the [GitBook Editor](https://www.gitbook.com/editor). + +### Local Installation + +##### Requirements + +Installing GitBook is easy and straight-forward, but there are a few requirements you’ll need to make sure your system has before you start. + +* NodeJS (v4.0.0 and above are adviced) +* Windows, Linux, Unix, or Mac OS X + +##### Install with NPM + +The best way to install GitBook is via **NPM**. At the terminal prompt, simply run the following command to install GitBook: + +``` +$ npm install gitbook-cli -g +``` + +`gitbook-cli` is an utility to install and use multiple versions of GitBook on the same system. It will automatically install the required version to build a book. + +##### Using pre-releases + +`gitbook-cli` makes it easy to install and test other versions of GitBook with your book: + +``` +$ gitbook install beta +``` + +##### Debugging + +You can use the options `--log=debug` and `--debug` to get better error messages (with stack trace). For example: + +``` +$ gitbook build ./ --log=debug --debug +``` + diff --git a/docs/structure.md b/docs/structure.md new file mode 100644 index 000000000..31b00f64a --- /dev/null +++ b/docs/structure.md @@ -0,0 +1,62 @@ +# Directory structure + +GitBook uses a very simple and obvious directory sttructure: + +``` +. +├── book.json +├── README.md +├── SUMMARY.md +├── chapter-1/ +| ├── README.md +| └── something.md +└── chapter-2/ + ├── README.md + └── something.md +``` + +An overview of what each of these does: + +| File | Description | +| -------- | ----------- | +| `book.json` | Stores [configuration](config.md) data (__optional__) | +| `README.md` | Preface / Introduction for your book (**required**) | +| `SUMMARY.md` | Table of Contents | + + +### Static files and Images + +A static file is a file that is not listed in the `SUMMARY.md`. All static files, not [ignored](#ignore), are copied to the output. + +### Ignoring files & folders {#ignore} + +GitBook will read the `.gitignore`, `.bookignore` and `.ignore` files to get a list of files and folders to skip. +The format inside those files, follows the same convention as `.gitignore`: + +```markdown +# This is a comment + +# Ignore the file test.md +test.md + +# Ignore everything in the directory "bin" +bin/* +``` + +### Project documentation / Sub-directory {#subdirectory} + +For project documentaiton, it sometimes better to use a diretcory (like `docs/`) to store the prject's documentation. You can use a `.gitbook` file to indicate to GitBook in which folder the book is stored: + +``` +. +├── .gitbook +└── docs/ + ├── README.md + └── SUMMARY.md +``` + +With `.gitbook` containing: + +``` +./docs/ +``` diff --git a/docs/templating.md b/docs/templating.md new file mode 100644 index 000000000..38df38e4a --- /dev/null +++ b/docs/templating.md @@ -0,0 +1,90 @@ +# Templating + +GitBook uses the Nunjucks templating language to process pages and theme's templates. + +The Nunjucks syntax is very similar to **Jinja2** or **Liquid**. + +### Variables + +A variable looks up a value from the template context. If you wanted to simply display a variable, you would do: + +```twig +{{ username }} +``` + +This looks up username from the context and displays it. Variable names can have dots in them which lookup properties, just like javascript. You can also use the square bracket syntax. + +```twig +{{ foo.bar }} +{{ foo["bar"] }} +``` + +If a value is undefined, nothing is displayed. The following all output nothing if foo is undefined: `{{ foo }}`, `{{ foo.bar }}`, `{{ foo.bar.baz }}`. + +GitBook provides a set of [context variables](variables.md). + +### Filters + +Filters are essentially functions that can be applied to variables. They are called with a pipe operator (`|`) and can take arguments. + +```twig +{{ foo | title }} +{{ foo | join(",") }} +{{ foo | replace("foo", "bar") | capitalize }} +``` + +The third example shows how you can chain filters. It would display "Bar", by first replacing "foo" with "bar" and then capitalizing it. + +### Tags + +##### if + +`if` tests a condition and lets you selectively display content. It behaves exactly as javascript's if behaves. + +```twig +{% if variable %} + It is true +{% endif %} +``` + +If variable is defined and evaluates to true, "It is true" will be displayed. Otherwise, nothing will be. + +You can specify alternate conditions with elif and else: + +```twig +{% if hungry %} + I am hungry +{% elif tired %} + I am tired +{% else %} + I am good! +{% endif %} +``` + +##### for + +`for` iterates over arrays and dictionaries. + +```twig +# Chapters about GitBook + +{% for article in glossary.terms['gitbook'].articles %} +* [{{ article.title }}]({{ article.path }}) +{% endfor %} +``` + +##### set + +`set` lets you create/modify a variable. + +```twig +{% set softwareVersion = "1.0.0" %} + +Current version is {{ softwareVersion }}. +[Download it](website.com/download/{{ softwareVersion }}) +``` + +##### include and block + +Inclusion and inheritance is detailled in the [ConRefs](conrefs.md) section. + diff --git a/docs/themes.md b/docs/themes.md new file mode 100644 index 000000000..48c19dbde --- /dev/null +++ b/docs/themes.md @@ -0,0 +1,26 @@ +# Theming + +Since version 3.0.0, GitBook can be easily themed. Books are using by default the [theme-default](https://github.com/GitbookIO/theme-default). + +The theme to use is specified in the [book's configuration](config.md) using key `theme`. + +> **Caution**: Custom theming can block some plugins from working correctly. + +### Structure of a theme + +A theme is a folder containing templates and assets. All the templates are optionnal, since theme are always extending the default theme. + +| Folder | Description | +| -------- | ----------- | +| `_layouts` | Main folder containing all the templates | +| `_layouts/website/page.html` | Template for a normal page | +| `_layouts/ebook/page.html` | Template for a normal page during ebook generation (PDF< ePub, Mobi) | + + +### Extend/Customize theme in a book + +Authors can extend the templates of a theme directly from the book source (without creating an external theme). Templates will be resolved in the `_layouts` folder of the book first, then in + +### Publish a theme + +Themes are published as plugins ([see related docs](plugins.md)) with a `theme-` prefix. For example the theme `awesome` will be loaded from `theme-awesome` plugin, and then from `gitbook-plugin-theme-awesome` NPM package. diff --git a/docs/variables.md b/docs/variables.md new file mode 100644 index 000000000..3dbd6f736 --- /dev/null +++ b/docs/variables.md @@ -0,0 +1,64 @@ +# Variables + +The following is a reference of the available data during book's parsing and theme generation. + +### Global Variables + +| Variable | Description | +| -------- | ----------- | +| `book` | Bookwide information + configuration settings from `book.json`. See below for details. | +| `gitbook` | GitBook specific information | +| `page` | Current page specific information | +| `file` | File associated with the current page specific information | +| `summary` | Information about the table of contents | +| `languages` | List of languages for multi-lingual books | +| `config` | Dump of the `book.json` | + +### Book Variables + +| Variable | Description | +| -------- | ----------- | +| `book.[CONFIGURATION_DATA]` | All the `variables` set via the `book.json` are available through the book variable. | +| `book.language` | Current language for a multilingual book | + +### GitBook Variables + +| Variable | Description | +| -------- | ----------- | +| `gitbook.time` | The current time (when you run the `gitbook` command). | +| `gitbook.version` | Version of GitBook used to generate the book | + +### File Variables + +| Variable | Description | +| -------- | ----------- | +| `file.path` | The path to the raw page | +| `file.mtime` | Modified Time, Time when file data last modified | +| `file.type` | The name of the parser used to compile this file (ex: `markdown`, `asciidoc`, etc) | + +#### Page Variables + +| Variable | Description | +| -------- | ----------- | +| `page.title` | Title of the page | +| `page.previous` | Previous page in the Table of Contents (can be `null`) | +| `page.next` | Next page in the Table of Contents (can be `null`) | +| `page.dir` | Text direction, based on configuration or detected from content (`rtl` or `ltr`) | + +#### Table of Contents Variables + +| Variable | Description | +| -------- | ----------- | +| `summary.parts` | List of sections in the Table of Contents | + +Thw whole table of contents (`SUMMARY.md`) can be accessed: + +`summary.parts[0].articles[0].title` will return the title of the first article. + +#### Multi-lingual book Variable + +| Variable | Description | +| -------- | ----------- | +| `languages.list` | List of languages for this book | + +Languages are defined by `{ id: 'en', title: 'English' }`. diff --git a/gulpfile.js b/gulpfile.js deleted file mode 100644 index 570b6a038..000000000 --- a/gulpfile.js +++ /dev/null @@ -1,47 +0,0 @@ -var _ = require('lodash'); -var gulp = require('gulp'); -var gutil = require('gulp-util'); -var less = require('gulp-less'); -var rename = require('gulp-rename'); -var minifyCSS = require('gulp-minify-css'); -var browserify = require('browserify'); -var mergeStream = require('merge-stream'); -var source = require('vinyl-source-stream'); - -gulp.task('css', function() { - var merged = mergeStream(); - - _.each({ - 'ebook.less': 'ebook/ebook.css', - 'pdf.less': 'ebook/pdf.css', - 'mobi.less': 'ebook/mobi.css', - 'epub.less': 'ebook/epub.css', - 'website.less': 'website/style.css' - }, function(out, input) { - gutil.log('compiling', input, 'into', out); - merged.add(gulp.src('theme/stylesheets/'+input) - .pipe(less()) - .pipe(minifyCSS()) - .pipe(rename(out)) - .pipe(gulp.dest('theme/assets/'))); - }); - - return merged; -}); - -gulp.task('js', function() { - return browserify('./theme/javascript/index.js') - .bundle() - .pipe(source('app.js')) - .pipe(gulp.dest('./theme/assets/website')); -}); - -gulp.task('assets', function() { - return gulp.src('./node_modules/font-awesome/fonts/*') - .pipe(gulp.dest('theme/assets/website/fonts/fontawesome/')); -}); - -gulp.task('default', ['css', 'js', 'assets'], function() { - -}); - diff --git a/lib/backbone/file.js b/lib/backbone/file.js new file mode 100644 index 000000000..209e261ca --- /dev/null +++ b/lib/backbone/file.js @@ -0,0 +1,69 @@ +var _ = require('lodash'); + +function BackboneFile(book) { + if (!(this instanceof BackboneFile)) return new BackboneFile(book); + + this.book = book; + this.log = this.book.log; + + // Filename in the book + this.path = ''; + this.parser; + + _.bindAll(this); +} + +// Type of the backbone file +BackboneFile.prototype.type = ''; + +// Parse a backbone file +BackboneFile.prototype.parse = function() { + // To be implemented by each child +}; + +// Handle case where file doesn't exists +BackboneFile.prototype.parseNotFound = function() { + +}; + +// Return true if backbone file exists +BackboneFile.prototype.exists = function() { + return Boolean(this.path); +}; + +// Locate a backbone file, could be .md, .asciidoc, etc +BackboneFile.prototype.locate = function() { + var that = this; + var filename = this.book.config.getStructure(this.type, true); + this.log.debug.ln('locating', this.type, ':', filename); + + return this.book.findParsableFile(filename) + .then(function(result) { + if (!result) return; + + that.path = result.path; + that.parser = result.parser; + }); +}; + +// Read and parse the file +BackboneFile.prototype.load = function() { + var that = this; + this.log.debug.ln('loading', this.type, ':', that.path); + + return this.locate() + .then(function() { + if (!that.path) return that.parseNotFound(); + + that.log.debug.ln(that.type, 'located at', that.path); + + return that.book.readFile(that.path) + + // Parse it + .then(function(content) { + return that.parse(content); + }); + }); +}; + +module.exports = BackboneFile; diff --git a/lib/backbone/glossary.js b/lib/backbone/glossary.js new file mode 100644 index 000000000..cc0fdce0b --- /dev/null +++ b/lib/backbone/glossary.js @@ -0,0 +1,99 @@ +var _ = require('lodash'); +var util = require('util'); +var BackboneFile = require('./file'); + +// Normalize a glossary entry name into a unique id +function nameToId(name) { + return name.toLowerCase() + .replace(/[\/\\\?\%\*\:\;\|\"\'\\<\\>\#\$\(\)\!\.\@]/g, '') + .replace(/ /g, '_') + .trim(); +} + + +/* +A glossary entry is represented by a name and a short description +An unique id for the entry is generated using its name +*/ +function GlossaryEntry(name, description) { + if (!(this instanceof GlossaryEntry)) return new GlossaryEntry(name, description); + + this.name = name; + this.description = description; + + Object.defineProperty(this, 'id', { + get: _.bind(this.getId, this) + }); +} + +// Normalizes a glossary entry's name to create an ID +GlossaryEntry.prototype.getId = function() { + return nameToId(this.name); +}; + + +/* +A glossary is a list of entries stored in a GLOSSARY.md file +*/ +function Glossary() { + BackboneFile.apply(this, arguments); + + this.entries = []; +} +util.inherits(Glossary, BackboneFile); + +Glossary.prototype.type = 'glossary'; + +// Get templating context +Glossary.prototype.getContext = function() { + if (!this.path) return {}; + + return { + glossary: { + path: this.path + } + }; +}; + +// Parse the readme content +Glossary.prototype.parse = function(content) { + var that = this; + + return this.parser.glossary(content) + .then(function(entries) { + that.entries = _.map(entries, function(entry) { + return new GlossaryEntry(entry.name, entry.description); + }); + }); +}; + +// Return an entry by its id +Glossary.prototype.get = function(id) { + return _.find(this.entries, { + id: id + }); +}; + +// Find an entry by its name +Glossary.prototype.find = function(name) { + return this.get(nameToId(name)); +}; + +// Return false if glossary has entries (and exists) +Glossary.prototype.isEmpty = function(id) { + return _.size(this.entries) === 0; +}; + +// Convert the glossary to a list of annotations +Glossary.prototype.annotations = function() { + return _.map(this.entries, function(entry) { + return { + id: entry.id, + name: entry.name, + description: entry.description, + href: '/' + this.path + '#' + entry.id + }; + }, this); +}; + +module.exports = Glossary; diff --git a/lib/backbone/index.js b/lib/backbone/index.js new file mode 100644 index 000000000..4c3c3f342 --- /dev/null +++ b/lib/backbone/index.js @@ -0,0 +1,8 @@ + +module.exports = { + Readme: require('./readme'), + Summary: require('./summary'), + Glossary: require('./glossary'), + Langs: require('./langs') +}; + diff --git a/lib/backbone/langs.js b/lib/backbone/langs.js new file mode 100644 index 000000000..e339fa984 --- /dev/null +++ b/lib/backbone/langs.js @@ -0,0 +1,81 @@ +var _ = require('lodash'); +var path = require('path'); +var util = require('util'); +var BackboneFile = require('./file'); + +function Language(title, folder) { + var that = this; + + this.title = title; + this.folder = folder; + + Object.defineProperty(this, 'id', { + get: function() { + return path.basename(that.folder); + } + }); +} + +/* +A Langs is a list of languages stored in a LANGS.md file +*/ +function Langs() { + BackboneFile.apply(this, arguments); + + this.languages = []; +} +util.inherits(Langs, BackboneFile); + +Langs.prototype.type = 'langs'; + +// Parse the readme content +Langs.prototype.parse = function(content) { + var that = this; + + return this.parser.langs(content) + .then(function(langs) { + that.languages = _.map(langs, function(entry) { + return new Language(entry.title, entry.path); + }); + }); +}; + +// Return the list of languages +Langs.prototype.list = function() { + return this.languages; +}; + +// Return default/main language for the book +Langs.prototype.getDefault = function() { + return _.first(this.languages); +}; + +// Return true if a language is the default one +// "lang" cam be a string (id) or a Language entry +Langs.prototype.isDefault = function(lang) { + lang = lang.id || lang; + return (this.cound() > 0 && this.getDefault().id == lang); +}; + +// Return the count of languages +Langs.prototype.count = function() { + return _.size(this.languages); +}; + +// Return templating context for the languages list +Langs.prototype.getContext = function() { + if (this.count() == 0) return {}; + + return { + languages: { + list: _.map(this.languages, function(lang) { + return { + id: lang.id, + title: lang.title + }; + }) + } + }; +}; + +module.exports = Langs; diff --git a/lib/backbone/readme.js b/lib/backbone/readme.js new file mode 100644 index 000000000..a4cd9d8a9 --- /dev/null +++ b/lib/backbone/readme.js @@ -0,0 +1,26 @@ +var util = require('util'); +var BackboneFile = require('./file'); + +function Readme() { + BackboneFile.apply(this, arguments); + + this.title; + this.description; +} +util.inherits(Readme, BackboneFile); + +Readme.prototype.type = 'readme'; + +// Parse the readme content +Readme.prototype.parse = function(content) { + var that = this; + + return this.parser.readme(content) + .then(function(out) { + that.title = out.title; + that.description = out.description; + }); +}; + + +module.exports = Readme; diff --git a/lib/backbone/summary.js b/lib/backbone/summary.js new file mode 100644 index 000000000..4ae345370 --- /dev/null +++ b/lib/backbone/summary.js @@ -0,0 +1,339 @@ +var _ = require('lodash'); +var util = require('util'); +var url = require('url'); + +var location = require('../utils/location'); +var error = require('../utils/error'); +var BackboneFile = require('./file'); + + +/* + An article represent an entry in the Summary. + It's defined by a title, a reference, and children articles, + the reference (ref) can be a filename + anchor or an external file (optional) +*/ +function TOCArticle(def, parent) { + // Title + this.title = def.title; + + // Parent TOCPart or TOCArticle + this.parent = parent; + + // As string indicating the overall position + // ex: '1.0.0' + this.level; + this._next; + this._prev; + + // When README has been automatically added + this.isAutoIntro = def.isAutoIntro; + this.isIntroduction = def.isIntroduction; + + this.validate(); + + // Path can be a relative path or an url, or nothing + this.ref = def.path; + if (this.ref) { + var parts = url.parse(this.ref); + + if (!this.isExternal()) { + this.path = parts.pathname; + this.anchor = parts.hash; + } + } + + this.articles = _.map(def.articles || [], function(article) { + if (article instanceof TOCArticle) return article; + return new TOCArticle(article, this); + }, this); +} + +// Validate the article +TOCArticle.prototype.validate = function() { + if (!this.title) { + throw error.ParsingError(new Error('SUMMARY entries should have an non-empty title')); + } +}; + +// Iterate over all articles in this articles +TOCArticle.prototype.walk = function(iter, base) { + base = base || this.level; + + _.each(this.articles, function(article, i) { + var level = levelId(base, i); + + if (iter(article, level) === false) { + return false; + } + article.walk(iter, level); + }); +}; + +// Return templating context for an article +TOCArticle.prototype.getContext = function() { + return { + level: this.level, + title: this.title, + depth: this.depth(), + path: this.isExternal()? undefined : this.path, + anchor: this.isExternal()? undefined : this.anchor, + url: this.isExternal()? this.ref : undefined + }; +}; + +// Return true if is pointing to a file +TOCArticle.prototype.hasLocation = function() { + return Boolean(this.path); +}; + +// Return true if is pointing to an external location +TOCArticle.prototype.isExternal = function() { + return location.isExternal(this.ref); +}; + +// Return true if this article is the introduction +TOCArticle.prototype.isIntro = function() { + return Boolean(this.isIntroduction); +}; + +// Return true if has children +TOCArticle.prototype.hasChildren = function() { + return this.articles.length > 0; +}; + +// Return true if has an article as parent +TOCArticle.prototype.hasParent = function() { + return !(this.parent instanceof TOCPart); +}; + +// Return depth of this article +TOCArticle.prototype.depth = function() { + return this.level.split('.').length; +}; + +// Return next article in the TOC +TOCArticle.prototype.next = function() { + return this._next; +}; + +// Return previous article in the TOC +TOCArticle.prototype.prev = function() { + return this._prev; +}; + +// Map over all articles +TOCArticle.prototype.map = function(iter) { + return _.map(this.articles, iter); +}; + + +/* + A part of a ToC is a composed of a tree of articles and an optiona title +*/ +function TOCPart(part, parent) { + if (!(this instanceof TOCPart)) return new TOCPart(part, parent); + + TOCArticle.apply(this, arguments); +} +util.inherits(TOCPart, TOCArticle); + +// Validate the part +TOCPart.prototype.validate = function() { }; + +// Return a sibling (next or prev) of this part +TOCPart.prototype.sibling = function(direction) { + var parts = this.parent.parts; + var pos = _.findIndex(parts, this); + + if (parts[pos + direction]) { + return parts[pos + direction]; + } + + return null; +}; + +// Iterate over all entries of the part +TOCPart.prototype.walk = function(iter, base) { + var articles = this.articles; + + if (articles.length == 0) return; + + // Has introduction? + if (articles[0].isIntro()) { + if (iter(articles[0], '0') === false) { + return; + } + + articles = articles.slice(1); + } + + + _.each(articles, function(article, i) { + var level = levelId(base, i); + + if (iter(article, level) === false) { + return false; + } + + article.walk(iter, level); + }); +}; + +// Return templating context for a part +TOCPart.prototype.getContext = function(onArticle) { + onArticle = onArticle || function(article) { + return article.getContext(); + }; + + return { + title: this.title, + articles: this.map(onArticle) + }; +}; + +/* +A summary is composed of a list of parts, each composed wit a tree of articles. +*/ +function Summary() { + BackboneFile.apply(this, arguments); + + this.parts = []; + this._length = 0; +} +util.inherits(Summary, BackboneFile); + +Summary.prototype.type = 'summary'; + +// Prepare summary when non existant +Summary.prototype.parseNotFound = function() { + this.update([]); +}; + +// Parse the summary content +Summary.prototype.parse = function(content) { + var that = this; + + return this.parser.summary(content) + + .then(function(summary) { + that.update(summary.parts); + }); +}; + +// Return templating context for the summary +Summary.prototype.getContext = function() { + function onArticle(article) { + var result = article.getContext(); + if (article.hasChildren()) { + result.articles = article.map(onArticle); + } + + return result; + } + + return { + summary: { + parts: _.map(this.parts, function(part) { + return part.getContext(onArticle); + }) + } + }; +}; + +// Iterate over all entries of the summary +// iter is called with an TOCArticle +Summary.prototype.walk = function(iter) { + var hasMultipleParts = this.parts.length > 1; + + _.each(this.parts, function(part, i) { + part.walk(iter, hasMultipleParts? levelId('', i) : null); + }); +}; + +// Find a specific article using a filter +Summary.prototype.find = function(filter) { + var result; + + this.walk(function(article) { + if (filter(article)) { + result = article; + return false; + } + }); + + return result; +}; + +// Return the first TOCArticle for a specific page (or path) +Summary.prototype.getArticle = function(page) { + if (!_.isString(page)) page = page.path; + + return this.find(function(article) { + return article.path == page; + }); +}; + +// Return the first TOCArticle for a specific level +Summary.prototype.getArticleByLevel = function(lvl) { + return this.find(function(article) { + return article.level == lvl; + }); +}; + +// Return the count of articles in the summary +Summary.prototype.count = function() { + return this._length; +}; + +// Prepare the summary +Summary.prototype.update = function(parts) { + var that = this; + + + that.parts = _.map(parts, function(part) { + return new TOCPart(part, that); + }); + + // Create first part if none + if (that.parts.length == 0) { + that.parts.push(new TOCPart({}, that)); + } + + // Add README as first entry + var firstArticle = that.parts[0].articles[0]; + if (!firstArticle || firstArticle.path != that.book.readme.path) { + that.parts[0].articles.unshift(new TOCArticle({ + title: 'Introduction', + path: that.book.readme.path, + isAutoIntro: true + }, that.parts[0])); + } + that.parts[0].articles[0].isIntroduction = true; + + + // Update the count and indexing of "level" + var prev = undefined; + + that._length = 0; + that.walk(function(article, level) { + // Index level + article.level = level; + + // Chain articles + article._prev = prev; + if (prev) prev._next = article; + + prev = article; + + that._length += 1; + }); +}; + + +// Return a level string from a base level and an index +function levelId(base, i) { + i = i + 1; + return (base? [base || '', i] : [i]).join('.'); +} + +module.exports = Summary; diff --git a/lib/book.js b/lib/book.js index 9ba27f319..400296e5e 100644 --- a/lib/book.js +++ b/lib/book.js @@ -1,25 +1,34 @@ -var Q = require('q'); var _ = require('lodash'); var path = require('path'); -var parsers = require('gitbook-parsers'); +var Ignore = require('ignore'); -var fs = require('./utils/fs'); -var parseNavigation = require('./utils/navigation'); -var parseProgress = require('./utils/progress'); -var pageUtil = require('./utils/page'); +var Config = require('./config'); +var Readme = require('./backbone/readme'); +var Glossary = require('./backbone/glossary'); +var Summary = require('./backbone/summary'); +var Langs = require('./backbone/langs'); +var Page = require('./page'); var pathUtil = require('./utils/path'); -var links = require('./utils/links'); -var i18n = require('./utils/i18n'); -var logger = require('./utils/logger'); +var error = require('./utils/error'); +var Promise = require('./utils/promise'); +var Logger = require('./utils/logger'); +var parsers = require('./parsers'); -var Configuration = require('./configuration'); -var TemplateEngine = require('./template'); -var PluginsList = require('./pluginslist'); -var generators = require('./generators'); +/* +The Book class is an interface for parsing books content. +It does not require to run on Node.js, isnce it only depends on the fs implementation +*/ + +function Book(opts) { + if (!(this instanceof Book)) return new Book(opts); + + this.opts = _.defaults(opts || {}, { + fs: null, + + // Root path for the book + root: '', -var Book = function(root, context, parent) { - this.context = _.defaults(context || {}, { // Extend book configuration config: {}, @@ -32,572 +41,234 @@ var Book = function(root, context, parent) { logLevel: 'info' }); - // Log - this.log = logger(this.context.log, this.context.logLevel); + if (!opts.fs) throw error.ParsingError(new Error('Book requires a fs instance')); - // Root folder of the book - this.root = path.resolve(root); + // Root path for the book + this.root = opts.root; - // Parent book - this.parent = parent; + // If multi-lingual, book can have a parent + this.parent = opts.parent; + if (this.parent) { + this.language = path.relative(this.parent.root, this.root); + } - // Configuration - this.config = new Configuration(this, this.context.config); - Object.defineProperty(this, 'options', { - get: function () { - return this.config.options; - } - }); + // A book is linked to an fs, to access its content + this.fs = opts.fs; - // Template - this.template = new TemplateEngine(this); + // Rules to ignore some files + this.ignore = Ignore(); + this.ignore.addPattern([ + // Skip Git stuff + '.git/', - // Summary - this.summary = {}; - this.navigation = []; + // Skip OS X meta data + '.DS_Store', - // Glossary - this.glossary = []; + // Skip stuff installed by plugins + 'node_modules', - // Langs - this.langs = []; + // Skip book outputs + '_book', + '*.pdf', + '*.epub', + '*.mobi' + ]); - // Sub-books + // Create a logger for the book + this.log = new Logger(opts.log, opts.logLevel); + + // Create an interface to access the configuration + this.config = new Config(this, opts.config); + + // Interfaces for the book structure + this.readme = new Readme(this); + this.summary = new Summary(this); + this.glossary = new Glossary(this); + + // Multilinguals book + this.langs = new Langs(this); this.books = []; - // Files in the book - this.files = []; + // List of page in the book + this.pages = {}; - // List of plugins - this.plugins = new PluginsList(this); - - // Structure files - this.summaryFile = null; - this.glossaryFile = null; - this.readmeFile = null; - this.langsFile = null; - - // Bind methods _.bindAll(this); +} + +// Return templating context for the book +Book.prototype.getContext = function() { + var variables = this.config.get('variables', {}); + + return { + book: _.extend({ + language: this.language + }, variables) + }; }; -// Return string representation -Book.prototype.toString = function() { - return '[Book '+this.root+']'; +// Parse and prepare the configuration, fail if invalid +Book.prototype.prepareConfig = function() { + return this.config.load(); }; -// Initialize and parse the book: config, summary, glossary +// Resolve a path in the book source +// Enforce that the output path is in the scope +Book.prototype.resolve = function() { + var filename = path.resolve.apply(path, [this.root].concat(_.toArray(arguments))); + if (!this.isFileInScope(filename)) { + throw error.FileOutOfScopeError({ + filename: filename, + root: this.root + }); + } + + return filename; +}; + +// Return false if a file is outside the book' scope +Book.prototype.isFileInScope = function(filename) { + filename = path.resolve(this.root, filename); + + // Is the file in the scope of the parent? + if (this.parent && this.parent.isFileInScope(filename)) return true; + + // Is file in the root folder? + return pathUtil.isInRoot(this.root, filename); +}; + +// Parse .gitignore, etc to extract rules +Book.prototype.parseIgnoreRules = function() { + var that = this; + + return Promise.serie([ + '.ignore', + '.gitignore', + '.bookignore' + ], function(filename) { + return that.readFile(filename) + .then(function(content) { + that.ignore.addPattern(content.toString().split(/\r?\n/)); + }, function() { + return Promise(); + }); + }); +}; + +// Parse the whole book Book.prototype.parse = function() { var that = this; - var multilingual = false; - return this.parseConfig() + return Promise() + .then(this.prepareConfig) + .then(this.parseIgnoreRules) + // Parse languages .then(function() { - return that.parsePlugins(); + return that.langs.load(); }) - .then(function() { - return that.parseLangs() - .then(function() { - multilingual = that.langs.length > 0; - if (multilingual) that.log.info.ln('Parsing multilingual book, with', that.langs.length, 'languages'); - - // Sub-books that inherit from the current book configuration - that.books = _.map(that.langs, function(lang) { - that.log.info.ln('Preparing language book', lang.lang); - return new Book( - path.join(that.root, lang.path), - _.merge({}, that.context, { - config: _.extend({}, that.options, { - 'output': path.join(that.options.output, lang.lang), - 'language': lang.lang - }) - }), - that - ); - }); - }); - }) - - .then(function() { - if (multilingual) return; - return that.listAllFiles(); - }) - .then(function() { - if (multilingual) return; - return that.parseReadme(); - }) - .then(function() { - if (multilingual) return; - return that.parseSummary(); - }) - .then(function() { - if (multilingual) return; - return that.parseGlossary(); - }) - - .then(function() { - // Init sub-books - return _.reduce(that.books, function(prev, book) { - return prev.then(function() { - return book.parse(); - }); - }, Q()); - }) - - .thenResolve(this); -}; - -// Generate the output -Book.prototype.generate = function(generator) { - var that = this; - that.options.generator = generator || that.options.generator; - - that.log.info.ln('start generation with', that.options.generator, 'generator'); - return Q() - - // Clean output folder - .then(function() { - that.log.info('clean', that.options.generator, 'generator'); - return fs.clean(that.options.output) - .progress(function(p) { - that.log.debug.ln('remove', p.file, '('+p.i+'/'+p.count+')'); - }) - .then(function() { - that.log.info.ok(); - }); - }) - - // Create generator - .then(function() { - var Generator = generators[generator]; - if (!Generator) throw 'Generator \''+that.options.generator+'\' doesn\'t exist'; - generator = new Generator(that); - - return generator.prepare(); - }) - - // Transform configuration - .then(function() { - return that.callHook('config', that.config.dump()) - .then(function(newConfig) { - that.config.replace(newConfig); - }); - }) - - // Generate content .then(function() { if (that.isMultilingual()) { - return that.generateMultiLingual(generator); - } else { - // Separate list of files into the different operations needed - var ops = _.groupBy(that.files, function(file) { - if (file[file.length -1] == '/') { - return 'directories'; - } else if (_.contains(parsers.extensions, path.extname(file)) && that.navigation[file]) { - return 'content'; - } else { - return 'files'; - } - }); + if (that.isLanguageBook()) { + throw error.ParsingError(new Error('A multilingual book as a language book is forbidden')); + } + that.log.info.ln('Parsing multilingual book, with', that.langs.count(), 'languages'); - return Q() - - // First, let's create folder - .then(function() { - return _.reduce(ops.directories || [], function(prev, folder) { - return prev.then(function() { - that.log.debug.ln('transferring folder', folder); - return Q(generator.transferFolder(folder)); - }); - }, Q()); - }) - - // Then, let's copy other files - .then(function() { - return Q.all(_.map(ops.files || [], function(file) { - that.log.debug.ln('transferring file', file); - return Q(generator.transferFile(file)); + // Create a new book for each language and parse it + return Promise.serie(that.langs.list(), function(lang) { + that.log.debug.ln('Preparing book for language', lang.id); + var langBook = new Book(_.extend({}, that.opts, { + parent: that, + config: that.config.dump(), + root: that.resolve(lang.id) })); - }) - // Finally let's generate content - .then(function() { - var nFiles = (ops.content || []).length; - return _.reduce(ops.content || [], function(prev, file, i) { - return prev.then(function() { - var p = ((i*100)/nFiles).toFixed(0)+'%'; - that.log.debug.ln('processing', file, p); + that.books.push(langBook); - return Q(generator.convertFile(file)) - .fail(function(err) { - // Transform error message to signal file - throw that.normError(err, { - fileName: file - }); - }); - }); - }, Q()); + return langBook.parse(); }); } - }) - // Finish generation - .then(function() { - return that.callHook('finish:before'); - }) - .then(function() { - return generator.finish(); - }) - .then(function() { - return that.callHook('finish'); - }) - .then(function() { - that.log.info.ln('generation is finished'); - }); -}; + return Promise() -// Generate the output for a multilingual book -Book.prototype.generateMultiLingual = function() { - var that = this; - - return Q() - .then(function() { - // Generate sub-books - return _.reduce(that.books, function(prev, book) { - return prev.then(function() { - return book.generate(that.options.generator); - }); - }, Q()); - }); -}; - -// Extract files from ebook generated -Book.prototype.generateFile = function(output, options) { - var book = this; - - options = _.defaults(options || {}, { - ebookFormat: path.extname(output).slice(1) - }); - output = output || path.resolve(book.root, 'book.'+options.ebookFormat); - - return fs.tmp.dir() - .then(function(tmpDir) { - book.setOutput(tmpDir); - - return book.generate(options.ebookFormat) + // Parse the readme + .then(that.readme.load) .then(function() { - var copyFile = function(lang) { - var _outputFile = output; - var _tmpDir = tmpDir; + if (!that.readme.exists()) { + throw new error.FileNotFoundError({ filename: 'README' }); + } - if (lang) { - _outputFile = _outputFile.slice(0, -path.extname(_outputFile).length)+'_'+lang+path.extname(_outputFile); - _tmpDir = path.join(_tmpDir, lang); - } + // Default configuration to infos extracted from readme + if (!that.config.get('title')) that.config.set('title', that.readme.title); + if (!that.config.get('description')) that.config.set('description', that.readme.description); + }) - book.log.debug.ln('copy ebook to', _outputFile); - return fs.copy( - path.join(_tmpDir, 'index.'+options.ebookFormat), - _outputFile - ); - }; + // Parse the summary + .then(that.summary.load) + .then(function() { + if (!that.summary.exists()) { + that.log.warn.ln('no summary file in this book'); + } - // Multi-langs book - return Q() - .then(function() { - if (book.isMultilingual()) { - return Q.all( - _.map(book.langs, function(lang) { - return copyFile(lang.lang); - }) - ) - .thenResolve(book.langs.length); - } else { - return copyFile().thenResolve(1); - } - }) - .then(function(n) { - book.log.info.ok(n+' file(s) generated'); - - return fs.remove(tmpDir); + // Index summary's articles + that.summary.walk(function(article) { + if (!article.hasLocation() || article.isExternal()) return; + that.addPage(article.path); }); + }) + + // Parse the glossary + .then(that.glossary.load) + + // Add the glossary as a page + .then(function() { + if (!that.glossary.exists()) return; + that.addPage(that.glossary.path); }); }); }; -// Parse configuration -Book.prototype.parseConfig = function() { - var that = this; +// Mark a filename as being parsable +Book.prototype.addPage = function(filename) { + if (this.hasPage(filename)) return this.getPage(filename); - that.log.info('loading book configuration....'); - return that.config.load() - .then(function() { - that.log.info.ok(); - }); + filename = pathUtil.normalize(filename); + this.pages[filename] = new Page(this, filename); + return this.pages[filename]; }; -// Parse list of plugins -Book.prototype.parsePlugins = function() { - var that = this; - - // Load plugins - return that.plugins.load(that.options.plugins) - .then(function() { - if (_.size(that.plugins.failed) > 0) return Q.reject(new Error('Error loading plugins: '+that.plugins.failed.join(',')+'. Run \'gitbook install\' to install plugins from NPM.')); - - that.log.info.ok(that.plugins.count()+' plugins loaded'); - that.log.debug.ln('normalize plugins list'); - }); -}; - -// Parse readme to extract defaults title and description -Book.prototype.parseReadme = function() { - var that = this; - var filename = that.config.getStructure('readme', true); - that.log.debug.ln('start parsing readme:', filename); - - return that.findFile(filename) - .then(function(readme) { - if (!readme) throw 'No README file'; - if (!_.contains(that.files, readme.path)) throw 'README file is ignored'; - - that.readmeFile = readme.path; - that._defaultsStructure(that.readmeFile); - - that.log.debug.ln('readme located at', that.readmeFile); - return that.template.renderFile(that.readmeFile) - .then(function(content) { - return readme.parser.readme(content) - .fail(function(err) { - throw that.normError(err, { - name: err.name || 'Readme Parse Error', - fileName: that.readmeFile - }); - }); - }); - }) - .then(function(readme) { - that.options.title = that.options.title || readme.title; - that.options.description = that.options.description || readme.description; - }); +// Return a page by its filename (or undefined) +Book.prototype.getPage = function(filename) { + filename = pathUtil.normalize(filename); + return this.pages[filename]; }; -// Parse langs to extract list of sub-books -Book.prototype.parseLangs = function() { - var that = this; - - var filename = that.config.getStructure('langs', true); - that.log.debug.ln('start parsing languages index:', filename); - - return that.findFile(filename) - .then(function(langs) { - if (!langs) return []; - - that.langsFile = langs.path; - that._defaultsStructure(that.langsFile); - - that.log.debug.ln('languages index located at', that.langsFile); - return that.template.renderFile(that.langsFile) - .then(function(content) { - return langs.parser.langs(content) - .fail(function(err) { - throw that.normError(err, { - name: err.name || 'Langs Parse Error', - fileName: that.langsFile - }); - }); - }); - }) - .then(function(langs) { - that.langs = langs; - }); +// Return true, if has a specific page +Book.prototype.hasPage = function(filename) { + return Boolean(this.getPage(filename)); }; -// Parse summary to extract list of chapters -Book.prototype.parseSummary = function() { - var that = this; - - var filename = that.config.getStructure('summary', true); - that.log.debug.ln('start parsing summary:', filename); - - return that.findFile(filename) - .then(function(summary) { - if (!summary) throw 'No SUMMARY file'; - - // Remove the summary from the list of files to parse - that.summaryFile = summary.path; - that._defaultsStructure(that.summaryFile); - that.files = _.without(that.files, that.summaryFile); - - that.log.debug.ln('summary located at', that.summaryFile); - return that.template.renderFile(that.summaryFile) - .then(function(content) { - return summary.parser.summary(content, { - entryPoint: that.readmeFile, - entryPointTitle: that.i18n('SUMMARY_INTRODUCTION'), - files: that.files - }) - .fail(function(err) { - throw that.normError(err, { - name: err.name || 'Summary Parse Error', - fileName: that.summaryFile - }); - }); - }); - }) - .then(function(summary) { - that.summary = summary; - that.navigation = parseNavigation(that.summary, that.files); - }); +// Test if a file is ignored, return true if it is +Book.prototype.isFileIgnored = function(filename) { + return this.ignore.filter([filename]).length == 0; }; -// Parse glossary to extract terms -Book.prototype.parseGlossary = function() { - var that = this; - - var filename = that.config.getStructure('glossary', true); - that.log.debug.ln('start parsing glossary: ', filename); - - return that.findFile(filename) - .then(function(glossary) { - if (!glossary) return []; - - // Remove the glossary from the list of files to parse - that.glossaryFile = glossary.path; - that._defaultsStructure(that.glossaryFile); - that.files = _.without(that.files, that.glossaryFile); - - that.log.debug.ln('glossary located at', that.glossaryFile); - return that.template.renderFile(that.glossaryFile) - .then(function(content) { - return glossary.parser.glossary(content) - .fail(function(err) { - throw that.normError(err, { - name: err.name || 'Glossary Parse Error', - fileName: that.glossaryFile - }); - }); - }); - }) - .then(function(glossary) { - that.glossary = glossary; - }); +// Read a file in the book, throw error if ignored +Book.prototype.readFile = function(filename) { + if (this.isFileIgnored(filename)) return Promise.reject(new error.FileNotFoundError({ filename: filename })); + return this.fs.readAsString(this.resolve(filename)); }; -// Parse a page -Book.prototype.parsePage = function(filename, options) { - var that = this, page = {}; - options = _.defaults(options || {}, { - // Transform svg images - convertImages: false, - - // Interpolate before templating - interpolateTemplate: _.identity, - - // Interpolate after templating - interpolateContent: _.identity - }); - - var interpolate = function(fn) { - return Q(fn(page)) - .then(function(_page) { - page = _page || page; - }); - }; - - that.log.debug.ln('start parsing file', filename); - - var extension = path.extname(filename); - var filetype = parsers.get(extension); - - if (!filetype) return Q.reject(new Error('Can\'t parse file: '+filename)); - - // Type of parser used - page.type = filetype.name; - - // Path relative to book - page.path = filename; - - // Path absolute in the system - page.rawPath = path.resolve(that.root, filename); - - // Progress in the book - page.progress = parseProgress(that.navigation, filename); - - that.log.debug.ln('render template', filename); - - // Read file content - return that.readFile(page.path) - .then(function(content) { - page.content = content; - - return interpolate(options.interpolateTemplate); - }) - - // Prepare page markup - .then(function() { - return filetype.page.prepare(page.content) - .then(function(content) { - page.content = content; - }); - }) - - // Generate template - .then(function() { - return that.template.renderPage(page); - }) - - // Prepare and Parse markup - .then(function(content) { - page.content = content; - - that.log.debug.ln('use file parser', filetype.name, 'for', filename); - return filetype.page(page.content); - }) - - // Post process sections - .then(function(_page) { - return _.reduce(_page.sections, function(prev, section) { - return prev.then(function(_sections) { - return that.template.postProcess(section.content || '') - .then(function(content) { - section.content = content; - return _sections.concat([section]); - }); - }); - }, Q([])); - }) - - // Prepare html - .then(function(_sections) { - return pageUtil.normalize(_sections, { - book: that, - convertImages: options.convertImages, - input: filename, - navigation: that.navigation, - base: path.dirname(filename) || './', - output: path.dirname(filename) || './', - glossary: that.glossary - }); - }) - - // Interpolate output - .then(function(_sections) { - page.sections = _sections; - return interpolate(options.interpolateContent); - }) - - .then(function() { - return page; - }); +// Get stat infos about a file +Book.prototype.statFile = function(filename) { + if (this.isFileIgnored(filename)) return Promise.reject(new error.FileNotFoundError({ filename: filename })); + return this.fs.stat(this.resolve(filename)); }; -// Find file that can be parsed with a specific filename -Book.prototype.findFile = function(filename) { +// Find a parsable file using a filename +Book.prototype.findParsableFile = function(filename) { var that = this; var ext = path.extname(filename); @@ -614,7 +285,7 @@ Book.prototype.findFile = function(filename) { var filepath = basename+ext; - return fs.findFile(that.root, filepath) + return that.fs.findFile(that.root, filepath) .then(function(realFilepath) { if (!realFilepath) return null; @@ -624,200 +295,64 @@ Book.prototype.findFile = function(filename) { }; }); }); - }, Q(null)); + }, Promise(null)); }; -// Format a string using a specific markup language -Book.prototype.formatString = function(extension, content) { - return Q() - .then(function() { - var filetype = parsers.get(extension); - if (!filetype) throw new Error('Filetype doesn\'t exist: '+filetype); - - return filetype.page(content); - }) - - // Merge sections - .then(function(page) { - return _.reduce(page.sections, function(content, section) { - return content + section.content; - }, ''); - }); +// Return true if book is associated to a language +Book.prototype.isLanguageBook = function() { + return Boolean(this.parent); }; +Book.prototype.isSubBook = Book.prototype.isLanguageBook; -// Check if a file exists in the book -Book.prototype.fileExists = function(filename) { - return fs.exists( - this.resolve(filename) - ); -}; - -// Check if a file path is inside the book -Book.prototype.fileIsInBook = function(filename) { - return pathUtil.isInRoot(this.root, filename); -}; - -// Read a file -Book.prototype.readFile = function(filename) { - return fs.readFile( - this.resolve(filename), - { encoding: 'utf8' } - ); -}; - -// Return stat for a file -Book.prototype.statFile = function(filename) { - return fs.stat(this.resolve(filename)); -}; - -// List all files in the book -Book.prototype.listAllFiles = function() { - var that = this; - - return fs.list(this.root, { - ignoreFiles: ['.ignore', '.gitignore', '.bookignore'], - ignoreRules: [ - // Skip Git stuff - '.git/', - '.gitignore', - - // Skip OS X meta data - '.DS_Store', - - // Skip stuff installed by plugins - 'node_modules', - - // Skip book outputs - '_book', - '*.pdf', - '*.epub', - '*.mobi', - - // Skip config files - '.ignore', - '.bookignore', - 'book.json', - ] - }) - .then(function(_files) { - that.files = _files; - }); -}; - -// Return true if the book is a multilingual book +// Return true if the book is main instance of a multilingual book Book.prototype.isMultilingual = function() { - return this.books.length > 0; + return this.langs.count() > 0; }; -// Return root of the parent -Book.prototype.parentRoot = function() { - if (this.parent) return this.parent.parentRoot(); - return this.root; +// Return true if file is in the scope of this book +Book.prototype.isInBook = function(filename) { + return pathUtil.isInRoot( + this.root, + filename + ); }; -// Return true if it's a sub-book -Book.prototype.isSubBook = function() { - return !!this.parent; -}; - -// Test if the file is the entry point -Book.prototype.isEntryPoint = function(fp) { - return fp == this.readmeFile; -}; - -// Alias to book.config.get -Book.prototype.getConfig = function(key, def) { - return this.config.get(key, def); -}; - -// Resolve a path in the book source -// Enforce that the output path in the root folder -Book.prototype.resolve = function() { - return pathUtil.resolveInRoot.apply(null, [this.root].concat(_.toArray(arguments))); -}; - -// Resolve a path in the book output -// Enforce that the output path in the output folder -Book.prototype.resolveOutput = function() { - return pathUtil.resolveInRoot.apply(null, [this.options.output].concat(_.toArray(arguments))); -}; - -// Convert an abslute path into a relative path to this -Book.prototype.relative = function(p) { - return path.relative(this.root, p); -}; - -// Normalize a path to .html and convert README -> index -Book.prototype.contentPath = function(link) { - if ( - path.basename(link, path.extname(link)) == 'README' || - link == this.readmeFile - ) { - link = path.join(path.dirname(link), 'index'+path.extname(link)); - } - - link = links.changeExtension(link, '.html'); - return link; -}; - -// Normalize a link to .html and convert README -> index -Book.prototype.contentLink = function(link) { - return links.normalize(this.contentPath(link)); -}; - -// Default structure paths to an extension -Book.prototype._defaultsStructure = function(filename) { +// Return true if file is in the scope of a child book +Book.prototype.isInLanguageBook = function(filename) { var that = this; - var extension = path.extname(filename); - that.readmeFile = that.readmeFile || that.config.getStructure('readme')+extension; - that.summaryFile = that.summaryFile || that.config.getStructure('summary')+extension; - that.glossaryFile = that.glossaryFile || that.config.getStructure('glossary')+extension; - that.langsFile = that.langsFile || that.config.getStructure('langs')+extension; -}; - -// Change output path -Book.prototype.setOutput = function(p) { - var that = this; - this.options.output = path.resolve(p); - - _.each(this.books, function(book) { - book.setOutput(path.join(that.options.output, book.options.language)); + return _.some(this.langs.list(), function(lang) { + return pathUtil.isInRoot( + that.resolve(lang.id), + that.resolve(filename) + ); }); }; -// Translate a strign according to the book language -Book.prototype.i18n = function() { - var args = Array.prototype.slice.call(arguments); - return i18n.__.apply({}, [this.config.normalizeLanguage()].concat(args)); +// Locate a book in a folder +// - Read the ".gitbook" is exists +// - Try the folder itself +// - Try a "docs" folder +Book.locate = function(fs, root) { + return fs.readAsString(path.join(root, '.gitbook')) + .then(function(content) { + return path.join(root, content); + }, function() { + // .gitbook doesn't exists, fall back to the root folder + return Promise(root); + }); }; -// Normalize error -Book.prototype.normError = function(err, opts, defs) { - if (_.isString(err)) err = new Error(err); - - // Extend err - _.extend(err, opts || {}); - _.defaults(err, defs || {}); - - err.lineNumber = err.lineNumber || err.lineno; - err.columnNumber = err.columnNumber || err.colno; - - err.toString = function() { - var attributes = []; - - if (this.fileName) attributes.push('In file \''+this.fileName+'\''); - if (this.lineNumber) attributes.push('Line '+this.lineNumber); - if (this.columnNumber) attributes.push('Column '+this.columnNumber); - return (this.name || 'Error')+': '+this.message+((attributes.length > 0)? ' ('+attributes.join(', ')+')' : ''); - }; - - return err; +// Locate and setup a book +Book.setup = function(fs, root, opts) { + return Book.locate(fs, root) + .then(function(_root) { + return new Book(_.extend(opts || {}, { + root: _root, + fs: fs + })); + }); }; -// Call a hook in plugins -Book.prototype.callHook = function(name, data) { - return this.plugins.hook(name, data); -}; -module.exports= Book; +module.exports = Book; diff --git a/lib/cli/helper.js b/lib/cli/helper.js new file mode 100644 index 000000000..e4dc8daf5 --- /dev/null +++ b/lib/cli/helper.js @@ -0,0 +1,139 @@ +var _ = require('lodash'); +var path = require('path'); + +var Book = require('../book'); +var NodeFS = require('../fs/node'); +var Logger = require('../utils/logger'); +var Promise = require('../utils/promise'); +var fs = require('../utils/fs'); +var JSONOutput = require('../output/json'); +var WebsiteOutput = require('../output/website'); +var EBookOutput = require('../output/ebook'); + +var nodeFS = new NodeFS(); + +var LOG_OPTION = { + name: 'log', + description: 'Minimum log level to display', + values: _.chain(Logger.LEVELS) + .keys() + .map(function(s) { + return s.toLowerCase(); + }) + .value(), + defaults: 'info' +}; + +var FORMAT_OPTION = { + name: 'format', + description: 'Format to build to', + values: ['website', 'json', 'ebook'], + defaults: 'website' +}; + +var FORMATS = { + json: JSONOutput, + website: WebsiteOutput, + ebook: EBookOutput +}; + +// Commands which is processing a book +// the root of the book is the first argument (or current directory) +function bookCmd(fn) { + return function(args, kwargs) { + var input = path.resolve(args[0] || process.cwd()); + return Book.setup(nodeFS, input, { + logLevel: kwargs.log + }) + .then(function(book) { + return fn(book, args.slice(1), kwargs); + }); + }; +} + +// Commands which is working on a Output instance +function outputCmd(fn) { + return bookCmd(function(book, args, kwargs) { + var Out = FORMATS[kwargs.format]; + var outputFolder = undefined; + + // Set output folder + if (args[0]) { + outputFolder = path.resolve(process.cwd(), args[0]); + } + + return fn(new Out(book, { + root: outputFolder + }), args); + }); +} + +// Command to generate an ebook +function ebookCmd(format) { + return { + name: format + ' [book] [output] [file]', + description: 'generates ebook '+format, + options: [ + LOG_OPTION + ], + exec: bookCmd(function(book, args, kwargs) { + return fs.tmpDir() + .then(function(dir) { + var ext = '.'+format; + var outputFile = path.resolve(process.cwd(), args[1] || ('book' + ext)); + var output = new EBookOutput(book, { + root: dir, + format: format + }); + + return output.book.parse() + .then(function() { + return output.generate(); + }) + + // Copy the ebook files + .then(function() { + if (output.book.isMultilingual()) { + return Promise.serie(output.book.langs.list(), function(lang) { + var _outputFile = path.join( + path.dirname(outputFile), + path.basename(outputFile, ext) + '_' + lang.id + ext + ); + + return fs.copy( + path.resolve(dir, lang.id, 'index' + ext), + _outputFile + ); + }) + .thenResolve(output.book.langs.count()); + } else { + return fs.copy( + path.resolve(dir, 'index' + ext), + outputFile + ).thenResolve(1); + } + }) + .then(function(n) { + output.book.log.info.ok(n+' file(s) generated'); + + output.book.log.info('cleaning up... '); + return output.book.log.info.promise(fs.rmDir(dir)); + }); + }); + }) + }; +} + +module.exports = { + nodeFS: nodeFS, + bookCmd: bookCmd, + outputCmd: outputCmd, + ebookCmd: ebookCmd, + + options: { + log: LOG_OPTION, + format: FORMAT_OPTION + }, + + FORMATS: FORMATS +}; diff --git a/lib/cli/index.js b/lib/cli/index.js new file mode 100644 index 000000000..4d3d36483 --- /dev/null +++ b/lib/cli/index.js @@ -0,0 +1,187 @@ +/* eslint-disable no-console */ + +var _ = require('lodash'); +var path = require('path'); +var tinylr = require('tiny-lr'); + +var Promise = require('../utils/promise'); +var PluginsManager = require('../plugins'); +var Book = require('../book'); + +var helper = require('./helper'); +var Server = require('./server'); +var watch = require('./watch'); + +module.exports = { + commands: [ + + { + name: 'parse [book]', + description: 'parse and returns debug information for a book', + options: [ + helper.options.log + ], + exec: helper.bookCmd(function(book) { + return book.parse() + .then(function() { + book.log.info.ln('Book located in:', book.root); + book.log.info.ln(''); + + if (book.config.exists()) book.log.info.ln('Configuration:', book.config.path); + + if (book.isMultilingual()) { + book.log.info.ln('Multilingual book detected:', book.langs.path); + } else { + book.log.info.ln('Readme:', book.readme.path); + book.log.info.ln('Summary:', book.summary.path); + if (book.glossary.exists()) book.log.info.ln('Glossary:', book.glossary.path); + + book.log.info.ln('Pages:'); + _.each(book.pages, function(page) { + book.log.info.ln('\t-', page.path); + }); + } + }); + }) + }, + + { + name: 'install [book]', + description: 'install all plugins dependencies', + options: [ + helper.options.log + ], + exec: helper.bookCmd(function(book, args) { + var plugins = new PluginsManager(book); + + return book.config.load() + .then(function() { + return plugins.install(); + }); + }) + }, + + { + name: 'build [book] [output]', + description: 'build a book', + options: [ + helper.options.log, + helper.options.format + ], + exec: helper.outputCmd(function(output, args, kwargs) { + return output.book.parse() + .then(function() { + return output.generate(); + }); + }) + }, + + helper.ebookCmd('pdf'), + helper.ebookCmd('epub'), + helper.ebookCmd('mobi'), + + { + name: 'serve [book]', + description: 'Build then serve a book from a directory', + options: [ + { + name: 'port', + description: 'Port for server to listen on', + defaults: 4000 + }, + { + name: 'lrport', + description: 'Port for livereload server to listen on', + defaults: 35729 + }, + { + name: 'watch', + description: 'Enable/disable file watcher', + defaults: true + }, + helper.options.format, + helper.options.log + ], + exec: function(args, kwargs) { + var input = path.resolve(args[0] || process.cwd()); + var server = new Server(); + + // Init livereload server + var lrServer = tinylr({}); + var port = kwargs.port; + var lrPath; + + var generate = function() { + + // Stop server if running + if (server.isRunning()) console.log('Stopping server'); + return server.stop() + + // Generate the book + .then(function() { + return Book.setup(helper.nodeFS, input, { + 'logLevel': kwargs.log + }) + .then(function(book) { + return book.parse() + .then(function() { + // Add livereload plugin + book.config.set('plugins', + book.config.get('plugins') + .concat([ + { name: 'livereload' } + ]) + ); + + var Out = helper.FORMATS[kwargs.format]; + var output = new Out(book); + + return output.generate() + .thenResolve(output); + }); + }); + }) + + // Start server and watch changes + .then(function(output) { + console.log(); + console.log('Starting server ...'); + return server.start(output.root(), port) + .then(function() { + console.log('Serving book on http://localhost:'+port); + + if (lrPath) { + // trigger livereload + lrServer.changed({ + body: { + files: [lrPath] + } + }); + } + + if (!kwargs.watch) return; + + return watch(output.book.root) + .then(function(filepath) { + // set livereload path + lrPath = filepath; + console.log('Restart after change in file', filepath); + console.log(''); + return generate(); + }); + }); + }); + }; + + return Promise.nfcall(lrServer.listen.bind(lrServer), kwargs.lrport) + .then(function() { + console.log('Live reload server started on port:', kwargs.lrport); + console.log('Press CTRL+C to quit ...'); + console.log(''); + return generate(); + }); + } + } + + ] +}; diff --git a/lib/utils/server.js b/lib/cli/server.js similarity index 68% rename from lib/utils/server.js rename to lib/cli/server.js index 1d6822fa5..3bd5d1813 100644 --- a/lib/utils/server.js +++ b/lib/cli/server.js @@ -1,9 +1,10 @@ -var Q = require("q"); -var events = require("events"); -var http = require("http"); -var send = require("send"); -var util = require("util"); -var url = require("url"); +var events = require('events'); +var http = require('http'); +var send = require('send'); +var util = require('util'); +var url = require('url'); + +var Promise = require('../utils/promise'); var Server = function() { this.running = null; @@ -21,12 +22,12 @@ Server.prototype.isRunning = function() { // Stop the server Server.prototype.stop = function() { var that = this; - if (!this.isRunning()) return Q(); + if (!this.isRunning()) return Promise(); - var d = Q.defer(); + var d = Promise.defer(); this.running.close(function(err) { that.running = null; - that.emit("state", false); + that.emit('state', false); if (err) d.reject(err); else d.resolve(); @@ -40,13 +41,13 @@ Server.prototype.stop = function() { }; Server.prototype.start = function(dir, port) { - var that = this, pre = Q(); + var that = this, pre = Promise(); port = port || 8004; if (that.isRunning()) pre = this.stop(); return pre .then(function() { - var d = Q.defer(); + var d = Promise.defer(); that.running = http.createServer(function(req, res){ // Render error @@ -55,25 +56,25 @@ Server.prototype.start = function(dir, port) { res.end(err.message); } - // Redirect to directory"s index.html + // Redirect to directory's index.html function redirect() { res.statusCode = 301; - res.setHeader("Location", req.url + "/"); - res.end("Redirecting to " + req.url + "/"); + res.setHeader('Location', req.url + '/'); + res.end('Redirecting to ' + req.url + '/'); } // Send file send(req, url.parse(req.url).pathname) .root(dir) - .on("error", error) - .on("directory", redirect) + .on('error', error) + .on('directory', redirect) .pipe(res); }); - that.running.on("connection", function (socket) { + that.running.on('connection', function (socket) { that.sockets.push(socket); socket.setTimeout(4000); - socket.on("close", function () { + socket.on('close', function () { that.sockets.splice(that.sockets.indexOf(socket), 1); }); }); @@ -83,7 +84,7 @@ Server.prototype.start = function(dir, port) { that.port = port; that.dir = dir; - that.emit("state", true); + that.emit('state', true); d.resolve(); }); diff --git a/lib/cli/watch.js b/lib/cli/watch.js new file mode 100644 index 000000000..b98faeb61 --- /dev/null +++ b/lib/cli/watch.js @@ -0,0 +1,42 @@ +var _ = require('lodash'); +var path = require('path'); +var chokidar = require('chokidar'); + +var Promise = require('../utils/promise'); +var parsers = require('../parsers'); + +// Watch a folder and resolve promise once a file is modified +function watch(dir) { + var d = Promise.defer(); + dir = path.resolve(dir); + + var toWatch = [ + 'book.json', 'book.js' + ]; + + // Watch all parsable files + _.each(parsers.extensions, function(ext) { + toWatch.push('**/*'+ext); + }); + + var watcher = chokidar.watch(toWatch, { + cwd: dir, + ignored: '_book/**', + ignoreInitial: true + }); + + watcher.once('all', function(e, filepath) { + watcher.close(); + + d.resolve(filepath); + }); + watcher.once('error', function(err) { + watcher.close(); + + d.reject(err); + }); + + return d.promise; +} + +module.exports = watch; diff --git a/lib/config/index.js b/lib/config/index.js new file mode 100644 index 000000000..7f7573302 --- /dev/null +++ b/lib/config/index.js @@ -0,0 +1,132 @@ +var _ = require('lodash'); +var semver = require('semver'); + +var gitbook = require('../gitbook'); +var Promise = require('../utils/promise'); +var validator = require('./validator'); +var plugins = require('./plugins'); + +// Config files to tested (sorted) +var CONFIG_FILES = [ + 'book.js', + 'book.json' +]; + +/* +Config is an interface for the book's configuration stored in "book.json" (or "book.js") +*/ + +function Config(book, baseConfig) { + this.book = book; + this.fs = book.fs; + this.log = book.log; + this.path = ''; + + this.baseConfig = baseConfig || {}; + this.replace({}); +} + +// Load configuration of the book +// and verify that the configuration is satisfying +Config.prototype.load = function() { + var that = this; + var isLanguageBook = this.book.isLanguageBook(); + + // Try all potential configuration file + return Promise.some(CONFIG_FILES, function(filename) { + that.log.debug.ln('try loading configuration from', filename); + + return that.fs.loadAsObject(that.book.resolve(filename)) + .then(function(_config) { + that.log.debug.ln('configuration loaded from', filename); + + that.path = filename; + return that.replace(_config); + }) + .fail(function(err) { + if (err.code != 'MODULE_NOT_FOUND') throw(err); + else return Promise(false); + }); + }) + .then(function() { + if (!isLanguageBook) { + if (!gitbook.satisfies(that.options.gitbook)) { + throw new Error('GitBook version doesn\'t satisfy version required by the book: '+that.options.gitbook); + } + if (that.options.gitbook != '*' && !semver.satisfies(semver.inc(gitbook.version, 'patch'), that.options.gitbook)) { + that.log.warn.ln('gitbook version specified in your book.json might be too strict for future patches, \''+(_.first(gitbook.version.split('.'))+'.x.x')+'\' is more adequate'); + } + + that.options.plugins = plugins.toList(that.options.plugins); + } else { + // Multilingual book should inherits the plugins list from parent + that.options.plugins = that.book.parent.config.get('plugins'); + } + + that.options.gitbook = gitbook.version; + }); +}; + +// Replace the whole configuration +Config.prototype.replace = function(options) { + var that = this; + + // Extend base config + options = _.defaults(_.cloneDeep(options), this.baseConfig); + + // Validate the config + this.options = validator.validate(options); + + // options.input == book.root + Object.defineProperty(this.options, 'input', { + get: function () { + return that.book.root; + } + }); + + // options.originalInput == book.parent.root + Object.defineProperty(this.options, 'originalInput', { + get: function () { + return that.book.parent? that.book.parent.root : undefined; + } + }); +}; + +// Return true if book has a configuration file +Config.prototype.exists = function() { + return Boolean(this.path); +}; + +// Return path to a structure file +// Strip the extension by default +Config.prototype.getStructure = function(name, dontStripExt) { + var filename = this.options.structure[name]; + if (dontStripExt) return filename; + + filename = filename.split('.').slice(0, -1).join('.'); + return filename; +}; + +// Return a configuration using a key and a default value +Config.prototype.get = function(key, def) { + return _.get(this.options, key, def); +}; + +// Update a configuration +Config.prototype.set = function(key, value) { + return _.set(this.options, key, value); +}; + +// Return a dump of the configuration +Config.prototype.dump = function() { + return _.cloneDeep(this.options); +}; + +// Return templating context +Config.prototype.getContext = function() { + return { + config: this.book.config.dump() + }; +}; + +module.exports = Config; diff --git a/lib/config/plugins.js b/lib/config/plugins.js new file mode 100644 index 000000000..5d9873629 --- /dev/null +++ b/lib/config/plugins.js @@ -0,0 +1,67 @@ +var _ = require('lodash'); + +// Default plugins added to each books +var DEFAULT_PLUGINS = ['highlight', 'search', 'sharing', 'fontsettings', 'theme-default']; + +// Return true if a plugin is a default plugin +function isDefaultPlugin(name, version) { + return _.contains(DEFAULT_PLUGINS, name); +} + +// Normalize a list of plugins to use +function normalizePluginsList(plugins) { + // Normalize list to an array + plugins = _.isString(plugins) ? plugins.split(',') : (plugins || []); + + // Remove empty parts + plugins = _.compact(plugins); + + // Divide as {name, version} to handle format like 'myplugin@1.0.0' + plugins = _.map(plugins, function(plugin) { + if (plugin.name) return plugin; + + var parts = plugin.split('@'); + var name = parts[0]; + var version = parts[1]; + return { + 'name': name, + 'version': version // optional + }; + }); + + // List plugins to remove + var toremove = _.chain(plugins) + .filter(function(plugin) { + return plugin.name.length > 0 && plugin.name[0] == '-'; + }) + .map(function(plugin) { + return plugin.name.slice(1); + }) + .value(); + + // Merge with defaults + _.each(DEFAULT_PLUGINS, function(plugin) { + if (_.find(plugins, { name: plugin })) { + return; + } + + plugins.push({ + 'name': plugin + }); + }); + // Remove plugin that start with '-' + plugins = _.filter(plugins, function(plugin) { + return !_.contains(toremove, plugin.name) && !(plugin.name.length > 0 && plugin.name[0] == '-'); + }); + + // Remove duplicates + plugins = _.uniq(plugins, 'name'); + + return plugins; +} + +module.exports = { + isDefaultPlugin: isDefaultPlugin, + toList: normalizePluginsList +}; + diff --git a/lib/config/schema.js b/lib/config/schema.js new file mode 100644 index 000000000..34a6c7609 --- /dev/null +++ b/lib/config/schema.js @@ -0,0 +1,188 @@ +module.exports = { + '$schema': 'http://json-schema.org/schema#', + 'id': 'https://gitbook.com/schemas/book.json', + 'title': 'GitBook Configuration', + 'type': 'object', + 'properties': { + 'title': { + 'type': 'string', + 'title': 'Title of the book, default is extracted from README' + }, + 'title': { + 'type': 'string', + 'title': 'Description of the book, default is extracted from README' + }, + 'isbn': { + 'type': 'string', + 'title': 'ISBN for published book' + }, + 'author': { + 'type': 'string', + 'title': 'Name of the author' + }, + 'gitbook': { + 'type': 'string', + 'default': '*', + 'title': 'GitBook version to match' + }, + 'direction': { + 'type': 'string', + 'enum': ['ltr', 'rtl'], + 'title': 'Direction of texts, default is detected in the pages' + }, + 'theme': { + 'type': 'string', + 'default': 'default', + 'title': 'Name of the theme plugin to use' + }, + 'variables': { + 'type': 'object', + 'title': 'Templating context variables' + }, + 'plugins': { + 'oneOf': [ + { '$ref': '#/definitions/pluginsArray' }, + { '$ref': '#/definitions/pluginsString' } + ], + 'default': [] + }, + 'pluginsConfig': { + 'type': 'object', + 'title': 'Configuration for plugins' + }, + 'structure': { + 'type': 'object', + 'properties': { + 'langs': { + 'default': 'LANGS.md', + 'type': 'string', + 'title': 'File to use as languages index', + 'pattern': '^[0-9a-zA-Z ... ]+$' + }, + 'readme': { + 'default': 'README.md', + 'type': 'string', + 'title': 'File to use as preface', + 'pattern': '^[0-9a-zA-Z ... ]+$' + }, + 'glossary': { + 'default': 'GLOSSARY.md', + 'type': 'string', + 'title': 'File to use as glossary index', + 'pattern': '^[0-9a-zA-Z ... ]+$' + }, + 'summary': { + 'default': 'SUMMARY.md', + 'type': 'string', + 'title': 'File to use as table of contents', + 'pattern': '^[0-9a-zA-Z ... ]+$' + } + }, + 'additionalProperties': false + }, + 'pdf': { + 'type': 'object', + 'title': 'PDF specific configurations', + 'properties': { + 'pageNumbers': { + 'type': 'boolean', + 'default': true, + 'title': 'Add page numbers to the bottom of every page' + }, + 'fontSize': { + 'type': 'integer', + 'minimum': 8, + 'maximum': 30, + 'default': 12, + 'title': 'Font size for the PDF output' + }, + 'fontFamily': { + 'type': 'string', + 'default': 'Arial', + 'title': 'Font family for the PDF output' + }, + 'paperSize': { + 'type': 'string', + 'enum': ['a0', 'a1', 'a2', 'a3', 'a4', 'a5', 'a6', 'b0', 'b1', 'b2', 'b3', 'b4', 'b5', 'b6', 'legal', 'letter'], + 'default': 'a4', + 'title': 'Paper size for the PDF' + }, + 'chapterMark': { + 'type': 'string', + 'enum': ['pagebreak', 'rule', 'both', 'none'], + 'default': 'pagebreak', + 'title': 'How to mark detected chapters' + }, + 'pageBreaksBefore': { + 'type': 'string', + 'default': '/', + 'title': 'An XPath expression. Page breaks are inserted before the specified elements. To disable use the expression: "/"' + }, + 'margin': { + 'type': 'object', + 'properties': { + 'right': { + 'type': 'integer', + 'title': 'Right Margin', + 'minimum': 0, + 'maximum': 100, + 'default': 62 + }, + 'left': { + 'type': 'integer', + 'title': 'Left Margin', + 'minimum': 0, + 'maximum': 100, + 'default': 62 + }, + 'top': { + 'type': 'integer', + 'title': 'Top Margin', + 'minimum': 0, + 'maximum': 100, + 'default': 56 + }, + 'bottom': { + 'type': 'integer', + 'title': 'Bottom Margin', + 'minimum': 0, + 'maximum': 100, + 'default': 56 + } + } + } + } + } + }, + 'required': [], + 'definitions': { + 'pluginsArray': { + 'type': 'array', + 'items': { + 'oneOf': [ + { '$ref': '#/definitions/pluginObject' }, + { '$ref': '#/definitions/pluginString' } + ] + } + }, + 'pluginsString': { + 'type': 'string' + }, + 'pluginString': { + 'type': 'string' + }, + 'pluginObject': { + 'type': 'object', + 'properties': { + 'name': { + 'type': 'string' + }, + 'version': { + 'type': 'string' + } + }, + 'additionalProperties': false, + 'required': ['name'] + } + } +}; diff --git a/lib/config/validator.js b/lib/config/validator.js new file mode 100644 index 000000000..764b19a33 --- /dev/null +++ b/lib/config/validator.js @@ -0,0 +1,28 @@ +var jsonschema = require('jsonschema'); +var jsonSchemaDefaults = require('json-schema-defaults'); +var mergeDefaults = require('merge-defaults'); + +var schema = require('./schema'); +var error = require('../utils/error'); + +// Validate a book.json content +// And return a mix with the default value +function validate(bookJson) { + var v = new jsonschema.Validator(); + var result = v.validate(bookJson, schema, { + propertyName: 'config' + }); + + // Throw error + if (result.errors.length > 0) { + throw new error.ConfigurationError(new Error(result.errors[0].stack)); + } + + // Insert default values + var defaults = jsonSchemaDefaults(schema); + return mergeDefaults(bookJson, defaults); +} + +module.exports = { + validate: validate +}; diff --git a/lib/config_default.js b/lib/config_default.js deleted file mode 100644 index 604003dc9..000000000 --- a/lib/config_default.js +++ /dev/null @@ -1,109 +0,0 @@ -var path = require('path'); - -module.exports = { - // Options that can't be extend - 'configFile': 'book', - 'generator': 'website', - 'extension': null, - - // Book metadats (somes are extracted from the README by default) - 'title': null, - 'description': null, - 'isbn': null, - 'language': 'en', - 'direction': null, - 'author': null, - - // version of gitbook to use - 'gitbook': '*', - - // Structure - 'structure': { - 'langs': 'LANGS.md', - 'readme': 'README.md', - 'glossary': 'GLOSSARY.md', - 'summary': 'SUMMARY.md' - }, - - // CSS Styles - 'styles': { - 'website': 'styles/website.css', - 'print': 'styles/print.css', - 'ebook': 'styles/ebook.css', - 'pdf': 'styles/pdf.css', - 'mobi': 'styles/mobi.css', - 'epub': 'styles/epub.css' - }, - - // Plugins list, can contain '-name' for removing default plugins - 'plugins': [], - - // Global configuration for plugins - 'pluginsConfig': {}, - - // Variables for templating - 'variables': {}, - - // Set another theme with your own layout - // It's recommended to use plugins or add more options for default theme, though - // See https://github.com/GitbookIO/gitbook/issues/209 - 'theme': path.resolve(__dirname, '../theme'), - - // Links in template (null: default, false: remove, string: new value) - 'links': { - // Custom links at top of sidebar - 'sidebar': { - // 'Custom link name': 'https://customlink.com' - }, - - // Sharing links - 'sharing': { - 'google': null, - 'facebook': null, - 'twitter': null, - 'weibo': null, - 'all': null - } - }, - - - // Options for PDF generation - 'pdf': { - // Add toc at the end of the file - 'toc': true, - - // Add page numbers to the bottom of every page - 'pageNumbers': false, - - // Font for the file content - 'fontSize': 12, - 'fontFamily': 'Arial', - - // Paper size for the pdf - // Choices are [u’a0’, u’a1’, u’a2’, u’a3’, u’a4’, u’a5’, u’a6’, u’b0’, u’b1’, u’b2’, u’b3’, u’b4’, u’b5’, u’b6’, u’legal’, u’letter’] - 'paperSize': 'a4', - - // How to mark detected chapters. - // Choices are “pagebreak”, “rule”, 'both' or “none”. - 'chapterMark' : 'pagebreak', - - // An XPath expression. Page breaks are inserted before the specified elements. - // To disable use the expression: '/' - 'pageBreaksBefore': '/', - - // Margin (in pts) - // Note: 72 pts equals 1 inch - 'margin': { - 'right': 62, - 'left': 62, - 'top': 56, - 'bottom': 56 - }, - - // Header HTML template. Available variables: _PAGENUM_, _TITLE_, _AUTHOR_ and _SECTION_. - 'headerTemplate': null, - - // Footer HTML template. Available variables: _PAGENUM_, _TITLE_, _AUTHOR_ and _SECTION_. - 'footerTemplate': null - } -}; diff --git a/lib/configuration.js b/lib/configuration.js deleted file mode 100644 index dd955851d..000000000 --- a/lib/configuration.js +++ /dev/null @@ -1,210 +0,0 @@ -var _ = require('lodash'); -var Q = require('q'); -var path = require('path'); -var semver = require('semver'); - -var pkg = require('../package.json'); -var i18n = require('./utils/i18n'); -var version = require('./version'); - -var DEFAULT_CONFIG = require('./config_default'); - -// Default plugins added to each books -var DEFAULT_PLUGINS = ['highlight', 'search', 'sharing', 'fontsettings']; - -// Check if a plugin is a default plugin -// Plugin should be in the list -// And version from book.json specified for this plugin should be satisfied -function isDefaultPlugin(name, version) { - if (!_.contains(DEFAULT_PLUGINS, name)) return false; - - try { - var pluginPkg = require('gitbook-plugin-'+name+'/package.json'); - return semver.satisfies(pluginPkg.version, version || '*'); - } catch(e) { - return false; - } -} - -// Normalize a list of plugins to use -function normalizePluginsList(plugins, addDefaults) { - // Normalize list to an array - plugins = _.isString(plugins) ? plugins.split(',') : (plugins || []); - - // Remove empty parts - plugins = _.compact(plugins); - - // Divide as {name, version} to handle format like 'myplugin@1.0.0' - plugins = _.map(plugins, function(plugin) { - if (plugin.name) return plugin; - - var parts = plugin.split('@'); - var name = parts[0]; - var version = parts[1]; - return { - 'name': name, - 'version': version, // optional - 'isDefault': isDefaultPlugin(name, version) - }; - }); - - // List plugins to remove - var toremove = _.chain(plugins) - .filter(function(plugin) { - return plugin.name.length > 0 && plugin.name[0] == '-'; - }) - .map(function(plugin) { - return plugin.name.slice(1); - }) - .value(); - - // Merge with defaults - if (addDefaults !== false) { - _.each(DEFAULT_PLUGINS, function(plugin) { - if (_.find(plugins, { name: plugin })) { - return; - } - - plugins.push({ - 'name': plugin, - 'isDefault': true - }); - }); - } - - // Remove plugin that start with '-' - plugins = _.filter(plugins, function(plugin) { - return !_.contains(toremove, plugin.name) && !(plugin.name.length > 0 && plugin.name[0] == '-'); - }); - - // Remove duplicates - plugins = _.uniq(plugins, 'name'); - - return plugins; -} - -var Configuration = function(book, options) { - this.book = book; - this.replace(options); -}; - -// Read and parse the configuration -Configuration.prototype.load = function() { - var that = this; - - return Q() - .then(function() { - var configPath, _config; - - try { - configPath = require.resolve( - that.book.resolve(that.options.configFile) - ); - - // Invalidate node.js cache for livreloading - delete require.cache[configPath]; - - _config = require(configPath); - that.options = _.merge( - that.options, - _.omit(_config, 'configFile', 'defaultsPlugins', 'generator', 'extension') - ); - } - catch(err) { - if (err instanceof SyntaxError) return Q.reject(err); - return Q(); - } - }) - .then(function() { - if (!that.book.isSubBook()) { - if (!version.satisfies(that.options.gitbook)) { - throw new Error('GitBook version doesn\'t satisfy version required by the book: '+that.options.gitbook); - } - if (that.options.gitbook != '*' && !semver.satisfies(semver.inc(pkg.version, 'patch'), that.options.gitbook)) { - that.book.log.warn.ln('gitbook version specified in your book.json might be too strict for future patches, \''+(_.first(pkg.version.split('.'))+'.x.x')+'\' is more adequate'); - } - } - - that.options.output = path.resolve(that.options.output || that.book.resolve('_book')); - that.options.plugins = normalizePluginsList(that.options.plugins); - that.options.defaultsPlugins = normalizePluginsList(that.options.defaultsPlugins || '', false); - that.options.plugins = _.union(that.options.plugins, that.options.defaultsPlugins); - that.options.plugins = _.uniq(that.options.plugins, 'name'); - - // Default value for text direction (from language) - if (!that.options.direction) { - var lang = i18n.getCatalog(that.options.language); - if (lang) that.options.direction = lang.direction; - } - - that.options.gitbook = pkg.version; - }); -}; - -// Extend the configuration -Configuration.prototype.extend = function(options) { - _.extend(this.options, options); -}; - -// Replace the whole configuration -Configuration.prototype.replace = function(options) { - var that = this; - - this.options = _.cloneDeep(DEFAULT_CONFIG); - this.options = _.merge(this.options, options || {}); - - // options.input == book.root - Object.defineProperty(this.options, 'input', { - get: function () { - return that.book.root; - } - }); - - // options.originalInput == book.parent.root - Object.defineProperty(this.options, 'originalInput', { - get: function () { - return that.book.parent? that.book.parent.root : undefined; - } - }); - - // options.originalOutput == book.parent.options.output - Object.defineProperty(this.options, 'originalOutput', { - get: function () { - return that.book.parent? that.book.parent.options.output : undefined; - } - }); -}; - -// Dump configuration as json object -Configuration.prototype.dump = function() { - return _.cloneDeep(this.options); -}; - -// Get structure file -Configuration.prototype.getStructure = function(name, dontStripExt) { - var filename = this.options.structure[name]; - if (dontStripExt) return filename; - - filename = filename.split('.').slice(0, -1).join('.'); - return filename; -}; - -// Return normalized language -Configuration.prototype.normalizeLanguage = function() { - return i18n.normalizeLanguage(this.options.language); -}; - -// Return a configuration -Configuration.prototype.get = function(key, def) { - return _.get(this.options, key, def); -}; - -// Update a configuration -Configuration.prototype.set = function(key, value) { - return _.set(this.options, key, value); -}; - -// Default configuration -Configuration.DEFAULT = DEFAULT_CONFIG; - -module.exports= Configuration; diff --git a/lib/conrefs_loader.js b/lib/conrefs_loader.js deleted file mode 100644 index 255bf0647..000000000 --- a/lib/conrefs_loader.js +++ /dev/null @@ -1,73 +0,0 @@ -var _ = require('lodash'); -var path = require('path'); -var nunjucks = require('nunjucks'); - -var git = require('./utils/git'); -var fs = require('./utils/fs'); -var pathUtil = require('./utils/path'); - -// The loader should handle relative and git url -var BookLoader = nunjucks.Loader.extend({ - async: true, - - init: function(book, opts) { - this.opts = _.defaults(opts || {}, { - interpolate: _.identity - }); - this.book = book; - }, - - getSource: function(fileurl, callback) { - var that = this; - - git.resolveFile(fileurl) - .then(function(filepath) { - // Is local file - if (!filepath) filepath = path.resolve(fileurl); - else that.book.log.debug.ln('resolve from git', fileurl, 'to', filepath); - - // Read file from absolute path - return fs.readFile(filepath) - .then(function(source) { - return that.opts.interpolate(filepath, source.toString()); - }) - .then(function(source) { - return { - src: source, - path: filepath, - - // We disable cache sincde content is modified (shortcuts, ...) - noCache: true - }; - }); - }) - .nodeify(callback); - }, - - resolve: function(from, to) { - // If origin is in the book, we enforce result file to be in the book - if (this.book.fileIsInBook(from)) { - return this.book.resolve( - this.book.relative(path.dirname(from)), - to - ); - } - - // If origin is in a git repository, we resolve file in the git repository - var gitRoot = git.resolveRoot(from); - if (gitRoot) { - return pathUtil.resolveInRoot(gitRoot, to); - } - - // If origin is not in the book (include from a git content ref) - return path.resolve(path.dirname(from), to); - }, - - // Handle all files as relative, so that nunjucks pass responsability to 'resolve' - // Only git urls are considered as absolute - isRelative: function(filename) { - return !git.checkUrl(filename); - } -}); - -module.exports = BookLoader; diff --git a/lib/fs/index.js b/lib/fs/index.js new file mode 100644 index 000000000..8a3ca1e3f --- /dev/null +++ b/lib/fs/index.js @@ -0,0 +1,106 @@ +var _ = require('lodash'); +var path = require('path'); + +var Promise = require('../utils/promise'); + +/* +A filesystem is an interface to read files +GitBook can works with a virtual filesystem, for example in the browser. +*/ + +// .readdir return files/folder as a list of string, folder ending with '/' +function pathIsFolder(filename) { + return _.last(filename) == '/' || _.last(filename) == '\\'; +} + + +function FS() { + +} + +// Check if a file exists, run a Promise(true) if that's the case, Promise(false) otherwise +FS.prototype.exists = function(filename) { + // To implement for each fs +}; + +// Read a file and returns a promise with the content as a buffer +FS.prototype.read = function(filename) { + // To implement for each fs +}; + +// Read stat infos about a file +FS.prototype.stat = function(filename) { + // To implement for each fs +}; + +// List files/directories in a directory +FS.prototype.readdir = function(folder) { + // To implement for each fs +}; + +// These methods don't require to be redefined, by default it uses .exists, .read, .write, .list +// For optmization, it can be redefined: + +// List files in a directory +FS.prototype.listFiles = function(folder) { + return this.readdir(folder) + .then(function(files) { + return _.reject(files, pathIsFolder); + }); +}; + +// List all files in the fs +FS.prototype.listAllFiles = function(folder) { + var that = this; + + return this.readdir(folder) + .then(function(files) { + return _.reduce(files, function(prev, file) { + return prev.then(function(output) { + var isDirectory = pathIsFolder(file); + + if (!isDirectory) { + output.push(file); + return output; + } else { + return that.listAllFiles(path.join(folder, file)) + .then(function(files) { + return output.concat(_.map(files, function(_file) { + return path.join(file, _file); + })); + }); + } + }); + }, Promise([])); + }); +}; + +// Read a file as a string (utf-8) +FS.prototype.readAsString = function(filename) { + return this.read(filename) + .then(function(buf) { + return buf.toString('utf-8'); + }); +}; + +// Find a file in a folder (case incensitive) +// Return the real filename +FS.prototype.findFile = function findFile(root, filename) { + return this.listFiles(root) + .then(function(files) { + return _.find(files, function(file) { + return (file.toLowerCase() == filename.toLowerCase()); + }); + }); +}; + +// Load a JSON file +// By default, fs only supports JSON +FS.prototype.loadAsObject = function(filename) { + return this.readAsString(filename) + .then(function(str) { + return JSON.parse(str); + }); +}; + +module.exports = FS; diff --git a/lib/fs/node.js b/lib/fs/node.js new file mode 100644 index 000000000..fc2517e76 --- /dev/null +++ b/lib/fs/node.js @@ -0,0 +1,66 @@ +var _ = require('lodash'); +var util = require('util'); +var path = require('path'); + +var fs = require('../utils/fs'); +var Promise = require('../utils/promise'); +var BaseFS = require('./'); + +function NodeFS() { + BaseFS.call(this); +} +util.inherits(NodeFS, BaseFS); + +// Check if a file exists, run a Promise(true) if that's the case, Promise(false) otherwise +NodeFS.prototype.exists = function(filename) { + return fs.exists(filename); +}; + +// Read a file and returns a promise with the content as a buffer +NodeFS.prototype.read = function(filename) { + return fs.readFile(filename); +}; + +// Read stat infos about a file +NodeFS.prototype.stat = function(filename) { + return fs.stat(filename); +}; + +// List files in a directory +NodeFS.prototype.readdir = function(folder) { + return fs.readdir(folder) + .then(function(files) { + return _.chain(files) + .map(function(file) { + if (file == '.' || file == '..') return; + + var stat = fs.statSync(path.join(folder, file)); + if (stat.isDirectory()) file = file + path.sep; + return file; + }) + .compact() + .value(); + }); +}; + +// Load a JSON/JS file +NodeFS.prototype.loadAsObject = function(filename) { + return Promise() + .then(function() { + var jsFile; + + try { + jsFile = require.resolve(filename); + + // Invalidate node.js cache for livreloading + delete require.cache[jsFile]; + + return require(jsFile); + } + catch(err) { + return Promise.reject(err); + } + }); +}; + +module.exports = NodeFS; diff --git a/lib/generator.js b/lib/generator.js deleted file mode 100644 index 4e280d81b..000000000 --- a/lib/generator.js +++ /dev/null @@ -1,76 +0,0 @@ -var _ = require('lodash'); -var path = require('path'); -var Q = require('q'); -var fs = require('./utils/fs'); - -var BaseGenerator = function(book) { - this.book = book; - - Object.defineProperty(this, 'options', { - get: function () { - return this.book.options; - } - }); - - _.bindAll(this); -}; - -BaseGenerator.prototype.callHook = function(name, data) { - return this.book.callHook(name, data); -}; - -// Prepare the genertor -BaseGenerator.prototype.prepare = function() { - var that = this; - - return that.callHook('init'); -}; - -// Write a parsed file to the output -BaseGenerator.prototype.convertFile = function(input) { - return Q.reject(new Error('Could not convert '+input)); -}; - -// Copy file to the output (non parsable) -BaseGenerator.prototype.transferFile = function(input) { - return fs.copy( - this.book.resolve(input), - path.join(this.options.output, input) - ); -}; - -// Copy a folder to the output -BaseGenerator.prototype.transferFolder = function(input) { - return fs.mkdirp( - path.join(this.book.options.output, input) - ); -}; - -// Copy the cover picture -BaseGenerator.prototype.copyCover = function() { - var that = this; - - return Q.all([ - fs.copy(that.book.resolve('cover.jpg'), path.join(that.options.output, 'cover.jpg')), - fs.copy(that.book.resolve('cover_small.jpg'), path.join(that.options.output, 'cover_small.jpg')) - ]) - .fail(function() { - // If orignaly from multi-lang, try copy from parent - if (!that.book.isSubBook()) return; - - return Q.all([ - fs.copy(path.join(that.book.parentRoot(), 'cover.jpg'), path.join(that.options.output, 'cover.jpg')), - fs.copy(path.join(that.book.parentRoot(), 'cover_small.jpg'), path.join(that.options.output, 'cover_small.jpg')) - ]); - }) - .fail(function() { - return Q(); - }); -}; - -// At teh end of the generation -BaseGenerator.prototype.finish = function() { - return Q.reject(new Error('Could not finish generation')); -}; - -module.exports = BaseGenerator; diff --git a/lib/generators/ebook.js b/lib/generators/ebook.js deleted file mode 100644 index ff804c696..000000000 --- a/lib/generators/ebook.js +++ /dev/null @@ -1,172 +0,0 @@ -var util = require('util'); -var path = require('path'); -var Q = require('q'); -var _ = require('lodash'); -var juice = require('juice'); -var exec = require('child_process').exec; - -var fs = require('../utils/fs'); -var stringUtils = require('../utils/string'); -var BaseGenerator = require('./website'); - -var Generator = function(book, format) { - BaseGenerator.apply(this, arguments); - - // eBook format - this.ebookFormat = format; - - // Resources namespace - this.namespace = 'ebook'; - - // Styles to use - this.styles = _.compact(['print', 'ebook', this.ebookFormat]); - - // Convert images (svg -> png) - this.convertImages = true; -}; -util.inherits(Generator, BaseGenerator); - -Generator.prototype.prepareTemplates = function() { - this.templates.page = this.book.plugins.template('ebook:page') || path.resolve(this.options.theme, 'templates/ebook/page.html'); - this.templates.summary = this.book.plugins.template('ebook:summary') || path.resolve(this.options.theme, 'templates/ebook/summary.html'); - this.templates.glossary = this.book.plugins.template('ebook:glossary') || path.resolve(this.options.theme, 'templates/ebook/glossary.html'); - - return Q(); -}; - -// Generate table of contents -Generator.prototype.writeSummary = function() { - var that = this; - - that.book.log.info.ln('write SUMMARY.html'); - return this._writeTemplate(this.templates.summary, {}, path.join(this.options.output, 'SUMMARY.html')); -}; - -// Return template for footer/header with inlined css -Generator.prototype.getPDFTemplate = function(id) { - var tpl = this.options.pdf[id+'Template']; - var defaultTpl = path.resolve(this.options.theme, 'templates/ebook/'+id+'.html'); - var defaultCSS = path.resolve(this.options.theme, 'assets/ebook/pdf.css'); - - // Default template from theme - if (!tpl && fs.existsSync(defaultTpl)) { - tpl = fs.readFileSync(defaultTpl, { encoding: 'utf-8' }); - } - - // Inline CSS using juice - var stylesheets = []; - - // From theme - if (fs.existsSync(defaultCSS)) { - stylesheets.push(fs.readFileSync(defaultCSS, { encoding: 'utf-8' })); - } - - // Custom PDF style - if (this.styles.pdf) { - stylesheets.push(fs.readFileSync(this.book.resolveOutput(this.styles.pdf), { encoding: 'utf-8' })); - } - - tpl = juice(tpl, { - extraCss: stylesheets.join('\n\n') - }); - - return tpl; -}; - -Generator.prototype.finish = function() { - var that = this; - - return Q() - .then(this.copyAssets) - .then(this.copyCover) - .then(this.writeGlossary) - .then(this.writeSummary) - .then(function() { - if (!that.ebookFormat) return Q(); - - if (!that.options.cover && fs.existsSync(path.join(that.options.output, 'cover.jpg'))) { - that.options.cover = path.join(that.options.output, 'cover.jpg'); - } - - var d = Q.defer(); - - var _options = { - '--cover': that.options.cover, - '--title': that.options.title, - '--comments': that.options.description, - '--isbn': that.options.isbn, - '--authors': that.options.author, - '--language': that.options.language, - '--book-producer': 'GitBook', - '--publisher': 'GitBook', - '--chapter': 'descendant-or-self::*[contains(concat(\' \', normalize-space(@class), \' \'), \' book-chapter \')]', - '--level1-toc': 'descendant-or-self::*[contains(concat(\' \', normalize-space(@class), \' \'), \' book-chapter-1 \')]', - '--level2-toc': 'descendant-or-self::*[contains(concat(\' \', normalize-space(@class), \' \'), \' book-chapter-2 \')]', - '--level3-toc': 'descendant-or-self::*[contains(concat(\' \', normalize-space(@class), \' \'), \' book-chapter-3 \')]', - '--no-chapters-in-toc': true, - '--max-levels': '1', - '--breadth-first': true - }; - - if (that.ebookFormat == 'pdf') { - var pdfOptions = that.options.pdf; - - _.extend(_options, { - '--chapter-mark': String(pdfOptions.chapterMark), - '--page-breaks-before': String(pdfOptions.pageBreaksBefore), - '--margin-left': String(pdfOptions.margin.left), - '--margin-right': String(pdfOptions.margin.right), - '--margin-top': String(pdfOptions.margin.top), - '--margin-bottom': String(pdfOptions.margin.bottom), - '--pdf-default-font-size': String(pdfOptions.fontSize), - '--pdf-mono-font-size': String(pdfOptions.fontSize), - '--paper-size': String(pdfOptions.paperSize), - '--pdf-page-numbers': Boolean(pdfOptions.pageNumbers), - '--pdf-header-template': that.getPDFTemplate('header'), - '--pdf-footer-template': that.getPDFTemplate('footer'), - '--pdf-sans-family': String(pdfOptions.fontFamily) - }); - } else if (that.ebookFormat == 'epub') { - _.extend(_options, { - '--dont-split-on-page-breaks': true - }); - } - - var command = [ - 'ebook-convert', - path.join(that.options.output, 'SUMMARY.html'), - path.join(that.options.output, 'index.'+that.ebookFormat), - stringUtils.optionsToShellArgs(_options) - ].join(' '); - - that.book.log.info('start conversion to', that.ebookFormat, '....'); - - var child = exec(command, function (error, stdout) { - if (error) { - that.book.log.info.fail(); - - if (error.code == 127) { - error.message = 'Need to install ebook-convert from Calibre'; - } else { - error.message = error.message + ' '+stdout; - } - return d.reject(error); - } - - that.book.log.info.ok(); - d.resolve(); - }); - - child.stdout.on('data', function (data) { - that.book.log.debug(data); - }); - - child.stderr.on('data', function (data) { - that.book.log.debug(data); - }); - - return d.promise; - }); -}; - -module.exports = Generator; diff --git a/lib/generators/index.js b/lib/generators/index.js deleted file mode 100644 index 068d0d994..000000000 --- a/lib/generators/index.js +++ /dev/null @@ -1,11 +0,0 @@ -var _ = require("lodash"); -var EbookGenerator = require("./ebook"); - -module.exports = { - json: require("./json"), - website: require("./website"), - ebook: EbookGenerator, - pdf: _.partialRight(EbookGenerator, "pdf"), - mobi: _.partialRight(EbookGenerator, "mobi"), - epub: _.partialRight(EbookGenerator, "epub") -}; diff --git a/lib/generators/json.js b/lib/generators/json.js deleted file mode 100644 index 37ffa0b3f..000000000 --- a/lib/generators/json.js +++ /dev/null @@ -1,76 +0,0 @@ -var util = require('util'); -var path = require('path'); -var Q = require('q'); -var _ = require('lodash'); - -var fs = require('../utils/fs'); -var BaseGenerator = require('../generator'); -var links = require('../utils/links'); - -var Generator = function() { - BaseGenerator.apply(this, arguments); -}; -util.inherits(Generator, BaseGenerator); - -// Ignore some methods -Generator.prototype.transferFile = function() { }; - -// Convert an input file -Generator.prototype.convertFile = function(input) { - var that = this; - - return that.book.parsePage(input) - .then(function(page) { - var json = { - progress: page.progress, - sections: page.sections - }; - - var output = links.changeExtension(page.path, '.json'); - output = path.join(that.options.output, output); - - return fs.writeFile( - output, - JSON.stringify(json, null, 4) - ); - }); -}; - -// Finish generation -Generator.prototype.finish = function() { - return this.writeReadme(); -}; - -// Write README.json -Generator.prototype.writeReadme = function() { - var that = this; - var mainLang, langs, readme; - - return Q() - .then(function() { - langs = that.book.langs; - mainLang = langs.length > 0? _.first(langs).lang : null; - - readme = links.changeExtension(that.book.readmeFile, '.json'); - - // Read readme from main language - return fs.readFile( - mainLang? path.join(that.options.output, mainLang, readme) : path.join(that.options.output, readme) - ); - }) - .then(function(content) { - // Extend it with infos about the languages - var json = JSON.parse(content); - _.extend(json, { - langs: langs - }); - - // Write it as README.json - return fs.writeFile( - path.join(that.options.output, 'README.json'), - JSON.stringify(json, null, 4) - ); - }); -}; - -module.exports = Generator; diff --git a/lib/generators/website.js b/lib/generators/website.js deleted file mode 100644 index efb7c0fba..000000000 --- a/lib/generators/website.js +++ /dev/null @@ -1,268 +0,0 @@ -var util = require('util'); -var path = require('path'); -var Q = require('q'); -var _ = require('lodash'); - -var nunjucks = require('nunjucks'); -var AutoEscapeExtension = require('nunjucks-autoescape')(nunjucks); -var FilterExtension = require('nunjucks-filter')(nunjucks); - -var fs = require('../utils/fs'); -var BaseGenerator = require('../generator'); -var links = require('../utils/links'); -var i18n = require('../utils/i18n'); - -var pkg = require('../../package.json'); - -var Generator = function() { - BaseGenerator.apply(this, arguments); - - // Revision - this.revision = new Date(); - - // Resources namespace - this.namespace = 'website'; - - // Style to integrates in the output - this.styles = ['website']; - - // Convert images (svg -> png) - this.convertImages = false; - - // Templates - this.templates = {}; -}; -util.inherits(Generator, BaseGenerator); - -// Prepare the genertor -Generator.prototype.prepare = function() { - return BaseGenerator.prototype.prepare.apply(this) - .then(this.prepareStyles) - .then(this.prepareTemplates) - .then(this.prepareTemplateEngine); -}; - -// Prepare all styles -Generator.prototype.prepareStyles = function() { - var that = this; - - this.styles = _.chain(this.styles) - .map(function(style) { - var stylePath = that.options.styles[style]; - var styleExists = ( - fs.existsSync(that.book.resolveOutput(stylePath)) || - fs.existsSync(that.book.resolve(stylePath)) - ); - - if (stylePath && styleExists) { - return [style, stylePath]; - } - return null; - }) - .compact() - .object() - .value(); - - return Q(); -}; - -// Prepare templates -Generator.prototype.prepareTemplates = function() { - this.templates.page = this.book.plugins.template('site:page') || path.resolve(this.options.theme, 'templates/website/page.html'); - this.templates.langs = this.book.plugins.template('site:langs') || path.resolve(this.options.theme, 'templates/website/langs.html'); - this.templates.glossary = this.book.plugins.template('site:glossary') || path.resolve(this.options.theme, 'templates/website/glossary.html'); - - return Q(); -}; - -// Prepare template engine -Generator.prototype.prepareTemplateEngine = function() { - var that = this; - - return Q() - .then(function() { - var language = that.book.config.normalizeLanguage(); - - if (!i18n.hasLocale(language)) { - that.book.log.warn.ln('Language "'+language+'" is not available as a layout locales (en, '+i18n.getLocales().join(', ')+')'); - } - - var folders = _.chain(that.templates) - .values() - .map(path.dirname) - .uniq() - .value(); - - that.env = new nunjucks.Environment( - new nunjucks.FileSystemLoader(folders), - { - autoescape: true - } - ); - - // Add filter - that.env.addFilter('contentLink', that.book.contentLink.bind(that.book)); - that.env.addFilter('lvl', function(lvl) { - return lvl.split('.').length; - }); - - // Add extension - that.env.addExtension('AutoEscapeExtension', new AutoEscapeExtension(that.env)); - that.env.addExtension('FilterExtension', new FilterExtension(that.env)); - }); -}; - -// Finis generation -Generator.prototype.finish = function() { - return this.copyAssets() - .then(this.copyCover) - .then(this.writeGlossary) - .then(this.writeLangsIndex); -}; - -// Convert an input file -Generator.prototype.convertFile = function(input) { - var that = this; - - return that.book.parsePage(input, { - convertImages: that.convertImages, - interpolateTemplate: function(page) { - return that.callHook('page:before', page); - }, - interpolateContent: function(page) { - return that.callHook('page', page); - } - }) - .then(function(page) { - var relativeOutput = that.book.contentPath(page.path); - var output = path.join(that.options.output, relativeOutput); - - var basePath = path.relative(path.dirname(output), that.options.output) || '.'; - if (process.platform === 'win32') basePath = basePath.replace(/\\/g, '/'); - - that.book.log.debug.ln('write parsed file', page.path, 'to', relativeOutput); - - return that._writeTemplate(that.templates.page, { - progress: page.progress, - - _input: page.path, - content: page.sections, - - basePath: basePath, - staticBase: links.join(basePath, 'gitbook') - }, output); - }); -}; - -// Write the index for langs -Generator.prototype.writeLangsIndex = function() { - if (!this.book.langs.length) return Q(); - - return this._writeTemplate(this.templates.langs, { - langs: this.book.langs - }, path.join(this.options.output, 'index.html')); -}; - -// Write glossary -Generator.prototype.writeGlossary = function() { - // No glossary - if (this.book.glossary.length === 0) return Q(); - - return this._writeTemplate(this.templates.glossary, {}, path.join(this.options.output, 'GLOSSARY.html')); -}; - -// Convert a page into a normalized data set -Generator.prototype.normalizePage = function(page) { - var that = this; - - var _callHook = function(name) { - return that.callHook(name, page) - .then(function(_page) { - page = _page; - return page; - }); - }; - - return Q() - .then(function() { - return _callHook('page'); - }) - .then(function() { - return page; - }); -}; - -// Generate a template -Generator.prototype._writeTemplate = function(tpl, options, output, interpolate) { - var that = this; - - interpolate = interpolate || _.identity; - return Q() - .then(function() { - return that.env.render( - tpl, - _.extend({ - gitbook: { - version: pkg.version - }, - - styles: that.styles, - - revision: that.revision, - - title: that.options.title, - description: that.options.description, - language: that.book.config.normalizeLanguage(), - innerlanguage: that.book.isSubBook()? that.book.config.get('language') : null, - - glossary: that.book.glossary, - - summary: that.book.summary, - allNavigation: that.book.navigation, - - plugins: { - resources: that.book.plugins.resources(that.namespace) - }, - pluginsConfig: JSON.stringify(that.options.pluginsConfig), - htmlSnippet: _.partial(_.partialRight(that.book.plugins.html, that, options), that.namespace), - - options: that.options, - - basePath: '.', - staticBase: path.join('.', 'gitbook'), - - '__': that.book.i18n.bind(that.book) - }, options) - ); - }) - .then(interpolate) - .then(function(html) { - return fs.writeFile( - output, - html - ); - }); -}; - -// Copy assets -Generator.prototype.copyAssets = function() { - var that = this; - - // Copy gitbook assets - return fs.copy( - path.join(that.options.theme, 'assets/'+this.namespace), - path.join(that.options.output, 'gitbook') - ) - - // Copy plugins assets - .then(function() { - return Q.all( - _.map(that.book.plugins.list, function(plugin) { - var pluginAssets = path.join(that.options.output, 'gitbook/plugins/', plugin.name); - return plugin.copyAssets(pluginAssets, that.namespace); - }) - ); - }); -}; - -module.exports = Generator; diff --git a/lib/version.js b/lib/gitbook.js similarity index 59% rename from lib/version.js rename to lib/gitbook.js index f0ae187f7..54513c165 100644 --- a/lib/version.js +++ b/lib/gitbook.js @@ -4,7 +4,9 @@ var pkg = require('../package.json'); var VERSION = pkg.version; var VERSION_STABLE = VERSION.replace(/\-(\S+)/g, ''); -// Test if current current gitbook version satisfies a condition +var START_TIME = new Date(); + +// Verify that this gitbook version satisfies a requirement // We can't directly use samver.satisfies since it will break all plugins when gitbook version is a prerelease (beta, alpha) function satisfies(condition) { // Test with real version @@ -14,6 +16,18 @@ function satisfies(condition) { return semver.satisfies(VERSION_STABLE, condition); } +// Return templating/json context for gitbook itself +function getContext() { + return { + gitbook: { + version: pkg.version, + time: START_TIME + } + }; +} + module.exports = { - satisfies: satisfies + version: pkg.version, + satisfies: satisfies, + getContext: getContext }; diff --git a/lib/index.js b/lib/index.js index a23ec3fba..fdad6ee2c 100644 --- a/lib/index.js +++ b/lib/index.js @@ -1,215 +1,7 @@ -/* eslint no-console: 0 */ - -var Q = require('q'); -var _ = require('lodash'); -var path = require('path'); -var tinylr = require('tiny-lr'); -var color = require('bash-color'); - var Book = require('./book'); -var initBook = require('./init'); -var Server = require('./utils/server'); -var stringUtils = require('./utils/string'); -var watch = require('./utils/watch'); -var logger = require('./utils/logger'); - -var LOG_OPTION = { - name: 'log', - description: 'Minimum log level to display', - values: _.chain(logger.LEVELS).keys().map(stringUtils.toLowerCase).value(), - defaults: 'info' -}; - -var FORMAT_OPTION = { - name: 'format', - description: 'Format to build to', - values: ['website', 'json', 'ebook'], - defaults: 'website' -}; - -// Export init to gitbook library -Book.init = initBook; +var cli = require('./cli'); module.exports = { Book: Book, - LOG_LEVELS: logger.LEVELS, - - commands: _.flatten([ - { - name: 'build [book] [output]', - description: 'build a book', - options: [ - FORMAT_OPTION, - LOG_OPTION - ], - exec: function(args, kwargs) { - var input = args[0] || process.cwd(); - var output = args[1] || path.join(input, '_book'); - - var book = new Book(input, _.extend({}, { - 'config': { - 'output': output - }, - 'logLevel': kwargs.log - })); - - return book.parse() - .then(function() { - return book.generate(kwargs.format); - }) - .then(function(){ - console.log(''); - console.log(color.green('Done, without error')); - }); - } - }, - - _.map(['pdf', 'epub', 'mobi'], function(ebookType) { - return { - name: ebookType+' [book] [output]', - description: 'build a book to '+ebookType, - options: [ - LOG_OPTION - ], - exec: function(args, kwargs) { - var input = args[0] || process.cwd(); - var output = args[1]; - - var book = new Book(input, _.extend({}, { - 'logLevel': kwargs.log - })); - - return book.parse() - .then(function() { - return book.generateFile(output, { - ebookFormat: ebookType - }); - }) - .then(function(){ - console.log(''); - console.log(color.green('Done, without error')); - }); - } - }; - }), - - { - name: 'serve [book]', - description: 'Build then serve a gitbook from a directory', - options: [ - { - name: 'port', - description: 'Port for server to listen on', - defaults: 4000 - }, - { - name: 'lrport', - description: 'Port for livereload server to listen on', - defaults: 35729 - }, - { - name: 'watch', - description: 'Enable/disable file watcher', - defaults: true - }, - FORMAT_OPTION, - LOG_OPTION - ], - exec: function(args, kwargs) { - var input = args[0] || process.cwd(); - var server = new Server(); - - // Init livereload server - var lrServer = tinylr({}); - var lrPath; - - var generate = function() { - if (server.isRunning()) console.log('Stopping server'); - - return server.stop() - .then(function() { - var book = new Book(input, _.extend({}, { - 'config': { - 'defaultsPlugins': ['livereload'] - }, - 'logLevel': kwargs.log - })); - - return book.parse() - .then(function() { - return book.generate(kwargs.format); - }) - .thenResolve(book); - }) - .then(function(book) { - console.log(); - console.log('Starting server ...'); - return server.start(book.options.output, kwargs.port) - .then(function() { - console.log('Serving book on http://localhost:'+kwargs.port); - - if (lrPath) { - // trigger livereload - lrServer.changed({ - body: { - files: [lrPath] - } - }); - } - - if (!kwargs.watch) return; - - return watch(book.root) - .then(function(filepath) { - // set livereload path - lrPath = filepath; - console.log('Restart after change in file', filepath); - console.log(''); - return generate(); - }); - }); - }); - }; - - return Q.nfcall(lrServer.listen.bind(lrServer), kwargs.lrport) - .then(function() { - console.log('Live reload server started on port:', kwargs.lrport); - console.log('Press CTRL+C to quit ...'); - console.log(''); - return generate(); - }); - } - }, - - { - name: 'install [book]', - description: 'install plugins dependencies', - exec: function(args) { - var input = args[0] || process.cwd(); - - var book = new Book(input); - - return book.config.load() - .then(function() { - return book.plugins.install(); - }) - .then(function(){ - console.log(''); - console.log(color.green('Done, without error')); - }); - } - }, - - { - name: 'init [directory]', - description: 'create files and folders based on contents of SUMMARY.md', - exec: function(args) { - return initBook(args[0] || process.cwd()) - .then(function(){ - console.log(''); - console.log(color.green('Done, without error')); - }); - } - } - ]) + commands: cli.commands }; diff --git a/lib/init.js b/lib/init.js deleted file mode 100644 index 2fc8016c9..000000000 --- a/lib/init.js +++ /dev/null @@ -1,83 +0,0 @@ -var _ = require('lodash'); -var Q = require('q'); -var path = require('path'); - -var Book = require('./book'); -var fs = require('./utils/fs'); - -// Initialize folder structure for a book -// Read SUMMARY to created the right chapter -function initBook(root, opts) { - var book = new Book(root, opts); - var extensionToUse = '.md'; - - var chaptersPaths = function(chapters) { - return _.reduce(chapters || [], function(accu, chapter) { - var o = { - title: chapter.title - }; - if (chapter.path) o.path = chapter.path; - - return accu.concat( - [o].concat(chaptersPaths(chapter.articles)) - ); - }, []); - }; - - book.log.info.ln('init book at', root); - return fs.mkdirp(root) - .then(function() { - book.log.info.ln('detect structure from SUMMARY (if it exists)'); - return book.parseSummary(); - }) - .fail(function() { - return Q(); - }) - .then(function() { - var summary = book.summaryFile || 'SUMMARY.md'; - var chapters = book.summary.chapters || []; - extensionToUse = path.extname(summary); - - if (chapters.length === 0) { - chapters = [ - { - title: 'Summary', - path: 'SUMMARY'+extensionToUse - }, - { - title: 'Introduction', - path: 'README'+extensionToUse - } - ]; - } - - return Q(chaptersPaths(chapters)); - }) - .then(function(chapters) { - // Create files that don't exist - return Q.all(_.map(chapters, function(chapter) { - if (!chapter.path) return Q(); - var absolutePath = path.resolve(book.root, chapter.path); - - return fs.exists(absolutePath) - .then(function(exists) { - if(exists) { - book.log.info.ln('found', chapter.path); - return; - } else { - book.log.info.ln('create', chapter.path); - } - - return fs.mkdirp(path.dirname(absolutePath)) - .then(function() { - return fs.writeFile(absolutePath, '# '+chapter.title+'\n'); - }); - }); - })); - }) - .then(function() { - book.log.info.ln('initialization is finished'); - }); -} - -module.exports = initBook; diff --git a/lib/output/assets-inliner.js b/lib/output/assets-inliner.js new file mode 100644 index 000000000..6f1f02d4a --- /dev/null +++ b/lib/output/assets-inliner.js @@ -0,0 +1,140 @@ +var util = require('util'); +var path = require('path'); +var crc = require('crc'); + +var FolderOutput = require('./folder')(); +var Promise = require('../utils/promise'); +var fs = require('../utils/fs'); +var imagesUtil = require('../utils/images'); +var location = require('../utils/location'); + +var DEFAULT_ASSETS_FOLDER = 'assets'; + +/* +Mixin to inline all the assets in a book: + - Outline tags + - Download remote images + - Convert .svg images as png +*/ + +module.exports = function assetsInliner(Base) { + Base = Base || FolderOutput; + + function AssetsInliner() { + Base.apply(this, arguments); + + // Map of svg already converted + this.svgs = {}; + this.inlineSvgs = {}; + + // Map of images already downloaded + this.downloaded = {}; + } + util.inherits(AssetsInliner, Base); + + // Output a SVG buffer as a file + AssetsInliner.prototype.onOutputSVG = function(page, svg) { + this.log.debug.ln('output svg from', page.path); + + // Convert svg buffer to a png file + return this.convertSVGBuffer(svg) + + // Return relative path from the page + .then(function(filename) { + return page.relative('/' + filename); + }); + }; + + + // Output an image as a file + AssetsInliner.prototype.onOutputImage = function(page, src) { + var that = this; + + return Promise() + + // Download file if external + .then(function() { + if (!location.isExternal(src)) return; + + return that.downloadAsset(src) + .then(function(_asset) { + src = '/' + _asset; + }); + + }) + .then(function() { + // Resolve src to a relative filepath to the book's root + src = page.resolveLocal(src); + + // Already a PNG/JPG/.. ? + if (path.extname(src).toLowerCase() != '.svg') { + return src; + } + + // Convert SVG to PNG + return that.convertSVGFile(that.resolve(src)); + }) + + // Return relative path from the page + .then(function(filename) { + return page.relative(filename); + }); + }; + + // Download an asset if not already download; returns the output file + AssetsInliner.prototype.downloadAsset = function(src) { + if (this.downloaded[src]) return Promise(this.downloaded[src]); + + var that = this; + var ext = path.extname(src); + var hash = crc.crc32(src).toString(16); + + // Create new file + return this.createNewFile(DEFAULT_ASSETS_FOLDER, hash + ext) + .then(function(filename) { + that.downloaded[src] = filename; + + that.log.debug.ln('downloading asset', src); + return fs.download(src, that.resolve(filename)) + .thenResolve(filename); + }); + }; + + // Convert a .svg into an .png + // Return the output filename for the .png + AssetsInliner.prototype.convertSVGFile = function(src) { + if (this.svgs[src]) return Promise(this.svgs[src]); + + var that = this; + var hash = crc.crc32(src).toString(16); + + // Create new file + return this.createNewFile(DEFAULT_ASSETS_FOLDER, hash + '.png') + .then(function(filename) { + that.svgs[src] = filename; + + return imagesUtil.convertSVGToPNG(src, that.resolve(filename)) + .thenResolve(filename); + }); + }; + + // Convert an inline svg into an .png + // Return the output filename for the .png + AssetsInliner.prototype.convertSVGBuffer = function(buf) { + var that = this; + var hash = crc.crc32(buf).toString(16); + + // Already converted? + if (this.inlineSvgs[hash]) return Promise(this.inlineSvgs[hash]); + + return this.createNewFile(DEFAULT_ASSETS_FOLDER, hash + '.png') + .then(function(filename) { + that.inlineSvgs[hash] = filename; + + return imagesUtil.convertSVGBufferToPNG(buf, that.resolve(filename)) + .thenResolve(filename); + }); + }; + + return AssetsInliner; +}; diff --git a/lib/output/base.js b/lib/output/base.js new file mode 100644 index 000000000..a1d880404 --- /dev/null +++ b/lib/output/base.js @@ -0,0 +1,274 @@ +var _ = require('lodash'); +var Ignore = require('ignore'); +var path = require('path'); + +var Promise = require('../utils/promise'); +var pathUtil = require('../utils/path'); +var location = require('../utils/location'); +var PluginsManager = require('../plugins'); +var TemplateEngine = require('../template'); + +/* +Output is like a stream interface for a parsed book +to output "something". + +The process is mostly on the behavior of "onPage" and "onAsset" +*/ + +function Output(book, opts, parent) { + _.bindAll(this); + this.parent = parent; + + this.opts = _.defaults({}, opts || {}, { + directoryIndex: true + }); + + this.book = book; + this.log = this.book.log; + + // Create plugins manager + this.plugins = new PluginsManager(this.book); + + // Create template engine + this.template = new TemplateEngine(this); + + // Files to ignore in output + this.ignore = Ignore(); +} + +// Default extension for output +Output.prototype.defaultExtension = '.html'; + +// Start the generation, for a parsed book +Output.prototype.generate = function() { + var that = this; + var isMultilingual = this.book.isMultilingual(); + + return Promise() + + // Load all plugins + .then(function() { + return that.plugins.loadAll() + .then(function() { + that.template.addFilters(that.plugins.getFilters()); + that.template.addBlocks(that.plugins.getBlocks()); + }); + }) + + // Transform the configuration + .then(function() { + return that.plugins.hook('config', that.book.config.dump()) + .then(function(cfg) { + that.book.config.replace(cfg); + }); + }) + + // Initialize the generation + .then(function() { + return that.plugins.hook('init'); + }) + .then(function() { + that.log.info.ln('preparing the generation'); + return that.prepare(); + }) + + // Process all files + .then(function() { + that.log.debug.ln('listing files'); + return that.book.fs.listAllFiles(that.book.root); + }) + + // We want to process assets first, then pages + // Since pages can have logic based on existance of assets + .then(function(files) { + // Split into pages/assets + var byTypes = _.chain(files) + .filter(that.ignore.createFilter()) + + // Ignore file present in a language book + .filter(function(filename) { + return !(isMultilingual && that.book.isInLanguageBook(filename)); + }) + + .groupBy(function(filename) { + return (that.book.hasPage(filename)? 'page' : 'asset'); + }) + + .value(); + + return Promise.serie(byTypes.asset, function(filename) { + that.log.debug.ln('copy asset', filename); + return that.onAsset(filename); + }) + .then(function() { + return Promise.serie(byTypes.page, function(filename) { + that.log.debug.ln('process page', filename); + return that.onPage(that.book.getPage(filename)); + }); + }); + }) + + // Generate sub-books + .then(function() { + if (!that.book.isMultilingual()) return; + + return Promise.serie(that.book.books, function(subbook) { + that.log.info.ln(''); + that.log.info.ln('start generation of language "' + path.relative(that.book.root, subbook.root) + '"'); + + var out = that.onLanguageBook(subbook); + return out.generate(); + }); + }) + + // Finish the generation + .then(function() { + return that.plugins.hook('finish:before'); + }) + .then(function() { + that.log.debug.ln('finishing the generation'); + return that.finish(); + }) + .then(function() { + return that.plugins.hook('finish'); + }) + + .then(function() { + if (!that.book.isLanguageBook()) that.log.info.ln(''); + that.log.info.ok('generation finished with success!'); + }); +}; + +// Prepare the generation +Output.prototype.prepare = function() { + this.ignore.addPattern(_.compact([ + '.gitignore', + '.ignore', + '.bookignore', + 'node_modules', + + // The configuration file should not be copied in the output + this.book.config.path, + + // Structure file to ignore + this.book.summary.path, + this.book.langs.path + ])); +}; + +// Write a page (parsable file), ex: markdown, etc +Output.prototype.onPage = function(page) { + return page.toHTML(this); +}; + +// Copy an asset file (non-parsable), ex: images, etc +Output.prototype.onAsset = function(filename) { + +}; + +// Finish the generation +Output.prototype.finish = function() { + +}; + +// Resolve an HTML link +Output.prototype.onRelativeLink = function(currentPage, href) { + var to = currentPage.followPage(href); + + // Replace by an .html link + if (to) { + href = to.path; + + // Recalcul as relative link + href = currentPage.relative(href); + + // Replace .md by .html + href = this.outputUrl(href); + } + + return href; +}; + +// Output a SVG buffer as a file +Output.prototype.onOutputSVG = function(page, svg) { + return null; +}; + +// Output an image as a file +// Normalize the relative link +Output.prototype.onOutputImage = function(page, imgFile) { + imgFile = page.resolveLocal(imgFile); + return page.relative(imgFile); +}; + +// Read a template by its source URL +Output.prototype.onGetTemplate = function(sourceUrl) { + throw new Error('template not found '+sourceUrl); +}; + +// Generate a source URL for a template +Output.prototype.onResolveTemplate = function(from, to) { + return path.resolve(path.dirname(from), to); +}; + +// Prepare output for a language book +Output.prototype.onLanguageBook = function(book) { + return new this.constructor(book, this.opts, this); +}; + + +// ---- Utilities ---- + +// Return a default context for templates +Output.prototype.getContext = function() { + return _.extend( + {}, + this.book.getContext(), + this.book.langs.getContext(), + this.book.summary.getContext(), + this.book.glossary.getContext(), + this.book.config.getContext() + ); +}; + +// Resolve a file path in the context of a specific page +// Result is an "absolute path relative to the output folder" +Output.prototype.resolveForPage = function(page, href) { + if (_.isString(page)) page = this.book.getPage(page); + + href = page.relative(href); + return this.onRelativeLink(page, href); +}; + +// Filename for output +// READMEs are replaced by index.html +// /test/README.md -> /test/index.html +Output.prototype.outputPath = function(filename, ext) { + ext = ext || this.defaultExtension; + var output = filename; + + if ( + path.basename(filename, path.extname(filename)) == 'README' || + output == this.book.readme.path + ) { + output = path.join(path.dirname(output), 'index'+ext); + } else { + output = pathUtil.setExtension(output, ext); + } + + return output; +}; + +// Filename for output +// /test/index.html -> /test/ +Output.prototype.outputUrl = function(filename, ext) { + var href = this.outputPath(filename, ext); + + if (path.basename(href) == 'index.html' && this.opts.directoryIndex) { + href = path.dirname(href) + '/'; + } + + return location.normalize(href); +}; + +module.exports = Output; diff --git a/lib/output/conrefs.js b/lib/output/conrefs.js new file mode 100644 index 000000000..e58f836fe --- /dev/null +++ b/lib/output/conrefs.js @@ -0,0 +1,67 @@ +var path = require('path'); +var util = require('util'); + +var folderOutput = require('./folder'); +var Git = require('../utils/git'); +var fs = require('../utils/fs'); +var pathUtil = require('../utils/path'); +var location = require('../utils/location'); + +/* +Mixin for output to resolve git conrefs +*/ + +module.exports = function conrefsLoader(Base) { + Base = folderOutput(Base); + + function ConrefsLoader() { + Base.apply(this, arguments); + + this.git = new Git(); + } + util.inherits(ConrefsLoader, Base); + + // Read a template by its source URL + ConrefsLoader.prototype.onGetTemplate = function(sourceURL) { + var that = this; + + return this.git.resolve(sourceURL) + .then(function(filepath) { + // Is local file + if (!filepath) { + filepath = that.book.resolve(sourceURL); + } else { + that.book.log.debug.ln('resolve from git', sourceURL, 'to', filepath); + } + + // Read file from absolute path + return fs.readFile(filepath) + .then(function(source) { + return { + src: source.toString('utf8'), + path: filepath + }; + }); + }); + }; + + // Generate a source URL for a template + ConrefsLoader.prototype.onResolveTemplate = function(from, to) { + // If origin is in the book, we enforce result file to be in the book + if (this.book.isInBook(from)) { + var href = location.toAbsolute(to, path.dirname(from), ''); + return this.book.resolve(href); + } + + // If origin is in a git repository, we resolve file in the git repository + var gitRoot = this.git.resolveRoot(from); + if (gitRoot) { + return pathUtil.resolveInRoot(gitRoot, to); + } + + // If origin is not in the book (include from a git content ref) + return path.resolve(path.dirname(from), to); + }; + + return ConrefsLoader; +}; diff --git a/lib/output/ebook.js b/lib/output/ebook.js new file mode 100644 index 000000000..228a0256a --- /dev/null +++ b/lib/output/ebook.js @@ -0,0 +1,190 @@ +var _ = require('lodash'); +var util = require('util'); +var juice = require('juice'); + +var command = require('../utils/command'); +var fs = require('../utils/fs'); +var Promise = require('../utils/promise'); +var error = require('../utils/error'); +var WebsiteOutput = require('./website'); +var assetsInliner = require('./assets-inliner'); + +function _EbookOutput() { + WebsiteOutput.apply(this, arguments); + + // ebook-convert does not support link like "./" + this.opts.directoryIndex = false; +} +util.inherits(_EbookOutput, WebsiteOutput); + +var EbookOutput = assetsInliner(_EbookOutput); + +EbookOutput.prototype.name = 'ebook'; + +// Finish generation, create ebook using ebook-convert +EbookOutput.prototype.finish = function() { + var that = this; + if (that.book.isMultilingual()) { + return EbookOutput.super_.prototype.finish.apply(that); + } + + return Promise() + .then(function() { + return EbookOutput.super_.prototype.finish.apply(that); + }) + + // Generate SUMMARY.html + .then(function() { + return that.render('summary', that.getContext()) + .then(function(html) { + return that.writeFile( + 'SUMMARY.html', + html + ); + }); + }) + + // Start ebook-convert + .then(function() { + return that.ebookConvertOption(); + }) + + .then(function(options) { + if (!that.opts.format) return; + + var cmd = [ + 'ebook-convert', + that.resolve('SUMMARY.html'), + that.resolve('index.'+that.opts.format), + command.optionsToShellArgs(options) + ].join(' '); + + return command.exec(cmd) + .progress(function(data) { + that.book.log.debug(data); + }) + .fail(function(err) { + if (err.code == 127) { + throw error.RequireInstallError({ + cmd: 'ebook-convert', + install: 'Install it from Calibre: https://calibre-ebook.com' + }); + } + + throw error.EbookError(err); + }); + }); +}; + +// Generate header/footer for PDF +EbookOutput.prototype.getPDFTemplate = function(tpl) { + var that = this; + var context = _.extend( + { + // Nunjucks context mapping to ebook-convert templating + page: { + num: '_PAGENUM_', + title: '_TITLE_', + section: '_SECTION_' + } + }, + this.getContext() + ); + + return this.render('pdf_'+tpl, context) + + // Inline css, include css relative to the output folder + .then(function(output) { + return Promise.nfcall(juice.juiceResources, output, { + webResources: { + relativeTo: that.root() + } + }); + }); +}; + +// Locate the cover file to use +// Use configuration or search a "cover.jpg" file +// For multi-lingual book, it can use the one from the main book +EbookOutput.prototype.locateCover = function() { + var cover = this.book.config.get('cover', 'cover.jpg'); + + // Resolve to absolute + cover = this.resolve(cover); + + // Cover doesn't exist and multilingual? + if (!fs.existsSync(cover)) { + if (this.parent) return this.parent.locateCover() + else return undefined; + } + + return cover; +}; + +// Generate options for ebook-convert +EbookOutput.prototype.ebookConvertOption = function() { + var that = this; + + var options = { + '--cover': this.locateCover(), + '--title': that.book.config.get('title'), + '--comments': that.book.config.get('description'), + '--isbn': that.book.config.get('isbn'), + '--authors': that.book.config.get('author'), + '--language': that.book.config.get('language'), + '--book-producer': 'GitBook', + '--publisher': 'GitBook', + '--chapter': 'descendant-or-self::*[contains(concat(\' \', normalize-space(@class), \' \'), \' book-chapter \')]', + '--level1-toc': 'descendant-or-self::*[contains(concat(\' \', normalize-space(@class), \' \'), \' book-chapter-1 \')]', + '--level2-toc': 'descendant-or-self::*[contains(concat(\' \', normalize-space(@class), \' \'), \' book-chapter-2 \')]', + '--level3-toc': 'descendant-or-self::*[contains(concat(\' \', normalize-space(@class), \' \'), \' book-chapter-3 \')]', + '--no-chapters-in-toc': true, + '--max-levels': '1', + '--breadth-first': true + }; + + if (that.opts.format == 'epub') { + options = _.extend(options, { + '--dont-split-on-page-breaks': true + }); + } + + if (that.opts.format != 'pdf') return Promise(options); + + var pdfOptions = that.book.config.get('pdf'); + + options = _.extend(options, { + '--chapter-mark': String(pdfOptions.chapterMark), + '--page-breaks-before': String(pdfOptions.pageBreaksBefore), + '--margin-left': String(pdfOptions.margin.left), + '--margin-right': String(pdfOptions.margin.right), + '--margin-top': String(pdfOptions.margin.top), + '--margin-bottom': String(pdfOptions.margin.bottom), + '--pdf-default-font-size': String(pdfOptions.fontSize), + '--pdf-mono-font-size': String(pdfOptions.fontSize), + '--paper-size': String(pdfOptions.paperSize), + '--pdf-page-numbers': Boolean(pdfOptions.pageNumbers), + '--pdf-header-template': that.getPDFTemplate('header'), + '--pdf-footer-template': that.getPDFTemplate('footer'), + '--pdf-sans-family': String(pdfOptions.fontFamily) + }); + + return that.getPDFTemplate('header') + .then(function(tpl) { + options['--pdf-header-template'] = tpl; + + return that.getPDFTemplate('footer'); + }) + .then(function(tpl) { + options['--pdf-footer-template'] = tpl; + + return options; + }); +}; + +// Don't write multi-lingual index for wbook +EbookOutput.prototype.outputMultilingualIndex = function() { + +}; + +module.exports = EbookOutput; diff --git a/lib/output/folder.js b/lib/output/folder.js new file mode 100644 index 000000000..8303ed284 --- /dev/null +++ b/lib/output/folder.js @@ -0,0 +1,152 @@ +var _ = require('lodash'); +var util = require('util'); +var path = require('path'); + +var Output = require('./base'); +var fs = require('../utils/fs'); +var pathUtil = require('../utils/path'); +var Promise = require('../utils/promise'); + +/* +This output requires the native fs module to output +book as a directory (mapping assets and pages) +*/ + +module.exports = function folderOutput(Base) { + Base = Base || Output; + + function FolderOutput() { + Base.apply(this, arguments); + + this.opts.root = path.resolve(this.opts.root || this.book.resolve('_book')); + } + util.inherits(FolderOutput, Base); + + // Copy an asset file (non-parsable), ex: images, etc + FolderOutput.prototype.onAsset = function(filename) { + return this.copyFile( + this.book.resolve(filename), + filename + ); + }; + + // Prepare the generation by creating the output folder + FolderOutput.prototype.prepare = function() { + var that = this; + + return Promise() + .then(function() { + return FolderOutput.super_.prototype.prepare.apply(that); + }) + + // Cleanup output folder + .then(function() { + that.log.debug.ln('removing previous output directory'); + return fs.rmDir(that.root()) + .fail(function() { + return Promise(); + }); + }) + + // Create output folder + .then(function() { + that.log.debug.ln('creating output directory'); + return fs.mkdirp(that.root()); + }) + + // Add output folder to ignored files + .then(function() { + that.ignore.addPattern([ + path.relative(that.book.root, that.root()) + ]); + }); + }; + + // Prepare output for a language book + FolderOutput.prototype.onLanguageBook = function(book) { + return new this.constructor(book, _.extend({}, this.opts, { + + // Language output should be output in sub-directory of output + root: path.resolve(this.root(), book.language) + }), this); + }; + + // ----- Utility methods ----- + + // Return path to the root folder + FolderOutput.prototype.root = function() { + return this.opts.root; + }; + + // Resolve a file in the output directory + FolderOutput.prototype.resolve = function(filename) { + return pathUtil.resolveInRoot.apply(null, [this.root()].concat(_.toArray(arguments))); + }; + + // Copy a file to the output + FolderOutput.prototype.copyFile = function(from, to) { + var that = this; + + return Promise() + .then(function() { + to = that.resolve(to); + var folder = path.dirname(to); + + // Ensure folder exists + return fs.mkdirp(folder); + }) + .then(function() { + return fs.copy(from, to); + }); + }; + + // Write a file/buffer to the output folder + FolderOutput.prototype.writeFile = function(filename, buf) { + var that = this; + + return Promise() + .then(function() { + filename = that.resolve(filename); + var folder = path.dirname(filename); + + // Ensure folder exists + return fs.mkdirp(folder); + }) + + // Write the file + .then(function() { + return fs.writeFile(filename, buf); + }); + }; + + // Return true if a file exists in the output folder + FolderOutput.prototype.hasFile = function(filename) { + var that = this; + + return Promise() + .then(function() { + return fs.exists(that.resolve(filename)); + }); + }; + + // Create a new unique file + // Returns its filename + FolderOutput.prototype.createNewFile = function(base, filename) { + var that = this; + + if (!filename) { + filename = path.basename(filename); + base = path.dirname(base); + } + + return fs.uniqueFilename(this.resolve(base), filename) + .then(function(out) { + out = path.join(base, out); + + return fs.ensure(that.resolve(out)) + .thenResolve(out); + }); + }; + + return FolderOutput; +}; diff --git a/lib/output/json.js b/lib/output/json.js new file mode 100644 index 000000000..e8a71cc28 --- /dev/null +++ b/lib/output/json.js @@ -0,0 +1,47 @@ +var conrefsLoader = require('./conrefs'); + +var JSONOutput = conrefsLoader(); + +JSONOutput.prototype.name = 'json'; + +// Don't copy asset on JSON output +JSONOutput.prototype.onAsset = function(filename) {}; + +// Write a page (parsable file) +JSONOutput.prototype.onPage = function(page) { + var that = this; + + // Parse the page + return page.toHTML(this) + + // Write as json + .then(function() { + var json = page.getContext(); + + // Delete some private properties + delete json.config; + + // Specify JSON output version + json.version = '2'; + + return that.writeFile( + page.withExtension('.json'), + JSON.stringify(json, null, 4) + ); + }); +}; + +// At the end of generation, generate README.json for multilingual books +JSONOutput.prototype.finish = function() { + if (!this.book.isMultilingual()) return; + + // Copy README.json from main book + var mainLanguage = this.book.langs.getDefault().id; + return this.copyFile( + this.resolve(mainLanguage, 'README.json'), + 'README.json' + ); +}; + + +module.exports = JSONOutput; diff --git a/lib/output/website.js b/lib/output/website.js new file mode 100644 index 000000000..230af7182 --- /dev/null +++ b/lib/output/website.js @@ -0,0 +1,270 @@ +var _ = require('lodash'); +var path = require('path'); +var util = require('util'); +var nunjucks = require('nunjucks'); +var I18n = require('i18n-t'); + +var Promise = require('../utils/promise'); +var location = require('../utils/location'); +var fs = require('../utils/fs'); +var defaultFilters = require('../template/filters'); +var conrefsLoader = require('./conrefs'); +var Output = require('./base'); + +// Tranform a theme ID into a plugin +function themeID(plugin) { + return 'theme-' + plugin; +} + +// Directory for a theme with the templates +function templatesPath(dir) { + return path.join(dir, '_layouts'); +} + +function _WebsiteOutput() { + Output.apply(this, arguments); + + // Nunjucks environment + this.env; + + // Plugin instance for the main theme + this.theme; + + // Plugin instance for the default theme + this.defaultTheme; + + // Resources loaded from plugins + this.resources; + + // i18n for themes + this.i18n = new I18n(); +} +util.inherits(_WebsiteOutput, Output); + +var WebsiteOutput = conrefsLoader(_WebsiteOutput); + +// Name of the generator +// It's being used as a prefix for templates +WebsiteOutput.prototype.name = 'website'; + +// Load and setup the theme +WebsiteOutput.prototype.prepare = function() { + var that = this; + + return Promise() + .then(function() { + return WebsiteOutput.super_.prototype.prepare.apply(that); + }) + + .then(function() { + var themeName = that.book.config.get('theme'); + that.theme = that.plugins.get(themeID(themeName)); + that.themeDefault = that.plugins.get(themeID('default')); + + if (!that.theme) { + throw new Error('Theme "' + themeName + '" is not installed, add "' + themeID(themeName) + '" to your "book.json"'); + } + + if (that.themeDefault.root != that.theme.root) { + that.log.info.ln('build using theme "' + themeName + '"'); + } + + // This list is ordered to give priority to templates in the book + var searchPaths = _.chain([ + // The book itself can contains a "_layouts" folder + that.book.root, + + // Installed plugin (it can be identical to themeDefault.root) + that.theme.root, + + // Is default theme still installed + that.themeDefault? that.themeDefault.root : null + ]) + .compact() + .uniq() + .value(); + + // Load i18n + _.each(searchPaths.concat().reverse(), function(searchPath) { + var i18nRoot = path.resolve(searchPath, '_i18n'); + + if (!fs.existsSync(i18nRoot)) return; + that.i18n.load(i18nRoot); + }); + + that.env = new nunjucks.Environment(new nunjucks.FileSystemLoader(_.map(searchPaths, templatesPath))); + + // Add GitBook default filters + _.each(defaultFilters, function(fn, filter) { + that.env.addFilter(filter, fn); + }); + + // Translate using _i18n locales + that.env.addFilter('t', function(s) { + return that.i18n.t(that.book.config.get('language'), s); + }); + + // Transform an absolute path into a relative path + // using this.ctx.page.path + that.env.addFilter('resolveFile', function(href) { + return location.normalize(that.resolveForPage(this.ctx.file.path, href)); + }); + + // Test if a file exists + that.env.addFilter('fileExists', function(href) { + return fs.existsSync(that.resolve(href)); + }); + + // Transform a '.md' into a '.html' (README -> index) + that.env.addFilter('contentURL', function(s) { + return location.normalize(that.outputUrl(s)); + }); + + // Relase path to an asset + that.env.addFilter('resolveAsset', function(href) { + href = path.join('gitbook', href); + + // Resolve for current file + if (this.ctx.file) { + href = that.resolveForPage(this.ctx.file.path, '/' + href); + } + + // Use assets from parent + if (that.book.isLanguageBook()) { + href = path.join('../', href); + } + + return location.normalize(href); + }); + }) + + // Copy assets from themes before copying files from book + .then(function() { + if (that.book.isLanguageBook()) return; + + return Promise.serie([ + // Assets from the book are already copied + // The order is reversed from the template's one + + // Is default theme still installed + that.themeDefault && that.themeDefault.root != that.theme.root? + that.themeDefault.root : null, + + // Installed plugin (it can be identical to themeDefault.root) + that.theme.root + ], function(folder) { + if (!folder) return; + + // Copy assets only if exists (don't fail otherwise) + var assetFolder = path.join(folder, '_assets', that.name); + if (!fs.existsSync(assetFolder)) return; + + that.log.debug.ln('copy assets from theme', assetFolder); + return fs.copyDir( + assetFolder, + that.resolve('gitbook'), + { + deleteFirst: false, // Delete "to" before + overwrite: true, + confirm: true + } + ); + }); + }) + + // Load resources for plugins + .then(function() { + return that.plugins.getResources(that.name) + .then(function(resources) { + that.resources = resources; + }); + }); +}; + +// Write a page (parsable file) +WebsiteOutput.prototype.onPage = function(page) { + var that = this; + + // Parse the page + return page.toHTML(this) + + // Render the page template with the same context as the json output + .then(function() { + return that.render('page', page.getContext()); + }) + + // Write the HTML file + .then(function(html) { + return that.writeFile( + that.outputPath(page.path), + html + ); + }); +}; + +// Finish generation, create ebook using ebook-convert +WebsiteOutput.prototype.finish = function() { + var that = this; + + return Promise() + .then(function() { + return WebsiteOutput.super_.prototype.finish.apply(that); + }) + + // Copy assets from plugins + .then(function() { + if (that.book.isLanguageBook()) return; + return that.plugins.copyResources(that.name, that.resolve('gitbook')); + }) + + // Generate homepage to select languages + .then(function() { + if (!that.book.isMultilingual()) return; + return that.outputMultilingualIndex(); + }); +}; + +// ----- Utilities ---- + +// Write multi-languages index +WebsiteOutput.prototype.outputMultilingualIndex = function() { + var that = this; + + return that.render('languages', that.getContext()) + .then(function(html) { + return that.writeFile( + 'index.html', + html + ); + }); +}; + +// Render a template using nunjucks +// Templates are stored in `_layouts` folders +WebsiteOutput.prototype.render = function(tpl, context) { + var filename = this.templateName(tpl); + context = _.extend(context, { + template: { + // Same template but in the default theme + default: this.themeDefault? path.resolve(templatesPath(this.themeDefault.root), filename) : null, + + // Same template but in the theme + theme: path.resolve(templatesPath(this.theme.root), filename) + }, + + plugins: { + resources: this.resources + }, + + options: this.opts + }); + + return Promise.nfcall(this.env.render.bind(this.env), filename, context); +}; + +// Return a complete name for a template +WebsiteOutput.prototype.templateName = function(name) { + return path.join(this.name, name+'.html'); +}; + +module.exports = WebsiteOutput; diff --git a/lib/page/html.js b/lib/page/html.js new file mode 100644 index 000000000..bce6cd2f4 --- /dev/null +++ b/lib/page/html.js @@ -0,0 +1,280 @@ +var _ = require('lodash'); +var url = require('url'); +var cheerio = require('cheerio'); +var domSerializer = require('dom-serializer'); +var slug = require('github-slugid'); + +var Promise = require('../utils/promise'); +var location = require('../utils/location'); + +// Selector to ignore +var ANNOTATION_IGNORE = '.no-glossary,code,pre,a,script,h1,h2,h3,h4,h5,h6'; + +function HTMLPipeline(htmlString, opts) { + _.bindAll(this); + + this.opts = _.defaults(opts || {}, { + // Called once the description has been found + onDescription: function(description) { }, + + // Calcul new href for a relative link + onRelativeLink: _.identity, + + // Output an image + onImage: _.identity, + + // Syntax highlighting + onCodeBlock: _.identity, + + // Output a svg, if returns null the svg is kept inlined + onOutputSVG: _.constant(null), + + // Words to annotate + annotations: [], + + // When an annotation is applied + onAnnotation: function () { } + }); + + this.$ = cheerio.load(htmlString, { + // We should parse html without trying to normalize too much + xmlMode: false, + + // SVG need some attributes to use uppercases + lowerCaseAttributeNames: false, + lowerCaseTags: false + }); +} + +// Transform a query of elements in the page +HTMLPipeline.prototype._transform = function(query, fn) { + var that = this; + + var $elements = this.$(query); + + return Promise.serie($elements, function(el) { + var $el = that.$(el); + return fn.call(that, $el); + }); +}; + +// Normalize links +HTMLPipeline.prototype.transformLinks = function() { + return this._transform('a', function($a) { + var href = $a.attr('href'); + if (!href) return; + + if (location.isAnchor(href)) { + // Don't "change" anchor links + } else if (location.isRelative(href)) { + // Preserve anchor + var parsed = url.parse(href); + var filename = this.opts.onRelativeLink(parsed.pathname); + + $a.attr('href', filename + (parsed.hash || '')); + } else { + // External links + $a.attr('target', '_blank'); + } + }); +}; + +// Normalize images +HTMLPipeline.prototype.transformImages = function() { + return this._transform('img', function($img) { + return Promise(this.opts.onImage($img.attr('src'))) + .then(function(filename) { + $img.attr('src', filename); + }); + }); +}; + +// Normalize code blocks +HTMLPipeline.prototype.transformCodeBlocks = function() { + return this._transform('code', function($code) { + // Extract language + var lang = _.chain( + ($code.attr('class') || '').split(' ') + ) + .map(function(cl) { + // Markdown + if (cl.search('lang-') === 0) return cl.slice('lang-'.length); + + // Asciidoc + if (cl.search('language-') === 0) return cl.slice('language-'.length); + + return null; + }) + .compact() + .first() + .value(); + + var source = $code.text(); + + return Promise(this.opts.onCodeBlock(source, lang)) + .then(function(blk) { + if (blk.html === false) { + $code.text(blk.body); + } else { + $code.html(blk.body); + } + }); + }); +}; + +// Add ID to headings +HTMLPipeline.prototype.transformHeadings = function() { + var that = this; + + this.$('h1,h2,h3,h4,h5,h6').each(function() { + var $h = that.$(this); + + // Already has an ID? + if ($h.attr('id')) return; + $h.attr('id', slug($h.text())); + }); +}; + +// Outline SVG from the HML +HTMLPipeline.prototype.transformSvgs = function() { + var that = this; + + return this._transform('svg', function($svg) { + var content = [ + '', + renderDOM(that.$, $svg) + ].join('\n'); + + return Promise(that.opts.onOutputSVG(content)) + .then(function(filename) { + if (!filename) return; + + $svg.replaceWith(that.$('').attr('src', filename)); + }); + }); +}; + +// Annotate the content +HTMLPipeline.prototype.applyAnnotations = function() { + var that = this; + + _.each(this.opts.annotations, function(annotation) { + var searchRegex = new RegExp( '\\b(' + pregQuote(annotation.name.toLowerCase()) + ')\\b' , 'gi' ); + + that.$('*').each(function() { + var $this = that.$(this); + + if ( + $this.is(ANNOTATION_IGNORE) || + $this.parents(ANNOTATION_IGNORE).length > 0 + ) return; + + replaceText(that.$, this, searchRegex, function(match) { + that.opts.onAnnotation(annotation); + + return '' + + match + + ''; + }); + }); + }); +}; + +// Extract page description from html +// This can totally be improved +HTMLPipeline.prototype.extractDescription = function() { + var $p = this.$('p').first(); + var description = $p.text().trim().slice(0, 155); + + this.opts.onDescription(description); +}; + +// Write content to the pipeline +HTMLPipeline.prototype.output = function() { + var that = this; + + return Promise() + .then(this.extractDescription) + .then(this.transformImages) + .then(this.transformHeadings) + .then(this.transformCodeBlocks) + .then(this.transformSvgs) + .then(this.applyAnnotations) + + // Transform of links should be applied after annotations + // because annotations are created as links + .then(this.transformLinks) + + .then(function() { + return renderDOM(that.$); + }); +}; + + +// Render a cheerio DOM as html +function renderDOM($, dom, options) { + if (!dom && $._root && $._root.children) { + dom = $._root.children; + } + options = options|| dom.options || $._options; + return domSerializer(dom, options); +} + +// Replace text in an element +function replaceText($, el, search, replace, text_only ) { + return $(el).each(function(){ + var node = this.firstChild, + val, + new_val, + + // Elements to be removed at the end. + remove = []; + + // Only continue if firstChild exists. + if ( node ) { + + // Loop over all childNodes. + while (node) { + + // Only process text nodes. + if ( node.nodeType === 3 ) { + + // The original node value. + val = node.nodeValue; + + // The new value. + new_val = val.replace( search, replace ); + + // Only replace text if the new value is actually different! + if ( new_val !== val ) { + + if ( !text_only && /\|\:])/g, '\\$1'); +} + +module.exports = HTMLPipeline; diff --git a/lib/page/index.js b/lib/page/index.js new file mode 100644 index 000000000..f3a8f39b2 --- /dev/null +++ b/lib/page/index.js @@ -0,0 +1,250 @@ +var _ = require('lodash'); +var path = require('path'); +var direction = require('direction'); +var fm = require('front-matter'); + +var error = require('../utils/error'); +var pathUtil = require('../utils/path'); +var location = require('../utils/location'); +var parsers = require('../parsers'); +var gitbook = require('../gitbook'); +var pluginCompatibility = require('../plugins/compatibility'); +var HTMLPipeline = require('./html'); + +/* +A page represent a parsable file in the book (Markdown, Asciidoc, etc) +*/ + +function Page(book, filename) { + if (!(this instanceof Page)) return new Page(book, filename); + var extension; + _.bindAll(this); + + this.book = book; + this.log = this.book.log; + + // Current content + this.content = ''; + + // Short description for the page + this.description = ''; + + // Relative path to the page + this.path = location.normalize(filename); + + // Absolute path to the page + this.rawPath = this.book.resolve(filename); + + // Last modification date + this.mtime = 0; + + // Can we parse it? + extension = path.extname(this.path); + this.parser = parsers.get(extension); + if (!this.parser) throw error.ParsingError(new Error('Can\'t parse file "'+this.path+'"')); + + this.type = this.parser.name; +} + +// Return the filename of the page with another extension +// "README.md" -> "README.html" +Page.prototype.withExtension = function(ext) { + return pathUtil.setExtension(this.path, ext); +}; + +// Resolve a filename relative to this page +// It returns a path relative to the book root folder +Page.prototype.resolveLocal = function() { + var dir = path.dirname(this.path); + var file = path.join.apply(path, _.toArray(arguments)); + + return location.toAbsolute(file, dir, ''); +}; + +// Resolve a filename relative to this page +// It returns an absolute path for the FS +Page.prototype.resolve = function() { + return this.book.resolve(this.resolveLocal.apply(this, arguments)); +}; + +// Convert an absolute path (in the book) to a relative path from this page +Page.prototype.relative = function(name) { + // Convert /test.png -> test.png + name = location.toAbsolute(name, '', ''); + + return location.relative( + this.resolve('.') + '/', + this.book.resolve(name) + ); +}; + +// Return a page result of a relative page from this page +Page.prototype.followPage = function(filename) { + var absPath = this.resolveLocal(filename); + return this.book.getPage(absPath); +}; + +// Update content of the page +Page.prototype.update = function(content) { + this.content = content; +}; + +// Read the page as a string +Page.prototype.read = function() { + var that = this; + + return this.book.statFile(this.path) + .then(function(stat) { + that.mtime = stat.mtime; + return that.book.readFile(that.path); + }) + .then(this.update); +}; + +// Return templating context for this page +// This is used both for themes and page parsing +Page.prototype.getContext = function() { + var article = this.book.summary.getArticle(this); + var next = article? article.next() : null; + var prev = article? article.prev() : null; + + // Detect text direction in this page + var dir = this.book.config.get('direction'); + if (!dir) { + dir = direction(this.content); + if (dir == 'neutral') dir = null; + } + + return _.extend( + { + file: { + path: this.path, + mtime: this.mtime, + type: this.type + }, + page: { + title: article? article.title : null, + description: this.description, + next: next? next.getContext() : null, + previous: prev? prev.getContext() : null, + level: article? article.level : null, + depth: article? article.depth : 0, + content: this.content, + dir: dir + } + }, + gitbook.getContext(), + this.book.getContext(), + this.book.langs.getContext(), + this.book.summary.getContext(), + this.book.glossary.getContext(), + this.book.config.getContext() + ); +}; + +// Parse the page and return its content +Page.prototype.toHTML = function(output) { + var that = this; + + this.log.debug.ln('start parsing file', this.path); + + // Call a hook in the output + // using an utility to "keep" compatibility with gitbook 2 + function hook(name) { + return pluginCompatibility.pageHook(that, function(ctx) { + return output.plugins.hook(name, ctx); + }) + .then(function(result) { + if(_.isString(result)) that.update(result); + }); + } + + return this.read() + + // Parse yaml front matter + .then(function() { + var parsed = fm(that.content); + + // Extend page with the fontmatter attribute + that.description = parsed.attributes.description || ''; + + // Keep only the body + that.update(parsed.body); + }) + + .then(function() { + return hook('page:before'); + }) + + // Pre-process page with parser + .then(function() { + return that.parser.page.prepare(that.content) + .then(that.update); + }) + + // Render template + .then(function() { + return output.template.render(that.content, that.getContext(), { + path: that.path + }) + .then(that.update); + }) + + // Render markup using the parser + .then(function() { + return that.parser.page(that.content) + .then(function(out) { + that.update(out.content); + }); + }) + + // Post process templating + .then(function() { + return output.template.postProcess(that.content) + .then(that.update); + }) + + // Normalize HTML output + .then(function() { + var pipelineOpts = { + onRelativeLink: _.partial(output.onRelativeLink, that), + onImage: _.partial(output.onOutputImage, that), + onOutputSVG: _.partial(output.onOutputSVG, that), + + // Use 'code' template block + onCodeBlock: function(source, lang) { + return output.template.applyBlock('code', { + body: source, + kwargs: { + language: lang + } + }); + }, + + // Extract description from page's content if no frontmatter + onDescription: function(description) { + if (that.description) return; + that.description = description; + }, + + // Convert glossary entries to annotations + annotations: that.book.glossary.annotations() + }; + var pipeline = new HTMLPipeline(that.content, pipelineOpts); + + return pipeline.output() + .then(that.update); + }) + + .then(function() { + return hook('page'); + }) + + // Return content itself + .then(function() { + return that.content; + }); +}; + + +module.exports = Page; diff --git a/lib/parsers.js b/lib/parsers.js new file mode 100644 index 000000000..68998658d --- /dev/null +++ b/lib/parsers.js @@ -0,0 +1,60 @@ +var _ = require('lodash'); +var path = require('path'); + +var markdownParser = require('gitbook-markdown'); +var asciidocParser = require('gitbook-asciidoc'); + +var Promise = require('./utils/promise'); + +// This list is ordered by priority of parsers to use +var PARSERS = [ + createParser(markdownParser, { + name: 'markdown', + extensions: ['.md', '.markdown', '.mdown'] + }), + createParser(asciidocParser, { + name: 'asciidoc', + extensions: ['.adoc', '.asciidoc'] + }) +]; + + +// Prepare and compose a parser +function createParser(parser, base) { + var nparser = base; + + nparser.glossary = Promise.wrapfn(parser.glossary); + nparser.glossary.toText = Promise.wrapfn(parser.glossary.toText); + + nparser.summary = Promise.wrapfn(parser.summary); + nparser.summary.toText = Promise.wrapfn(parser.summary.toText); + + nparser.langs = Promise.wrapfn(parser.langs); + nparser.langs.toText = Promise.wrapfn(parser.langs.toText); + + nparser.readme = Promise.wrapfn(parser.readme); + + nparser.page = Promise.wrapfn(parser.page); + nparser.page.prepare = Promise.wrapfn(parser.page.prepare || _.identity); + + return nparser; +} + +// Return a specific parser according to an extension +function getParser(ext) { + return _.find(PARSERS, function(input) { + return input.name == ext || _.contains(input.extensions, ext); + }); +} + +// Return parser for a file +function getParserForFile(filename) { + return getParser(path.extname(filename)); +} + +module.exports = { + all: PARSERS, + extensions: _.flatten(_.pluck(PARSERS, 'extensions')), + get: getParser, + getForFile: getParserForFile +}; diff --git a/lib/plugin.js b/lib/plugin.js deleted file mode 100644 index b7e826017..000000000 --- a/lib/plugin.js +++ /dev/null @@ -1,241 +0,0 @@ -var _ = require('lodash'); -var Q = require('q'); -var path = require('path'); -var url = require('url'); -var fs = require('./utils/fs'); -var resolve = require('resolve'); -var mergeDefaults = require('merge-defaults'); -var jsonschema = require('jsonschema'); -var jsonSchemaDefaults = require('json-schema-defaults'); - -var version = require('./version'); - -var PLUGIN_PREFIX = 'gitbook-plugin-'; - -// Return an absolute name for the plugin (the one on NPM) -function absoluteName(name) { - if (name.indexOf(PLUGIN_PREFIX) === 0) return name; - return [PLUGIN_PREFIX, name].join(''); -} - - -var Plugin = function(book, name) { - this.book = book; - this.name = absoluteName(name); - this.packageInfos = {}; - this.infos = {}; - - // Bind methods - _.bindAll(this); - - _.each([ - absoluteName(name), - name - ], function(_name) { - // Load from the book - if (this.load(_name, book.root)) return false; - - // Load from default plugins - if (this.load(_name, __dirname)) return false; - }, this); -}; - -// Type of plugins resources -Plugin.RESOURCES = ['js', 'css']; -Plugin.HOOKS = [ - 'init', 'finish', 'finish:before', 'config', 'page', 'page:before' -]; - -// Return the reduce name for the plugin -// "gitbook-plugin-test" -> "test" -// Return a relative name for the plugin (the one on GitBook) -Plugin.prototype.reducedName = function() { - return this.name.replace(PLUGIN_PREFIX, ''); -}; - -// Load from a name -Plugin.prototype.load = function(name, baseDir) { - try { - var res = resolve.sync(name+'/package.json', { basedir: baseDir }); - - this.baseDir = path.dirname(res); - this.packageInfos = require(res); - this.infos = require(resolve.sync(name, { basedir: baseDir })); - this.name = this.packageInfos.name; - - return true; - } catch (e) { - this.packageInfos = {}; - this.infos = {}; - return false; - } -}; - -Plugin.prototype.normalizeResource = function(resource) { - // Parse the resource path - var parsed = url.parse(resource); - - // This is a remote resource - // so we will simply link to using it's URL - if (parsed.protocol) { - return { - 'url': resource - }; - } - - // This will be copied over from disk - // and shipped with the book's build - return { 'path': this.name+'/'+resource }; -}; - -// Return resources -Plugin.prototype._getResources = function(base) { - base = base; - var book = this.infos[base]; - - // Compatibility with version 1.x.x - if (base == 'website') book = book || this.infos.book; - - // Nothing specified, fallback to default - if (!book) { - return Q({}); - } - - // Dynamic function - if(typeof book === 'function') { - // Call giving it the context of our book - return Q().then(book.bind(this.book)); - } - - // Plain data object - return Q(_.cloneDeep(book)); -}; - -// Normalize resources and return them -Plugin.prototype.getResources = function(base) { - var that = this; - - return this._getResources(base) - .then(function(resources) { - - _.each(Plugin.RESOURCES, function(resourceType) { - resources[resourceType] = (resources[resourceType] || []).map(that.normalizeResource); - }); - - return resources; - }); -}; - -// Normalize filters and return them -Plugin.prototype.getFilters = function() { - return this.infos.filters || {}; -}; - -// Normalize blocks and return them -Plugin.prototype.getBlocks = function() { - return this.infos.blocks || {}; -}; - -// Test if it's a valid plugin -Plugin.prototype.isValid = function() { - var that = this; - var isValid = ( - this.packageInfos && - this.packageInfos.name && - this.packageInfos.engines && - this.packageInfos.engines.gitbook && - version.satisfies(this.packageInfos.engines.gitbook) - ); - - // Valid hooks - _.each(this.infos.hooks, function(hook, hookName) { - if (_.contains(Plugin.HOOKS, hookName)) return; - that.book.log.warn.ln('Hook "'+hookName+'"" used by plugin "'+that.packageInfos.name+'" has been removed or is deprecated'); - }); - - return isValid; -}; - -// Normalize, validate configuration for this plugin using its schema -// Throw an error when shcema is not respected -Plugin.prototype.validateConfig = function(config) { - var that = this; - - return Q() - .then(function() { - var schema = that.packageInfos.gitbook || {}; - if (!schema) return config; - - // Normalize schema - schema.id = '/pluginsConfig.'+that.reducedName(); - schema.type = 'object'; - - // Validate and throw if invalid - var v = new jsonschema.Validator(); - var result = v.validate(config, schema, { - propertyName: 'pluginsConfig.'+that.reducedName() - }); - - // Throw error - if (result.errors.length > 0) { - throw new Error('Configuration Error: '+result.errors[0].stack); - } - - // Insert default values - var defaults = jsonSchemaDefaults(schema); - return mergeDefaults(config, defaults); - }); -}; - -// Resolve file path -Plugin.prototype.resolveFile = function(filename) { - return path.resolve(this.baseDir, filename); -}; - -// Resolve file path -Plugin.prototype.callHook = function(name, data) { - // Our book will be the context to apply - var context = this.book; - - var hookFunc = this.infos.hooks? this.infos.hooks[name] : null; - data = data || {}; - - if (!hookFunc) return Q(data); - - this.book.log.debug.ln('call hook', name); - if (!_.contains(Plugin.HOOKS, name)) this.book.log.warn.ln('hook "'+name+'" used by plugin "'+this.name+'" is deprecated, and will be removed in the coming versions'); - - return Q() - .then(function() { - return hookFunc.apply(context, [data]); - }); -}; - -// Copy plugin assets fodler -Plugin.prototype.copyAssets = function(out, base) { - var that = this; - - return this.getResources(base) - .get('assets') - .then(function(assets) { - // Assets are undefined - if(!assets) return false; - - return fs.copy( - that.resolveFile(assets), - out - ).then(_.constant(true)); - }, _.constant(false)); -}; - -// Get config from book -Plugin.prototype.getConfig = function() { - return this.book.config.get('pluginsConfig.'+this.reducedName(), {}); -}; - -// Set configuration for this plugin -Plugin.prototype.setConfig = function(values) { - return this.book.config.set('pluginsConfig.'+this.reducedName(), values); -}; - -module.exports = Plugin; diff --git a/lib/plugins/compatibility.js b/lib/plugins/compatibility.js new file mode 100644 index 000000000..7ad35a95b --- /dev/null +++ b/lib/plugins/compatibility.js @@ -0,0 +1,57 @@ +var _ = require('lodash'); +var error = require('../utils/error'); + +/* + Return the context for a plugin. + It tries to keep compatibilities with GitBook v2 +*/ +function pluginCtx(plugin) { + var book = plugin.book; + var ctx = { + config: book.config, + log: plugin.log, + + // Paths + resolve: book.resolve + }; + + // Deprecation + error.deprecateField(ctx, 'options', book.config.dump(), '"options" property is deprecated, use config.get(key) instead'); + + // Loop for template filters/blocks + error.deprecateField(ctx, 'book', ctx, '"book" property is deprecated, use "this" directly instead'); + + return ctx; +} + +// Call a function "fn" with a context of page similar to the one in GitBook v2 +function pageHook(page, fn) { + var ctx = { + type: page.type, + content: page.content, + path: page.path, + rawPath: page.rawPath + }; + + // Deprecate sections + error.deprecateField(ctx, 'sections', [ + { content: ctx.content } + ], '"sections" property is deprecated, use page.content instead'); + + return fn(ctx) + .then(function(result) { + if (!result) return undefined; + if (result.content) { + return result.content; + } + + if (result.sections) { + return _.pluck(result.sections, 'content').join('\n'); + } + }); +} + +module.exports = { + pluginCtx: pluginCtx, + pageHook: pageHook +}; diff --git a/lib/plugins/index.js b/lib/plugins/index.js new file mode 100644 index 000000000..8280542f6 --- /dev/null +++ b/lib/plugins/index.js @@ -0,0 +1,155 @@ +var _ = require('lodash'); +var path = require('path'); + +var Promise = require('../utils/promise'); +var fs = require('../utils/fs'); +var BookPlugin = require('./plugin'); +var registry = require('./registry'); +var pluginsConfig = require('../config/plugins'); + +/* +PluginsManager is an interface to work with multiple plugins at once: +- Extract assets from plugins +- Call hooks for all plugins, etc +*/ + +function PluginsManager(book) { + this.book = book; + this.log = this.book.log; + this.plugins = []; + + _.bindAll(this); +} + +// Return count of plugins loaded +PluginsManager.prototype.count = function() { + return _.size(this.plugins); +}; + +// Returns a plugin by its name +PluginsManager.prototype.get = function(name) { + return _.find(this.plugins, { + id: name + }); +}; + +// Load a plugin, or a list of plugins +PluginsManager.prototype.load = function(name) { + var that = this; + + if (_.isArray(name)) { + return Promise.serie(name, function(_name) { + return that.load(_name); + }); + } + + return Promise() + + // Initiate and load the plugin + .then(function() { + var plugin; + + if (!_.isString(name)) plugin = name; + else plugin = new BookPlugin(that.book, name); + + if (that.get(plugin.id)) { + throw new Error('Plugin "'+plugin.id+'" is already loaded'); + } + + + if (plugin.isLoaded()) return plugin; + else return plugin.load() + .thenResolve(plugin); + }) + + // Setup the plugin + .then(this._setup); +}; + +// Load all plugins from the book's configuration +PluginsManager.prototype.loadAll = function() { + var plugins = _.pluck(this.book.config.get('plugins'), 'name'); + + this.log.info.ln('loading', plugins.length, 'plugins'); + return this.load(plugins); +}; + +// Setup a plugin +// Register its filter, blocks, etc +PluginsManager.prototype._setup = function(plugin) { + this.plugins.push(plugin); +}; + +// Install all plugins for the book +PluginsManager.prototype.install = function() { + var that = this; + var plugins = _.filter(this.book.config.get('plugins'), function(plugin) { + return !pluginsConfig.isDefaultPlugin(plugin.name); + }); + + if (plugins.length == 0) { + this.log.info.ln('nothing to install!'); + return Promise(0); + } + + this.log.info.ln('installing', plugins.length, 'plugins'); + + return Promise.serie(plugins, function(plugin) { + return registry.install(that.book, plugin.name, plugin.version); + }) + .thenResolve(plugins.length); +}; + +// Call a hook on all plugins to transform an input +PluginsManager.prototype.hook = function(name, input) { + return Promise.reduce(this.plugins, function(current, plugin) { + return plugin.hook(name, current); + }, input); +}; + +// Extract all resources for a namespace +PluginsManager.prototype.getResources = function(namespace) { + return Promise.reduce(this.plugins, function(out, plugin) { + return plugin.getResources(namespace) + .then(function(pluginResources) { + _.each(BookPlugin.RESOURCES, function(resourceType) { + out[resourceType] = (out[resourceType] || []).concat(pluginResources[resourceType] || []); + }); + + return out; + }); + }, {}); +}; + +// Copy all resources for a plugin +PluginsManager.prototype.copyResources = function(namespace, outputRoot) { + return Promise.serie(this.plugins, function(plugin) { + return plugin.getResources(namespace) + .then(function(resources) { + if (!resources.assets) return; + + var input = path.resolve(plugin.root, resources.assets); + var output = path.resolve(outputRoot, plugin.npmId); + + return fs.copyDir(input, output); + }); + }); +}; + +// Get all filters and blocks +PluginsManager.prototype.getFilters = function() { + return _.reduce(this.plugins, function(out, plugin) { + var filters = plugin.getFilters(); + + return _.extend(out, filters); + }, {}); +}; +PluginsManager.prototype.getBlocks = function() { + return _.reduce(this.plugins, function(out, plugin) { + var blocks = plugin.getBlocks(); + + return _.extend(out, blocks); + }, {}); +}; + +module.exports = PluginsManager; diff --git a/lib/plugins/plugin.js b/lib/plugins/plugin.js new file mode 100644 index 000000000..f67811180 --- /dev/null +++ b/lib/plugins/plugin.js @@ -0,0 +1,300 @@ +var _ = require('lodash'); +var path = require('path'); +var url = require('url'); +var resolve = require('resolve'); +var mergeDefaults = require('merge-defaults'); +var jsonschema = require('jsonschema'); +var jsonSchemaDefaults = require('json-schema-defaults'); + +var Promise = require('../utils/promise'); +var error = require('../utils/error'); +var gitbook = require('../gitbook'); +var registry = require('./registry'); +var compatibility = require('./compatibility'); + +var HOOKS = [ + 'init', 'finish', 'finish:before', 'config', 'page', 'page:before' +]; + +var RESOURCES = ['js', 'css']; + +// Return true if an error is a "module not found" +// Wait on https://github.com/substack/node-resolve/pull/81 to be merged +function isModuleNotFound(err) { + return err.message.indexOf('Cannot find module') >= 0; +} + +function BookPlugin(book, pluginId) { + this.book = book; + this.log = this.book.log.prefix(pluginId); + + this.id = pluginId; + this.npmId = registry.npmId(pluginId); + this.root; + + this.packageInfos = undefined; + this.content = undefined; + + // Cache for resources + this._resources = {}; + + _.bindAll(this); +} + +// Return true if plugin has been loaded correctly +BookPlugin.prototype.isLoaded = function() { + return Boolean(this.packageInfos && this.content); +}; + +// Bind a function to the plugin's context +BookPlugin.prototype.bind = function(fn) { + return fn.bind(compatibility.pluginCtx(this)); +}; + +// Load this plugin +// An optional folder to search in can be passed +BookPlugin.prototype.load = function(folder) { + var that = this; + + if (this.isLoaded()) { + return Promise.reject(new Error('Plugin "' + this.id + '" is already loaded')); + } + + // Fodlers to search plugins in + var searchPaths = _.compact([ + folder, + this.book.resolve('node_modules'), + __dirname + ]); + + // Try loading plugins from different location + var p = Promise.some(searchPaths, function(baseDir) { + // Locate plugin and load pacjage.json + try { + var res = resolve.sync(that.npmId + '/package.json', { basedir: baseDir }); + + that.root = path.dirname(res); + that.packageInfos = require(res); + } catch (err) { + if (!isModuleNotFound(err)) throw err; + + that.packageInfos = undefined; + that.content = undefined; + + return false; + } + + // Load plugin JS content + try { + that.content = require(resolve.sync(that.npmId, { basedir: baseDir })); + } catch(err) { + // It's no big deal if the plugin doesn't have an "index.js" + // (For example: themes) + if (isModuleNotFound(err)) { + that.content = {}; + } else { + throw new error.PluginError(err, { + plugin: that.id + }); + } + } + + return true; + }) + + .then(that.validate) + + // Validate the configuration and update it + .then(function() { + var config = that.book.config.get(that.getConfigKey(), {}); + return that.validateConfig(config); + }) + .then(function(config) { + that.book.config.set(that.getConfigKey(), config); + }); + + this.log.info('loading plugin "' + this.id + '"... '); + return this.log.info.promise(p); +}; + +// Verify the definition of a plugin +// Also verify that the plugin accepts the current gitbook version +// This method throws erros if plugin is invalid +BookPlugin.prototype.validate = function() { + var isValid = ( + this.packageInfos && + this.packageInfos.name && + this.packageInfos.engines && + this.packageInfos.engines.gitbook + ); + + if (!this.isLoaded()) { + throw new Error('Couldn\'t locate plugin "' + this.id + '", Run \'gitbook install\' to install plugins from registry.'); + } + + if (!isValid) { + throw new Error('Invalid plugin "' + this.id + '"'); + } + + if (!gitbook.satisfies(this.packageInfos.engines.gitbook)) { + throw new Error('GitBook doesn\'t satisfy the requirements of this plugin: '+this.packageInfos.engines.gitbook); + } +}; + +// Normalize, validate configuration for this plugin using its schema +// Throw an error when shcema is not respected +BookPlugin.prototype.validateConfig = function(config) { + var that = this; + + return Promise() + .then(function() { + var schema = that.packageInfos.gitbook || {}; + if (!schema) return config; + + // Normalize schema + schema.id = '/'+that.getConfigKey(); + schema.type = 'object'; + + // Validate and throw if invalid + var v = new jsonschema.Validator(); + var result = v.validate(config, schema, { + propertyName: that.getConfigKey() + }); + + // Throw error + if (result.errors.length > 0) { + throw new error.ConfigurationError(new Error(result.errors[0].stack)); + } + + // Insert default values + var defaults = jsonSchemaDefaults(schema); + return mergeDefaults(config, defaults); + }); +}; + +// Return key for configuration +BookPlugin.prototype.getConfigKey = function() { + return 'pluginsConfig.'+this.id; +}; + +// Call a hook and returns its result +BookPlugin.prototype.hook = function(name, input) { + var that = this; + var hookFunc = this.content.hooks? this.content.hooks[name] : null; + input = input || {}; + + if (!hookFunc) return Promise(input); + + this.book.log.debug.ln('call hook "' + name + '" for plugin "' + this.id + '"'); + if (!_.contains(HOOKS, name)) { + this.book.log.warn.ln('hook "'+name+'" used by plugin "'+this.name+'" is deprecated, and will be removed in the coming versions'); + } + + return Promise() + .then(function() { + return that.bind(hookFunc)(input); + }); +}; + +// Return resources without normalization +BookPlugin.prototype._getResources = function(base) { + var that = this; + + return Promise() + .then(function() { + if (that._resources[base]) return that._resources[base]; + + base = base; + var book = that.content[base]; + + // Compatibility with version 1.x.x + if (base == 'website') book = book || that.content.book; + + // Nothing specified, fallback to default + if (!book) { + return Promise({}); + } + + // Dynamic function + if(typeof book === 'function') { + // Call giving it the context of our book + return that.bind(book)(); + } + + // Plain data object + return book; + }) + + .then(function(resources) { + that._resources[base] = resources; + return _.cloneDeep(resources); + }); +}; + +// Normalize a specific resource +BookPlugin.prototype.normalizeResource = function(resource) { + // Parse the resource path + var parsed = url.parse(resource); + + // This is a remote resource + // so we will simply link to using it's URL + if (parsed.protocol) { + return { + 'url': resource + }; + } + + // This will be copied over from disk + // and shipped with the book's build + return { 'path': this.npmId+'/'+resource }; +}; + + +// Normalize resources and return them +BookPlugin.prototype.getResources = function(base) { + var that = this; + + return this._getResources(base) + .then(function(resources) { + _.each(RESOURCES, function(resourceType) { + resources[resourceType] = _.map(resources[resourceType] || [], that.normalizeResource); + }); + + return resources; + }); +}; + +// Normalize filters and return them +BookPlugin.prototype.getFilters = function() { + var that = this; + + return _.mapValues(this.content.filters || {}, function(fn, filter) { + return function() { + var ctx = _.extend(compatibility.pluginCtx(that), this); + + return fn.apply(ctx, arguments); + }; + }); +}; + +// Normalize blocks and return them +BookPlugin.prototype.getBlocks = function() { + var that = this; + + return _.mapValues(this.content.blocks || {}, function(block, blockName) { + block = _.isFunction(block)? { process: block } : block; + + var fn = block.process; + block.process = function() { + var ctx = _.extend(compatibility.pluginCtx(that), this); + + return fn.apply(ctx, arguments); + }; + + return block; + }); +}; + +module.exports = BookPlugin; +module.exports.RESOURCES = RESOURCES; + diff --git a/lib/plugins/registry.js b/lib/plugins/registry.js new file mode 100644 index 000000000..837c9b5ed --- /dev/null +++ b/lib/plugins/registry.js @@ -0,0 +1,115 @@ +var npm = require('npm'); +var npmi = require('npmi'); +var semver = require('semver'); +var _ = require('lodash'); + +var Promise = require('../utils/promise'); +var gitbook = require('../gitbook'); + +var PLUGIN_PREFIX = 'gitbook-plugin-'; + +// Return an absolute name for the plugin (the one on NPM) +function npmId(name) { + if (name.indexOf(PLUGIN_PREFIX) === 0) return name; + return [PLUGIN_PREFIX, name].join(''); +} + +// Return a plugin ID 9the one on GitBook +function pluginId(name) { + return name.replace(PLUGIN_PREFIX, ''); +} + +// Validate an NPM plugin ID +function validateId(name) { + return name.indexOf(PLUGIN_PREFIX) === 0; +} + +// Initialize NPM for operations +var initNPM = _.memoize(function() { + return Promise.nfcall(npm.load, { + silent: true, + loglevel: 'silent' + }); +}); + +// Link a plugin for use in a specific book +function linkPlugin(book, pluginPath) { + book.log('linking', pluginPath); +} + +// Resolve the latest version for a plugin +function resolveVersion(plugin) { + var npnName = npmId(plugin); + + return initNPM() + .then(function() { + return Promise.nfcall(npm.commands.view, [npnName+'@*', 'engines'], true); + }) + .then(function(versions) { + return _.chain(versions) + .pairs() + .map(function(v) { + return { + version: v[0], + gitbook: (v[1].engines || {}).gitbook + }; + }) + .filter(function(v) { + return v.gitbook && gitbook.satisfies(v.gitbook); + }) + .sort(function(v1, v2) { + return semver.lt(v1.version, v2.version)? 1 : -1; + }) + .pluck('version') + .first() + .value(); + }); +} + + +// Install a plugin in a book +function installPlugin(book, plugin, version) { + book.log.info.ln('installing plugin', plugin); + + var npnName = npmId(plugin); + + return Promise() + .then(function() { + if (version) return version; + + book.log.info.ln('No version specified, resolve plugin "' + plugin + '"'); + return resolveVersion(plugin); + }) + + // Install the plugin with the resolved version + .then(function(version) { + if (!version) { + throw new Error('Found no satisfactory version for plugin "' + plugin + '"'); + } + + book.log.info.ln('install plugin' + plugin +'" from npm ('+npnName+') with version', version); + return Promise.nfcall(npmi, { + 'name': npnName, + 'version': version, + 'path': book.root, + 'npmLoad': { + 'loglevel': 'silent', + 'loaded': true, + 'prefix': book.root + } + }); + }) + .then(function() { + book.log.info.ok('plugin "' + plugin + '" installed with success'); + }); +} + +module.exports = { + npmId: npmId, + pluginId: pluginId, + validateId: validateId, + + resolve: resolveVersion, + link: linkPlugin, + install: installPlugin +}; diff --git a/lib/pluginslist.js b/lib/pluginslist.js deleted file mode 100644 index 290cd354a..000000000 --- a/lib/pluginslist.js +++ /dev/null @@ -1,230 +0,0 @@ -var _ = require('lodash'); -var Q = require('q'); -var npmi = require('npmi'); -var npm = require('npm'); -var semver = require('semver'); - -var Plugin = require('./plugin'); -var version = require('./version'); - -var initNPM = _.memoize(function() { - return Q.nfcall(npm.load, { silent: true, loglevel: 'silent' }); -}); - - -var PluginsList = function(book, plugins) { - this.book = book; - this.log = this.book.log; - - // List of Plugin objects - this.list = []; - - // List of names of failed plugins - this.failed = []; - - // Namespaces - this.namespaces = _.chain(['website', 'ebook']) - .map(function(namespace) { - return [ - namespace, - { - html: {}, - resources: _.chain(Plugin.RESOURCES) - .map(function(type) { - return [type, []]; - }) - .object() - .value() - } - ]; - }) - .object() - .value(); - - // Bind methods - _.bindAll(this); - - if (plugins) this.load(plugins); -}; - -// return count of plugins -PluginsList.prototype.count = function() { - return this.list.length; -}; - -// Add and load a plugin -PluginsList.prototype.load = function(plugin) { - var that = this; - - if (_.isArray(plugin)) { - return _.reduce(plugin, function(prev, p) { - return prev.then(function() { - return that.load(p); - }); - }, Q()); - } - if (_.isObject(plugin) && !(plugin instanceof Plugin)) plugin = plugin.name; - if (_.isString(plugin)) plugin = new Plugin(this.book, plugin); - - that.log.info('load plugin', plugin.name, '....'); - if (!plugin.isValid()) { - that.log.info.fail(); - that.failed.push(plugin.name); - return Q(); - } else { - that.log.info.ok(); - - // Push in the list - that.list.push(plugin); - } - - return Q() - - // Validate and normalize configuration - .then(function() { - var config = plugin.getConfig(); - return plugin.validateConfig(config); - }) - .then(function(config) { - // Update configuration - plugin.setConfig(config); - - // Extract filters - that.book.template.addFilters(plugin.getFilters()); - - // Extract blocks - that.book.template.addBlocks(plugin.getBlocks()); - - return _.reduce(_.keys(that.namespaces), function(prev, namespaceName) { - return prev.then(function() { - return plugin.getResources(namespaceName) - .then(function(plResources) { - var namespace = that.namespaces[namespaceName]; - - // Extract js and css - _.each(Plugin.RESOURCES, function(resourceType) { - namespace.resources[resourceType] = (namespace.resources[resourceType] || []).concat(plResources[resourceType] || []); - }); - - // Map of html resources by name added by each plugin - _.each(plResources.html || {}, function(value, tag) { - // Turn into function if not one already - if (!_.isFunction(value)) value = _.constant(value); - - namespace.html[tag] = namespace.html[tag] || []; - namespace.html[tag].push(value); - }); - }); - }); - }, Q()); - }); -}; - -// Call a hook -PluginsList.prototype.hook = function(name, data) { - return _.reduce(this.list, function(prev, plugin) { - return prev.then(function(ret) { - return plugin.callHook(name, ret); - }); - }, Q(data)); -}; - -// Return a template from a plugin -PluginsList.prototype.template = function(name) { - var withTpl = _.find(this.list, function(plugin) { - return ( - plugin.infos.templates && - plugin.infos.templates[name] - ); - }); - - if (!withTpl) return null; - return withTpl.resolveFile(withTpl.infos.templates[name]); -}; - -// Return an html snippet -PluginsList.prototype.html = function(namespace, tag, context, options) { - var htmlSnippets = this.namespaces[namespace].html[tag]; - return _.map(htmlSnippets || [], function(code) { - return code.call(context, options); - }).join('\n'); -}; - -// Return a resources map for a namespace -PluginsList.prototype.resources = function(namespace) { - return this.namespaces[namespace].resources; -}; - -// Install plugins from a book -PluginsList.prototype.install = function() { - var that = this; - - // Remove defaults (no need to install) - var plugins = _.reject(that.book.options.plugins, { - isDefault: true - }); - - // Install plugins one by one - that.book.log.info.ln(plugins.length+' plugins to install'); - return _.reduce(plugins, function(prev, plugin) { - return prev.then(function() { - var fullname = 'gitbook-plugin-'+plugin.name; - - return Q() - - // Resolve version if needed - .then(function() { - if (plugin.version) return plugin.version; - - that.book.log.info.ln('No version specified, resolve plugin', plugin.name); - return initNPM() - .then(function() { - return Q.nfcall(npm.commands.view, [fullname+'@*', 'engines'], true); - }) - .then(function(versions) { - return _.chain(versions) - .pairs() - .map(function(v) { - return { - version: v[0], - gitbook: (v[1].engines || {}).gitbook - }; - }) - .filter(function(v) { - return v.gitbook && version.satisfies(v.gitbook); - }) - .sort(function(v1, v2) { - return semver.lt(v1.version, v2.version)? 1 : -1; - }) - .pluck('version') - .first() - .value(); - }); - }) - - // Install the plugin with the resolved version - .then(function(version) { - if (!version) { - throw 'Found no satisfactory version for plugin '+plugin.name; - } - - that.book.log.info.ln('install plugin', plugin.name, 'from npm ('+fullname+') with version', version); - return Q.nfcall(npmi, { - 'name': fullname, - 'version': version, - 'path': that.book.root, - 'npmLoad': { - 'loglevel': 'silent', - 'loaded': true, - 'prefix': that.book.root - } - }); - }) - .then(function() { - that.book.log.info.ok('plugin', plugin.name, 'installed with success'); - }); - }); - }, Q()); -}; - -module.exports = PluginsList; diff --git a/lib/blocks.js b/lib/template/blocks.js similarity index 58% rename from lib/blocks.js rename to lib/template/blocks.js index 92097a707..a079cdea0 100644 --- a/lib/blocks.js +++ b/lib/template/blocks.js @@ -6,6 +6,11 @@ module.exports = { html: _.identity, // Highlight a code block - // This block can be extent by plugins - code: _.identity + // This block can be replaced by plugins + code: function(blk) { + return { + html: false, + body: blk.body + }; + } }; diff --git a/lib/template/filters.js b/lib/template/filters.js new file mode 100644 index 000000000..ac68b82ac --- /dev/null +++ b/lib/template/filters.js @@ -0,0 +1,15 @@ +var moment = require('moment'); + + +module.exports = { + // Format a date + // ex: 'MMMM Do YYYY, h:mm:ss a + date: function(time, format) { + return moment(time).format(format); + }, + + // Relative Time + dateFromNow: function(time) { + return moment(time).fromNow(); + } +}; diff --git a/lib/template.js b/lib/template/index.js similarity index 65% rename from lib/template.js rename to lib/template/index.js index dac1201ed..fc7603dc0 100644 --- a/lib/template.js +++ b/lib/template/index.js @@ -1,47 +1,42 @@ var _ = require('lodash'); -var Q = require('q'); var path = require('path'); var nunjucks = require('nunjucks'); -var parsers = require('gitbook-parsers'); var escapeStringRegexp = require('escape-string-regexp'); -var batch = require('./utils/batch'); -var pkg = require('../package.json'); +var Promise = require('../utils/promise'); +var error = require('../utils/error'); +var parsers = require('../parsers'); var defaultBlocks = require('./blocks'); -var BookLoader = require('./conrefs_loader'); +var defaultFilters = require('./filters'); +var Loader = require('./loader'); -// Normalize result from a block +// Return extension name for a specific block +function blockExtName(name) { + return 'Block'+name+'Extension'; +} + +// Normalize the result of block process function function normBlockResult(blk) { if (_.isString(blk)) blk = { body: blk }; return blk; } - -var TemplateEngine = function(book) { - var that = this; - - this.book = book; +function TemplateEngine(output) { + this.output = output; + this.book = output.book; this.log = this.book.log; - // Template loader - this.loader = new BookLoader(book, { - // Replace shortcuts in imported files - interpolate: function(filepath, source) { - var parser = parsers.get(path.extname(filepath)); - var type = parser? parser.name : null; + // Create file loader + this.loader = new Loader(this); - return that.applyShortcuts(type, source); - } - }); - - // Nunjucks env + // Create nunjucks instance this.env = new nunjucks.Environment( this.loader, { - // Escaping is done after by the markdown parser + // Escaping is done after by the asciidoc/markdown parser autoescape: false, - // Tags + // Syntax tags: { blockStart: '{%', blockEnd: '%}', @@ -65,79 +60,48 @@ var TemplateEngine = function(book) { // Bind methods _.bindAll(this); - // Add default blocks + // Add default blocks and filters this.addBlocks(defaultBlocks); -}; - -// Process the result of block in a context -TemplateEngine.prototype.processBlock = function(blk) { - blk = _.defaults(blk, { - parse: false, - post: undefined - }); - blk.id = _.uniqueId('blk'); - - var toAdd = (!blk.parse) || (blk.post !== undefined); - - // Add to global map - if (toAdd) this.blockBodies[blk.id] = blk; - - // Parsable block, just return it - if (blk.parse) { - return blk.body; - } - - // Return it as a position marker - return '@%@'+blk.id+'@%@'; -}; - -// Replace position markers of blocks by body after processing -// This is done to avoid that markdown/asciidoc processer parse the block content -TemplateEngine.prototype.replaceBlocks = function(content) { - var that = this; - - return content.replace(/\@\%\@([\s\S]+?)\@\%\@/g, function(match, key) { - var blk = that.blockBodies[key]; - if (!blk) return match; - - var body = blk.body; - - return body; - }); -}; + this.addFilters(defaultFilters); +} // Bind a function to a context +// Filters and blocks are binded to this context TemplateEngine.prototype.bindContext = function(func) { - var that = this; - - return function() { - var ctx = { - ctx: this.ctx, - book: that.book, - generator: that.book.options.generator - }; - - return func.apply(ctx, arguments); + var ctx = { + ctx: this.ctx, + output: this.output, + generator: this.output.name }; + + return _.bind(func, ctx); }; -// Add filter +// Interpolate a string content to replace shortcuts according to the filetype +TemplateEngine.prototype.interpolate = function(filepath, source) { + var parser = parsers.get(path.extname(filepath)); + var type = parser? parser.name : null; + + return this.applyShortcuts(type, source); +}; + +// Add a new custom filter TemplateEngine.prototype.addFilter = function(filterName, func) { try { this.env.getFilter(filterName); - this.log.warn.ln('conflict in filters, \''+filterName+'\' is already set'); + this.log.error.ln('conflict in filters, "'+filterName+'" is already set'); return false; } catch(e) { // Filter doesn't exist } - this.log.debug.ln('add filter \''+filterName+'\''); + this.log.debug.ln('add filter "'+filterName+'"'); this.env.addFilter(filterName, this.bindContext(function() { var ctx = this; var args = Array.prototype.slice.apply(arguments); var callback = _.last(args); - Q() + Promise() .then(function() { return func.apply(ctx, args.slice(0, -1)); }) @@ -146,29 +110,24 @@ TemplateEngine.prototype.addFilter = function(filterName, func) { return true; }; -// Add multiple filters +// Add multiple filters at once TemplateEngine.prototype.addFilters = function(filters) { _.each(filters, function(filter, name) { this.addFilter(name, filter); }, this); }; -// Return nunjucks extension name of a block -TemplateEngine.prototype.blockExtName = function(name) { - return 'Block'+name+'Extension'; -}; - -// Test if a block is defined +// Return true if a block is defined TemplateEngine.prototype.hasBlock = function(name) { - return this.env.hasExtension(this.blockExtName(name)); + return this.env.hasExtension(blockExtName(name)); }; -// Remove a block +// Remove/Disable a block TemplateEngine.prototype.removeBlock = function(name) { if (!this.hasBlock(name)) return; // Remove nunjucks extension - this.env.removeExtension(this.blockExtName(name)); + this.env.removeExtension(blockExtName(name)); // Cleanup shortcuts this.shortcuts = _.reject(this.shortcuts, { @@ -177,22 +136,27 @@ TemplateEngine.prototype.removeBlock = function(name) { }; // Add a block +// Using the extensions of nunjucks: https://mozilla.github.io/nunjucks/api.html#addextension TemplateEngine.prototype.addBlock = function(name, block) { var that = this, Ext, extName; + // Block can be a simple function if (_.isFunction(block)) block = { process: block }; block = _.defaults(block || {}, { shortcuts: [], end: 'end'+name, - process: _.identity, blocks: [] }); - extName = this.blockExtName(name); + extName = blockExtName(name); + + if (!block.process) { + throw new Error('Invalid block "' + name + '", it should have a "process" method'); + } if (this.hasBlock(name) && !defaultBlocks[name]) { - this.log.warn.ln('conflict in blocks, \''+name+'\' is already defined'); + this.log.warn.ln('conflict in blocks, "'+name+'" is already defined'); } // Cleanup previous block @@ -215,7 +179,7 @@ TemplateEngine.prototype.addBlock = function(name, block) { var args = parser.parseSignature(null, true); parser.advanceAfterBlockEnd(tok.value); - while (1) { + do { // Read body var currentBody = parser.parseUntilBlocks.apply(parser, allBlocks); @@ -232,14 +196,14 @@ TemplateEngine.prototype.addBlock = function(name, block) { // Read new block lastBlockName = parser.peekToken().value; - if (lastBlockName == block.end) { - break; - } // Parse signature and move to the end of the block - lastBlockArgs = parser.parseSignature(null, true); - parser.advanceAfterBlockEnd(lastBlockName); - } + if (lastBlockName != block.end) { + lastBlockArgs = parser.parseSignature(null, true); + parser.advanceAfterBlockEnd(lastBlockName); + } + } while (lastBlockName != block.end) + parser.advanceAfterBlockEnd(); var bodies = [body]; @@ -282,7 +246,7 @@ TemplateEngine.prototype.addBlock = function(name, block) { }; }); - Q() + Promise() .then(function() { return that.applyBlock(name, { body: body(), @@ -292,7 +256,7 @@ TemplateEngine.prototype.addBlock = function(name, block) { }, context); }) - // process the block returned + // Process the block returned .then(that.processBlock) .nodeify(callback); }; @@ -301,10 +265,13 @@ TemplateEngine.prototype.addBlock = function(name, block) { // Add the Extension this.env.addExtension(extName, new Ext()); - // Add shortcuts - if (!_.isArray(block.shortcuts)) block.shortcuts = [block.shortcuts]; + // Add shortcuts if any + if (!_.isArray(block.shortcuts)) { + block.shortcuts = [block.shortcuts]; + } + _.each(block.shortcuts, function(shortcut) { - this.log.debug.ln('add template shortcut from \''+shortcut.start+'\' to block \''+name+'\' for parsers ', shortcut.parsers); + this.log.debug.ln('add template shortcut from "'+shortcut.start+'" to block "'+name+'" for parsers ', shortcut.parsers); this.shortcuts.push({ block: name, parsers: shortcut.parsers, @@ -318,7 +285,7 @@ TemplateEngine.prototype.addBlock = function(name, block) { }, this); }; -// Add multiple blocks +// Add multiple blocks at once TemplateEngine.prototype.addBlocks = function(blocks) { _.each(blocks, function(block, name) { this.addBlock(name, block); @@ -331,7 +298,7 @@ TemplateEngine.prototype.applyBlock = function(name, blk, ctx) { var func, block, r; block = this.blocks[name]; - if (!block) throw new Error('Block not found \''+name+'\''); + if (!block) throw new Error('Block not found "'+name+'"'); if (_.isString(blk)) { blk = { body: blk @@ -348,13 +315,63 @@ TemplateEngine.prototype.applyBlock = function(name, blk, ctx) { func = this.bindContext(block.process); r = func.call(ctx || {}, blk); - if (Q.isPromise(r)) return r.then(normBlockResult); + if (Promise.isPromise(r)) return r.then(normBlockResult); else return normBlockResult(r); }; +// Process the result of block in a context +TemplateEngine.prototype.processBlock = function(blk) { + blk = _.defaults(blk, { + parse: false, + post: undefined + }); + blk.id = _.uniqueId('blk'); + + var toAdd = (!blk.parse) || (blk.post !== undefined); + + // Add to global map + if (toAdd) this.blockBodies[blk.id] = blk; + + // Parsable block, just return it + if (blk.parse) { + return blk.body; + } + + // Return it as a position marker + return '@%@'+blk.id+'@%@'; +}; + +// Render a string (without post processing) +TemplateEngine.prototype.render = function(content, context, options) { + options = _.defaults(options || {}, { + path: null + }); + var filename = options.path; + + // Setup path and type + if (options.path) { + options.path = this.book.resolve(options.path); + } + + // Replace shortcuts + content = this.applyShortcuts(options.type, content); + + return Promise.nfcall(this.env.renderString.bind(this.env), content, context, options) + .fail(function(err) { + throw error.TemplateError(err, { + filename: filename || '' + }); + }); +}; + +// Render a string with post-processing +TemplateEngine.prototype.renderString = function(content, context, options) { + return this.render(content, context, options) + .then(this.postProcess); +}; + // Apply a shortcut to a string -TemplateEngine.prototype._applyShortcut = function(parser, content, shortcut) { - if (!_.contains(shortcut.parsers, parser)) return content; +TemplateEngine.prototype.applyShortcut = function(content, shortcut) { var regex = new RegExp( escapeStringRegexp(shortcut.start) + '([\\s\\S]*?[^\\$])' + escapeStringRegexp(shortcut.end), 'g' @@ -364,100 +381,48 @@ TemplateEngine.prototype._applyShortcut = function(parser, content, shortcut) { }); }; -// Apply all shortcuts to some template string +// Replace position markers of blocks by body after processing +// This is done to avoid that markdown/asciidoc processer parse the block content +TemplateEngine.prototype.replaceBlocks = function(content) { + var that = this; + + return content.replace(/\@\%\@([\s\S]+?)\@\%\@/g, function(match, key) { + var blk = that.blockBodies[key]; + if (!blk) return match; + + var body = blk.body; + + return body; + }); +}; + +// Apply all shortcuts to a template TemplateEngine.prototype.applyShortcuts = function(type, content) { - return _.reduce(this.shortcuts, _.partial(this._applyShortcut.bind(this), type), content); + return _.chain(this.shortcuts) + .filter(function(shortcut) { + return _.contains(shortcut.parsers, type); + }) + .reduce(this.applyShortcut, content) + .value(); }; -// Render a string from the book -TemplateEngine.prototype.renderString = function(content, context, options) { - context = _.extend({}, context, { - // Variables from book.json - book: this.book.options.variables, - - // Complete book.json - config: this.book.options, - - // infos about gitbook - gitbook: { - version: pkg.version, - generator: this.book.options.generator - } - }); - options = _.defaults(options || {}, { - path: null, - type: null - }); - if (options.path) options.path = this.book.resolve(options.path); - if (!options.type && options.path) { - var parser = parsers.get(path.extname(options.path)); - options.type = parser? parser.name : null; - } - - // Replace shortcuts - content = this.applyShortcuts(options.type, content); - - return Q.nfcall(this.env.renderString.bind(this.env), content, context, options) - .fail(function(err) { - if (_.isString(err)) err = new Error(err); - err.message = err.message.replace(/^Error: /, ''); - - throw err; - }); -}; - -// Render a file from the book -TemplateEngine.prototype.renderFile = function(filename) { - var that = this; - - return that.book.readFile(filename) - .then(function(content) { - return that.renderString(content, {}, { - path: filename - }); - }); -}; - -// Render a page from the book -TemplateEngine.prototype.renderPage = function(page) { - var that = this; - - return that.book.statFile(page.path) - .then(function(stat) { - var context = { - // infos about the file - file: { - path: page.path, - mtime: stat.mtime - } - }; - - return that.renderString(page.content, context, { - path: page.path, - type: page.type - }); - }); -}; // Post process content TemplateEngine.prototype.postProcess = function(content) { var that = this; - return Q(content) + return Promise(content) .then(that.replaceBlocks) .then(function(_content) { - return batch.execEach(that.blockBodies, { - max: 20, - fn: function(blk, blkId) { - return Q() - .then(function() { - if (!blk.post) return Q(); - return blk.post(); - }) - .then(function() { - delete that.blockBodies[blkId]; - }); - } + return Promise.serie(that.blockBodies, function(blk, blkId) { + return Promise() + .then(function() { + if (!blk.post) return; + return blk.post(); + }) + .then(function() { + delete that.blockBodies[blkId]; + }); }) .thenResolve(_content); }); diff --git a/lib/template/loader.js b/lib/template/loader.js new file mode 100644 index 000000000..23d179a83 --- /dev/null +++ b/lib/template/loader.js @@ -0,0 +1,42 @@ +var nunjucks = require('nunjucks'); +var location = require('../utils/location'); + +/* +Simple nunjucks loader which is passing the reponsability to the Output +*/ + +var Loader = nunjucks.Loader.extend({ + async: true, + + init: function(engine, opts) { + this.engine = engine; + this.output = engine.output; + }, + + getSource: function(sourceURL, callback) { + var that = this; + + this.output.onGetTemplate(sourceURL) + .then(function(out) { + // We disable cache since content is modified (shortcuts, ...) + out.noCache = true; + + // Transform template before runnign it + out.source = that.engine.interpolate(out.path, out.source); + + return out; + }) + .nodeify(callback); + }, + + resolve: function(from, to) { + return this.output.onResolveTemplate(from, to); + }, + + // Handle all files as relative, so that nunjucks pass responsability to 'resolve' + isRelative: function(filename) { + return location.isRelative(filename); + } +}); + +module.exports = Loader; diff --git a/lib/utils/batch.js b/lib/utils/batch.js deleted file mode 100644 index 90697664f..000000000 --- a/lib/utils/batch.js +++ /dev/null @@ -1,52 +0,0 @@ -var Q = require("q"); -var _ = require("lodash"); - -// Execute a method for all element -function execEach(items, options) { - if (_.size(items) === 0) return Q(); - var concurrents = 0, d = Q.defer(), pending = []; - - options = _.defaults(options || {}, { - max: 100, - fn: function() {} - }); - - - function startItem(item, i) { - if (concurrents >= options.max) { - pending.push([item, i]); - return; - } - - concurrents++; - Q() - .then(function() { - return options.fn(item, i); - }) - .then(function() { - concurrents--; - - // Next pending - var next = pending.shift(); - - if (concurrents === 0 && !next) { - d.resolve(); - } else if (next) { - startItem.apply(null, next); - } - }) - .fail(function(err) { - pending = []; - d.reject(err); - }); - } - - _.each(items, startItem); - - return d.promise; -} - -module.exports = { - execEach: execEach -}; - diff --git a/lib/utils/command.js b/lib/utils/command.js new file mode 100644 index 000000000..de240df03 --- /dev/null +++ b/lib/utils/command.js @@ -0,0 +1,80 @@ +var _ = require('lodash'); +var childProcess = require('child_process'); +var spawn = require("spawn-cmd").spawn; +var Promise = require('./promise'); + +// Execute a command +function exec(command, options) { + var d = Promise.defer(); + + var child = childProcess.exec(command, options, function(err, stdout, stderr) { + if (!err) { + return d.resolve(); + } + + err.message = stdout.toString('utf8') + stderr.toString('utf8'); + d.reject(err); + }); + + child.stdout.on('data', function (data) { + d.notify(data); + }); + + child.stderr.on('data', function (data) { + d.notify(data); + }); + + return d.promise; +} + +// Spawn an executable +function spawnCmd(command, args, options) { + var d = Promise.defer(); + var child = spawn(command, args, options); + + child.on('error', function(error) { + return d.reject(error); + }); + + child.stdout.on('data', function (data) { + d.notify(data); + }); + + child.stderr.on('data', function (data) { + d.notify(data); + }); + + child.on('close', function(code) { + if (code === 0) { + d.resolve(); + } else { + d.reject(new Error('Error with command "'+command+'"')); + } + }); + + return d.promise; +} + +// Transform an option object to a command line string +function escapeShellArg(s) { + s = s.replace(/"/g, '\\"'); + return '"' + s + '"'; +} + +function optionsToShellArgs(options) { + return _.chain(options) + .map(function(value, key) { + if (value === null || value === undefined || value === false) return null; + if (value === true) return key; + return key + '=' + escapeShellArg(value); + }) + .compact() + .value() + .join(' '); +} + +module.exports = { + exec: exec, + spawn: spawnCmd, + optionsToShellArgs: optionsToShellArgs +}; diff --git a/lib/utils/error.js b/lib/utils/error.js new file mode 100644 index 000000000..27fa59d59 --- /dev/null +++ b/lib/utils/error.js @@ -0,0 +1,105 @@ +var _ = require('lodash'); +var TypedError = require('error/typed'); +var WrappedError = require('error/wrapped'); +var deprecated = require('deprecated'); + +var Logger = require('./logger'); + +var log = new Logger(); + +// Enforce as an Error object, and cleanup message +function enforce(err) { + if (_.isString(err)) err = new Error(err); + err.message = err.message.replace(/^Error: /, ''); + + return err; +} + +// Random error wrappers during parsing/generation +var ParsingError = WrappedError({ + message: 'Parsing Error: {origMessage}', + type: 'parse' +}); +var OutputError = WrappedError({ + message: 'Output Error: {origMessage}', + type: 'generate' +}); + +// A file does not exists +var FileNotFoundError = TypedError({ + type: 'file.not-found', + message: 'No "{filename}" file (or is ignored)', + filename: null +}); + +// A file is outside the scope +var FileOutOfScopeError = TypedError({ + type: 'file.out-of-scope', + message: '"{filename}" not in "{root}"', + filename: null, + root: null, + code: 'EACCESS' +}); + +// A file is outside the scope +var RequireInstallError = TypedError({ + type: 'install.required', + message: '"{cmd}" is not installed.\n{install}', + cmd: null, + code: 'ENOENT', + install: '' +}); + +// Error for nunjucks templates +var TemplateError = WrappedError({ + message: 'Error compiling template "{filename}": {origMessage}', + type: 'template', + filename: null +}); + +// Error for nunjucks templates +var PluginError = WrappedError({ + message: 'Error with plugin "{plugin}": {origMessage}', + type: 'plugin', + plugin: null +}); + +// Error with the book's configuration +var ConfigurationError = WrappedError({ + message: 'Error with book\'s configuration: {origMessage}', + type: 'configuration' +}); + +// Error during ebook generation +var EbookError = WrappedError({ + message: 'Error during ebook generation: {origMessage}\n{stdout}', + type: 'ebook', + stdout: '' +}); + +// Deprecate methods/fields +function deprecateMethod(fn, msg) { + return deprecated.method(msg, log.warn.ln, fn); +} +function deprecateField(obj, prop, value, msg) { + return deprecated.field(msg, log.warn.ln, obj, prop, value); +} + +module.exports = { + enforce: enforce, + + ParsingError: ParsingError, + OutputError: OutputError, + RequireInstallError: RequireInstallError, + + FileNotFoundError: FileNotFoundError, + FileOutOfScopeError: FileOutOfScopeError, + + TemplateError: TemplateError, + PluginError: PluginError, + ConfigurationError: ConfigurationError, + EbookError: EbookError, + + deprecateMethod: deprecateMethod, + deprecateField: deprecateField +}; diff --git a/lib/utils/fs.js b/lib/utils/fs.js index b82701f98..42fd3c612 100644 --- a/lib/utils/fs.js +++ b/lib/utils/fs.js @@ -1,71 +1,36 @@ -var _ = require('lodash'); -var Q = require('q'); -var tmp = require('tmp'); -var path = require('path'); var fs = require('graceful-fs'); -var fsExtra = require('fs-extra'); -var Ignore = require('fstream-ignore'); +var mkdirp = require('mkdirp'); +var destroy = require('destroy'); +var rmdir = require('rmdir'); +var tmp = require('tmp'); +var request = require('request'); +var path = require('path'); +var cp = require('cp'); +var cpr = require('cpr'); -var fsUtils = { - tmp: { - file: function(opt) { - return Q.nfcall(tmp.file.bind(tmp), opt).get(0); - }, - dir: function() { - return Q.nfcall(tmp.dir.bind(tmp)).get(0); - } - }, - list: listFiles, - stat: Q.denodeify(fs.stat), - readdir: Q.denodeify(fs.readdir), - readFile: Q.denodeify(fs.readFile), - writeFile: writeFile, - writeStream: writeStream, - mkdirp: Q.denodeify(fsExtra.mkdirp), - copy: Q.denodeify(fsExtra.copy), - remove: Q.denodeify(fsExtra.remove), - symlink: Q.denodeify(fsExtra.symlink), - exists: function(path) { - var d = Q.defer(); - fs.exists(path, d.resolve); - return d.promise; - }, - findFile: findFile, - existsSync: fs.existsSync.bind(fs), - readFileSync: fs.readFileSync.bind(fs), - clean: cleanFolder, - getUniqueFilename: getUniqueFilename -}; - -// Write a file -function writeFile(filename, data, options) { - var d = Q.defer(); - - try { - fs.writeFileSync(filename, data, options); - } catch(err) { - d.reject(err); - } - d.resolve(); - - - return d.promise; -} +var Promise = require('./promise'); // Write a stream to a file function writeStream(filename, st) { - var d = Q.defer(); + var d = Promise.defer(); var wstream = fs.createWriteStream(filename); + var cleanup = function() { + destroy(wstream); + wstream.removeAllListeners(); + }; wstream.on('finish', function () { + cleanup(); d.resolve(); }); wstream.on('error', function (err) { + cleanup(); d.reject(err); }); st.on('error', function(err) { + cleanup(); d.reject(err); }); @@ -74,120 +39,80 @@ function writeStream(filename, st) { return d.promise; } -// Find a filename available -function getUniqueFilename(base, filename) { - if (!filename) { - filename = base; - base = '/'; - } +// Return a promise resolved with a boolean +function fileExists(filename) { + var d = Promise.defer(); - filename = path.resolve(base, filename); + fs.exists(filename, function(exists) { + d.resolve(exists); + }); + + return d.promise; +} + +// Generate temporary file +function genTmpFile(opts) { + return Promise.nfcall(tmp.file, opts) + .get(0); +} + +// Generate temporary dir +function genTmpDir(opts) { + return Promise.nfcall(tmp.dir, opts) + .get(0); +} + +// Download an image +function download(uri, dest) { + return writeStream(dest, request(uri)); +} + +// Find a filename available in a folder +function uniqueFilename(base, filename) { var ext = path.extname(filename); + filename = path.resolve(base, filename); filename = path.join(path.dirname(filename), path.basename(filename, ext)); var _filename = filename+ext; var i = 0; while (fs.existsSync(filename)) { - _filename = filename+'_'+i+ext; + _filename = filename + '_' + i + ext; i = i + 1; } - return path.relative(base, _filename); + return Promise(path.relative(base, _filename)); } - -// List files in a directory -function listFiles(root, options) { - options = _.defaults(options || {}, { - ignoreFiles: [], - ignoreRules: [] - }); - - var d = Q.defer(); - - // Our list of files - var files = []; - - var ig = Ignore({ - path: root, - ignoreFiles: options.ignoreFiles - }); - - // Add extra rules to ignore common folders - ig.addIgnoreRules(options.ignoreRules, '__custom_stuff'); - - // Push each file to our list - ig.on('child', function (c) { - files.push( - c.path.substr(c.root.path.length + 1) + (c.props.Directory === true ? '/' : '') - ); - }); - - ig.on('end', function() { - // Normalize paths on Windows - if(process.platform === 'win32') { - return d.resolve(files.map(function(file) { - return file.replace(/\\/g, '/'); - })); - } - - // Simply return paths otherwise - return d.resolve(files); - }); - - ig.on('error', d.reject); - - return d.promise; +// Create all required folder to create a file +function ensureFile(filename) { + var base = path.dirname(filename); + return Promise.nfcall(mkdirp, base); } -// Clean a folder without removing .git and .svn -// Creates it if non existant -function cleanFolder(root) { - if (!fs.existsSync(root)) return fsUtils.mkdirp(root); - - return listFiles(root, { - ignoreFiles: [], - ignoreRules: [ - // Skip Git and SVN stuff - '.git/', - '.svn/' - ] - }) - .then(function(files) { - var d = Q.defer(); - - _.reduce(files, function(prev, file, i) { - return prev.then(function() { - var _file = path.join(root, file); - - d.notify({ - i: i+1, - count: files.length, - file: _file - }); - return fsUtils.remove(_file); - }); - }, Q()) - .then(function() { - d.resolve(); - }, function(err) { - d.reject(err); - }); - - return d.promise; +// Remove a folder +function rmDir(base) { + return Promise.nfcall(rmdir, base, { + fs: fs }); } -// Find a file in a folder (case incensitive) -// Return the real filename -function findFile(root, filename) { - return Q.nfcall(fs.readdir, root) - .then(function(files) { - return _.find(files, function(file) { - return (file.toLowerCase() == filename.toLowerCase()); - }); - }); -} - -module.exports = fsUtils; +module.exports = { + exists: fileExists, + existsSync: fs.existsSync, + mkdirp: Promise.nfbind(mkdirp), + readFile: Promise.nfbind(fs.readFile), + writeFile: Promise.nfbind(fs.writeFile), + stat: Promise.nfbind(fs.stat), + statSync: fs.statSync, + readdir: Promise.nfbind(fs.readdir), + writeStream: writeStream, + copy: Promise.nfbind(cp), + copyDir: Promise.nfbind(cpr), + tmpFile: genTmpFile, + tmpDir: genTmpDir, + download: download, + uniqueFilename: uniqueFilename, + ensure: ensureFile, + rmDir: rmDir +}; diff --git a/lib/utils/git.js b/lib/utils/git.js index 72c881826..52b109666 100644 --- a/lib/utils/git.js +++ b/lib/utils/git.js @@ -1,40 +1,118 @@ -var Q = require('q'); var _ = require('lodash'); var path = require('path'); var crc = require('crc'); -var exec = Q.denodeify(require('child_process').exec); var URI = require('urijs'); -var pathUtil = require('./path'); +var pathUtil = require('./path'); +var Promise = require('./promise'); +var command = require('./command'); var fs = require('./fs'); var GIT_PREFIX = 'git+'; -var GIT_TMP = null; +function Git() { + this.tmpDir; + this.cloned = {}; +} + +// Return an unique ID for a combinaison host/ref +Git.prototype.repoID = function(host, ref) { + return crc.crc32(host+'#'+(ref || '')).toString(16); +}; + +// Allocate a temporary folder for cloning repos in it +Git.prototype.allocateDir = function() { + var that = this; + + if (this.tmpDir) return Promise(); + + return fs.tmpDir() + .then(function(dir) { + that.tmpDir = dir; + }); +}; + +// Clone a git repository if non existant +Git.prototype.clone = function(host, ref) { + var that = this; + + return this.allocateDir() + + // Return or clone the git repo + .then(function() { + // Unique ID for repo/ref combinaison + var repoId = that.repoID(host, ref); + + // Absolute path to the folder + var repoPath = path.join(that.tmpDir, repoId); + + if (that.cloned[repoId]) return repoPath; + + // Clone repo + return command.exec('git clone '+host+' '+repoPath) + + // Checkout reference if specified + .then(function() { + that.cloned[repoId] = true; + + if (!ref) return; + return command.exec('git checkout '+ref, { cwd: repoPath }); + }) + .thenResolve(repoPath); + }); +}; + +// Get file from a git repo +Git.prototype.resolve = function(giturl) { + // Path to a file in a git repo? + if (!Git.isUrl(giturl)) { + if (this.resolveRoot(giturl)) return Promise(giturl); + return Promise(null); + } + if (_.isString(giturl)) giturl = Git.parseUrl(giturl); + if (!giturl) return Promise(null); + + // Clone or get from cache + return this.clone(giturl.host, giturl.ref) + .then(function(repo) { + return path.resolve(repo, giturl.filepath); + }); +}; + +// Return root of git repo from a filepath +Git.prototype.resolveRoot = function(filepath) { + var relativeToGit, repoId; + + // No git repo cloned, or file is not in a git repository + if (!this.tmpDir || !pathUtil.isInRoot(this.tmpDir, filepath)) return null; + + // Extract first directory (is the repo id) + relativeToGit = path.relative(this.tmpDir, filepath); + repoId = _.first(relativeToGit.split(path.sep)); + if (!repoId) return; + + // Return an absolute file + return path.resolve(this.tmpDir, repoId); +}; // Check if an url is a git dependency url -function checkGitUrl(giturl) { +Git.isUrl = function(giturl) { return (giturl.indexOf(GIT_PREFIX) === 0); -} - -// Validates a SHA in hexadecimal -function validateSha(str) { - return (/[0-9a-f]{40}/).test(str); -} +}; // Parse and extract infos -function parseGitUrl(giturl) { +Git.parseUrl = function(giturl) { var ref, uri, fileParts, filepath; - if (!checkGitUrl(giturl)) return null; + if (!Git.isUrl(giturl)) return null; giturl = giturl.slice(GIT_PREFIX.length); uri = new URI(giturl); - ref = uri.fragment() || 'master'; + ref = uri.fragment() || null; uri.fragment(null); // Extract file inside the repo (after the .git) - fileParts =uri.path().split('.git'); + fileParts = uri.path().split('.git'); filepath = fileParts.length > 1? fileParts.slice(1).join('.git') : ''; if (filepath[0] == '/') filepath = filepath.slice(1); @@ -43,85 +121,9 @@ function parseGitUrl(giturl) { return { host: uri.toString(), - ref: ref || 'master', + ref: ref, filepath: filepath }; -} - -// Clone a git repo from a specific ref -function cloneGitRepo(host, ref) { - var isBranch = false; - - ref = ref || 'master'; - if (!validateSha(ref)) isBranch = true; - - return Q() - - // Create temporary folder to store git repos - .then(function() { - if (GIT_TMP) return; - return fs.tmp.dir() - .then(function(_tmp) { - GIT_TMP = _tmp; - }); - }) - - // Return or clone the git repo - .then(function() { - // Unique ID for repo/ref combinaison - var repoId = crc.crc32(host+'#'+ref).toString(16); - - // Absolute path to the folder - var repoPath = path.resolve(GIT_TMP, repoId); - - return fs.exists(repoPath) - .then(function(doExists) { - if (doExists) return; - - // Clone repo - return exec('git clone '+host+' '+repoPath) - .then(function() { - return exec('git checkout '+ref, { cwd: repoPath }); - }); - }) - .thenResolve(repoPath); - }); -} - -// Get file from a git repo -function resolveFileFromGit(giturl) { - if (_.isString(giturl)) giturl = parseGitUrl(giturl); - if (!giturl) return Q(null); - - // Clone or get from cache - return cloneGitRepo(giturl.host, giturl.ref) - .then(function(repo) { - - // Resolve relative path - return path.resolve(repo, giturl.filepath); - }); -} - -// Return root of git repo from a filepath -function resolveGitRoot(filepath) { - var relativeToGit, repoId; - - // No git repo cloned, or file is not in a git repository - if (!GIT_TMP || !pathUtil.isInRoot(GIT_TMP, filepath)) return null; - - // Extract first directory (is the repo id) - relativeToGit = path.relative(GIT_TMP, filepath); - repoId = _.first(relativeToGit.split(path.sep)); - if (!repoId) return; - - // Return an absolute file - return path.resolve(GIT_TMP, repoId); -} - - -module.exports = { - checkUrl: checkGitUrl, - parseUrl: parseGitUrl, - resolveFile: resolveFileFromGit, - resolveRoot: resolveGitRoot }; + +module.exports = Git; diff --git a/lib/utils/i18n.js b/lib/utils/i18n.js deleted file mode 100644 index de64b49fd..000000000 --- a/lib/utils/i18n.js +++ /dev/null @@ -1,80 +0,0 @@ -var _ = require('lodash'); -var path = require('path'); -var fs = require('fs'); - -var i18n = require('i18n'); - -var I18N_PATH = path.resolve(__dirname, '../../theme/i18n/'); -var DEFAULT_LANGUAGE = 'en'; -var LOCALES = _.map(fs.readdirSync(I18N_PATH), function(lang) { - return path.basename(lang, '.json'); -}); - -i18n.configure({ - locales: LOCALES, - directory: I18N_PATH, - defaultLocale: DEFAULT_LANGUAGE, - updateFiles: false -}); - -function compareLocales(lang, locale) { - var langMain = _.first(lang.split('-')); - var langSecond = _.last(lang.split('-')); - - var localeMain = _.first(locale.split('-')); - var localeSecond = _.last(locale.split('-')); - - if (locale == lang) return 100; - if (localeMain == langMain) return 50; - if (localeSecond == langSecond) return 20; - return 0; -} - -var normalizeLanguage = _.memoize(function(lang) { - var language = _.chain(LOCALES) - .values() - .map(function(locale) { - return { - locale: locale, - score: compareLocales(lang, locale) - }; - }) - .filter(function(lang) { - return lang.score > 0; - }) - .sortBy('score') - .pluck('locale') - .last() - .value(); - return language || lang; -}); - -function translate(locale, phrase) { - var args = Array.prototype.slice.call(arguments, 2); - - return i18n.__.apply({}, [{ - locale: locale, - phrase: phrase - }].concat(args)); -} - -function getCatalog(locale) { - locale = normalizeLanguage(locale); - return i18n.getCatalog(locale); -} - -function getLocales() { - return LOCALES; -} - -function hasLocale(locale) { - return _.contains(LOCALES, locale); -} - -module.exports = { - __: translate, - normalizeLanguage: normalizeLanguage, - getCatalog: getCatalog, - getLocales: getLocales, - hasLocale: hasLocale -}; diff --git a/lib/utils/images.js b/lib/utils/images.js index a82b0a153..e387d6b36 100644 --- a/lib/utils/images.js +++ b/lib/utils/images.js @@ -1,37 +1,44 @@ -var _ = require("lodash"); -var Q = require("q"); -var fs = require("./fs"); -var spawn = require("spawn-cmd").spawn; +var Promise = require('./promise'); +var command = require('./command'); +var fs = require('./fs'); +var error = require('./error'); -// Convert a svg file -var convertSVG = function(source, dest, options) { - if (!fs.existsSync(source)) return Q.reject(new Error("File doesn't exist: "+source)); - var d = Q.defer(); +// Convert a svg file to a pmg +function convertSVGToPNG(source, dest, options) { + if (!fs.existsSync(source)) return Promise.reject(new error.FileNotFoundError({ filename: source })); - options = _.defaults(options || {}, { - - }); - - //var command = shellescape(["svgexport", source, dest]); - var child = spawn("svgexport", [source, dest]); - - child.on("error", function(error) { - if (error.code == "ENOENT") error = new Error("Need to install \"svgexport\" using \"npm install svgexport -g\""); - return d.reject(error); - }); - - child.on("close", function(code) { - if (code === 0 && fs.existsSync(dest)) { - d.resolve(); - } else { - d.reject(new Error("Error converting "+source+" into "+dest)); + return command.spawn('svgexport', [source, dest]) + .fail(function(err) { + if (err.code == 'ENOENT') { + err = error.RequireInstallError({ + cmd: 'svgexport', + install: 'Install it using: "npm install svgexport -g"' + }); } - }); + throw err; + }) + .then(function() { + if (fs.existsSync(dest)) return; - return d.promise; -}; + throw new Error('Error converting '+source+' into '+dest); + }); +} + +// Convert a svg buffer to a png file +function convertSVGBufferToPNG(buf, dest) { + // Create a temporary SVG file to convert + return fs.tmpFile({ + postfix: '.svg' + }) + .then(function(tmpSvg) { + return fs.writeFile(tmpSvg, buf) + .then(function() { + return convertSVGToPNG(tmpSvg, dest); + }); + }); +} module.exports = { - convertSVG: convertSVG, - INVALID: [".svg"] -}; + convertSVGToPNG: convertSVGToPNG, + convertSVGBufferToPNG: convertSVGBufferToPNG +}; \ No newline at end of file diff --git a/lib/utils/links.js b/lib/utils/location.js similarity index 62% rename from lib/utils/links.js rename to lib/utils/location.js index 51223967e..ba0c57dca 100644 --- a/lib/utils/links.js +++ b/lib/utils/location.js @@ -1,7 +1,7 @@ var url = require('url'); var path = require('path'); -// Is the link an external link +// Is the url an external url function isExternal(href) { try { return Boolean(url.parse(href).protocol); @@ -10,15 +10,9 @@ function isExternal(href) { } } -// Return true if the link is relative +// Inverse of isExternal function isRelative(href) { - try { - var parsed = url.parse(href); - - return !!(!parsed.protocol && parsed.path); - } catch(err) { - return true; - } + return !isExternal(href); } // Return true if the link is an achor @@ -32,15 +26,20 @@ function isAnchor(href) { } // Normalize a path to be a link -function normalizeLink(s) { +function normalize(s) { return s.replace(/\\/g, '/'); } -// Relative to absolute path +// Convert relative to absolute path // dir: directory parent of the file currently in rendering process // outdir: directory parent from the html output function toAbsolute(_href, dir, outdir) { if (isExternal(_href)) return _href; + outdir = outdir == undefined? dir : outdir; + + _href = normalize(_href); + dir = normalize(dir); + outdir = normalize(outdir); // Path "_href" inside the base folder var hrefInRoot = path.normalize(path.join(dir, _href)); @@ -50,32 +49,22 @@ function toAbsolute(_href, dir, outdir) { _href = path.relative(outdir, hrefInRoot); // Normalize windows paths - _href = normalizeLink(_href); + _href = normalize(_href); return _href; } -// Join links -function join() { - var _href = path.join.apply(path, arguments); - - return normalizeLink(_href); -}; - -// Change extension -function changeExtension(filename, newext) { - return path.join( - path.dirname(filename), - path.basename(filename, path.extname(filename))+newext - ); +// Convert an absolute path to a relative path for a specific folder (dir) +// ('test/', 'hello.md') -> '../hello.md' +function relative(dir, file) { + return normalize(path.relative(dir, file)); } module.exports = { - isAnchor: isAnchor, - isRelative: isRelative, isExternal: isExternal, + isRelative: isRelative, + isAnchor: isAnchor, + normalize: normalize, toAbsolute: toAbsolute, - join: join, - changeExtension: changeExtension, - normalize: normalizeLink + relative: relative }; diff --git a/lib/utils/logger.js b/lib/utils/logger.js index db3d90eab..60215af4c 100644 --- a/lib/utils/logger.js +++ b/lib/utils/logger.js @@ -1,6 +1,6 @@ -var _ = require("lodash"); -var util = require("util"); -var color = require("bash-color"); +var _ = require('lodash'); +var util = require('util'); +var color = require('bash-color'); var LEVELS = { DEBUG: 0, @@ -17,86 +17,112 @@ var COLORS = { ERROR: color.red }; -module.exports = function(_write, logLevel) { - var logger = {}; - var lastChar = "\n"; - if (_.isString(logLevel)) logLevel = LEVELS[logLevel.toUpperCase()]; +function Logger(write, logLevel, prefix) { + if (!(this instanceof Logger)) return new Logger(write, logLevel); - // Write a simple message - logger.write = function(msg) { - msg = msg.toString(); - lastChar = _.last(msg); - return _write(msg); - }; + this._write = write || function(msg) { process.stdout.write(msg); }; + this.lastChar = '\n'; - // Format a message - logger.format = function() { - return util.format.apply(util, arguments); - }; + // Define log level + this.setLevel(logLevel); - // Write a line - logger.writeLn = function(msg) { - return this.write((msg || "")+"\n"); - }; + _.bindAll(this); - // Write a message with a certain level - logger.log = function(level) { - if (level < logLevel) return; - - var levelKey = _.findKey(LEVELS, function(v) { return v == level; }); - var args = Array.prototype.slice.apply(arguments, [1]); - var msg = logger.format.apply(logger, args); - - if (lastChar == "\n") { - msg = COLORS[levelKey](levelKey.toLowerCase()+":")+" "+msg; - } - - return logger.write(msg); - }; - logger.logLn = function() { - if (lastChar != "\n") logger.write("\n"); - - var args = Array.prototype.slice.apply(arguments); - args.push("\n"); - logger.log.apply(logger, args); - }; - - // Write a OK - logger.ok = function(level) { - var args = Array.prototype.slice.apply(arguments, [1]); - var msg = logger.format.apply(logger, args); - if (arguments.length > 1) { - logger.logLn(level, color.green(">> ") + msg.trim().replace(/\n/g, color.green("\n>> "))); - } else { - logger.log(level, color.green("OK"), "\n"); - } - }; - - // Write an "FAIL" - logger.fail = function(level) { - return logger.log(level, color.red("ERROR")+"\n"); - }; - - _.each(_.omit(LEVELS, "DISABLED"), function(level, levelKey) { + // Create easy-to-use method like "logger.debug.ln('....')" + _.each(_.omit(LEVELS, 'DISABLED'), function(level, levelKey) { levelKey = levelKey.toLowerCase(); - logger[levelKey] = _.partial(logger.log, level); - logger[levelKey].ln = _.partial(logger.logLn, level); - logger[levelKey].ok = _.partial(logger.ok, level); - logger[levelKey].fail = _.partial(logger.fail, level); - logger[levelKey].promise = function(p) { - return p. - then(function(st) { - logger[levelKey].ok(); - return st; - }, function(err) { - logger[levelKey].fail(); - throw err; - }); - }; - }); + this[levelKey] = _.partial(this.log, level); + this[levelKey].ln = _.partial(this.logLn, level); + this[levelKey].ok = _.partial(this.ok, level); + this[levelKey].fail = _.partial(this.fail, level); + this[levelKey].promise = _.partial(this.promise, level); + }, this); +} - return logger; +// Create a new logger prefixed from this logger +Logger.prototype.prefix = function(prefix) { + return (new Logger(this._write, this.logLevel, prefix)); }; -module.exports.LEVELS = LEVELS; -module.exports.COLORS = COLORS; + +// Change minimum level +Logger.prototype.setLevel = function(logLevel) { + if (_.isString(logLevel)) logLevel = LEVELS[logLevel.toUpperCase()]; + this.logLevel = logLevel; +}; + +// Print a simple string +Logger.prototype.write = function(msg) { + msg = msg.toString(); + this.lastChar = _.last(msg); + return this._write(msg); +}; + +// Format a string using the first argument as a printf-like format. +Logger.prototype.format = function() { + return util.format.apply(util, arguments); +}; + +// Print a line +Logger.prototype.writeLn = function(msg) { + return this.write((msg || '')+'\n'); +}; + +// Log/Print a message if level is allowed +Logger.prototype.log = function(level) { + if (level < this.logLevel) return; + + var levelKey = _.findKey(LEVELS, function(v) { return v == level; }); + var args = Array.prototype.slice.apply(arguments, [1]); + var msg = this.format.apply(this, args); + + if (this.lastChar == '\n') { + msg = COLORS[levelKey](levelKey.toLowerCase()+':')+' '+msg; + } + + return this.write(msg); +}; + +// Log/Print a line if level is allowed +Logger.prototype.logLn = function() { + if (this.lastChar != '\n') this.write('\n'); + + var args = Array.prototype.slice.apply(arguments); + args.push('\n'); + return this.log.apply(this, args); +}; + +// Log a confirmation [OK] +Logger.prototype.ok = function(level) { + var args = Array.prototype.slice.apply(arguments, [1]); + var msg = this.format.apply(this, args); + if (arguments.length > 1) { + this.logLn(level, color.green('>> ') + msg.trim().replace(/\n/g, color.green('\n>> '))); + } else { + this.log(level, color.green('OK'), '\n'); + } +}; + +// Log a "FAIL" +Logger.prototype.fail = function(level) { + return this.log(level, color.red('ERROR') + '\n'); +}; + +// Log state of a promise +Logger.prototype.promise = function(level, p) { + var that = this; + + return p. + then(function(st) { + that.ok(level); + return st; + }, function(err) { + that.fail(level); + throw err; + }); +}; + +Logger.LEVELS = LEVELS; +Logger.COLORS = COLORS; + +module.exports = Logger; diff --git a/lib/utils/navigation.js b/lib/utils/navigation.js deleted file mode 100644 index d07eb35f6..000000000 --- a/lib/utils/navigation.js +++ /dev/null @@ -1,79 +0,0 @@ -var _ = require("lodash"); - -// Cleans up an article/chapter object -// remove "articles" attributes -function clean(obj) { - return obj && _.omit(obj, ["articles"]); -} - -function flattenChapters(chapters) { - return _.reduce(chapters, function(accu, chapter) { - return accu.concat([clean(chapter)].concat(flattenChapters(chapter.articles))); - }, []); -} - -// Returns from a summary a map of -/* - { - "file/path.md": { - prev: ..., - next: ..., - }, - ... - } -*/ -function navigation(summary, files) { - // Support single files as well as list - files = _.isArray(files) ? files : (_.isString(files) ? [files] : null); - - // List of all navNodes - // Flatten chapters - var navNodes = flattenChapters(summary.chapters); - - // Mapping of prev/next for a give path - var mapping = _.chain(navNodes) - .map(function(current, i) { - var prev = null, next = null; - - // Skip if no path - if(!current.exists) return null; - - // Find prev - prev = _.chain(navNodes.slice(0, i)) - .reverse() - .find(function(node) { - return node.exists && !node.external; - }) - .value(); - - // Find next - next = _.chain(navNodes.slice(i+1)) - .find(function(node) { - return node.exists && !node.external; - }) - .value(); - - return [current.path, { - index: i, - title: current.title, - introduction: current.introduction, - prev: prev, - next: next, - level: current.level, - }]; - }) - .compact() - .object() - .value(); - - // Filter for only files we want - if(files) { - return _.pick(mapping, files); - } - - return mapping; -} - - -// Exports -module.exports = navigation; diff --git a/lib/utils/page.js b/lib/utils/page.js deleted file mode 100644 index 010d703c0..000000000 --- a/lib/utils/page.js +++ /dev/null @@ -1,397 +0,0 @@ -var Q = require('q'); -var _ = require('lodash'); -var url = require('url'); -var path = require('path'); -var cheerio = require('cheerio'); -var domSerializer = require('dom-serializer'); -var request = require('request'); -var crc = require('crc'); -var slug = require('github-slugid'); - -var links = require('./links'); -var imgUtils = require('./images'); -var fs = require('./fs'); -var batch = require('./batch'); - -var parsableExtensions = require('gitbook-parsers').extensions; - -// Map of images that have been converted -var imgConversionCache = {}; - -// Render a cheerio dom as html -function renderDom($, dom, options) { - if (!dom && $._root && $._root.children) { - dom = $._root.children; - } - - options = options|| dom.options || $._options; - return domSerializer(dom, options); -} - -function replaceText($, el, search, replace, text_only ) { - return $(el).each(function(){ - var node = this.firstChild, - val, - new_val, - - // Elements to be removed at the end. - remove = []; - - // Only continue if firstChild exists. - if ( node ) { - - // Loop over all childNodes. - while (node) { - - // Only process text nodes. - if ( node.nodeType === 3 ) { - - // The original node value. - val = node.nodeValue; - - // The new value. - new_val = val.replace( search, replace ); - - // Only replace text if the new value is actually different! - if ( new_val !== val ) { - - if ( !text_only && /\|\:])/g, '\\$1'); -} - - -// Adapt an html snippet to be relative to a base folder -function normalizeHtml(src, options) { - var $ = cheerio.load(src, { - // We should parse html without trying to normalize too much - xmlMode: false, - - // SVG need some attributes to use uppercases - lowerCaseAttributeNames: false, - lowerCaseTags: false - }); - var toConvert = []; - var svgContent = {}; - var outputRoot = options.book.options.output; - - imgConversionCache[outputRoot] = imgConversionCache[outputRoot] || {}; - - // Find svg images to extract and process - if (options.convertImages) { - $('svg').each(function() { - var content = renderDom($, $(this)); - var svgId = _.uniqueId('svg'); - var dest = svgId+'.svg'; - - // Generate filename - dest = '/'+fs.getUniqueFilename(outputRoot, dest); - - svgContent[dest] = ''+content; - $(this).replaceWith($('').attr('src', dest)); - }); - } - - // Generate ID for headings - $('h1,h2,h3,h4,h5,h6').each(function() { - if ($(this).attr('id')) return; - - $(this).attr('id', slug($(this).text())); - }); - - // Find images to normalize - $('img').each(function() { - var origin; - var src = $(this).attr('src'); - - if (!src) return; - var isExternal = links.isExternal(src); - - // Transform as relative to the bases - if (links.isRelative(src)) { - src = links.toAbsolute(src, options.base, options.output); - } - - // Convert if needed - if (options.convertImages) { - // If image is external and ebook, then downlaod the images - if (isExternal) { - origin = src; - src = '/'+crc.crc32(origin).toString(16)+path.extname(url.parse(origin).pathname); - src = links.toAbsolute(src, options.base, options.output); - isExternal = false; - } - - var ext = path.extname(src); - var srcAbs = links.join('/', options.base, src); - - // Test image extension - if (_.contains(imgUtils.INVALID, ext)) { - if (imgConversionCache[outputRoot][srcAbs]) { - // Already converted - src = imgConversionCache[outputRoot][srcAbs]; - } else { - // Not converted yet - var dest = ''; - - // Replace extension - dest = links.join(path.dirname(srcAbs), path.basename(srcAbs, ext)+'.png'); - dest = dest[0] == '/'? dest.slice(1) : dest; - - // Get a name that doesn't exists - dest = fs.getUniqueFilename(outputRoot, dest); - - options.book.log.debug.ln('detect invalid image (will be converted to png):', srcAbs); - - // Add to cache - imgConversionCache[outputRoot][srcAbs] = '/'+dest; - - // Push to convert - toConvert.push({ - origin: origin, - content: svgContent[srcAbs], - source: isExternal? srcAbs : path.join('./', srcAbs), - dest: path.join('./', dest) - }); - - src = links.join('/', dest); - } - - // Reset as relative to output - src = links.toAbsolute(src, options.base, options.output); - } - - else if (origin) { - // Need to downlaod image - toConvert.push({ - origin: origin, - source: path.join('./', srcAbs) - }); - } - } - - $(this).attr('src', src); - }); - - // Normalize links - $('a').each(function() { - var href = $(this).attr('href'); - if (!href) return; - - if (links.isAnchor(href)) { - // Keep it as it is - } else if (links.isRelative(href)) { - var parts = url.parse(href); - - var pathName = decodeURIComponent(parts.pathname); - var anchor = parts.hash || ''; - - // Calcul absolute path for this file (without the anchor) - var absolutePath = links.join(options.base, pathName); - - // If is in navigation relative: transform as content - if (options.navigation[absolutePath]) { - absolutePath = options.book.contentLink(absolutePath); - } - - // If md/adoc/rst files is not in summary - // or for ebook, signal all files that are outside the summary - else if (_.contains(parsableExtensions, path.extname(absolutePath)) || - _.contains(['epub', 'pdf', 'mobi'], options.book.options.generator)) { - options.book.log.warn.ln('page', options.input, 'contains an hyperlink to resource outside spine \''+href+'\''); - } - - // Transform as absolute - href = links.toAbsolute('/'+absolutePath, options.base, options.output)+anchor; - } else { - // External links - $(this).attr('target', '_blank'); - } - - // Transform extension - $(this).attr('href', href); - }); - - // Highlight code blocks - $('code').each(function() { - // Normalize language - var lang = _.chain( - ($(this).attr('class') || '').split(' ') - ) - .map(function(cl) { - // Markdown - if (cl.search('lang-') === 0) return cl.slice('lang-'.length); - - // Asciidoc - if (cl.search('language-') === 0) return cl.slice('language-'.length); - - return null; - }) - .compact() - .first() - .value(); - - var source = $(this).text(); - var blk = options.book.template.applyBlock('code', { - body: source, - kwargs: { - language: lang - } - }); - - if (blk.html === false) $(this).text(blk.body); - else $(this).html(blk.body); - }); - - // Replace glossary terms - var glossary = _.sortBy(options.glossary, function(term) { - return -term.name.length; - }); - - _.each(glossary, function(term) { - var r = new RegExp( '\\b(' + pregQuote(term.name.toLowerCase()) + ')\\b' , 'gi' ); - var includedInFiles = false; - - $('*').each(function() { - // Ignore codeblocks - if (_.contains(['code', 'pre', 'a', 'script'], this.name.toLowerCase())) return; - - replaceText($, this, r, function(match) { - // Add to files index in glossary - if (!includedInFiles) { - includedInFiles = true; - term.files = term.files || []; - term.files.push(options.navigation[options.input]); - } - return ''+match+''; - }); - }); - }); - - return { - html: renderDom($), - images: toConvert - }; -} - -// Convert svg images to png -function convertImages(images, options) { - if (!options.convertImages) return Q(); - - var downloaded = []; - options.book.log.debug.ln('convert ', images.length, 'images to png'); - - return batch.execEach(images, { - max: 100, - fn: function(image) { - var imgin = path.resolve(options.book.options.output, image.source); - - return Q() - - // Write image if need to be download - .then(function() { - if (!image.origin && !_.contains(downloaded, image.origin)) return; - options.book.log.debug('download image', image.origin, '...'); - downloaded.push(image.origin); - return options.book.log.debug.promise(fs.writeStream(imgin, request(image.origin))) - .fail(function(err) { - if (!_.isError(err)) err = new Error(err); - - err.message = 'Fail downloading '+image.origin+': '+err.message; - throw err; - }); - }) - - // Write svg if content - .then(function() { - if (!image.content) return; - return fs.writeFile(imgin, image.content); - }) - - // Convert - .then(function() { - if (!image.dest) return; - var imgout = path.resolve(options.book.options.output, image.dest); - options.book.log.debug('convert image', image.source, 'to', image.dest, '...'); - return options.book.log.debug.promise(imgUtils.convertSVG(imgin, imgout)); - }); - } - }) - .then(function() { - options.book.log.debug.ok(images.length+' images converted with success'); - }); -} - -// Adapt page content to be relative to a base folder -function normalizePage(sections, options) { - options = _.defaults(options || {}, { - // Current book - book: null, - - // Do we need to convert svg? - convertImages: false, - - // Current file path - input: '.', - - // Navigation to use to transform path - navigation: {}, - - // Directory parent of the file currently in rendering process - base: './', - - // Directory parent from the html output - output: './', - - // Glossary terms - glossary: [] - }); - - // List of images to convert - var toConvert = []; - - sections = _.map(sections, function(section) { - if (section.type != 'normal') return section; - - var out = normalizeHtml(section.content, options); - - toConvert = toConvert.concat(out.images); - section.content = out.html; - return section; - }); - - return Q() - .then(function() { - toConvert = _.uniq(toConvert, 'source'); - return convertImages(toConvert, options); - }) - .thenResolve(sections); -} - - -module.exports = { - normalize: normalizePage -}; diff --git a/lib/utils/path.js b/lib/utils/path.js index 5285896fb..c233c923f 100644 --- a/lib/utils/path.js +++ b/lib/utils/path.js @@ -1,5 +1,12 @@ -var _ = require("lodash"); -var path = require("path"); +var _ = require('lodash'); +var path = require('path'); + +var error = require('./error'); + +// Normalize a filename +function normalizePath(filename) { + return path.normalize(filename); +} // Return true if file path is inside a folder function isInRoot(root, filename) { @@ -10,31 +17,42 @@ function isInRoot(root, filename) { // Resolve paths in a specific folder // Throw error if file is outside this folder function resolveInRoot(root) { - var input, result, err; + var input, result; input = _.chain(arguments) .toArray() .slice(1) .reduce(function(current, p) { // Handle path relative to book root ("/README.md") - if (p[0] == "/" || p[0] == "\\") return p.slice(1); + if (p[0] == '/' || p[0] == '\\') return p.slice(1); return current? path.join(current, p) : path.normalize(p); - }, "") + }, '') .value(); result = path.resolve(root, input); if (!isInRoot(root, result)) { - err = new Error("EACCESS: \"" + result + "\" not in \"" + root + "\""); - err.code = "EACCESS"; - throw err; + throw new error.FileOutOfScopeError({ + filename: result, + root: root + }); } return result; } +// Chnage extension +function setExtension(filename, ext) { + return path.join( + path.dirname(filename), + path.basename(filename, path.extname(filename)) + ext + ); +} + module.exports = { isInRoot: isInRoot, - resolveInRoot: resolveInRoot + resolveInRoot: resolveInRoot, + normalize: normalizePath, + setExtension: setExtension }; diff --git a/lib/utils/progress.js b/lib/utils/progress.js deleted file mode 100644 index 8dda89242..000000000 --- a/lib/utils/progress.js +++ /dev/null @@ -1,55 +0,0 @@ -var _ = require('lodash'); - -// Returns from a navigation and a current file, a snapshot of current detailed state -function calculProgress(navigation, current) { - var n = _.size(navigation); - var percent = 0, prevPercent = 0, currentChapter = null; - var done = true; - - var chapters = _.chain(navigation) - - // Transform as array - .map(function(nav, path) { - nav.path = path; - return nav; - }) - - // Sort entries - .sortBy(function(nav) { - return nav.index; - }) - - .map(function(nav, i) { - // Calcul percent - nav.percent = (i * 100) / Math.max((n - 1), 1); - - // Is it done - nav.done = done; - if (nav.path == current) { - currentChapter = nav; - percent = nav.percent; - done = false; - } else if (done) { - prevPercent = nav.percent; - } - - return nav; - }) - .value(); - - return { - // Previous percent - prevPercent: prevPercent, - - // Current percent - percent: percent, - - // List of chapter with progress - chapters: chapters, - - // Current chapter - current: currentChapter - }; -} - -module.exports = calculProgress; diff --git a/lib/utils/promise.js b/lib/utils/promise.js new file mode 100644 index 000000000..d49cf27a2 --- /dev/null +++ b/lib/utils/promise.js @@ -0,0 +1,62 @@ +var Q = require('q'); +var _ = require('lodash'); + +// Reduce an array to a promise +function reduce(arr, iter, base) { + return _.reduce(arr, function(prev, elem, i) { + return prev.then(function(val) { + return iter(val, elem, i); + }); + }, Q(base)); +} + +// Transform an array +function serie(arr, iter, base) { + return reduce(arr, function(before, item, i) { + return Q(iter(item, i)) + .then(function(r) { + before.push(r); + return before; + }); + }, []); +} + +// Iter over an array and return first result (not null) +function some(arr, iter) { + return _.reduce(arr, function(prev, elem, i) { + return prev.then(function(val) { + if (val) return val; + + return iter(elem, i); + }); + }, Q()); +} + +// Map an array using an async (promised) iterator +function map(arr, iter) { + return reduce(arr, function(prev, entry, i) { + return Q(iter(entry, i)) + .then(function(out) { + prev.push(out); + return prev; + }); + }, []); +} + +// Wrap a fucntion in a promise +function wrap(func) { + return _.wrap(func, function(_func) { + var args = Array.prototype.slice.call(arguments, 1); + return Q() + .then(function() { + return _func.apply(null, args); + }); + }); +} + +module.exports = Q; +module.exports.reduce = reduce; +module.exports.map = map; +module.exports.serie = serie; +module.exports.some = some; +module.exports.wrapfn = wrap; diff --git a/lib/utils/string.js b/lib/utils/string.js deleted file mode 100644 index caa236428..000000000 --- a/lib/utils/string.js +++ /dev/null @@ -1,27 +0,0 @@ -var _ = require("lodash"); - -function escapeShellArg(arg) { - var ret = ""; - - ret = arg.replace(/"/g, '\\"'); - - return "\"" + ret + "\""; -} - -function optionsToShellArgs(options) { - return _.chain(options) - .map(function(value, key) { - if (value === null || value === undefined || value === false) return null; - if (value === true) return key; - return key+"="+escapeShellArg(value); - }) - .compact() - .value() - .join(" "); -} - -module.exports = { - escapeShellArg: escapeShellArg, - optionsToShellArgs: optionsToShellArgs, - toLowerCase: String.prototype.toLowerCase.call.bind(String.prototype.toLowerCase) -}; diff --git a/lib/utils/watch.js b/lib/utils/watch.js deleted file mode 100644 index 4d1a752d4..000000000 --- a/lib/utils/watch.js +++ /dev/null @@ -1,40 +0,0 @@ -var Q = require("q"); -var _ = require("lodash"); -var path = require("path"); -var chokidar = require("chokidar"); - -var parsers = require("gitbook-parsers"); - -function watch(dir) { - var d = Q.defer(); - dir = path.resolve(dir); - - var toWatch = [ - "book.json", "book.js" - ]; - - _.each(parsers.extensions, function(ext) { - toWatch.push("**/*"+ext); - }); - - var watcher = chokidar.watch(toWatch, { - cwd: dir, - ignored: "_book/**", - ignoreInitial: true - }); - - watcher.once("all", function(e, filepath) { - watcher.close(); - - d.resolve(filepath); - }); - watcher.once("error", function(err) { - watcher.close(); - - d.reject(err); - }); - - return d.promise; -} - -module.exports = watch; diff --git a/package.json b/package.json index 0d11bca30..e0c54fb75 100644 --- a/package.json +++ b/package.json @@ -1,25 +1,26 @@ { "name": "gitbook", - "version": "2.6.7", + "version": "3.0.0-pre.0", "homepage": "https://www.gitbook.com", "description": "Library and cmd utility to generate GitBooks", "main": "lib/index.js", "dependencies": { "q": "1.0.1", "lodash": "3.10.1", - "graceful-fs": "3.0.5", + "graceful-fs": "4.1.3", "resolve": "0.6.3", - "fs-extra": "0.16.5", - "fstream-ignore": "1.0.2", - "gitbook-parsers": "0.8.9", + "mkdirp": "0.5.1", + "error": "7.0.2", + "gitbook-markdown": "1.0.3", + "gitbook-asciidoc": "1.0.2", "gitbook-plugin-highlight": "1.0.3", "gitbook-plugin-sharing": "1.0.1", "gitbook-plugin-search": "1.1.0", "gitbook-plugin-fontsettings": "1.0.2", - "nunjucks": "2.2.0", + "gitbook-plugin-theme-default": "1.0.0-pre.4", + "nunjucks": "2.3.0", "nunjucks-autoescape": "1.0.0", "nunjucks-filter": "1.0.0", - "i18n": "0.5.0", "semver": "5.0.1", "npmi": "0.1.1", "cheerio": "0.19.0", @@ -27,42 +28,40 @@ "chokidar": "~1.0.5", "send": "0.2.0", "tiny-lr": "0.2.1", - "tmp": "0.0.24", + "tmp": "0.0.28", "crc": "3.2.1", "bash-color": "0.0.3", "urijs": "1.17.0", - "request": "2.51.0", + "request": "2.69.0", "npm": "2.4.1", "dom-serializer": "0.1.0", "spawn-cmd": "0.0.2", "escape-string-regexp": "1.0.3", - "juice": "1.5.0", - "jsonschema": "1.0.2", + "juice": "1.9.0", + "jsonschema": "1.1.0", "json-schema-defaults": "0.1.1", "merge-defaults": "0.2.1", - "github-slugid": "1.0.0" + "github-slugid": "1.0.0", + "destroy": "1.0.4", + "ignore": "2.2.19", + "deprecated": "0.0.1", + "rmdir": "1.2.0", + "cp": "0.2.0", + "cpr": "1.0.0", + "direction": "0.1.5", + "moment": "2.11.2", + "i18n-t": "1.0.0", + "front-matter": "2.0.6", + "spawn-cmd": "0.0.2" }, "devDependencies": { "eslint": "1.5.0", - "mocha": "2.3.2", - "should": "7.1.0", - "should-promised": "0.3.1", - "gulp": "^3.8.11", - "gulp-rename": "^1.2.2", - "gulp-uglify": "1.1.0", - "gulp-less": "3.0.2", - "gulp-minify-css": "1.0.0", - "gulp-util": "3.0.6", - "browserify": "11.0.1", - "merge-stream": "0.1.7", - "vinyl-source-stream": "1.1.0", - "jquery": "2.1.4", - "mousetrap": "1.5.3", - "font-awesome": "4.1.0", - "gitbook-markdown-css": "1.0.1" + "mocha": "2.4.5", + "should": "8.2.2" }, "scripts": { - "test": "node_modules/.bin/mocha --reporter spec --timeout 15000" + "test": "node_modules/.bin/mocha --reporter spec --bail --timeout 15000 ./test/all.js", + "lint": "eslint ." }, "repository": { "type": "git", diff --git a/test/all.js b/test/all.js new file mode 100644 index 000000000..9c82c91c7 --- /dev/null +++ b/test/all.js @@ -0,0 +1,27 @@ + +// Utilities +require('./location'); +require('./paths'); + +// Parsing +require('./locate'); +require('./config'); +require('./readme'); +require('./summary'); +require('./glossary'); +require('./langs'); +require('./parse'); + +require('./git'); +require('./plugins'); +require('./template'); +require('./conrefs'); + +// Page and HTML generation +require('./page'); + +// Output +require('./assets-inliner'); +require('./output-json'); +require('./output-website'); +require('./output-ebook'); diff --git a/test/assertions.js b/test/assertions.js index f9c4ba394..56da24917 100644 --- a/test/assertions.js +++ b/test/assertions.js @@ -1,25 +1,18 @@ -var _ = require('lodash'); var fs = require('fs'); -var path = require('path'); -var should = require('should'); +var _ = require('lodash'); var cheerio = require('cheerio'); +var should = require('should'); -require('should-promised'); - +// Assertions to test if an Output has generated a file should.Assertion.add('file', function(file, description) { - this.params = { actual: this.obj.toString(), operator: 'have file ' + file, message: description }; + this.params = { + actual: this.obj.root(), + operator: 'have file ' + file, + message: description + }; - this.obj.should.have.property('options').which.is.an.Object(); - this.obj.options.should.have.property('output').which.is.a.String(); - this.assert(fs.existsSync(path.resolve(this.obj.options.output, file))); -}); - -should.Assertion.add('jsonfile', function(file, description) { - this.params = { actual: this.obj.toString(), operator: 'have valid jsonfile ' + file, message: description }; - - this.obj.should.have.property('options').which.is.an.Object(); - this.obj.options.should.have.property('output').which.is.a.String(); - this.assert(JSON.parse(fs.readFileSync(path.resolve(this.obj.options.output, file), { encoding: 'utf-8' }))); + this.obj.should.have.property('resolve').which.is.a.Function; + this.assert(fs.existsSync(this.obj.resolve(file))); }); should.Assertion.add('html', function(rules, description) { diff --git a/test/assets-inliner.js b/test/assets-inliner.js new file mode 100644 index 000000000..7ccabc56b --- /dev/null +++ b/test/assets-inliner.js @@ -0,0 +1,86 @@ +var cheerio = require('cheerio'); +var path = require('path'); + +var mock = require('./mock'); +var AssetsInliner = require('../lib/output/assets-inliner')(); + +describe('Assets Inliner Output', function() { + var output; + + before(function() { + var SVG = ''; + + return mock.outputDefaultBook(AssetsInliner, { + 'README.md': '', + + // SVGs + 'svg_file.md': '![image](test.svg)', + 'svg_inline.md': 'This is a svg: '+SVG, + 'test.svg': '' + SVG, + + // Relative + 'folder/test.md': '![image](../test.svg)', + + // Remote images + 'remote_png.md': '![image](https://upload.wikimedia.org/wikipedia/commons/4/47/PNG_transparency_demonstration_1.png)', + 'remote_svg.md': '![image](https://upload.wikimedia.org/wikipedia/commons/0/02/SVG_logo.svg)', + + 'SUMMARY.md': '* [svg inline](svg_inline.md)\n' + + '* [svg file](svg_file.md)\n' + + '* [remote png file](remote_png.md)\n' + + '* [remote svg file](remote_svg.md)\n' + + '* [relative image](folder/test.md)\n' + + '\n\n' + }) + .then(function(_output) { + output = _output; + }); + }); + + function testImageInPage(filename) { + var page = output.book.getPage(filename); + var $ = cheerio.load(page.content); + + // Is there an image? + var $img = $('img'); + $img.length.should.equal(1); + + // Does the file exists + var src = $img.attr('src'); + + // Resolve the filename + src = page.resolveLocal(src); + + output.should.have.file(src); + path.extname(src).should.equal('.png'); + + return src; + } + + describe('SVG', function() { + it('should correctly convert SVG files to PNG', function() { + testImageInPage('svg_file.md'); + }); + + it('should correctly convert inline SVG to PNG', function() { + testImageInPage('svg_inline.md'); + }); + }); + + describe('Remote Assets', function() { + it('should correctly download a PNG file', function() { + testImageInPage('remote_png.md'); + }); + + it('should correctly download then convert a remote SVG to PNG', function() { + testImageInPage('remote_svg.md'); + }); + }); + + describe('Relative Images', function() { + it('should correctly resolve image', function() { + testImageInPage('folder/test.md'); + }); + }); +}); + diff --git a/test/books/basic/README.md b/test/books/basic/README.md deleted file mode 100644 index 09ade4067..000000000 --- a/test/books/basic/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# Readme - -Default description for the book. diff --git a/test/books/basic/SUMMARY.md b/test/books/basic/SUMMARY.md deleted file mode 100644 index ac9323cbc..000000000 --- a/test/books/basic/SUMMARY.md +++ /dev/null @@ -1 +0,0 @@ -# Summary diff --git a/test/books/config-js/README.md b/test/books/config-js/README.md deleted file mode 100644 index f3954314c..000000000 --- a/test/books/config-js/README.md +++ /dev/null @@ -1 +0,0 @@ -# Readme diff --git a/test/books/config-js/SUMMARY.md b/test/books/config-js/SUMMARY.md deleted file mode 100644 index ac9323cbc..000000000 --- a/test/books/config-js/SUMMARY.md +++ /dev/null @@ -1 +0,0 @@ -# Summary diff --git a/test/books/config-js/book.js b/test/books/config-js/book.js deleted file mode 100644 index 8731343c2..000000000 --- a/test/books/config-js/book.js +++ /dev/null @@ -1,3 +0,0 @@ -module.exports = { - "title": "js-config" -}; diff --git a/test/books/config-json/README.md b/test/books/config-json/README.md deleted file mode 100644 index f3954314c..000000000 --- a/test/books/config-json/README.md +++ /dev/null @@ -1 +0,0 @@ -# Readme diff --git a/test/books/config-json/SUMMARY.md b/test/books/config-json/SUMMARY.md deleted file mode 100644 index ac9323cbc..000000000 --- a/test/books/config-json/SUMMARY.md +++ /dev/null @@ -1 +0,0 @@ -# Summary diff --git a/test/books/config-json/book.json b/test/books/config-json/book.json deleted file mode 100644 index eda10bb88..000000000 --- a/test/books/config-json/book.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "title": "json-config" -} diff --git a/test/books/conrefs/README.md b/test/books/conrefs/README.md deleted file mode 100644 index 324ee1f69..000000000 --- a/test/books/conrefs/README.md +++ /dev/null @@ -1,15 +0,0 @@ -# Readme - -

$$test_block1$$

-

{% include "./block.md" %}

- -### Relative - -

{% include "./hello.md" %}

-

{% include "/hello.md" %}

- -### Git - -

{% include "git+https://gist.github.com/69ea4542e4c8967d2fa7.git/test.md" %}

-

{% include "git+https://gist.github.com/69ea4542e4c8967d2fa7.git/test2.md" %}

-

{% include "git+https://gist.github.com/69ea4542e4c8967d2fa7.git/test3.md" %}

diff --git a/test/books/conrefs/SUMMARY.md b/test/books/conrefs/SUMMARY.md deleted file mode 100644 index ac9323cbc..000000000 --- a/test/books/conrefs/SUMMARY.md +++ /dev/null @@ -1 +0,0 @@ -# Summary diff --git a/test/books/conrefs/block.md b/test/books/conrefs/block.md deleted file mode 100644 index 3910cb608..000000000 --- a/test/books/conrefs/block.md +++ /dev/null @@ -1 +0,0 @@ -$$test_block2$$ diff --git a/test/books/conrefs/hello.md b/test/books/conrefs/hello.md deleted file mode 100644 index 557db03de..000000000 --- a/test/books/conrefs/hello.md +++ /dev/null @@ -1 +0,0 @@ -Hello World diff --git a/test/books/glossary/GLOSSARY.md b/test/books/glossary/GLOSSARY.md deleted file mode 100644 index 18840d246..000000000 --- a/test/books/glossary/GLOSSARY.md +++ /dev/null @@ -1,13 +0,0 @@ -# Glossary - -## test - -Just a simple and easy to understand test. - -## hakunamatata - -This word is not present in the content. - -## test long - -This is a test with a longer text that the first entry to test order. diff --git a/test/books/glossary/README.md b/test/books/glossary/README.md deleted file mode 100644 index d46da8bd2..000000000 --- a/test/books/glossary/README.md +++ /dev/null @@ -1,10 +0,0 @@ -# Readme - -The word test should be replaced by a glossary link. - -``` -But the word test should not be replaced in code blocks -``` - -The two words test long should be replaced by the correct glossary links. - diff --git a/test/books/glossary/SUMMARY.md b/test/books/glossary/SUMMARY.md deleted file mode 100644 index deaea2031..000000000 --- a/test/books/glossary/SUMMARY.md +++ /dev/null @@ -1,3 +0,0 @@ -# Summary - -* [Page](folder/PAGE.md) diff --git a/test/books/glossary/folder/PAGE.md b/test/books/glossary/folder/PAGE.md deleted file mode 100644 index 33b17c2f8..000000000 --- a/test/books/glossary/folder/PAGE.md +++ /dev/null @@ -1,3 +0,0 @@ -# Page - -Just a page in a sub-directory to test relative link to glossary. diff --git a/test/books/headings/README.md b/test/books/headings/README.md deleted file mode 100644 index b08c485e1..000000000 --- a/test/books/headings/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# Hello World - -## Hello {#hello-custom} diff --git a/test/books/headings/SUMMARY.md b/test/books/headings/SUMMARY.md deleted file mode 100644 index e69de29bb..000000000 diff --git a/test/books/highlight/README.md b/test/books/highlight/README.md deleted file mode 100644 index f47ac835c..000000000 --- a/test/books/highlight/README.md +++ /dev/null @@ -1,16 +0,0 @@ -# Readme - -Block without language - -``` -test 1 -``` - -Block with a language - -```lang -test 2 -``` - -Inline code: `test 3` -Inline code with html: `` \ No newline at end of file diff --git a/test/books/highlight/SUMMARY.md b/test/books/highlight/SUMMARY.md deleted file mode 100644 index ac9323cbc..000000000 --- a/test/books/highlight/SUMMARY.md +++ /dev/null @@ -1 +0,0 @@ -# Summary diff --git a/test/books/images/README.md b/test/books/images/README.md deleted file mode 100644 index 484f4101b..000000000 --- a/test/books/images/README.md +++ /dev/null @@ -1,18 +0,0 @@ -# Tests for Images - -# SVG relative image - - -### Convert SVG to PNG -![test.svg](test.svg) - -### Should only convert it once -![test.svg](test.svg) - -### Download remote images -![remote image](http://upload.wikimedia.org/wikipedia/commons/b/b0/NewTux.svg) - -### Remote images with same filename -![youtube1](http://img.youtube.com/vi/9bZkp7q19f0/0.jpg) -![youtube2](http://img.youtube.com/vi/IkV2HQLAKHY/0.jpg) - diff --git a/test/books/images/SUMMARY.md b/test/books/images/SUMMARY.md deleted file mode 100644 index ec0c4fca4..000000000 --- a/test/books/images/SUMMARY.md +++ /dev/null @@ -1,3 +0,0 @@ -# Summary - -* [Page](./folder/PAGE.md) diff --git a/test/books/images/folder/PAGE.md b/test/books/images/folder/PAGE.md deleted file mode 100644 index 8beb060ef..000000000 --- a/test/books/images/folder/PAGE.md +++ /dev/null @@ -1,3 +0,0 @@ -### Images from other folder -![test.svg](../test.svg) - diff --git a/test/books/images/test.svg b/test/books/images/test.svg deleted file mode 100644 index 48bba7061..000000000 --- a/test/books/images/test.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - basic SVG tiny doc - - hello world - - Revision: 1.1 - - - diff --git a/test/books/init/.gitignore b/test/books/init/.gitignore deleted file mode 100644 index 8a88b2a94..000000000 --- a/test/books/init/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -* -!SUMMARY.md -!.gitignore diff --git a/test/books/init/SUMMARY.md b/test/books/init/SUMMARY.md deleted file mode 100644 index 31c1561d6..000000000 --- a/test/books/init/SUMMARY.md +++ /dev/null @@ -1,9 +0,0 @@ -# Summary - -* [Hello](hello.md) -* [Hello 2](hello2.md) -* Hello 3 - * [Hello 4](hello3/hello4.md) - * Hello 5 - * [Hello 6](hello3/hello5/hello6.md) - diff --git a/test/books/languages/LANGS.md b/test/books/languages/LANGS.md deleted file mode 100644 index 4267b3c4b..000000000 --- a/test/books/languages/LANGS.md +++ /dev/null @@ -1,4 +0,0 @@ -# Language - -* [English](en) -* [French](fr) diff --git a/test/books/languages/README.md b/test/books/languages/README.md deleted file mode 100644 index f3954314c..000000000 --- a/test/books/languages/README.md +++ /dev/null @@ -1 +0,0 @@ -# Readme diff --git a/test/books/languages/en/README.md b/test/books/languages/en/README.md deleted file mode 100644 index e965047ad..000000000 --- a/test/books/languages/en/README.md +++ /dev/null @@ -1 +0,0 @@ -Hello diff --git a/test/books/languages/en/SUMMARY.md b/test/books/languages/en/SUMMARY.md deleted file mode 100644 index ac9323cbc..000000000 --- a/test/books/languages/en/SUMMARY.md +++ /dev/null @@ -1 +0,0 @@ -# Summary diff --git a/test/books/languages/fr/README.md b/test/books/languages/fr/README.md deleted file mode 100644 index 632e4fe73..000000000 --- a/test/books/languages/fr/README.md +++ /dev/null @@ -1 +0,0 @@ -Bonjour diff --git a/test/books/languages/fr/SUMMARY.md b/test/books/languages/fr/SUMMARY.md deleted file mode 100644 index ac9323cbc..000000000 --- a/test/books/languages/fr/SUMMARY.md +++ /dev/null @@ -1 +0,0 @@ -# Summary diff --git a/test/books/links/README.md b/test/books/links/README.md deleted file mode 100644 index f3954314c..000000000 --- a/test/books/links/README.md +++ /dev/null @@ -1 +0,0 @@ -# Readme diff --git a/test/books/links/SUMMARY.md b/test/books/links/SUMMARY.md deleted file mode 100644 index a7debc22a..000000000 --- a/test/books/links/SUMMARY.md +++ /dev/null @@ -1,4 +0,0 @@ -# Summary - -* [Folder1](folder1/README.md) -* [Folder2](folder2/README.md) \ No newline at end of file diff --git a/test/books/links/folder1/README.md b/test/books/links/folder1/README.md deleted file mode 100644 index 99a83f667..000000000 --- a/test/books/links/folder1/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# Folder 1 - -[Link to folder2](../folder2/README.md) diff --git a/test/books/links/folder2/README.md b/test/books/links/folder2/README.md deleted file mode 100644 index aa2056ab6..000000000 --- a/test/books/links/folder2/README.md +++ /dev/null @@ -1 +0,0 @@ -# Folder 2 diff --git a/test/books/structure/README.adoc b/test/books/structure/README.adoc deleted file mode 100644 index 354647f02..000000000 --- a/test/books/structure/README.adoc +++ /dev/null @@ -1 +0,0 @@ -== Readme for the bookk diff --git a/test/books/structure/README.md b/test/books/structure/README.md deleted file mode 100644 index 94f18a813..000000000 --- a/test/books/structure/README.md +++ /dev/null @@ -1 +0,0 @@ -# Readme for GitHub diff --git a/test/books/structure/SUMMARY.md b/test/books/structure/SUMMARY.md deleted file mode 100644 index ac9323cbc..000000000 --- a/test/books/structure/SUMMARY.md +++ /dev/null @@ -1 +0,0 @@ -# Summary diff --git a/test/books/structure/book.json b/test/books/structure/book.json deleted file mode 100644 index 110e0ba35..000000000 --- a/test/books/structure/book.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "structure": { - "readme": "README.adoc" - } -} \ No newline at end of file diff --git a/test/books/structure/glossary.md b/test/books/structure/glossary.md deleted file mode 100644 index 8c6c0fd77..000000000 --- a/test/books/structure/glossary.md +++ /dev/null @@ -1,5 +0,0 @@ -# Glossary - -### Hello - -Hello world diff --git a/test/books/style-print/README.md b/test/books/style-print/README.md deleted file mode 100644 index 09ade4067..000000000 --- a/test/books/style-print/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# Readme - -Default description for the book. diff --git a/test/books/style-print/SUMMARY.md b/test/books/style-print/SUMMARY.md deleted file mode 100644 index ac9323cbc..000000000 --- a/test/books/style-print/SUMMARY.md +++ /dev/null @@ -1 +0,0 @@ -# Summary diff --git a/test/books/style-print/styles/print.css b/test/books/style-print/styles/print.css deleted file mode 100644 index b05faf8ea..000000000 --- a/test/books/style-print/styles/print.css +++ /dev/null @@ -1,3 +0,0 @@ -body { - color: red; -} diff --git a/test/books/summary/PAGE1.md b/test/books/summary/PAGE1.md deleted file mode 100644 index 960800143..000000000 --- a/test/books/summary/PAGE1.md +++ /dev/null @@ -1 +0,0 @@ -# Page 1 diff --git a/test/books/summary/README.md b/test/books/summary/README.md deleted file mode 100644 index f3954314c..000000000 --- a/test/books/summary/README.md +++ /dev/null @@ -1 +0,0 @@ -# Readme diff --git a/test/books/summary/SUMMARY.md b/test/books/summary/SUMMARY.md deleted file mode 100644 index 38d0f6b69..000000000 --- a/test/books/summary/SUMMARY.md +++ /dev/null @@ -1,5 +0,0 @@ -# Summary - -* [Page 1](./PAGE1.md) -* [Page 2](./folder/PAGE2.md) -* [Don't exists](./NOTFOUND.md) diff --git a/test/books/summary/folder/PAGE2.md b/test/books/summary/folder/PAGE2.md deleted file mode 100644 index f310be332..000000000 --- a/test/books/summary/folder/PAGE2.md +++ /dev/null @@ -1 +0,0 @@ -# Page 2 diff --git a/test/codehighlighting.js b/test/codehighlighting.js deleted file mode 100644 index f167980a6..000000000 --- a/test/codehighlighting.js +++ /dev/null @@ -1,65 +0,0 @@ -var path = require('path'); -var fs = require('fs'); - -var Plugin = require('../lib/plugin'); -var PLUGINS_ROOT = path.resolve(__dirname, 'plugins'); - -describe('Code Highlighting', function () { - var book, PAGE; - - before(function() { - return books.generate('highlight', 'website', { - prepare: function(_book) { - book = _book; - - var plugin = new Plugin(book, 'replace_highlight'); - plugin.load('./replace_highlight', PLUGINS_ROOT); - - book.plugins.load(plugin); - } - }) - .then(function() { - PAGE = fs.readFileSync( - path.join(book.options.output, 'index.html'), - { encoding: 'utf-8' } - ); - }); - }); - - it('should correctly replace highlighting', function() { - PAGE.should.be.html({ - 'code': { - index: 0, - text: 'code_test 1\n_code' - } - }); - }); - - it('should correctly replace highlighting with language', function() { - PAGE.should.be.html({ - 'code': { - index: 1, - text: 'lang_test 2\n_lang' - } - }); - }); - - it('should correctly replace highlighting for inline code', function() { - PAGE.should.be.html({ - 'code': { - index: 2, - text: 'code_test 3_code' - } - }); - }); - - it('should correctly replace highlighting for inline code with html tags', function() { - PAGE.should.be.html({ - 'code': { - index: 3, - text: 'code__code' - } - }); - }); -}); - diff --git a/test/config.js b/test/config.js new file mode 100644 index 000000000..be28fc9a2 --- /dev/null +++ b/test/config.js @@ -0,0 +1,137 @@ +var should = require('should'); +var mock = require('./mock'); +var validator = require('../lib/config/validator'); + +describe('Configuration', function() { + + describe('Validation', function() { + it('should merge default', function() { + validator.validate({}).should.have.property('gitbook').which.equal('*'); + }); + + it('should throw error for invalid configuration', function() { + should.throws(function() { + validator.validate({ + direction: 'invalid' + }); + }); + }); + + it('should not throw error for non existing configuration', function() { + validator.validate({ + style: { + 'pdf': 'test.css' + } + }); + }); + + it('should validate plugins as an array', function() { + validator.validate({ + plugins: ['hello'] + }); + }); + + it('should validate plugins as a string', function() { + validator.validate({ + plugins: 'hello,world' + }); + }); + + }); + + describe('No configuration', function() { + var book; + + before(function() { + return mock.setupDefaultBook() + .then(function(_book) { + book = _book; + return book.config.load(); + }); + }); + + it('should signal that configuration is not defined', function() { + book.config.exists().should.not.be.ok(); + }); + }); + + describe('JSON file', function() { + var book; + + before(function() { + return mock.setupDefaultBook({ + 'book.json': { title: 'Hello World' } + }) + .then(function(_book) { + book = _book; + return book.config.load(); + }); + }); + + it('should correctly extend configuration', function() { + book.config.get('title', '').should.equal('Hello World'); + }); + }); + + describe('JS file', function() { + var book; + + before(function() { + return mock.setupDefaultBook({ + 'book.js': 'module.exports = { title: "Hello World" };' + }) + .then(function(_book) { + book = _book; + return book.config.load(); + }); + }); + + it('should correctly extend configuration', function() { + book.config.get('title', '').should.equal('Hello World'); + }); + }); + + describe('Multilingual', function() { + var book; + + before(function() { + return mock.setupDefaultBook({ + 'book.json': { + title: 'Hello World', + pluginsConfig: { + 'test': { + 'hello': true + } + } + }, + 'LANGS.md': '# Languages\n\n' + + '* [en](./en)\n' + + '* [fr](./fr)\n\n', + 'en/README.md': '# Hello', + 'fr/README.md': '# Bonjour', + 'en/book.json': { description: 'In english' }, + 'fr/book.json': { description: 'En francais' } + }) + .then(function(_book) { + book = _book; + return book.parse(); + }); + }); + + it('should correctly extend configuration', function() { + book.config.get('title', '').should.equal('Hello World'); + book.config.get('description', '').should.equal(''); + + var en = book.books[0]; + en.config.get('title', '').should.equal('Hello World'); + en.config.get('description', '').should.equal('In english'); + en.config.get('pluginsConfig.test.hello').should.equal(true); + + var fr = book.books[1]; + fr.config.get('title', '').should.equal('Hello World'); + fr.config.get('description', '').should.equal('En francais'); + fr.config.get('pluginsConfig.test.hello').should.equal(true); + }); + }); +}); + diff --git a/test/configuration.js b/test/configuration.js deleted file mode 100644 index d30fd61e7..000000000 --- a/test/configuration.js +++ /dev/null @@ -1,37 +0,0 @@ -describe('Configuration', function () { - it('should extract default title from README', function() { - return books.parse('basic') - .then(function(book) { - book.options.title.should.be.equal('Readme'); - }); - }); - - it('should extract default description from README', function() { - return books.parse('basic') - .then(function(book) { - book.options.description.should.be.equal('Default description for the book.'); - }); - }); - - it('should correctly load from json (book.json)', function() { - return books.parse('config-json') - .then(function(book) { - book.options.title.should.be.equal('json-config'); - }); - }); - - it('should correctly load from JavaScript (book.js)', function() { - return books.parse('config-js') - .then(function(book) { - book.options.title.should.be.equal('js-config'); - }); - }); - - it('should provide configuration on book.config.get', function() { - return books.parse('basic') - .then(function(book) { - book.config.get('description').should.be.equal('Default description for the book.'); - book.getConfig('description').should.be.equal('Default description for the book.'); - }); - }); -}); diff --git a/test/conrefs.js b/test/conrefs.js index 32e40584c..65007095f 100644 --- a/test/conrefs.js +++ b/test/conrefs.js @@ -1,68 +1,49 @@ -var fs = require('fs'); -var path = require('path'); +var mock = require('./mock'); +var ConrefsLoader = require('../lib/output/conrefs')(); -describe('ConRefs', function () { - var book, readme; + +describe('Conrefs Loader', function() { + var output; before(function() { - return books.generate('conrefs', 'website') - .then(function(_book) { - book = _book; - - readme = fs.readFileSync( - path.join(book.options.output, 'index.html'), - { encoding: 'utf-8' } - ); - }); - }); - - it('should handle local references', function() { - readme.should.be.html({ - '.page-inner p#t1': { - count: 1, - text: 'Hello World', - trim: true - } + return mock.outputDefaultBook(ConrefsLoader, { + 'test.md': 'World' + }) + .then(function(_output) { + output = _output; }); }); - it('should handle local references with absolute paths', function() { - readme.should.be.html({ - '.page-inner p#t2': { - count: 1, - text: 'Hello World', - trim: true - } - }); + + it('should include a local file', function() { + return output.template.renderString('Hello {% include "./test.md" %}') + .should.be.fulfilledWith('Hello World'); }); - it('should correctly include file from git reference', function() { - readme.should.be.html({ - '.page-inner p#t3': { - count: 1, - text: 'Hello from git', - trim: true - } - }); + it('should include a git url', function() { + return output.template.renderString('Hello {% include "./test.md" %}') + .should.be.fulfilledWith('Hello World'); }); - it('should correctly handle deep include in git reference', function() { - readme.should.be.html({ - '.page-inner p#t4': { - count: 1, - text: 'First Hello. Hello from git', - trim: true - } - }); + it('should reject file out of scope', function() { + return output.template.renderString('Hello {% include "../test.md" %}') + .should.be.rejected(); }); - it('should correctly handle absolute include in git reference', function() { - readme.should.be.html({ - '.page-inner p#t5': { - count: 1, - text: 'First Hello. Hello from git', - trim: true - } + describe('Git Urls', function() { + it('should include a file from a git repo', function() { + return output.template.renderString('{% include "git+https://gist.github.com/69ea4542e4c8967d2fa7.git/test.md" %}') + .should.be.fulfilledWith('Hello from git'); + }); + + it('should handle deep inclusion (1)', function() { + return output.template.renderString('{% include "git+https://gist.github.com/69ea4542e4c8967d2fa7.git/test2.md" %}') + .should.be.fulfilledWith('First Hello. Hello from git'); + }); + + it('should handle deep inclusion (2)', function() { + return output.template.renderString('{% include "git+https://gist.github.com/69ea4542e4c8967d2fa7.git/test3.md" %}') + .should.be.fulfilledWith('First Hello. Hello from git'); }); }); }); diff --git a/test/ebook.js b/test/ebook.js deleted file mode 100644 index 5caf28b0e..000000000 --- a/test/ebook.js +++ /dev/null @@ -1,70 +0,0 @@ -var fs = require('fs'); -var path = require('path'); - -describe('eBook generator', function () { - describe('Basic Book', function() { - var book; - - before(function() { - return books.generate('basic', 'ebook') - .then(function(_book) { - book = _book; - }); - }); - - it('should correctly output a SUMMARY.html', function() { - book.should.have.file('SUMMARY.html'); - }); - - it('should correctly copy assets', function() { - book.should.have.file('gitbook'); - book.should.have.file('gitbook/ebook.css'); - }); - - it('should not copy website assets', function() { - book.should.not.have.file('gitbook/style.css'); - }); - }); - - describe('Custom styles', function() { - var book; - - before(function() { - return books.generate('style-print', 'ebook') - .then(function(_book) { - book = _book; - }); - }); - - it('should correctly copy print.css', function() { - book.should.have.file('styles'); - book.should.have.file('styles/print.css'); - }); - - it('should remove default print.css', function() { - var PAGE = fs.readFileSync( - path.join(book.options.output, 'index.html'), - { encoding: 'utf-8' } - ); - - // There are 2 styles (one from plugin-highlight and the new style) - PAGE.should.be.html({ - 'link': { - count: 2 - } - }); - - PAGE.should.be.html({ - 'link[href=\'./styles/print.css\']': { - count: 1 - } - }); - - PAGE.should.be.html({ - 'link[href="gitbook/plugins/gitbook-plugin-highlight/ebook.css"]': { - count: 1 - } - }); - }); - }); -}); diff --git a/test/format.js b/test/format.js deleted file mode 100644 index 2ec1a6f75..000000000 --- a/test/format.js +++ /dev/null @@ -1,11 +0,0 @@ -describe('Formatting', function () { - it('should provide formatting with book.formatString', function() { - return books.parse('basic') - .then(function(book) { - return book.formatString('markdown', 'this is a **test**'); - }) - .then(function(content) { - content.should.equal('

this is a test

\n'); - }); - }); -}); diff --git a/test/git.js b/test/git.js index 6fd6b41c8..9fd749033 100644 --- a/test/git.js +++ b/test/git.js @@ -1,31 +1,61 @@ -var should = require("should"); -var git = require("../lib/utils/git"); +var should = require('should'); +var path = require('path'); +var os = require('os'); -describe("GIT parser and getter", function () { - it("should correctly parse an https url", function() { - var parts = git.parseUrl("git+https://gist.github.com/69ea4542e4c8967d2fa7.git/test.md"); +var Git = require('../lib/utils/git'); - should.exist(parts); - parts.host.should.be.equal("https://gist.github.com/69ea4542e4c8967d2fa7.git"); - parts.ref.should.be.equal("master"); - parts.filepath.should.be.equal("test.md"); +describe('Git', function() { + + describe('URL parsing', function() { + + it('should correctly validate git urls', function() { + // HTTPS + Git.isUrl('git+https://github.com/Hello/world.git').should.be.ok(); + + // SSH + Git.isUrl('git+git@github.com:GitbookIO/gitbook.git/directory/README.md#e1594cde2c32e4ff48f6c4eff3d3d461743d74e1').should.be.ok; + + // Non valid + Git.isUrl('https://github.com/Hello/world.git').should.not.be.ok(); + Git.isUrl('README.md').should.not.be.ok(); + }); + + it('should parse HTTPS urls', function() { + var parts = Git.parseUrl('git+https://gist.github.com/69ea4542e4c8967d2fa7.git/test.md'); + + should.exist(parts); + parts.host.should.be.equal('https://gist.github.com/69ea4542e4c8967d2fa7.git'); + should(parts.ref).be.equal(null); + parts.filepath.should.be.equal('test.md'); + }); + + it('should parse HTTPS urls with a reference', function() { + var parts = Git.parseUrl('git+https://gist.github.com/69ea4542e4c8967d2fa7.git/test.md#1.0.0'); + + should.exist(parts); + parts.host.should.be.equal('https://gist.github.com/69ea4542e4c8967d2fa7.git'); + parts.ref.should.be.equal('1.0.0'); + parts.filepath.should.be.equal('test.md'); + }); + + it('should parse SSH urls', function() { + var parts = Git.parseUrl('git+git@github.com:GitbookIO/gitbook.git/directory/README.md#e1594cde2c32e4ff48f6c4eff3d3d461743d74e1'); + + should.exist(parts); + parts.host.should.be.equal('git@github.com:GitbookIO/gitbook.git'); + parts.ref.should.be.equal('e1594cde2c32e4ff48f6c4eff3d3d461743d74e1'); + parts.filepath.should.be.equal('directory/README.md'); + }); }); - it("should correctly parse an https url with a reference", function() { - var parts = git.parseUrl("git+https://gist.github.com/69ea4542e4c8967d2fa7.git/test.md#0.1.2"); - - should.exist(parts); - parts.host.should.be.equal("https://gist.github.com/69ea4542e4c8967d2fa7.git"); - parts.ref.should.be.equal("0.1.2"); - parts.filepath.should.be.equal("test.md"); + describe('Cloning and resolving', function() { + it('should clone an HTTPS url', function() { + var git = new Git(path.join(os.tmpdir(), 'test-git-'+Date.now())); + return git.resolve('git+https://gist.github.com/69ea4542e4c8967d2fa7.git/test.md') + .then(function(filename) { + path.extname(filename).should.equal('.md'); + }); + }); }); - it("should correctly parse an ssh url", function() { - var parts = git.parseUrl("git+git@github.com:GitbookIO/gitbook.git/directory/README.md#e1594cde2c32e4ff48f6c4eff3d3d461743d74e1"); - - should.exist(parts); - parts.host.should.be.equal("git@github.com:GitbookIO/gitbook.git"); - parts.ref.should.be.equal("e1594cde2c32e4ff48f6c4eff3d3d461743d74e1"); - parts.filepath.should.be.equal("directory/README.md"); - }); }); diff --git a/test/glossary.js b/test/glossary.js index f0f31a5e0..d6d1af613 100644 --- a/test/glossary.js +++ b/test/glossary.js @@ -1,87 +1,71 @@ -var fs = require("fs"); -var path = require("path"); +var should = require('should'); +var mock = require('./mock'); -describe("Glossary", function () { - describe("Parsing", function() { - var book; +describe('Glossary', function() { + it('should parse empty glossary', function() { + return mock.setupDefaultBook({ + 'GLOSSARY.md': '' + }) + .then(function(book) { + return book.config.load() - before(function() { - return books.parse("glossary") - .then(function(_book) { - book = _book; - }); - }); - - it("should correctly list items", function() { - book.should.have.property("glossary"); - book.glossary.should.have.lengthOf(3); + .then(function() { + return book.glossary.load(); + }) + .then(function() { + book.glossary.isEmpty().should.be.true(); + }); }); }); - describe("Generation", function() { + describe('Non-empty glossary', function() { var book; before(function() { - return books.generate("glossary", "website") - .then(function(_book) { - book = _book; - }); + return mock.setupDefaultBook({ + 'GLOSSARY.md': '# Glossary\n\n## Hello World\n\nThis is an entry' + }) + .then(function(_book) { + book = _book; + return book.config.load(); + }) + .then(function() { + return book.glossary.load(); + }); }); - it("should correctly generate a GLOSSARY.html", function() { - book.should.have.file("GLOSSARY.html"); + it('should not be empty', function() { + book.glossary.isEmpty().should.be.false(); }); - describe("Page Integration", function() { - var readme, page; + describe('glossary.get', function() { + it('should return an existing entry', function() { + var entry = book.glossary.get('hello_world'); + should.exist(entry); - before(function() { - readme = fs.readFileSync( - path.join(book.options.output, "index.html"), - { encoding: "utf-8" } - ); - page = fs.readFileSync( - path.join(book.options.output, "folder/PAGE.html"), - { encoding: "utf-8" } - ); + entry.name.should.equal('Hello World'); + entry.description.should.equal('This is an entry'); + entry.id.should.equal('hello_world'); }); - it("should correctly replaced terms by links", function() { - readme.should.be.html({ - ".page-inner a[href=\"GLOSSARY.html#test\"]": { - count: 1, - text: "test", - attributes: { - title: "Just a simple and easy to understand test." - } - } - }); + it('should undefined return non existing entry', function() { + var entry = book.glossary.get('cool'); + should.not.exist(entry); + }); + }); + + describe('glossary.find', function() { + it('should return an existing entry', function() { + var entry = book.glossary.find('HeLLo World'); + should.exist(entry); + entry.id.should.equal('hello_world'); }); - it("should correctly replaced terms by links (relative)", function() { - page.should.be.html({ - ".page-inner a[href=\"../GLOSSARY.html#test\"]": { - count: 1 - } - }); - }); - - it("should not replace terms in codeblocks", function() { - readme.should.be.html({ - ".page-inner code a": { - count: 0 - } - }); - }); - - it("should correctly select the longest term", function() { - readme.should.be.html({ - ".page-inner a[href=\"GLOSSARY.html#test_long\"]": { - count: 1, - text: "test long" - } - }); + it('should return undefined for non existing entry', function() { + var entry = book.glossary.find('Hello'); + should.not.exist(entry); }); }); }); }); + diff --git a/test/heading.js b/test/heading.js deleted file mode 100644 index f6d65c3fb..000000000 --- a/test/heading.js +++ /dev/null @@ -1,37 +0,0 @@ -var path = require('path'); -var fs = require('fs'); - -describe('Headings', function () { - var book, PAGE; - - before(function() { - return books.generate('headings', 'website') - .then(function(_book) { - book = _book; - - PAGE = fs.readFileSync( - path.join(book.options.output, 'index.html'), - { encoding: 'utf-8' } - ); - }); - }); - - describe('IDs', function() { - it('should correctly generate an ID', function() { - PAGE.should.be.html({ - 'h1#hello-world': { - count: 1 - } - }); - }); - - it('should correctly accept custom ID', function() { - PAGE.should.be.html({ - 'h2#hello-custom': { - count: 1 - } - }); - }); - }); -}); - diff --git a/test/helper.js b/test/helper.js deleted file mode 100644 index bbe82de47..000000000 --- a/test/helper.js +++ /dev/null @@ -1,78 +0,0 @@ -var os = require('os'); -var path = require('path'); -var Q = require('q'); -var _ = require('lodash'); - -var fsUtil = require('../lib/utils/fs'); -var Book = require('../').Book; -var LOG_LEVELS = require('../').LOG_LEVELS; - -require('./assertions'); - - -var BOOKS = {}; -var TMPDIR = os.tmpdir(); - - -// Generate and return a book -function generateBook(bookId, test, opts) { - opts = _.defaults(opts || {}, { - prepare: function() {} - }); - - return parseBook(bookId, test, opts) - .then(function(book) { - - return Q(opts.prepare(book)) - .then(function() { - return book.generate(test); - }) - .thenResolve(book); - }); -} - -// Generate and return a book -function parseBook(bookId, test, opts) { - opts = _.defaults(opts || {}, { - testId: '' - }); - - test = test || 'website'; - var testId = [test, opts.testId].join('-'); - - BOOKS[bookId] = BOOKS[bookId] || {}; - if (BOOKS[bookId][testId]) return Q(BOOKS[bookId][testId]); - - BOOKS[bookId][testId] = new Book(path.resolve(__dirname, 'books', bookId), { - logLevel: LOG_LEVELS.DISABLED, - config: { - output: path.resolve(TMPDIR, bookId+'-'+testId) - } - }); - - return BOOKS[bookId][testId].parse() - .then(function() { - return BOOKS[bookId][testId]; - }); -} - - -global.books = { - parse: parseBook, - generate: generateBook -}; - -// Cleanup all tests -after(function() { - return _.chain(BOOKS) - .map(function(types) { - return _.values(types); - }) - .flatten() - .reduce(function(prev, book) { - return prev.then(function() { - return fsUtil.remove(book.options.output); - }); - }, Q()) - .value(); -}); diff --git a/test/images.js b/test/images.js deleted file mode 100644 index de4506642..000000000 --- a/test/images.js +++ /dev/null @@ -1,58 +0,0 @@ -var fs = require("fs"); -var _ = require("lodash"); -var path = require("path"); -var cheerio = require("cheerio"); - -describe("Images", function () { - var book, readme, $, $img, srcs; - - before(function() { - return books.generate("images", "ebook") - .then(function(_book) { - book = _book; - - readme = fs.readFileSync( - path.join(book.options.output, "index.html"), - { encoding: "utf-8" } - ); - $ = cheerio.load(readme); - $img = $("img"); - srcs = $img.map(function() { - return $(this).attr("src"); - }); - }); - }); - - it("should detect all images", function() { - _.uniq(srcs).should.have.lengthOf(4); - }); - - it("should keep image tags", function() { - srcs.should.have.lengthOf(5); - }); - - it("should not have .svg files", function() { - _.each(srcs, function(src) { - path.extname(src).should.not.equal(".svg"); - }); - }); - - it("should correctly convert svg images to png", function() { - _.each(srcs, function(src) { - book.should.have.file(src); - }); - }); - - it("should handle relative paths", function() { - var PAGE = fs.readFileSync( - path.join(book.options.output, "folder/PAGE.html"), - { encoding: "utf-8" } - ); - - PAGE.should.be.html({ - "img[src=\"../test.png\"]": { - count: 1 - } - }); - }); -}); diff --git a/test/init.js b/test/init.js deleted file mode 100644 index 625d77c4d..000000000 --- a/test/init.js +++ /dev/null @@ -1,24 +0,0 @@ -var fs = require('fs'); -var path = require('path'); -var should = require('should'); - -var Book = require('../').Book; -var LOG_LEVELS = require('../').LOG_LEVELS; - -describe('Init Books', function () { - var initRoot; - - before(function() { - initRoot = path.resolve(__dirname, 'books/init'); - return Book.init(initRoot, { - logLevel: LOG_LEVELS.DISABLED - }); - }); - - it('should create all chapters', function() { - should(fs.existsSync(path.resolve(initRoot, 'hello.md'))).be.ok(); - should(fs.existsSync(path.resolve(initRoot, 'hello2.md'))).be.ok(); - should(fs.existsSync(path.resolve(initRoot, 'hello3/hello4.md'))).be.ok(); - should(fs.existsSync(path.resolve(initRoot, 'hello3/hello5/hello6.md'))).be.ok(); - }); -}); diff --git a/test/json.js b/test/json.js deleted file mode 100644 index 60baf9ae9..000000000 --- a/test/json.js +++ /dev/null @@ -1,92 +0,0 @@ -var fs = require('fs'); -var path = require('path'); - -describe('JSON generator', function () { - describe('Basic Book', function() { - var book; - - before(function() { - return books.generate('basic', 'json') - .then(function(_book) { - book = _book; - }); - }); - - it('should correctly output a README.json', function() { - book.should.have.file('README.json'); - }); - - it('should output a valid json', function() { - book.should.have.jsonfile('README.json'); - }); - - describe('Page Format', function() { - var page; - - before(function() { - page = JSON.parse( - fs.readFileSync( - path.join(book.options.output, 'README.json'), - { encoding: 'utf-8' } - ) - ); - }); - - it('should contains valid section', function() { - page.should.have.property('sections').with.lengthOf(1); - page.sections[0].should.have.property('content').which.is.a.String(); - page.sections[0].should.have.property('type', 'normal'); - }); - - it('should contains valid progress', function() { - page.should.have.property('progress'); - page.progress.should.have.property('chapters').with.lengthOf(1); - page.progress.should.have.property('current'); - }); - - it('should contains no languages', function() { - page.should.have.property('langs').with.lengthOf(0); - }); - }); - }); - - describe('Multilingual Book', function() { - var book; - - before(function() { - return books.generate('languages', 'json') - .then(function(_book) { - book = _book; - }); - }); - - it('should correctly output READMEs', function() { - book.should.have.file('README.json'); - book.should.have.file('en/README.json'); - book.should.have.file('fr/README.json'); - }); - - it('should output valid json', function() { - book.should.have.jsonfile('README.json'); - book.should.have.jsonfile('en/README.json'); - book.should.have.jsonfile('fr/README.json'); - }); - - describe('Page Format', function() { - var page; - - before(function() { - page = JSON.parse( - fs.readFileSync( - path.join(book.options.output, 'README.json'), - { encoding: 'utf-8' } - ) - ); - }); - - it('should contains no languages', function() { - page.should.have.property('langs').with.lengthOf(2); - }); - }); - }); -}); diff --git a/test/langs.js b/test/langs.js new file mode 100644 index 000000000..91dbd5a5d --- /dev/null +++ b/test/langs.js @@ -0,0 +1,46 @@ +var mock = require('./mock'); + +describe('Langs', function() { + it('should parse empty langs', function() { + return mock.setupDefaultBook({ + 'LANGS.md': '' + }) + .then(function(book) { + return book.config.load() + + .then(function() { + return book.langs.load(); + }) + + .then(function() { + book.langs.count().should.equal(0); + }); + }); + }); + + describe('Non-empty languages list', function() { + var book; + + before(function() { + return mock.setupDefaultBook({ + 'LANGS.md': '# Languages\n\n' + + '* [en](./en)\n' + + '* [fr](./fr)\n\n' + }) + .then(function(_book) { + book = _book; + + return book.langs.load(); + }); + }); + + it('should correctly count languages', function() { + book.langs.count().should.equal(2); + }); + + it('should correctly define book as multilingual', function() { + book.isMultilingual().should.equal(true); + }); + }); +}); + diff --git a/test/languages.js b/test/languages.js deleted file mode 100644 index 0bde347cb..000000000 --- a/test/languages.js +++ /dev/null @@ -1,37 +0,0 @@ -describe("Languages", function () { - describe("Parsing", function() { - var book; - - before(function() { - return books.parse("languages") - .then(function(_book) { - book = _book; - }); - }); - - it("should correctly list languages", function() { - book.should.have.property("books"); - book.books.should.have.lengthOf(2); - - book.books[0].options.language.should.be.equal("en"); - book.books[1].options.language.should.be.equal("fr"); - }); - }); - - describe("Generation", function() { - var book; - - before(function() { - return books.generate("languages", "website") - .then(function(_book) { - book = _book; - }); - }); - - it("should correctly create books", function() { - book.should.have.file("index.html"); - book.should.have.file("en/index.html"); - book.should.have.file("fr/index.html"); - }); - }); -}); diff --git a/test/links.js b/test/links.js deleted file mode 100644 index baca9d1b3..000000000 --- a/test/links.js +++ /dev/null @@ -1,63 +0,0 @@ -var fs = require("fs"); -var path = require("path"); -var cheerio = require("cheerio"); - -var links = require("../lib/utils/links"); - -describe("Links", function () { - it("should correctly test external links", function() { - links.isExternal("http://google.fr").should.be.exactly(true); - links.isExternal("https://google.fr").should.be.exactly(true); - links.isExternal("test.md").should.be.exactly(false); - links.isExternal("folder/test.md").should.be.exactly(false); - links.isExternal("/folder/test.md").should.be.exactly(false); - }); - - it("should correctly detect anchor links", function() { - links.isAnchor("#test").should.be.exactly(true); - links.isAnchor(" #test").should.be.exactly(true); - links.isAnchor("https://google.fr#test").should.be.exactly(false); - links.isAnchor("test.md#test").should.be.exactly(false); - }); - - describe("toAbsolute", function() { - it("should correctly transform as absolute", function() { - links.toAbsolute("http://google.fr").should.be.equal("http://google.fr"); - links.toAbsolute("test.md", "./", "./").should.be.equal("test.md"); - links.toAbsolute("folder/test.md", "./", "./").should.be.equal("folder/test.md"); - }); - - it("should correctly handle windows path", function() { - links.toAbsolute("folder\\test.md", "./", "./").should.be.equal("folder/test.md"); - }); - - it("should correctly handle absolute path", function() { - links.toAbsolute("/test.md", "./", "./").should.be.equal("test.md"); - links.toAbsolute("/test.md", "test", "test").should.be.equal("../test.md"); - links.toAbsolute("/sub/test.md", "test", "test").should.be.equal("../sub/test.md"); - }); - }); - - describe("page", function() { - var book; - - before(function() { - return books.generate("links", "website") - .then(function(_book) { - book = _book; - }); - }); - - it("should correctly replace relative links", function() { - var readme = fs.readFileSync( - path.join(book.options.output, "folder1/index.html"), - { encoding: "utf-8" } - ); - var $ = cheerio.load(readme); - var $a = $(".page-inner a"); - - $a.attr("href").should.be.exactly("../folder2/index.html"); - }); - }); - -}); diff --git a/test/locate.js b/test/locate.js new file mode 100644 index 000000000..609f2da8a --- /dev/null +++ b/test/locate.js @@ -0,0 +1,28 @@ +var path = require('path'); + +var Book = require('../').Book; +var mock = require('./mock'); + +describe('Locate', function() { + it('should use root folder if no .gitbook', function() { + return mock.setupFS({ + 'README.md': '# Hello' + }) + .then(function(root) { + return Book.locate(mock.fs, root) + .should.be.fulfilledWith(root); + }); + }); + + it('should use resolve using .gitbook', function() { + return mock.setupFS({ + 'README.md': '# Hello', + '.gitbook': './docs' + }) + .then(function(root) { + return Book.locate(mock.fs, root) + .should.be.fulfilledWith(path.resolve(root, 'docs')); + }); + }); + +}); diff --git a/test/location.js b/test/location.js new file mode 100644 index 000000000..3e2294ea6 --- /dev/null +++ b/test/location.js @@ -0,0 +1,55 @@ +var location = require('../lib/utils/location'); + +describe('Location', function() { + it('should correctly test external location', function() { + location.isExternal('http://google.fr').should.be.exactly(true); + location.isExternal('https://google.fr').should.be.exactly(true); + location.isExternal('test.md').should.be.exactly(false); + location.isExternal('folder/test.md').should.be.exactly(false); + location.isExternal('/folder/test.md').should.be.exactly(false); + }); + + it('should correctly detect anchor location', function() { + location.isAnchor('#test').should.be.exactly(true); + location.isAnchor(' #test').should.be.exactly(true); + location.isAnchor('https://google.fr#test').should.be.exactly(false); + location.isAnchor('test.md#test').should.be.exactly(false); + }); + + describe('.relative', function() { + it('should resolve to a relative path (same folder)', function() { + location.relative('links/', 'links/test.md').should.equal('test.md'); + }); + + it('should resolve to a relative path (parent folder)', function() { + location.relative('links/', 'test.md').should.equal('../test.md'); + }); + + it('should resolve to a relative path (child folder)', function() { + location.relative('links/', 'links/hello/test.md').should.equal('hello/test.md'); + }); + }); + + describe('.toAbsolute', function() { + it('should correctly transform as absolute', function() { + location.toAbsolute('http://google.fr').should.be.equal('http://google.fr'); + location.toAbsolute('test.md', './', './').should.be.equal('test.md'); + location.toAbsolute('folder/test.md', './', './').should.be.equal('folder/test.md'); + }); + + it('should correctly handle windows path', function() { + location.toAbsolute('folder\\test.md', './', './').should.be.equal('folder/test.md'); + }); + + it('should correctly handle absolute path', function() { + location.toAbsolute('/test.md', './', './').should.be.equal('test.md'); + location.toAbsolute('/test.md', 'test', 'test').should.be.equal('../test.md'); + location.toAbsolute('/sub/test.md', 'test', 'test').should.be.equal('../sub/test.md'); + location.toAbsolute('/test.png', 'folder', '').should.be.equal('test.png'); + }); + + it('should correctly handle absolute path (windows)', function() { + location.toAbsolute('\\test.png', 'folder', '').should.be.equal('test.png'); + }); + }); +}); diff --git a/test/mock.js b/test/mock.js new file mode 100644 index 000000000..5ff9f6314 --- /dev/null +++ b/test/mock.js @@ -0,0 +1,116 @@ +/* eslint-disable no-console */ + +var Q = require('q'); +var _ = require('lodash'); +var tmp = require('tmp'); +var path = require('path'); + +var Book = require('../').Book; +var NodeFS = require('../lib/fs/node'); +var fs = require('../lib/utils/fs'); + +require('./assertions'); + +// Create filesystem instance for testing +var nodeFS = new NodeFS(); + +function setupFS(files) { + return Q.nfcall(tmp.dir.bind(tmp)).get(0) + .then(function(rootFolder) { + return _.chain(_.pairs(files)) + .sortBy(0) + .reduce(function(prev, pair) { + return prev.then(function() { + var filename = path.resolve(rootFolder, pair[0]); + var buf = pair[1]; + + if (_.isObject(buf)) buf = JSON.stringify(buf); + if (_.isString(buf)) buf = new Buffer(buf, 'utf-8'); + + return fs.mkdirp(path.dirname(filename)) + .then(function() { + return fs.writeFile(filename, buf); + }); + }); + }, Q()) + .value() + .then(function() { + return rootFolder; + }); + }); +} + +// Setup a mock book for testing using a map of files +function setupBook(files, opts) { + opts = opts || {}; + opts.log = function() { }; + + return setupFS(files) + .then(function(folder) { + opts.fs = nodeFS; + opts.root = folder; + + return new Book(opts); + }); +} + +// Setup a book with default README/SUMMARY +function setupDefaultBook(files, summary, opts) { + var summaryContent = '# Summary \n\n' + + _.map(summary, function(article) { + return '* [' + article.title +'](' + article.path + ')'; + }) + .join('\n'); + + return setupBook(_.defaults(files || {}, { + 'README.md': 'Hello', + 'SUMMARY.md': summaryContent + }), opts); +} + +// Output a book with a specific generator +function outputDefaultBook(Output, files, summary, opts) { + return setupDefaultBook(files, summary, opts) + .then(function(book) { + // Parse the book + return book.parse() + + // Start generation + .then(function() { + var output = new Output(book); + return output.generate() + .thenResolve(output); + }); + }); +} + +// Output a book with a specific generator +function outputBook(Output, files, opts) { + return setupBook(files, opts) + .then(function(book) { + // Parse the book + return book.parse() + + // Start generation + .then(function() { + var output = new Output(book); + return output.generate() + .thenResolve(output); + }); + }); +} + +// Log an error +function logError(err) { + console.log(err.stack || err); +} + +module.exports = { + fs: nodeFS, + setupFS: setupFS, + setupBook: setupBook, + outputBook: outputBook, + setupDefaultBook: setupDefaultBook, + outputDefaultBook: outputDefaultBook, + logError: logError +}; diff --git a/test/navigation.js b/test/navigation.js deleted file mode 100644 index 9118b3c8e..000000000 --- a/test/navigation.js +++ /dev/null @@ -1,61 +0,0 @@ -var should = require("should"); - -describe("Navigation", function () { - var book; - - before(function() { - return books.parse("summary") - .then(function(_book) { - book = _book; - }); - }); - - it("should correctly parse navigation as a map", function() { - book.should.have.property("navigation"); - book.navigation.should.have.property("README.md"); - book.navigation.should.have.property("README.md"); - }); - - it("should correctly include filenames", function() { - book.navigation.should.have.property("README.md"); - book.navigation.should.have.property("PAGE1.md"); - book.navigation.should.have.property("folder/PAGE2.md"); - book.navigation.should.not.have.property("NOTFOUND.md"); - }); - - it("should correctly detect next/prev for README", function() { - var README = book.navigation["README.md"]; - - README.index.should.equal(0); - README.should.have.property("next"); - should(README.prev).not.be.ok(); - - README.next.should.have.property("path"); - README.next.path.should.equal("PAGE1.md"); - }); - - it("should correctly detect next/prev a page", function() { - var PAGE = book.navigation["PAGE1.md"]; - - PAGE.index.should.equal(1); - PAGE.should.have.property("next"); - PAGE.should.have.property("prev"); - - PAGE.prev.should.have.property("path"); - PAGE.prev.path.should.equal("README.md"); - - PAGE.next.should.have.property("path"); - PAGE.next.path.should.equal("folder/PAGE2.md"); - }); - - it("should correctly detect next/prev for last page", function() { - var PAGE = book.navigation["folder/PAGE2.md"]; - - PAGE.index.should.equal(2); - PAGE.should.have.property("prev"); - should(PAGE.next).not.be.ok(); - - PAGE.prev.should.have.property("path"); - PAGE.prev.path.should.equal("PAGE1.md"); - }); -}); diff --git a/test/node_modules/gitbook-plugin-test-blocks/index.js b/test/node_modules/gitbook-plugin-test-blocks/index.js new file mode 100644 index 000000000..710400602 --- /dev/null +++ b/test/node_modules/gitbook-plugin-test-blocks/index.js @@ -0,0 +1,21 @@ +var should = require('should'); + +module.exports = { + blocks: { + // Simple test + hello: function(blk) { + return 'Hello ' + blk.body + '!'; + }, + + // Test using the conetxt "this" + testContext: function(s) { + this.should.have.property('config'); + this.should.have.property('log'); + this.should.have.property('options'); + this.should.have.property('resolve').which.is.a.function; + this.should.have.property('book').which.equal(this); + + return 'Hello ' + s + '!'; + } + } +}; diff --git a/test/node_modules/gitbook-plugin-test-blocks/package.json b/test/node_modules/gitbook-plugin-test-blocks/package.json new file mode 100644 index 000000000..a756fb515 --- /dev/null +++ b/test/node_modules/gitbook-plugin-test-blocks/package.json @@ -0,0 +1,7 @@ +{ + "name": "gitbook-plugin-test-blocks", + "version": "1.0.0", + "engines": { + "gitbook": "*" + } +} \ No newline at end of file diff --git a/test/plugins/config/index.js b/test/node_modules/gitbook-plugin-test-config/index.js similarity index 100% rename from test/plugins/config/index.js rename to test/node_modules/gitbook-plugin-test-config/index.js diff --git a/test/node_modules/gitbook-plugin-test-config/package.json b/test/node_modules/gitbook-plugin-test-config/package.json new file mode 100644 index 000000000..74bb95357 --- /dev/null +++ b/test/node_modules/gitbook-plugin-test-config/package.json @@ -0,0 +1,21 @@ +{ + "name": "gitbook-plugin-test-config", + "version": "1.0.0", + "engines": { + "gitbook": "*" + }, + "gitbook": { + "properties": { + "myProperty": { + "type": "string", + "required": true, + "title": "This is a required property" + }, + "myDefaultProperty": { + "type": "string", + "default": "hello", + "title": "This is a required property" + } + } + } +} \ No newline at end of file diff --git a/test/node_modules/gitbook-plugin-test-filters/index.js b/test/node_modules/gitbook-plugin-test-filters/index.js new file mode 100644 index 000000000..71f27520b --- /dev/null +++ b/test/node_modules/gitbook-plugin-test-filters/index.js @@ -0,0 +1,21 @@ +var should = require('should'); + +module.exports = { + filters: { + // Simple test + hello: function(s) { + return 'Hello ' + s + '!'; + }, + + // Test using the conetxt "this" + testContext: function(s) { + this.should.have.property('config'); + this.should.have.property('log'); + this.should.have.property('options'); + this.should.have.property('resolve').which.is.a.function; + this.should.have.property('book').which.equal(this); + + return 'Hello ' + s + '!'; + } + } +}; diff --git a/test/node_modules/gitbook-plugin-test-filters/package.json b/test/node_modules/gitbook-plugin-test-filters/package.json new file mode 100644 index 000000000..d555d0693 --- /dev/null +++ b/test/node_modules/gitbook-plugin-test-filters/package.json @@ -0,0 +1,7 @@ +{ + "name": "gitbook-plugin-test-filters", + "version": "1.0.0", + "engines": { + "gitbook": "*" + } +} \ No newline at end of file diff --git a/test/node_modules/gitbook-plugin-test-hooks/index.js b/test/node_modules/gitbook-plugin-test-hooks/index.js new file mode 100644 index 000000000..c0666ca8e --- /dev/null +++ b/test/node_modules/gitbook-plugin-test-hooks/index.js @@ -0,0 +1,16 @@ +var should = require('should'); + +module.exports = { + hooks: { + 'init': function() { + global._hooks = []; + global._hooks.push('init'); + }, + 'finish': function() { + global._hooks.push('finish'); + }, + 'finish:before': function() { + global._hooks.push('finish:before'); + } + } +}; diff --git a/test/node_modules/gitbook-plugin-test-hooks/package.json b/test/node_modules/gitbook-plugin-test-hooks/package.json new file mode 100644 index 000000000..adb12a88f --- /dev/null +++ b/test/node_modules/gitbook-plugin-test-hooks/package.json @@ -0,0 +1,7 @@ +{ + "name": "gitbook-plugin-test-hooks", + "version": "1.0.0", + "engines": { + "gitbook": "*" + } +} \ No newline at end of file diff --git a/test/node_modules/gitbook-plugin-test-resources/index.js b/test/node_modules/gitbook-plugin-test-resources/index.js new file mode 100644 index 000000000..e95e411aa --- /dev/null +++ b/test/node_modules/gitbook-plugin-test-resources/index.js @@ -0,0 +1,12 @@ +module.exports = { + book: { + assets: './assets', + js: [ + 'myfile.js', + 'https://ajax.googleapis.com/ajax/libs/angularjs/1.4.9/angular.min.js' + ], + css: [ + 'myfile.css' + ] + } +}; diff --git a/test/node_modules/gitbook-plugin-test-resources/package.json b/test/node_modules/gitbook-plugin-test-resources/package.json new file mode 100644 index 000000000..606de3114 --- /dev/null +++ b/test/node_modules/gitbook-plugin-test-resources/package.json @@ -0,0 +1,7 @@ +{ + "name": "gitbook-plugin-test-resources", + "version": "1.0.0", + "engines": { + "gitbook": "*" + } +} \ No newline at end of file diff --git a/test/output-ebook.js b/test/output-ebook.js new file mode 100644 index 000000000..c43fc5508 --- /dev/null +++ b/test/output-ebook.js @@ -0,0 +1,33 @@ +var mock = require('./mock'); +var EbookOutput = require('../lib/output/ebook'); + +describe('Ebook Output', function() { + describe('Sample Book', function() { + var output; + + before(function() { + return mock.outputDefaultBook(EbookOutput) + .then(function(_output) { + output = _output; + }); + }); + + it('should correctly generate an index.html', function() { + output.should.have.file('index.html'); + }); + + it('should correctly generate a SUMMARY.html', function() { + output.should.have.file('index.html'); + }); + + it('should correctly copy assets', function() { + output.should.have.file('gitbook/ebook.css'); + }); + + it('should correctly copy plugins', function() { + output.should.have.file('gitbook/gitbook-plugin-highlight/ebook.css'); + }); + + }); + +}); diff --git a/test/output-json.js b/test/output-json.js new file mode 100644 index 000000000..1006dab26 --- /dev/null +++ b/test/output-json.js @@ -0,0 +1,49 @@ +var mock = require('./mock'); +var JSONOutput = require('../lib/output/json'); + +describe('JSON Output', function() { + + describe('Sample book', function() { + var output; + + before(function() { + return mock.outputDefaultBook(JSONOutput) + .then(function(_output) { + output = _output; + }); + }); + + it('should correctly generate a README.json', function() { + output.should.have.file('README.json'); + }); + + }); + + describe('Multilingual Book', function() { + var output; + + before(function() { + return mock.outputBook(JSONOutput, { + 'LANGS.md': '# Languages\n\n' + + '* [en](./en)\n' + + '* [fr](./fr)\n\n', + 'en/README.md': '# Hello', + 'fr/README.md': '# Bonjour' + + }) + .then(function(_output) { + output = _output; + }); + }); + + it('should correctly generate a README.json for each language', function() { + output.should.have.file('en/README.json'); + output.should.have.file('fr/README.json'); + }); + + it('should correctly generate a README.json for the whole book', function() { + output.should.have.file('README.json'); + }); + }); +}); + diff --git a/test/output-website.js b/test/output-website.js new file mode 100644 index 000000000..19459b38f --- /dev/null +++ b/test/output-website.js @@ -0,0 +1,99 @@ +var mock = require('./mock'); +var WebsiteOutput = require('../lib/output/website'); + +describe('Website Output', function() { + + describe('Sample Book', function() { + var output; + + before(function() { + return mock.outputDefaultBook(WebsiteOutput) + .then(function(_output) { + output = _output; + }); + }); + + it('should correctly generate an index.html', function() { + output.should.have.file('index.html'); + }); + + it('should correctly copy assets', function() { + output.should.have.file('gitbook/app.js'); + output.should.have.file('gitbook/images/favicon.ico'); + }); + + it('should correctly copy plugins', function() { + output.should.have.file('gitbook/gitbook-plugin-highlight/website.css'); + }); + }); + + describe('Book with chapters', function() { + var output; + + before(function() { + return mock.outputDefaultBook(WebsiteOutput, { + 'hello/README.md': '# Hello', + 'hello/test.md': '# Test' + }, [ + { + title: 'Hello', + path: 'hello/README.md' + }, + { + title: 'Test', + path: 'hello/test.md' + } + ]) + .then(function(_output) { + output = _output; + }); + }); + + it('should correctly generate an index.html', function() { + output.should.have.file('index.html'); + }); + + it('should correctly generate files in folder', function() { + output.should.have.file('hello/index.html'); + output.should.have.file('hello/test.html'); + }); + }); + + describe('Multilingual Book', function() { + var output; + + before(function() { + return mock.outputBook(WebsiteOutput, { + 'LANGS.md': '# Languages\n\n' + + '* [en](./en)\n' + + '* [fr](./fr)\n\n', + 'en/README.md': '# Hello', + 'fr/README.md': '# Bonjour' + + }) + .then(function(_output) { + output = _output; + }); + }); + + it('should correctly generate an index.html for each language', function() { + output.should.have.file('en/index.html'); + output.should.have.file('fr/index.html'); + }); + + it('should correctly copy assets', function() { + output.should.have.file('gitbook/app.js'); + }); + + it('should not copy assets for each language', function() { + output.should.have.not.file('en/gitbook/app.js'); + output.should.have.not.file('fr/gitbook/app.js'); + }); + + it('should correctly generate an index.html', function() { + output.should.have.file('index.html'); + }); + }); + +}); + diff --git a/test/page.js b/test/page.js new file mode 100644 index 000000000..ae36660bb --- /dev/null +++ b/test/page.js @@ -0,0 +1,399 @@ +var mock = require('./mock'); +var Output = require('../lib/output/base'); + +describe('Page', function() { + var book, output; + + before(function() { + return mock.setupDefaultBook({ + 'README.md': ' # Hello World\n\nThis is a description', + 'heading.md': '# Hello\n\n## World', + 'description.md': '# This is a title\n\nThis is the short description.\n\nNot this one.', + 'frontmatter.md': '---\ndescription: Hello World\n---\n\n# This is a title\n\nThis is not the description', + + 'links.md': '[link](hello.md) [link 2](variables/page/next.md) [readme](README.md)', + 'links/relative.md': '[link](../hello.md) [link 2](/variables/page/next.md) [readme](../README.md)', + + 'images.md': '![this is an image](test.png) ![this is an absolute image](/test2.png) ![this is a remote image](https://upload.wikimedia.org/wikipedia/commons/4/47/PNG_transparency_demonstration_1.png)', + 'images/relative.md': '![this is an image](test.png) ![this is an absolute image](/test2.png)', + + 'annotations/simple.md': 'A magicien say abracadabra!', + 'annotations/code.md': 'A magicien say `abracadabra`!', + 'annotations/class.md': 'A magicien say
abracadabra, right?
!', + + 'codes/simple.md': '```hello world```', + 'codes/lang.md': '```js\nhello world\n```', + 'codes/lang.adoc': '```js\nhello world\n```', + + 'folder/paths.md': '', + + 'variables/file/mtime.md': '{{ file.mtime }}', + 'variables/file/path.md': '{{ file.path }}', + 'variables/page/title.md': '{{ page.title }}', + 'variables/page/previous.md': '{{ page.previous.title }} {{ page.previous.path }}', + 'variables/page/next.md': '{{ page.next.title }} {{ page.next.path }}', + 'variables/page/dir/ltr.md': 'This is english: {{ page.dir }}', + 'variables/page/dir/rtl.md': 'بسيطة {{ page.dir }}', + 'variables/config/title.md': '{{ config.title}}', + + 'GLOSSARY.md': '# Glossary\n\n\n## abracadabra\n\nthis is the description' + }, [ + { + title: 'Test page.next', + path: 'variables/page/next.md' + }, + { + title: 'Test Variables', + path: 'variables/page/title.md' + }, + { + title: 'Test page.previous', + path: 'variables/page/previous.md' + } + ]) + .then(function(_book) { + book = _book; + output = new Output(book); + + return book.parse(); + }); + }); + + describe('.resolveLocal', function() { + it('should correctly resolve path to file', function() { + var page = book.addPage('heading.md'); + + page.resolveLocal('test.png').should.equal('test.png'); + page.resolveLocal('/test.png').should.equal('test.png'); + page.resolveLocal('test/hello.png').should.equal('test/hello.png'); + page.resolveLocal('/test/hello.png').should.equal('test/hello.png'); + }); + + it('should correctly resolve path to file (2)', function() { + var page = book.addPage('folder/paths.md'); + + page.resolveLocal('test.png').should.equal('folder/test.png'); + page.resolveLocal('/test.png').should.equal('test.png'); + page.resolveLocal('test/hello.png').should.equal('folder/test/hello.png'); + page.resolveLocal('/test/hello.png').should.equal('test/hello.png'); + }); + }); + + describe('.relative', function() { + it('should correctly resolve absolute path in the book', function() { + var page = book.addPage('heading.md'); + page.relative('/test.png').should.equal('test.png'); + page.relative('test.png').should.equal('test.png'); + + var page2 = book.addPage('folder/paths.md'); + page2.relative('/test.png').should.equal('../test.png'); + page2.relative('test.png').should.equal('../test.png'); + }); + }); + + describe('.resolve', function() { + var page; + + before(function() { + page = book.addPage('links/relative.md'); + }); + + it('should resolve to a relative path (same folder)', function() { + page.relative('links/test.md').should.equal('test.md'); + }); + + it('should resolve to a relative path (parent folder)', function() { + page.relative('test.md').should.equal('../test.md'); + page.relative('hello/test.md').should.equal('../hello/test.md'); + }); + + it('should resolve to a relative path (child folder)', function() { + page.relative('links/hello/test.md').should.equal('hello/test.md'); + }); + }); + + describe('Headings', function() { + it('should add a default ID to headings', function() { + var page = book.addPage('heading.md'); + + return page.toHTML(output) + .then(function() { + page.content.should.be.html({ + 'h1#hello': { + count: 1 + }, + 'h2#world': { + count: 1 + } + }); + }); + }); + }); + + describe('Description', function() { + it('should extratc page description from content', function() { + var page = book.addPage('description.md'); + + return page.toHTML(output) + .then(function() { + page.description.should.equal('This is the short description.'); + }); + }); + }); + + describe('Font-Matter', function() { + it('should extratc page description from front matter', function() { + var page = book.addPage('frontmatter.md'); + + return page.toHTML(output) + .then(function() { + page.description.should.equal('Hello World'); + }); + }); + }); + + describe('Code Blocks', function() { + var page; + + before(function() { + output.template.addBlock('code', function(blk) { + return (blk.kwargs.language || '') + blk.body + 'test'; + }); + }); + + it('should apply "code" block', function() { + page = book.addPage('codes/simple.md'); + return page.toHTML(output) + .should.be.fulfilledWith('

hello worldtest

\n'); + }); + + it('should add language as kwargs', function() { + page = book.addPage('codes/lang.md'); + return page.toHTML(output) + .should.be.fulfilledWith('
jshello world\ntest
\n'); + }); + + it('should add language as kwargs (asciidoc)', function() { + page = book.addPage('codes/lang.adoc'); + return page.toHTML(output) + .should.be.fulfilledWith('
\n
\n
jshello worldtest
\n
\n
'); + }); + }); + + describe('Links', function() { + describe('From base directory', function() { + var page; + + before(function() { + page = book.addPage('links.md'); + return page.toHTML(output); + }); + + it('should replace links to page to .html', function() { + page.content.should.be.html({ + 'a[href="variables/page/next.html"]': { + count: 1 + } + }); + }); + + it('should use directory urls when file is a README', function() { + page.content.should.be.html({ + 'a[href="./"]': { + count: 1 + } + }); + }); + + it('should not replace links to file not in SUMMARY', function() { + page.content.should.be.html({ + 'a[href="hello.md"]': { + count: 1 + } + }); + }); + }); + + describe('From sub-directory', function() { + var page; + + before(function() { + page = book.addPage('links/relative.md'); + return page.toHTML(output); + }); + + it('should replace links to page to .html', function() { + page.content.should.be.html({ + 'a[href="../variables/page/next.html"]': { + count: 1 + } + }); + }); + + it('should use directory urls when file is a README', function() { + page.content.should.be.html({ + 'a[href="../"]': { + count: 1 + } + }); + }); + + it('should not replace links to file not in SUMMARY', function() { + page.content.should.be.html({ + 'a[href="../hello.md"]': { + count: 1 + } + }); + }); + }); + }); + + describe('Images', function() { + describe('From base directory', function() { + var page; + + before(function() { + page = book.addPage('images.md'); + return page.toHTML(output); + }); + + it('should resolve relative images', function() { + page.content.should.be.html({ + 'img[src="test.png"]': { + count: 1 + } + }); + }); + + it('should resolve absolute images', function() { + page.content.should.be.html({ + 'img[src="test2.png"]': { + count: 1 + } + }); + }); + + it('should keep external images path', function() { + page.content.should.be.html({ + 'img[src="https:/upload.wikimedia.org/wikipedia/commons/4/47/PNG_transparency_demonstration_1.png"]': { + count: 1 + } + }); + }); + }); + + describe('From sub-directory', function() { + var page; + + before(function() { + page = book.addPage('images/relative.md'); + return page.toHTML(output); + }); + + it('should resolve relative images', function() { + page.content.should.be.html({ + 'img[src="test.png"]': { + count: 1 + } + }); + }); + + it('should resolve absolute images', function() { + page.content.should.be.html({ + 'img[src="../test2.png"]': { + count: 1 + } + }); + }); + }); + }); + + describe('Templating Context', function() { + it('should set file.mtime', function() { + var page = book.addPage('variables/file/mtime.md'); + return page.toHTML(output) + .then(function(content) { + // A date ends with "(CET)" or "(UTC)"" + content.should.endWith(')

\n'); + }); + }); + + it('should set file.path', function() { + var page = book.addPage('variables/file/path.md'); + return page.toHTML(output) + .should.be.fulfilledWith('

variables/file/path.md

\n'); + }); + + it('should set page.title when page is in summary', function() { + var page = book.getPage('variables/page/title.md'); + return page.toHTML(output) + .should.be.fulfilledWith('

Test Variables

\n'); + }); + + it('should set page.previous when possible', function() { + var page = book.getPage('variables/page/previous.md'); + return page.toHTML(output) + .should.be.fulfilledWith('

Test Variables variables/page/title.md

\n'); + }); + + it('should set page.next when possible', function() { + var page = book.getPage('variables/page/next.md'); + return page.toHTML(output) + .should.be.fulfilledWith('

Test Variables variables/page/title.md

\n'); + }); + + it('should set config.title', function() { + var page = book.addPage('variables/config/title.md'); + return page.toHTML(output) + .should.be.fulfilledWith('

Hello World

\n'); + }); + + describe('page.dir', function() { + it('should detect ltr', function() { + var page = book.addPage('variables/page/dir/ltr.md'); + return page.toHTML(output) + .should.be.fulfilledWith('

This is english: ltr

\n'); + }); + + it('should detect rtl', function() { + var page = book.addPage('variables/page/dir/rtl.md'); + return page.toHTML(output) + .should.be.fulfilledWith('

بسيطة rtl

\n'); + }); + }); + }); + + describe('Annotations / Glossary', function() { + it('should replace glossary terms', function() { + return book.addPage('annotations/simple.md').toHTML(output) + .should.finally.be.html({ + '.glossary-term': { + count: 1, + text: 'abracadabra', + attributes: { + title: 'this is the description', + href: '../GLOSSARY.html#abracadabra' + } + } + }); + }); + + it('should not replace terms in code blocks', function() { + return book.addPage('annotations/code.md').toHTML(output) + .should.finally.be.html({ + '.glossary-term': { + count: 0 + } + }); + }); + + it('should not replace terms in ".no-glossary"', function() { + return book.addPage('annotations/class.md').toHTML(output) + .should.finally.be.html({ + '.glossary-term': { + count: 0 + } + }); + }); + }); +}); diff --git a/test/parse.js b/test/parse.js new file mode 100644 index 000000000..63565eed1 --- /dev/null +++ b/test/parse.js @@ -0,0 +1,59 @@ +var mock = require('./mock'); + +describe('Parsing', function() { + it('should not fail without SUMMARY', function() { + return mock.setupBook({ + 'README.md': '' + }) + .then(function(book) { + return book.parse().should.be.fulfilled(); + }); + }); + + it('should fail without README', function() { + return mock.setupBook({ + 'SUMMARY.md': '' + }) + .then(function(book) { + return book.parse().should.be.rejected; + }); + }); + + it('should add GLOSSARY as a page', function() { + return mock.setupDefaultBook({ + 'GLOSSARY.md': '' + }) + .then(function(book) { + return book.parse() + .then(function() { + book.hasPage('GLOSSARY.md').should.equal(true); + }); + }); + }); + + describe('Multilingual book', function() { + var book; + + before(function() { + return mock.setupBook({ + 'LANGS.md': '# Languages\n\n' + + '* [English](./en)\n' + + '* [French](./fr)\n\n', + 'en/README.md': '# English', + 'en/SUMMARY.md': '# Summary', + 'fr/README.md': '# French', + 'fr/SUMMARY.md': '# Summary' + }) + .then(function(_book) { + book = _book; + return book.parse(); + }); + }); + + it('should list language books', function() { + book.isMultilingual().should.equal(true); + book.books.should.have.lengthOf(2); + }); + }); +}); + diff --git a/test/paths.js b/test/paths.js new file mode 100644 index 000000000..339da5106 --- /dev/null +++ b/test/paths.js @@ -0,0 +1,17 @@ +var path = require('path'); +var pathUtils = require('../lib/utils/path'); + +describe('Paths', function() { + + describe('setExtension', function() { + it('should correctly change extension of filename', function() { + pathUtils.setExtension('test.md', '.html').should.be.equal('test.html'); + pathUtils.setExtension('test.md', '.json').should.be.equal('test.json'); + }); + + it('should correctly change extension of path', function() { + pathUtils.setExtension('hello/test.md', '.html').should.be.equal(path.normalize('hello/test.html')); + pathUtils.setExtension('hello/test.md', '.json').should.be.equal(path.normalize('hello/test.json')); + }); + }); +}); diff --git a/test/plugins.js b/test/plugins.js index 1600d0dc1..4d9cdf1f8 100644 --- a/test/plugins.js +++ b/test/plugins.js @@ -1,84 +1,103 @@ var _ = require('lodash'); -var fs = require('fs'); -var should = require('should'); var path = require('path'); -var Plugin = require('../lib/plugin'); -var parsers = require('gitbook-parsers'); -var PLUGINS_ROOT = path.resolve(__dirname, 'plugins'); +var mock = require('./mock'); +var registry = require('../lib/plugins/registry'); +var Output = require('../lib/output/base'); +var PluginsManager = require('../lib/plugins'); +var BookPlugin = require('../lib/plugins/plugin'); -describe('Plugins', function () { +var PLUGINS_ROOT = path.resolve(__dirname, 'node_modules'); + +describe('Plugins', function() { var book; before(function() { - return books.parse('basic') + return mock.setupBook({}) .then(function(_book) { book = _book; }); }); - describe('Invalid', function() { - var plugin; - - before(function() { - plugin = new Plugin(book, 'invalid'); - plugin.load('./invalid', PLUGINS_ROOT); - }); - - it('should be detected', function() { - should(plugin.isValid()).be.exactly(false); + describe('Resolve Version', function() { + it('should resolve a plugin version', function() { + return registry.resolve('ga') + .should.be.fulfilled(); }); }); - describe('Empty', function() { - var plugin; - - before(function() { - plugin = new Plugin(book, 'empty'); - plugin.load('./empty', PLUGINS_ROOT); + describe('Installation', function() { + it('should install a plugin from NPM without a specific version', function() { + return registry.install(book, 'ga') + .should.be.fulfilled(); }); - it('should valid a plugin', function() { - should(plugin.isValid()).be.exactly(true); + it('should install a plugin from NPM with a specific version', function() { + return registry.install(book, 'ga', '1.0.0') + .should.be.fulfilled(); }); - it('should return an empty list of resources', function() { - return plugin.getResources() - .then(function(resources) { - _.each(Plugin.RESOURCES, function(resName) { - resources[resName].should.have.lengthOf(0); + it('should correctly install all dependencies (if none)', function() { + return mock.setupBook({}) + .then(function(book) { + var plugins = new PluginsManager(book); + return plugins.install() + .should.be.fulfilledWith(0); + }); + }); + + it('should correctly install all dependencies (if any)', function() { + return mock.setupBook({ + 'book.json': { + plugins: ['ga'] + } + }) + .then(function(book) { + return book.config.load() + .then(function() { + var plugins = new PluginsManager(book); + return plugins.install(); }); + }) + .should.be.fulfilledWith(1); + }); + }); + + describe('Loading', function() { + it('should load default plugins', function() { + return mock.outputDefaultBook(Output) + .then(function(output) { + output.plugins.count().should.be.greaterThan(0); }); }); }); describe('Configuration', function() { - var plugin; - - before(function() { - plugin = new Plugin(book, 'testconfig'); - plugin.load('./config', PLUGINS_ROOT); + it('should fail loading a plugin with an invalid configuration', function() { + var plugin = new BookPlugin(book, 'test-config'); + return plugin.load(PLUGINS_ROOT) + .should.be.rejectedWith('Error with book\'s configuration: pluginsConfig.test-config.myProperty is required'); }); - it('should throw error for invalid configuration', function() { - return plugin.validateConfig({}) - .should.be.rejectedWith('Configuration Error: pluginsConfig.testconfig.testRequired is required'); - }); - - it('should throw error for invalid types', function() { - return plugin.validateConfig({ - testRequired: 'hello' + it('should extend configuration with default properties', function() { + return mock.setupBook({ + 'book.json': { + pluginsConfig: { + 'test-config': { + 'myProperty': 'world' + } + } + } }) - .should.be.rejectedWith('Configuration Error: pluginsConfig.testconfig.testRequired is not of a type(s) number'); - }); - - it('should extend with default values', function() { - return plugin.validateConfig({ - testRequired: 12 - }) - .should.be.fulfilledWith({ - hello: 'world', - testRequired: 12 + .then(function(book2) { + return book2.config.load() + .then(function() { + var plugin = new BookPlugin(book2, 'test-config'); + return plugin.load(PLUGINS_ROOT); + }) + .then(function() { + book2.config.get('pluginsConfig.test-config.myDefaultProperty', '').should.equal('hello'); + }); }); }); }); @@ -87,243 +106,89 @@ describe('Plugins', function () { var plugin; before(function() { - plugin = new Plugin(book, 'resources'); - plugin.load('./resources', PLUGINS_ROOT); - - return book.plugins.load(plugin); + plugin = new BookPlugin(book, 'test-resources'); + return plugin.load(PLUGINS_ROOT); }); - it('should valid a plugin', function() { - should(plugin.isValid()).be.exactly(true); - }); - describe('Website', function() { - it('should return a valid list of resources', function() { - return plugin.getResources('website') - .then(function(resources) { - resources.js.should.have.lengthOf(1); - }); - }); + it('should list all resources for website', function() { + return plugin.getResources('website') + .then(function(resources) { + resources.assets.should.equal('./assets'); - it('should extend books plugins', function() { - var resources = book.plugins.resources('website'); - resources.js.should.have.lengthOf(5); - }); - }); + resources.js.should.have.lengthOf(2); + resources.js[0].path.should.equal('gitbook-plugin-test-resources/myfile.js'); + resources.js[1].url.should.equal('https://ajax.googleapis.com/ajax/libs/angularjs/1.4.9/angular.min.js'); - describe('eBook', function() { - it('should return a valid list of resources', function() { - return plugin.getResources('ebook') - .then(function(resources) { - resources.css.should.have.lengthOf(1); - }); - }); - - it('should extend books plugins', function() { - var resources = book.plugins.resources('ebook'); - - // There is resources from highlight plugin and this plugin - resources.css.should.have.lengthOf(2); - should.exist(_.find(resources.css, { - path: 'gitbook-plugin-resources/test' - })); + resources.css.should.have.lengthOf(1); + resources.css[0].path.should.equal('gitbook-plugin-test-resources/myfile.css'); }); }); }); describe('Filters', function() { - var plugin; + var plugin, filters; before(function() { - plugin = new Plugin(book, 'filters'); - plugin.load('./filters', PLUGINS_ROOT); + plugin = new BookPlugin(book, 'test-filters'); + return plugin.load(PLUGINS_ROOT) - return book.plugins.load(plugin); + .then(function() { + filters = plugin.getFilters(); + }); }); - it('should valid a plugin', function() { - should(plugin.isValid()).be.exactly(true); - }); - - it('should return a map of filters', function() { - var filters = plugin.getFilters(); - + it('should list all filters', function() { _.size(filters).should.equal(2); - filters.should.have.property('hello'); - filters.should.have.property('helloCtx'); }); - it('should correctly extend template filters', function() { - return book.template.renderString('{{ \'World\'|hello }}') - .then(function(content) { - content.should.equal('Hello World'); - }); + it('should correctly execute a filter', function() { + filters.hello('World').should.equal('Hello World!'); }); - it('should correctly set book as context', function() { - return book.template.renderString('{{ \'root\'|helloCtx }}') - .then(function(content) { - content.should.equal('root:'+book.root); - }); + it('should correctly set contexts for filter', function() { + filters.testContext('Hello'); }); }); describe('Blocks', function() { - var plugin; + var plugin, blocks; before(function() { - plugin = new Plugin(book, 'blocks'); - plugin.load('./blocks', PLUGINS_ROOT); + plugin = new BookPlugin(book, 'test-blocks'); + return plugin.load(PLUGINS_ROOT) - return book.plugins.load(plugin); - }); - - var testTpl = function(str, args, options) { - return book.template.renderString(str, args, options) - .then(book.template.postProcess); - }; - - it('should valid a plugin', function() { - should(plugin.isValid()).be.exactly(true); - }); - - it('should correctly extend template blocks', function() { - return testTpl('{% test %}hello{% endtest %}') - .then(function(content) { - content.should.equal('testhellotest'); - }); - }); - - describe('Shortcuts', function() { - it('should correctly accept shortcuts', function() { - return testTpl('$$hello$$', {}, { - type: 'markdown' - }) - .then(function(content) { - content.should.equal('testhellotest'); - }); - }); - - it('should correctly apply shortcuts to included file', function() { - return books.generate('conrefs', 'website', { - testId: 'include-plugins', - prepare: function(bookConref) { - plugin = new Plugin(bookConref, 'blocks'); - plugin.load('./blocks', PLUGINS_ROOT); - - return bookConref.plugins.load(plugin); - } - }) - .then(function(bookConref) { - var readme = fs.readFileSync( - path.join(bookConref.options.output, 'index.html'), - { encoding: 'utf-8' } - ); - - readme.should.be.html({ - '.page-inner p#test-plugin-block-shortcuts-1': { - count: 1, - text: 'testtest_block1test', - trim: true - }, - '.page-inner p#test-plugin-block-shortcuts-2': { - count: 1, - text: 'testtest_block2test', - trim: true - } - }); - }); + .then(function() { + blocks = plugin.getBlocks(); }); }); - - it('should correctly extend template blocks with defined end', function() { - return testTpl('{% test2 %}hello{% endtest2end %}') - .then(function(content) { - content.should.equal('test2hellotest2'); - }); + it('should list all blocks', function() { + _.size(blocks).should.equal(2); }); - it('should correctly extend template blocks with sub-blocks', function() { - return testTpl('{% test3join separator=";" %}hello{% also %}world{% endtest3join %}') - .then(function(content) { - content.should.equal('hello;world'); - }); + it('should correctly normalize block', function() { + blocks.hello.process({ body: 'World' }).should.equal('Hello World!'); }); - it('should correctly extend template blocks with different sub-blocks', function() { - return testTpl('{% test4join separator=";" %}hello{% also %}the{% finally %}world{% endtest4join %}') - .then(function(content) { - content.should.equal('hello;the;world'); - }); - }); - - it('should correctly extend template blocks with arguments (1)', function() { - return testTpl('{% test5args "a" %}{% endtest5args %}') - .then(function(content) { - content.should.equal('test5atest5'); - }); - }); - - it('should correctly extend template blocks with arguments (2)', function() { - return testTpl('{% test5args "a", "b" %}{% endtest5args %}') - .then(function(content) { - content.should.equal('test5a,btest5'); - }); - }); - - it('should correctly extend template blocks with arguments (3)', function() { - return testTpl('{% test5args "a", "b", "c" %}{% endtest5args %}') - .then(function(content) { - content.should.equal('test5a,b,ctest5'); - }); - }); - - it('should correctly extend template blocks with args and kwargs', function() { - return testTpl('{% test5kwargs "a", "b", "c", d="test", e="test2" %}{% endtest5kwargs %}') - .then(function(content) { - content.should.equal('test5a,b,c,d:test,e:test2,__keywords:truetest5'); - }); - }); - - it('should correctly extend template blocks with access to context', function() { - return testTpl('{% set name = "john" %}{% test6context %}{% endtest6context %}', {}) - .then(function(content) { - content.should.equal('test6johntest6'); - }); + it('should correctly set contexts for filter', function() { + blocks.testContext.process({ body: 'Hello' }); }); }); - describe('Blocks without parsing', function() { + describe('Hooks', function() { var plugin; before(function() { - plugin = new Plugin(book, 'blocks'); - plugin.load('./blocks', PLUGINS_ROOT); - - return book.plugins.load(plugin); + plugin = new BookPlugin(book, 'test-hooks'); + return plugin.load(PLUGINS_ROOT); }); - var testTpl = function(markup, str, args, options) { - var filetype = parsers.get(markup); - - return book.template.renderString(str, args, options) - .then(filetype.page).get('sections').get(0).get('content') - .then(book.template.postProcess); - }; - - it('should correctly process unparsable for markdown', function() { - return testTpl('.md', '{% test %}**hello**{% endtest %}') - .then(function(content) { - content.should.equal('

test**hello**test

\n'); - }); - }); - - it('should correctly process unparsable for asciidoc', function() { - return testTpl('.adoc', '{% test %}**hello**{% endtest %}') - .then(function(content) { - content.should.equal('
\n

test**hello**test

\n
'); - }); + it('can call a hook', function() { + return plugin.hook('init') + .then(function() { + global._hooks.should.deepEqual(['init']); + }); }); }); }); diff --git a/test/plugins/blocks/index.js b/test/plugins/blocks/index.js deleted file mode 100644 index 9bdbe864d..000000000 --- a/test/plugins/blocks/index.js +++ /dev/null @@ -1,61 +0,0 @@ -var assert = require("assert"); - -module.exports = { - blocks: { - "test": { - shortcuts: { - parsers: ["markdown"], - start: "$$", - end: "$$" - }, - process: function(blk) { - return "test"+blk.body+"test"; - } - }, - "test2": { - end: "endtest2end", - process: function(blk) { - return "test2"+blk.body+"test2"; - } - }, - "test3join": { - blocks: [ - "also" - ], - process: function(blk) { - return [blk.body, blk.blocks[0].body].join(blk.kwargs.separator); - } - }, - "test4join": { - blocks: [ - "also", "finally" - ], - process: function(blk) { - assert(blk.blocks.length, 2); - assert(blk.blocks[0].name, "also"); - assert(blk.blocks[1].name, "finally"); - return [blk.body, blk.blocks[0].body, blk.blocks[1].body].join(blk.kwargs.separator); - } - }, - "test5args": { - process: function(blk) { - return "test5"+blk.args.join(",")+"test5"; - } - }, - "test5kwargs": { - process: function(blk) { - var s = blk.args.join(","); - for (var key in blk.kwargs) { - s = s + ","+key+":"+blk.kwargs[key]; - } - - return "test5"+s+"test5"; - } - }, - "test6context": { - process: function() { - return "test6"+(this.ctx.name)+"test6"; - } - }, - } -}; \ No newline at end of file diff --git a/test/plugins/blocks/package.json b/test/plugins/blocks/package.json deleted file mode 100644 index 7c41fd32a..000000000 --- a/test/plugins/blocks/package.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "gitbook-plugin-blocks", - "description": "Test blocks", - "main": "index.js", - "version": "0.0.1", - "engines": { - "gitbook": "*" - } -} \ No newline at end of file diff --git a/test/plugins/config/package.json b/test/plugins/config/package.json deleted file mode 100644 index 03ef744a4..000000000 --- a/test/plugins/config/package.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "name": "gitbook-plugin-testconfig", - "description": "Test plugin configuration", - "main": "index.js", - "version": "0.0.1", - "engines": { - "gitbook": "*" - }, - "gitbook": { - "properties": { - "hello": { - "type": "string", - "default": "world" - }, - "testRequired": { - "type": "number", - "required": true - } - } - } -} \ No newline at end of file diff --git a/test/plugins/empty/index.js b/test/plugins/empty/index.js deleted file mode 100644 index a09954537..000000000 --- a/test/plugins/empty/index.js +++ /dev/null @@ -1 +0,0 @@ -module.exports = {}; \ No newline at end of file diff --git a/test/plugins/empty/package.json b/test/plugins/empty/package.json deleted file mode 100644 index 78c7e727d..000000000 --- a/test/plugins/empty/package.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "gitbook-plugin-empty", - "description": "Test empty plugin", - "main": "index.js", - "version": "0.0.1", - "engines": { - "gitbook": "*" - } -} \ No newline at end of file diff --git a/test/plugins/filters/index.js b/test/plugins/filters/index.js deleted file mode 100644 index 2cf53b1ef..000000000 --- a/test/plugins/filters/index.js +++ /dev/null @@ -1,10 +0,0 @@ -module.exports = { - filters: { - hello: function(text) { - return "Hello "+text; - }, - helloCtx: function(text) { - return text+":"+this.book.root; - } - } -}; \ No newline at end of file diff --git a/test/plugins/filters/package.json b/test/plugins/filters/package.json deleted file mode 100644 index f1d4e4530..000000000 --- a/test/plugins/filters/package.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "gitbook-plugin-filters", - "description": "Test filters", - "main": "index.js", - "version": "0.0.1", - "engines": { - "gitbook": "*" - } -} \ No newline at end of file diff --git a/test/plugins/invalid/index.js b/test/plugins/invalid/index.js deleted file mode 100644 index a09954537..000000000 --- a/test/plugins/invalid/index.js +++ /dev/null @@ -1 +0,0 @@ -module.exports = {}; \ No newline at end of file diff --git a/test/plugins/invalid/package.json b/test/plugins/invalid/package.json deleted file mode 100644 index da34090c2..000000000 --- a/test/plugins/invalid/package.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "gitbook-plugin-invalid", - "description": "Test invalid plugin", - "main": "index.js", - "version": "0.0.1", - "engines": { - "gitbook": "<2.0.0" - } -} \ No newline at end of file diff --git a/test/plugins/replace_highlight/index.js b/test/plugins/replace_highlight/index.js deleted file mode 100644 index 8586486d5..000000000 --- a/test/plugins/replace_highlight/index.js +++ /dev/null @@ -1,14 +0,0 @@ -module.exports = { - blocks: { - "code": { - process: function(blk) { - var lang = blk.kwargs.language || "code"; - - return { - body: lang+"_"+blk.body+"_"+lang, - html: false - }; - } - } - } -}; \ No newline at end of file diff --git a/test/plugins/replace_highlight/package.json b/test/plugins/replace_highlight/package.json deleted file mode 100644 index 72d1033db..000000000 --- a/test/plugins/replace_highlight/package.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "gitbook-plugin-replace_highlight", - "description": "Test replacing default code highlighter", - "main": "index.js", - "version": "0.0.1", - "engines": { - "gitbook": "*" - } -} \ No newline at end of file diff --git a/test/plugins/resources/index.js b/test/plugins/resources/index.js deleted file mode 100644 index bafa54b2e..000000000 --- a/test/plugins/resources/index.js +++ /dev/null @@ -1,12 +0,0 @@ -module.exports = { - website: { - js: [ - "https://cdn.mathjax.org/mathjax/2.4-latest/MathJax.js?config=TeX-AMS-MML_HTMLorMML" - ] - }, - ebook: { - css: [ - "test" - ] - } -}; diff --git a/test/plugins/resources/package.json b/test/plugins/resources/package.json deleted file mode 100644 index ab4320d8b..000000000 --- a/test/plugins/resources/package.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "gitbook-plugin-resources", - "description": "Test resources plugin", - "main": "index.js", - "version": "0.0.1", - "engines": { - "gitbook": "*" - } -} \ No newline at end of file diff --git a/test/readme.js b/test/readme.js new file mode 100644 index 000000000..0cf66ffe4 --- /dev/null +++ b/test/readme.js @@ -0,0 +1,43 @@ +var mock = require('./mock'); + +describe('Readme', function() { + it('should parse empty readme', function() { + return mock.setupDefaultBook({ + 'README.md': '' + }) + .then(function(book) { + return book.config.load() + + .then(function() { + return book.readme.load(); + }); + }); + }); + + it('should parse readme', function() { + return mock.setupDefaultBook({ + 'README.md': '# Hello World\nThis is my book' + }) + .then(function(book) { + return book.readme.load() + .then(function() { + book.readme.title.should.equal('Hello World'); + book.readme.description.should.equal('This is my book'); + }); + }); + }); + + it('should parse AsciiDoc readme', function() { + return mock.setupBook({ + 'README.adoc': '# Hello World\n\nThis is my book\n' + }) + .then(function(book) { + return book.readme.load() + .then(function() { + book.readme.title.should.equal('Hello World'); + book.readme.description.should.equal('This is my book'); + }); + }); + }); +}); + diff --git a/test/resolve.js b/test/resolve.js deleted file mode 100644 index e474ed03a..000000000 --- a/test/resolve.js +++ /dev/null @@ -1,60 +0,0 @@ -var path = require("path"); - -describe("Resolve Files", function () { - var book; - - before(function() { - return books.parse("basic") - .then(function(_book) { - book = _book; - }); - }); - - describe("book.fileIsInBook", function() { - it("should return true for correct paths", function() { - book.fileIsInBook(path.join(book.root, "README.md")).should.equal(true); - book.fileIsInBook(path.join(book.root, "styles/website.css")).should.equal(true); - }); - - it("should return true for root folder", function() { - book.fileIsInBook(path.join(book.root, "./")).should.equal(true); - book.fileIsInBook(book.root).should.equal(true); - }); - - it("should return false for files out of scope", function() { - book.fileIsInBook(path.join(book.root, "../")).should.equal(false); - book.fileIsInBook("README.md").should.equal(false); - book.fileIsInBook(path.resolve(book.root, "../README.md")).should.equal(false); - }); - - it("should correctly handle windows paths", function() { - book.fileIsInBook(path.join(book.root, "\\styles\\website.css")).should.equal(true); - }); - }); - - describe("book.resolve", function() { - it("should resolve a file to its absolute path", function() { - book.resolve("README.md").should.equal(path.resolve(book.root, "README.md")); - book.resolve("website/README.md").should.equal(path.resolve(book.root, "website/README.md")); - }); - - it("should correctly handle windows paths", function() { - book.resolve("styles\\website.css").should.equal(path.resolve(book.root, "styles\\website.css")); - }); - - it("should correctly resolve all arguments", function() { - book.resolve("test", "hello", "..", "README.md").should.equal(path.resolve(book.root, "test/README.md")); - }); - - it("should correctly resolve to root folder", function() { - book.resolve("test", "/README.md").should.equal(path.resolve(book.root, "README.md")); - book.resolve("test", "\\README.md").should.equal(path.resolve(book.root, "README.md")); - }); - - it("should throw an error for file out of book", function() { - (function() { - return book.resolve("../README.md"); - }).should.throw(); - }); - }); -}); diff --git a/test/structure.js b/test/structure.js deleted file mode 100644 index 90413cb4f..000000000 --- a/test/structure.js +++ /dev/null @@ -1,20 +0,0 @@ -describe('Structure', function () { - var book; - - before(function() { - return books.parse('structure') - .then(function(_book) { - book = _book; - }); - }); - - - it('should prioritize structure defined in book.json', function() { - book.readmeFile.should.equal('README.adoc'); - }); - - it('should be case incensitive', function() { - book.glossaryFile.should.equal('glossary.md'); - book.glossary.should.have.lengthOf(1); - }); -}); diff --git a/test/summary.js b/test/summary.js index 2d3a2481b..08b9db852 100644 --- a/test/summary.js +++ b/test/summary.js @@ -1,70 +1,300 @@ -var fs = require("fs"); -var path = require("path"); +var should = require('should'); -describe("Summary", function () { - describe("Parsing", function() { +var mock = require('./mock'); + +function mockSummary(files, summary) { + return mock.setupDefaultBook(files, summary) + .then(function(book) { + return book.readme.load() + .then(function() { + return book.summary.load(); + }) + .thenResolve(book); + }); +} + +describe('Summary / Table of contents', function() { + describe('Empty summary list', function() { var book; before(function() { - return books.parse("summary") - .then(function(_book) { - book = _book; - }); + return mockSummary({}) + .then(function(_book) { + book = _book; + }); }); - it("should correctly list items", function() { - book.should.have.property("summary"); - book.summary.should.have.property("chapters"); - book.summary.chapters.should.have.lengthOf(4); + it('should add README as first entry', function() { + should(book.summary.getArticle('README.md')).be.ok(); }); - it("should correctly mark non-existant entries", function() { - book.summary.chapters[0].exists.should.have.equal(true); - book.summary.chapters[1].exists.should.have.equal(true); - book.summary.chapters[2].exists.should.have.equal(true); - book.summary.chapters[3].exists.should.have.equal(false); + it('should correctly count articles', function() { + book.summary.count().should.equal(1); }); }); - describe("Generation", function() { + describe('Non-existant summary', function() { var book; before(function() { - return books.generate("summary", "website") - .then(function(_book) { - book = _book; + return mock.setupBook({ + 'README.md': 'Hello' + }) + .then(function(_book) { + book = _book; + + return book.readme.load() + .then(function() { + return book.summary.load(); }); - }); - - it("should create files according to summary", function() { - book.should.have.file("index.html"); - book.should.have.file("PAGE1.html"); - book.should.have.file("folder/PAGE2.html"); - }); - - it("should correctly output summary", function() { - var PAGE = fs.readFileSync( - path.join(book.options.output, "index.html"), - { encoding: "utf-8" } - ); - - PAGE.should.be.html({ - ".book-summary .chapter[data-level=\"0\"] a": { - attributes: { - href: "./index.html" - } - }, - ".book-summary .chapter[data-level=\"1\"] a": { - attributes: { - href: "./PAGE1.html" - } - }, - ".book-summary .chapter[data-level=\"2\"] a": { - attributes: { - href: "./folder/PAGE2.html" - } - } }); }); + + it('should add README as first entry', function() { + should(book.summary.getArticle('README.md')).be.ok(); + }); + + it('should correctly count articles', function() { + book.summary.count().should.equal(1); + }); + }); + + describe('Non-empty summary list', function() { + var book; + + before(function() { + return mockSummary({ + 'SUMMARY.md': '# Summary\n\n' + + '* [Hello](./hello.md)\n' + + '* [World](./world.md)\n\n' + }) + .then(function(_book) { + book = _book; + }); + }); + + it('should correctly count articles', function() { + book.summary.count().should.equal(3); + }); + }); + + describe('Levels', function() { + var book; + + before(function() { + return mockSummary({ + 'SUMMARY.md': '# Summary\n\n' + + '* [Hello](./hello.md)\n' + + ' * [Hello 2](./hello2.md)\n' + + '* [World](./world.md)\n\n' + + '## Part 2\n\n' + + '* [Hello 3](./hello.md)\n' + + ' * [Hello 4](./hello2.md)\n' + }) + .then(function(_book) { + book = _book; + }); + }); + + it('should correctly index levels', function() { + book.summary.getArticleByLevel('0').title.should.equal('Introduction'); + book.summary.getArticleByLevel('1.1').title.should.equal('Hello'); + book.summary.getArticleByLevel('1.1.1').title.should.equal('Hello 2'); + book.summary.getArticleByLevel('1.2').title.should.equal('World'); + + book.summary.getArticleByLevel('2.1').title.should.equal('Hello 3'); + book.summary.getArticleByLevel('2.1.1').title.should.equal('Hello 4'); + }); + + it('should correctly calcul depth', function() { + book.summary.getArticleByLevel('0').depth().should.equal(1); + book.summary.getArticleByLevel('1.1').depth().should.equal(2); + book.summary.getArticleByLevel('1.1.1').depth().should.equal(3); + }); + }); + + describe('External', function() { + var book; + + before(function() { + return mockSummary({}, [ + { + title: 'Google', + path: 'https://www.google.fr' + } + ]) + .then(function(_book) { + book = _book; + }); + }); + + it('should correctly count articles', function() { + book.summary.count().should.equal(2); + }); + + it('should correctly signal it as external', function() { + var article = book.summary.getArticleByLevel('1'); + + should(article).be.ok(); + should(article.path).not.be.ok(); + + article.title.should.equal('Google'); + article.ref.should.equal('https://www.google.fr'); + article.isExternal().should.be.ok; + }); + }); + + describe('Next / Previous', function() { + var book; + + before(function() { + return mockSummary({ + 'SUMMARY.md': '# Summary\n\n' + + '* [Hello](hello.md)\n' + + '* [Hello 2](hello2.md)\n' + + ' * [Hello 3](hello3.md)\n' + + ' * [Hello 4](hello4.md)\n' + + ' * [Hello 5](hello5.md)\n' + + '* [Hello 6](hello6.md)\n\n\n' + + '### Part 2\n\n' + + '* [Hello 7](hello7.md)\n' + + ' * [Hello 8](hello8.md)\n\n' + + '### Part 3\n\n' + + '* [Hello 9](hello9.md)\n' + + '* [Hello 10](hello10.md)\n\n' + }) + .then(function(_book) { + book = _book; + }); + }); + + it('should only return a next for the readme', function() { + var article = book.summary.getArticle('README.md'); + + var prev = article.prev(); + var next = article.next(); + + should(prev).not.be.ok(); + should(next).be.ok(); + + next.path.should.equal('hello.md'); + }); + + it('should return next/prev for a first level page', function() { + var article = book.summary.getArticle('hello.md'); + + var prev = article.prev(); + var next = article.next(); + + should(prev).be.ok(); + should(next).be.ok(); + + prev.path.should.equal('README.md'); + next.path.should.equal('hello2.md'); + }); + + it('should return next/prev for a joint -> child', function() { + var article = book.summary.getArticle('hello2.md'); + + var prev = article.prev(); + var next = article.next(); + + should(prev).be.ok(); + should(next).be.ok(); + + prev.path.should.equal('hello.md'); + next.path.should.equal('hello3.md'); + }); + + it('should return next/prev for a joint <- child', function() { + var article = book.summary.getArticle('hello3.md'); + + var prev = article.prev(); + var next = article.next(); + + should(prev).be.ok(); + should(next).be.ok(); + + prev.path.should.equal('hello2.md'); + next.path.should.equal('hello4.md'); + }); + + it('should return next/prev for a children', function() { + var article = book.summary.getArticle('hello4.md'); + + var prev = article.prev(); + var next = article.next(); + + should(prev).be.ok(); + should(next).be.ok(); + + prev.path.should.equal('hello3.md'); + next.path.should.equal('hello5.md'); + }); + + it('should return next/prev for a joint -> parent', function() { + var article = book.summary.getArticle('hello5.md'); + + var prev = article.prev(); + var next = article.next(); + + should(prev).be.ok(); + should(next).be.ok(); + + prev.path.should.equal('hello4.md'); + next.path.should.equal('hello6.md'); + }); + + it('should return next/prev for a joint -> parts', function() { + var article = book.summary.getArticle('hello6.md'); + + var prev = article.prev(); + var next = article.next(); + + should(prev).be.ok(); + should(next).be.ok(); + + prev.path.should.equal('hello5.md'); + next.path.should.equal('hello7.md'); + }); + + it('should return next/prev for a joint <- parts', function() { + var article = book.summary.getArticle('hello7.md'); + + var prev = article.prev(); + var next = article.next(); + + should(prev).be.ok(); + should(next).be.ok(); + + prev.path.should.equal('hello6.md'); + next.path.should.equal('hello8.md'); + }); + + it('should return next and prev', function() { + var article = book.summary.getArticle('hello8.md'); + + var prev = article.prev(); + var next = article.next(); + + should(prev).be.ok(); + should(next).be.ok(); + + prev.path.should.equal('hello7.md'); + next.path.should.equal('hello9.md'); + }); + + it('should return only prev for last', function() { + var article = book.summary.getArticle('hello10.md'); + + var prev = article.prev(); + var next = article.next(); + + should(prev).be.ok(); + should(next).be.not.ok(); + + prev.path.should.equal('hello9.md'); + }); }); }); + diff --git a/test/template.js b/test/template.js new file mode 100644 index 000000000..6eb227869 --- /dev/null +++ b/test/template.js @@ -0,0 +1,54 @@ +var mock = require('./mock'); +var Output = require('../lib/output/base'); + +describe('Template', function() { + var output; + + before(function() { + return mock.outputDefaultBook(Output, {}) + .then(function(_output) { + output = _output; + }); + }); + + describe('.renderString', function() { + it('should render a simple string', function() { + return output.template.renderString('Hello World') + .should.be.fulfilledWith('Hello World'); + }); + + it('should render with variable', function() { + return output.template.renderString('Hello {{ world }}', { world: 'World'}) + .should.be.fulfilledWith('Hello World'); + }); + }); + + describe('Blocks', function() { + it('should correctly add a block', function() { + output.template.addBlock('sayhello', function(blk) { + return 'Hello ' + blk.body + '!'; + }); + + return output.template.renderString('{% sayhello %}World{% endsayhello %}') + .should.be.fulfilledWith('Hello World!'); + }); + + it('should correctly add a block with args', function() { + output.template.addBlock('sayhello_args', function(blk) { + return 'Hello ' + blk.args[0] + '!'; + }); + + return output.template.renderString('{% sayhello_args "World" %}{% endsayhello_args %}') + .should.be.fulfilledWith('Hello World!'); + }); + + it('should correctly add a block with kwargs', function() { + output.template.addBlock('sayhello_kwargs', function(blk) { + return 'Hello ' + blk.kwargs.name + '!'; + }); + + return output.template.renderString('{% sayhello_kwargs name="World" %}{% endsayhello_kwargs %}') + .should.be.fulfilledWith('Hello World!'); + }); + }); +}); diff --git a/test/templating.js b/test/templating.js deleted file mode 100644 index f92154bad..000000000 --- a/test/templating.js +++ /dev/null @@ -1,33 +0,0 @@ -var pkg = require("../package.json"); - -describe("Templating", function () { - var book; - - before(function() { - return books.parse("basic") - .then(function(_book) { - book = _book; - }); - }); - - var testTpl = function(str, args, options) { - return book.template.renderString(str, args, options) - .then(book.template.postProcess); - }; - - describe("Context", function() { - it("should correctly have access to generator", function() { - return testTpl("{{ gitbook.generator }}") - .then(function(content) { - content.should.equal("website"); - }); - }); - - it("should correctly have access to gitbook version", function() { - return testTpl("{{ gitbook.version }}") - .then(function(content) { - content.should.equal(pkg.version.toString()); - }); - }); - }); -}); diff --git a/test/website.js b/test/website.js deleted file mode 100644 index 6a0fd1cb5..000000000 --- a/test/website.js +++ /dev/null @@ -1,26 +0,0 @@ -describe('Website generator', function () { - describe('Basic Book', function() { - var book; - - before(function() { - return books.generate('basic', 'website') - .then(function(_book) { - book = _book; - }); - }); - - it('should correctly output an index.html', function() { - book.should.have.file('index.html'); - }); - - it('should correctly copy assets', function() { - book.should.have.file('gitbook'); - book.should.have.file('gitbook/app.js'); - book.should.have.file('gitbook/style.css'); - }); - - it('should not copy ebook assets', function() { - book.should.not.have.file('gitbook/ebook.css'); - }); - }); -}); diff --git a/theme/assets/ebook/ebook.css b/theme/assets/ebook/ebook.css deleted file mode 100644 index b8d575479..000000000 --- a/theme/assets/ebook/ebook.css +++ /dev/null @@ -1 +0,0 @@ -.section.glossary>h1,.section.toc>h1{text-align:center}.page .section b,.page .section dl dt,.page .section strong,.page .section table th{font-weight:700}.link-inherit,.link-inherit:focus,.link-inherit:hover{color:inherit}.hidden{display:none}.section.toc>ol{margin:0;padding:0}.section.toc li{list-style:none}.section.toc li .inner{display:block;border-bottom:1px dotted #eee;margin-bottom:4px}.section.toc li .inner a,.section.toc li .inner span{padding-right:5px;border-bottom:1px solid #fff;margin-bottom:-1px}.section.toc li .inner .page{float:right;padding-left:5px}.section.toc li ol{margin:0;padding:0 0 0 2em}.dir-rtl .section.toc li .inner .page{float:left}.dir-rtl .section.toc li ol{margin:0;padding:0 2em 0 0}.section.glossary .glossary-entry{margin-bottom:40px}.section.glossary .glossary-entry h2 a,.section.glossary .glossary-entry h2 a:hover{color:inherit;text-decoration:none}.page .section,body{color:#000;text-size-adjust:100%;-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}.section.glossary .glossary-entry .glossary-index{list-style:none;margin:0;padding:0}.section.glossary .glossary-entry .glossary-index li{display:inline;margin:0 8px;white-space:nowrap}body{font-family:sans-serif}.page .section{display:block;word-wrap:break-word;overflow:hidden;line-height:1.6;-moz-text-size-adjust:100%}.page .section *{box-sizing:border-box;-webkit-box-sizing:border-box;font-size:inherit}.page .section>:first-child{margin-top:0!important}.page .section>:last-child{margin-bottom:0!important}.page .section blockquote,.page .section code,.page .section figure,.page .section img,.page .section pre,.page .section table,.page .section tr{page-break-inside:avoid}.page .section h2,.page .section h3,.page .section h4,.page .section h5,.page .section p{orphans:3;widows:3}.page .section h1,.page .section h2,.page .section h3,.page .section h4,.page .section h5{page-break-after:avoid}.page .section em{font-style:italic}.page .section blockquote,.page .section dl,.page .section ol,.page .section p,.page .section table,.page .section ul{margin-top:0;margin-bottom:.8em}.page .section a{color:#4183c4;text-decoration:none;background:0 0}.page .section a:active,.page .section a:focus,.page .section a:hover{outline:0;text-decoration:underline}.page .section img{border:0;max-width:100%}.page .section hr{height:4px;padding:0;margin:1.6em 0;overflow:hidden;background-color:#e7e7e7;border:none}.page .section hr:after,.page .section hr:before{display:table;content:" "}.page .section hr:after{clear:both}.page .section h1,.page .section h2,.page .section h3,.page .section h4,.page .section h5,.page .section h6{margin-top:1.2em;margin-bottom:.8em;font-weight:700}.page .section h1{font-size:2em}.page .section h2{font-size:1.75em}.page .section h3{font-size:1.5em}.page .section h4{font-size:1.25em}.page .section h5{font-size:1em}.page .section h6{font-size:1em;color:#777}.page .section code,.page .section pre{font-family:Consolas,"Liberation Mono",Menlo,Courier,monospace;direction:ltr;border:none;color:inherit}.page .section pre{overflow:auto;word-wrap:normal;margin:0 0 1.2em;padding:.85em 1em;background:#f7f7f7}.page .section pre>code{display:inline;max-width:initial;padding:0;margin:0;overflow:initial;line-height:inherit;font-size:.85em;white-space:pre;background:0 0}.page .section pre>code:after,.page .section pre>code:before{content:normal}.page .section code{padding:.2em;margin:0;font-size:.85em;background-color:#f7f7f7}.page .section code:after,.page .section code:before{letter-spacing:-.2em;content:"\00a0"}.page .section table{display:table;width:100%;border-collapse:collapse;border-spacing:0;overflow:auto}.page .section table td,.page .section table th{padding:6px 13px;border:1px solid #ddd}.page .section table tr{background-color:#fff;border-top:1px solid #ccc}.page .section table tr:nth-child(2n){background-color:#f8f8f8}.page .section ol,.page .section ul{padding:0 0 0 2em;margin:0 0 .8em}.page .section ol ol,.page .section ol ul,.page .section ul ol,.page .section ul ul{margin-top:0;margin-bottom:0}.page .section ol ol{list-style-type:lower-roman}.page .section blockquote{margin:0 0 .8em;padding:0 15px;color:#858585;border-left:4px solid #e5e5e5}.page .section blockquote:first-child{margin-top:0}.page .section blockquote:last-child{margin-bottom:0}.page .section dl{padding:0}.page .section dl dt{padding:0;margin-top:.8em;font-style:italic}.page .section dl dd{padding:0 .8em;margin-bottom:.8em}.page .section dd{margin-left:0}.page .book-chapter{display:none} \ No newline at end of file diff --git a/theme/assets/ebook/epub.css b/theme/assets/ebook/epub.css deleted file mode 100644 index 904983255..000000000 --- a/theme/assets/ebook/epub.css +++ /dev/null @@ -1 +0,0 @@ -.section.glossary>h1,.section.toc>h1{text-align:center}.page .section b,.page .section dl dt,.page .section strong,.page .section table th{font-weight:700}.link-inherit,.link-inherit:focus,.link-inherit:hover{color:inherit}.hidden{display:none}.section.toc>ol{margin:0;padding:0}.section.toc li{list-style:none}.section.toc li .inner{display:block;border-bottom:1px dotted #eee;margin-bottom:4px}.section.toc li .inner a,.section.toc li .inner span{padding-right:5px;border-bottom:1px solid #fff;margin-bottom:-1px}.section.toc li .inner .page{float:right;padding-left:5px}.section.toc li ol{margin:0;padding:0 0 0 2em}.dir-rtl .section.toc li .inner .page{float:left}.dir-rtl .section.toc li ol{margin:0;padding:0 2em 0 0}.section.glossary .glossary-entry{margin-bottom:40px}.section.glossary .glossary-entry h2 a,.section.glossary .glossary-entry h2 a:hover{color:inherit;text-decoration:none}.page .section,body{color:#000;text-size-adjust:100%;-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}.section.glossary .glossary-entry .glossary-index{list-style:none;margin:0;padding:0}.section.glossary .glossary-entry .glossary-index li{display:inline;margin:0 8px;white-space:nowrap}body{font-family:serif}.page .section{display:block;word-wrap:break-word;overflow:hidden;line-height:1.6;-moz-text-size-adjust:100%}.page .section *{box-sizing:border-box;-webkit-box-sizing:border-box;font-size:inherit}.page .section>:first-child{margin-top:0!important}.page .section>:last-child{margin-bottom:0!important}.page .section blockquote,.page .section code,.page .section figure,.page .section img,.page .section pre,.page .section table,.page .section tr{page-break-inside:avoid}.page .section h2,.page .section h3,.page .section h4,.page .section h5,.page .section p{orphans:3;widows:3}.page .section h1,.page .section h2,.page .section h3,.page .section h4,.page .section h5{page-break-after:avoid}.page .section em{font-style:italic}.page .section blockquote,.page .section dl,.page .section ol,.page .section p,.page .section table,.page .section ul{margin-top:0;margin-bottom:.8em}.page .section a{color:#4183c4;text-decoration:none;background:0 0}.page .section a:active,.page .section a:focus,.page .section a:hover{outline:0;text-decoration:underline}.page .section img{border:0;max-width:100%}.page .section hr{height:4px;padding:0;margin:1.6em 0;overflow:hidden;background-color:#e7e7e7;border:none}.page .section hr:after,.page .section hr:before{display:table;content:" "}.page .section hr:after{clear:both}.page .section h1,.page .section h2,.page .section h3,.page .section h4,.page .section h5,.page .section h6{margin-top:1.2em;margin-bottom:.8em;font-weight:700}.page .section h1{font-size:2em}.page .section h2{font-size:1.75em}.page .section h3{font-size:1.5em}.page .section h4{font-size:1.25em}.page .section h5{font-size:1em}.page .section h6{font-size:1em;color:#777}.page .section code,.page .section pre{font-family:Consolas,"Liberation Mono",Menlo,Courier,monospace;direction:ltr;border:none;color:inherit}.page .section pre{overflow:auto;word-wrap:normal;margin:0 0 1.2em;padding:.85em 1em;background:#f7f7f7}.page .section pre>code{display:inline;max-width:initial;padding:0;margin:0;overflow:initial;line-height:inherit;font-size:.85em;white-space:pre;background:0 0}.page .section pre>code:after,.page .section pre>code:before{content:normal}.page .section code{padding:.2em;margin:0;font-size:.85em;background-color:#f7f7f7}.page .section code:after,.page .section code:before{letter-spacing:-.2em;content:"\00a0"}.page .section table{display:table;width:100%;border-collapse:collapse;border-spacing:0;overflow:auto}.page .section table td,.page .section table th{padding:6px 13px;border:1px solid #ddd}.page .section table tr{background-color:#fff;border-top:1px solid #ccc}.page .section table tr:nth-child(2n){background-color:#f8f8f8}.page .section ol,.page .section ul{padding:0 0 0 2em;margin:0 0 .8em}.page .section ol ol,.page .section ol ul,.page .section ul ol,.page .section ul ul{margin-top:0;margin-bottom:0}.page .section ol ol{list-style-type:lower-roman}.page .section blockquote{margin:0 0 .8em;padding:0 15px;color:#858585;border-left:4px solid #e5e5e5}.page .section blockquote:first-child{margin-top:0}.page .section blockquote:last-child{margin-bottom:0}.page .section dl{padding:0}.page .section dl dt{padding:0;margin-top:.8em;font-style:italic}.page .section dl dd{padding:0 .8em;margin-bottom:.8em}.page .section dd{margin-left:0}.page .book-chapter{display:none} \ No newline at end of file diff --git a/theme/assets/ebook/mobi.css b/theme/assets/ebook/mobi.css deleted file mode 100644 index f3c903cc2..000000000 --- a/theme/assets/ebook/mobi.css +++ /dev/null @@ -1 +0,0 @@ -.section.glossary>h1,.section.toc>h1{text-align:center}.page .section,body{text-size-adjust:100%;-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}.page .section b,.page .section dl dt,.page .section strong,.page .section table th{font-weight:700}.link-inherit,.link-inherit:focus,.link-inherit:hover{color:inherit}.hidden{display:none}.section.toc>ol{margin:0;padding:0}.section.toc li{list-style:none}.section.toc li .inner{display:block;border-bottom:1px dotted #eee;margin-bottom:4px}.section.toc li .inner a,.section.toc li .inner span{padding-right:5px;border-bottom:1px solid #fff;margin-bottom:-1px}.section.toc li .inner .page{float:right;padding-left:5px}.section.toc li ol{margin:0;padding:0 0 0 2em}.dir-rtl .section.toc li .inner .page{float:left}.dir-rtl .section.toc li ol{margin:0;padding:0 2em 0 0}.section.glossary .glossary-entry{margin-bottom:40px}.section.glossary .glossary-entry h2 a,.section.glossary .glossary-entry h2 a:hover{color:inherit;text-decoration:none}.section.glossary .glossary-entry .glossary-index{list-style:none;margin:0;padding:0}.section.glossary .glossary-entry .glossary-index li{display:inline;margin:0 8px;white-space:nowrap}body{font-family:serif;color:#000}.page .section{display:block;word-wrap:break-word;overflow:hidden;color:#000;line-height:1.6;-moz-text-size-adjust:100%}.page .section *{box-sizing:border-box;-webkit-box-sizing:border-box;font-size:inherit}.page .section>:first-child{margin-top:0!important}.page .section>:last-child{margin-bottom:0!important}.page .section blockquote,.page .section code,.page .section figure,.page .section img,.page .section pre,.page .section table,.page .section tr{page-break-inside:avoid}.page .section h2,.page .section h3,.page .section h4,.page .section h5,.page .section p{orphans:3;widows:3}.page .section h1,.page .section h2,.page .section h3,.page .section h4,.page .section h5{page-break-after:avoid}.page .section em{font-style:italic}.page .section blockquote,.page .section dl,.page .section ol,.page .section p,.page .section table,.page .section ul{margin-top:0;margin-bottom:.8em}.page .section a{color:#000;text-decoration:none;background:0 0}.page .section a:active,.page .section a:focus,.page .section a:hover{outline:0;text-decoration:underline}.page .section img{border:0;max-width:100%}.page .section hr{height:4px;padding:0;margin:1.6em 0;overflow:hidden;background-color:#e7e7e7;border:none}.page .section hr:after,.page .section hr:before{display:table;content:" "}.page .section hr:after{clear:both}.page .section h1,.page .section h2,.page .section h3,.page .section h4,.page .section h5,.page .section h6{margin-top:1.2em;margin-bottom:.8em;font-weight:700}.page .section h1{font-size:2em}.page .section h2{font-size:1.75em}.page .section h3{font-size:1.5em}.page .section h4{font-size:1.25em}.page .section h5{font-size:1em}.page .section h6{font-size:1em;color:#777}.page .section code,.page .section pre{font-family:Consolas,"Liberation Mono",Menlo,Courier,monospace;direction:ltr;border:none;color:inherit}.page .section pre{overflow:auto;word-wrap:normal;margin:0 0 1.2em;padding:.85em 1em;background:#f7f7f7}.page .section pre>code{display:inline;max-width:initial;padding:0;margin:0;overflow:initial;line-height:inherit;font-size:.85em;white-space:pre;background:0 0}.page .section pre>code:after,.page .section pre>code:before{content:normal}.page .section code{padding:.2em;margin:0;font-size:.85em;background-color:#f7f7f7}.page .section code:after,.page .section code:before{letter-spacing:-.2em;content:"\00a0"}.page .section table{display:table;width:100%;border-collapse:collapse;border-spacing:0;overflow:auto}.page .book-chapter,.page-toc li .inner span.page{display:none}.page .section table td,.page .section table th{padding:6px 13px;border:1px solid #ddd}.page .section table tr{background-color:#fff;border-top:1px solid #ccc}.page .section table tr:nth-child(2n){background-color:#f8f8f8}.page .section ol,.page .section ul{padding:0 0 0 2em;margin:0 0 .8em}.page .section ol ol,.page .section ol ul,.page .section ul ol,.page .section ul ul{margin-top:0;margin-bottom:0}.page .section ol ol{list-style-type:lower-roman}.page .section blockquote{margin:0 0 .8em;padding:0 15px;color:#858585;border-left:4px solid #e5e5e5}.page .section blockquote:first-child{margin-top:0}.page .section blockquote:last-child{margin-bottom:0}.page .section dl{padding:0}.page .section dl dt{padding:0;margin-top:.8em;font-style:italic}.page .section dl dd{padding:0 .8em;margin-bottom:.8em}.page .section dd{margin-left:0}a{color:#000;text-decoration:underline!important}.page-toc li{list-style-type:lower-alpha}.page-toc li .inner{border:none} \ No newline at end of file diff --git a/theme/assets/ebook/pdf.css b/theme/assets/ebook/pdf.css deleted file mode 100644 index 10bb53e8f..000000000 --- a/theme/assets/ebook/pdf.css +++ /dev/null @@ -1 +0,0 @@ -.section.glossary>h1,.section.toc>h1{text-align:center}.page .section b,.page .section dl dt,.page .section strong,.page .section table th{font-weight:700}.link-inherit,.link-inherit:focus,.link-inherit:hover{color:inherit}.hidden{display:none}.section.toc>ol{margin:0;padding:0}.section.toc li{list-style:none}.section.toc li .inner{display:block;border-bottom:1px dotted #eee;margin-bottom:4px}.section.toc li .inner a,.section.toc li .inner span{padding-right:5px;border-bottom:1px solid #fff;margin-bottom:-1px}.section.toc li .inner .page{float:right;padding-left:5px}.section.toc li ol{margin:0;padding:0 0 0 2em}.dir-rtl .section.toc li .inner .page{float:left}.dir-rtl .section.toc li ol{margin:0;padding:0 2em 0 0}.section.glossary .glossary-entry{margin-bottom:40px}.section.glossary .glossary-entry h2 a,.section.glossary .glossary-entry h2 a:hover{color:inherit;text-decoration:none}.page .section,body{color:#000;text-size-adjust:100%;-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}.section.glossary .glossary-entry .glossary-index{list-style:none;margin:0;padding:0}.section.glossary .glossary-entry .glossary-index li{display:inline;margin:0 8px;white-space:nowrap}body{font-family:sans-serif}.page .section{display:block;word-wrap:break-word;overflow:hidden;line-height:1.6;-moz-text-size-adjust:100%}.page .section *{box-sizing:border-box;-webkit-box-sizing:border-box;font-size:inherit}.page .section>:first-child{margin-top:0!important}.page .section>:last-child{margin-bottom:0!important}.page .section blockquote,.page .section code,.page .section figure,.page .section img,.page .section pre,.page .section table,.page .section tr{page-break-inside:avoid}.page .section h2,.page .section h3,.page .section h4,.page .section h5,.page .section p{orphans:3;widows:3}.page .section h1,.page .section h2,.page .section h3,.page .section h4,.page .section h5{page-break-after:avoid}.page .section em{font-style:italic}.page .section blockquote,.page .section dl,.page .section ol,.page .section p,.page .section table,.page .section ul{margin-top:0;margin-bottom:.8em}.page .section a{color:#4183c4;text-decoration:none;background:0 0}.page .section a:active,.page .section a:focus,.page .section a:hover{outline:0;text-decoration:underline}.page .section img{border:0;max-width:100%}.page .section hr{height:4px;padding:0;margin:1.6em 0;overflow:hidden;background-color:#e7e7e7;border:none}.page .section hr:after,.page .section hr:before{display:table;content:" "}.page .section hr:after{clear:both}.page .section h1,.page .section h2,.page .section h3,.page .section h4,.page .section h5,.page .section h6{margin-top:1.2em;margin-bottom:.8em;font-weight:700}.page .section h1{font-size:2em}.page .section h2{font-size:1.75em}.page .section h3{font-size:1.5em}.page .section h4{font-size:1.25em}.page .section h5{font-size:1em}.page .section h6{font-size:1em;color:#777}.page .section code,.page .section pre{font-family:Consolas,"Liberation Mono",Menlo,Courier,monospace;direction:ltr;border:none;color:inherit}.page .section pre{overflow:auto;word-wrap:normal;margin:0 0 1.2em;padding:.85em 1em;background:#f7f7f7}.page .section pre>code{display:inline;max-width:initial;padding:0;margin:0;overflow:initial;line-height:inherit;font-size:.85em;white-space:pre;background:0 0}.page .section pre>code:after,.page .section pre>code:before{content:normal}.page .section code{padding:.2em;margin:0;font-size:.85em;background-color:#f7f7f7}.page .section code:after,.page .section code:before{letter-spacing:-.2em;content:"\00a0"}.page .section table{display:table;width:100%;border-collapse:collapse;border-spacing:0;overflow:auto}.page .section table td,.page .section table th{padding:6px 13px;border:1px solid #ddd}.page .section table tr{background-color:#fff;border-top:1px solid #ccc}.page .section table tr:nth-child(2n){background-color:#f8f8f8}.page .section ol,.page .section ul{padding:0 0 0 2em;margin:0 0 .8em}.page .section ol ol,.page .section ol ul,.page .section ul ol,.page .section ul ul{margin-top:0;margin-bottom:0}.page .section ol ol{list-style-type:lower-roman}.page .section blockquote{margin:0 0 .8em;padding:0 15px;color:#858585;border-left:4px solid #e5e5e5}.page .section blockquote:first-child{margin-top:0}.page .section blockquote:last-child{margin-bottom:0}.page .section dl{padding:0}.page .section dl dt{padding:0;margin-top:.8em;font-style:italic}.pdf-footer,.pdf-header{margin-top:20px;color:#aaa}.page .section dl dd{padding:0 .8em;margin-bottom:.8em}.page .section dd{margin-left:0}.page .book-chapter{display:none}.pdf-footer{padding-top:10px;border-top:1px solid #eee}.pdf-footer .footer-pages-count{float:right}.pdf-header{padding-bottom:10px;border-bottom:1px solid #eee} \ No newline at end of file diff --git a/theme/assets/website/app.js b/theme/assets/website/app.js deleted file mode 100644 index 46a921bc4..000000000 --- a/theme/assets/website/app.js +++ /dev/null @@ -1,25001 +0,0 @@ -(function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o= 0 && j < len ? [ this[j] ] : [] ); - }, - - end: function() { - return this.prevObject || this.constructor(null); - }, - - // For internal use only. - // Behaves like an Array's method, not like a jQuery method. - push: push, - sort: arr.sort, - splice: arr.splice -}; - -jQuery.extend = jQuery.fn.extend = function() { - var options, name, src, copy, copyIsArray, clone, - target = arguments[0] || {}, - i = 1, - length = arguments.length, - deep = false; - - // Handle a deep copy situation - if ( typeof target === "boolean" ) { - deep = target; - - // Skip the boolean and the target - target = arguments[ i ] || {}; - i++; - } - - // Handle case when target is a string or something (possible in deep copy) - if ( typeof target !== "object" && !jQuery.isFunction(target) ) { - target = {}; - } - - // Extend jQuery itself if only one argument is passed - if ( i === length ) { - target = this; - i--; - } - - for ( ; i < length; i++ ) { - // Only deal with non-null/undefined values - if ( (options = arguments[ i ]) != null ) { - // Extend the base object - for ( name in options ) { - src = target[ name ]; - copy = options[ name ]; - - // Prevent never-ending loop - if ( target === copy ) { - continue; - } - - // Recurse if we're merging plain objects or arrays - if ( deep && copy && ( jQuery.isPlainObject(copy) || (copyIsArray = jQuery.isArray(copy)) ) ) { - if ( copyIsArray ) { - copyIsArray = false; - clone = src && jQuery.isArray(src) ? src : []; - - } else { - clone = src && jQuery.isPlainObject(src) ? src : {}; - } - - // Never move original objects, clone them - target[ name ] = jQuery.extend( deep, clone, copy ); - - // Don't bring in undefined values - } else if ( copy !== undefined ) { - target[ name ] = copy; - } - } - } - } - - // Return the modified object - return target; -}; - -jQuery.extend({ - // Unique for each copy of jQuery on the page - expando: "jQuery" + ( version + Math.random() ).replace( /\D/g, "" ), - - // Assume jQuery is ready without the ready module - isReady: true, - - error: function( msg ) { - throw new Error( msg ); - }, - - noop: function() {}, - - isFunction: function( obj ) { - return jQuery.type(obj) === "function"; - }, - - isArray: Array.isArray, - - isWindow: function( obj ) { - return obj != null && obj === obj.window; - }, - - isNumeric: function( obj ) { - // parseFloat NaNs numeric-cast false positives (null|true|false|"") - // ...but misinterprets leading-number strings, particularly hex literals ("0x...") - // subtraction forces infinities to NaN - // adding 1 corrects loss of precision from parseFloat (#15100) - return !jQuery.isArray( obj ) && (obj - parseFloat( obj ) + 1) >= 0; - }, - - isPlainObject: function( obj ) { - // Not plain objects: - // - Any object or value whose internal [[Class]] property is not "[object Object]" - // - DOM nodes - // - window - if ( jQuery.type( obj ) !== "object" || obj.nodeType || jQuery.isWindow( obj ) ) { - return false; - } - - if ( obj.constructor && - !hasOwn.call( obj.constructor.prototype, "isPrototypeOf" ) ) { - return false; - } - - // If the function hasn't returned already, we're confident that - // |obj| is a plain object, created by {} or constructed with new Object - return true; - }, - - isEmptyObject: function( obj ) { - var name; - for ( name in obj ) { - return false; - } - return true; - }, - - type: function( obj ) { - if ( obj == null ) { - return obj + ""; - } - // Support: Android<4.0, iOS<6 (functionish RegExp) - return typeof obj === "object" || typeof obj === "function" ? - class2type[ toString.call(obj) ] || "object" : - typeof obj; - }, - - // Evaluates a script in a global context - globalEval: function( code ) { - var script, - indirect = eval; - - code = jQuery.trim( code ); - - if ( code ) { - // If the code includes a valid, prologue position - // strict mode pragma, execute code by injecting a - // script tag into the document. - if ( code.indexOf("use strict") === 1 ) { - script = document.createElement("script"); - script.text = code; - document.head.appendChild( script ).parentNode.removeChild( script ); - } else { - // Otherwise, avoid the DOM node creation, insertion - // and removal by using an indirect global eval - indirect( code ); - } - } - }, - - // Convert dashed to camelCase; used by the css and data modules - // Support: IE9-11+ - // Microsoft forgot to hump their vendor prefix (#9572) - camelCase: function( string ) { - return string.replace( rmsPrefix, "ms-" ).replace( rdashAlpha, fcamelCase ); - }, - - nodeName: function( elem, name ) { - return elem.nodeName && elem.nodeName.toLowerCase() === name.toLowerCase(); - }, - - // args is for internal usage only - each: function( obj, callback, args ) { - var value, - i = 0, - length = obj.length, - isArray = isArraylike( obj ); - - if ( args ) { - if ( isArray ) { - for ( ; i < length; i++ ) { - value = callback.apply( obj[ i ], args ); - - if ( value === false ) { - break; - } - } - } else { - for ( i in obj ) { - value = callback.apply( obj[ i ], args ); - - if ( value === false ) { - break; - } - } - } - - // A special, fast, case for the most common use of each - } else { - if ( isArray ) { - for ( ; i < length; i++ ) { - value = callback.call( obj[ i ], i, obj[ i ] ); - - if ( value === false ) { - break; - } - } - } else { - for ( i in obj ) { - value = callback.call( obj[ i ], i, obj[ i ] ); - - if ( value === false ) { - break; - } - } - } - } - - return obj; - }, - - // Support: Android<4.1 - trim: function( text ) { - return text == null ? - "" : - ( text + "" ).replace( rtrim, "" ); - }, - - // results is for internal usage only - makeArray: function( arr, results ) { - var ret = results || []; - - if ( arr != null ) { - if ( isArraylike( Object(arr) ) ) { - jQuery.merge( ret, - typeof arr === "string" ? - [ arr ] : arr - ); - } else { - push.call( ret, arr ); - } - } - - return ret; - }, - - inArray: function( elem, arr, i ) { - return arr == null ? -1 : indexOf.call( arr, elem, i ); - }, - - merge: function( first, second ) { - var len = +second.length, - j = 0, - i = first.length; - - for ( ; j < len; j++ ) { - first[ i++ ] = second[ j ]; - } - - first.length = i; - - return first; - }, - - grep: function( elems, callback, invert ) { - var callbackInverse, - matches = [], - i = 0, - length = elems.length, - callbackExpect = !invert; - - // Go through the array, only saving the items - // that pass the validator function - for ( ; i < length; i++ ) { - callbackInverse = !callback( elems[ i ], i ); - if ( callbackInverse !== callbackExpect ) { - matches.push( elems[ i ] ); - } - } - - return matches; - }, - - // arg is for internal usage only - map: function( elems, callback, arg ) { - var value, - i = 0, - length = elems.length, - isArray = isArraylike( elems ), - ret = []; - - // Go through the array, translating each of the items to their new values - if ( isArray ) { - for ( ; i < length; i++ ) { - value = callback( elems[ i ], i, arg ); - - if ( value != null ) { - ret.push( value ); - } - } - - // Go through every key on the object, - } else { - for ( i in elems ) { - value = callback( elems[ i ], i, arg ); - - if ( value != null ) { - ret.push( value ); - } - } - } - - // Flatten any nested arrays - return concat.apply( [], ret ); - }, - - // A global GUID counter for objects - guid: 1, - - // Bind a function to a context, optionally partially applying any - // arguments. - proxy: function( fn, context ) { - var tmp, args, proxy; - - if ( typeof context === "string" ) { - tmp = fn[ context ]; - context = fn; - fn = tmp; - } - - // Quick check to determine if target is callable, in the spec - // this throws a TypeError, but we will just return undefined. - if ( !jQuery.isFunction( fn ) ) { - return undefined; - } - - // Simulated bind - args = slice.call( arguments, 2 ); - proxy = function() { - return fn.apply( context || this, args.concat( slice.call( arguments ) ) ); - }; - - // Set the guid of unique handler to the same of original handler, so it can be removed - proxy.guid = fn.guid = fn.guid || jQuery.guid++; - - return proxy; - }, - - now: Date.now, - - // jQuery.support is not used in Core but other projects attach their - // properties to it so it needs to exist. - support: support -}); - -// Populate the class2type map -jQuery.each("Boolean Number String Function Array Date RegExp Object Error".split(" "), function(i, name) { - class2type[ "[object " + name + "]" ] = name.toLowerCase(); -}); - -function isArraylike( obj ) { - - // Support: iOS 8.2 (not reproducible in simulator) - // `in` check used to prevent JIT error (gh-2145) - // hasOwn isn't used here due to false negatives - // regarding Nodelist length in IE - var length = "length" in obj && obj.length, - type = jQuery.type( obj ); - - if ( type === "function" || jQuery.isWindow( obj ) ) { - return false; - } - - if ( obj.nodeType === 1 && length ) { - return true; - } - - return type === "array" || length === 0 || - typeof length === "number" && length > 0 && ( length - 1 ) in obj; -} -var Sizzle = -/*! - * Sizzle CSS Selector Engine v2.2.0-pre - * http://sizzlejs.com/ - * - * Copyright 2008, 2014 jQuery Foundation, Inc. and other contributors - * Released under the MIT license - * http://jquery.org/license - * - * Date: 2014-12-16 - */ -(function( window ) { - -var i, - support, - Expr, - getText, - isXML, - tokenize, - compile, - select, - outermostContext, - sortInput, - hasDuplicate, - - // Local document vars - setDocument, - document, - docElem, - documentIsHTML, - rbuggyQSA, - rbuggyMatches, - matches, - contains, - - // Instance-specific data - expando = "sizzle" + 1 * new Date(), - preferredDoc = window.document, - dirruns = 0, - done = 0, - classCache = createCache(), - tokenCache = createCache(), - compilerCache = createCache(), - sortOrder = function( a, b ) { - if ( a === b ) { - hasDuplicate = true; - } - return 0; - }, - - // General-purpose constants - MAX_NEGATIVE = 1 << 31, - - // Instance methods - hasOwn = ({}).hasOwnProperty, - arr = [], - pop = arr.pop, - push_native = arr.push, - push = arr.push, - slice = arr.slice, - // Use a stripped-down indexOf as it's faster than native - // http://jsperf.com/thor-indexof-vs-for/5 - indexOf = function( list, elem ) { - var i = 0, - len = list.length; - for ( ; i < len; i++ ) { - if ( list[i] === elem ) { - return i; - } - } - return -1; - }, - - booleans = "checked|selected|async|autofocus|autoplay|controls|defer|disabled|hidden|ismap|loop|multiple|open|readonly|required|scoped", - - // Regular expressions - - // Whitespace characters http://www.w3.org/TR/css3-selectors/#whitespace - whitespace = "[\\x20\\t\\r\\n\\f]", - // http://www.w3.org/TR/css3-syntax/#characters - characterEncoding = "(?:\\\\.|[\\w-]|[^\\x00-\\xa0])+", - - // Loosely modeled on CSS identifier characters - // An unquoted value should be a CSS identifier http://www.w3.org/TR/css3-selectors/#attribute-selectors - // Proper syntax: http://www.w3.org/TR/CSS21/syndata.html#value-def-identifier - identifier = characterEncoding.replace( "w", "w#" ), - - // Attribute selectors: http://www.w3.org/TR/selectors/#attribute-selectors - attributes = "\\[" + whitespace + "*(" + characterEncoding + ")(?:" + whitespace + - // Operator (capture 2) - "*([*^$|!~]?=)" + whitespace + - // "Attribute values must be CSS identifiers [capture 5] or strings [capture 3 or capture 4]" - "*(?:'((?:\\\\.|[^\\\\'])*)'|\"((?:\\\\.|[^\\\\\"])*)\"|(" + identifier + "))|)" + whitespace + - "*\\]", - - pseudos = ":(" + characterEncoding + ")(?:\\((" + - // To reduce the number of selectors needing tokenize in the preFilter, prefer arguments: - // 1. quoted (capture 3; capture 4 or capture 5) - "('((?:\\\\.|[^\\\\'])*)'|\"((?:\\\\.|[^\\\\\"])*)\")|" + - // 2. simple (capture 6) - "((?:\\\\.|[^\\\\()[\\]]|" + attributes + ")*)|" + - // 3. anything else (capture 2) - ".*" + - ")\\)|)", - - // Leading and non-escaped trailing whitespace, capturing some non-whitespace characters preceding the latter - rwhitespace = new RegExp( whitespace + "+", "g" ), - rtrim = new RegExp( "^" + whitespace + "+|((?:^|[^\\\\])(?:\\\\.)*)" + whitespace + "+$", "g" ), - - rcomma = new RegExp( "^" + whitespace + "*," + whitespace + "*" ), - rcombinators = new RegExp( "^" + whitespace + "*([>+~]|" + whitespace + ")" + whitespace + "*" ), - - rattributeQuotes = new RegExp( "=" + whitespace + "*([^\\]'\"]*?)" + whitespace + "*\\]", "g" ), - - rpseudo = new RegExp( pseudos ), - ridentifier = new RegExp( "^" + identifier + "$" ), - - matchExpr = { - "ID": new RegExp( "^#(" + characterEncoding + ")" ), - "CLASS": new RegExp( "^\\.(" + characterEncoding + ")" ), - "TAG": new RegExp( "^(" + characterEncoding.replace( "w", "w*" ) + ")" ), - "ATTR": new RegExp( "^" + attributes ), - "PSEUDO": new RegExp( "^" + pseudos ), - "CHILD": new RegExp( "^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\(" + whitespace + - "*(even|odd|(([+-]|)(\\d*)n|)" + whitespace + "*(?:([+-]|)" + whitespace + - "*(\\d+)|))" + whitespace + "*\\)|)", "i" ), - "bool": new RegExp( "^(?:" + booleans + ")$", "i" ), - // For use in libraries implementing .is() - // We use this for POS matching in `select` - "needsContext": new RegExp( "^" + whitespace + "*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\(" + - whitespace + "*((?:-\\d)?\\d*)" + whitespace + "*\\)|)(?=[^-]|$)", "i" ) - }, - - rinputs = /^(?:input|select|textarea|button)$/i, - rheader = /^h\d$/i, - - rnative = /^[^{]+\{\s*\[native \w/, - - // Easily-parseable/retrievable ID or TAG or CLASS selectors - rquickExpr = /^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/, - - rsibling = /[+~]/, - rescape = /'|\\/g, - - // CSS escapes http://www.w3.org/TR/CSS21/syndata.html#escaped-characters - runescape = new RegExp( "\\\\([\\da-f]{1,6}" + whitespace + "?|(" + whitespace + ")|.)", "ig" ), - funescape = function( _, escaped, escapedWhitespace ) { - var high = "0x" + escaped - 0x10000; - // NaN means non-codepoint - // Support: Firefox<24 - // Workaround erroneous numeric interpretation of +"0x" - return high !== high || escapedWhitespace ? - escaped : - high < 0 ? - // BMP codepoint - String.fromCharCode( high + 0x10000 ) : - // Supplemental Plane codepoint (surrogate pair) - String.fromCharCode( high >> 10 | 0xD800, high & 0x3FF | 0xDC00 ); - }, - - // Used for iframes - // See setDocument() - // Removing the function wrapper causes a "Permission Denied" - // error in IE - unloadHandler = function() { - setDocument(); - }; - -// Optimize for push.apply( _, NodeList ) -try { - push.apply( - (arr = slice.call( preferredDoc.childNodes )), - preferredDoc.childNodes - ); - // Support: Android<4.0 - // Detect silently failing push.apply - arr[ preferredDoc.childNodes.length ].nodeType; -} catch ( e ) { - push = { apply: arr.length ? - - // Leverage slice if possible - function( target, els ) { - push_native.apply( target, slice.call(els) ); - } : - - // Support: IE<9 - // Otherwise append directly - function( target, els ) { - var j = target.length, - i = 0; - // Can't trust NodeList.length - while ( (target[j++] = els[i++]) ) {} - target.length = j - 1; - } - }; -} - -function Sizzle( selector, context, results, seed ) { - var match, elem, m, nodeType, - // QSA vars - i, groups, old, nid, newContext, newSelector; - - if ( ( context ? context.ownerDocument || context : preferredDoc ) !== document ) { - setDocument( context ); - } - - context = context || document; - results = results || []; - nodeType = context.nodeType; - - if ( typeof selector !== "string" || !selector || - nodeType !== 1 && nodeType !== 9 && nodeType !== 11 ) { - - return results; - } - - if ( !seed && documentIsHTML ) { - - // Try to shortcut find operations when possible (e.g., not under DocumentFragment) - if ( nodeType !== 11 && (match = rquickExpr.exec( selector )) ) { - // Speed-up: Sizzle("#ID") - if ( (m = match[1]) ) { - if ( nodeType === 9 ) { - elem = context.getElementById( m ); - // Check parentNode to catch when Blackberry 4.6 returns - // nodes that are no longer in the document (jQuery #6963) - if ( elem && elem.parentNode ) { - // Handle the case where IE, Opera, and Webkit return items - // by name instead of ID - if ( elem.id === m ) { - results.push( elem ); - return results; - } - } else { - return results; - } - } else { - // Context is not a document - if ( context.ownerDocument && (elem = context.ownerDocument.getElementById( m )) && - contains( context, elem ) && elem.id === m ) { - results.push( elem ); - return results; - } - } - - // Speed-up: Sizzle("TAG") - } else if ( match[2] ) { - push.apply( results, context.getElementsByTagName( selector ) ); - return results; - - // Speed-up: Sizzle(".CLASS") - } else if ( (m = match[3]) && support.getElementsByClassName ) { - push.apply( results, context.getElementsByClassName( m ) ); - return results; - } - } - - // QSA path - if ( support.qsa && (!rbuggyQSA || !rbuggyQSA.test( selector )) ) { - nid = old = expando; - newContext = context; - newSelector = nodeType !== 1 && selector; - - // qSA works strangely on Element-rooted queries - // We can work around this by specifying an extra ID on the root - // and working up from there (Thanks to Andrew Dupont for the technique) - // IE 8 doesn't work on object elements - if ( nodeType === 1 && context.nodeName.toLowerCase() !== "object" ) { - groups = tokenize( selector ); - - if ( (old = context.getAttribute("id")) ) { - nid = old.replace( rescape, "\\$&" ); - } else { - context.setAttribute( "id", nid ); - } - nid = "[id='" + nid + "'] "; - - i = groups.length; - while ( i-- ) { - groups[i] = nid + toSelector( groups[i] ); - } - newContext = rsibling.test( selector ) && testContext( context.parentNode ) || context; - newSelector = groups.join(","); - } - - if ( newSelector ) { - try { - push.apply( results, - newContext.querySelectorAll( newSelector ) - ); - return results; - } catch(qsaError) { - } finally { - if ( !old ) { - context.removeAttribute("id"); - } - } - } - } - } - - // All others - return select( selector.replace( rtrim, "$1" ), context, results, seed ); -} - -/** - * Create key-value caches of limited size - * @returns {Function(string, Object)} Returns the Object data after storing it on itself with - * property name the (space-suffixed) string and (if the cache is larger than Expr.cacheLength) - * deleting the oldest entry - */ -function createCache() { - var keys = []; - - function cache( key, value ) { - // Use (key + " ") to avoid collision with native prototype properties (see Issue #157) - if ( keys.push( key + " " ) > Expr.cacheLength ) { - // Only keep the most recent entries - delete cache[ keys.shift() ]; - } - return (cache[ key + " " ] = value); - } - return cache; -} - -/** - * Mark a function for special use by Sizzle - * @param {Function} fn The function to mark - */ -function markFunction( fn ) { - fn[ expando ] = true; - return fn; -} - -/** - * Support testing using an element - * @param {Function} fn Passed the created div and expects a boolean result - */ -function assert( fn ) { - var div = document.createElement("div"); - - try { - return !!fn( div ); - } catch (e) { - return false; - } finally { - // Remove from its parent by default - if ( div.parentNode ) { - div.parentNode.removeChild( div ); - } - // release memory in IE - div = null; - } -} - -/** - * Adds the same handler for all of the specified attrs - * @param {String} attrs Pipe-separated list of attributes - * @param {Function} handler The method that will be applied - */ -function addHandle( attrs, handler ) { - var arr = attrs.split("|"), - i = attrs.length; - - while ( i-- ) { - Expr.attrHandle[ arr[i] ] = handler; - } -} - -/** - * Checks document order of two siblings - * @param {Element} a - * @param {Element} b - * @returns {Number} Returns less than 0 if a precedes b, greater than 0 if a follows b - */ -function siblingCheck( a, b ) { - var cur = b && a, - diff = cur && a.nodeType === 1 && b.nodeType === 1 && - ( ~b.sourceIndex || MAX_NEGATIVE ) - - ( ~a.sourceIndex || MAX_NEGATIVE ); - - // Use IE sourceIndex if available on both nodes - if ( diff ) { - return diff; - } - - // Check if b follows a - if ( cur ) { - while ( (cur = cur.nextSibling) ) { - if ( cur === b ) { - return -1; - } - } - } - - return a ? 1 : -1; -} - -/** - * Returns a function to use in pseudos for input types - * @param {String} type - */ -function createInputPseudo( type ) { - return function( elem ) { - var name = elem.nodeName.toLowerCase(); - return name === "input" && elem.type === type; - }; -} - -/** - * Returns a function to use in pseudos for buttons - * @param {String} type - */ -function createButtonPseudo( type ) { - return function( elem ) { - var name = elem.nodeName.toLowerCase(); - return (name === "input" || name === "button") && elem.type === type; - }; -} - -/** - * Returns a function to use in pseudos for positionals - * @param {Function} fn - */ -function createPositionalPseudo( fn ) { - return markFunction(function( argument ) { - argument = +argument; - return markFunction(function( seed, matches ) { - var j, - matchIndexes = fn( [], seed.length, argument ), - i = matchIndexes.length; - - // Match elements found at the specified indexes - while ( i-- ) { - if ( seed[ (j = matchIndexes[i]) ] ) { - seed[j] = !(matches[j] = seed[j]); - } - } - }); - }); -} - -/** - * Checks a node for validity as a Sizzle context - * @param {Element|Object=} context - * @returns {Element|Object|Boolean} The input node if acceptable, otherwise a falsy value - */ -function testContext( context ) { - return context && typeof context.getElementsByTagName !== "undefined" && context; -} - -// Expose support vars for convenience -support = Sizzle.support = {}; - -/** - * Detects XML nodes - * @param {Element|Object} elem An element or a document - * @returns {Boolean} True iff elem is a non-HTML XML node - */ -isXML = Sizzle.isXML = function( elem ) { - // documentElement is verified for cases where it doesn't yet exist - // (such as loading iframes in IE - #4833) - var documentElement = elem && (elem.ownerDocument || elem).documentElement; - return documentElement ? documentElement.nodeName !== "HTML" : false; -}; - -/** - * Sets document-related variables once based on the current document - * @param {Element|Object} [doc] An element or document object to use to set the document - * @returns {Object} Returns the current document - */ -setDocument = Sizzle.setDocument = function( node ) { - var hasCompare, parent, - doc = node ? node.ownerDocument || node : preferredDoc; - - // If no document and documentElement is available, return - if ( doc === document || doc.nodeType !== 9 || !doc.documentElement ) { - return document; - } - - // Set our document - document = doc; - docElem = doc.documentElement; - parent = doc.defaultView; - - // Support: IE>8 - // If iframe document is assigned to "document" variable and if iframe has been reloaded, - // IE will throw "permission denied" error when accessing "document" variable, see jQuery #13936 - // IE6-8 do not support the defaultView property so parent will be undefined - if ( parent && parent !== parent.top ) { - // IE11 does not have attachEvent, so all must suffer - if ( parent.addEventListener ) { - parent.addEventListener( "unload", unloadHandler, false ); - } else if ( parent.attachEvent ) { - parent.attachEvent( "onunload", unloadHandler ); - } - } - - /* Support tests - ---------------------------------------------------------------------- */ - documentIsHTML = !isXML( doc ); - - /* Attributes - ---------------------------------------------------------------------- */ - - // Support: IE<8 - // Verify that getAttribute really returns attributes and not properties - // (excepting IE8 booleans) - support.attributes = assert(function( div ) { - div.className = "i"; - return !div.getAttribute("className"); - }); - - /* getElement(s)By* - ---------------------------------------------------------------------- */ - - // Check if getElementsByTagName("*") returns only elements - support.getElementsByTagName = assert(function( div ) { - div.appendChild( doc.createComment("") ); - return !div.getElementsByTagName("*").length; - }); - - // Support: IE<9 - support.getElementsByClassName = rnative.test( doc.getElementsByClassName ); - - // Support: IE<10 - // Check if getElementById returns elements by name - // The broken getElementById methods don't pick up programatically-set names, - // so use a roundabout getElementsByName test - support.getById = assert(function( div ) { - docElem.appendChild( div ).id = expando; - return !doc.getElementsByName || !doc.getElementsByName( expando ).length; - }); - - // ID find and filter - if ( support.getById ) { - Expr.find["ID"] = function( id, context ) { - if ( typeof context.getElementById !== "undefined" && documentIsHTML ) { - var m = context.getElementById( id ); - // Check parentNode to catch when Blackberry 4.6 returns - // nodes that are no longer in the document #6963 - return m && m.parentNode ? [ m ] : []; - } - }; - Expr.filter["ID"] = function( id ) { - var attrId = id.replace( runescape, funescape ); - return function( elem ) { - return elem.getAttribute("id") === attrId; - }; - }; - } else { - // Support: IE6/7 - // getElementById is not reliable as a find shortcut - delete Expr.find["ID"]; - - Expr.filter["ID"] = function( id ) { - var attrId = id.replace( runescape, funescape ); - return function( elem ) { - var node = typeof elem.getAttributeNode !== "undefined" && elem.getAttributeNode("id"); - return node && node.value === attrId; - }; - }; - } - - // Tag - Expr.find["TAG"] = support.getElementsByTagName ? - function( tag, context ) { - if ( typeof context.getElementsByTagName !== "undefined" ) { - return context.getElementsByTagName( tag ); - - // DocumentFragment nodes don't have gEBTN - } else if ( support.qsa ) { - return context.querySelectorAll( tag ); - } - } : - - function( tag, context ) { - var elem, - tmp = [], - i = 0, - // By happy coincidence, a (broken) gEBTN appears on DocumentFragment nodes too - results = context.getElementsByTagName( tag ); - - // Filter out possible comments - if ( tag === "*" ) { - while ( (elem = results[i++]) ) { - if ( elem.nodeType === 1 ) { - tmp.push( elem ); - } - } - - return tmp; - } - return results; - }; - - // Class - Expr.find["CLASS"] = support.getElementsByClassName && function( className, context ) { - if ( documentIsHTML ) { - return context.getElementsByClassName( className ); - } - }; - - /* QSA/matchesSelector - ---------------------------------------------------------------------- */ - - // QSA and matchesSelector support - - // matchesSelector(:active) reports false when true (IE9/Opera 11.5) - rbuggyMatches = []; - - // qSa(:focus) reports false when true (Chrome 21) - // We allow this because of a bug in IE8/9 that throws an error - // whenever `document.activeElement` is accessed on an iframe - // So, we allow :focus to pass through QSA all the time to avoid the IE error - // See http://bugs.jquery.com/ticket/13378 - rbuggyQSA = []; - - if ( (support.qsa = rnative.test( doc.querySelectorAll )) ) { - // Build QSA regex - // Regex strategy adopted from Diego Perini - assert(function( div ) { - // Select is set to empty string on purpose - // This is to test IE's treatment of not explicitly - // setting a boolean content attribute, - // since its presence should be enough - // http://bugs.jquery.com/ticket/12359 - docElem.appendChild( div ).innerHTML = "" + - ""; - - // Support: IE8, Opera 11-12.16 - // Nothing should be selected when empty strings follow ^= or $= or *= - // The test attribute must be unknown in Opera but "safe" for WinRT - // http://msdn.microsoft.com/en-us/library/ie/hh465388.aspx#attribute_section - if ( div.querySelectorAll("[msallowcapture^='']").length ) { - rbuggyQSA.push( "[*^$]=" + whitespace + "*(?:''|\"\")" ); - } - - // Support: IE8 - // Boolean attributes and "value" are not treated correctly - if ( !div.querySelectorAll("[selected]").length ) { - rbuggyQSA.push( "\\[" + whitespace + "*(?:value|" + booleans + ")" ); - } - - // Support: Chrome<29, Android<4.2+, Safari<7.0+, iOS<7.0+, PhantomJS<1.9.7+ - if ( !div.querySelectorAll( "[id~=" + expando + "-]" ).length ) { - rbuggyQSA.push("~="); - } - - // Webkit/Opera - :checked should return selected option elements - // http://www.w3.org/TR/2011/REC-css3-selectors-20110929/#checked - // IE8 throws error here and will not see later tests - if ( !div.querySelectorAll(":checked").length ) { - rbuggyQSA.push(":checked"); - } - - // Support: Safari 8+, iOS 8+ - // https://bugs.webkit.org/show_bug.cgi?id=136851 - // In-page `selector#id sibing-combinator selector` fails - if ( !div.querySelectorAll( "a#" + expando + "+*" ).length ) { - rbuggyQSA.push(".#.+[+~]"); - } - }); - - assert(function( div ) { - // Support: Windows 8 Native Apps - // The type and name attributes are restricted during .innerHTML assignment - var input = doc.createElement("input"); - input.setAttribute( "type", "hidden" ); - div.appendChild( input ).setAttribute( "name", "D" ); - - // Support: IE8 - // Enforce case-sensitivity of name attribute - if ( div.querySelectorAll("[name=d]").length ) { - rbuggyQSA.push( "name" + whitespace + "*[*^$|!~]?=" ); - } - - // FF 3.5 - :enabled/:disabled and hidden elements (hidden elements are still enabled) - // IE8 throws error here and will not see later tests - if ( !div.querySelectorAll(":enabled").length ) { - rbuggyQSA.push( ":enabled", ":disabled" ); - } - - // Opera 10-11 does not throw on post-comma invalid pseudos - div.querySelectorAll("*,:x"); - rbuggyQSA.push(",.*:"); - }); - } - - if ( (support.matchesSelector = rnative.test( (matches = docElem.matches || - docElem.webkitMatchesSelector || - docElem.mozMatchesSelector || - docElem.oMatchesSelector || - docElem.msMatchesSelector) )) ) { - - assert(function( div ) { - // Check to see if it's possible to do matchesSelector - // on a disconnected node (IE 9) - support.disconnectedMatch = matches.call( div, "div" ); - - // This should fail with an exception - // Gecko does not error, returns false instead - matches.call( div, "[s!='']:x" ); - rbuggyMatches.push( "!=", pseudos ); - }); - } - - rbuggyQSA = rbuggyQSA.length && new RegExp( rbuggyQSA.join("|") ); - rbuggyMatches = rbuggyMatches.length && new RegExp( rbuggyMatches.join("|") ); - - /* Contains - ---------------------------------------------------------------------- */ - hasCompare = rnative.test( docElem.compareDocumentPosition ); - - // Element contains another - // Purposefully does not implement inclusive descendent - // As in, an element does not contain itself - contains = hasCompare || rnative.test( docElem.contains ) ? - function( a, b ) { - var adown = a.nodeType === 9 ? a.documentElement : a, - bup = b && b.parentNode; - return a === bup || !!( bup && bup.nodeType === 1 && ( - adown.contains ? - adown.contains( bup ) : - a.compareDocumentPosition && a.compareDocumentPosition( bup ) & 16 - )); - } : - function( a, b ) { - if ( b ) { - while ( (b = b.parentNode) ) { - if ( b === a ) { - return true; - } - } - } - return false; - }; - - /* Sorting - ---------------------------------------------------------------------- */ - - // Document order sorting - sortOrder = hasCompare ? - function( a, b ) { - - // Flag for duplicate removal - if ( a === b ) { - hasDuplicate = true; - return 0; - } - - // Sort on method existence if only one input has compareDocumentPosition - var compare = !a.compareDocumentPosition - !b.compareDocumentPosition; - if ( compare ) { - return compare; - } - - // Calculate position if both inputs belong to the same document - compare = ( a.ownerDocument || a ) === ( b.ownerDocument || b ) ? - a.compareDocumentPosition( b ) : - - // Otherwise we know they are disconnected - 1; - - // Disconnected nodes - if ( compare & 1 || - (!support.sortDetached && b.compareDocumentPosition( a ) === compare) ) { - - // Choose the first element that is related to our preferred document - if ( a === doc || a.ownerDocument === preferredDoc && contains(preferredDoc, a) ) { - return -1; - } - if ( b === doc || b.ownerDocument === preferredDoc && contains(preferredDoc, b) ) { - return 1; - } - - // Maintain original order - return sortInput ? - ( indexOf( sortInput, a ) - indexOf( sortInput, b ) ) : - 0; - } - - return compare & 4 ? -1 : 1; - } : - function( a, b ) { - // Exit early if the nodes are identical - if ( a === b ) { - hasDuplicate = true; - return 0; - } - - var cur, - i = 0, - aup = a.parentNode, - bup = b.parentNode, - ap = [ a ], - bp = [ b ]; - - // Parentless nodes are either documents or disconnected - if ( !aup || !bup ) { - return a === doc ? -1 : - b === doc ? 1 : - aup ? -1 : - bup ? 1 : - sortInput ? - ( indexOf( sortInput, a ) - indexOf( sortInput, b ) ) : - 0; - - // If the nodes are siblings, we can do a quick check - } else if ( aup === bup ) { - return siblingCheck( a, b ); - } - - // Otherwise we need full lists of their ancestors for comparison - cur = a; - while ( (cur = cur.parentNode) ) { - ap.unshift( cur ); - } - cur = b; - while ( (cur = cur.parentNode) ) { - bp.unshift( cur ); - } - - // Walk down the tree looking for a discrepancy - while ( ap[i] === bp[i] ) { - i++; - } - - return i ? - // Do a sibling check if the nodes have a common ancestor - siblingCheck( ap[i], bp[i] ) : - - // Otherwise nodes in our document sort first - ap[i] === preferredDoc ? -1 : - bp[i] === preferredDoc ? 1 : - 0; - }; - - return doc; -}; - -Sizzle.matches = function( expr, elements ) { - return Sizzle( expr, null, null, elements ); -}; - -Sizzle.matchesSelector = function( elem, expr ) { - // Set document vars if needed - if ( ( elem.ownerDocument || elem ) !== document ) { - setDocument( elem ); - } - - // Make sure that attribute selectors are quoted - expr = expr.replace( rattributeQuotes, "='$1']" ); - - if ( support.matchesSelector && documentIsHTML && - ( !rbuggyMatches || !rbuggyMatches.test( expr ) ) && - ( !rbuggyQSA || !rbuggyQSA.test( expr ) ) ) { - - try { - var ret = matches.call( elem, expr ); - - // IE 9's matchesSelector returns false on disconnected nodes - if ( ret || support.disconnectedMatch || - // As well, disconnected nodes are said to be in a document - // fragment in IE 9 - elem.document && elem.document.nodeType !== 11 ) { - return ret; - } - } catch (e) {} - } - - return Sizzle( expr, document, null, [ elem ] ).length > 0; -}; - -Sizzle.contains = function( context, elem ) { - // Set document vars if needed - if ( ( context.ownerDocument || context ) !== document ) { - setDocument( context ); - } - return contains( context, elem ); -}; - -Sizzle.attr = function( elem, name ) { - // Set document vars if needed - if ( ( elem.ownerDocument || elem ) !== document ) { - setDocument( elem ); - } - - var fn = Expr.attrHandle[ name.toLowerCase() ], - // Don't get fooled by Object.prototype properties (jQuery #13807) - val = fn && hasOwn.call( Expr.attrHandle, name.toLowerCase() ) ? - fn( elem, name, !documentIsHTML ) : - undefined; - - return val !== undefined ? - val : - support.attributes || !documentIsHTML ? - elem.getAttribute( name ) : - (val = elem.getAttributeNode(name)) && val.specified ? - val.value : - null; -}; - -Sizzle.error = function( msg ) { - throw new Error( "Syntax error, unrecognized expression: " + msg ); -}; - -/** - * Document sorting and removing duplicates - * @param {ArrayLike} results - */ -Sizzle.uniqueSort = function( results ) { - var elem, - duplicates = [], - j = 0, - i = 0; - - // Unless we *know* we can detect duplicates, assume their presence - hasDuplicate = !support.detectDuplicates; - sortInput = !support.sortStable && results.slice( 0 ); - results.sort( sortOrder ); - - if ( hasDuplicate ) { - while ( (elem = results[i++]) ) { - if ( elem === results[ i ] ) { - j = duplicates.push( i ); - } - } - while ( j-- ) { - results.splice( duplicates[ j ], 1 ); - } - } - - // Clear input after sorting to release objects - // See https://github.com/jquery/sizzle/pull/225 - sortInput = null; - - return results; -}; - -/** - * Utility function for retrieving the text value of an array of DOM nodes - * @param {Array|Element} elem - */ -getText = Sizzle.getText = function( elem ) { - var node, - ret = "", - i = 0, - nodeType = elem.nodeType; - - if ( !nodeType ) { - // If no nodeType, this is expected to be an array - while ( (node = elem[i++]) ) { - // Do not traverse comment nodes - ret += getText( node ); - } - } else if ( nodeType === 1 || nodeType === 9 || nodeType === 11 ) { - // Use textContent for elements - // innerText usage removed for consistency of new lines (jQuery #11153) - if ( typeof elem.textContent === "string" ) { - return elem.textContent; - } else { - // Traverse its children - for ( elem = elem.firstChild; elem; elem = elem.nextSibling ) { - ret += getText( elem ); - } - } - } else if ( nodeType === 3 || nodeType === 4 ) { - return elem.nodeValue; - } - // Do not include comment or processing instruction nodes - - return ret; -}; - -Expr = Sizzle.selectors = { - - // Can be adjusted by the user - cacheLength: 50, - - createPseudo: markFunction, - - match: matchExpr, - - attrHandle: {}, - - find: {}, - - relative: { - ">": { dir: "parentNode", first: true }, - " ": { dir: "parentNode" }, - "+": { dir: "previousSibling", first: true }, - "~": { dir: "previousSibling" } - }, - - preFilter: { - "ATTR": function( match ) { - match[1] = match[1].replace( runescape, funescape ); - - // Move the given value to match[3] whether quoted or unquoted - match[3] = ( match[3] || match[4] || match[5] || "" ).replace( runescape, funescape ); - - if ( match[2] === "~=" ) { - match[3] = " " + match[3] + " "; - } - - return match.slice( 0, 4 ); - }, - - "CHILD": function( match ) { - /* matches from matchExpr["CHILD"] - 1 type (only|nth|...) - 2 what (child|of-type) - 3 argument (even|odd|\d*|\d*n([+-]\d+)?|...) - 4 xn-component of xn+y argument ([+-]?\d*n|) - 5 sign of xn-component - 6 x of xn-component - 7 sign of y-component - 8 y of y-component - */ - match[1] = match[1].toLowerCase(); - - if ( match[1].slice( 0, 3 ) === "nth" ) { - // nth-* requires argument - if ( !match[3] ) { - Sizzle.error( match[0] ); - } - - // numeric x and y parameters for Expr.filter.CHILD - // remember that false/true cast respectively to 0/1 - match[4] = +( match[4] ? match[5] + (match[6] || 1) : 2 * ( match[3] === "even" || match[3] === "odd" ) ); - match[5] = +( ( match[7] + match[8] ) || match[3] === "odd" ); - - // other types prohibit arguments - } else if ( match[3] ) { - Sizzle.error( match[0] ); - } - - return match; - }, - - "PSEUDO": function( match ) { - var excess, - unquoted = !match[6] && match[2]; - - if ( matchExpr["CHILD"].test( match[0] ) ) { - return null; - } - - // Accept quoted arguments as-is - if ( match[3] ) { - match[2] = match[4] || match[5] || ""; - - // Strip excess characters from unquoted arguments - } else if ( unquoted && rpseudo.test( unquoted ) && - // Get excess from tokenize (recursively) - (excess = tokenize( unquoted, true )) && - // advance to the next closing parenthesis - (excess = unquoted.indexOf( ")", unquoted.length - excess ) - unquoted.length) ) { - - // excess is a negative index - match[0] = match[0].slice( 0, excess ); - match[2] = unquoted.slice( 0, excess ); - } - - // Return only captures needed by the pseudo filter method (type and argument) - return match.slice( 0, 3 ); - } - }, - - filter: { - - "TAG": function( nodeNameSelector ) { - var nodeName = nodeNameSelector.replace( runescape, funescape ).toLowerCase(); - return nodeNameSelector === "*" ? - function() { return true; } : - function( elem ) { - return elem.nodeName && elem.nodeName.toLowerCase() === nodeName; - }; - }, - - "CLASS": function( className ) { - var pattern = classCache[ className + " " ]; - - return pattern || - (pattern = new RegExp( "(^|" + whitespace + ")" + className + "(" + whitespace + "|$)" )) && - classCache( className, function( elem ) { - return pattern.test( typeof elem.className === "string" && elem.className || typeof elem.getAttribute !== "undefined" && elem.getAttribute("class") || "" ); - }); - }, - - "ATTR": function( name, operator, check ) { - return function( elem ) { - var result = Sizzle.attr( elem, name ); - - if ( result == null ) { - return operator === "!="; - } - if ( !operator ) { - return true; - } - - result += ""; - - return operator === "=" ? result === check : - operator === "!=" ? result !== check : - operator === "^=" ? check && result.indexOf( check ) === 0 : - operator === "*=" ? check && result.indexOf( check ) > -1 : - operator === "$=" ? check && result.slice( -check.length ) === check : - operator === "~=" ? ( " " + result.replace( rwhitespace, " " ) + " " ).indexOf( check ) > -1 : - operator === "|=" ? result === check || result.slice( 0, check.length + 1 ) === check + "-" : - false; - }; - }, - - "CHILD": function( type, what, argument, first, last ) { - var simple = type.slice( 0, 3 ) !== "nth", - forward = type.slice( -4 ) !== "last", - ofType = what === "of-type"; - - return first === 1 && last === 0 ? - - // Shortcut for :nth-*(n) - function( elem ) { - return !!elem.parentNode; - } : - - function( elem, context, xml ) { - var cache, outerCache, node, diff, nodeIndex, start, - dir = simple !== forward ? "nextSibling" : "previousSibling", - parent = elem.parentNode, - name = ofType && elem.nodeName.toLowerCase(), - useCache = !xml && !ofType; - - if ( parent ) { - - // :(first|last|only)-(child|of-type) - if ( simple ) { - while ( dir ) { - node = elem; - while ( (node = node[ dir ]) ) { - if ( ofType ? node.nodeName.toLowerCase() === name : node.nodeType === 1 ) { - return false; - } - } - // Reverse direction for :only-* (if we haven't yet done so) - start = dir = type === "only" && !start && "nextSibling"; - } - return true; - } - - start = [ forward ? parent.firstChild : parent.lastChild ]; - - // non-xml :nth-child(...) stores cache data on `parent` - if ( forward && useCache ) { - // Seek `elem` from a previously-cached index - outerCache = parent[ expando ] || (parent[ expando ] = {}); - cache = outerCache[ type ] || []; - nodeIndex = cache[0] === dirruns && cache[1]; - diff = cache[0] === dirruns && cache[2]; - node = nodeIndex && parent.childNodes[ nodeIndex ]; - - while ( (node = ++nodeIndex && node && node[ dir ] || - - // Fallback to seeking `elem` from the start - (diff = nodeIndex = 0) || start.pop()) ) { - - // When found, cache indexes on `parent` and break - if ( node.nodeType === 1 && ++diff && node === elem ) { - outerCache[ type ] = [ dirruns, nodeIndex, diff ]; - break; - } - } - - // Use previously-cached element index if available - } else if ( useCache && (cache = (elem[ expando ] || (elem[ expando ] = {}))[ type ]) && cache[0] === dirruns ) { - diff = cache[1]; - - // xml :nth-child(...) or :nth-last-child(...) or :nth(-last)?-of-type(...) - } else { - // Use the same loop as above to seek `elem` from the start - while ( (node = ++nodeIndex && node && node[ dir ] || - (diff = nodeIndex = 0) || start.pop()) ) { - - if ( ( ofType ? node.nodeName.toLowerCase() === name : node.nodeType === 1 ) && ++diff ) { - // Cache the index of each encountered element - if ( useCache ) { - (node[ expando ] || (node[ expando ] = {}))[ type ] = [ dirruns, diff ]; - } - - if ( node === elem ) { - break; - } - } - } - } - - // Incorporate the offset, then check against cycle size - diff -= last; - return diff === first || ( diff % first === 0 && diff / first >= 0 ); - } - }; - }, - - "PSEUDO": function( pseudo, argument ) { - // pseudo-class names are case-insensitive - // http://www.w3.org/TR/selectors/#pseudo-classes - // Prioritize by case sensitivity in case custom pseudos are added with uppercase letters - // Remember that setFilters inherits from pseudos - var args, - fn = Expr.pseudos[ pseudo ] || Expr.setFilters[ pseudo.toLowerCase() ] || - Sizzle.error( "unsupported pseudo: " + pseudo ); - - // The user may use createPseudo to indicate that - // arguments are needed to create the filter function - // just as Sizzle does - if ( fn[ expando ] ) { - return fn( argument ); - } - - // But maintain support for old signatures - if ( fn.length > 1 ) { - args = [ pseudo, pseudo, "", argument ]; - return Expr.setFilters.hasOwnProperty( pseudo.toLowerCase() ) ? - markFunction(function( seed, matches ) { - var idx, - matched = fn( seed, argument ), - i = matched.length; - while ( i-- ) { - idx = indexOf( seed, matched[i] ); - seed[ idx ] = !( matches[ idx ] = matched[i] ); - } - }) : - function( elem ) { - return fn( elem, 0, args ); - }; - } - - return fn; - } - }, - - pseudos: { - // Potentially complex pseudos - "not": markFunction(function( selector ) { - // Trim the selector passed to compile - // to avoid treating leading and trailing - // spaces as combinators - var input = [], - results = [], - matcher = compile( selector.replace( rtrim, "$1" ) ); - - return matcher[ expando ] ? - markFunction(function( seed, matches, context, xml ) { - var elem, - unmatched = matcher( seed, null, xml, [] ), - i = seed.length; - - // Match elements unmatched by `matcher` - while ( i-- ) { - if ( (elem = unmatched[i]) ) { - seed[i] = !(matches[i] = elem); - } - } - }) : - function( elem, context, xml ) { - input[0] = elem; - matcher( input, null, xml, results ); - // Don't keep the element (issue #299) - input[0] = null; - return !results.pop(); - }; - }), - - "has": markFunction(function( selector ) { - return function( elem ) { - return Sizzle( selector, elem ).length > 0; - }; - }), - - "contains": markFunction(function( text ) { - text = text.replace( runescape, funescape ); - return function( elem ) { - return ( elem.textContent || elem.innerText || getText( elem ) ).indexOf( text ) > -1; - }; - }), - - // "Whether an element is represented by a :lang() selector - // is based solely on the element's language value - // being equal to the identifier C, - // or beginning with the identifier C immediately followed by "-". - // The matching of C against the element's language value is performed case-insensitively. - // The identifier C does not have to be a valid language name." - // http://www.w3.org/TR/selectors/#lang-pseudo - "lang": markFunction( function( lang ) { - // lang value must be a valid identifier - if ( !ridentifier.test(lang || "") ) { - Sizzle.error( "unsupported lang: " + lang ); - } - lang = lang.replace( runescape, funescape ).toLowerCase(); - return function( elem ) { - var elemLang; - do { - if ( (elemLang = documentIsHTML ? - elem.lang : - elem.getAttribute("xml:lang") || elem.getAttribute("lang")) ) { - - elemLang = elemLang.toLowerCase(); - return elemLang === lang || elemLang.indexOf( lang + "-" ) === 0; - } - } while ( (elem = elem.parentNode) && elem.nodeType === 1 ); - return false; - }; - }), - - // Miscellaneous - "target": function( elem ) { - var hash = window.location && window.location.hash; - return hash && hash.slice( 1 ) === elem.id; - }, - - "root": function( elem ) { - return elem === docElem; - }, - - "focus": function( elem ) { - return elem === document.activeElement && (!document.hasFocus || document.hasFocus()) && !!(elem.type || elem.href || ~elem.tabIndex); - }, - - // Boolean properties - "enabled": function( elem ) { - return elem.disabled === false; - }, - - "disabled": function( elem ) { - return elem.disabled === true; - }, - - "checked": function( elem ) { - // In CSS3, :checked should return both checked and selected elements - // http://www.w3.org/TR/2011/REC-css3-selectors-20110929/#checked - var nodeName = elem.nodeName.toLowerCase(); - return (nodeName === "input" && !!elem.checked) || (nodeName === "option" && !!elem.selected); - }, - - "selected": function( elem ) { - // Accessing this property makes selected-by-default - // options in Safari work properly - if ( elem.parentNode ) { - elem.parentNode.selectedIndex; - } - - return elem.selected === true; - }, - - // Contents - "empty": function( elem ) { - // http://www.w3.org/TR/selectors/#empty-pseudo - // :empty is negated by element (1) or content nodes (text: 3; cdata: 4; entity ref: 5), - // but not by others (comment: 8; processing instruction: 7; etc.) - // nodeType < 6 works because attributes (2) do not appear as children - for ( elem = elem.firstChild; elem; elem = elem.nextSibling ) { - if ( elem.nodeType < 6 ) { - return false; - } - } - return true; - }, - - "parent": function( elem ) { - return !Expr.pseudos["empty"]( elem ); - }, - - // Element/input types - "header": function( elem ) { - return rheader.test( elem.nodeName ); - }, - - "input": function( elem ) { - return rinputs.test( elem.nodeName ); - }, - - "button": function( elem ) { - var name = elem.nodeName.toLowerCase(); - return name === "input" && elem.type === "button" || name === "button"; - }, - - "text": function( elem ) { - var attr; - return elem.nodeName.toLowerCase() === "input" && - elem.type === "text" && - - // Support: IE<8 - // New HTML5 attribute values (e.g., "search") appear with elem.type === "text" - ( (attr = elem.getAttribute("type")) == null || attr.toLowerCase() === "text" ); - }, - - // Position-in-collection - "first": createPositionalPseudo(function() { - return [ 0 ]; - }), - - "last": createPositionalPseudo(function( matchIndexes, length ) { - return [ length - 1 ]; - }), - - "eq": createPositionalPseudo(function( matchIndexes, length, argument ) { - return [ argument < 0 ? argument + length : argument ]; - }), - - "even": createPositionalPseudo(function( matchIndexes, length ) { - var i = 0; - for ( ; i < length; i += 2 ) { - matchIndexes.push( i ); - } - return matchIndexes; - }), - - "odd": createPositionalPseudo(function( matchIndexes, length ) { - var i = 1; - for ( ; i < length; i += 2 ) { - matchIndexes.push( i ); - } - return matchIndexes; - }), - - "lt": createPositionalPseudo(function( matchIndexes, length, argument ) { - var i = argument < 0 ? argument + length : argument; - for ( ; --i >= 0; ) { - matchIndexes.push( i ); - } - return matchIndexes; - }), - - "gt": createPositionalPseudo(function( matchIndexes, length, argument ) { - var i = argument < 0 ? argument + length : argument; - for ( ; ++i < length; ) { - matchIndexes.push( i ); - } - return matchIndexes; - }) - } -}; - -Expr.pseudos["nth"] = Expr.pseudos["eq"]; - -// Add button/input type pseudos -for ( i in { radio: true, checkbox: true, file: true, password: true, image: true } ) { - Expr.pseudos[ i ] = createInputPseudo( i ); -} -for ( i in { submit: true, reset: true } ) { - Expr.pseudos[ i ] = createButtonPseudo( i ); -} - -// Easy API for creating new setFilters -function setFilters() {} -setFilters.prototype = Expr.filters = Expr.pseudos; -Expr.setFilters = new setFilters(); - -tokenize = Sizzle.tokenize = function( selector, parseOnly ) { - var matched, match, tokens, type, - soFar, groups, preFilters, - cached = tokenCache[ selector + " " ]; - - if ( cached ) { - return parseOnly ? 0 : cached.slice( 0 ); - } - - soFar = selector; - groups = []; - preFilters = Expr.preFilter; - - while ( soFar ) { - - // Comma and first run - if ( !matched || (match = rcomma.exec( soFar )) ) { - if ( match ) { - // Don't consume trailing commas as valid - soFar = soFar.slice( match[0].length ) || soFar; - } - groups.push( (tokens = []) ); - } - - matched = false; - - // Combinators - if ( (match = rcombinators.exec( soFar )) ) { - matched = match.shift(); - tokens.push({ - value: matched, - // Cast descendant combinators to space - type: match[0].replace( rtrim, " " ) - }); - soFar = soFar.slice( matched.length ); - } - - // Filters - for ( type in Expr.filter ) { - if ( (match = matchExpr[ type ].exec( soFar )) && (!preFilters[ type ] || - (match = preFilters[ type ]( match ))) ) { - matched = match.shift(); - tokens.push({ - value: matched, - type: type, - matches: match - }); - soFar = soFar.slice( matched.length ); - } - } - - if ( !matched ) { - break; - } - } - - // Return the length of the invalid excess - // if we're just parsing - // Otherwise, throw an error or return tokens - return parseOnly ? - soFar.length : - soFar ? - Sizzle.error( selector ) : - // Cache the tokens - tokenCache( selector, groups ).slice( 0 ); -}; - -function toSelector( tokens ) { - var i = 0, - len = tokens.length, - selector = ""; - for ( ; i < len; i++ ) { - selector += tokens[i].value; - } - return selector; -} - -function addCombinator( matcher, combinator, base ) { - var dir = combinator.dir, - checkNonElements = base && dir === "parentNode", - doneName = done++; - - return combinator.first ? - // Check against closest ancestor/preceding element - function( elem, context, xml ) { - while ( (elem = elem[ dir ]) ) { - if ( elem.nodeType === 1 || checkNonElements ) { - return matcher( elem, context, xml ); - } - } - } : - - // Check against all ancestor/preceding elements - function( elem, context, xml ) { - var oldCache, outerCache, - newCache = [ dirruns, doneName ]; - - // We can't set arbitrary data on XML nodes, so they don't benefit from dir caching - if ( xml ) { - while ( (elem = elem[ dir ]) ) { - if ( elem.nodeType === 1 || checkNonElements ) { - if ( matcher( elem, context, xml ) ) { - return true; - } - } - } - } else { - while ( (elem = elem[ dir ]) ) { - if ( elem.nodeType === 1 || checkNonElements ) { - outerCache = elem[ expando ] || (elem[ expando ] = {}); - if ( (oldCache = outerCache[ dir ]) && - oldCache[ 0 ] === dirruns && oldCache[ 1 ] === doneName ) { - - // Assign to newCache so results back-propagate to previous elements - return (newCache[ 2 ] = oldCache[ 2 ]); - } else { - // Reuse newcache so results back-propagate to previous elements - outerCache[ dir ] = newCache; - - // A match means we're done; a fail means we have to keep checking - if ( (newCache[ 2 ] = matcher( elem, context, xml )) ) { - return true; - } - } - } - } - } - }; -} - -function elementMatcher( matchers ) { - return matchers.length > 1 ? - function( elem, context, xml ) { - var i = matchers.length; - while ( i-- ) { - if ( !matchers[i]( elem, context, xml ) ) { - return false; - } - } - return true; - } : - matchers[0]; -} - -function multipleContexts( selector, contexts, results ) { - var i = 0, - len = contexts.length; - for ( ; i < len; i++ ) { - Sizzle( selector, contexts[i], results ); - } - return results; -} - -function condense( unmatched, map, filter, context, xml ) { - var elem, - newUnmatched = [], - i = 0, - len = unmatched.length, - mapped = map != null; - - for ( ; i < len; i++ ) { - if ( (elem = unmatched[i]) ) { - if ( !filter || filter( elem, context, xml ) ) { - newUnmatched.push( elem ); - if ( mapped ) { - map.push( i ); - } - } - } - } - - return newUnmatched; -} - -function setMatcher( preFilter, selector, matcher, postFilter, postFinder, postSelector ) { - if ( postFilter && !postFilter[ expando ] ) { - postFilter = setMatcher( postFilter ); - } - if ( postFinder && !postFinder[ expando ] ) { - postFinder = setMatcher( postFinder, postSelector ); - } - return markFunction(function( seed, results, context, xml ) { - var temp, i, elem, - preMap = [], - postMap = [], - preexisting = results.length, - - // Get initial elements from seed or context - elems = seed || multipleContexts( selector || "*", context.nodeType ? [ context ] : context, [] ), - - // Prefilter to get matcher input, preserving a map for seed-results synchronization - matcherIn = preFilter && ( seed || !selector ) ? - condense( elems, preMap, preFilter, context, xml ) : - elems, - - matcherOut = matcher ? - // If we have a postFinder, or filtered seed, or non-seed postFilter or preexisting results, - postFinder || ( seed ? preFilter : preexisting || postFilter ) ? - - // ...intermediate processing is necessary - [] : - - // ...otherwise use results directly - results : - matcherIn; - - // Find primary matches - if ( matcher ) { - matcher( matcherIn, matcherOut, context, xml ); - } - - // Apply postFilter - if ( postFilter ) { - temp = condense( matcherOut, postMap ); - postFilter( temp, [], context, xml ); - - // Un-match failing elements by moving them back to matcherIn - i = temp.length; - while ( i-- ) { - if ( (elem = temp[i]) ) { - matcherOut[ postMap[i] ] = !(matcherIn[ postMap[i] ] = elem); - } - } - } - - if ( seed ) { - if ( postFinder || preFilter ) { - if ( postFinder ) { - // Get the final matcherOut by condensing this intermediate into postFinder contexts - temp = []; - i = matcherOut.length; - while ( i-- ) { - if ( (elem = matcherOut[i]) ) { - // Restore matcherIn since elem is not yet a final match - temp.push( (matcherIn[i] = elem) ); - } - } - postFinder( null, (matcherOut = []), temp, xml ); - } - - // Move matched elements from seed to results to keep them synchronized - i = matcherOut.length; - while ( i-- ) { - if ( (elem = matcherOut[i]) && - (temp = postFinder ? indexOf( seed, elem ) : preMap[i]) > -1 ) { - - seed[temp] = !(results[temp] = elem); - } - } - } - - // Add elements to results, through postFinder if defined - } else { - matcherOut = condense( - matcherOut === results ? - matcherOut.splice( preexisting, matcherOut.length ) : - matcherOut - ); - if ( postFinder ) { - postFinder( null, results, matcherOut, xml ); - } else { - push.apply( results, matcherOut ); - } - } - }); -} - -function matcherFromTokens( tokens ) { - var checkContext, matcher, j, - len = tokens.length, - leadingRelative = Expr.relative[ tokens[0].type ], - implicitRelative = leadingRelative || Expr.relative[" "], - i = leadingRelative ? 1 : 0, - - // The foundational matcher ensures that elements are reachable from top-level context(s) - matchContext = addCombinator( function( elem ) { - return elem === checkContext; - }, implicitRelative, true ), - matchAnyContext = addCombinator( function( elem ) { - return indexOf( checkContext, elem ) > -1; - }, implicitRelative, true ), - matchers = [ function( elem, context, xml ) { - var ret = ( !leadingRelative && ( xml || context !== outermostContext ) ) || ( - (checkContext = context).nodeType ? - matchContext( elem, context, xml ) : - matchAnyContext( elem, context, xml ) ); - // Avoid hanging onto element (issue #299) - checkContext = null; - return ret; - } ]; - - for ( ; i < len; i++ ) { - if ( (matcher = Expr.relative[ tokens[i].type ]) ) { - matchers = [ addCombinator(elementMatcher( matchers ), matcher) ]; - } else { - matcher = Expr.filter[ tokens[i].type ].apply( null, tokens[i].matches ); - - // Return special upon seeing a positional matcher - if ( matcher[ expando ] ) { - // Find the next relative operator (if any) for proper handling - j = ++i; - for ( ; j < len; j++ ) { - if ( Expr.relative[ tokens[j].type ] ) { - break; - } - } - return setMatcher( - i > 1 && elementMatcher( matchers ), - i > 1 && toSelector( - // If the preceding token was a descendant combinator, insert an implicit any-element `*` - tokens.slice( 0, i - 1 ).concat({ value: tokens[ i - 2 ].type === " " ? "*" : "" }) - ).replace( rtrim, "$1" ), - matcher, - i < j && matcherFromTokens( tokens.slice( i, j ) ), - j < len && matcherFromTokens( (tokens = tokens.slice( j )) ), - j < len && toSelector( tokens ) - ); - } - matchers.push( matcher ); - } - } - - return elementMatcher( matchers ); -} - -function matcherFromGroupMatchers( elementMatchers, setMatchers ) { - var bySet = setMatchers.length > 0, - byElement = elementMatchers.length > 0, - superMatcher = function( seed, context, xml, results, outermost ) { - var elem, j, matcher, - matchedCount = 0, - i = "0", - unmatched = seed && [], - setMatched = [], - contextBackup = outermostContext, - // We must always have either seed elements or outermost context - elems = seed || byElement && Expr.find["TAG"]( "*", outermost ), - // Use integer dirruns iff this is the outermost matcher - dirrunsUnique = (dirruns += contextBackup == null ? 1 : Math.random() || 0.1), - len = elems.length; - - if ( outermost ) { - outermostContext = context !== document && context; - } - - // Add elements passing elementMatchers directly to results - // Keep `i` a string if there are no elements so `matchedCount` will be "00" below - // Support: IE<9, Safari - // Tolerate NodeList properties (IE: "length"; Safari: ) matching elements by id - for ( ; i !== len && (elem = elems[i]) != null; i++ ) { - if ( byElement && elem ) { - j = 0; - while ( (matcher = elementMatchers[j++]) ) { - if ( matcher( elem, context, xml ) ) { - results.push( elem ); - break; - } - } - if ( outermost ) { - dirruns = dirrunsUnique; - } - } - - // Track unmatched elements for set filters - if ( bySet ) { - // They will have gone through all possible matchers - if ( (elem = !matcher && elem) ) { - matchedCount--; - } - - // Lengthen the array for every element, matched or not - if ( seed ) { - unmatched.push( elem ); - } - } - } - - // Apply set filters to unmatched elements - matchedCount += i; - if ( bySet && i !== matchedCount ) { - j = 0; - while ( (matcher = setMatchers[j++]) ) { - matcher( unmatched, setMatched, context, xml ); - } - - if ( seed ) { - // Reintegrate element matches to eliminate the need for sorting - if ( matchedCount > 0 ) { - while ( i-- ) { - if ( !(unmatched[i] || setMatched[i]) ) { - setMatched[i] = pop.call( results ); - } - } - } - - // Discard index placeholder values to get only actual matches - setMatched = condense( setMatched ); - } - - // Add matches to results - push.apply( results, setMatched ); - - // Seedless set matches succeeding multiple successful matchers stipulate sorting - if ( outermost && !seed && setMatched.length > 0 && - ( matchedCount + setMatchers.length ) > 1 ) { - - Sizzle.uniqueSort( results ); - } - } - - // Override manipulation of globals by nested matchers - if ( outermost ) { - dirruns = dirrunsUnique; - outermostContext = contextBackup; - } - - return unmatched; - }; - - return bySet ? - markFunction( superMatcher ) : - superMatcher; -} - -compile = Sizzle.compile = function( selector, match /* Internal Use Only */ ) { - var i, - setMatchers = [], - elementMatchers = [], - cached = compilerCache[ selector + " " ]; - - if ( !cached ) { - // Generate a function of recursive functions that can be used to check each element - if ( !match ) { - match = tokenize( selector ); - } - i = match.length; - while ( i-- ) { - cached = matcherFromTokens( match[i] ); - if ( cached[ expando ] ) { - setMatchers.push( cached ); - } else { - elementMatchers.push( cached ); - } - } - - // Cache the compiled function - cached = compilerCache( selector, matcherFromGroupMatchers( elementMatchers, setMatchers ) ); - - // Save selector and tokenization - cached.selector = selector; - } - return cached; -}; - -/** - * A low-level selection function that works with Sizzle's compiled - * selector functions - * @param {String|Function} selector A selector or a pre-compiled - * selector function built with Sizzle.compile - * @param {Element} context - * @param {Array} [results] - * @param {Array} [seed] A set of elements to match against - */ -select = Sizzle.select = function( selector, context, results, seed ) { - var i, tokens, token, type, find, - compiled = typeof selector === "function" && selector, - match = !seed && tokenize( (selector = compiled.selector || selector) ); - - results = results || []; - - // Try to minimize operations if there is no seed and only one group - if ( match.length === 1 ) { - - // Take a shortcut and set the context if the root selector is an ID - tokens = match[0] = match[0].slice( 0 ); - if ( tokens.length > 2 && (token = tokens[0]).type === "ID" && - support.getById && context.nodeType === 9 && documentIsHTML && - Expr.relative[ tokens[1].type ] ) { - - context = ( Expr.find["ID"]( token.matches[0].replace(runescape, funescape), context ) || [] )[0]; - if ( !context ) { - return results; - - // Precompiled matchers will still verify ancestry, so step up a level - } else if ( compiled ) { - context = context.parentNode; - } - - selector = selector.slice( tokens.shift().value.length ); - } - - // Fetch a seed set for right-to-left matching - i = matchExpr["needsContext"].test( selector ) ? 0 : tokens.length; - while ( i-- ) { - token = tokens[i]; - - // Abort if we hit a combinator - if ( Expr.relative[ (type = token.type) ] ) { - break; - } - if ( (find = Expr.find[ type ]) ) { - // Search, expanding context for leading sibling combinators - if ( (seed = find( - token.matches[0].replace( runescape, funescape ), - rsibling.test( tokens[0].type ) && testContext( context.parentNode ) || context - )) ) { - - // If seed is empty or no tokens remain, we can return early - tokens.splice( i, 1 ); - selector = seed.length && toSelector( tokens ); - if ( !selector ) { - push.apply( results, seed ); - return results; - } - - break; - } - } - } - } - - // Compile and execute a filtering function if one is not provided - // Provide `match` to avoid retokenization if we modified the selector above - ( compiled || compile( selector, match ) )( - seed, - context, - !documentIsHTML, - results, - rsibling.test( selector ) && testContext( context.parentNode ) || context - ); - return results; -}; - -// One-time assignments - -// Sort stability -support.sortStable = expando.split("").sort( sortOrder ).join("") === expando; - -// Support: Chrome 14-35+ -// Always assume duplicates if they aren't passed to the comparison function -support.detectDuplicates = !!hasDuplicate; - -// Initialize against the default document -setDocument(); - -// Support: Webkit<537.32 - Safari 6.0.3/Chrome 25 (fixed in Chrome 27) -// Detached nodes confoundingly follow *each other* -support.sortDetached = assert(function( div1 ) { - // Should return 1, but returns 4 (following) - return div1.compareDocumentPosition( document.createElement("div") ) & 1; -}); - -// Support: IE<8 -// Prevent attribute/property "interpolation" -// http://msdn.microsoft.com/en-us/library/ms536429%28VS.85%29.aspx -if ( !assert(function( div ) { - div.innerHTML = ""; - return div.firstChild.getAttribute("href") === "#" ; -}) ) { - addHandle( "type|href|height|width", function( elem, name, isXML ) { - if ( !isXML ) { - return elem.getAttribute( name, name.toLowerCase() === "type" ? 1 : 2 ); - } - }); -} - -// Support: IE<9 -// Use defaultValue in place of getAttribute("value") -if ( !support.attributes || !assert(function( div ) { - div.innerHTML = ""; - div.firstChild.setAttribute( "value", "" ); - return div.firstChild.getAttribute( "value" ) === ""; -}) ) { - addHandle( "value", function( elem, name, isXML ) { - if ( !isXML && elem.nodeName.toLowerCase() === "input" ) { - return elem.defaultValue; - } - }); -} - -// Support: IE<9 -// Use getAttributeNode to fetch booleans when getAttribute lies -if ( !assert(function( div ) { - return div.getAttribute("disabled") == null; -}) ) { - addHandle( booleans, function( elem, name, isXML ) { - var val; - if ( !isXML ) { - return elem[ name ] === true ? name.toLowerCase() : - (val = elem.getAttributeNode( name )) && val.specified ? - val.value : - null; - } - }); -} - -return Sizzle; - -})( window ); - - - -jQuery.find = Sizzle; -jQuery.expr = Sizzle.selectors; -jQuery.expr[":"] = jQuery.expr.pseudos; -jQuery.unique = Sizzle.uniqueSort; -jQuery.text = Sizzle.getText; -jQuery.isXMLDoc = Sizzle.isXML; -jQuery.contains = Sizzle.contains; - - - -var rneedsContext = jQuery.expr.match.needsContext; - -var rsingleTag = (/^<(\w+)\s*\/?>(?:<\/\1>|)$/); - - - -var risSimple = /^.[^:#\[\.,]*$/; - -// Implement the identical functionality for filter and not -function winnow( elements, qualifier, not ) { - if ( jQuery.isFunction( qualifier ) ) { - return jQuery.grep( elements, function( elem, i ) { - /* jshint -W018 */ - return !!qualifier.call( elem, i, elem ) !== not; - }); - - } - - if ( qualifier.nodeType ) { - return jQuery.grep( elements, function( elem ) { - return ( elem === qualifier ) !== not; - }); - - } - - if ( typeof qualifier === "string" ) { - if ( risSimple.test( qualifier ) ) { - return jQuery.filter( qualifier, elements, not ); - } - - qualifier = jQuery.filter( qualifier, elements ); - } - - return jQuery.grep( elements, function( elem ) { - return ( indexOf.call( qualifier, elem ) >= 0 ) !== not; - }); -} - -jQuery.filter = function( expr, elems, not ) { - var elem = elems[ 0 ]; - - if ( not ) { - expr = ":not(" + expr + ")"; - } - - return elems.length === 1 && elem.nodeType === 1 ? - jQuery.find.matchesSelector( elem, expr ) ? [ elem ] : [] : - jQuery.find.matches( expr, jQuery.grep( elems, function( elem ) { - return elem.nodeType === 1; - })); -}; - -jQuery.fn.extend({ - find: function( selector ) { - var i, - len = this.length, - ret = [], - self = this; - - if ( typeof selector !== "string" ) { - return this.pushStack( jQuery( selector ).filter(function() { - for ( i = 0; i < len; i++ ) { - if ( jQuery.contains( self[ i ], this ) ) { - return true; - } - } - }) ); - } - - for ( i = 0; i < len; i++ ) { - jQuery.find( selector, self[ i ], ret ); - } - - // Needed because $( selector, context ) becomes $( context ).find( selector ) - ret = this.pushStack( len > 1 ? jQuery.unique( ret ) : ret ); - ret.selector = this.selector ? this.selector + " " + selector : selector; - return ret; - }, - filter: function( selector ) { - return this.pushStack( winnow(this, selector || [], false) ); - }, - not: function( selector ) { - return this.pushStack( winnow(this, selector || [], true) ); - }, - is: function( selector ) { - return !!winnow( - this, - - // If this is a positional/relative selector, check membership in the returned set - // so $("p:first").is("p:last") won't return true for a doc with two "p". - typeof selector === "string" && rneedsContext.test( selector ) ? - jQuery( selector ) : - selector || [], - false - ).length; - } -}); - - -// Initialize a jQuery object - - -// A central reference to the root jQuery(document) -var rootjQuery, - - // A simple way to check for HTML strings - // Prioritize #id over to avoid XSS via location.hash (#9521) - // Strict HTML recognition (#11290: must start with <) - rquickExpr = /^(?:\s*(<[\w\W]+>)[^>]*|#([\w-]*))$/, - - init = jQuery.fn.init = function( selector, context ) { - var match, elem; - - // HANDLE: $(""), $(null), $(undefined), $(false) - if ( !selector ) { - return this; - } - - // Handle HTML strings - if ( typeof selector === "string" ) { - if ( selector[0] === "<" && selector[ selector.length - 1 ] === ">" && selector.length >= 3 ) { - // Assume that strings that start and end with <> are HTML and skip the regex check - match = [ null, selector, null ]; - - } else { - match = rquickExpr.exec( selector ); - } - - // Match html or make sure no context is specified for #id - if ( match && (match[1] || !context) ) { - - // HANDLE: $(html) -> $(array) - if ( match[1] ) { - context = context instanceof jQuery ? context[0] : context; - - // Option to run scripts is true for back-compat - // Intentionally let the error be thrown if parseHTML is not present - jQuery.merge( this, jQuery.parseHTML( - match[1], - context && context.nodeType ? context.ownerDocument || context : document, - true - ) ); - - // HANDLE: $(html, props) - if ( rsingleTag.test( match[1] ) && jQuery.isPlainObject( context ) ) { - for ( match in context ) { - // Properties of context are called as methods if possible - if ( jQuery.isFunction( this[ match ] ) ) { - this[ match ]( context[ match ] ); - - // ...and otherwise set as attributes - } else { - this.attr( match, context[ match ] ); - } - } - } - - return this; - - // HANDLE: $(#id) - } else { - elem = document.getElementById( match[2] ); - - // Support: Blackberry 4.6 - // gEBID returns nodes no longer in the document (#6963) - if ( elem && elem.parentNode ) { - // Inject the element directly into the jQuery object - this.length = 1; - this[0] = elem; - } - - this.context = document; - this.selector = selector; - return this; - } - - // HANDLE: $(expr, $(...)) - } else if ( !context || context.jquery ) { - return ( context || rootjQuery ).find( selector ); - - // HANDLE: $(expr, context) - // (which is just equivalent to: $(context).find(expr) - } else { - return this.constructor( context ).find( selector ); - } - - // HANDLE: $(DOMElement) - } else if ( selector.nodeType ) { - this.context = this[0] = selector; - this.length = 1; - return this; - - // HANDLE: $(function) - // Shortcut for document ready - } else if ( jQuery.isFunction( selector ) ) { - return typeof rootjQuery.ready !== "undefined" ? - rootjQuery.ready( selector ) : - // Execute immediately if ready is not present - selector( jQuery ); - } - - if ( selector.selector !== undefined ) { - this.selector = selector.selector; - this.context = selector.context; - } - - return jQuery.makeArray( selector, this ); - }; - -// Give the init function the jQuery prototype for later instantiation -init.prototype = jQuery.fn; - -// Initialize central reference -rootjQuery = jQuery( document ); - - -var rparentsprev = /^(?:parents|prev(?:Until|All))/, - // Methods guaranteed to produce a unique set when starting from a unique set - guaranteedUnique = { - children: true, - contents: true, - next: true, - prev: true - }; - -jQuery.extend({ - dir: function( elem, dir, until ) { - var matched = [], - truncate = until !== undefined; - - while ( (elem = elem[ dir ]) && elem.nodeType !== 9 ) { - if ( elem.nodeType === 1 ) { - if ( truncate && jQuery( elem ).is( until ) ) { - break; - } - matched.push( elem ); - } - } - return matched; - }, - - sibling: function( n, elem ) { - var matched = []; - - for ( ; n; n = n.nextSibling ) { - if ( n.nodeType === 1 && n !== elem ) { - matched.push( n ); - } - } - - return matched; - } -}); - -jQuery.fn.extend({ - has: function( target ) { - var targets = jQuery( target, this ), - l = targets.length; - - return this.filter(function() { - var i = 0; - for ( ; i < l; i++ ) { - if ( jQuery.contains( this, targets[i] ) ) { - return true; - } - } - }); - }, - - closest: function( selectors, context ) { - var cur, - i = 0, - l = this.length, - matched = [], - pos = rneedsContext.test( selectors ) || typeof selectors !== "string" ? - jQuery( selectors, context || this.context ) : - 0; - - for ( ; i < l; i++ ) { - for ( cur = this[i]; cur && cur !== context; cur = cur.parentNode ) { - // Always skip document fragments - if ( cur.nodeType < 11 && (pos ? - pos.index(cur) > -1 : - - // Don't pass non-elements to Sizzle - cur.nodeType === 1 && - jQuery.find.matchesSelector(cur, selectors)) ) { - - matched.push( cur ); - break; - } - } - } - - return this.pushStack( matched.length > 1 ? jQuery.unique( matched ) : matched ); - }, - - // Determine the position of an element within the set - index: function( elem ) { - - // No argument, return index in parent - if ( !elem ) { - return ( this[ 0 ] && this[ 0 ].parentNode ) ? this.first().prevAll().length : -1; - } - - // Index in selector - if ( typeof elem === "string" ) { - return indexOf.call( jQuery( elem ), this[ 0 ] ); - } - - // Locate the position of the desired element - return indexOf.call( this, - - // If it receives a jQuery object, the first element is used - elem.jquery ? elem[ 0 ] : elem - ); - }, - - add: function( selector, context ) { - return this.pushStack( - jQuery.unique( - jQuery.merge( this.get(), jQuery( selector, context ) ) - ) - ); - }, - - addBack: function( selector ) { - return this.add( selector == null ? - this.prevObject : this.prevObject.filter(selector) - ); - } -}); - -function sibling( cur, dir ) { - while ( (cur = cur[dir]) && cur.nodeType !== 1 ) {} - return cur; -} - -jQuery.each({ - parent: function( elem ) { - var parent = elem.parentNode; - return parent && parent.nodeType !== 11 ? parent : null; - }, - parents: function( elem ) { - return jQuery.dir( elem, "parentNode" ); - }, - parentsUntil: function( elem, i, until ) { - return jQuery.dir( elem, "parentNode", until ); - }, - next: function( elem ) { - return sibling( elem, "nextSibling" ); - }, - prev: function( elem ) { - return sibling( elem, "previousSibling" ); - }, - nextAll: function( elem ) { - return jQuery.dir( elem, "nextSibling" ); - }, - prevAll: function( elem ) { - return jQuery.dir( elem, "previousSibling" ); - }, - nextUntil: function( elem, i, until ) { - return jQuery.dir( elem, "nextSibling", until ); - }, - prevUntil: function( elem, i, until ) { - return jQuery.dir( elem, "previousSibling", until ); - }, - siblings: function( elem ) { - return jQuery.sibling( ( elem.parentNode || {} ).firstChild, elem ); - }, - children: function( elem ) { - return jQuery.sibling( elem.firstChild ); - }, - contents: function( elem ) { - return elem.contentDocument || jQuery.merge( [], elem.childNodes ); - } -}, function( name, fn ) { - jQuery.fn[ name ] = function( until, selector ) { - var matched = jQuery.map( this, fn, until ); - - if ( name.slice( -5 ) !== "Until" ) { - selector = until; - } - - if ( selector && typeof selector === "string" ) { - matched = jQuery.filter( selector, matched ); - } - - if ( this.length > 1 ) { - // Remove duplicates - if ( !guaranteedUnique[ name ] ) { - jQuery.unique( matched ); - } - - // Reverse order for parents* and prev-derivatives - if ( rparentsprev.test( name ) ) { - matched.reverse(); - } - } - - return this.pushStack( matched ); - }; -}); -var rnotwhite = (/\S+/g); - - - -// String to Object options format cache -var optionsCache = {}; - -// Convert String-formatted options into Object-formatted ones and store in cache -function createOptions( options ) { - var object = optionsCache[ options ] = {}; - jQuery.each( options.match( rnotwhite ) || [], function( _, flag ) { - object[ flag ] = true; - }); - return object; -} - -/* - * Create a callback list using the following parameters: - * - * options: an optional list of space-separated options that will change how - * the callback list behaves or a more traditional option object - * - * By default a callback list will act like an event callback list and can be - * "fired" multiple times. - * - * Possible options: - * - * once: will ensure the callback list can only be fired once (like a Deferred) - * - * memory: will keep track of previous values and will call any callback added - * after the list has been fired right away with the latest "memorized" - * values (like a Deferred) - * - * unique: will ensure a callback can only be added once (no duplicate in the list) - * - * stopOnFalse: interrupt callings when a callback returns false - * - */ -jQuery.Callbacks = function( options ) { - - // Convert options from String-formatted to Object-formatted if needed - // (we check in cache first) - options = typeof options === "string" ? - ( optionsCache[ options ] || createOptions( options ) ) : - jQuery.extend( {}, options ); - - var // Last fire value (for non-forgettable lists) - memory, - // Flag to know if list was already fired - fired, - // Flag to know if list is currently firing - firing, - // First callback to fire (used internally by add and fireWith) - firingStart, - // End of the loop when firing - firingLength, - // Index of currently firing callback (modified by remove if needed) - firingIndex, - // Actual callback list - list = [], - // Stack of fire calls for repeatable lists - stack = !options.once && [], - // Fire callbacks - fire = function( data ) { - memory = options.memory && data; - fired = true; - firingIndex = firingStart || 0; - firingStart = 0; - firingLength = list.length; - firing = true; - for ( ; list && firingIndex < firingLength; firingIndex++ ) { - if ( list[ firingIndex ].apply( data[ 0 ], data[ 1 ] ) === false && options.stopOnFalse ) { - memory = false; // To prevent further calls using add - break; - } - } - firing = false; - if ( list ) { - if ( stack ) { - if ( stack.length ) { - fire( stack.shift() ); - } - } else if ( memory ) { - list = []; - } else { - self.disable(); - } - } - }, - // Actual Callbacks object - self = { - // Add a callback or a collection of callbacks to the list - add: function() { - if ( list ) { - // First, we save the current length - var start = list.length; - (function add( args ) { - jQuery.each( args, function( _, arg ) { - var type = jQuery.type( arg ); - if ( type === "function" ) { - if ( !options.unique || !self.has( arg ) ) { - list.push( arg ); - } - } else if ( arg && arg.length && type !== "string" ) { - // Inspect recursively - add( arg ); - } - }); - })( arguments ); - // Do we need to add the callbacks to the - // current firing batch? - if ( firing ) { - firingLength = list.length; - // With memory, if we're not firing then - // we should call right away - } else if ( memory ) { - firingStart = start; - fire( memory ); - } - } - return this; - }, - // Remove a callback from the list - remove: function() { - if ( list ) { - jQuery.each( arguments, function( _, arg ) { - var index; - while ( ( index = jQuery.inArray( arg, list, index ) ) > -1 ) { - list.splice( index, 1 ); - // Handle firing indexes - if ( firing ) { - if ( index <= firingLength ) { - firingLength--; - } - if ( index <= firingIndex ) { - firingIndex--; - } - } - } - }); - } - return this; - }, - // Check if a given callback is in the list. - // If no argument is given, return whether or not list has callbacks attached. - has: function( fn ) { - return fn ? jQuery.inArray( fn, list ) > -1 : !!( list && list.length ); - }, - // Remove all callbacks from the list - empty: function() { - list = []; - firingLength = 0; - return this; - }, - // Have the list do nothing anymore - disable: function() { - list = stack = memory = undefined; - return this; - }, - // Is it disabled? - disabled: function() { - return !list; - }, - // Lock the list in its current state - lock: function() { - stack = undefined; - if ( !memory ) { - self.disable(); - } - return this; - }, - // Is it locked? - locked: function() { - return !stack; - }, - // Call all callbacks with the given context and arguments - fireWith: function( context, args ) { - if ( list && ( !fired || stack ) ) { - args = args || []; - args = [ context, args.slice ? args.slice() : args ]; - if ( firing ) { - stack.push( args ); - } else { - fire( args ); - } - } - return this; - }, - // Call all the callbacks with the given arguments - fire: function() { - self.fireWith( this, arguments ); - return this; - }, - // To know if the callbacks have already been called at least once - fired: function() { - return !!fired; - } - }; - - return self; -}; - - -jQuery.extend({ - - Deferred: function( func ) { - var tuples = [ - // action, add listener, listener list, final state - [ "resolve", "done", jQuery.Callbacks("once memory"), "resolved" ], - [ "reject", "fail", jQuery.Callbacks("once memory"), "rejected" ], - [ "notify", "progress", jQuery.Callbacks("memory") ] - ], - state = "pending", - promise = { - state: function() { - return state; - }, - always: function() { - deferred.done( arguments ).fail( arguments ); - return this; - }, - then: function( /* fnDone, fnFail, fnProgress */ ) { - var fns = arguments; - return jQuery.Deferred(function( newDefer ) { - jQuery.each( tuples, function( i, tuple ) { - var fn = jQuery.isFunction( fns[ i ] ) && fns[ i ]; - // deferred[ done | fail | progress ] for forwarding actions to newDefer - deferred[ tuple[1] ](function() { - var returned = fn && fn.apply( this, arguments ); - if ( returned && jQuery.isFunction( returned.promise ) ) { - returned.promise() - .done( newDefer.resolve ) - .fail( newDefer.reject ) - .progress( newDefer.notify ); - } else { - newDefer[ tuple[ 0 ] + "With" ]( this === promise ? newDefer.promise() : this, fn ? [ returned ] : arguments ); - } - }); - }); - fns = null; - }).promise(); - }, - // Get a promise for this deferred - // If obj is provided, the promise aspect is added to the object - promise: function( obj ) { - return obj != null ? jQuery.extend( obj, promise ) : promise; - } - }, - deferred = {}; - - // Keep pipe for back-compat - promise.pipe = promise.then; - - // Add list-specific methods - jQuery.each( tuples, function( i, tuple ) { - var list = tuple[ 2 ], - stateString = tuple[ 3 ]; - - // promise[ done | fail | progress ] = list.add - promise[ tuple[1] ] = list.add; - - // Handle state - if ( stateString ) { - list.add(function() { - // state = [ resolved | rejected ] - state = stateString; - - // [ reject_list | resolve_list ].disable; progress_list.lock - }, tuples[ i ^ 1 ][ 2 ].disable, tuples[ 2 ][ 2 ].lock ); - } - - // deferred[ resolve | reject | notify ] - deferred[ tuple[0] ] = function() { - deferred[ tuple[0] + "With" ]( this === deferred ? promise : this, arguments ); - return this; - }; - deferred[ tuple[0] + "With" ] = list.fireWith; - }); - - // Make the deferred a promise - promise.promise( deferred ); - - // Call given func if any - if ( func ) { - func.call( deferred, deferred ); - } - - // All done! - return deferred; - }, - - // Deferred helper - when: function( subordinate /* , ..., subordinateN */ ) { - var i = 0, - resolveValues = slice.call( arguments ), - length = resolveValues.length, - - // the count of uncompleted subordinates - remaining = length !== 1 || ( subordinate && jQuery.isFunction( subordinate.promise ) ) ? length : 0, - - // the master Deferred. If resolveValues consist of only a single Deferred, just use that. - deferred = remaining === 1 ? subordinate : jQuery.Deferred(), - - // Update function for both resolve and progress values - updateFunc = function( i, contexts, values ) { - return function( value ) { - contexts[ i ] = this; - values[ i ] = arguments.length > 1 ? slice.call( arguments ) : value; - if ( values === progressValues ) { - deferred.notifyWith( contexts, values ); - } else if ( !( --remaining ) ) { - deferred.resolveWith( contexts, values ); - } - }; - }, - - progressValues, progressContexts, resolveContexts; - - // Add listeners to Deferred subordinates; treat others as resolved - if ( length > 1 ) { - progressValues = new Array( length ); - progressContexts = new Array( length ); - resolveContexts = new Array( length ); - for ( ; i < length; i++ ) { - if ( resolveValues[ i ] && jQuery.isFunction( resolveValues[ i ].promise ) ) { - resolveValues[ i ].promise() - .done( updateFunc( i, resolveContexts, resolveValues ) ) - .fail( deferred.reject ) - .progress( updateFunc( i, progressContexts, progressValues ) ); - } else { - --remaining; - } - } - } - - // If we're not waiting on anything, resolve the master - if ( !remaining ) { - deferred.resolveWith( resolveContexts, resolveValues ); - } - - return deferred.promise(); - } -}); - - -// The deferred used on DOM ready -var readyList; - -jQuery.fn.ready = function( fn ) { - // Add the callback - jQuery.ready.promise().done( fn ); - - return this; -}; - -jQuery.extend({ - // Is the DOM ready to be used? Set to true once it occurs. - isReady: false, - - // A counter to track how many items to wait for before - // the ready event fires. See #6781 - readyWait: 1, - - // Hold (or release) the ready event - holdReady: function( hold ) { - if ( hold ) { - jQuery.readyWait++; - } else { - jQuery.ready( true ); - } - }, - - // Handle when the DOM is ready - ready: function( wait ) { - - // Abort if there are pending holds or we're already ready - if ( wait === true ? --jQuery.readyWait : jQuery.isReady ) { - return; - } - - // Remember that the DOM is ready - jQuery.isReady = true; - - // If a normal DOM Ready event fired, decrement, and wait if need be - if ( wait !== true && --jQuery.readyWait > 0 ) { - return; - } - - // If there are functions bound, to execute - readyList.resolveWith( document, [ jQuery ] ); - - // Trigger any bound ready events - if ( jQuery.fn.triggerHandler ) { - jQuery( document ).triggerHandler( "ready" ); - jQuery( document ).off( "ready" ); - } - } -}); - -/** - * The ready event handler and self cleanup method - */ -function completed() { - document.removeEventListener( "DOMContentLoaded", completed, false ); - window.removeEventListener( "load", completed, false ); - jQuery.ready(); -} - -jQuery.ready.promise = function( obj ) { - if ( !readyList ) { - - readyList = jQuery.Deferred(); - - // Catch cases where $(document).ready() is called after the browser event has already occurred. - // We once tried to use readyState "interactive" here, but it caused issues like the one - // discovered by ChrisS here: http://bugs.jquery.com/ticket/12282#comment:15 - if ( document.readyState === "complete" ) { - // Handle it asynchronously to allow scripts the opportunity to delay ready - setTimeout( jQuery.ready ); - - } else { - - // Use the handy event callback - document.addEventListener( "DOMContentLoaded", completed, false ); - - // A fallback to window.onload, that will always work - window.addEventListener( "load", completed, false ); - } - } - return readyList.promise( obj ); -}; - -// Kick off the DOM ready check even if the user does not -jQuery.ready.promise(); - - - - -// Multifunctional method to get and set values of a collection -// The value/s can optionally be executed if it's a function -var access = jQuery.access = function( elems, fn, key, value, chainable, emptyGet, raw ) { - var i = 0, - len = elems.length, - bulk = key == null; - - // Sets many values - if ( jQuery.type( key ) === "object" ) { - chainable = true; - for ( i in key ) { - jQuery.access( elems, fn, i, key[i], true, emptyGet, raw ); - } - - // Sets one value - } else if ( value !== undefined ) { - chainable = true; - - if ( !jQuery.isFunction( value ) ) { - raw = true; - } - - if ( bulk ) { - // Bulk operations run against the entire set - if ( raw ) { - fn.call( elems, value ); - fn = null; - - // ...except when executing function values - } else { - bulk = fn; - fn = function( elem, key, value ) { - return bulk.call( jQuery( elem ), value ); - }; - } - } - - if ( fn ) { - for ( ; i < len; i++ ) { - fn( elems[i], key, raw ? value : value.call( elems[i], i, fn( elems[i], key ) ) ); - } - } - } - - return chainable ? - elems : - - // Gets - bulk ? - fn.call( elems ) : - len ? fn( elems[0], key ) : emptyGet; -}; - - -/** - * Determines whether an object can have data - */ -jQuery.acceptData = function( owner ) { - // Accepts only: - // - Node - // - Node.ELEMENT_NODE - // - Node.DOCUMENT_NODE - // - Object - // - Any - /* jshint -W018 */ - return owner.nodeType === 1 || owner.nodeType === 9 || !( +owner.nodeType ); -}; - - -function Data() { - // Support: Android<4, - // Old WebKit does not have Object.preventExtensions/freeze method, - // return new empty object instead with no [[set]] accessor - Object.defineProperty( this.cache = {}, 0, { - get: function() { - return {}; - } - }); - - this.expando = jQuery.expando + Data.uid++; -} - -Data.uid = 1; -Data.accepts = jQuery.acceptData; - -Data.prototype = { - key: function( owner ) { - // We can accept data for non-element nodes in modern browsers, - // but we should not, see #8335. - // Always return the key for a frozen object. - if ( !Data.accepts( owner ) ) { - return 0; - } - - var descriptor = {}, - // Check if the owner object already has a cache key - unlock = owner[ this.expando ]; - - // If not, create one - if ( !unlock ) { - unlock = Data.uid++; - - // Secure it in a non-enumerable, non-writable property - try { - descriptor[ this.expando ] = { value: unlock }; - Object.defineProperties( owner, descriptor ); - - // Support: Android<4 - // Fallback to a less secure definition - } catch ( e ) { - descriptor[ this.expando ] = unlock; - jQuery.extend( owner, descriptor ); - } - } - - // Ensure the cache object - if ( !this.cache[ unlock ] ) { - this.cache[ unlock ] = {}; - } - - return unlock; - }, - set: function( owner, data, value ) { - var prop, - // There may be an unlock assigned to this node, - // if there is no entry for this "owner", create one inline - // and set the unlock as though an owner entry had always existed - unlock = this.key( owner ), - cache = this.cache[ unlock ]; - - // Handle: [ owner, key, value ] args - if ( typeof data === "string" ) { - cache[ data ] = value; - - // Handle: [ owner, { properties } ] args - } else { - // Fresh assignments by object are shallow copied - if ( jQuery.isEmptyObject( cache ) ) { - jQuery.extend( this.cache[ unlock ], data ); - // Otherwise, copy the properties one-by-one to the cache object - } else { - for ( prop in data ) { - cache[ prop ] = data[ prop ]; - } - } - } - return cache; - }, - get: function( owner, key ) { - // Either a valid cache is found, or will be created. - // New caches will be created and the unlock returned, - // allowing direct access to the newly created - // empty data object. A valid owner object must be provided. - var cache = this.cache[ this.key( owner ) ]; - - return key === undefined ? - cache : cache[ key ]; - }, - access: function( owner, key, value ) { - var stored; - // In cases where either: - // - // 1. No key was specified - // 2. A string key was specified, but no value provided - // - // Take the "read" path and allow the get method to determine - // which value to return, respectively either: - // - // 1. The entire cache object - // 2. The data stored at the key - // - if ( key === undefined || - ((key && typeof key === "string") && value === undefined) ) { - - stored = this.get( owner, key ); - - return stored !== undefined ? - stored : this.get( owner, jQuery.camelCase(key) ); - } - - // [*]When the key is not a string, or both a key and value - // are specified, set or extend (existing objects) with either: - // - // 1. An object of properties - // 2. A key and value - // - this.set( owner, key, value ); - - // Since the "set" path can have two possible entry points - // return the expected data based on which path was taken[*] - return value !== undefined ? value : key; - }, - remove: function( owner, key ) { - var i, name, camel, - unlock = this.key( owner ), - cache = this.cache[ unlock ]; - - if ( key === undefined ) { - this.cache[ unlock ] = {}; - - } else { - // Support array or space separated string of keys - if ( jQuery.isArray( key ) ) { - // If "name" is an array of keys... - // When data is initially created, via ("key", "val") signature, - // keys will be converted to camelCase. - // Since there is no way to tell _how_ a key was added, remove - // both plain key and camelCase key. #12786 - // This will only penalize the array argument path. - name = key.concat( key.map( jQuery.camelCase ) ); - } else { - camel = jQuery.camelCase( key ); - // Try the string as a key before any manipulation - if ( key in cache ) { - name = [ key, camel ]; - } else { - // If a key with the spaces exists, use it. - // Otherwise, create an array by matching non-whitespace - name = camel; - name = name in cache ? - [ name ] : ( name.match( rnotwhite ) || [] ); - } - } - - i = name.length; - while ( i-- ) { - delete cache[ name[ i ] ]; - } - } - }, - hasData: function( owner ) { - return !jQuery.isEmptyObject( - this.cache[ owner[ this.expando ] ] || {} - ); - }, - discard: function( owner ) { - if ( owner[ this.expando ] ) { - delete this.cache[ owner[ this.expando ] ]; - } - } -}; -var data_priv = new Data(); - -var data_user = new Data(); - - - -// Implementation Summary -// -// 1. Enforce API surface and semantic compatibility with 1.9.x branch -// 2. Improve the module's maintainability by reducing the storage -// paths to a single mechanism. -// 3. Use the same single mechanism to support "private" and "user" data. -// 4. _Never_ expose "private" data to user code (TODO: Drop _data, _removeData) -// 5. Avoid exposing implementation details on user objects (eg. expando properties) -// 6. Provide a clear path for implementation upgrade to WeakMap in 2014 - -var rbrace = /^(?:\{[\w\W]*\}|\[[\w\W]*\])$/, - rmultiDash = /([A-Z])/g; - -function dataAttr( elem, key, data ) { - var name; - - // If nothing was found internally, try to fetch any - // data from the HTML5 data-* attribute - if ( data === undefined && elem.nodeType === 1 ) { - name = "data-" + key.replace( rmultiDash, "-$1" ).toLowerCase(); - data = elem.getAttribute( name ); - - if ( typeof data === "string" ) { - try { - data = data === "true" ? true : - data === "false" ? false : - data === "null" ? null : - // Only convert to a number if it doesn't change the string - +data + "" === data ? +data : - rbrace.test( data ) ? jQuery.parseJSON( data ) : - data; - } catch( e ) {} - - // Make sure we set the data so it isn't changed later - data_user.set( elem, key, data ); - } else { - data = undefined; - } - } - return data; -} - -jQuery.extend({ - hasData: function( elem ) { - return data_user.hasData( elem ) || data_priv.hasData( elem ); - }, - - data: function( elem, name, data ) { - return data_user.access( elem, name, data ); - }, - - removeData: function( elem, name ) { - data_user.remove( elem, name ); - }, - - // TODO: Now that all calls to _data and _removeData have been replaced - // with direct calls to data_priv methods, these can be deprecated. - _data: function( elem, name, data ) { - return data_priv.access( elem, name, data ); - }, - - _removeData: function( elem, name ) { - data_priv.remove( elem, name ); - } -}); - -jQuery.fn.extend({ - data: function( key, value ) { - var i, name, data, - elem = this[ 0 ], - attrs = elem && elem.attributes; - - // Gets all values - if ( key === undefined ) { - if ( this.length ) { - data = data_user.get( elem ); - - if ( elem.nodeType === 1 && !data_priv.get( elem, "hasDataAttrs" ) ) { - i = attrs.length; - while ( i-- ) { - - // Support: IE11+ - // The attrs elements can be null (#14894) - if ( attrs[ i ] ) { - name = attrs[ i ].name; - if ( name.indexOf( "data-" ) === 0 ) { - name = jQuery.camelCase( name.slice(5) ); - dataAttr( elem, name, data[ name ] ); - } - } - } - data_priv.set( elem, "hasDataAttrs", true ); - } - } - - return data; - } - - // Sets multiple values - if ( typeof key === "object" ) { - return this.each(function() { - data_user.set( this, key ); - }); - } - - return access( this, function( value ) { - var data, - camelKey = jQuery.camelCase( key ); - - // The calling jQuery object (element matches) is not empty - // (and therefore has an element appears at this[ 0 ]) and the - // `value` parameter was not undefined. An empty jQuery object - // will result in `undefined` for elem = this[ 0 ] which will - // throw an exception if an attempt to read a data cache is made. - if ( elem && value === undefined ) { - // Attempt to get data from the cache - // with the key as-is - data = data_user.get( elem, key ); - if ( data !== undefined ) { - return data; - } - - // Attempt to get data from the cache - // with the key camelized - data = data_user.get( elem, camelKey ); - if ( data !== undefined ) { - return data; - } - - // Attempt to "discover" the data in - // HTML5 custom data-* attrs - data = dataAttr( elem, camelKey, undefined ); - if ( data !== undefined ) { - return data; - } - - // We tried really hard, but the data doesn't exist. - return; - } - - // Set the data... - this.each(function() { - // First, attempt to store a copy or reference of any - // data that might've been store with a camelCased key. - var data = data_user.get( this, camelKey ); - - // For HTML5 data-* attribute interop, we have to - // store property names with dashes in a camelCase form. - // This might not apply to all properties...* - data_user.set( this, camelKey, value ); - - // *... In the case of properties that might _actually_ - // have dashes, we need to also store a copy of that - // unchanged property. - if ( key.indexOf("-") !== -1 && data !== undefined ) { - data_user.set( this, key, value ); - } - }); - }, null, value, arguments.length > 1, null, true ); - }, - - removeData: function( key ) { - return this.each(function() { - data_user.remove( this, key ); - }); - } -}); - - -jQuery.extend({ - queue: function( elem, type, data ) { - var queue; - - if ( elem ) { - type = ( type || "fx" ) + "queue"; - queue = data_priv.get( elem, type ); - - // Speed up dequeue by getting out quickly if this is just a lookup - if ( data ) { - if ( !queue || jQuery.isArray( data ) ) { - queue = data_priv.access( elem, type, jQuery.makeArray(data) ); - } else { - queue.push( data ); - } - } - return queue || []; - } - }, - - dequeue: function( elem, type ) { - type = type || "fx"; - - var queue = jQuery.queue( elem, type ), - startLength = queue.length, - fn = queue.shift(), - hooks = jQuery._queueHooks( elem, type ), - next = function() { - jQuery.dequeue( elem, type ); - }; - - // If the fx queue is dequeued, always remove the progress sentinel - if ( fn === "inprogress" ) { - fn = queue.shift(); - startLength--; - } - - if ( fn ) { - - // Add a progress sentinel to prevent the fx queue from being - // automatically dequeued - if ( type === "fx" ) { - queue.unshift( "inprogress" ); - } - - // Clear up the last queue stop function - delete hooks.stop; - fn.call( elem, next, hooks ); - } - - if ( !startLength && hooks ) { - hooks.empty.fire(); - } - }, - - // Not public - generate a queueHooks object, or return the current one - _queueHooks: function( elem, type ) { - var key = type + "queueHooks"; - return data_priv.get( elem, key ) || data_priv.access( elem, key, { - empty: jQuery.Callbacks("once memory").add(function() { - data_priv.remove( elem, [ type + "queue", key ] ); - }) - }); - } -}); - -jQuery.fn.extend({ - queue: function( type, data ) { - var setter = 2; - - if ( typeof type !== "string" ) { - data = type; - type = "fx"; - setter--; - } - - if ( arguments.length < setter ) { - return jQuery.queue( this[0], type ); - } - - return data === undefined ? - this : - this.each(function() { - var queue = jQuery.queue( this, type, data ); - - // Ensure a hooks for this queue - jQuery._queueHooks( this, type ); - - if ( type === "fx" && queue[0] !== "inprogress" ) { - jQuery.dequeue( this, type ); - } - }); - }, - dequeue: function( type ) { - return this.each(function() { - jQuery.dequeue( this, type ); - }); - }, - clearQueue: function( type ) { - return this.queue( type || "fx", [] ); - }, - // Get a promise resolved when queues of a certain type - // are emptied (fx is the type by default) - promise: function( type, obj ) { - var tmp, - count = 1, - defer = jQuery.Deferred(), - elements = this, - i = this.length, - resolve = function() { - if ( !( --count ) ) { - defer.resolveWith( elements, [ elements ] ); - } - }; - - if ( typeof type !== "string" ) { - obj = type; - type = undefined; - } - type = type || "fx"; - - while ( i-- ) { - tmp = data_priv.get( elements[ i ], type + "queueHooks" ); - if ( tmp && tmp.empty ) { - count++; - tmp.empty.add( resolve ); - } - } - resolve(); - return defer.promise( obj ); - } -}); -var pnum = (/[+-]?(?:\d*\.|)\d+(?:[eE][+-]?\d+|)/).source; - -var cssExpand = [ "Top", "Right", "Bottom", "Left" ]; - -var isHidden = function( elem, el ) { - // isHidden might be called from jQuery#filter function; - // in that case, element will be second argument - elem = el || elem; - return jQuery.css( elem, "display" ) === "none" || !jQuery.contains( elem.ownerDocument, elem ); - }; - -var rcheckableType = (/^(?:checkbox|radio)$/i); - - - -(function() { - var fragment = document.createDocumentFragment(), - div = fragment.appendChild( document.createElement( "div" ) ), - input = document.createElement( "input" ); - - // Support: Safari<=5.1 - // Check state lost if the name is set (#11217) - // Support: Windows Web Apps (WWA) - // `name` and `type` must use .setAttribute for WWA (#14901) - input.setAttribute( "type", "radio" ); - input.setAttribute( "checked", "checked" ); - input.setAttribute( "name", "t" ); - - div.appendChild( input ); - - // Support: Safari<=5.1, Android<4.2 - // Older WebKit doesn't clone checked state correctly in fragments - support.checkClone = div.cloneNode( true ).cloneNode( true ).lastChild.checked; - - // Support: IE<=11+ - // Make sure textarea (and checkbox) defaultValue is properly cloned - div.innerHTML = ""; - support.noCloneChecked = !!div.cloneNode( true ).lastChild.defaultValue; -})(); -var strundefined = typeof undefined; - - - -support.focusinBubbles = "onfocusin" in window; - - -var - rkeyEvent = /^key/, - rmouseEvent = /^(?:mouse|pointer|contextmenu)|click/, - rfocusMorph = /^(?:focusinfocus|focusoutblur)$/, - rtypenamespace = /^([^.]*)(?:\.(.+)|)$/; - -function returnTrue() { - return true; -} - -function returnFalse() { - return false; -} - -function safeActiveElement() { - try { - return document.activeElement; - } catch ( err ) { } -} - -/* - * Helper functions for managing events -- not part of the public interface. - * Props to Dean Edwards' addEvent library for many of the ideas. - */ -jQuery.event = { - - global: {}, - - add: function( elem, types, handler, data, selector ) { - - var handleObjIn, eventHandle, tmp, - events, t, handleObj, - special, handlers, type, namespaces, origType, - elemData = data_priv.get( elem ); - - // Don't attach events to noData or text/comment nodes (but allow plain objects) - if ( !elemData ) { - return; - } - - // Caller can pass in an object of custom data in lieu of the handler - if ( handler.handler ) { - handleObjIn = handler; - handler = handleObjIn.handler; - selector = handleObjIn.selector; - } - - // Make sure that the handler has a unique ID, used to find/remove it later - if ( !handler.guid ) { - handler.guid = jQuery.guid++; - } - - // Init the element's event structure and main handler, if this is the first - if ( !(events = elemData.events) ) { - events = elemData.events = {}; - } - if ( !(eventHandle = elemData.handle) ) { - eventHandle = elemData.handle = function( e ) { - // Discard the second event of a jQuery.event.trigger() and - // when an event is called after a page has unloaded - return typeof jQuery !== strundefined && jQuery.event.triggered !== e.type ? - jQuery.event.dispatch.apply( elem, arguments ) : undefined; - }; - } - - // Handle multiple events separated by a space - types = ( types || "" ).match( rnotwhite ) || [ "" ]; - t = types.length; - while ( t-- ) { - tmp = rtypenamespace.exec( types[t] ) || []; - type = origType = tmp[1]; - namespaces = ( tmp[2] || "" ).split( "." ).sort(); - - // There *must* be a type, no attaching namespace-only handlers - if ( !type ) { - continue; - } - - // If event changes its type, use the special event handlers for the changed type - special = jQuery.event.special[ type ] || {}; - - // If selector defined, determine special event api type, otherwise given type - type = ( selector ? special.delegateType : special.bindType ) || type; - - // Update special based on newly reset type - special = jQuery.event.special[ type ] || {}; - - // handleObj is passed to all event handlers - handleObj = jQuery.extend({ - type: type, - origType: origType, - data: data, - handler: handler, - guid: handler.guid, - selector: selector, - needsContext: selector && jQuery.expr.match.needsContext.test( selector ), - namespace: namespaces.join(".") - }, handleObjIn ); - - // Init the event handler queue if we're the first - if ( !(handlers = events[ type ]) ) { - handlers = events[ type ] = []; - handlers.delegateCount = 0; - - // Only use addEventListener if the special events handler returns false - if ( !special.setup || special.setup.call( elem, data, namespaces, eventHandle ) === false ) { - if ( elem.addEventListener ) { - elem.addEventListener( type, eventHandle, false ); - } - } - } - - if ( special.add ) { - special.add.call( elem, handleObj ); - - if ( !handleObj.handler.guid ) { - handleObj.handler.guid = handler.guid; - } - } - - // Add to the element's handler list, delegates in front - if ( selector ) { - handlers.splice( handlers.delegateCount++, 0, handleObj ); - } else { - handlers.push( handleObj ); - } - - // Keep track of which events have ever been used, for event optimization - jQuery.event.global[ type ] = true; - } - - }, - - // Detach an event or set of events from an element - remove: function( elem, types, handler, selector, mappedTypes ) { - - var j, origCount, tmp, - events, t, handleObj, - special, handlers, type, namespaces, origType, - elemData = data_priv.hasData( elem ) && data_priv.get( elem ); - - if ( !elemData || !(events = elemData.events) ) { - return; - } - - // Once for each type.namespace in types; type may be omitted - types = ( types || "" ).match( rnotwhite ) || [ "" ]; - t = types.length; - while ( t-- ) { - tmp = rtypenamespace.exec( types[t] ) || []; - type = origType = tmp[1]; - namespaces = ( tmp[2] || "" ).split( "." ).sort(); - - // Unbind all events (on this namespace, if provided) for the element - if ( !type ) { - for ( type in events ) { - jQuery.event.remove( elem, type + types[ t ], handler, selector, true ); - } - continue; - } - - special = jQuery.event.special[ type ] || {}; - type = ( selector ? special.delegateType : special.bindType ) || type; - handlers = events[ type ] || []; - tmp = tmp[2] && new RegExp( "(^|\\.)" + namespaces.join("\\.(?:.*\\.|)") + "(\\.|$)" ); - - // Remove matching events - origCount = j = handlers.length; - while ( j-- ) { - handleObj = handlers[ j ]; - - if ( ( mappedTypes || origType === handleObj.origType ) && - ( !handler || handler.guid === handleObj.guid ) && - ( !tmp || tmp.test( handleObj.namespace ) ) && - ( !selector || selector === handleObj.selector || selector === "**" && handleObj.selector ) ) { - handlers.splice( j, 1 ); - - if ( handleObj.selector ) { - handlers.delegateCount--; - } - if ( special.remove ) { - special.remove.call( elem, handleObj ); - } - } - } - - // Remove generic event handler if we removed something and no more handlers exist - // (avoids potential for endless recursion during removal of special event handlers) - if ( origCount && !handlers.length ) { - if ( !special.teardown || special.teardown.call( elem, namespaces, elemData.handle ) === false ) { - jQuery.removeEvent( elem, type, elemData.handle ); - } - - delete events[ type ]; - } - } - - // Remove the expando if it's no longer used - if ( jQuery.isEmptyObject( events ) ) { - delete elemData.handle; - data_priv.remove( elem, "events" ); - } - }, - - trigger: function( event, data, elem, onlyHandlers ) { - - var i, cur, tmp, bubbleType, ontype, handle, special, - eventPath = [ elem || document ], - type = hasOwn.call( event, "type" ) ? event.type : event, - namespaces = hasOwn.call( event, "namespace" ) ? event.namespace.split(".") : []; - - cur = tmp = elem = elem || document; - - // Don't do events on text and comment nodes - if ( elem.nodeType === 3 || elem.nodeType === 8 ) { - return; - } - - // focus/blur morphs to focusin/out; ensure we're not firing them right now - if ( rfocusMorph.test( type + jQuery.event.triggered ) ) { - return; - } - - if ( type.indexOf(".") >= 0 ) { - // Namespaced trigger; create a regexp to match event type in handle() - namespaces = type.split("."); - type = namespaces.shift(); - namespaces.sort(); - } - ontype = type.indexOf(":") < 0 && "on" + type; - - // Caller can pass in a jQuery.Event object, Object, or just an event type string - event = event[ jQuery.expando ] ? - event : - new jQuery.Event( type, typeof event === "object" && event ); - - // Trigger bitmask: & 1 for native handlers; & 2 for jQuery (always true) - event.isTrigger = onlyHandlers ? 2 : 3; - event.namespace = namespaces.join("."); - event.namespace_re = event.namespace ? - new RegExp( "(^|\\.)" + namespaces.join("\\.(?:.*\\.|)") + "(\\.|$)" ) : - null; - - // Clean up the event in case it is being reused - event.result = undefined; - if ( !event.target ) { - event.target = elem; - } - - // Clone any incoming data and prepend the event, creating the handler arg list - data = data == null ? - [ event ] : - jQuery.makeArray( data, [ event ] ); - - // Allow special events to draw outside the lines - special = jQuery.event.special[ type ] || {}; - if ( !onlyHandlers && special.trigger && special.trigger.apply( elem, data ) === false ) { - return; - } - - // Determine event propagation path in advance, per W3C events spec (#9951) - // Bubble up to document, then to window; watch for a global ownerDocument var (#9724) - if ( !onlyHandlers && !special.noBubble && !jQuery.isWindow( elem ) ) { - - bubbleType = special.delegateType || type; - if ( !rfocusMorph.test( bubbleType + type ) ) { - cur = cur.parentNode; - } - for ( ; cur; cur = cur.parentNode ) { - eventPath.push( cur ); - tmp = cur; - } - - // Only add window if we got to document (e.g., not plain obj or detached DOM) - if ( tmp === (elem.ownerDocument || document) ) { - eventPath.push( tmp.defaultView || tmp.parentWindow || window ); - } - } - - // Fire handlers on the event path - i = 0; - while ( (cur = eventPath[i++]) && !event.isPropagationStopped() ) { - - event.type = i > 1 ? - bubbleType : - special.bindType || type; - - // jQuery handler - handle = ( data_priv.get( cur, "events" ) || {} )[ event.type ] && data_priv.get( cur, "handle" ); - if ( handle ) { - handle.apply( cur, data ); - } - - // Native handler - handle = ontype && cur[ ontype ]; - if ( handle && handle.apply && jQuery.acceptData( cur ) ) { - event.result = handle.apply( cur, data ); - if ( event.result === false ) { - event.preventDefault(); - } - } - } - event.type = type; - - // If nobody prevented the default action, do it now - if ( !onlyHandlers && !event.isDefaultPrevented() ) { - - if ( (!special._default || special._default.apply( eventPath.pop(), data ) === false) && - jQuery.acceptData( elem ) ) { - - // Call a native DOM method on the target with the same name name as the event. - // Don't do default actions on window, that's where global variables be (#6170) - if ( ontype && jQuery.isFunction( elem[ type ] ) && !jQuery.isWindow( elem ) ) { - - // Don't re-trigger an onFOO event when we call its FOO() method - tmp = elem[ ontype ]; - - if ( tmp ) { - elem[ ontype ] = null; - } - - // Prevent re-triggering of the same event, since we already bubbled it above - jQuery.event.triggered = type; - elem[ type ](); - jQuery.event.triggered = undefined; - - if ( tmp ) { - elem[ ontype ] = tmp; - } - } - } - } - - return event.result; - }, - - dispatch: function( event ) { - - // Make a writable jQuery.Event from the native event object - event = jQuery.event.fix( event ); - - var i, j, ret, matched, handleObj, - handlerQueue = [], - args = slice.call( arguments ), - handlers = ( data_priv.get( this, "events" ) || {} )[ event.type ] || [], - special = jQuery.event.special[ event.type ] || {}; - - // Use the fix-ed jQuery.Event rather than the (read-only) native event - args[0] = event; - event.delegateTarget = this; - - // Call the preDispatch hook for the mapped type, and let it bail if desired - if ( special.preDispatch && special.preDispatch.call( this, event ) === false ) { - return; - } - - // Determine handlers - handlerQueue = jQuery.event.handlers.call( this, event, handlers ); - - // Run delegates first; they may want to stop propagation beneath us - i = 0; - while ( (matched = handlerQueue[ i++ ]) && !event.isPropagationStopped() ) { - event.currentTarget = matched.elem; - - j = 0; - while ( (handleObj = matched.handlers[ j++ ]) && !event.isImmediatePropagationStopped() ) { - - // Triggered event must either 1) have no namespace, or 2) have namespace(s) - // a subset or equal to those in the bound event (both can have no namespace). - if ( !event.namespace_re || event.namespace_re.test( handleObj.namespace ) ) { - - event.handleObj = handleObj; - event.data = handleObj.data; - - ret = ( (jQuery.event.special[ handleObj.origType ] || {}).handle || handleObj.handler ) - .apply( matched.elem, args ); - - if ( ret !== undefined ) { - if ( (event.result = ret) === false ) { - event.preventDefault(); - event.stopPropagation(); - } - } - } - } - } - - // Call the postDispatch hook for the mapped type - if ( special.postDispatch ) { - special.postDispatch.call( this, event ); - } - - return event.result; - }, - - handlers: function( event, handlers ) { - var i, matches, sel, handleObj, - handlerQueue = [], - delegateCount = handlers.delegateCount, - cur = event.target; - - // Find delegate handlers - // Black-hole SVG instance trees (#13180) - // Avoid non-left-click bubbling in Firefox (#3861) - if ( delegateCount && cur.nodeType && (!event.button || event.type !== "click") ) { - - for ( ; cur !== this; cur = cur.parentNode || this ) { - - // Don't process clicks on disabled elements (#6911, #8165, #11382, #11764) - if ( cur.disabled !== true || event.type !== "click" ) { - matches = []; - for ( i = 0; i < delegateCount; i++ ) { - handleObj = handlers[ i ]; - - // Don't conflict with Object.prototype properties (#13203) - sel = handleObj.selector + " "; - - if ( matches[ sel ] === undefined ) { - matches[ sel ] = handleObj.needsContext ? - jQuery( sel, this ).index( cur ) >= 0 : - jQuery.find( sel, this, null, [ cur ] ).length; - } - if ( matches[ sel ] ) { - matches.push( handleObj ); - } - } - if ( matches.length ) { - handlerQueue.push({ elem: cur, handlers: matches }); - } - } - } - } - - // Add the remaining (directly-bound) handlers - if ( delegateCount < handlers.length ) { - handlerQueue.push({ elem: this, handlers: handlers.slice( delegateCount ) }); - } - - return handlerQueue; - }, - - // Includes some event props shared by KeyEvent and MouseEvent - props: "altKey bubbles cancelable ctrlKey currentTarget eventPhase metaKey relatedTarget shiftKey target timeStamp view which".split(" "), - - fixHooks: {}, - - keyHooks: { - props: "char charCode key keyCode".split(" "), - filter: function( event, original ) { - - // Add which for key events - if ( event.which == null ) { - event.which = original.charCode != null ? original.charCode : original.keyCode; - } - - return event; - } - }, - - mouseHooks: { - props: "button buttons clientX clientY offsetX offsetY pageX pageY screenX screenY toElement".split(" "), - filter: function( event, original ) { - var eventDoc, doc, body, - button = original.button; - - // Calculate pageX/Y if missing and clientX/Y available - if ( event.pageX == null && original.clientX != null ) { - eventDoc = event.target.ownerDocument || document; - doc = eventDoc.documentElement; - body = eventDoc.body; - - event.pageX = original.clientX + ( doc && doc.scrollLeft || body && body.scrollLeft || 0 ) - ( doc && doc.clientLeft || body && body.clientLeft || 0 ); - event.pageY = original.clientY + ( doc && doc.scrollTop || body && body.scrollTop || 0 ) - ( doc && doc.clientTop || body && body.clientTop || 0 ); - } - - // Add which for click: 1 === left; 2 === middle; 3 === right - // Note: button is not normalized, so don't use it - if ( !event.which && button !== undefined ) { - event.which = ( button & 1 ? 1 : ( button & 2 ? 3 : ( button & 4 ? 2 : 0 ) ) ); - } - - return event; - } - }, - - fix: function( event ) { - if ( event[ jQuery.expando ] ) { - return event; - } - - // Create a writable copy of the event object and normalize some properties - var i, prop, copy, - type = event.type, - originalEvent = event, - fixHook = this.fixHooks[ type ]; - - if ( !fixHook ) { - this.fixHooks[ type ] = fixHook = - rmouseEvent.test( type ) ? this.mouseHooks : - rkeyEvent.test( type ) ? this.keyHooks : - {}; - } - copy = fixHook.props ? this.props.concat( fixHook.props ) : this.props; - - event = new jQuery.Event( originalEvent ); - - i = copy.length; - while ( i-- ) { - prop = copy[ i ]; - event[ prop ] = originalEvent[ prop ]; - } - - // Support: Cordova 2.5 (WebKit) (#13255) - // All events should have a target; Cordova deviceready doesn't - if ( !event.target ) { - event.target = document; - } - - // Support: Safari 6.0+, Chrome<28 - // Target should not be a text node (#504, #13143) - if ( event.target.nodeType === 3 ) { - event.target = event.target.parentNode; - } - - return fixHook.filter ? fixHook.filter( event, originalEvent ) : event; - }, - - special: { - load: { - // Prevent triggered image.load events from bubbling to window.load - noBubble: true - }, - focus: { - // Fire native event if possible so blur/focus sequence is correct - trigger: function() { - if ( this !== safeActiveElement() && this.focus ) { - this.focus(); - return false; - } - }, - delegateType: "focusin" - }, - blur: { - trigger: function() { - if ( this === safeActiveElement() && this.blur ) { - this.blur(); - return false; - } - }, - delegateType: "focusout" - }, - click: { - // For checkbox, fire native event so checked state will be right - trigger: function() { - if ( this.type === "checkbox" && this.click && jQuery.nodeName( this, "input" ) ) { - this.click(); - return false; - } - }, - - // For cross-browser consistency, don't fire native .click() on links - _default: function( event ) { - return jQuery.nodeName( event.target, "a" ); - } - }, - - beforeunload: { - postDispatch: function( event ) { - - // Support: Firefox 20+ - // Firefox doesn't alert if the returnValue field is not set. - if ( event.result !== undefined && event.originalEvent ) { - event.originalEvent.returnValue = event.result; - } - } - } - }, - - simulate: function( type, elem, event, bubble ) { - // Piggyback on a donor event to simulate a different one. - // Fake originalEvent to avoid donor's stopPropagation, but if the - // simulated event prevents default then we do the same on the donor. - var e = jQuery.extend( - new jQuery.Event(), - event, - { - type: type, - isSimulated: true, - originalEvent: {} - } - ); - if ( bubble ) { - jQuery.event.trigger( e, null, elem ); - } else { - jQuery.event.dispatch.call( elem, e ); - } - if ( e.isDefaultPrevented() ) { - event.preventDefault(); - } - } -}; - -jQuery.removeEvent = function( elem, type, handle ) { - if ( elem.removeEventListener ) { - elem.removeEventListener( type, handle, false ); - } -}; - -jQuery.Event = function( src, props ) { - // Allow instantiation without the 'new' keyword - if ( !(this instanceof jQuery.Event) ) { - return new jQuery.Event( src, props ); - } - - // Event object - if ( src && src.type ) { - this.originalEvent = src; - this.type = src.type; - - // Events bubbling up the document may have been marked as prevented - // by a handler lower down the tree; reflect the correct value. - this.isDefaultPrevented = src.defaultPrevented || - src.defaultPrevented === undefined && - // Support: Android<4.0 - src.returnValue === false ? - returnTrue : - returnFalse; - - // Event type - } else { - this.type = src; - } - - // Put explicitly provided properties onto the event object - if ( props ) { - jQuery.extend( this, props ); - } - - // Create a timestamp if incoming event doesn't have one - this.timeStamp = src && src.timeStamp || jQuery.now(); - - // Mark it as fixed - this[ jQuery.expando ] = true; -}; - -// jQuery.Event is based on DOM3 Events as specified by the ECMAScript Language Binding -// http://www.w3.org/TR/2003/WD-DOM-Level-3-Events-20030331/ecma-script-binding.html -jQuery.Event.prototype = { - isDefaultPrevented: returnFalse, - isPropagationStopped: returnFalse, - isImmediatePropagationStopped: returnFalse, - - preventDefault: function() { - var e = this.originalEvent; - - this.isDefaultPrevented = returnTrue; - - if ( e && e.preventDefault ) { - e.preventDefault(); - } - }, - stopPropagation: function() { - var e = this.originalEvent; - - this.isPropagationStopped = returnTrue; - - if ( e && e.stopPropagation ) { - e.stopPropagation(); - } - }, - stopImmediatePropagation: function() { - var e = this.originalEvent; - - this.isImmediatePropagationStopped = returnTrue; - - if ( e && e.stopImmediatePropagation ) { - e.stopImmediatePropagation(); - } - - this.stopPropagation(); - } -}; - -// Create mouseenter/leave events using mouseover/out and event-time checks -// Support: Chrome 15+ -jQuery.each({ - mouseenter: "mouseover", - mouseleave: "mouseout", - pointerenter: "pointerover", - pointerleave: "pointerout" -}, function( orig, fix ) { - jQuery.event.special[ orig ] = { - delegateType: fix, - bindType: fix, - - handle: function( event ) { - var ret, - target = this, - related = event.relatedTarget, - handleObj = event.handleObj; - - // For mousenter/leave call the handler if related is outside the target. - // NB: No relatedTarget if the mouse left/entered the browser window - if ( !related || (related !== target && !jQuery.contains( target, related )) ) { - event.type = handleObj.origType; - ret = handleObj.handler.apply( this, arguments ); - event.type = fix; - } - return ret; - } - }; -}); - -// Support: Firefox, Chrome, Safari -// Create "bubbling" focus and blur events -if ( !support.focusinBubbles ) { - jQuery.each({ focus: "focusin", blur: "focusout" }, function( orig, fix ) { - - // Attach a single capturing handler on the document while someone wants focusin/focusout - var handler = function( event ) { - jQuery.event.simulate( fix, event.target, jQuery.event.fix( event ), true ); - }; - - jQuery.event.special[ fix ] = { - setup: function() { - var doc = this.ownerDocument || this, - attaches = data_priv.access( doc, fix ); - - if ( !attaches ) { - doc.addEventListener( orig, handler, true ); - } - data_priv.access( doc, fix, ( attaches || 0 ) + 1 ); - }, - teardown: function() { - var doc = this.ownerDocument || this, - attaches = data_priv.access( doc, fix ) - 1; - - if ( !attaches ) { - doc.removeEventListener( orig, handler, true ); - data_priv.remove( doc, fix ); - - } else { - data_priv.access( doc, fix, attaches ); - } - } - }; - }); -} - -jQuery.fn.extend({ - - on: function( types, selector, data, fn, /*INTERNAL*/ one ) { - var origFn, type; - - // Types can be a map of types/handlers - if ( typeof types === "object" ) { - // ( types-Object, selector, data ) - if ( typeof selector !== "string" ) { - // ( types-Object, data ) - data = data || selector; - selector = undefined; - } - for ( type in types ) { - this.on( type, selector, data, types[ type ], one ); - } - return this; - } - - if ( data == null && fn == null ) { - // ( types, fn ) - fn = selector; - data = selector = undefined; - } else if ( fn == null ) { - if ( typeof selector === "string" ) { - // ( types, selector, fn ) - fn = data; - data = undefined; - } else { - // ( types, data, fn ) - fn = data; - data = selector; - selector = undefined; - } - } - if ( fn === false ) { - fn = returnFalse; - } else if ( !fn ) { - return this; - } - - if ( one === 1 ) { - origFn = fn; - fn = function( event ) { - // Can use an empty set, since event contains the info - jQuery().off( event ); - return origFn.apply( this, arguments ); - }; - // Use same guid so caller can remove using origFn - fn.guid = origFn.guid || ( origFn.guid = jQuery.guid++ ); - } - return this.each( function() { - jQuery.event.add( this, types, fn, data, selector ); - }); - }, - one: function( types, selector, data, fn ) { - return this.on( types, selector, data, fn, 1 ); - }, - off: function( types, selector, fn ) { - var handleObj, type; - if ( types && types.preventDefault && types.handleObj ) { - // ( event ) dispatched jQuery.Event - handleObj = types.handleObj; - jQuery( types.delegateTarget ).off( - handleObj.namespace ? handleObj.origType + "." + handleObj.namespace : handleObj.origType, - handleObj.selector, - handleObj.handler - ); - return this; - } - if ( typeof types === "object" ) { - // ( types-object [, selector] ) - for ( type in types ) { - this.off( type, selector, types[ type ] ); - } - return this; - } - if ( selector === false || typeof selector === "function" ) { - // ( types [, fn] ) - fn = selector; - selector = undefined; - } - if ( fn === false ) { - fn = returnFalse; - } - return this.each(function() { - jQuery.event.remove( this, types, fn, selector ); - }); - }, - - trigger: function( type, data ) { - return this.each(function() { - jQuery.event.trigger( type, data, this ); - }); - }, - triggerHandler: function( type, data ) { - var elem = this[0]; - if ( elem ) { - return jQuery.event.trigger( type, data, elem, true ); - } - } -}); - - -var - rxhtmlTag = /<(?!area|br|col|embed|hr|img|input|link|meta|param)(([\w:]+)[^>]*)\/>/gi, - rtagName = /<([\w:]+)/, - rhtml = /<|&#?\w+;/, - rnoInnerhtml = /<(?:script|style|link)/i, - // checked="checked" or checked - rchecked = /checked\s*(?:[^=]|=\s*.checked.)/i, - rscriptType = /^$|\/(?:java|ecma)script/i, - rscriptTypeMasked = /^true\/(.*)/, - rcleanScript = /^\s*\s*$/g, - - // We have to close these tags to support XHTML (#13200) - wrapMap = { - - // Support: IE9 - option: [ 1, "" ], - - thead: [ 1, "", "
" ], - col: [ 2, "", "
" ], - tr: [ 2, "", "
" ], - td: [ 3, "", "
" ], - - _default: [ 0, "", "" ] - }; - -// Support: IE9 -wrapMap.optgroup = wrapMap.option; - -wrapMap.tbody = wrapMap.tfoot = wrapMap.colgroup = wrapMap.caption = wrapMap.thead; -wrapMap.th = wrapMap.td; - -// Support: 1.x compatibility -// Manipulating tables requires a tbody -function manipulationTarget( elem, content ) { - return jQuery.nodeName( elem, "table" ) && - jQuery.nodeName( content.nodeType !== 11 ? content : content.firstChild, "tr" ) ? - - elem.getElementsByTagName("tbody")[0] || - elem.appendChild( elem.ownerDocument.createElement("tbody") ) : - elem; -} - -// Replace/restore the type attribute of script elements for safe DOM manipulation -function disableScript( elem ) { - elem.type = (elem.getAttribute("type") !== null) + "/" + elem.type; - return elem; -} -function restoreScript( elem ) { - var match = rscriptTypeMasked.exec( elem.type ); - - if ( match ) { - elem.type = match[ 1 ]; - } else { - elem.removeAttribute("type"); - } - - return elem; -} - -// Mark scripts as having already been evaluated -function setGlobalEval( elems, refElements ) { - var i = 0, - l = elems.length; - - for ( ; i < l; i++ ) { - data_priv.set( - elems[ i ], "globalEval", !refElements || data_priv.get( refElements[ i ], "globalEval" ) - ); - } -} - -function cloneCopyEvent( src, dest ) { - var i, l, type, pdataOld, pdataCur, udataOld, udataCur, events; - - if ( dest.nodeType !== 1 ) { - return; - } - - // 1. Copy private data: events, handlers, etc. - if ( data_priv.hasData( src ) ) { - pdataOld = data_priv.access( src ); - pdataCur = data_priv.set( dest, pdataOld ); - events = pdataOld.events; - - if ( events ) { - delete pdataCur.handle; - pdataCur.events = {}; - - for ( type in events ) { - for ( i = 0, l = events[ type ].length; i < l; i++ ) { - jQuery.event.add( dest, type, events[ type ][ i ] ); - } - } - } - } - - // 2. Copy user data - if ( data_user.hasData( src ) ) { - udataOld = data_user.access( src ); - udataCur = jQuery.extend( {}, udataOld ); - - data_user.set( dest, udataCur ); - } -} - -function getAll( context, tag ) { - var ret = context.getElementsByTagName ? context.getElementsByTagName( tag || "*" ) : - context.querySelectorAll ? context.querySelectorAll( tag || "*" ) : - []; - - return tag === undefined || tag && jQuery.nodeName( context, tag ) ? - jQuery.merge( [ context ], ret ) : - ret; -} - -// Fix IE bugs, see support tests -function fixInput( src, dest ) { - var nodeName = dest.nodeName.toLowerCase(); - - // Fails to persist the checked state of a cloned checkbox or radio button. - if ( nodeName === "input" && rcheckableType.test( src.type ) ) { - dest.checked = src.checked; - - // Fails to return the selected option to the default selected state when cloning options - } else if ( nodeName === "input" || nodeName === "textarea" ) { - dest.defaultValue = src.defaultValue; - } -} - -jQuery.extend({ - clone: function( elem, dataAndEvents, deepDataAndEvents ) { - var i, l, srcElements, destElements, - clone = elem.cloneNode( true ), - inPage = jQuery.contains( elem.ownerDocument, elem ); - - // Fix IE cloning issues - if ( !support.noCloneChecked && ( elem.nodeType === 1 || elem.nodeType === 11 ) && - !jQuery.isXMLDoc( elem ) ) { - - // We eschew Sizzle here for performance reasons: http://jsperf.com/getall-vs-sizzle/2 - destElements = getAll( clone ); - srcElements = getAll( elem ); - - for ( i = 0, l = srcElements.length; i < l; i++ ) { - fixInput( srcElements[ i ], destElements[ i ] ); - } - } - - // Copy the events from the original to the clone - if ( dataAndEvents ) { - if ( deepDataAndEvents ) { - srcElements = srcElements || getAll( elem ); - destElements = destElements || getAll( clone ); - - for ( i = 0, l = srcElements.length; i < l; i++ ) { - cloneCopyEvent( srcElements[ i ], destElements[ i ] ); - } - } else { - cloneCopyEvent( elem, clone ); - } - } - - // Preserve script evaluation history - destElements = getAll( clone, "script" ); - if ( destElements.length > 0 ) { - setGlobalEval( destElements, !inPage && getAll( elem, "script" ) ); - } - - // Return the cloned set - return clone; - }, - - buildFragment: function( elems, context, scripts, selection ) { - var elem, tmp, tag, wrap, contains, j, - fragment = context.createDocumentFragment(), - nodes = [], - i = 0, - l = elems.length; - - for ( ; i < l; i++ ) { - elem = elems[ i ]; - - if ( elem || elem === 0 ) { - - // Add nodes directly - if ( jQuery.type( elem ) === "object" ) { - // Support: QtWebKit, PhantomJS - // push.apply(_, arraylike) throws on ancient WebKit - jQuery.merge( nodes, elem.nodeType ? [ elem ] : elem ); - - // Convert non-html into a text node - } else if ( !rhtml.test( elem ) ) { - nodes.push( context.createTextNode( elem ) ); - - // Convert html into DOM nodes - } else { - tmp = tmp || fragment.appendChild( context.createElement("div") ); - - // Deserialize a standard representation - tag = ( rtagName.exec( elem ) || [ "", "" ] )[ 1 ].toLowerCase(); - wrap = wrapMap[ tag ] || wrapMap._default; - tmp.innerHTML = wrap[ 1 ] + elem.replace( rxhtmlTag, "<$1>" ) + wrap[ 2 ]; - - // Descend through wrappers to the right content - j = wrap[ 0 ]; - while ( j-- ) { - tmp = tmp.lastChild; - } - - // Support: QtWebKit, PhantomJS - // push.apply(_, arraylike) throws on ancient WebKit - jQuery.merge( nodes, tmp.childNodes ); - - // Remember the top-level container - tmp = fragment.firstChild; - - // Ensure the created nodes are orphaned (#12392) - tmp.textContent = ""; - } - } - } - - // Remove wrapper from fragment - fragment.textContent = ""; - - i = 0; - while ( (elem = nodes[ i++ ]) ) { - - // #4087 - If origin and destination elements are the same, and this is - // that element, do not do anything - if ( selection && jQuery.inArray( elem, selection ) !== -1 ) { - continue; - } - - contains = jQuery.contains( elem.ownerDocument, elem ); - - // Append to fragment - tmp = getAll( fragment.appendChild( elem ), "script" ); - - // Preserve script evaluation history - if ( contains ) { - setGlobalEval( tmp ); - } - - // Capture executables - if ( scripts ) { - j = 0; - while ( (elem = tmp[ j++ ]) ) { - if ( rscriptType.test( elem.type || "" ) ) { - scripts.push( elem ); - } - } - } - } - - return fragment; - }, - - cleanData: function( elems ) { - var data, elem, type, key, - special = jQuery.event.special, - i = 0; - - for ( ; (elem = elems[ i ]) !== undefined; i++ ) { - if ( jQuery.acceptData( elem ) ) { - key = elem[ data_priv.expando ]; - - if ( key && (data = data_priv.cache[ key ]) ) { - if ( data.events ) { - for ( type in data.events ) { - if ( special[ type ] ) { - jQuery.event.remove( elem, type ); - - // This is a shortcut to avoid jQuery.event.remove's overhead - } else { - jQuery.removeEvent( elem, type, data.handle ); - } - } - } - if ( data_priv.cache[ key ] ) { - // Discard any remaining `private` data - delete data_priv.cache[ key ]; - } - } - } - // Discard any remaining `user` data - delete data_user.cache[ elem[ data_user.expando ] ]; - } - } -}); - -jQuery.fn.extend({ - text: function( value ) { - return access( this, function( value ) { - return value === undefined ? - jQuery.text( this ) : - this.empty().each(function() { - if ( this.nodeType === 1 || this.nodeType === 11 || this.nodeType === 9 ) { - this.textContent = value; - } - }); - }, null, value, arguments.length ); - }, - - append: function() { - return this.domManip( arguments, function( elem ) { - if ( this.nodeType === 1 || this.nodeType === 11 || this.nodeType === 9 ) { - var target = manipulationTarget( this, elem ); - target.appendChild( elem ); - } - }); - }, - - prepend: function() { - return this.domManip( arguments, function( elem ) { - if ( this.nodeType === 1 || this.nodeType === 11 || this.nodeType === 9 ) { - var target = manipulationTarget( this, elem ); - target.insertBefore( elem, target.firstChild ); - } - }); - }, - - before: function() { - return this.domManip( arguments, function( elem ) { - if ( this.parentNode ) { - this.parentNode.insertBefore( elem, this ); - } - }); - }, - - after: function() { - return this.domManip( arguments, function( elem ) { - if ( this.parentNode ) { - this.parentNode.insertBefore( elem, this.nextSibling ); - } - }); - }, - - remove: function( selector, keepData /* Internal Use Only */ ) { - var elem, - elems = selector ? jQuery.filter( selector, this ) : this, - i = 0; - - for ( ; (elem = elems[i]) != null; i++ ) { - if ( !keepData && elem.nodeType === 1 ) { - jQuery.cleanData( getAll( elem ) ); - } - - if ( elem.parentNode ) { - if ( keepData && jQuery.contains( elem.ownerDocument, elem ) ) { - setGlobalEval( getAll( elem, "script" ) ); - } - elem.parentNode.removeChild( elem ); - } - } - - return this; - }, - - empty: function() { - var elem, - i = 0; - - for ( ; (elem = this[i]) != null; i++ ) { - if ( elem.nodeType === 1 ) { - - // Prevent memory leaks - jQuery.cleanData( getAll( elem, false ) ); - - // Remove any remaining nodes - elem.textContent = ""; - } - } - - return this; - }, - - clone: function( dataAndEvents, deepDataAndEvents ) { - dataAndEvents = dataAndEvents == null ? false : dataAndEvents; - deepDataAndEvents = deepDataAndEvents == null ? dataAndEvents : deepDataAndEvents; - - return this.map(function() { - return jQuery.clone( this, dataAndEvents, deepDataAndEvents ); - }); - }, - - html: function( value ) { - return access( this, function( value ) { - var elem = this[ 0 ] || {}, - i = 0, - l = this.length; - - if ( value === undefined && elem.nodeType === 1 ) { - return elem.innerHTML; - } - - // See if we can take a shortcut and just use innerHTML - if ( typeof value === "string" && !rnoInnerhtml.test( value ) && - !wrapMap[ ( rtagName.exec( value ) || [ "", "" ] )[ 1 ].toLowerCase() ] ) { - - value = value.replace( rxhtmlTag, "<$1>" ); - - try { - for ( ; i < l; i++ ) { - elem = this[ i ] || {}; - - // Remove element nodes and prevent memory leaks - if ( elem.nodeType === 1 ) { - jQuery.cleanData( getAll( elem, false ) ); - elem.innerHTML = value; - } - } - - elem = 0; - - // If using innerHTML throws an exception, use the fallback method - } catch( e ) {} - } - - if ( elem ) { - this.empty().append( value ); - } - }, null, value, arguments.length ); - }, - - replaceWith: function() { - var arg = arguments[ 0 ]; - - // Make the changes, replacing each context element with the new content - this.domManip( arguments, function( elem ) { - arg = this.parentNode; - - jQuery.cleanData( getAll( this ) ); - - if ( arg ) { - arg.replaceChild( elem, this ); - } - }); - - // Force removal if there was no new content (e.g., from empty arguments) - return arg && (arg.length || arg.nodeType) ? this : this.remove(); - }, - - detach: function( selector ) { - return this.remove( selector, true ); - }, - - domManip: function( args, callback ) { - - // Flatten any nested arrays - args = concat.apply( [], args ); - - var fragment, first, scripts, hasScripts, node, doc, - i = 0, - l = this.length, - set = this, - iNoClone = l - 1, - value = args[ 0 ], - isFunction = jQuery.isFunction( value ); - - // We can't cloneNode fragments that contain checked, in WebKit - if ( isFunction || - ( l > 1 && typeof value === "string" && - !support.checkClone && rchecked.test( value ) ) ) { - return this.each(function( index ) { - var self = set.eq( index ); - if ( isFunction ) { - args[ 0 ] = value.call( this, index, self.html() ); - } - self.domManip( args, callback ); - }); - } - - if ( l ) { - fragment = jQuery.buildFragment( args, this[ 0 ].ownerDocument, false, this ); - first = fragment.firstChild; - - if ( fragment.childNodes.length === 1 ) { - fragment = first; - } - - if ( first ) { - scripts = jQuery.map( getAll( fragment, "script" ), disableScript ); - hasScripts = scripts.length; - - // Use the original fragment for the last item instead of the first because it can end up - // being emptied incorrectly in certain situations (#8070). - for ( ; i < l; i++ ) { - node = fragment; - - if ( i !== iNoClone ) { - node = jQuery.clone( node, true, true ); - - // Keep references to cloned scripts for later restoration - if ( hasScripts ) { - // Support: QtWebKit - // jQuery.merge because push.apply(_, arraylike) throws - jQuery.merge( scripts, getAll( node, "script" ) ); - } - } - - callback.call( this[ i ], node, i ); - } - - if ( hasScripts ) { - doc = scripts[ scripts.length - 1 ].ownerDocument; - - // Reenable scripts - jQuery.map( scripts, restoreScript ); - - // Evaluate executable scripts on first document insertion - for ( i = 0; i < hasScripts; i++ ) { - node = scripts[ i ]; - if ( rscriptType.test( node.type || "" ) && - !data_priv.access( node, "globalEval" ) && jQuery.contains( doc, node ) ) { - - if ( node.src ) { - // Optional AJAX dependency, but won't run scripts if not present - if ( jQuery._evalUrl ) { - jQuery._evalUrl( node.src ); - } - } else { - jQuery.globalEval( node.textContent.replace( rcleanScript, "" ) ); - } - } - } - } - } - } - - return this; - } -}); - -jQuery.each({ - appendTo: "append", - prependTo: "prepend", - insertBefore: "before", - insertAfter: "after", - replaceAll: "replaceWith" -}, function( name, original ) { - jQuery.fn[ name ] = function( selector ) { - var elems, - ret = [], - insert = jQuery( selector ), - last = insert.length - 1, - i = 0; - - for ( ; i <= last; i++ ) { - elems = i === last ? this : this.clone( true ); - jQuery( insert[ i ] )[ original ]( elems ); - - // Support: QtWebKit - // .get() because push.apply(_, arraylike) throws - push.apply( ret, elems.get() ); - } - - return this.pushStack( ret ); - }; -}); - - -var iframe, - elemdisplay = {}; - -/** - * Retrieve the actual display of a element - * @param {String} name nodeName of the element - * @param {Object} doc Document object - */ -// Called only from within defaultDisplay -function actualDisplay( name, doc ) { - var style, - elem = jQuery( doc.createElement( name ) ).appendTo( doc.body ), - - // getDefaultComputedStyle might be reliably used only on attached element - display = window.getDefaultComputedStyle && ( style = window.getDefaultComputedStyle( elem[ 0 ] ) ) ? - - // Use of this method is a temporary fix (more like optimization) until something better comes along, - // since it was removed from specification and supported only in FF - style.display : jQuery.css( elem[ 0 ], "display" ); - - // We don't have any data stored on the element, - // so use "detach" method as fast way to get rid of the element - elem.detach(); - - return display; -} - -/** - * Try to determine the default display value of an element - * @param {String} nodeName - */ -function defaultDisplay( nodeName ) { - var doc = document, - display = elemdisplay[ nodeName ]; - - if ( !display ) { - display = actualDisplay( nodeName, doc ); - - // If the simple way fails, read from inside an iframe - if ( display === "none" || !display ) { - - // Use the already-created iframe if possible - iframe = (iframe || jQuery( "