diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000000..013dd2021d --- /dev/null +++ b/.coveragerc @@ -0,0 +1,2 @@ +[report] +show_missing = True diff --git a/docs/about/release-notes.md b/docs/about/release-notes.md index d07ca9062d..91a2472d87 100644 --- a/docs/about/release-notes.md +++ b/docs/about/release-notes.md @@ -25,6 +25,131 @@ The current and past members of the MkDocs team. ### Major Additions to Development Version +#### Internal Refactor of Pages, Files, and Navigation + +Internal handling of pages, files and navigation has been completely refactored. +The changes included in the refactor are summarized below. + +* Support for hidden pages. All Markdown pages are now included in the build + regardless of whether they are included in the navigation configuration + (#699). +* The navigation can now include links to external sites (#989 #1373 & #1406). +* Page data (including titles) is properly determined for all pages before any + page is rendered (#1347). +* Automatically populated navigation now sorts index pages to the top. In other + words, The index page will be listed as the first child of a directory, while + all other documents are sorted alphanumerically by file name after the index + page (#73 & #1042). +* A `README.md` file is now treated as an index file within a directory and + will be rendered to `index.html` (#608). +* The URLs for all files are computed once and stored in a files collection. + This ensures all internal links are always computed correctly regardless of + the configuration. This also allows all internal links to be validated, not + just links to other Markdown pages. (#842 & #872). +* An [on_files] plugin event has been added, which could be used to include + files not in the `docs_dir`, exclude files, redefine page URLs (i.e. + implement extensionless URLs), or to manipulate files in various other ways. + + [on_files]: ../user-guide/plugins.md#on_files + +##### Backward Incompatible Changes + +As part of the internal refactor, a number of backward incompatible changes have +been introduced, which are summarized below. + +###### URLS have changed when `use_directory_urls` is `False` + +Previously, all Markdown pages would be have their filenames altered to be index +pages regardless of how the [use_directory_urls] setting was configured. +However, the path munging is only needed when `use_directory_urls` is set to +`True` (the default). The path mungling no longer happens when +`use_directory_urls` is set to `False`, which will result in different URLs for +all pages that were not already index files. As this behavior only effects a +non-default configuration, and the most common user-case for setting the option +to `False` is for local file system (`file://`) browsing, its not likely to +effect most users. However, if you have `use_directory_urls` set to `False` +for a MkDocs site hosted on a web server, most of your URLs will now be broken. +As you can see below, the new URLs are much more sensible. + +| Markdown file | Old URL | New URL | +| --------------- | -------------------- | -------------- | +| `index.md` | `index.html` | `index.html` | +| `foo.md` | `foo/index.html` | `foo.html` | +| `foo/bar.md` | `foo/bar/index.html` | `foo/bar.html` | + +Note that there has been no change to URLs or file paths when +`use_directory_urls` is set to `True` (the default), except that MkDocs more +consistently includes an ending slash on all internally generated URLs. + +[use_directory_urls]: ../user-guide/configuration.md#use_directory_urls + +###### The `pages` configuration setting has been renamed to `nav` + +The `pages` configuration setting is deprecated and will issue a warning if set +in the configuration file. The setting has been renamed `nav`. To update your +configuration, simply rename the setting to `nav`. In other words, if your +configuration looked like this: + +```yaml +pages: + - Home: index.md + - User Guide: user-guide.md +``` + +Simply edit the configuration as follows: + +```yaml +nav: + - Home: index.md + - User Guide: user-guide.md +``` + +In the current release, any configuration which includes a `pages` setting, but +no `nav` setting, the `pages` configuration will be copied to `nav` and a +warning will be issued. However, in a future release, that may no longer happen. +If both `pages` and `nav` are defined, the `pages` setting will be ignored. + +###### Template variables and `base_url` + +In previous versions of MkDocs some URLs expected the [base_url] template +variable to be prepended to the URL and others did not. That inconsistency has +been removed. All URLs must now be joined with the `base_url`. As previously, a +slash must be included between the `base_url` and the URL variable. For example, +a theme template might have previously included a link to the `site_name` as: + +```django +{{ config.site_name }} +``` + +And MkDocs would magically return a URL for the homepage which was relative to +the current page. That "magic" has been removed and the `base_url` must now be +explicitly included: + +```django +{{ config.site_name }} +``` + +This change applies to any navigation items and pages, as well as the +`page.next_page` and `page.previous_page` attributes. For the time being, the +`extra_javascript` and `extra_css` variables continue to work as previously +(without `base_url`), but they have been deprecated and the corresponding +configuration values (`config.extra_javascript` and `config.extra_css` +respectively) should be used with `base_url` instead. + +Note that navigation can now include links to external sites. Obviously, the +`base_url` should not be prepended to these items. Therefore, all navigation +items contain a `is_link` attribute which can be used to alter the behavior for +external links. + +```django +{{ nav_item.title }} +``` + +Any other URL variables which should not be used with `base_url` are explicitly +documented as such. + +[base_url]: ../user-guide/custom-themes.md#base_url + #### Path Based Settings are Relative to Configuration File (#543) Previously any relative paths in the various configuration options were @@ -181,7 +306,7 @@ template exists. ##### Context Variables Page specific variable names in the template context have been refactored as -defined in [Custom Themes](../user-guide/custom-themes/#page). The +defined in [Custom Themes](../user-guide/custom-themes.md#page). The old variable names issued a warning in version 0.16, but have been removed in version 1.0. @@ -199,14 +324,14 @@ user created and third-party templates: | previous_page | [page.previous_page]| | next_page | [page.next_page] | -[page]: ../user-guide/custom-themes/#page -[page.title]: ../user-guide/custom-themes/#pagetitle -[page.content]: ../user-guide/custom-themes/#pagecontent -[page.toc]: ../user-guide/custom-themes/#pagetoc -[page.meta]: ../user-guide/custom-themes/#pagemeta -[page.canonical_url]: ../user-guide/custom-themes/#pagecanonical_url -[page.previous_page]: ../user-guide/custom-themes/#pageprevious_page -[page.next_page]: ../user-guide/custom-themes/#pagenext_page +[page]: ../user-guide/custom-themes.md#page +[page.title]: ../user-guide/custom-themes.md#pagetitle +[page.content]: ../user-guide/custom-themes.md#pagecontent +[page.toc]: ../user-guide/custom-themes.md#pagetoc +[page.meta]: ../user-guide/custom-themes.md#pagemeta +[page.canonical_url]: ../user-guide/custom-themes.md#pagecanonical_url +[page.previous_page]: ../user-guide/custom-themes.md#pageprevious_page +[page.next_page]: ../user-guide/custom-themes.md#pagenext_page Additionally, a number of global variables have been altered and/or removed and user created and third-party templates should be updated as outlined below: @@ -286,7 +411,7 @@ the `extra_css` or `extra_javascript` config settings going forward. ##### Page Context Page specific variable names in the template context have been refactored as -defined in [Custom Themes](../user-guide/custom-themes/#page). The +defined in [Custom Themes](../user-guide/custom-themes.md#page). The old variable names will issue a warning but continue to work for version 0.16, but may be removed in a future version. @@ -304,14 +429,14 @@ user created and third-party templates: | previous_page | [page.previous_page]| | next_page | [page.next_page] | -[page]: ../user-guide/custom-themes/#page -[page.title]: ../user-guide/custom-themes/#pagetitle -[page.content]: ../user-guide/custom-themes/#pagecontent -[page.toc]: ../user-guide/custom-themes/#pagetoc -[page.meta]: ../user-guide/custom-themes/#pagemeta -[page.canonical_url]: ../user-guide/custom-themes/#pagecanonical_url -[page.previous_page]: ../user-guide/custom-themes/#pageprevious_page -[page.next_page]: ../user-guide/custom-themes/#pagenext_page +[page]: ../user-guide/custom-themes.md#page +[page.title]: ../user-guide/custom-themes.md#pagetitle +[page.content]: ../user-guide/custom-themes.md#pagecontent +[page.toc]: ../user-guide/custom-themes.md#pagetoc +[page.meta]: ../user-guide/custom-themes.md#pagemeta +[page.canonical_url]: ../user-guide/custom-themes.md#pagecanonical_url +[page.previous_page]: ../user-guide/custom-themes.md#pageprevious_page +[page.next_page]: ../user-guide/custom-themes.md#pagenext_page ##### Global Context @@ -400,7 +525,7 @@ overriding blocks in the same manner as the built-in themes. Third party themes are encouraged to wrap the various pieces of their templates in blocks in order to support such customization. -[blocks]: ../user-guide/styling-your-docs/#overriding-template-blocks +[blocks]: ../user-guide/styling-your-docs.md#overriding-template-blocks #### Auto-Populated `extra_css` and `extra_javascript` Deprecated. (#986) @@ -444,7 +569,7 @@ the `docs_dir` is set to the directory which contains your config file rather than a child directory. You will need to rearrange you directory structure to better conform with the documented [layout]. -[layout]: ../user-guide/writing-your-docs/#file-layout +[layout]: ../user-guide/writing-your-docs.md#file-layout ### Other Changes and Additions to Version 0.16.0 @@ -522,8 +647,8 @@ See the documentation for [Styling your docs] for more information about using and customizing themes and [Custom themes] for creating and distributing new themes -[Styling your docs]: /user-guide/styling-your-docs.md -[Custom themes]: /user-guide/custom-themes.md +[Styling your docs]: ../user-guide/styling-your-docs.md +[Custom themes]: ../user-guide/custom-themes.md ### Other Changes and Additions to Version 0.15.0 @@ -544,9 +669,9 @@ themes * Bugfix: Provide filename to Read the Docs. (#721 and RTD#1480) * Bugfix: Silence Click's unicode_literals warning. (#708) -[site_description]: /user-guide/configuration.md#site_description -[site_author]: /user-guide/configuration.md#site_author -[ReadTheDocs]: /user-guide/styling-your-docs.md#readthedocs +[site_description]: ../user-guide/configuration.md#site_description +[site_author]: ../user-guide/configuration.md#site_author +[ReadTheDocs]: ../user-guide/styling-your-docs.md#readthedocs ## Version 0.14.0 (2015-06-09) @@ -604,7 +729,7 @@ This new file is created on every MkDocs build (with `mkdocs build`) and no configuration is needed to enable it. [future release]: https://github.com/mkdocs/mkdocs/pull/481 -[site_dir]: /user-guide/configuration.md#site_dir +[site_dir]: ../user-guide/configuration.md#site_dir #### Change the pages configuration @@ -612,8 +737,8 @@ Provide a [new way] to define pages, and specifically [nested pages], in the mkdocs.yml file and deprecate the existing approach, support will be removed with MkDocs 1.0. -[new way]: /user-guide/writing-your-docs.md#configure-pages-and-navigation -[nested pages]: /user-guide/writing-your-docs.md#multilevel-documentation +[new way]: ../user-guide/writing-your-docs.md#configure-pages-and-navigation +[nested pages]: ../user-guide/writing-your-docs.md#multilevel-documentation #### Warn users about the removal of builtin themes @@ -631,7 +756,7 @@ JavaScript library [lunr.js]. It has been added to both the `mkdocs` and for adding it to your own themes. [lunr.js]: http://lunrjs.com/ -[supporting search]: /user-guide/styling-your-docs.md#search-and-themes +[supporting search]: ../user-guide/styling-your-docs.md#search-and-themes #### New Command Line Interface @@ -659,10 +784,10 @@ can also use Jinja2 syntax and take advantage of the [global variables]. By default MkDocs will use this approach to create a sitemap for the documentation. -[extra_javascript]: /user-guide/configuration.md#extra_javascript -[extra_css]: /user-guide/configuration.md#extra_css -[extra_templates]: /user-guide/configuration.md#extra_templates -[global variables]: /user-guide/styling-your-docs.md#global-context +[extra_javascript]: ../user-guide/configuration.md#extra_javascript +[extra_css]: ../user-guide/configuration.md#extra_css +[extra_templates]: ../user-guide/configuration.md#extra_templates +[global variables]: ../user-guide/styling-your-docs.md#global-context ### Other Changes and Additions to Version 0.13.0 @@ -679,8 +804,8 @@ documentation. called index.md (#535) * Bugfix: Fix errors with Unicode filenames (#542). -[extra config]: /user-guide/configuration.md#extra -[Markdown extension configuration options]: /user-guide/configuration.md#markdown_extensions +[extra config]: ../user-guide/configuration.md#extra +[Markdown extension configuration options]: ../user-guide/configuration.md#markdown_extensions [wheels]: http://pythonwheels.com/ ## Version 0.12.2 (2015-04-22) diff --git a/docs/user-guide/configuration.md b/docs/user-guide/configuration.md index 8cac62f917..c9a16b30af 100644 --- a/docs/user-guide/configuration.md +++ b/docs/user-guide/configuration.md @@ -162,24 +162,60 @@ This option can be overridden by a command line option in `gh-deploy`. ## Documentation layout -### pages +### nav -This setting is used to determine the set of pages that should be built for the -documentation. For example, the following would create Introduction, User Guide -and About pages, given the three source files `index.md`, `user-guide.md` and -`about.md`, respectively. +This setting is used to determine the format and layout of the global navigation +for the site. For example, the following would create "Introduction", "User +Guide" and "About" navigation items. ```yaml -pages: +nav: - 'Introduction': 'index.md' - 'User Guide': 'user-guide.md' - 'About': 'about.md' ``` -See the section on [configuring pages and navigation] for a more detailed -breakdown, including how to create sub-sections. +All paths must be relative to the `mkdocs.yml` configuration file. See the +section on [configuring pages and navigation] for a more detailed breakdown, +including how to create sub-sections. -**default**: By default `pages` will contain an alphanumerically sorted, nested +Navigation items may also include links to external sites. While titles are +optional for internal links, they are required for external links. An external +link may be a full URL or a relative URL. Any path which is not found in the +files is assumed to be an external link. + +```yaml +nav: + - Home: index.md + - User Guide: user-guide.md + - Bug Tracker: https://example.com/ +``` + +In the above example, the first two items point to local files while the third +points to an external site. + +However, sometimes the MkDocs site is hosted in a subdirectory of a project's +site and you may want to link to other parts of the same site without including +the full domain. In that case, you may use and appropriate relative URL. + +```yaml +site_url: http://example.com/foo/ + +nav: + - Home: ../ + - User Guide: user-guide.md + - Bug Tracker: /bugs/ +``` + +In the above example, two different styles of external links are used. First +note that the `site_url` indicates that the MkDocs site is hosted in the `/foo/` +subdirectory of the domain. Therefore, the `Home` navigation item is a relative +link which steps up one level to the server root and effectively points to +`http://example.com/`. The `Bug Tracker` item uses an absolute path from the +server root and effectively points to `http://example.com/bugs/`. Of course, the +`User Guide` points to a local MkDocs page. + +**default**: By default `nav` will contain an alphanumerically sorted, nested list of all the Markdown files found within the `docs_dir` and its sub-directories. If none are found it will be `[]` (an empty list). @@ -324,27 +360,26 @@ documentation. The following table demonstrates how the URLs used on the site differ when setting `use_directory_urls` to `true` or `false`. -Source file | Generated HTML | use_directory_urls: true | use_directory_urls: false ------------- | -------------------- | ------------------------ | ------------------------ -index.md | index.html | / | /index.html -api-guide.md | api-guide/index.html | /api-guide/ | /api-guide/index.html -about.md | about/index.html | /about/ | /about/index.html +Source file | use_directory_urls: true | use_directory_urls: false +---------------- | ------------------------- | ------------------------- +index.md | / | /index.html +api-guide.md | /api-guide/ | /api-guide.html +about/license.md | /about/license/ | /about/license.html The default style of `use_directory_urls: true` creates more user friendly URLs, and is usually what you'll want to use. The alternate style can occasionally be useful if you want your documentation to remain properly linked when opening pages directly from the file system, because -it create links that point directly to the target *file* rather than the target +it creates links that point directly to the target *file* rather than the target *directory*. **default**: `true` ### strict -Determines if a broken link to a page within the documentation is considered a -warning or an error (link to a page not listed in the pages setting). Set to -true to halt processing when a broken link is found, false prints a warning. +Determines how warnings are handled. Set to `true` to halt processing when a +warning is raised. Set to `false` to print a warning and continue processing. **default**: `false` @@ -519,7 +554,7 @@ You may [contribute additional languages]. any reason, a warning is issued. You may use the `--strict` flag when building to cause such a failure to raise an error instead. - !!! Note +!!! Note On smaller sites, using a pre-built index is not recommended as it creates a significant increase is bandwidth requirements with little to no noticeable @@ -545,3 +580,4 @@ You may [contribute additional languages]. [ISO 639-1]: https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes [Lunr Languages]: https://github.com/MihaiValentin/lunr-languages#lunr-languages----- [contribute additional languages]: https://github.com/MihaiValentin/lunr-languages/blob/master/CONTRIBUTING.md +[Node.js]: https://nodejs.org/ diff --git a/docs/user-guide/custom-themes.md b/docs/user-guide/custom-themes.md index 18f72b282a..dc1c971f3e 100644 --- a/docs/user-guide/custom-themes.md +++ b/docs/user-guide/custom-themes.md @@ -126,6 +126,8 @@ used options include: * [config.site_url](./configuration.md#site_url) * [config.site_author](./configuration.md#site_author) * [config.site_description](./configuration.md#site_description) +* [config.extra_javascript](./configuration.md#extra_javascript) +* [config.extra_css](./configuration.md#extra_css) * [config.repo_url](./configuration.md#repo_url) * [config.repo_name](./configuration.md#repo_name) * [config.copyright](./configuration.md#copyright) @@ -133,7 +135,29 @@ used options include: #### nav -The `nav` variable is used to create the navigation for the documentation. +The `nav` variable is used to create the navigation for the documentation. The +`nav` object is an iterable of [navigation objects](#navigation-objects) as +defined by the [nav] configuration setting. + +[nav]: configuration.md#nav + +In addition to the iterable of [navigation objects](#navigation-objects), the +`nav` object contains the following attributes: + +##### nav.homepage + +The [page](#page) object for the homepage of the site. + +##### nav.pages + +A flat list of all [page](#page) objects contained in the navigation. This list +is not necessarily a complete list of all site pages as it does not contain +pages which are not included in the navigation. This list does match the list +and order of pages used for all "next page" and "previous page" links. For a +list of all pages, use the [pages](#pages) template variable. + +##### Nav Example + Following is a basic usage example which outputs the first and second level navigation as a nested list. @@ -145,15 +169,15 @@ navigation as a nested list.
  • {{ nav_item.title }}
  • {% else %} - {% endif %} {% endfor %} @@ -161,9 +185,6 @@ navigation as a nested list. {% endif %} ``` -The `nav` object also contains a `homepage` object, which points to the `page` -object of the homepage. For example, you may want to access `nav.homepage.url`. - #### base_url The `base_url` provides a relative path to the root of the MkDocs project. @@ -175,22 +196,6 @@ folder on all pages you would do this: ``` -#### extra_css - -Contains a list of URLs to the style-sheets listed in the [extra_css] -config setting. Unlike the config setting, which contains local paths, this -variable contains absolute paths from the homepage. - -[extra_css]: configuration.md#extra_css - -#### extra_javascript - -Contains a list of URLs to the scripts listed in the [extra_javascript] config -setting. Unlike the config setting, which contains local paths, this variable -contains absolute paths from the homepage. - -[extra_javascript]: configuration.md#extra_javascript - #### mkdocs_version Contains the current MkDocs version. @@ -201,11 +206,23 @@ A Python datetime object that represents the date and time the documentation was built in UTC. This is useful for showing how recently the documentation was updated. +#### pages + +A list of [page](#page) objects including *all* pages in the project. The list +is a flat list with all pages sorted alphanumerically by directory and file +name. Note that index pages sort to the top within a directory. This list can +contain pages not included in the global [navigation](#nav) and may not match +the order of pages within that navigation. + #### page In templates which are not rendered from a Markdown source file, the `page` variable is `None`. In templates which are rendered from a Markdown source file, -the `page` variable contains a page object with the following attributes: +the `page` variable contains a `page` object. The same `page` objects are used +as `page` [navigation objects](#navigation-objects) in the global +[navigation](#nav) and in the [pages](#pages) template variable. + +All `page` objects contain the following attributes: ##### page.title @@ -257,19 +274,43 @@ documentation page. {% endfor %} ``` -##### page.canonical_url +##### page.url -The full, canonical URL to the current page. This includes the `site_url` from -the configuration. +The URL of the page relative to the MkDocs `site_dir`. It is expected that this +be used with [base_url] to ensure the URL is relative to the current page. -##### page.edit_url +```django +{{ page.title }} +``` -The full URL to the input page in the source repository. Typically used to -provide a link to edit the source page. +[base_url]: #base_url -##### page.url +##### page.abs_url + +The absolute URL of the page from the server root as determined by the value +assigned to the [site_url] configuration setting. The value includes any +subdirectory included in the `site_url`, but not the domain. [base_url] should +not be used with this variable. + +For example, if `site_url: http://example.com/`, then the value of +`page.abs_url` for the page `foo.md` would be `/foo/`. However, if +`site_url: http://example.com/bar/`, then the value of `page.abs_url` for the +page `foo.md` would be `/bar/foo/`. + +[site_url]: ./configuration.md#site_url + +##### page.canonical_url + +The full, canonical URL to the current page as determined by the value assigned +to the [site_url] configuration setting. The value includes the domain and any +subdirectory included in the `site_url`. [base_url] should not be used with this +variable. + +##### page.edit_url -The URL to the current page not including the `site_url` from the configuration. +The full URL to the source page in the source repository. Typically used to +provide a link to edit the source page. [base_url] should not be used with this +variable. ##### page.is_homepage @@ -284,12 +325,143 @@ on the homepage: ##### page.previous_page -The page object for the previous page. The usage is the same as for -`page`. +The page object for the previous page or `None`. The value will be `None` if the +current page is the first item in the site navigation or if the current page is +not included in the navigation at all. When the value is a page object, the +usage is the same as for `page`. ##### page.next_page -The page object for the next page.The usage is the same as for `page`. +The page object for the next page or `None`. The value will be `None` if the +current page is the last item in the site navigation or if the current page is +not included in the navigation at all. When the value is a page object, the +usage is the same as for `page`. + +##### page.parent + +The immediate parent of the page in the [site navigation](#nav). `None` if the +page is at the top level. + +##### page.children + +Pages do not contain children and the attribute is always `None`. + +##### page.active + +When `True`, indicates that this page is the currently viewed page. Defaults +to `False`. + +##### page.is_section + +Indicates that the navigation object is a "section" object. Always `False` for +page objects. + +##### page.is_page + +Indicates that the navigation object is a "page" object. Always `True` for +page objects. + +##### page.is_link + +Indicates that the navigation object is a "link" object. Always `False` for +page objects. + +### Navigation Objects + +Navigation objects contained in the [nav](#nav) template variable may be one of +[section](#section) objects, [page](#page) objects, and [link](#link) objects. +While section objects may contain nested navigation objects, pages and links do +not. + +Page objects are the full page object as used for the current [page](#page) with +all of the same attributes available. Section and Link objects contain a subset +of those attributes as defined below: + +#### Section + +A `section` navigation object defines a named section in the navigation and +contains a list of child navigation objects. Note that sections do not contain +URLs and are not links of any kind. However, by default, MkDocs sorts index +pages to the top and the first child might be used as the URL for a section if a +theme choses to do so. + + The following attributes are available on `section` objects: + +##### section.title + +The title of the section. + +##### section.parent + +The immediate parent of the section or `None` if the section is at the top +level. + +##### section.children + +An iterable of all child navigation objects. Children may include nested +sections, pages and links. + +##### section.active + +When `True`, indicates that a child page of this section is the current page and +can be used to highlight the section as the currently viewed section. Defaults +to `False`. + +##### section.is_section + +Indicates that the navigation object is a "section" object. Always `True` for +section objects. + +##### section.is_page + +Indicates that the navigation object is a "page" object. Always `False` for +section objects. + +##### section.is_link + +Indicates that the navigation object is a "link" object. Always `False` for +section objects. + +#### Link + +A `link` navigation object contains a link which does not point to an internal +MkDocs page. The following attributes are available on `link` objects: + +##### link.title + +The title of the link. This would generally be used as the label of the link. + +##### link.url + +The URL that the link points to. The URL should always be an absolute URLs and +should not need to have `base_url` prepened. + +##### link.parent + +The immediate parent of the link. `None` if the link is at the top level. + +##### link.children + +Links do not contain children and the attribute is always `None`. + +##### link.active + +External links cannot be "active" and the attribute is always `False`. + +##### link.is_section + +Indicates that the navigation object is a "section" object. Always `False` for +link objects. + +##### link.is_page + +Indicates that the navigation object is a "page" object. Always `False` for +link objects. + +##### link.is_link + +Indicates that the navigation object is a "link" object. Always `True` for +link objects. ### Extra Context @@ -607,4 +779,4 @@ For a much more detailed guide, see the official Python packaging documentation for [Packaging and Distributing Projects]. [Packaging and Distributing Projects]: https://packaging.python.org/en/latest/distributing/ -[theme]: ./configuration/#theme +[theme]: ./configuration.md#theme diff --git a/docs/user-guide/deploying-your-docs.md b/docs/user-guide/deploying-your-docs.md index b06accd221..5709b6f6d5 100644 --- a/docs/user-guide/deploying-your-docs.md +++ b/docs/user-guide/deploying-your-docs.md @@ -108,7 +108,7 @@ public repository. [rtd]: https://readthedocs.org/ [instructions]: https://read-the-docs.readthedocs.io/en/latest/getting_started.html#in-markdown [features]: http://read-the-docs.readthedocs.io/en/latest/features.html -[theme]: /user-guide/styling-your-docs.md +[theme]: ./styling-your-docs.md#readthedocs ## Other Providers @@ -153,4 +153,4 @@ deploying to [GitHub](#github-pages) but only on a custom domain. Other web servers may be configured to use it but the feature won't always be available. See the documentation for your server of choice for more information. -[site_dir]: ./configuration/#site_dir +[site_dir]: ./configuration.md#site_dir diff --git a/docs/user-guide/plugins.md b/docs/user-guide/plugins.md index fbf53d3c8d..d1b8fc451f 100644 --- a/docs/user-guide/plugins.md +++ b/docs/user-guide/plugins.md @@ -149,7 +149,7 @@ entire site. : The `serve` event is only called when the `serve` command is used during development. It is passed the `Server` instance which can be modified before it is activated. For example, additional files or directories could be added - to the list of "watched" filed for auto-reloading. + to the list of "watched" files for auto-reloading. Parameters: : __server:__ `livereload.Server` instance @@ -178,14 +178,30 @@ entire site. Parameters: : __config:__ global configuration object +##### on_files + +: The `files` event is called after the files collection is populated from the + `docs_dir`. Use this event to add, remove, or alter files in the + collection. Note that Page objects have not yet been associated with the + file objects in the collection. Use [Page Events] to manipulate page + specific data. + + Parameters: + : __files:__ global files collection + : __config:__ global configuration object + + Returns: + : global files collection + ##### on_nav : The `nav` event is called after the site navigation is created and can be used to alter the site navigation. Parameters: - : __site_navigation:__ global navigation object + : __nav:__ global navigation object : __config:__ global configuration object + : __files:__ global files collection Returns: : global navigation object @@ -263,8 +279,8 @@ called after the [env] event and before any [page events]. #### Page Events Page events are called once for each Markdown page included in the site. All -page events are called after the [post_template] event and before the [post_build] -event. +page events are called after the [post_template] event and before the +[post_build] event. ##### on_pre_page diff --git a/docs/user-guide/styling-your-docs.md b/docs/user-guide/styling-your-docs.md index c97abf99d1..cae1d8f244 100644 --- a/docs/user-guide/styling-your-docs.md +++ b/docs/user-guide/styling-your-docs.md @@ -6,9 +6,8 @@ How to style and theme your documentation. MkDocs includes a couple [built-in themes] as well as various [third party themes], all of which can easily be customized with [extra CSS or -JavaScript][docs_dir] or overridden from the [theme directory][theme_dir]. You -can also create your own [custom theme] from the ground up for your -documentation. +JavaScript][docs_dir] or overridden from the theme's [custom_dir]. You can also +create your own [custom theme] from the ground up for your documentation. To use a theme that is included in MkDocs, simply add this to your `mkdocs.yml` config file. @@ -261,21 +260,21 @@ any additional CSS files included in the `custom_dir`. [browse source]: https://github.com/mkdocs/mkdocs/tree/master/mkdocs/themes/mkdocs [built-in themes]: #built-in-themes [Bootstrap]: http://getbootstrap.com/ -[theme configuration options]: configuration.md#theme +[theme configuration options]: ./configuration.md#theme [Read the Docs]: https://readthedocs.org/ [community wiki]: https://github.com/mkdocs/mkdocs/wiki/MkDocs-Themes [custom theme]: ./custom-themes.md [customize]: #customizing-a-theme [docs_dir]: #using-the-docs_dir -[documentation directory]: ./configuration/#docs_dir +[documentation directory]: ./configuration.md#docs_dir [extra_css]: ./configuration.md#extra_css [extra_javascript]: ./configuration.md#extra_javascript [Jinja documentation]: http://jinja.pocoo.org/docs/dev/templates/#template-inheritance [mkdocs]: #mkdocs [ReadTheDocs]: ./deploying-your-docs.md#readthedocs [Template Variables]: ./custom-themes.md#template-variables -[custom_dir]: ./configuration/#custom_dir -[name]: ./configuration/#name +[custom_dir]: ./configuration.md#custom_dir +[name]: ./configuration.md#name [third party themes]: #third-party-themes [super block]: http://jinja.pocoo.org/docs/dev/templates/#super-blocks [base_url]: ./custom-themes.md#base_url diff --git a/docs/user-guide/writing-your-docs.md b/docs/user-guide/writing-your-docs.md index 91fde615d3..15eec890a3 100644 --- a/docs/user-guide/writing-your-docs.md +++ b/docs/user-guide/writing-your-docs.md @@ -22,7 +22,8 @@ docs/ By convention your project homepage should always be named `index`. Any of the following extensions may be used for your Markdown source files: `markdown`, -`mdown`, `mkdn`, `mkd`, `md`. +`mdown`, `mkdn`, `mkd`, `md`. All Markdown files included in your documentation +directory will be rendered in the built site regardless of any settings. You can also create multi-page documentation, by creating several Markdown files: @@ -65,55 +66,84 @@ nested URLs, like so: /license/ ``` +### Index pages + +When a directory is requested, by default, most web servers will return an index +file (usually named `index.html`) contained within that directory if one exists. +For that reason, the homepage in all of the examples above has been named +`index.md`, which MkDocs will render to `index.html` when building the site. + +Many repository hosting sites provide special treatment for README files by +displaying the contents of the README file when browsing the contents of a +directory. Therefore, MkDocs will allow you to name your index pages as +`README.md` instead of `index.md`. In that way, when users are browsing your +source code, the repository host can display the index page of that directory as +it is a README file. However, when MkDocs renders your site, the file will be +renamed to `index.html` so that the server will serve it as a proper index file. + +You should not include both an `index.md` file and a `README.md` file in the +same directory. It is suggested that you chose a convention for your project and +then stick to it. + ### Configure Pages and Navigation -The [pages configuration](configuration.md#pages) in your `mkdocs.yml` defines -which pages are built by MkDocs and how they appear in the documentation -navigation. If not provided, the pages configuration will be automatically -created by discovering all the Markdown files in the [documentation -directory](configuration.md#docs_dir). An automatically created pages -configuration will always be sorted alphanumerically by file name. You will need -to manually define your pages configuration if you would like your pages sorted -differently. +The [nav](configuration.md#nav) configuration setting in your `mkdocs.yml` file +defines which pages are included in the global site navigation menu as well as +the structure of that menu. If not provided, the navigation will be +automatically created by discovering all the Markdown files in the +[documentation directory](configuration.md#docs_dir). An automatically created +navigation configuration will always be sorted alphanumerically by file name +(except that index files will always be listed first within a sub-section). You +will need to manually define your navigation configuration if you would like +your navigation menu sorted differently. -A simple pages configuration looks like this: +A simple navigation configuration looks like this: ```no-highlight -pages: +nav: - 'index.md' - 'about.md' ``` -With this example we will build two pages at the top level and they will -automatically have their titles inferred from the filename. Assuming `docs_dir` -has the default value, `docs`, the source files for this documentation would be -`docs/index.md` and `docs/about.md`. To provide a custom name for these pages, -they can be added before the filename. +All paths in the navigation configuration must be relative to the `docs_dir` +configuration option. If that option is set to the default value, `docs`, the +source files for the above configuration would be located at `docs/index.md` and +`docs/about.md`. + +The above example will result in two navigation items being created at the top +level and with their titles inferred from the contents of the file (or the +filename if no title is defined within the file). To define a custom title for +the pages, the title can be added before the filename. ```no-highlight -pages: +nav: - Home: 'index.md' - About: 'about.md' ``` -Subsections can be created by listing related pages together under a section -title. For example: +Note that if a title is defined for a page in the navigation, that title will be +used throughout the site for that page and will override any title defined +within the page itself. + +Navigation sub-sections can be created by listing related pages together under a +section title. For example: ```no-highlight -pages: +nav: - Home: 'index.md' - User Guide: - - 'Writing your docs': 'user-guide/writing-your-docs.md' - - 'Styling your docs': 'user-guide/styling-your-docs.md' + - 'Writing your docs': 'writing-your-docs.md' + - 'Styling your docs': 'styling-your-docs.md' - About: - - 'License': 'about/license.md' - - 'Release Notes': 'about/release-notes.md' + - 'License': 'license.md' + - 'Release Notes': 'release-notes.md' ``` -With the above configuration we have three top level sections: Home, User Guide -and About. Then under User Guide we have two pages, Writing your docs and -Styling your docs. Under the About section we also have two pages, License and -Release Notes. +With the above configuration we have three top level items: "Home", "User Guide" +and "About." "Home" is a link to the homepage for the site. Under the "User +Guide" section two pages are listed: "Writing your docs" and "Styling your +docs." Under the "About" section two more pages are listed: "License" and +"Release Notes." Note that a section cannot have a page assigned to it. Sections are only containers for child pages and sub-sections. You may nest sections as deeply as @@ -121,6 +151,11 @@ you like. However, be careful that you don't make it too difficult for your users to navigate through the site navigation by over-complicating the nesting. While sections may mirror your directly structure, they do not have to. +Any pages not listed in your navigation configuration will still be rendered and +included with the built site, however, they will not be linked from the global +navigation and will not be included in the `previous` and `next` links. Such +pages will be "hidden" unless linked to directly. + ## Writing with Markdown MkDocs pages must be authored in [Markdown][md], a lightweight markup language @@ -139,7 +174,7 @@ configuration setting for details on how to enable extensions. MkDocs includes some extensions by default, which are highlighted below. [Python-Markdown]: https://python-markdown.github.io/ -[md]: http://daringfireball.net/projects/markdown/ +[md]: https://daringfireball.net/projects/markdown/ [differences]: https://python-markdown.github.io/#differences [syntax]: https://daringfireball.net/projects/markdown/syntax [extensions]: https://python-markdown.github.io/extensions/ @@ -149,7 +184,7 @@ MkDocs includes some extensions by default, which are highlighted below. MkDocs allows you to interlink your documentation by using regular Markdown [links]. However, there are a few additional benefits to formatting those links -specifically for MkDocs as outlines below. +specifically for MkDocs as outlined below. [links]: https://daringfireball.net/projects/markdown/syntax#link @@ -280,6 +315,15 @@ also be previewed if you're working on the documentation with a Markdown editor. [GitHub pages CNAME file]: https://help.github.com/articles/using-a-custom-domain-with-github-pages/ +#### Linking from raw HTML + +Markdown allows document authors to fall back to raw HTML when the Markdown +syntax does not meets the author's needs. MkDocs does not limit Markdown in this +regard. However, as all raw HTML is ignored by the Markdown parser, MkDocs is +not able to validate or convert links contained in raw HTML. When including +internal links within raw HTML, you will need to manually format the link +appropriately for the rendered document. + ### Meta-Data MkDocs includes support for [MultiMarkdown] style meta-data (often called @@ -341,7 +385,7 @@ specific page. The following keys are supported: MkDocs will attempt to determine the title of a document in the following ways, in order: - 1. A title defined in the [pages] configuration setting for a document. + 1. A title defined in the [nav] configuration setting for a document. 2. A title defined in the `title` meta-data key of a document. 3. A level 1 Markdown header on the first line of the document body. 4. The filename of a document. @@ -350,7 +394,7 @@ specific page. The following keys are supported: additional sources in the above list. [MultiMarkdown]: http://fletcherpenney.net/MultiMarkdown_Syntax_Guide#metadata -[pages]: configuration.md#pages +[nav]: configuration.md#nav ### Tables diff --git a/mkdocs.yml b/mkdocs.yml index ae1e80ca40..8bd62127dd 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -6,7 +6,7 @@ site_author: MkDocs Team repo_url: https://github.com/mkdocs/mkdocs/ edit_uri: "" -pages: +nav: - Home: index.md - User Guide: - Writing Your Docs: user-guide/writing-your-docs.md diff --git a/mkdocs/commands/build.py b/mkdocs/commands/build.py index d8a5cd27d6..df31d6efc9 100644 --- a/mkdocs/commands/build.py +++ b/mkdocs/commands/build.py @@ -3,15 +3,17 @@ from __future__ import unicode_literals from datetime import datetime from calendar import timegm -import io import logging import os import gzip +import io from jinja2.exceptions import TemplateNotFound import jinja2 -from mkdocs import nav, utils +from mkdocs import utils +from mkdocs.structure.files import get_files +from mkdocs.structure.nav import get_navigation import mkdocs @@ -30,18 +32,17 @@ def filter(self, record): log.addFilter(DuplicateFilter()) -def get_context(nav, config, page=None): +def get_context(nav, files, config, page=None, base_url=''): """ - Given the SiteNavigation and config, generate the context which is relevant - to app pages. + Return the template context for a given page or template. """ - if nav is None: - return {'page', page} + if page is not None: + base_url = utils.get_relative_url('.', page.url) - extra_javascript = utils.create_media_urls(nav, config['extra_javascript']) + extra_javascript = utils.create_media_urls(config['extra_javascript'], page, base_url) - extra_css = utils.create_media_urls(nav, config['extra_css']) + extra_css = utils.create_media_urls(config['extra_css'], page, base_url) # Support SOURCE_DATE_EPOCH environment variable for "reproducible" builds. # See https://reproducible-builds.org/specs/source-date-epoch/ @@ -49,8 +50,9 @@ def get_context(nav, config, page=None): return { 'nav': nav, - # base_url should never end with a slash. - 'base_url': nav.url_context.make_relative('/').rstrip('/'), + 'pages': files.documentation_pages(), + + 'base_url': base_url.rstrip('/'), 'extra_css': extra_css, 'extra_javascript': extra_javascript, @@ -63,192 +65,169 @@ def get_context(nav, config, page=None): } -def build_template(template_name, env, config, site_navigation=None): - """ Build a template using the theme environment. """ - - log.debug("Building template: %s", template_name) - - try: - template = env.get_template(template_name) - except TemplateNotFound: - log.info("Template skipped: '{}'. Not found in template directories.".format(template_name)) - return +def _build_template(name, template, files, config, nav): + """ + Return rendered output for given template as a string. + """ # Run `pre_template` plugin events. template = config['plugins'].run_event( - 'pre_template', template, template_name=template_name, config=config + 'pre_template', template, template_name=name, config=config ) - context = get_context(site_navigation, config) + if utils.is_error_template(name): + # Force absolute URLs in the nav of error pages and account for the + # possability that the docs root might be different than the server root. + # See https://github.com/mkdocs/mkdocs/issues/77 + base_url = utils.urlparse(config['site_url']).path + else: + base_url = utils.get_relative_url('.', name) + + context = get_context(nav, files, config, base_url=base_url) # Run `template_context` plugin events. context = config['plugins'].run_event( - 'template_context', context, template_name=template_name, config=config + 'template_context', context, template_name=name, config=config ) - output_content = template.render(context) + output = template.render(context) # Run `post_template` plugin events. - output_content = config['plugins'].run_event( - 'post_template', output_content, template_name=template_name, config=config + output = config['plugins'].run_event( + 'post_template', output, template_name=name, config=config ) - if output_content.strip(): - output_path = os.path.join(config['site_dir'], template_name) - utils.write_file(output_content.encode('utf-8'), output_path) + return output - if template_name == 'sitemap.xml': - log.debug("Gzipping template: %s", template_name) - with gzip.open('{}.gz'.format(output_path), 'wb') as f: - f.write(output_content.encode('utf-8')) - else: - log.info("Template skipped: '{}'. Generated empty output.".format(template_name)) +def _build_theme_template(template_name, env, files, config, nav): + """ Build a template using the theme environment. """ -def build_error_template(template, env, config, site_navigation): - """ - Build error template. + log.debug("Building theme template: {}".format(template_name)) - Force absolute URLs in the nav of error pages and account for the - possability that the docs root might be different than the server root. - See https://github.com/mkdocs/mkdocs/issues/77 - """ + try: + template = env.get_template(template_name) + except TemplateNotFound: + log.warn("Template skipped: '{}' not found in theme directories.".format(template_name)) + return - site_navigation.url_context.force_abs_urls = True - default_base = site_navigation.url_context.base_path - site_navigation.url_context.base_path = utils.urlparse(config['site_url']).path + output = _build_template(template_name, template, files, config, nav) - build_template(template, env, config, site_navigation) + if output.strip(): + output_path = os.path.join(config['site_dir'], template_name) + utils.write_file(output.encode('utf-8'), output_path) - # Reset nav behavior to the default - site_navigation.url_context.force_abs_urls = False - site_navigation.url_context.base_path = default_base + if template_name == 'sitemap.xml': + log.debug("Gzipping template: %s", template_name) + with gzip.open('{}.gz'.format(output_path), 'wb') as f: + f.write(output.encode('utf-8')) + else: + log.info("Template skipped: '{}' generated empty output.".format(template_name)) -def _build_page(page, config, site_navigation, env, dirty=False): - """ Build a Markdown page and pass to theme template. """ +def _build_extra_template(template_name, files, config, nav): + """ Build user templates which are not part of the theme. """ - # Run the `pre_page` plugin event - page = config['plugins'].run_event( - 'pre_page', page, config=config, site_navigation=site_navigation - ) + log.debug("Building extra template: {}".format(template_name)) - page.read_source(config=config) + file = files.get_file_from_path(template_name) + if file is None: + log.warn("Template skipped: '{}' not found in docs_dir.".format(template_name)) + return - # Run `page_markdown` plugin events. - page.markdown = config['plugins'].run_event( - 'page_markdown', page.markdown, page=page, config=config, site_navigation=site_navigation - ) + try: + with io.open(file.abs_src_path, 'r', encoding='utf-8', errors='strict') as f: + template = jinja2.Template(f.read()) + except Exception as e: + log.warn("Error reading template '{}': {}".format(template_name, e)) + return - page.render(config, site_navigation) + output = _build_template(template_name, template, files, config, nav) - # Run `page_content` plugin events. - page.content = config['plugins'].run_event( - 'page_content', page.content, page=page, config=config, site_navigation=site_navigation - ) + if output.strip(): + utils.write_file(output.encode('utf-8'), file.abs_dest_path) + else: + log.info("Template skipped: '{}' generated empty output.".format(template_name)) - context = get_context(site_navigation, config, page) - # Allow 'template:' override in md source files. - if 'template' in page.meta: - template = env.get_template(page.meta['template']) - else: - template = env.get_template('main.html') +def _populate_page(page, config, files, dirty=False): + """ Read page content from docs_dir and render Markdown. """ - # Run `page_context` plugin events. - context = config['plugins'].run_event( - 'page_context', context, page=page, config=config, site_navigation=site_navigation - ) + try: + # When --dirty is used, only read the page if the file has been modified since the + # previous build of the output. + if dirty and not page.file.is_modified(): + return + + # Run the `pre_page` plugin event + page = config['plugins'].run_event( + 'pre_page', page, config=config, files=files + ) - # Render the template. - output_content = template.render(context) + page.read_source(config) - # Run `post_page` plugin events. - output_content = config['plugins'].run_event( - 'post_page', output_content, page=page, config=config - ) + # Run `page_markdown` plugin events. + page.markdown = config['plugins'].run_event( + 'page_markdown', page.markdown, page=page, config=config, files=files + ) - # Write the output file. - if output_content.strip(): - utils.write_file(output_content.encode('utf-8'), page.abs_output_path) - else: - log.info("Page skipped: '{}'. Generated empty output.".format(page.title)) + page.render(config, files) + # Run `page_content` plugin events. + page.content = config['plugins'].run_event( + 'page_content', page.content, page=page, config=config, files=files + ) + except Exception as e: + log.error("Error reading page '{}': {}".format(page.file.src_path, e)) + raise -def build_extra_templates(extra_templates, config, site_navigation=None): - """ Build user templates which are not part of the theme. """ - log.debug("Building extra_templates pages") +def _build_page(page, config, files, nav, env, dirty=False): + """ Pass a Page to theme template and write output to site_dir. """ - for extra_template in extra_templates: + try: + # When --dirty is used, only build the page if the file has been modified since the + # previous build of the output. + if dirty and not page.file.is_modified(): + return - input_path = os.path.join(config['docs_dir'], extra_template) + log.debug("Building page {}".format(page.file.src_path)) - with io.open(input_path, 'r', encoding='utf-8') as template_file: - template = jinja2.Template(template_file.read()) + # Activate page. Signals to theme that this is the current page. + page.active = True - # Run `pre_template` plugin events. - template = config['plugins'].run_event( - 'pre_template', template, template_name=extra_template, config=config - ) + context = get_context(nav, files, config, page) - context = get_context(site_navigation, config) + # Allow 'template:' override in md source files. + if 'template' in page.meta: + template = env.get_template(page.meta['template']) + else: + template = env.get_template('main.html') - # Run `template_context` plugin events. + # Run `page_context` plugin events. context = config['plugins'].run_event( - 'template_context', context, template_name=extra_template, config=config + 'page_context', context, page=page, config=config, nav=nav ) - output_content = template.render(context) + # Render the template. + output = template.render(context) - # Run `post_template` plugin events. - output_content = config['plugins'].run_event( - 'post_template', output_content, template_name=extra_template, config=config + # Run `post_page` plugin events. + output = config['plugins'].run_event( + 'post_page', output, page=page, config=config ) - if output_content.strip(): - output_path = os.path.join(config['site_dir'], extra_template) - utils.write_file(output_content.encode('utf-8'), output_path) + # Write the output file. + if output.strip(): + utils.write_file(output.encode('utf-8', errors='xmlcharrefreplace'), page.file.abs_dest_path) else: - log.info("Template skipped: '{}'. Generated empty output.".format(extra_template)) - - -def build_pages(config, dirty=False): - """ Build all pages and write them into the build directory. """ - - site_navigation = nav.SiteNavigation(config) + log.info("Page skipped: '{}'. Generated empty output.".format(page.file.src_path)) - # Run `nav` plugin events. - site_navigation = config['plugins'].run_event('nav', site_navigation, config=config) - - env = config['theme'].get_env() - - # Run `env` plugin events. - env = config['plugins'].run_event( - 'env', env, config=config, site_navigation=site_navigation - ) - - for template in config['theme'].static_templates: - if utils.is_error_template(template): - build_error_template(template, env, config, site_navigation) - else: - build_template(template, env, config, site_navigation) - - build_extra_templates(config['extra_templates'], config, site_navigation) - - log.debug("Building markdown pages.") - for page in site_navigation.walk_pages(): - try: - # When --dirty is used, only build the page if the markdown has been modified since the - # previous build of the output. - if dirty and (utils.modified_time(page.abs_input_path) < utils.modified_time(page.abs_output_path)): - continue - - log.debug("Building page %s", page.input_path) - _build_page(page, config, site_navigation, env) - except Exception: - log.error("Error building page %s", page.input_path) - raise + # Deactivate page + page.active = False + except Exception as e: + log.error("Error building page '{}': {}".format(page.file.src_path, e)) + raise def build(config, live_server=False, dirty=False): @@ -263,29 +242,54 @@ def build(config, live_server=False, dirty=False): if not dirty: log.info("Cleaning site directory") utils.clean_directory(config['site_dir']) - else: + else: # pragma: no cover # Warn user about problems that may occur with --dirty option log.warning("A 'dirty' build is being performed, this will likely lead to inaccurate navigation and other" " links within your site. This option is designed for site development purposes only.") - if not live_server: + if not live_server: # pragma: no cover log.info("Building documentation to directory: %s", config['site_dir']) if dirty and site_directory_contains_stale_files(config['site_dir']): log.info("The directory contains stale files. Use --clean to remove them.") - # Reversed as we want to take the media files from the builtin theme - # and then from the custom theme_dir so that the custom versions take - # precedence. - for theme_dir in reversed(config['theme'].dirs): - log.debug("Copying static assets from %s", theme_dir) - utils.copy_media_files( - theme_dir, config['site_dir'], exclude=['*.py', '*.pyc', '*.html', 'mkdocs_theme.yml'], dirty=dirty - ) + # First gather all data from all files/pages to ensure all data is consistent across all pages. + + files = get_files(config) + env = config['theme'].get_env() + files.add_files_from_theme(env, config) + + # Run `files` plugin events. + files = config['plugins'].run_event('files', files, config=config) + + nav = get_navigation(files, config) + + # Run `nav` plugin events. + nav = config['plugins'].run_event('nav', nav, config=config, files=files) - log.debug("Copying static assets from the docs dir.") - utils.copy_media_files(config['docs_dir'], config['site_dir'], dirty=dirty) + log.debug("Reading markdown pages.") + for file in files.documentation_pages(): + _populate_page(file.page, config, files, dirty) - build_pages(config, dirty=dirty) + # Run `env` plugin events. + env = config['plugins'].run_event( + 'env', env, config=config, files=files + ) + + # Start writing files to site_dir now that all data is gathered. Note that order matters. Files + # with lower precedence get written first so that files with higher precedence can overwrite them. + + log.debug("Copying static assets.") + files.copy_static_files(dirty=dirty) + + for template in config['theme'].static_templates: + _build_theme_template(template, env, files, config, nav) + + for template in config['extra_templates']: + _build_extra_template(template, files, config, nav) + + log.debug("Building markdown pages.") + for file in files.documentation_pages(): + _build_page(file.page, config, files, nav, env, dirty) # Run `post_build` plugin events. config['plugins'].run_event('post_build', config) diff --git a/mkdocs/config/base.py b/mkdocs/config/base.py index 60f9b587ab..b19550e3ff 100644 --- a/mkdocs/config/base.py +++ b/mkdocs/config/base.py @@ -1,6 +1,7 @@ from __future__ import unicode_literals import logging import os +import sys from mkdocs import exceptions from mkdocs import utils @@ -28,6 +29,13 @@ def __init__(self, schema, config_file_path=None): self._schema = schema self._schema_keys = set(dict(schema).keys()) + # Ensure config_file_path is a Unicode string + if config_file_path is not None and not isinstance(config_file_path, utils.text_type): + try: + # Assume config_file_path is encoded with the file system encoding. + config_file_path = config_file_path.decode(encoding=sys.getfilesystemencoding()) + except UnicodeDecodeError: + raise ValidationError("config_file_path is not a Unicode string.") self.config_file_path = config_file_path self.data = {} diff --git a/mkdocs/config/config_options.py b/mkdocs/config/config_options.py index f410f72d5c..a631da7e66 100644 --- a/mkdocs/config/config_options.py +++ b/mkdocs/config/config_options.py @@ -1,8 +1,8 @@ from __future__ import unicode_literals -from collections import Sequence import os -from collections import namedtuple +import sys +from collections import Sequence, namedtuple import markdown from mkdocs import utils, theme, plugins @@ -294,31 +294,29 @@ class FilesystemObject(Type): def __init__(self, exists=False, **kwargs): super(FilesystemObject, self).__init__(type_=utils.string_types, **kwargs) self.exists = exists + self.config_dir = None def pre_validation(self, config, key_name): - value = config[key_name] - - if not value: - return - - if os.path.isabs(value): - return - - if config.config_file_path is None: - # Unable to determine absolute path of the config file; fall back - # to trusting the relative path - return - - config_dir = os.path.dirname(config.config_file_path) - value = os.path.join(config_dir, value) - config[key_name] = value + self.config_dir = os.path.dirname(config.config_file_path) if config.config_file_path else None def run_validation(self, value): value = super(FilesystemObject, self).run_validation(value) + # PY2 only: Ensure value is a Unicode string. On PY3 byte strings fail + # the type test (super.run_validation) so we never get this far. + if not isinstance(value, utils.text_type): + try: + # Assume value is encoded with the file system encoding. + value = value.decode(encoding=sys.getfilesystemencoding()) + except UnicodeDecodeError: + raise ValidationError("The path is not a Unicode string.") + if self.config_dir and not os.path.isabs(value): + value = os.path.join(self.config_dir, value) if self.exists and not self.existence_test(value): raise ValidationError("The path {path} isn't an existing {name}.". format(path=value, name=self.name)) - return os.path.abspath(value) + value = os.path.abspath(value) + assert isinstance(value, utils.text_type) + return value class Dir(FilesystemObject): @@ -392,6 +390,8 @@ def pre_validation(self, config, key_name): if config.get(key_name) is None: return + super(ThemeDir, self).pre_validation(config, key_name) + warning = ('The configuration option {0} has been deprecated and will ' 'be removed in a future release of MkDocs.') self.warnings.append(warning) @@ -518,15 +518,15 @@ def post_validation(self, config, key_name): ).format(key_name, "', '".join(actual_files))) -class Pages(OptionallyRequired): +class Nav(OptionallyRequired): """ - Pages Config Option + Nav Config Option - Validate the pages config. Automatically add all markdown files if empty. + Validate the Nav config. Automatically add all markdown files if empty. """ def __init__(self, **kwargs): - super(Pages, self).__init__(**kwargs) + super(Nav, self).__init__(**kwargs) self.file_match = utils.is_markdown_file def run_validation(self, value): @@ -546,42 +546,15 @@ def run_validation(self, value): config_types, {utils.text_type, dict} )) - def walk_docs_dir(self, docs_dir): - - if self.file_match is None: - raise StopIteration - - for (dirpath, dirs, filenames) in os.walk(docs_dir, followlinks=True): - dirs.sort() - for filename in sorted(filenames): - fullpath = os.path.join(dirpath, filename) - - # Some editors (namely Emacs) will create temporary symlinks - # for internal magic. We can just ignore these files. - if os.path.islink(fullpath): - local_fullpath = os.path.join(dirpath, os.readlink(fullpath)) - if not os.path.exists(local_fullpath): - continue - - relpath = os.path.normpath(os.path.relpath(fullpath, docs_dir)) - if self.file_match(relpath): - yield relpath - def post_validation(self, config, key_name): - - if config[key_name] is not None: - return - - pages = [] - - for filename in self.walk_docs_dir(config['docs_dir']): - - if os.path.splitext(filename)[0] == 'index': - pages.insert(0, filename) - else: - pages.append(filename) - - config[key_name] = utils.nest_paths(pages) + # TODO: remove this when `pages` config setting is fully deprecated. + if key_name == 'pages' and config['pages'] is not None: + if config['nav'] is None: + # copy `pages` config to new 'nav' config setting + config['nav'] = config['pages'] + warning = ("The 'pages' configuration option has been deprecated and will " + "be removed in a future release of MkDocs. Use 'nav' instead.") + self.warnings.append(warning) class Private(OptionallyRequired): diff --git a/mkdocs/config/defaults.py b/mkdocs/config/defaults.py index 2e93695893..99600b3bf7 100644 --- a/mkdocs/config/defaults.py +++ b/mkdocs/config/defaults.py @@ -19,9 +19,10 @@ # The title to use for the documentation ('site_name', config_options.Type(utils.string_types, required=True)), - # Defines the structure of the navigation and which markdown files are - # included in the build. - ('pages', config_options.Pages()), + # Defines the structure of the navigation. + ('nav', config_options.Nav()), + # TODO: remove this when the `pages` config setting is fully deprecated. + ('pages', config_options.Nav()), # The full URL to where the documentation will be hosted ('site_url', config_options.URL()), diff --git a/mkdocs/contrib/search/search_index.py b/mkdocs/contrib/search/search_index.py index 2b5587abc7..ca4eebcab2 100644 --- a/mkdocs/contrib/search/search_index.py +++ b/mkdocs/contrib/search/search_index.py @@ -69,17 +69,17 @@ def add_entry_from_context(self, page): # Get the absolute URL for the page, this is then # prepended to the urls of the sections - abs_url = page.abs_url + url = page.url # Create an entry for the full page. self._add_entry( title=page.title, text=self.strip_tags(page.content).rstrip('\n'), - loc=abs_url + loc=url ) for section in parser.data: - self.create_entry_for_section(section, page.toc, abs_url) + self.create_entry_for_section(section, page.toc, url) def create_entry_for_section(self, section, toc, abs_url): """ diff --git a/mkdocs/nav.py b/mkdocs/nav.py deleted file mode 100644 index a78ab588df..0000000000 --- a/mkdocs/nav.py +++ /dev/null @@ -1,416 +0,0 @@ -# coding: utf-8 - -""" -Deals with generating the site-wide navigation. - -This consists of building a set of interlinked page and header objects. -""" - -from __future__ import unicode_literals -import datetime -import logging -import markdown -import os -import io - -from mkdocs import utils, exceptions, toc -from mkdocs.utils import meta -from mkdocs.relative_path_ext import RelativePathExtension - -log = logging.getLogger(__name__) - - -def _filename_to_title(filename): - """ - Automatically generate a default title, given a filename. - """ - if utils.is_homepage(filename): - return 'Home' - - return utils.filename_to_title(filename) - - -@meta.transformer() -def default(value): - """ By default, return all meta values as strings. """ - return ' '.join(value) - - -class SiteNavigation(object): - def __init__(self, config): - self.url_context = URLContext() - self.file_context = FileContext() - self.nav_items, self.pages = _generate_site_navigation( - config, self.url_context) - self.homepage = self.pages[0] if self.pages else None - self.use_directory_urls = config['use_directory_urls'] - - def __str__(self): - return ''.join([str(item) for item in self]) - - def __iter__(self): - return iter(self.nav_items) - - def __len__(self): - return len(self.nav_items) - - def walk_pages(self): - """ - Returns each page in the site in turn. - - Additionally this sets the active status of the pages and headers, - in the site navigation, so that the rendered navbar can correctly - highlight the currently active page and/or header item. - """ - page = self.homepage - page.set_active() - self.url_context.set_current_url(page.abs_url) - self.file_context.set_current_path(page.input_path) - yield page - while page.next_page: - page.set_active(False) - page = page.next_page - page.set_active() - self.url_context.set_current_url(page.abs_url) - self.file_context.set_current_path(page.input_path) - yield page - page.set_active(False) - - @property - def source_files(self): - if not hasattr(self, '_source_files'): - self._source_files = set([page.input_path for page in self.pages]) - return self._source_files - - -class URLContext(object): - """ - The URLContext is used to ensure that we can generate the appropriate - relative URLs to other pages from any given page in the site. - - We use relative URLs so that static sites can be deployed to any location - without having to specify what the path component on the host will be - if the documentation is not hosted at the root path. - """ - - def __init__(self): - self.base_path = '/' - self.force_abs_urls = False - - def set_current_url(self, current_url): - self.base_path = os.path.dirname(current_url) - - def make_relative(self, url): - """ - Given a URL path return it as a relative URL, - given the context of the current page. - """ - if self.force_abs_urls: - abs_url = '%s/%s' % (self.base_path.rstrip('/'), utils.path_to_url(url.lstrip('/'))) - return abs_url - - suffix = '/' if (url.endswith('/') and len(url) > 1) else '' - # Workaround for bug on `os.path.relpath()` in Python 2.6 - if self.base_path == '/': - if url == '/': - # Workaround for static assets - return '.' - return url.lstrip('/') - # Under Python 2.6, relative_path adds an extra '/' at the end. - relative_path = os.path.relpath(url, start=self.base_path) - relative_path = relative_path.rstrip('/') + suffix - - return utils.path_to_url(relative_path) - - -class FileContext(object): - """ - The FileContext is used to ensure that we can generate the appropriate - full path for other pages given their relative path from a particular page. - - This is used when we have relative hyperlinks in the documentation, so that - we can ensure that they point to markdown documents that actually exist - in the `pages` config. - """ - def __init__(self): - self.current_file = None - self.base_path = '' - - def set_current_path(self, current_path): - self.current_file = current_path - self.base_path = os.path.dirname(current_path) - - def make_absolute(self, path): - """ - Given a relative file path return it as a POSIX-style - absolute filepath, given the context of the current page. - """ - return os.path.normpath(os.path.join(self.base_path, path)) - - -class Page(object): - def __init__(self, title, path, url_context, config): - - self._title = title - self.abs_url = utils.get_url_path(path, config['use_directory_urls']) - self.active = False - self.url_context = url_context - - # Support SOURCE_DATE_EPOCH environment variable for "reproducible" builds. - # See https://reproducible-builds.org/specs/source-date-epoch/ - if 'SOURCE_DATE_EPOCH' in os.environ: - self.update_date = datetime.datetime.utcfromtimestamp( - int(os.environ['SOURCE_DATE_EPOCH']) - ).strftime("%Y-%m-%d") - else: - self.update_date = datetime.datetime.now().strftime("%Y-%m-%d") - - # Relative and absolute paths to the input markdown file and output html file. - self.input_path = path - self.output_path = utils.get_html_path(path) - self.abs_input_path = os.path.join(config['docs_dir'], self.input_path) - self.abs_output_path = os.path.join(config['site_dir'], self.output_path) - - self.canonical_url = None - if config['site_url']: - self._set_canonical_url(config['site_url']) - - self.edit_url = None - if config['repo_url'] and config['edit_uri']: - self._set_edit_url(config['repo_url'], config['edit_uri']) - - # Placeholders to be filled in later in the build - # process when we have access to the config. - self.markdown = '' - self.meta = {} - self.content = None - self.toc = None - - self.previous_page = None - self.next_page = None - self.ancestors = [] - - def __eq__(self, other): - - def sub_dict(d): - return dict((key, value) for key, value in d.items() - if key in ['title', 'input_path', 'abs_url']) - - return (isinstance(other, self.__class__) - and sub_dict(self.__dict__) == sub_dict(other.__dict__)) - - def __ne__(self, other): - return not self.__eq__(other) - - def __str__(self): - return self.indent_print() - - def __repr__(self): - return "nav.Page(title='{0}', input_path='{1}', url='{2}')".format( - self.title, self.input_path, self.abs_url) - - @property - def title(self): - """ - Get the title for a Markdown document - Check these in order and return the first that has a valid title: - - self._title which is populated from the mkdocs.yml - - self.meta['title'] which comes from the page metadata - - self.markdown - look for the first H1 - - self.input_path - create a title based on the filename - """ - if self._title is not None: - return self._title - elif 'title' in self.meta: - return self.meta['title'] - - title = utils.get_markdown_title(self.markdown) - - if title is not None: - return title - - return _filename_to_title(self.input_path.split(os.path.sep)[-1]) - - @property - def url(self): - return self.url_context.make_relative(self.abs_url) - - @property - def is_homepage(self): - return utils.is_homepage(self.input_path) - - @property - def is_top_level(self): - return len(self.ancestors) == 0 - - def read_source(self, config): - source = config['plugins'].run_event( - 'page_read_source', None, config=config, page=self) - if source is None: - try: - with io.open(self.abs_input_path, 'r', encoding='utf-8-sig') as f: - source = f.read() - except IOError: - log.error('File not found: %s', self.abs_input_path) - raise - - self.markdown, self.meta = meta.get_data(source) - - def _set_canonical_url(self, base): - if not base.endswith('/'): - base += '/' - self.canonical_url = utils.urljoin(base, self.abs_url.lstrip('/')) - - def _set_edit_url(self, repo_url, edit_uri): - # Normalize URL from Windows path '\\' -> '/' - input_path_url = self.input_path.replace('\\', '/') - self.edit_url = utils.urljoin(repo_url, edit_uri + input_path_url) - - def indent_print(self, depth=0): - indent = ' ' * depth - active_marker = ' [*]' if self.active else '' - title = self.title if (self.title is not None) else '[blank]' - return '%s%s - %s%s\n' % (indent, title, self.abs_url, active_marker) - - def set_active(self, active=True): - self.active = active - for ancestor in self.ancestors: - ancestor.set_active(active) - - def render(self, config, site_navigation=None): - """ - Convert the Markdown source file to HTML as per the config and - site_navigation. - - """ - - extensions = [ - RelativePathExtension(site_navigation, config['strict']) - ] + config['markdown_extensions'] - - md = markdown.Markdown( - extensions=extensions, - extension_configs=config['mdx_configs'] or {} - ) - self.content = md.convert(self.markdown) - self.toc = toc.TableOfContents(getattr(md, 'toc', '')) - - -class Header(object): - def __init__(self, title, children): - self.title, self.children = title, children - self.active = False - self.ancestors = [] - - def __str__(self): - return self.indent_print() - - @property - def is_top_level(self): - return len(self.ancestors) == 0 - - def indent_print(self, depth=0): - indent = ' ' * depth - active_marker = ' [*]' if self.active else '' - ret = '%s%s%s\n' % (indent, self.title, active_marker) - for item in self.children: - ret += item.indent_print(depth + 1) - return ret - - def set_active(self, active=True): - self.active = active - for ancestor in self.ancestors: - ancestor.set_active(active) - - -def _follow(config_line, url_context, config, header=None, title=None): - - if isinstance(config_line, utils.string_types): - path = os.path.normpath(config_line) - page = Page(title, path, url_context, config) - - if header: - page.ancestors = header.ancestors + [header, ] - header.children.append(page) - - yield page - raise StopIteration - - elif not isinstance(config_line, dict): - msg = ("Line in 'page' config is of type {0}, dict or string " - "expected. Config: {1}").format(type(config_line), config_line) - raise exceptions.ConfigurationError(msg) - - if len(config_line) > 1: - raise exceptions.ConfigurationError( - "Page configs should be in the format 'name: markdown.md'. The " - "config contains an invalid entry: {0}".format(config_line)) - elif len(config_line) == 0: - log.warning("Ignoring empty line in the pages config.") - raise StopIteration - - next_cat_or_title, subpages_or_path = next(iter(config_line.items())) - - if isinstance(subpages_or_path, utils.string_types): - path = subpages_or_path - for sub in _follow(path, url_context, config, header=header, title=next_cat_or_title): - yield sub - raise StopIteration - - elif not isinstance(subpages_or_path, list): - msg = ("Line in 'page' config is of type {0}, list or string " - "expected for sub pages. Config: {1}" - ).format(type(config_line), config_line) - raise exceptions.ConfigurationError(msg) - - next_header = Header(title=next_cat_or_title, children=[]) - if header: - next_header.ancestors = [header] - header.children.append(next_header) - yield next_header - - subpages = subpages_or_path - - for subpage in subpages: - for sub in _follow(subpage, url_context, config, next_header): - yield sub - - -def _generate_site_navigation(config, url_context): - """ - Returns a list of Page and Header instances that represent the - top level site navigation. - """ - nav_items = [] - pages = [] - - previous = None - - for config_line in config['pages']: - - for page_or_header in _follow( - config_line, url_context, config): - - if isinstance(page_or_header, Header): - - if page_or_header.is_top_level: - nav_items.append(page_or_header) - - elif isinstance(page_or_header, Page): - - if page_or_header.is_top_level: - nav_items.append(page_or_header) - - pages.append(page_or_header) - - if previous: - page_or_header.previous_page = previous - previous.next_page = page_or_header - previous = page_or_header - - if len(pages) == 0: - raise exceptions.ConfigurationError( - "No pages found in the pages config. " - "Remove it entirely to enable automatic page discovery.") - - return (nav_items, pages) diff --git a/mkdocs/plugins.py b/mkdocs/plugins.py index de4e837da5..08191b3f40 100644 --- a/mkdocs/plugins.py +++ b/mkdocs/plugins.py @@ -18,7 +18,7 @@ EVENTS = ( - 'config', 'pre_build', 'nav', 'env', 'pre_template', 'template_context', + 'config', 'pre_build', 'files', 'nav', 'env', 'pre_template', 'template_context', 'post_template', 'pre_page', 'page_read_source', 'page_markdown', 'page_content', 'page_context', 'post_page', 'post_build', 'serve' ) diff --git a/mkdocs/structure/__init__.py b/mkdocs/structure/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/mkdocs/structure/files.py b/mkdocs/structure/files.py new file mode 100644 index 0000000000..f7c8a9eee6 --- /dev/null +++ b/mkdocs/structure/files.py @@ -0,0 +1,266 @@ +# coding: utf-8 + +from __future__ import unicode_literals +import fnmatch +import os +import logging +from functools import cmp_to_key + +from mkdocs import utils + + +log = logging.getLogger(__name__) + + +class Files(object): + """ A collection of File objects. """ + def __init__(self, files): + self._files = files + self.src_paths = {file.src_path: file for file in files} + + def __iter__(self): + return iter(self._files) + + def __len__(self): + return len(self._files) + + def __contains__(self, path): + return path in self.src_paths + + def get_file_from_path(self, path): + """ Return a File instance with File.src_path equal to path. """ + return self.src_paths.get(os.path.normpath(path)) + + def append(self, file): + """ Append file to Files collection. """ + self._files.append(file) + self.src_paths[file.src_path] = file + + def copy_static_files(self, dirty=False): + """ Copy static files from source to destination. """ + for file in self: + if not file.is_documentation_page(): + file.copy_file(dirty) + + def documentation_pages(self): + """ Return iterable of all Markdown page file objects. """ + return [file for file in self if file.is_documentation_page()] + + def static_pages(self): + """ Return iterable of all static page file objects. """ + return [file for file in self if file.is_static_page()] + + def media_files(self): + """ Return iterable of all file objects which are not documentation or static pages. """ + return [file for file in self if file.is_media_file()] + + def javascript_files(self): + """ Return iterable of all javascript file objects. """ + return [file for file in self if file.is_javascript()] + + def css_files(self): + """ Return iterable of all CSS file objects. """ + return [file for file in self if file.is_css()] + + def add_files_from_theme(self, env, config): + """ Retrieve static files from Jinja environment and add to collection. """ + def filter(name): + patterns = ['.*', '*.py', '*.pyc', '*.html', 'mkdocs_theme.yml'] + patterns.extend(config['theme'].static_templates) + for pattern in patterns: + if fnmatch.fnmatch(name, pattern): + return False + return True + for path in env.list_templates(filter_func=filter): + for dir in config['theme'].dirs: + # Find the first theme dir which contains path + if os.path.isfile(os.path.join(dir, path)): + self.append(File(path, dir, config['site_dir'], config['use_directory_urls'])) + break + + +class File(object): + """ + A MkDocs File object. + + Points to the source and destination locations of a file. + + The `path` argument must be a path that exists relative to `src_dir`. + + The `src_dir` and `dest_dir` must be absolute paths on the local file system. + + The `use_directory_urls` argument controls how destination paths are generated. If `False`, a Markdown file is + mapped to an HTML file of the same name (the file extension is changed to `.html`). If True, a Markdown file is + mapped to an HTML index file (`index.html`) nested in a directory using the "name" of the file in `path`. The + `use_directory_urls` argument has no effect on non-Markdown files. + + File objects have the following properties, which are Unicode strings: + + File.src_path + The pure path of the source file relative to the source directory. + + File.abs_src_path + The absolute concrete path of the source file. + + File.dest_path + The pure path of the destination file relative to the destination directory. + + File.abs_dest_path + The absolute concrete path of the destination file. + + File.url + The url of the destination file relative to the destination directory as a string. + """ + def __init__(self, path, src_dir, dest_dir, use_directory_urls): + self.page = None + self.src_path = os.path.normpath(path) + self.abs_src_path = os.path.normpath(os.path.join(src_dir, self.src_path)) + self.name = self._get_stem() + self.dest_path = self._get_dest_path(use_directory_urls) + self.abs_dest_path = os.path.normpath(os.path.join(dest_dir, self.dest_path)) + self.url = self._get_url(use_directory_urls) + + def __eq__(self, other): + + def sub_dict(d): + return dict((key, value) for key, value in d.items() if key in ['src_path', 'abs_src_path', 'url']) + + return (isinstance(other, self.__class__) and sub_dict(self.__dict__) == sub_dict(other.__dict__)) + + def __ne__(self, other): + return not self.__eq__(other) + + def _get_stem(self): + """ Return the name of the file without it's extension. """ + filename = os.path.basename(self.src_path) + stem, ext = os.path.splitext(filename) + return 'index' if stem in ('index', 'README') else stem + + def _get_dest_path(self, use_directory_urls): + """ Return destination path based on source path. """ + if self.is_documentation_page(): + if use_directory_urls: + parent, filename = os.path.split(self.src_path) + if self.name == 'index': + # index.md or README.md => index.html + return os.path.join(parent, 'index.html') + else: + # foo.md => foo/index.html + return os.path.join(parent, self.name, 'index.html') + else: + # foo.md => foo.html + root, ext = os.path.splitext(self.src_path) + return root + '.html' + return self.src_path + + def _get_url(self, use_directory_urls): + """ Return url based in destination path. """ + url = self.dest_path.replace(os.path.sep, '/') + dirname, filename = os.path.split(url) + if use_directory_urls and filename == 'index.html': + if dirname == '': + url = '.' + else: + url = dirname + '/' + return url + + def url_relative_to(self, other): + """ Return url for file relative to other file. """ + return utils.get_relative_url(self.url, other.url if isinstance(other, File) else other) + + def copy_file(self, dirty=False): + """ Copy source file to destination, ensuring parent directories exist. """ + if dirty and not self.is_modified(): + log.debug("Skip copying unmodified file: '{}'".format(self.src_path)) + else: + log.debug("Copying media file: '{}'".format(self.src_path)) + utils.copy_file(self.abs_src_path, self.abs_dest_path) + + def is_modified(self): + if os.path.isfile(self.abs_dest_path): + return os.path.getmtime(self.abs_dest_path) < os.path.getmtime(self.abs_src_path) + return True + + def is_documentation_page(self): + """ Return True if file is a Markdown page. """ + return os.path.splitext(self.src_path)[1] in utils.markdown_extensions + + def is_static_page(self): + """ Return True if file is a static page (html, xml, json). """ + return os.path.splitext(self.src_path)[1] in ( + '.html', + '.htm', + '.xml', + '.json', + ) + + def is_media_file(self): + """ Return True if file is not a documentation or static page. """ + return not (self.is_documentation_page() or self.is_static_page()) + + def is_javascript(self): + """ Return True if file is a JavaScript file. """ + return os.path.splitext(self.src_path)[1] in ( + '.js', + '.javascript', + ) + + def is_css(self): + """ Return True if file is a CSS file. """ + return os.path.splitext(self.src_path)[1] in ( + '.css', + ) + + +def get_files(config): + """ Walk the `docs_dir` and return a Files collection. """ + files = [] + exclude = ['.*', '/templates'] + + for source_dir, dirnames, filenames in os.walk(config['docs_dir'], followlinks=True): + relative_dir = os.path.relpath(source_dir, config['docs_dir']) + + for dirname in list(dirnames): + path = os.path.normpath(os.path.join(relative_dir, dirname)) + # Skip any excluded directories + if _filter_paths(basename=dirname, path=path, is_dir=True, exclude=exclude): + dirnames.remove(dirname) + dirnames.sort() + + for filename in _sort_files(filenames): + path = os.path.normpath(os.path.join(relative_dir, filename)) + # Skip any excluded files + if _filter_paths(basename=filename, path=path, is_dir=False, exclude=exclude): + continue + files.append(File(path, config['docs_dir'], config['site_dir'], config['use_directory_urls'])) + + return Files(files) + + +def _sort_files(filenames): + """ Always sort `index` as first filename in list. """ + + def compare(x, y): + if x == y: + return 0 + if os.path.splitext(y)[0] == 'index': + return 1 + if os.path.splitext(x)[0] == 'index' or x < y: + return -1 + return 1 + + return sorted(filenames, key=cmp_to_key(compare)) + + +def _filter_paths(basename, path, is_dir, exclude): + """ .gitignore style file filtering. """ + for item in exclude: + # Items ending in '/' apply only to directories. + if item.endswith('/') and not is_dir: + continue + # Items starting with '/' apply to the whole path. + # In any other cases just the basename is used. + match = path if item.startswith('/') else basename + if fnmatch.fnmatch(match, item.strip('/')): + return True + return False diff --git a/mkdocs/structure/nav.py b/mkdocs/structure/nav.py new file mode 100644 index 0000000000..782f51a6c0 --- /dev/null +++ b/mkdocs/structure/nav.py @@ -0,0 +1,182 @@ +# coding: utf-8 + +from __future__ import unicode_literals +import logging + +from mkdocs.structure.pages import Page +from mkdocs.utils import string_types, nest_paths + +log = logging.getLogger(__name__) + + +class Navigation(object): + def __init__(self, items, pages): + self.items = items # Nested List with full navigation of Sections, Pages, and Links. + self.pages = pages # Flat List of subset of Pages in nav, in order. + + self.homepage = None + for page in pages: + if page.is_homepage: + self.homepage = page + break + + def __repr__(self): + return '\n'.join([item._indent_print() for item in self]) + + def __iter__(self): + return iter(self.items) + + def __len__(self): + return len(self.items) + + +class Section(object): + def __init__(self, title, children): + self.title = title + self.children = children + + self.parent = None + self.active = False + + self.is_section = True + self.is_page = False + self.is_link = False + + def __repr__(self): + return "Section(title='{0}')".format(self.title) + + def _get_active(self): + """ Return active status of section. """ + return self.__active + + def _set_active(self, value): + """ Set active status of section and ancestors. """ + self.__active = bool(value) + if self.parent is not None: + self.parent.active = bool(value) + + active = property(_get_active, _set_active) + + @property + def ancestors(self): + if self.parent is None: + return [] + return [self.parent] + self.parent.ancestors + + def _indent_print(self, depth=0): + ret = ['{}{}'.format(' ' * depth, repr(self))] + for item in self.children: + ret.append(item._indent_print(depth + 1)) + return '\n'.join(ret) + + +class Link(object): + def __init__(self, title, url): + self.title = title + self.url = url + self.parent = None + + # These should never change but are included for consistency with sections and pages. + self.children = None + self.active = False + self.is_section = False + self.is_page = False + self.is_link = True + + def __repr__(self): + title = "'{}'".format(self.title) if (self.title is not None) else '[blank]' + return "Link(title={}, url='{}')".format(title, self.url) + + @property + def ancestors(self): + if self.parent is None: + return [] + return [self.parent] + self.parent.ancestors + + def _indent_print(self, depth=0): + return '{}{}'.format(' ' * depth, repr(self)) + + +def get_navigation(files, config): + """ Build site navigation from config and files.""" + nav_config = config['nav'] or nest_paths(f.src_path for f in files.documentation_pages()) + items = _data_to_navigation(nav_config, files, config) + if not isinstance(items, list): + items = [items] + + # Get only the pages from the navigation, ignoring any sections and links. + pages = _get_by_type(items, Page) + + # Include next, previous and parent links. + _add_previous_and_next_links(pages) + _add_parent_links(items) + + missing_from_config = [file for file in files.documentation_pages() if file.page is None] + if missing_from_config: + log.info( + 'The following pages exist in the docs directory, but are not ' + 'included in the "nav" configuration:\n - {}'.format( + '\n - '.join([file.src_path for file in missing_from_config])) + ) + # Any documentation files not found in the nav should still have an associated page. + # However, these page objects are only accessable from File instances as `file.page`. + for file in missing_from_config: + Page(None, file, config) + + links = _get_by_type(items, Link) + if links: + # Assume all links are external. + # TODO: warn or error on internal links? + log.info( + 'The following paths are included in the "nav" configuration, ' + 'but do not exist in the docs directory:\n - {}'.format( + '\n - '.join([link.url for link in links])) + ) + return Navigation(items, pages) + + +def _data_to_navigation(data, files, config): + if isinstance(data, dict): + return [ + _data_to_navigation((key, value), files, config) + if isinstance(value, string_types) else + Section(title=key, children=_data_to_navigation(value, files, config)) + for key, value in data.items() + ] + elif isinstance(data, list): + return [ + _data_to_navigation(item, files, config)[0] + if isinstance(item, dict) and len(item) == 1 else + _data_to_navigation(item, files, config) + for item in data + ] + title, path = data if isinstance(data, tuple) else (None, data) + file = files.get_file_from_path(path) + if file: + return Page(title, file, config) + return Link(title, path) + + +def _get_by_type(nav, T): + ret = [] + for item in nav: + if isinstance(item, T): + ret.append(item) + elif item.children: + ret.extend(_get_by_type(item.children, T)) + return ret + + +def _add_parent_links(nav): + for item in nav: + if item.is_section: + for child in item.children: + child.parent = item + _add_parent_links(item.children) + + +def _add_previous_and_next_links(pages): + bookended = [None] + pages + [None] + zipped = zip(bookended[:-2], bookended[1:-1], bookended[2:]) + for page0, page1, page2 in zipped: + page1.previous_page, page1.next_page = page0, page2 diff --git a/mkdocs/structure/pages.py b/mkdocs/structure/pages.py new file mode 100644 index 0000000000..77086ed71d --- /dev/null +++ b/mkdocs/structure/pages.py @@ -0,0 +1,266 @@ +# coding: utf-8 + +from __future__ import unicode_literals + +import os +import io +import datetime +import logging + +import markdown +from markdown.extensions import Extension +from markdown.treeprocessors import Treeprocessor +from markdown.util import AMP_SUBSTITUTE + +from mkdocs.structure.toc import get_toc +from mkdocs.utils import meta, urlparse, urlunparse, urljoin, get_markdown_title +from mkdocs.exceptions import MarkdownNotFound + +log = logging.getLogger(__name__) + + +@meta.transformer() +def default(value): + """ By default, return all meta values as strings. """ + return ' '.join(value) + + +class Page(object): + def __init__(self, title, file, config): + file.page = self + self.file = file + self.title = title + + # Navigation attributes + self.parent = None + self.children = None + self.previous_page = None + self.next_page = None + self.active = False + + self.is_section = False + self.is_page = True + self.is_link = False + + # Support SOURCE_DATE_EPOCH environment variable for "reproducible" builds. + # See https://reproducible-builds.org/specs/source-date-epoch/ + if 'SOURCE_DATE_EPOCH' in os.environ: + self.update_date = datetime.datetime.utcfromtimestamp( + int(os.environ['SOURCE_DATE_EPOCH']) + ).strftime("%Y-%m-%d") + else: + self.update_date = datetime.datetime.now().strftime("%Y-%m-%d") + + self._set_canonical_url(config.get('site_url', None)) + self._set_edit_url(config.get('repo_url', None), config.get('edit_uri', None)) + + # Placeholders to be filled in later in the build process. + self.markdown = None + self.content = None + self.toc = [] + self.meta = {} + + def __eq__(self, other): + + def sub_dict(d): + return dict((key, value) for key, value in d.items() if key in ['title', 'file']) + + return (isinstance(other, self.__class__) and sub_dict(self.__dict__) == sub_dict(other.__dict__)) + + def __ne__(self, other): + return not self.__eq__(other) + + def __repr__(self): + title = "'{}'".format(self.title) if (self.title is not None) else '[blank]' + return "Page(title={}, url='{}')".format(title, self.abs_url or self.file.url) + + def _indent_print(self, depth=0): + return '{}{}'.format(' ' * depth, repr(self)) + + def _get_active(self): + """ Return active status of page. """ + return self.__active + + def _set_active(self, value): + """ Set active status of page and ancestors. """ + self.__active = bool(value) + if self.parent is not None: + self.parent.active = bool(value) + + active = property(_get_active, _set_active) + + @property + def is_index(self): + return self.file.name == 'index' + + @property + def is_top_level(self): + return self.parent is None + + @property + def is_homepage(self): + return self.is_top_level and self.is_index + + @property + def url(self): + return '' if self.file.url == '.' else self.file.url + + @property + def ancestors(self): + if self.parent is None: + return [] + return [self.parent] + self.parent.ancestors + + def _set_canonical_url(self, base): + if base: + if not base.endswith('/'): + base += '/' + self.canonical_url = urljoin(base, self.url) + self.abs_url = urlparse(self.canonical_url).path + else: + self.canonical_url = None + self.abs_url = None + + def _set_edit_url(self, repo_url, edit_uri): + if repo_url and edit_uri: + src_path = self.file.src_path.replace('\\', '/') + self.edit_url = urljoin(repo_url, edit_uri + src_path) + else: + self.edit_url = None + + def read_source(self, config): + source = config['plugins'].run_event('page_read_source', None, config=config, page=self) + if source is None: + try: + with io.open(self.file.abs_src_path, 'r', encoding='utf-8-sig', errors='strict') as f: + source = f.read() + except IOError: + log.error('File not found: {}'.format(self.file.src_path)) + raise + except ValueError: + log.error('Encoding error reading file: {}'.format(self.file.src_path)) + raise + + self.markdown, self.meta = meta.get_data(source) + self._set_title() + + def _set_title(self): + """ + Set the title for a Markdown document. + + Check these in order and use the first that returns a valid title: + - value provided on init (passed in from config) + - value of metadata 'title' + - content of the first H1 in Markdown content + - convert filename to title + """ + if self.title is not None: + return + + if 'title' in self.meta: + self.title = self.meta['title'] + return + + title = get_markdown_title(self.markdown) + + if title is None: + if self.is_homepage: + title = 'Home' + else: + title = self.file.name.replace('-', ' ').replace('_', ' ') + # Capitalize if the filename was all lowercase, otherwise leave it as-is. + if title.lower() == title: + title = title.capitalize() + + self.title = title + + def render(self, config, files): + """ + Convert the Markdown source file to HTML as per the config. + """ + + extensions = [ + _RelativePathExtension(self.file, files, config['strict']) + ] + config['markdown_extensions'] + + md = markdown.Markdown( + extensions=extensions, + extension_configs=config['mdx_configs'] or {} + ) + self.content = md.convert(self.markdown) + self.toc = get_toc(getattr(md, 'toc', '')) + + +class _RelativePathTreeprocessor(Treeprocessor): + def __init__(self, file, files, strict): + self.file = file + self.files = files + self.strict = strict + + def run(self, root): + """ + Update urls on anchors and images to make them relative + + Iterates through the full document tree looking for specific + tags and then makes them relative based on the site navigation + """ + for element in root.iter(): + if element.tag == 'a': + key = 'href' + elif element.tag == 'img': + key = 'src' + else: + continue + + url = element.get(key) + new_url = self.path_to_url(url) + element.set(key, new_url) + + return root + + def path_to_url(self, url): + scheme, netloc, path, params, query, fragment = urlparse(url) + + if scheme or netloc or not path or AMP_SUBSTITUTE in url or '.' not in os.path.split(path)[-1]: + # Ignore URLs unless they are a relative link to a source file. + # AMP_SUBSTITUTE is used internally by Markdown only for email. + # No '.' in the last part of a path indicates path does not point to a file. + return url + + # Determine the filepath of the target. + target_path = os.path.join(os.path.dirname(self.file.src_path), path) + target_path = os.path.normpath(target_path).lstrip(os.sep) + + # Validate that the target exists in files collection. + if target_path not in self.files: + msg = ( + "Documentation file '{}' contains a link to '{}' which does not exist " + "in the documentation directory.".format(self.file.src_path, target_path) + ) + # In strict mode raise an error at this point. + if self.strict: + raise MarkdownNotFound(msg) + # Otherwise, when strict mode isn't enabled, log a warning + # to the user and leave the URL as it is. + log.warning(msg) + return url + target_file = self.files.get_file_from_path(target_path) + path = target_file.url_relative_to(self.file) + components = (scheme, netloc, path, params, query, fragment) + return urlunparse(components) + + +class _RelativePathExtension(Extension): + """ + The Extension class is what we pass to markdown, it then + registers the Treeprocessor. + """ + + def __init__(self, file, files, strict): + self.file = file + self.files = files + self.strict = strict + + def extendMarkdown(self, md, md_globals): + relpath = _RelativePathTreeprocessor(self.file, self.files, self.strict) + md.treeprocessors.add("relpath", relpath, "_end") diff --git a/mkdocs/structure/toc.py b/mkdocs/structure/toc.py new file mode 100644 index 0000000000..2cd2cf6409 --- /dev/null +++ b/mkdocs/structure/toc.py @@ -0,0 +1,131 @@ +# coding: utf-8 +""" +Deals with generating the per-page table of contents. + +For the sake of simplicity we use an existing markdown extension to generate +an HTML table of contents, and then parse that into the underlying data. +""" +from __future__ import unicode_literals + +try: # pragma: no cover + from html.parser import HTMLParser # noqa +except ImportError: # pragma: no cover + from HTMLParser import HTMLParser # noqa + + +def get_toc(toc_html): + items = _parse_html_table_of_contents(toc_html) + return TableOfContents(items) + + +class TableOfContents(object): + """ + Represents the table of contents for a given page. + """ + def __init__(self, items): + self.items = items + + def __iter__(self): + return iter(self.items) + + def __len__(self): + return len(self.items) + + def __str__(self): + return ''.join([str(item) for item in self]) + + +class AnchorLink(object): + """ + A single entry in the table of contents. + """ + def __init__(self, title, url): + self.title, self.url = title, url + self.children = [] + + def __str__(self): + return self.indent_print() + + def indent_print(self, depth=0): + indent = ' ' * depth + ret = '%s%s - %s\n' % (indent, self.title, self.url) + for item in self.children: + ret += item.indent_print(depth + 1) + return ret + + +class _TOCParser(HTMLParser): + def __init__(self): + HTMLParser.__init__(self) + self.links = [] + + self.in_anchor = False + self.attrs = None + self.title = '' + + # Prior to Python3.4 no convert_charrefs keyword existed. + # However, in Python3.5 the default was changed to True. + # We need the False behavior in all versions but can only + # set it if it exists. + if hasattr(self, 'convert_charrefs'): # pragma: no cover + self.convert_charrefs = False + + def handle_starttag(self, tag, attrs): + if not self.in_anchor: + if tag == 'a': + self.in_anchor = True + self.attrs = dict(attrs) + + def handle_endtag(self, tag): + if tag == 'a': + self.in_anchor = False + + def handle_data(self, data): + if self.in_anchor: + self.title += data + + def handle_charref(self, ref): + self.handle_entityref("#" + ref) + + def handle_entityref(self, ref): + self.handle_data("&%s;" % ref) + + +def _parse_html_table_of_contents(html): + """ + Given a table of contents string that has been automatically generated by + the markdown library, parse it into a tree of AnchorLink instances. + + Returns a list of all the parent AnchorLink instances. + """ + lines = html.splitlines()[2:-2] + parents = [] + ret = [] + for line in lines: + parser = _TOCParser() + parser.feed(line) + if parser.title: + try: + href = parser.attrs['href'] + except KeyError: + continue + title = parser.title + nav = AnchorLink(title, href) + # Add the item to its parent if required. If it is a topmost + # item then instead append it to our return value. + if parents: + parents[-1].children.append(nav) + else: + ret.append(nav) + # If this item has children, store it as the current parent + if line.endswith(''): + if parents: + parents.pop() + + # For the table of contents, always mark the first element as active + if ret: + ret[0].active = True + + return ret diff --git a/mkdocs/tests/base.py b/mkdocs/tests/base.py index a17fc2e072..f975c42a30 100644 --- a/mkdocs/tests/base.py +++ b/mkdocs/tests/base.py @@ -5,9 +5,15 @@ import logging import collections import unittest +from functools import wraps + +try: + # py>=3.2 + from tempfile import TemporaryDirectory +except ImportError: + from backports.tempfile import TemporaryDirectory -from mkdocs import toc from mkdocs import config from mkdocs import utils @@ -16,11 +22,11 @@ def dedent(text): return textwrap.dedent(text).strip() -def markdown_to_toc(markdown_source): +def get_markdown_toc(markdown_source): + """ Return TOC generated by Markdown parser from Markdown source text. """ md = markdown.Markdown(extensions=['toc']) md.convert(markdown_source) - toc_output = md.toc - return toc.TableOfContents(toc_output) + return md.toc def load_config(**cfg): @@ -44,6 +50,95 @@ def load_config(**cfg): return conf +def tempdir(files=None, **kw): + """ + A decorator for building a temporary directory with prepopulated files. + + The temproary directory and files are created just before the wrapped function is called and are destroyed + imediately after the wrapped function returns. + + The `files` keyword should be a dict of file paths as keys and strings of file content as values. + If `files` is a list, then each item is assumed to be a path of an empty file. All other + keywords are passed to `tempfile.TemporaryDirectory` to create the parent directory. + + In the following example, two files are created in the temporary directory and then are destroyed when + the function exits: + + @tempdir(files={ + 'foo.txt': 'foo content', + 'bar.txt': 'bar content' + }) + def example(self, tdir): + assert os.path.isfile(os.path.join(tdir, 'foo.txt')) + pth = os.path.join(tdir, 'bar.txt') + assert os.path.isfile(pth) + with io.open(pth, 'r', encoding='utf-8') as f: + assert f.read() == 'bar content' + """ + files = {f: '' for f in files} if isinstance(files, (list, tuple)) else files or {} + + if 'prefix' not in kw: + kw['prefix'] = 'mkdocs_test-' + + def decorator(fn): + @wraps(fn) + def wrapper(self, *args): + with TemporaryDirectory(**kw) as td: + for path, content in files.items(): + pth = os.path.join(td, path) + utils.write_file(content.encode(encoding='utf-8'), pth) + return fn(self, td, *args) + return wrapper + return decorator + + +class PathAssertionMixin(object): + """ + Assertion methods for testing paths. + + Each method accepts one or more strings, which are first joined using os.path.join. + """ + + def assertPathsEqual(self, a, b, msg=None): + self.assertEqual(a.replace('\\', '/'), b.replace('\\', '/')) + + def assertPathExists(self, *parts): + path = os.path.join(*parts) + if not os.path.exists(path): + msg = self._formatMessage(None, "The path '{}' does not exist".format(path)) + raise self.failureException(msg) + + def assertPathNotExists(self, *parts): + path = os.path.join(*parts) + if os.path.exists(path): + msg = self._formatMessage(None, "The path '{}' does exist".format(path)) + raise self.failureException(msg) + + def assertPathIsFile(self, *parts): + path = os.path.join(*parts) + if not os.path.isfile(path): + msg = self._formatMessage(None, "The path '{}' is not a file that exists".format(path)) + raise self.failureException(msg) + + def assertPathNotFile(self, *parts): + path = os.path.join(*parts) + if os.path.isfile(path): + msg = self._formatMessage(None, "The path '{}' is a file that exists".format(path)) + raise self.failureException(msg) + + def assertPathIsDir(self, *parts): + path = os.path.join(*parts) + if not os.path.isdir(path): + msg = self._formatMessage(None, "The path '{}' is not a directory that exists".format(path)) + raise self.failureException(msg) + + def assertPathNotDir(self, *parts): + path = os.path.join(*parts) + if os.path.isfile(path): + msg = self._formatMessage(None, "The path '{}' is a directory that exists".format(path)) + raise self.failureException(msg) + + # Backport unittest.TestCase.assertLogs for Python 2.7 # see https://github.com/python/cpython/blob/3.6/Lib/unittest/case.py diff --git a/mkdocs/tests/build_tests.py b/mkdocs/tests/build_tests.py index 32b86206c3..a85693bcbd 100644 --- a/mkdocs/tests/build_tests.py +++ b/mkdocs/tests/build_tests.py @@ -2,482 +2,472 @@ # coding: utf-8 from __future__ import unicode_literals -import os -import unittest import mock -import io -try: - from itertools import izip as zip -except ImportError: - # In Py3 use builtin zip function - pass - -try: - # py>=3.2 - from tempfile import TemporaryDirectory -except ImportError: - from backports.tempfile import TemporaryDirectory - -from mkdocs import nav +from mkdocs.structure.pages import Page +from mkdocs.structure.files import File, Files +from mkdocs.structure.nav import get_navigation from mkdocs.commands import build -from mkdocs.exceptions import MarkdownNotFound -from mkdocs.tests.base import dedent, load_config +from mkdocs.tests.base import load_config, LogTestCase, tempdir, PathAssertionMixin from mkdocs.utils import meta -def build_page(title, path, config, md_src=None): +def build_page(title, path, config, md_src=''): """ Helper which returns a Page object. """ - sitenav = nav.SiteNavigation(config) - page = nav.Page(title, path, sitenav.url_context, config) - if md_src: - # Fake page.read_source() - page.markdown, page.meta = meta.get_data(md_src) - return page, sitenav - - -class BuildTests(unittest.TestCase): - - def test_empty_document(self): - config = load_config(pages=[{'Home': 'index.md'}]) - page, nav = build_page(None, 'index.md', config) - page.render(config, nav) - - self.assertEqual(page.content, '') - self.assertEqual(len(list(page.toc)), 0) - self.assertEqual(page.meta, {}) - self.assertEqual(page.title, 'Home') - - def test_convert_markdown(self): - """ - Ensure that basic Markdown -> HTML and TOC works. - """ - md_text = dedent(""" - title: custom title - - # Heading 1 - - This is some text. - - # Heading 2 - - And some more text. - """) - - config = load_config(pages=[{'Home': 'index.md'}]) - page, nav = build_page(None, 'index.md', config, md_text) - page.render(config, nav) - - expected_html = dedent(""" -

    Heading 1

    -

    This is some text.

    -

    Heading 2

    -

    And some more text.

    - """) - - expected_toc = dedent(""" - Heading 1 - #heading-1 - Heading 2 - #heading-2 - """) - - expected_meta = {'title': 'custom title'} - - self.assertEqual(page.content.strip(), expected_html) - self.assertEqual(str(page.toc).strip(), expected_toc) - self.assertEqual(page.meta, expected_meta) - self.assertEqual(page.title, 'custom title') - - def test_convert_internal_link(self): - md_text = 'An [internal link](internal.md) to another document.' - expected = '

    An internal link to another document.

    ' - config = load_config(pages=['index.md', 'internal.md']) - page, nav = build_page(None, 'index.md', config, md_text) - page.render(config, nav) - self.assertEqual(page.content.strip(), expected.strip()) - - def test_convert_multiple_internal_links(self): - md_text = '[First link](first.md) [second link](second.md).' - expected = '

    First link second link.

    ' - config = load_config(pages=['index.md', 'first.md', 'second.md']) - page, nav = build_page(None, 'index.md', config, md_text) - page.render(config, nav) - self.assertEqual(page.content.strip(), expected.strip()) - - def test_convert_internal_link_differing_directory(self): - md_text = 'An [internal link](../internal.md) to another document.' - expected = '

    An internal link to another document.

    ' - config = load_config(pages=['foo/bar.md', 'internal.md']) - page, nav = build_page(None, 'foo/bar.md', config, md_text) - page.render(config) - self.assertEqual(page.content.strip(), expected.strip()) - - def test_convert_internal_link_with_anchor(self): - md_text = 'An [internal link](internal.md#section1.1) to another document.' - expected = '

    An internal link to another document.

    ' - config = load_config(pages=['index.md', 'internal.md']) - page, nav = build_page(None, 'index.md', config, md_text) - page.render(config, nav) - self.assertEqual(page.content.strip(), expected.strip()) - - def test_convert_internal_media(self): - """Test relative image URL's are the same for different base_urls""" - pages = [ - 'index.md', - 'internal.md', - 'sub/internal.md', - ] - - config = load_config(pages=pages) - site_navigation = nav.SiteNavigation(config) + files = Files([File(path, config['docs_dir'], config['site_dir'], config['use_directory_urls'])]) + page = Page(title, list(files)[0], config) + # Fake page.read_source() + page.markdown, page.meta = meta.get_data(md_src) + return page, files - expected_results = ( - './img/initial-layout.png', - '../img/initial-layout.png', - '../img/initial-layout.png', - ) - template = '

    The initial MkDocs layout

    ' +class BuildTests(PathAssertionMixin, LogTestCase): - for (page, expected) in zip(site_navigation.walk_pages(), expected_results): - page.markdown = '![The initial MkDocs layout](img/initial-layout.png)' - page.render(config, site_navigation) - self.assertEqual(page.content, template % expected) + # Test build.get_context - def test_convert_internal_asbolute_media(self): - """Test absolute image URL's are correct for different base_urls""" - pages = [ - 'index.md', - 'internal.md', - 'sub/internal.md', + def test_context_base_url_homepage(self): + nav_cfg = [ + {'Home': 'index.md'} ] + cfg = load_config(nav=nav_cfg, use_directory_urls=False) + files = Files([ + File('index.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']), + ]) + nav = get_navigation(files, cfg) + context = build.get_context(nav, files, cfg, nav.pages[0]) + self.assertEqual(context['base_url'], '.') - config = load_config(pages=pages) - site_navigation = nav.SiteNavigation(config) - - expected_results = ( - './img/initial-layout.png', - '../img/initial-layout.png', - '../../img/initial-layout.png', - ) - - template = '

    The initial MkDocs layout

    ' - - for (page, expected) in zip(site_navigation.walk_pages(), expected_results): - page.markdown = '![The initial MkDocs layout](/img/initial-layout.png)' - page.render(config, site_navigation) - self.assertEqual(page.content, template % expected) - - def test_dont_convert_code_block_urls(self): - pages = [ - 'index.md', - 'internal.md', - 'sub/internal.md', + def test_context_base_url_homepage_use_directory_urls(self): + nav_cfg = [ + {'Home': 'index.md'} ] - - config = load_config(pages=pages) - site_navigation = nav.SiteNavigation(config) - - expected = dedent(""" -

    An HTML Anchor::

    -
    <a href="index.md">My example link</a>
    -        
    - """) - - for page in site_navigation.walk_pages(): - page.markdown = 'An HTML Anchor::\n\n My example link\n' - page.render(config, site_navigation) - self.assertEqual(page.content, expected) - - def test_anchor_only_link(self): - pages = [ - 'index.md', - 'internal.md', - 'sub/internal.md', + cfg = load_config(nav=nav_cfg) + files = Files([ + File('index.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']), + ]) + nav = get_navigation(files, cfg) + context = build.get_context(nav, files, cfg, nav.pages[0]) + self.assertEqual(context['base_url'], '.') + + def test_context_base_url_nested_page(self): + nav_cfg = [ + {'Home': 'index.md'}, + {'Nested': 'foo/bar.md'} ] - - config = load_config(pages=pages) - site_navigation = nav.SiteNavigation(config) - - for page in site_navigation.walk_pages(): - page.markdown = '[test](#test)' - page.render(config, site_navigation) - self.assertEqual(page.content, '

    test

    ') - - def test_ignore_external_link(self): - md_text = 'An [external link](http://example.com/external.md).' - expected = '

    An external link.

    ' - config = load_config(pages=[{'Home': 'index.md'}]) - page, nav = build_page(None, 'index.md', config, md_text) - page.render(config, nav) - self.assertEqual(page.content.strip(), expected.strip()) - - def test_not_use_directory_urls(self): - md_text = 'An [internal link](internal.md) to another document.' - expected = '

    An internal link to another document.

    ' - config = load_config(pages=['index.md', 'internal.md'], use_directory_urls=False) - page, nav = build_page(None, 'index.md', config, md_text) - page.render(config, nav) - self.assertEqual(page.content.strip(), expected.strip()) - - def test_ignore_email_links(self): - md_text = 'A and an [link](mailto:example@example.com).' - expected = ''.join([ - '

    A autolin', - 'k@example.com', - ' and an link.

    ' + cfg = load_config(nav=nav_cfg, use_directory_urls=False) + files = Files([ + File('index.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']), + File('foo/bar.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']) ]) - config = load_config(pages=[{'Home': 'index.md'}]) - page, nav = build_page(None, 'index.md', config, md_text) - page.render(config, nav) - self.assertEqual(page.content.strip(), expected.strip()) - - def test_markdown_table_extension(self): - """ - Ensure that the table extension is supported. - """ - md_text = dedent(""" - First Header | Second Header - -------------- | -------------- - Content Cell 1 | Content Cell 2 - Content Cell 3 | Content Cell 4 - """) - - expected_html = dedent(""" - - - - - - - - - - - - - - - - - -
    First HeaderSecond Header
    Content Cell 1Content Cell 2
    Content Cell 3Content Cell 4
    - """) - - config = load_config(pages=[{'Home': 'index.md'}]) - page, nav = build_page(None, 'index.md', config, md_text) - page.render(config, nav) - self.assertEqual(page.content.strip(), expected_html) - - def test_markdown_fenced_code_extension(self): - """ - Ensure that the fenced code extension is supported. - """ - md_text = dedent(""" - ``` - print 'foo' - ``` - """) - - expected_html = dedent(""" -
    print 'foo'\n
    - """) - - config = load_config(pages=[{'Home': 'index.md'}]) - page, nav = build_page(None, 'index.md', config, md_text) - page.render(config, nav) - self.assertEqual(page.content.strip(), expected_html) - - def test_markdown_custom_extension(self): - """ - Check that an extension applies when requested in the arguments to - `convert_markdown`. - """ - md_text = "foo__bar__baz" - - # Check that the plugin is not active when not requested. - expected_without_smartstrong = "

    foobarbaz

    " - config = load_config(pages=[{'Home': 'index.md'}]) - page, nav = build_page(None, 'index.md', config, md_text) - page.render(config, nav) - self.assertEqual(page.content.strip(), expected_without_smartstrong) - - # Check that the plugin is active when requested. - expected_with_smartstrong = "

    foo__bar__baz

    " - config = load_config(pages=[{'Home': 'index.md'}], markdown_extensions=['smart_strong']) - page, nav = build_page(None, 'index.md', config, md_text) - page.render(config, nav) - self.assertEqual(page.content.strip(), expected_with_smartstrong) - - def test_markdown_duplicate_custom_extension(self): - """ - Duplicated extension names should not cause problems. - """ - md_text = "foo" - config = load_config(pages=[{'Home': 'index.md'}], markdown_extensions=['toc']) - page, nav = build_page(None, 'index.md', config, md_text) - page.render(config, nav) - self.assertEqual(page.content.strip(), '

    foo

    ') - - def test_copying_media(self): - with TemporaryDirectory() as docs_dir, TemporaryDirectory() as site_dir: - # Create a non-empty markdown file, image, html file, dot file and dot directory. - f = open(os.path.join(docs_dir, 'index.md'), 'w') - f.write(dedent(""" - page_title: custom title - - # Heading 1 - - This is some text. - - # Heading 2 - - And some more text. - """)) - f.close() - open(os.path.join(docs_dir, 'img.jpg'), 'w').close() - open(os.path.join(docs_dir, 'example.html'), 'w').close() - open(os.path.join(docs_dir, '.hidden'), 'w').close() - os.mkdir(os.path.join(docs_dir, '.git')) - open(os.path.join(docs_dir, '.git/hidden'), 'w').close() - - cfg = load_config(docs_dir=docs_dir, site_dir=site_dir) - build.build(cfg) - - # Verify only the markdown (coverted to html) and the image are copied. - self.assertTrue(os.path.isfile(os.path.join(site_dir, 'index.html'))) - self.assertTrue(os.path.isfile(os.path.join(site_dir, 'img.jpg'))) - self.assertTrue(os.path.isfile(os.path.join(site_dir, 'example.html'))) - self.assertFalse(os.path.isfile(os.path.join(site_dir, '.hidden'))) - self.assertFalse(os.path.isfile(os.path.join(site_dir, '.git/hidden'))) - - def test_copy_theme_files(self): - with TemporaryDirectory() as docs_dir, TemporaryDirectory() as site_dir: - # Create a non-empty markdown file. - f = open(os.path.join(docs_dir, 'index.md'), 'w') - f.write(dedent(""" - page_title: custom title - - # Heading 1 - - This is some text. - """)) - f.close() - - cfg = load_config(docs_dir=docs_dir, site_dir=site_dir) - build.build(cfg) - - # Verify only theme media are copied, not templates or Python files. - self.assertTrue(os.path.isfile(os.path.join(site_dir, 'index.html'))) - self.assertTrue(os.path.isdir(os.path.join(site_dir, 'js'))) - self.assertTrue(os.path.isdir(os.path.join(site_dir, 'css'))) - self.assertTrue(os.path.isdir(os.path.join(site_dir, 'img'))) - self.assertFalse(os.path.isfile(os.path.join(site_dir, '__init__.py'))) - self.assertFalse(os.path.isfile(os.path.join(site_dir, '__init__.pyc'))) - self.assertFalse(os.path.isfile(os.path.join(site_dir, 'base.html'))) - self.assertFalse(os.path.isfile(os.path.join(site_dir, 'content.html'))) - self.assertFalse(os.path.isfile(os.path.join(site_dir, 'nav.html'))) - - def test_strict_mode_valid(self): - pages = [ - 'index.md', - 'internal.md', - 'sub/internal.md', + nav = get_navigation(files, cfg) + context = build.get_context(nav, files, cfg, nav.pages[1]) + self.assertEqual(context['base_url'], '..') + + def test_context_base_url_nested_page_use_directory_urls(self): + nav_cfg = [ + {'Home': 'index.md'}, + {'Nested': 'foo/bar.md'} ] - - md_text = "[test](internal.md)" - - config = load_config(pages=pages, strict=False) - page, nav = build_page(None, 'index.md', config, md_text) - page.render(config, nav) - - config = load_config(pages=pages, strict=True) - page, nav = build_page(None, 'index.md', config, md_text) - page.render(config, nav) - - def test_strict_mode_invalid(self): - pages = [ - 'index.md', - 'internal.md', - 'sub/internal.md', + cfg = load_config(nav=nav_cfg) + files = Files([ + File('index.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']), + File('foo/bar.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']) + ]) + nav = get_navigation(files, cfg) + context = build.get_context(nav, files, cfg, nav.pages[1]) + self.assertEqual(context['base_url'], '../..') + + def test_context_base_url_relative_no_page(self): + cfg = load_config(use_directory_urls=False) + context = build.get_context(mock.Mock(), mock.Mock(), cfg, base_url='..') + self.assertEqual(context['base_url'], '..') + + def test_context_base_url_relative_no_page_use_directory_urls(self): + cfg = load_config() + context = build.get_context(mock.Mock(), mock.Mock(), cfg, base_url='..') + self.assertEqual(context['base_url'], '..') + + def test_context_base_url_absolute_no_page(self): + cfg = load_config(use_directory_urls=False) + context = build.get_context(mock.Mock(), mock.Mock(), cfg, base_url='/') + self.assertEqual(context['base_url'], '') + + def test_context_base_url__absolute_no_page_use_directory_urls(self): + cfg = load_config() + context = build.get_context(mock.Mock(), mock.Mock(), cfg, base_url='/') + self.assertEqual(context['base_url'], '') + + def test_context_base_url_absolute_nested_no_page(self): + cfg = load_config(use_directory_urls=False) + context = build.get_context(mock.Mock(), mock.Mock(), cfg, base_url='/foo/') + self.assertEqual(context['base_url'], '/foo') + + def test_context_base_url__absolute_nested_no_page_use_directory_urls(self): + cfg = load_config() + context = build.get_context(mock.Mock(), mock.Mock(), cfg, base_url='/foo/') + self.assertEqual(context['base_url'], '/foo') + + def test_context_extra_css_js_from_homepage(self): + nav_cfg = [ + {'Home': 'index.md'} ] + cfg = load_config( + nav=nav_cfg, + extra_css=['style.css'], + extra_javascript=['script.js'], + use_directory_urls=False + ) + files = Files([ + File('index.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']), + ]) + nav = get_navigation(files, cfg) + context = build.get_context(nav, files, cfg, nav.pages[0]) + self.assertEqual(context['extra_css'], ['style.css']) + self.assertEqual(context['extra_javascript'], ['script.js']) + + def test_context_extra_css_js_from_nested_page(self): + nav_cfg = [ + {'Home': 'index.md'}, + {'Nested': 'foo/bar.md'} + ] + cfg = load_config( + nav=nav_cfg, + extra_css=['style.css'], + extra_javascript=['script.js'], + use_directory_urls=False + ) + files = Files([ + File('index.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']), + File('foo/bar.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']) + ]) + nav = get_navigation(files, cfg) + context = build.get_context(nav, files, cfg, nav.pages[1]) + self.assertEqual(context['extra_css'], ['../style.css']) + self.assertEqual(context['extra_javascript'], ['../script.js']) + + def test_context_extra_css_js_from_nested_page_use_directory_urls(self): + nav_cfg = [ + {'Home': 'index.md'}, + {'Nested': 'foo/bar.md'} + ] + cfg = load_config( + nav=nav_cfg, + extra_css=['style.css'], + extra_javascript=['script.js'] + ) + files = Files([ + File('index.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']), + File('foo/bar.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']) + ]) + nav = get_navigation(files, cfg) + context = build.get_context(nav, files, cfg, nav.pages[1]) + self.assertEqual(context['extra_css'], ['../../style.css']) + self.assertEqual(context['extra_javascript'], ['../../script.js']) - md_text = "[test](bad_link.md)" - - config = load_config(pages=pages, strict=False) - page, nav = build_page(None, 'index.md', config, md_text) - page.render(config, nav) + def test_context_extra_css_js_no_page(self): + cfg = load_config(extra_css=['style.css'], extra_javascript=['script.js']) + context = build.get_context(mock.Mock(), mock.Mock(), cfg, base_url='..') + self.assertEqual(context['extra_css'], ['../style.css']) + self.assertEqual(context['extra_javascript'], ['../script.js']) - config = load_config(pages=pages, strict=True) - page, nav = build_page(None, 'index.md', config, md_text) - self.assertRaises( - MarkdownNotFound, - page.render, config, nav) + def test_extra_context(self): + cfg = load_config(extra={'a': 1}) + context = build.get_context(mock.Mock(), mock.Mock(), cfg) + self.assertEqual(context['config']['extra']['a'], 1) - def test_absolute_link(self): - pages = [ - 'index.md', - 'sub/index.md', - ] + # Test build._build_theme_template + + @mock.patch('mkdocs.utils.write_file') + @mock.patch('mkdocs.commands.build._build_template', return_value='some content') + def test_build_theme_template(self, mock_build_template, mock_write_file): + cfg = load_config() + env = cfg['theme'].get_env() + build._build_theme_template('main.html', env, mock.Mock(), cfg, mock.Mock()) + mock_write_file.assert_called_once() + mock_build_template.assert_called_once() + + @mock.patch('mkdocs.utils.write_file') + @mock.patch('mkdocs.commands.build._build_template', return_value='some content') + @mock.patch('gzip.open') + def test_build_sitemap_template(self, mock_gzip_open, mock_build_template, mock_write_file): + cfg = load_config() + env = cfg['theme'].get_env() + build._build_theme_template('sitemap.xml', env, mock.Mock(), cfg, mock.Mock()) + mock_write_file.assert_called_once() + mock_build_template.assert_called_once() + mock_gzip_open.assert_called_once() + + @mock.patch('mkdocs.utils.write_file') + @mock.patch('mkdocs.commands.build._build_template', return_value='') + def test_skip_missing_theme_template(self, mock_build_template, mock_write_file): + cfg = load_config() + env = cfg['theme'].get_env() + with self.assertLogs('mkdocs', level='WARN') as cm: + build._build_theme_template('missing.html', env, mock.Mock(), cfg, mock.Mock()) + self.assertEqual( + cm.output, + ["WARNING:mkdocs.commands.build:Template skipped: 'missing.html' not found in theme directories."] + ) + mock_write_file.assert_not_called() + mock_build_template.assert_not_called() + + @mock.patch('mkdocs.utils.write_file') + @mock.patch('mkdocs.commands.build._build_template', return_value='') + def test_skip_theme_template_empty_output(self, mock_build_template, mock_write_file): + cfg = load_config() + env = cfg['theme'].get_env() + with self.assertLogs('mkdocs', level='INFO') as cm: + build._build_theme_template('main.html', env, mock.Mock(), cfg, mock.Mock()) + self.assertEqual( + cm.output, + ["INFO:mkdocs.commands.build:Template skipped: 'main.html' generated empty output."] + ) + mock_write_file.assert_not_called() + mock_build_template.assert_called_once() - md_text = "[test 1](/index.md) [test 2](/sub/index.md)" - config = load_config(pages=pages, strict=True) - page, nav = build_page(None, 'index.md', config, md_text) - page.render(config, nav) - - def test_extension_config(self): - """ - Test that a dictionary of 'markdown_extensions' is recognized as - both a list of extensions and a dictionary of extnesion configs. - """ - md_text = dedent(""" - # A Header - """) - - expected_html = dedent(""" -

    A Header

    - """) - - config = load_config(pages=[{'Home': 'index.md'}], markdown_extensions=[{'toc': {'permalink': True}}]) - page, nav = build_page(None, 'index.md', config, md_text) - page.render(config, nav) - self.assertEqual(page.content.strip(), expected_html) + # Test build._build_extra_template - def test_extra_context(self): + @mock.patch('io.open', mock.mock_open(read_data='template content')) + def test_build_extra_template(self): + cfg = load_config() + files = Files([ + File('foo.html', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']), + ]) + build._build_extra_template('foo.html', files, cfg, mock.Mock()) - # Same as the default schema, but don't verify the docs_dir exists. - cfg = load_config( - site_name="Site", - extra={ - 'a': 1 - } + @mock.patch('io.open', mock.mock_open(read_data='template content')) + def test_skip_missing_extra_template(self): + cfg = load_config() + files = Files([ + File('foo.html', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']), + ]) + with self.assertLogs('mkdocs', level='INFO') as cm: + build._build_extra_template('missing.html', files, cfg, mock.Mock()) + self.assertEqual( + cm.output, + ["WARNING:mkdocs.commands.build:Template skipped: 'missing.html' not found in docs_dir."] ) - context = build.get_context(mock.Mock(), cfg) + @mock.patch('io.open', side_effect=IOError('Error message.')) + def test_skip_ioerror_extra_template(self, mock_open): + cfg = load_config() + files = Files([ + File('foo.html', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']), + ]) + with self.assertLogs('mkdocs', level='INFO') as cm: + build._build_extra_template('foo.html', files, cfg, mock.Mock()) + self.assertEqual( + cm.output, + ["WARNING:mkdocs.commands.build:Error reading template 'foo.html': Error message."] + ) - self.assertEqual(context['config']['extra']['a'], 1) + @mock.patch('io.open', mock.mock_open(read_data='')) + def test_skip_extra_template_empty_output(self): + cfg = load_config() + files = Files([ + File('foo.html', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']), + ]) + with self.assertLogs('mkdocs', level='INFO') as cm: + build._build_extra_template('foo.html', files, cfg, mock.Mock()) + self.assertEqual( + cm.output, + ["INFO:mkdocs.commands.build:Template skipped: 'foo.html' generated empty output."] + ) - def test_BOM(self): - with TemporaryDirectory() as docs_dir, TemporaryDirectory() as site_dir: - # Create an UTF-8 Encoded file with BOM (as Micorsoft editors do). See #1186. - f = io.open(os.path.join(docs_dir, 'index.md'), 'w', encoding='utf-8-sig') - f.write('# An UTF-8 encoded file with a BOM') - f.close() - - cfg = load_config( - docs_dir=docs_dir, - site_dir=site_dir - ) - build.build(cfg) - - # Verify that the file was generated properly. - # If the BOM is not removed, Markdown will return: - # `

    \ufeff# An UTF-8 encoded file with a BOM

    `. - f = io.open(os.path.join(site_dir, 'index.html'), 'r', encoding='utf-8') - output = f.read() - f.close() - self.assertTrue( - '

    An UTF-8 encoded file with a BOM

    ' in output - ) + # Test build._populate_page + + @tempdir(files={'index.md': 'page content'}) + def test_populate_page(self, docs_dir): + cfg = load_config(docs_dir=docs_dir) + file = File('index.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']) + page = Page('Foo', file, cfg) + build._populate_page(page, cfg, Files([file])) + self.assertEqual(page.content, '

    page content

    ') + + @tempdir(files={'testing.html': '

    page content

    '}) + def test_populate_page_dirty_modified(self, site_dir): + cfg = load_config(site_dir=site_dir) + file = File('testing.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']) + page = Page('Foo', file, cfg) + build._populate_page(page, cfg, Files([file]), dirty=True) + self.assertTrue(page.markdown.startswith('# Welcome to MkDocs')) + self.assertTrue(page.content.startswith('

    Welcome to MkDocs

    ')) + + @tempdir(files={'index.md': 'page content'}) + @tempdir(files={'index.html': '

    page content

    '}) + def test_populate_page_dirty_not_modified(self, site_dir, docs_dir): + cfg = load_config(docs_dir=docs_dir, site_dir=site_dir) + file = File('index.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']) + page = Page('Foo', file, cfg) + build._populate_page(page, cfg, Files([file]), dirty=True) + # Content is empty as file read was skipped + self.assertEqual(page.markdown, None) + self.assertEqual(page.content, None) + + @tempdir(files={'index.md': 'new page content'}) + @mock.patch('io.open', side_effect=IOError('Error message.')) + def test_populate_page_read_error(self, docs_dir, mock_open): + cfg = load_config(docs_dir=docs_dir) + file = File('missing.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']) + page = Page('Foo', file, cfg) + with self.assertLogs('mkdocs', level='ERROR') as cm: + self.assertRaises(IOError, build._populate_page, page, cfg, Files([file])) + self.assertEqual( + cm.output, [ + 'ERROR:mkdocs.structure.pages:File not found: missing.md', + "ERROR:mkdocs.commands.build:Error reading page 'missing.md': Error message." + ] + ) + mock_open.assert_called_once() + + # Test build._build_page + + @tempdir() + def test_build_page(self, site_dir): + cfg = load_config(site_dir=site_dir, nav=['index.md'], plugins=[]) + files = Files([File('index.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls'])]) + nav = get_navigation(files, cfg) + page = files.documentation_pages()[0].page + # Fake populate page + page.title = 'Title' + page.markdown = 'page content' + page.content = '

    page content

    ' + build._build_page(page, cfg, files, nav, cfg['theme'].get_env()) + self.assertPathIsFile(site_dir, 'index.html') + + # TODO: fix this. It seems that jinja2 chokes on the mock object. Not sure how to resolve. + # @tempdir() + # @mock.patch('jinja2.environment.Template') + # def test_build_page_empty(self, site_dir, mock_template): + # mock_template.render = mock.Mock(return_value='') + # cfg = load_config(site_dir=site_dir, nav=['index.md'], plugins=[]) + # files = Files([File('index.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls'])]) + # nav = get_navigation(files, cfg) + # page = files.documentation_pages()[0].page + # # Fake populate page + # page.title = '' + # page.markdown = '' + # page.content = '' + # with self.assertLogs('mkdocs', level='INFO') as cm: + # build._build_page(page, cfg, files, nav, cfg['theme'].get_env()) + # self.assertEqual( + # cm.output, + # ["INFO:mkdocs.commands.build:Page skipped: 'index.md'. Generated empty output."] + # ) + # mock_template.render.assert_called_once() + # self.assertPathNotFile(site_dir, 'index.html') + + @tempdir(files={'index.md': 'page content'}) + @tempdir(files={'index.html': '

    page content

    '}) + @mock.patch('mkdocs.utils.write_file') + def test_build_page_dirty_modified(self, site_dir, docs_dir, mock_write_file): + cfg = load_config(docs_dir=docs_dir, site_dir=site_dir, nav=['index.md'], plugins=[]) + files = Files([File('index.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls'])]) + nav = get_navigation(files, cfg) + page = files.documentation_pages()[0].page + # Fake populate page + page.title = 'Title' + page.markdown = 'new page content' + page.content = '

    new page content

    ' + build._build_page(page, cfg, files, nav, cfg['theme'].get_env(), dirty=True) + mock_write_file.assert_not_called() + + @tempdir(files={'testing.html': '

    page content

    '}) + @mock.patch('mkdocs.utils.write_file') + def test_build_page_dirty_not_modified(self, site_dir, mock_write_file): + cfg = load_config(site_dir=site_dir, nav=['index.md'], plugins=[]) + files = Files([File('testing.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls'])]) + nav = get_navigation(files, cfg) + page = files.documentation_pages()[0].page + # Fake populate page + page.title = 'Title' + page.markdown = 'page content' + page.content = '

    page content

    ' + build._build_page(page, cfg, files, nav, cfg['theme'].get_env(), dirty=True) + mock_write_file.assert_called_once() + + @tempdir() + def test_build_page_custom_template(self, site_dir): + cfg = load_config(site_dir=site_dir, nav=['index.md'], plugins=[]) + files = Files([File('index.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls'])]) + nav = get_navigation(files, cfg) + page = files.documentation_pages()[0].page + # Fake populate page + page.title = 'Title' + page.meta = {'template': '404.html'} + page.markdown = 'page content' + page.content = '

    page content

    ' + build._build_page(page, cfg, files, nav, cfg['theme'].get_env()) + self.assertPathIsFile(site_dir, 'index.html') + + @tempdir() + @mock.patch('mkdocs.utils.write_file', side_effect=IOError('Error message.')) + def test_build_page_error(self, site_dir, mock_write_file): + cfg = load_config(site_dir=site_dir, nav=['index.md'], plugins=[]) + files = Files([File('index.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls'])]) + nav = get_navigation(files, cfg) + page = files.documentation_pages()[0].page + # Fake populate page + page.title = 'Title' + page.markdown = 'page content' + page.content = '

    page content

    ' + with self.assertLogs('mkdocs', level='ERROR') as cm: + self.assertRaises(IOError, build._build_page, page, cfg, files, nav, cfg['theme'].get_env()) + self.assertEqual( + cm.output, + ["ERROR:mkdocs.commands.build:Error building page 'index.md': Error message."] + ) + mock_write_file.assert_called_once() + + # Test build.build + + @tempdir(files={ + 'index.md': 'page content', + 'empty.md': '', + 'img.jpg': '', + 'static.html': 'content', + '.hidden': 'content', + '.git/hidden': 'content' + }) + @tempdir() + def test_copying_media(self, site_dir, docs_dir): + cfg = load_config(docs_dir=docs_dir, site_dir=site_dir) + build.build(cfg) + + # Verify that only non-empty md file (coverted to html), static HTML file and image are copied. + self.assertPathIsFile(site_dir, 'index.html') + self.assertPathIsFile(site_dir, 'img.jpg') + self.assertPathIsFile(site_dir, 'static.html') + self.assertPathNotExists(site_dir, 'empty.md') + self.assertPathNotExists(site_dir, '.hidden') + self.assertPathNotExists(site_dir, '.git/hidden') + + @tempdir(files={'index.md': 'page content'}) + @tempdir() + def test_copy_theme_files(self, site_dir, docs_dir): + cfg = load_config(docs_dir=docs_dir, site_dir=site_dir) + build.build(cfg) + + # Verify only theme media are copied, not templates or Python files. + self.assertPathIsFile(site_dir, 'index.html') + self.assertPathIsFile(site_dir, '404.html') + self.assertPathIsDir(site_dir, 'js') + self.assertPathIsDir(site_dir, 'css') + self.assertPathIsDir(site_dir, 'img') + self.assertPathIsDir(site_dir, 'fonts') + self.assertPathNotExists(site_dir, '__init__.py') + self.assertPathNotExists(site_dir, '__init__.pyc') + self.assertPathNotExists(site_dir, 'base.html') + self.assertPathNotExists(site_dir, 'content.html') + self.assertPathNotExists(site_dir, 'main.html') + + # Test build.site_directory_contains_stale_files + + @tempdir(files=['index.html']) + def test_site_dir_contains_stale_files(self, site_dir): + self.assertTrue(build.site_directory_contains_stale_files(site_dir)) + + @tempdir() + def test_not_site_dir_contains_stale_files(self, site_dir): + self.assertFalse(build.site_directory_contains_stale_files(site_dir)) diff --git a/mkdocs/tests/config/base_tests.py b/mkdocs/tests/config/base_tests.py index 5118279fe7..0d5e27fafa 100644 --- a/mkdocs/tests/config/base_tests.py +++ b/mkdocs/tests/config/base_tests.py @@ -9,7 +9,7 @@ except ImportError: from backports.tempfile import TemporaryDirectory -from mkdocs import exceptions +from mkdocs import exceptions, utils from mkdocs.config import base, defaults from mkdocs.config.config_options import BaseConfigOption @@ -273,5 +273,7 @@ def test_load_from_file_with_relative_paths(self): self.assertTrue(isinstance(cfg, base.Config)) self.assertEqual(cfg['site_name'], 'MkDocs Test') self.assertEqual(cfg['docs_dir'], docs_dir) + self.assertEqual(cfg.config_file_path, config_fname) + self.assertIsInstance(cfg.config_file_path, utils.text_type) finally: config_dir.cleanup() diff --git a/mkdocs/tests/config/config_options_tests.py b/mkdocs/tests/config/config_options_tests.py index 1d573ba244..cd828e4add 100644 --- a/mkdocs/tests/config/config_options_tests.py +++ b/mkdocs/tests/config/config_options_tests.py @@ -1,6 +1,9 @@ +# coding=UTF-8 + from __future__ import unicode_literals import os +import sys import unittest from mock import patch @@ -273,14 +276,99 @@ def test_incorrect_type_type_error(self): self.assertRaises(config_options.ValidationError, option.validate, []) - def test_doc_dir_is_config_dir(self): + def test_dir_unicode(self): + cfg = Config( + [('dir', config_options.Dir())], + config_file_path=os.path.join(os.path.abspath('.'), 'mkdocs.yml'), + ) + + test_config = { + 'dir': 'юникод' + } + + cfg.load_dict(test_config) + + fails, warns = cfg.validate() + + self.assertEqual(len(fails), 0) + self.assertEqual(len(warns), 0) + self.assertIsInstance(cfg['dir'], utils.text_type) + + def test_dir_filesystemencoding(self): + cfg = Config( + [('dir', config_options.Dir())], + config_file_path=os.path.join(os.path.abspath('.'), 'mkdocs.yml'), + ) + + test_config = { + 'dir': 'Übersicht'.encode(encoding=sys.getfilesystemencoding()) + } + + cfg.load_dict(test_config) + + fails, warns = cfg.validate() + + if utils.PY3: + # In PY3 string_types does not include byte strings so validation fails + self.assertEqual(len(fails), 1) + self.assertEqual(len(warns), 0) + else: + # In PY2 string_types includes byte strings so validation passes + # This test confirms that the byte string is properly decoded + self.assertEqual(len(fails), 0) + self.assertEqual(len(warns), 0) + self.assertIsInstance(cfg['dir'], utils.text_type) + + def test_dir_bad_encoding_fails(self): + cfg = Config( + [('dir', config_options.Dir())], + config_file_path=os.path.join(os.path.abspath('.'), 'mkdocs.yml'), + ) + + test_config = { + 'dir': 'юникод'.encode(encoding='ISO 8859-5') + } + + cfg.load_dict(test_config) + + fails, warns = cfg.validate() + + if sys.platform.startswith('win') and not utils.PY3: + # PY2 on Windows seems to be able to decode anything we give it. + # But that just means less possable errors for those users so we allow it. + self.assertEqual(len(fails), 0) + else: + self.assertEqual(len(fails), 1) + self.assertEqual(len(warns), 0) + + def test_config_dir_prepended(self): + base_path = os.path.abspath('.') + cfg = Config( + [('dir', config_options.Dir())], + config_file_path=os.path.join(base_path, 'mkdocs.yml'), + ) + + test_config = { + 'dir': 'foo' + } + + cfg.load_dict(test_config) + + fails, warns = cfg.validate() + + self.assertEqual(len(fails), 0) + self.assertEqual(len(warns), 0) + self.assertIsInstance(cfg['dir'], utils.text_type) + self.assertEqual(cfg['dir'], os.path.join(base_path, 'foo')) + + def test_dir_is_config_dir_fails(self): cfg = Config( - [('docs_dir', config_options.Dir())], + [('dir', config_options.Dir())], config_file_path=os.path.join(os.path.abspath('.'), 'mkdocs.yml'), ) test_config = { - 'docs_dir': '.' + 'dir': '.' } cfg.load_dict(test_config) @@ -438,11 +526,11 @@ def test_theme_invalid_type(self): option.validate, config) -class PagesTest(unittest.TestCase): +class NavTest(unittest.TestCase): def test_old_format(self): - option = config_options.Pages() + option = config_options.Nav() self.assertRaises( config_options.ValidationError, option.validate, @@ -451,7 +539,7 @@ def test_old_format(self): def test_provided_dict(self): - option = config_options.Pages() + option = config_options.Nav() value = option.validate([ 'index.md', {"Page": "page.md"} @@ -462,7 +550,7 @@ def test_provided_dict(self): def test_provided_empty(self): - option = config_options.Pages() + option = config_options.Nav() value = option.validate([]) self.assertEqual(None, value) @@ -470,13 +558,13 @@ def test_provided_empty(self): def test_invalid_type(self): - option = config_options.Pages() + option = config_options.Nav() self.assertRaises(config_options.ValidationError, option.validate, {}) def test_invalid_config(self): - option = config_options.Pages() + option = config_options.Nav() self.assertRaises(config_options.ValidationError, option.validate, [[], 1]) diff --git a/mkdocs/tests/config/config_tests.py b/mkdocs/tests/config/config_tests.py index 68c76c9484..2ad0fb54b3 100644 --- a/mkdocs/tests/config/config_tests.py +++ b/mkdocs/tests/config/config_tests.py @@ -178,56 +178,37 @@ def test_theme(self): self.assertEqual(c['theme'].static_templates, set(result['static_templates'])) self.assertEqual(dict([(k, c['theme'][k]) for k in iter(c['theme'])]), result['vars']) - def test_default_pages(self): - with TemporaryDirectory() as tmp_dir: - open(os.path.join(tmp_dir, 'index.md'), 'w').close() - open(os.path.join(tmp_dir, 'about.md'), 'w').close() - conf = config.Config(schema=config.DEFAULT_SCHEMA) - conf.load_dict({ - 'site_name': 'Example', - 'docs_dir': tmp_dir, - 'config_file_path': os.path.join(os.path.abspath('.'), 'mkdocs.yml') - }) - conf.validate() - self.assertEqual(['index.md', 'about.md'], conf['pages']) - - def test_default_pages_nested(self): - with TemporaryDirectory() as tmp_dir: - open(os.path.join(tmp_dir, 'index.md'), 'w').close() - open(os.path.join(tmp_dir, 'getting-started.md'), 'w').close() - open(os.path.join(tmp_dir, 'about.md'), 'w').close() - os.makedirs(os.path.join(tmp_dir, 'subA')) - open(os.path.join(tmp_dir, 'subA', 'index.md'), 'w').close() - os.makedirs(os.path.join(tmp_dir, 'subA', 'subA1')) - open(os.path.join(tmp_dir, 'subA', 'subA1', 'index.md'), 'w').close() - os.makedirs(os.path.join(tmp_dir, 'subC')) - open(os.path.join(tmp_dir, 'subC', 'index.md'), 'w').close() - os.makedirs(os.path.join(tmp_dir, 'subB')) - open(os.path.join(tmp_dir, 'subB', 'index.md'), 'w').close() - conf = config.Config(schema=config.DEFAULT_SCHEMA) - conf.load_dict({ - 'site_name': 'Example', - 'docs_dir': tmp_dir, - 'config_file_path': os.path.join(os.path.abspath('.'), 'mkdocs.yml') - }) - conf.validate() - self.assertEqual([ - 'index.md', - 'about.md', - 'getting-started.md', - {'subA': [ - os.path.join('subA', 'index.md'), - {'subA1': [ - os.path.join('subA', 'subA1', 'index.md') - ]} - ]}, - {'subB': [ - os.path.join('subB', 'index.md') - ]}, - {'subC': [ - os.path.join('subC', 'index.md') - ]} - ], conf['pages']) + def test_empty_nav(self): + conf = config.Config(schema=config.DEFAULT_SCHEMA) + conf.load_dict({ + 'site_name': 'Example', + 'config_file_path': os.path.join(os.path.abspath('.'), 'mkdocs.yml') + }) + conf.validate() + self.assertEqual(conf['nav'], None) + + def test_copy_pages_to_nav(self): + # TODO: remove this when pages config setting is fully deprecated. + conf = config.Config(schema=config.DEFAULT_SCHEMA) + conf.load_dict({ + 'site_name': 'Example', + 'pages': ['index.md', 'about.md'], + 'config_file_path': os.path.join(os.path.abspath('.'), 'mkdocs.yml') + }) + conf.validate() + self.assertEqual(conf['nav'], ['index.md', 'about.md']) + + def test_dont_overwrite_nav_with_pages(self): + # TODO: remove this when pages config setting is fully deprecated. + conf = config.Config(schema=config.DEFAULT_SCHEMA) + conf.load_dict({ + 'site_name': 'Example', + 'pages': ['index.md', 'about.md'], + 'nav': ['foo.md', 'bar.md'], + 'config_file_path': os.path.join(os.path.abspath('.'), 'mkdocs.yml') + }) + conf.validate() + self.assertEqual(conf['nav'], ['foo.md', 'bar.md']) def test_doc_dir_in_site_dir(self): diff --git a/mkdocs/tests/integration/complicated_config/mkdocs.yml b/mkdocs/tests/integration/complicated_config/mkdocs.yml index f84936cc25..5c20379555 100644 --- a/mkdocs/tests/integration/complicated_config/mkdocs.yml +++ b/mkdocs/tests/integration/complicated_config/mkdocs.yml @@ -1,6 +1,6 @@ site_name: My Docs -pages: +nav: - Home: index.md - User Guide: - Writing your docs: index.md diff --git a/mkdocs/tests/integration/minimal/mkdocs.yml b/mkdocs/tests/integration/minimal/mkdocs.yml index f4d5b08c94..ff21753366 100644 --- a/mkdocs/tests/integration/minimal/mkdocs.yml +++ b/mkdocs/tests/integration/minimal/mkdocs.yml @@ -1,6 +1,6 @@ site_name: MyTest -pages: +nav: - 'testing.md' site_author: "Tom Christie & Dougal Matthews" diff --git a/mkdocs/tests/integration/subpages/docs/index.md b/mkdocs/tests/integration/subpages/docs/index.md index e4801fa6f4..9700881581 100644 --- a/mkdocs/tests/integration/subpages/docs/index.md +++ b/mkdocs/tests/integration/subpages/docs/index.md @@ -1,4 +1,4 @@ -# Test sub pages and referencing images +## Test sub pages and referencing images ## Reference an image in: / diff --git a/mkdocs/tests/integration/subpages/docs/metadata.md b/mkdocs/tests/integration/subpages/docs/metadata.md new file mode 100644 index 0000000000..d871ea26db --- /dev/null +++ b/mkdocs/tests/integration/subpages/docs/metadata.md @@ -0,0 +1,5 @@ +title: A Page Title + +# Welcome to MkDocs + +Some page content goes here. diff --git a/mkdocs/tests/integration/subpages/docs/page-title.md b/mkdocs/tests/integration/subpages/docs/page-title.md new file mode 100644 index 0000000000..26075f1ca2 --- /dev/null +++ b/mkdocs/tests/integration/subpages/docs/page-title.md @@ -0,0 +1 @@ +Page content. diff --git a/mkdocs/tests/integration/subpages/docs/pageTitle.md b/mkdocs/tests/integration/subpages/docs/pageTitle.md new file mode 100644 index 0000000000..26075f1ca2 --- /dev/null +++ b/mkdocs/tests/integration/subpages/docs/pageTitle.md @@ -0,0 +1 @@ +Page content. diff --git "a/mkdocs/tests/integration/unicode/docs/\303\234bersicht.md" "b/mkdocs/tests/integration/unicode/docs/\303\234bersicht.md" index da37213adb..e410983437 100644 --- "a/mkdocs/tests/integration/unicode/docs/\303\234bersicht.md" +++ "b/mkdocs/tests/integration/unicode/docs/\303\234bersicht.md" @@ -1,4 +1,4 @@ -# Welcome to MkDocs +Welcome to MkDocs For full documentation visit [mkdocs.org](http://mkdocs.org). diff --git "a/mkdocs/tests/integration/unicode/docs/\342\231\252.md" "b/mkdocs/tests/integration/unicode/docs/\342\231\252.md" index da37213adb..e410983437 100644 --- "a/mkdocs/tests/integration/unicode/docs/\342\231\252.md" +++ "b/mkdocs/tests/integration/unicode/docs/\342\231\252.md" @@ -1,4 +1,4 @@ -# Welcome to MkDocs +Welcome to MkDocs For full documentation visit [mkdocs.org](http://mkdocs.org). diff --git a/mkdocs/tests/nav_tests.py b/mkdocs/tests/nav_tests.py deleted file mode 100644 index d8648b1ca3..0000000000 --- a/mkdocs/tests/nav_tests.py +++ /dev/null @@ -1,850 +0,0 @@ -#!/usr/bin/env python -# coding: utf-8 - -from __future__ import unicode_literals -import mock -import os -import unittest - -from mkdocs import nav -from mkdocs.exceptions import ConfigurationError -from mkdocs.tests.base import dedent, load_config - - -class SiteNavigationTests(unittest.TestCase): - def test_simple_toc(self): - pages = [ - {'Home': 'index.md'}, - {'About': 'about.md'} - ] - expected = dedent(""" - Home - / - About - /about/ - """) - site_navigation = nav.SiteNavigation(load_config(pages=pages)) - self.assertEqual(str(site_navigation).strip(), expected) - self.assertEqual(len(site_navigation.nav_items), 2) - self.assertEqual(len(site_navigation.pages), 2) - - def test_empty_toc_item(self): - pages = [ - 'index.md', - {'About': 'about.md'} - ] - expected = dedent(""" - Home - / - About - /about/ - """) - site_navigation = nav.SiteNavigation(load_config(pages=pages)) - self.assertEqual(str(site_navigation).strip(), expected) - self.assertEqual(len(site_navigation.nav_items), 2) - self.assertEqual(len(site_navigation.pages), 2) - - def test_indented_toc(self): - pages = [ - {'Home': 'index.md'}, - {'API Guide': [ - {'Running': 'api-guide/running.md'}, - {'Testing': 'api-guide/testing.md'}, - {'Debugging': 'api-guide/debugging.md'}, - ]}, - {'About': [ - {'Release notes': 'about/release-notes.md'}, - {'License': 'about/license.md'} - ]} - ] - expected = dedent(""" - Home - / - API Guide - Running - /api-guide/running/ - Testing - /api-guide/testing/ - Debugging - /api-guide/debugging/ - About - Release notes - /about/release-notes/ - License - /about/license/ - """) - site_navigation = nav.SiteNavigation(load_config(pages=pages)) - self.assertEqual(str(site_navigation).strip(), expected) - self.assertEqual(len(site_navigation.nav_items), 3) - self.assertEqual(len(site_navigation.pages), 6) - - def test_nested_ungrouped(self): - pages = [ - {'Home': 'index.md'}, - {'Contact': 'about/contact.md'}, - {'License Title': 'about/sub/license.md'}, - ] - expected = dedent(""" - Home - / - Contact - /about/contact/ - License Title - /about/sub/license/ - """) - site_navigation = nav.SiteNavigation(load_config(pages=pages)) - self.assertEqual(str(site_navigation).strip(), expected) - self.assertEqual(len(site_navigation.nav_items), 3) - self.assertEqual(len(site_navigation.pages), 3) - - def test_nested_ungrouped_no_titles(self): - pages = [ - 'index.md', - 'about/contact.md', - 'about/sub/license.md' - ] - expected = dedent(""" - Home - / - Contact - /about/contact/ - License - /about/sub/license/ - """) - - site_navigation = nav.SiteNavigation(load_config(pages=pages)) - self.assertEqual(str(site_navigation).strip(), expected) - self.assertEqual(len(site_navigation.nav_items), 3) - self.assertEqual(len(site_navigation.pages), 3) - - @mock.patch.object(os.path, 'sep', '\\') - def test_nested_ungrouped_no_titles_windows(self): - pages = [ - 'index.md', - 'about\\contact.md', - 'about\\sub\\license.md', - ] - expected = dedent(""" - Home - / - Contact - /about/contact/ - License - /about/sub/license/ - """) - - site_navigation = nav.SiteNavigation(load_config(pages=pages)) - self.assertEqual(str(site_navigation).strip(), expected) - self.assertEqual(len(site_navigation.nav_items), 3) - self.assertEqual(len(site_navigation.pages), 3) - - def test_walk_simple_toc(self): - pages = [ - {'Home': 'index.md'}, - {'About': 'about.md'} - ] - expected = [ - dedent(""" - Home - / [*] - About - /about/ - """), - dedent(""" - Home - / - About - /about/ [*] - """) - ] - site_navigation = nav.SiteNavigation(load_config(pages=pages)) - for index, page in enumerate(site_navigation.walk_pages()): - self.assertEqual(str(site_navigation).strip(), expected[index]) - - def test_walk_empty_toc(self): - pages = [ - 'index.md', - {'About': 'about.md'} - ] - expected = [ - dedent(""" - Home - / [*] - About - /about/ - """), - dedent(""" - Home - / - About - /about/ [*] - """) - ] - site_navigation = nav.SiteNavigation(load_config(pages=pages)) - for index, page in enumerate(site_navigation.walk_pages()): - self.assertEqual(str(site_navigation).strip(), expected[index]) - - def test_walk_indented_toc(self): - pages = [ - {'Home': 'index.md'}, - {'API Guide': [ - {'Running': 'api-guide/running.md'}, - {'Testing': 'api-guide/testing.md'}, - {'Debugging': 'api-guide/debugging.md'}, - ]}, - {'About': [ - {'Release notes': 'about/release-notes.md'}, - {'License': 'about/license.md'} - ]} - ] - expected = [ - dedent(""" - Home - / [*] - API Guide - Running - /api-guide/running/ - Testing - /api-guide/testing/ - Debugging - /api-guide/debugging/ - About - Release notes - /about/release-notes/ - License - /about/license/ - """), - dedent(""" - Home - / - API Guide [*] - Running - /api-guide/running/ [*] - Testing - /api-guide/testing/ - Debugging - /api-guide/debugging/ - About - Release notes - /about/release-notes/ - License - /about/license/ - """), - dedent(""" - Home - / - API Guide [*] - Running - /api-guide/running/ - Testing - /api-guide/testing/ [*] - Debugging - /api-guide/debugging/ - About - Release notes - /about/release-notes/ - License - /about/license/ - """), - dedent(""" - Home - / - API Guide [*] - Running - /api-guide/running/ - Testing - /api-guide/testing/ - Debugging - /api-guide/debugging/ [*] - About - Release notes - /about/release-notes/ - License - /about/license/ - """), - dedent(""" - Home - / - API Guide - Running - /api-guide/running/ - Testing - /api-guide/testing/ - Debugging - /api-guide/debugging/ - About [*] - Release notes - /about/release-notes/ [*] - License - /about/license/ - """), - dedent(""" - Home - / - API Guide - Running - /api-guide/running/ - Testing - /api-guide/testing/ - Debugging - /api-guide/debugging/ - About [*] - Release notes - /about/release-notes/ - License - /about/license/ [*] - """) - ] - site_navigation = nav.SiteNavigation(load_config(pages=pages)) - for index, page in enumerate(site_navigation.walk_pages()): - self.assertEqual(str(site_navigation).strip(), expected[index]) - - def test_base_url(self): - pages = [ - 'index.md' - ] - site_navigation = nav.SiteNavigation(load_config(pages=pages, use_directory_urls=False)) - base_url = site_navigation.url_context.make_relative('/') - self.assertEqual(base_url, '.') - - def test_relative_md_links_have_slash(self): - pages = [ - 'index.md', - 'user-guide/styling-your-docs.md' - ] - site_navigation = nav.SiteNavigation(load_config(pages=pages, use_directory_urls=False)) - site_navigation.url_context.base_path = "/user-guide/configuration" - url = site_navigation.url_context.make_relative('/user-guide/styling-your-docs/') - self.assertEqual(url, '../styling-your-docs/') - - def test_generate_site_navigation(self): - """ - Verify inferring page titles based on the filename - """ - - pages = [ - 'index.md', - 'api-guide/running.md', - 'about/notes.md', - 'about/sub/license.md', - ] - - url_context = nav.URLContext() - nav_items, pages = nav._generate_site_navigation(load_config(pages=pages), url_context) - - self.assertEqual([n.title for n in nav_items], - ['Home', 'Running', 'Notes', 'License']) - self.assertEqual([n.url for n in nav_items], [ - '.', - 'api-guide/running/', - 'about/notes/', - 'about/sub/license/' - ]) - self.assertEqual([p.title for p in pages], - ['Home', 'Running', 'Notes', 'License']) - - @mock.patch.object(os.path, 'sep', '\\') - def test_generate_site_navigation_windows(self): - """ - Verify inferring page titles based on the filename with a windows path - """ - pages = [ - 'index.md', - 'api-guide\\running.md', - 'about\\notes.md', - 'about\\sub\\license.md', - ] - - url_context = nav.URLContext() - nav_items, pages = nav._generate_site_navigation(load_config(pages=pages), url_context) - - self.assertEqual([n.title for n in nav_items], - ['Home', 'Running', 'Notes', 'License']) - self.assertEqual([n.url for n in nav_items], [ - '.', - 'api-guide/running/', - 'about/notes/', - 'about/sub/license/' - ]) - self.assertEqual([p.title for p in pages], - ['Home', 'Running', 'Notes', 'License']) - - def test_force_abs_urls(self): - """ - Verify force absolute URLs - """ - - pages = [ - 'index.md', - 'api-guide/running.md', - 'about/notes.md', - 'about/sub/license.md', - ] - - url_context = nav.URLContext() - url_context.force_abs_urls = True - nav_items, pages = nav._generate_site_navigation(load_config(pages=pages), url_context) - - self.assertEqual([n.title for n in nav_items], - ['Home', 'Running', 'Notes', 'License']) - self.assertEqual([n.url for n in nav_items], [ - '/', - '/api-guide/running/', - '/about/notes/', - '/about/sub/license/' - ]) - - def test_force_abs_urls_with_base(self): - """ - Verify force absolute URLs - """ - - pages = [ - 'index.md', - 'api-guide/running.md', - 'about/notes.md', - 'about/sub/license.md', - ] - - url_context = nav.URLContext() - url_context.force_abs_urls = True - url_context.base_path = '/foo/' - nav_items, pages = nav._generate_site_navigation(load_config(pages=pages), url_context) - - self.assertEqual([n.title for n in nav_items], - ['Home', 'Running', 'Notes', 'License']) - self.assertEqual([n.url for n in nav_items], [ - '/foo/', - '/foo/api-guide/running/', - '/foo/about/notes/', - '/foo/about/sub/license/' - ]) - - def test_invalid_pages_config(self): - - bad_page = {"a": "index.md", "b": "index.md"} # extra key - - def _test(): - return nav._generate_site_navigation(load_config(pages=[bad_page, ]), None) - - self.assertRaises(ConfigurationError, _test) - - def test_pages_config(self): - - bad_page = {} # empty - - def _test(): - return nav._generate_site_navigation(load_config(pages=[bad_page, ]), None) - - self.assertRaises(ConfigurationError, _test) - - def test_ancestors(self): - - pages = [ - {'Home': 'index.md'}, - {'API Guide': [ - {'Running': 'api-guide/running.md'}, - {'Testing': 'api-guide/testing.md'}, - {'Debugging': 'api-guide/debugging.md'}, - {'Advanced': [ - {'Part 1': 'api-guide/advanced/part-1.md'}, - ]}, - ]}, - {'About': [ - {'Release notes': 'about/release-notes.md'}, - {'License': 'about/license.md'} - ]} - ] - site_navigation = nav.SiteNavigation(load_config(pages=pages)) - - ancestors = ( - [], - [site_navigation.nav_items[1]], - [site_navigation.nav_items[1]], - [site_navigation.nav_items[1]], - [site_navigation.nav_items[1], - site_navigation.pages[4].ancestors[-1]], - [site_navigation.nav_items[2]], - [site_navigation.nav_items[2]], - ) - - self.assertEqual(len(site_navigation.pages), len(ancestors)) - - for i, (page, expected_ancestor) in enumerate( - zip(site_navigation.pages, ancestors)): - self.assertEqual(page.ancestors, expected_ancestor, - "Failed on ancestor test {0}".format(i)) - - def test_nesting(self): - - pages = [ - {'Home': 'index.md'}, - {'Install': [ - {'Pre-install': 'install/install-pre.md'}, - {'The install': 'install/install-actual.md'}, - {'Post install': 'install/install-post.md'}, - ]}, - {'Guide': [ - {'Tutorial': [ - {'Getting Started': 'guide/tutorial/running.md'}, - {'Advanced Features': 'guide/tutorial/testing.md'}, - {'Further Reading': 'guide/tutorial/debugging.md'}, - ]}, - {'API Reference': [ - {'Feature 1': 'guide/api-ref/running.md'}, - {'Feature 2': 'guide/api-ref/testing.md'}, - {'Feature 3': 'guide/api-ref/debugging.md'}, - ]}, - {'Testing': 'guide/testing.md'}, - {'Deploying': 'guide/deploying.md'}, - ]} - ] - - site_navigation = nav.SiteNavigation(load_config(pages=pages)) - - self.assertEqual([n.title for n in site_navigation.nav_items], - ['Home', 'Install', 'Guide']) - self.assertEqual(len(site_navigation.pages), 12) - - expected = dedent(""" - Home - / - Install - Pre-install - /install/install-pre/ - The install - /install/install-actual/ - Post install - /install/install-post/ - Guide - Tutorial - Getting Started - /guide/tutorial/running/ - Advanced Features - /guide/tutorial/testing/ - Further Reading - /guide/tutorial/debugging/ - API Reference - Feature 1 - /guide/api-ref/running/ - Feature 2 - /guide/api-ref/testing/ - Feature 3 - /guide/api-ref/debugging/ - Testing - /guide/testing/ - Deploying - /guide/deploying/ - """) - - self.maxDiff = None - self.assertEqual(str(site_navigation).strip(), expected) - - def test_edit_uri(self): - - pages = [ - 'index.md', - 'internal.md', - 'sub/internal.md', - 'sub1/sub2/internal.md', - ] - - # Basic test - repo_url = 'http://example.com/' - edit_uri = 'edit/master/docs/' - - site_navigation = nav.SiteNavigation(load_config( - pages=pages, - repo_url=repo_url, - edit_uri=edit_uri, - site_dir='site', - site_url='', - use_directory_urls=True - )) - - expected_results = ( - repo_url + edit_uri + pages[0], - repo_url + edit_uri + pages[1], - repo_url + edit_uri + pages[2], - repo_url + edit_uri + pages[3], - ) - - for idx, page in enumerate(site_navigation.walk_pages()): - self.assertEqual(page.edit_url, expected_results[idx]) - - def test_edit_uri_sub_dir(self): - - pages = [ - 'index.md', - 'internal.md', - 'sub/internal.md', - 'sub1/sub2/internal.md', - ] - - # Basic test - repo_url = 'http://example.com/foo/' - edit_uri = 'edit/master/docs/' - - site_navigation = nav.SiteNavigation(load_config( - pages=pages, - repo_url=repo_url, - edit_uri=edit_uri, - site_dir='site', - site_url='', - use_directory_urls=True - )) - - expected_results = ( - repo_url + edit_uri + pages[0], - repo_url + edit_uri + pages[1], - repo_url + edit_uri + pages[2], - repo_url + edit_uri + pages[3], - ) - - for idx, page in enumerate(site_navigation.walk_pages()): - self.assertEqual(page.edit_url, expected_results[idx]) - - def test_edit_uri_missing_slash(self): - - pages = [ - 'index.md', - 'internal.md', - 'sub/internal.md', - 'sub1/sub2/internal.md', - ] - - # Ensure the '/' is added to the repo_url and edit_uri - repo_url = 'http://example.com' - edit_uri = 'edit/master/docs' - - site_navigation = nav.SiteNavigation(load_config( - pages=pages, - repo_url=repo_url, - edit_uri=edit_uri, - site_dir='site', - site_url='', - use_directory_urls=True - )) - - expected_results = ( - repo_url + '/' + edit_uri + '/' + pages[0], - repo_url + '/' + edit_uri + '/' + pages[1], - repo_url + '/' + edit_uri + '/' + pages[2], - repo_url + '/' + edit_uri + '/' + pages[3], - ) - - for idx, page in enumerate(site_navigation.walk_pages()): - self.assertEqual(page.edit_url, expected_results[idx]) - - def test_edit_uri_sub_dir_missing_slash(self): - - pages = [ - 'index.md', - 'internal.md', - 'sub/internal.md', - 'sub1/sub2/internal.md', - ] - - # Basic test - repo_url = 'http://example.com/foo' - edit_uri = 'edit/master/docs' - - site_navigation = nav.SiteNavigation(load_config( - pages=pages, - repo_url=repo_url, - edit_uri=edit_uri, - site_dir='site', - site_url='', - use_directory_urls=True - )) - - expected_results = ( - repo_url + '/' + edit_uri + '/' + pages[0], - repo_url + '/' + edit_uri + '/' + pages[1], - repo_url + '/' + edit_uri + '/' + pages[2], - repo_url + '/' + edit_uri + '/' + pages[3], - ) - - for idx, page in enumerate(site_navigation.walk_pages()): - self.assertEqual(page.edit_url, expected_results[idx]) - - def test_edit_uri_query_string(self): - - pages = [ - 'index.md', - 'internal.md', - 'sub/internal.md', - 'sub1/sub2/internal.md', - ] - - # Ensure query strings are supported - repo_url = 'http://example.com' - edit_uri = '?query=edit/master/docs/' - - site_navigation = nav.SiteNavigation(load_config( - pages=pages, - repo_url=repo_url, - edit_uri=edit_uri, - site_dir='site', - site_url='', - use_directory_urls=True - )) - - expected_results = ( - repo_url + edit_uri + pages[0], - repo_url + edit_uri + pages[1], - repo_url + edit_uri + pages[2], - repo_url + edit_uri + pages[3], - ) - - for idx, page in enumerate(site_navigation.walk_pages()): - self.assertEqual(page.edit_url, expected_results[idx]) - - def test_edit_uri_fragment(self): - - pages = [ - 'index.md', - 'internal.md', - 'sub/internal.md', - 'sub1/sub2/internal.md', - ] - - # Ensure fragment strings are supported - repo_url = 'http://example.com' - edit_uri = '#fragment/edit/master/docs/' - - site_navigation = nav.SiteNavigation(load_config( - pages=pages, - repo_url=repo_url, - edit_uri=edit_uri, - site_dir='site', - site_url='', - use_directory_urls=True - )) - - expected_results = ( - repo_url + edit_uri + pages[0], - repo_url + edit_uri + pages[1], - repo_url + edit_uri + pages[2], - repo_url + edit_uri + pages[3], - ) - - for idx, page in enumerate(site_navigation.walk_pages()): - self.assertEqual(page.edit_url, expected_results[idx]) - - def test_edit_uri_windows(self): - - pages = [ - 'index.md', - 'internal.md', - 'sub\\internal.md', - 'sub1\\sub2\\internal.md', - ] - - # Basic test - repo_url = 'http://example.com/' - edit_uri = 'edit/master/docs/' - - site_navigation = nav.SiteNavigation(load_config( - pages=pages, - repo_url=repo_url, - edit_uri=edit_uri, - site_dir='site', - site_url='', - use_directory_urls=True - )) - - expected_results = ( - repo_url + edit_uri + pages[0], - repo_url + edit_uri + pages[1], - repo_url + edit_uri + pages[2].replace('\\', '/'), - repo_url + edit_uri + pages[3].replace('\\', '/'), - ) - - for idx, page in enumerate(site_navigation.walk_pages()): - self.assertEqual(page.edit_url, expected_results[idx]) - - def test_edit_uri_sub_dir_windows(self): - - pages = [ - 'index.md', - 'internal.md', - 'sub\\internal.md', - 'sub1\\sub2\\internal.md', - ] - - # Basic test - repo_url = 'http://example.com/foo/' - edit_uri = 'edit/master/docs/' - - site_navigation = nav.SiteNavigation(load_config( - pages=pages, - repo_url=repo_url, - edit_uri=edit_uri, - site_dir='site', - site_url='', - use_directory_urls=True - )) - - expected_results = ( - repo_url + edit_uri + pages[0], - repo_url + edit_uri + pages[1], - repo_url + edit_uri + pages[2].replace('\\', '/'), - repo_url + edit_uri + pages[3].replace('\\', '/'), - ) - - for idx, page in enumerate(site_navigation.walk_pages()): - self.assertEqual(page.edit_url, expected_results[idx]) - - def test_edit_uri_missing_slash_windows(self): - - pages = [ - 'index.md', - 'internal.md', - 'sub\\internal.md', - 'sub1\\sub2\\internal.md', - ] - - # Ensure the '/' is added to the repo_url and edit_uri - repo_url = 'http://example.com' - edit_uri = 'edit/master/docs' - - site_navigation = nav.SiteNavigation(load_config( - pages=pages, - repo_url=repo_url, - edit_uri=edit_uri, - site_dir='site', - site_url='', - use_directory_urls=True - )) - - expected_results = ( - repo_url + '/' + edit_uri + '/' + pages[0], - repo_url + '/' + edit_uri + '/' + pages[1], - repo_url + '/' + edit_uri + '/' + pages[2].replace('\\', '/'), - repo_url + '/' + edit_uri + '/' + pages[3].replace('\\', '/'), - ) - - for idx, page in enumerate(site_navigation.walk_pages()): - self.assertEqual(page.edit_url, expected_results[idx]) - - def test_edit_uri_sub_dir_missing_slash_windows(self): - - pages = [ - 'index.md', - 'internal.md', - 'sub\\internal.md', - 'sub1\\sub2\\internal.md', - ] - - # Ensure the '/' is added to the repo_url and edit_uri - repo_url = 'http://example.com/foo' - edit_uri = 'edit/master/docs' - - site_navigation = nav.SiteNavigation(load_config( - pages=pages, - repo_url=repo_url, - edit_uri=edit_uri, - site_dir='site', - site_url='', - use_directory_urls=True - )) - - expected_results = ( - repo_url + '/' + edit_uri + '/' + pages[0], - repo_url + '/' + edit_uri + '/' + pages[1], - repo_url + '/' + edit_uri + '/' + pages[2].replace('\\', '/'), - repo_url + '/' + edit_uri + '/' + pages[3].replace('\\', '/'), - ) - - for idx, page in enumerate(site_navigation.walk_pages()): - self.assertEqual(page.edit_url, expected_results[idx]) - - def test_edit_uri_query_string_windows(self): - - pages = [ - 'index.md', - 'internal.md', - 'sub\\internal.md', - 'sub1\\sub2\\internal.md', - ] - - # Ensure query strings are supported - repo_url = 'http://example.com' - edit_uri = '?query=edit/master/docs/' - - site_navigation = nav.SiteNavigation(load_config( - pages=pages, - repo_url=repo_url, - edit_uri=edit_uri, - site_dir='site', - site_url='', - use_directory_urls=True - )) - - expected_results = ( - repo_url + edit_uri + pages[0], - repo_url + edit_uri + pages[1], - repo_url + edit_uri + pages[2].replace('\\', '/'), - repo_url + edit_uri + pages[3].replace('\\', '/'), - ) - - for idx, page in enumerate(site_navigation.walk_pages()): - self.assertEqual(page.edit_url, expected_results[idx]) - - def test_edit_uri_fragment_windows(self): - - pages = [ - 'index.md', - 'internal.md', - 'sub\\internal.md', - 'sub1\\sub2\\internal.md', - ] - - # Ensure fragment strings are supported - repo_url = 'http://example.com' - edit_uri = '#fragment/edit/master/docs/' - - site_navigation = nav.SiteNavigation(load_config( - pages=pages, - repo_url=repo_url, - edit_uri=edit_uri, - site_dir='site', - site_url='', - use_directory_urls=True - )) - - expected_results = ( - repo_url + edit_uri + pages[0], - repo_url + edit_uri + pages[1], - repo_url + edit_uri + pages[2].replace('\\', '/'), - repo_url + edit_uri + pages[3].replace('\\', '/'), - ) - - for idx, page in enumerate(site_navigation.walk_pages()): - self.assertEqual(page.edit_url, expected_results[idx]) diff --git a/mkdocs/tests/search_tests.py b/mkdocs/tests/search_tests.py index 2444b8efed..8441e61bdb 100644 --- a/mkdocs/tests/search_tests.py +++ b/mkdocs/tests/search_tests.py @@ -6,11 +6,13 @@ import mock import json -from mkdocs import nav +from mkdocs.structure.files import File +from mkdocs.structure.pages import Page +from mkdocs.structure.toc import get_toc from mkdocs.contrib import search from mkdocs.contrib.search import search_index from mkdocs.config.config_options import ValidationError -from mkdocs.tests.base import dedent, markdown_to_toc, load_config +from mkdocs.tests.base import dedent, get_markdown_toc, load_config def strip_whitespace(string): @@ -248,7 +250,7 @@ def test_find_toc_by_id(self): ## Heading 2 ### Heading 3 """) - toc = markdown_to_toc(md) + toc = get_toc(get_markdown_toc(md)) toc_item = index._find_toc_by_id(toc, "heading-1") self.assertEqual(toc_item.url, "#heading-1") @@ -273,23 +275,22 @@ def test_create_search_index(self):

    Content 3

    """ + cfg = load_config() pages = [ - {'Home': 'index.md'}, - {'About': 'about.md'}, + Page('Home', File('index.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']), cfg), + Page('About', File('about.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']), cfg) ] - site_navigation = nav.SiteNavigation(load_config(pages=pages)) - md = dedent(""" # Heading 1 ## Heading 2 ### Heading 3 """) - toc = markdown_to_toc(md) + toc = get_toc(get_markdown_toc(md)) full_content = ''.join("""Heading{0}Content{0}""".format(i) for i in range(1, 4)) - for page in site_navigation: + for page in pages: # Fake page.read_source() and page.render() page.markdown = md page.toc = toc @@ -300,7 +301,7 @@ def test_create_search_index(self): self.assertEqual(len(index._entries), 4) - loc = page.abs_url + loc = page.url self.assertEqual(index._entries[0]['title'], page.title) self.assertEqual(strip_whitespace(index._entries[0]['text']), full_content) diff --git a/mkdocs/tests/structure/__init__.py b/mkdocs/tests/structure/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/mkdocs/tests/structure/file_tests.py b/mkdocs/tests/structure/file_tests.py new file mode 100644 index 0000000000..3e56bea1da --- /dev/null +++ b/mkdocs/tests/structure/file_tests.py @@ -0,0 +1,576 @@ +import unittest +import os +import io +import mock + +from mkdocs.structure.files import Files, File, get_files, _sort_files, _filter_paths +from mkdocs.tests.base import load_config, tempdir, PathAssertionMixin + + +class TestFiles(PathAssertionMixin, unittest.TestCase): + + def test_file_eq(self): + file = File('a.md', '/path/to/docs', '/path/to/site', use_directory_urls=False) + self.assertTrue(file == File('a.md', '/path/to/docs', '/path/to/site', use_directory_urls=False)) + + def test_file_ne(self): + file = File('a.md', '/path/to/docs', '/path/to/site', use_directory_urls=False) + # Different filename + self.assertTrue(file != File('b.md', '/path/to/docs', '/path/to/site', use_directory_urls=False)) + # Different src_path + self.assertTrue(file != File('a.md', '/path/to/other', '/path/to/site', use_directory_urls=False)) + # Different URL + self.assertTrue(file != File('a.md', '/path/to/docs', '/path/to/site', use_directory_urls=True)) + + def test_sort_files(self): + self.assertEqual( + _sort_files(['b.md', 'bb.md', 'a.md', 'index.md', 'aa.md']), + ['index.md', 'a.md', 'aa.md', 'b.md', 'bb.md'] + ) + + self.assertEqual( + _sort_files(['b.md', 'index.html', 'a.md', 'index.md']), + ['index.html', 'index.md', 'a.md', 'b.md'] + ) + + self.assertEqual( + _sort_files(['a.md', 'index.md', 'b.md', 'index.html']), + ['index.md', 'index.html', 'a.md', 'b.md'] + ) + + self.assertEqual( + _sort_files(['.md', '_.md', 'a.md', 'index.md', '1.md']), + ['index.md', '.md', '1.md', '_.md', 'a.md'] + ) + + self.assertEqual( + _sort_files(['a.md', 'b.md', 'a.md']), + ['a.md', 'a.md', 'b.md'] + ) + + def test_md_file(self): + f = File('foo.md', '/path/to/docs', '/path/to/site', use_directory_urls=False) + self.assertPathsEqual(f.src_path, 'foo.md') + self.assertPathsEqual(f.abs_src_path, '/path/to/docs/foo.md') + self.assertPathsEqual(f.dest_path, 'foo.html') + self.assertPathsEqual(f.abs_dest_path, '/path/to/site/foo.html') + self.assertEqual(f.url, 'foo.html') + self.assertEqual(f.name, 'foo') + self.assertTrue(f.is_documentation_page()) + self.assertFalse(f.is_static_page()) + self.assertFalse(f.is_media_file()) + self.assertFalse(f.is_javascript()) + self.assertFalse(f.is_css()) + + def test_md_file_use_directory_urls(self): + f = File('foo.md', '/path/to/docs', '/path/to/site', use_directory_urls=True) + self.assertPathsEqual(f.src_path, 'foo.md') + self.assertPathsEqual(f.abs_src_path, '/path/to/docs/foo.md') + self.assertPathsEqual(f.dest_path, 'foo/index.html') + self.assertPathsEqual(f.abs_dest_path, '/path/to/site/foo/index.html') + self.assertEqual(f.url, 'foo/') + self.assertEqual(f.name, 'foo') + self.assertTrue(f.is_documentation_page()) + self.assertFalse(f.is_static_page()) + self.assertFalse(f.is_media_file()) + self.assertFalse(f.is_javascript()) + self.assertFalse(f.is_css()) + + def test_md_file_nested(self): + f = File('foo/bar.md', '/path/to/docs', '/path/to/site', use_directory_urls=False) + self.assertPathsEqual(f.src_path, 'foo/bar.md') + self.assertPathsEqual(f.abs_src_path, '/path/to/docs/foo/bar.md') + self.assertPathsEqual(f.dest_path, 'foo/bar.html') + self.assertPathsEqual(f.abs_dest_path, '/path/to/site/foo/bar.html') + self.assertEqual(f.url, 'foo/bar.html') + self.assertEqual(f.name, 'bar') + self.assertTrue(f.is_documentation_page()) + self.assertFalse(f.is_static_page()) + self.assertFalse(f.is_media_file()) + self.assertFalse(f.is_javascript()) + self.assertFalse(f.is_css()) + + def test_md_file_nested_use_directory_urls(self): + f = File('foo/bar.md', '/path/to/docs', '/path/to/site', use_directory_urls=True) + self.assertPathsEqual(f.src_path, 'foo/bar.md') + self.assertPathsEqual(f.abs_src_path, '/path/to/docs/foo/bar.md') + self.assertPathsEqual(f.dest_path, 'foo/bar/index.html') + self.assertPathsEqual(f.abs_dest_path, '/path/to/site/foo/bar/index.html') + self.assertEqual(f.url, 'foo/bar/') + self.assertEqual(f.name, 'bar') + self.assertTrue(f.is_documentation_page()) + self.assertFalse(f.is_static_page()) + self.assertFalse(f.is_media_file()) + self.assertFalse(f.is_javascript()) + self.assertFalse(f.is_css()) + + def test_md_index_file(self): + f = File('index.md', '/path/to/docs', '/path/to/site', use_directory_urls=False) + self.assertPathsEqual(f.src_path, 'index.md') + self.assertPathsEqual(f.abs_src_path, '/path/to/docs/index.md') + self.assertPathsEqual(f.dest_path, 'index.html') + self.assertPathsEqual(f.abs_dest_path, '/path/to/site/index.html') + self.assertEqual(f.url, 'index.html') + self.assertEqual(f.name, 'index') + self.assertTrue(f.is_documentation_page()) + self.assertFalse(f.is_static_page()) + self.assertFalse(f.is_media_file()) + self.assertFalse(f.is_javascript()) + self.assertFalse(f.is_css()) + + def test_md_index_file_use_directory_urls(self): + f = File('index.md', '/path/to/docs', '/path/to/site', use_directory_urls=True) + self.assertPathsEqual(f.src_path, 'index.md') + self.assertPathsEqual(f.abs_src_path, '/path/to/docs/index.md') + self.assertPathsEqual(f.dest_path, 'index.html') + self.assertPathsEqual(f.abs_dest_path, '/path/to/site/index.html') + self.assertEqual(f.url, '.') + self.assertEqual(f.name, 'index') + self.assertTrue(f.is_documentation_page()) + self.assertFalse(f.is_static_page()) + self.assertFalse(f.is_media_file()) + self.assertFalse(f.is_javascript()) + self.assertFalse(f.is_css()) + + def test_md_index_file_nested(self): + f = File('foo/index.md', '/path/to/docs', '/path/to/site', use_directory_urls=False) + self.assertPathsEqual(f.src_path, 'foo/index.md') + self.assertPathsEqual(f.abs_src_path, '/path/to/docs/foo/index.md') + self.assertPathsEqual(f.dest_path, 'foo/index.html') + self.assertPathsEqual(f.abs_dest_path, '/path/to/site/foo/index.html') + self.assertEqual(f.url, 'foo/index.html') + self.assertEqual(f.name, 'index') + self.assertTrue(f.is_documentation_page()) + self.assertFalse(f.is_static_page()) + self.assertFalse(f.is_media_file()) + self.assertFalse(f.is_javascript()) + self.assertFalse(f.is_css()) + + def test_md_index_file_nested_use_directory_urls(self): + f = File('foo/index.md', '/path/to/docs', '/path/to/site', use_directory_urls=True) + self.assertPathsEqual(f.src_path, 'foo/index.md') + self.assertPathsEqual(f.abs_src_path, '/path/to/docs/foo/index.md') + self.assertPathsEqual(f.dest_path, 'foo/index.html') + self.assertPathsEqual(f.abs_dest_path, '/path/to/site/foo/index.html') + self.assertEqual(f.url, 'foo/') + self.assertEqual(f.name, 'index') + self.assertTrue(f.is_documentation_page()) + self.assertFalse(f.is_static_page()) + self.assertFalse(f.is_media_file()) + self.assertFalse(f.is_javascript()) + self.assertFalse(f.is_css()) + + def test_static_file(self): + f = File('foo/bar.html', '/path/to/docs', '/path/to/site', use_directory_urls=False) + self.assertPathsEqual(f.src_path, 'foo/bar.html') + self.assertPathsEqual(f.abs_src_path, '/path/to/docs/foo/bar.html') + self.assertPathsEqual(f.dest_path, 'foo/bar.html') + self.assertPathsEqual(f.abs_dest_path, '/path/to/site/foo/bar.html') + self.assertEqual(f.url, 'foo/bar.html') + self.assertEqual(f.name, 'bar') + self.assertFalse(f.is_documentation_page()) + self.assertTrue(f.is_static_page()) + self.assertFalse(f.is_media_file()) + self.assertFalse(f.is_javascript()) + self.assertFalse(f.is_css()) + + def test_static_file_use_directory_urls(self): + f = File('foo/bar.html', '/path/to/docs', '/path/to/site', use_directory_urls=True) + self.assertPathsEqual(f.src_path, 'foo/bar.html') + self.assertPathsEqual(f.abs_src_path, '/path/to/docs/foo/bar.html') + self.assertPathsEqual(f.dest_path, 'foo/bar.html') + self.assertPathsEqual(f.abs_dest_path, '/path/to/site/foo/bar.html') + self.assertEqual(f.url, 'foo/bar.html') + self.assertEqual(f.name, 'bar') + self.assertFalse(f.is_documentation_page()) + self.assertTrue(f.is_static_page()) + self.assertFalse(f.is_media_file()) + self.assertFalse(f.is_javascript()) + self.assertFalse(f.is_css()) + + def test_media_file(self): + f = File('foo/bar.jpg', '/path/to/docs', '/path/to/site', use_directory_urls=False) + self.assertPathsEqual(f.src_path, 'foo/bar.jpg') + self.assertPathsEqual(f.abs_src_path, '/path/to/docs/foo/bar.jpg') + self.assertPathsEqual(f.dest_path, 'foo/bar.jpg') + self.assertPathsEqual(f.abs_dest_path, '/path/to/site/foo/bar.jpg') + self.assertEqual(f.url, 'foo/bar.jpg') + self.assertEqual(f.name, 'bar') + self.assertFalse(f.is_documentation_page()) + self.assertFalse(f.is_static_page()) + self.assertTrue(f.is_media_file()) + self.assertFalse(f.is_javascript()) + self.assertFalse(f.is_css()) + + def test_media_file_use_directory_urls(self): + f = File('foo/bar.jpg', '/path/to/docs', '/path/to/site', use_directory_urls=True) + self.assertPathsEqual(f.src_path, 'foo/bar.jpg') + self.assertPathsEqual(f.abs_src_path, '/path/to/docs/foo/bar.jpg') + self.assertPathsEqual(f.dest_path, 'foo/bar.jpg') + self.assertPathsEqual(f.abs_dest_path, '/path/to/site/foo/bar.jpg') + self.assertEqual(f.url, 'foo/bar.jpg') + self.assertEqual(f.name, 'bar') + self.assertFalse(f.is_documentation_page()) + self.assertFalse(f.is_static_page()) + self.assertTrue(f.is_media_file()) + self.assertFalse(f.is_javascript()) + self.assertFalse(f.is_css()) + + def test_javascript_file(self): + f = File('foo/bar.js', '/path/to/docs', '/path/to/site', use_directory_urls=False) + self.assertPathsEqual(f.src_path, 'foo/bar.js') + self.assertPathsEqual(f.abs_src_path, '/path/to/docs/foo/bar.js') + self.assertPathsEqual(f.dest_path, 'foo/bar.js') + self.assertPathsEqual(f.abs_dest_path, '/path/to/site/foo/bar.js') + self.assertEqual(f.url, 'foo/bar.js') + self.assertEqual(f.name, 'bar') + self.assertFalse(f.is_documentation_page()) + self.assertFalse(f.is_static_page()) + self.assertTrue(f.is_media_file()) + self.assertTrue(f.is_javascript()) + self.assertFalse(f.is_css()) + + def test_javascript_file_use_directory_urls(self): + f = File('foo/bar.js', '/path/to/docs', '/path/to/site', use_directory_urls=True) + self.assertPathsEqual(f.src_path, 'foo/bar.js') + self.assertPathsEqual(f.abs_src_path, '/path/to/docs/foo/bar.js') + self.assertPathsEqual(f.dest_path, 'foo/bar.js') + self.assertPathsEqual(f.abs_dest_path, '/path/to/site/foo/bar.js') + self.assertEqual(f.url, 'foo/bar.js') + self.assertEqual(f.name, 'bar') + self.assertFalse(f.is_documentation_page()) + self.assertFalse(f.is_static_page()) + self.assertTrue(f.is_media_file()) + self.assertTrue(f.is_javascript()) + self.assertFalse(f.is_css()) + + def test_css_file(self): + f = File('foo/bar.css', '/path/to/docs', '/path/to/site', use_directory_urls=False) + self.assertPathsEqual(f.src_path, 'foo/bar.css') + self.assertPathsEqual(f.abs_src_path, '/path/to/docs/foo/bar.css') + self.assertPathsEqual(f.dest_path, 'foo/bar.css') + self.assertPathsEqual(f.abs_dest_path, '/path/to/site/foo/bar.css') + self.assertEqual(f.url, 'foo/bar.css') + self.assertEqual(f.name, 'bar') + self.assertFalse(f.is_documentation_page()) + self.assertFalse(f.is_static_page()) + self.assertTrue(f.is_media_file()) + self.assertFalse(f.is_javascript()) + self.assertTrue(f.is_css()) + + def test_css_file_use_directory_urls(self): + f = File('foo/bar.css', '/path/to/docs', '/path/to/site', use_directory_urls=True) + self.assertPathsEqual(f.src_path, 'foo/bar.css') + self.assertPathsEqual(f.abs_src_path, '/path/to/docs/foo/bar.css') + self.assertPathsEqual(f.dest_path, 'foo/bar.css') + self.assertPathsEqual(f.abs_dest_path, '/path/to/site/foo/bar.css') + self.assertEqual(f.url, 'foo/bar.css') + self.assertEqual(f.name, 'bar') + self.assertFalse(f.is_documentation_page()) + self.assertFalse(f.is_static_page()) + self.assertTrue(f.is_media_file()) + self.assertFalse(f.is_javascript()) + self.assertTrue(f.is_css()) + + def test_files(self): + fs = [ + File('index.md', '/path/to/docs', '/path/to/site', use_directory_urls=True), + File('foo/bar.md', '/path/to/docs', '/path/to/site', use_directory_urls=True), + File('foo/bar.html', '/path/to/docs', '/path/to/site', use_directory_urls=True), + File('foo/bar.jpg', '/path/to/docs', '/path/to/site', use_directory_urls=True), + File('foo/bar.js', '/path/to/docs', '/path/to/site', use_directory_urls=True), + File('foo/bar.css', '/path/to/docs', '/path/to/site', use_directory_urls=True) + ] + files = Files(fs) + self.assertEqual([f for f in files], fs) + self.assertEqual(len(files), 6) + self.assertEqual(files.documentation_pages(), [fs[0], fs[1]]) + self.assertEqual(files.static_pages(), [fs[2]]) + self.assertEqual(files.media_files(), [fs[3], fs[4], fs[5]]) + self.assertEqual(files.javascript_files(), [fs[4]]) + self.assertEqual(files.css_files(), [fs[5]]) + self.assertEqual(files.get_file_from_path('foo/bar.jpg'), fs[3]) + self.assertEqual(files.get_file_from_path('foo/bar.jpg'), fs[3]) + self.assertEqual(files.get_file_from_path('missing.jpg'), None) + self.assertTrue(fs[2].src_path in files) + self.assertTrue(fs[2].src_path in files) + extra_file = File('extra.md', '/path/to/docs', '/path/to/site', use_directory_urls=True) + self.assertFalse(extra_file.src_path in files) + files.append(extra_file) + self.assertEqual(len(files), 7) + self.assertTrue(extra_file.src_path in files) + self.assertEqual(files.documentation_pages(), [fs[0], fs[1], extra_file]) + + def test_filter_paths(self): + # Root level file + self.assertFalse(_filter_paths('foo.md', 'foo.md', False, ['bar.md'])) + self.assertTrue(_filter_paths('foo.md', 'foo.md', False, ['foo.md'])) + + # Nested file + self.assertFalse(_filter_paths('foo.md', 'baz/foo.md', False, ['bar.md'])) + self.assertTrue(_filter_paths('foo.md', 'baz/foo.md', False, ['foo.md'])) + + # Wildcard + self.assertFalse(_filter_paths('foo.md', 'foo.md', False, ['*.txt'])) + self.assertTrue(_filter_paths('foo.md', 'foo.md', False, ['*.md'])) + + # Root level dir + self.assertFalse(_filter_paths('bar', 'bar', True, ['/baz'])) + self.assertFalse(_filter_paths('bar', 'bar', True, ['/baz/'])) + self.assertTrue(_filter_paths('bar', 'bar', True, ['/bar'])) + self.assertTrue(_filter_paths('bar', 'bar', True, ['/bar/'])) + + # Nested dir + self.assertFalse(_filter_paths('bar', 'foo/bar', True, ['/bar'])) + self.assertFalse(_filter_paths('bar', 'foo/bar', True, ['/bar/'])) + self.assertTrue(_filter_paths('bar', 'foo/bar', True, ['bar/'])) + + # Files that look like dirs (no extension). Note that `is_dir` is `False`. + self.assertFalse(_filter_paths('bar', 'bar', False, ['bar/'])) + self.assertFalse(_filter_paths('bar', 'foo/bar', False, ['bar/'])) + + def test_get_relative_url_use_directory_urls(self): + to_files = [ + 'index.md', + 'foo/index.md', + 'foo/bar/index.md', + 'foo/bar/baz/index.md', + 'foo.md', + 'foo/bar.md', + 'foo/bar/baz.md' + ] + + to_file_urls = [ + '.', + 'foo/', + 'foo/bar/', + 'foo/bar/baz/', + 'foo/', + 'foo/bar/', + 'foo/bar/baz/' + ] + + from_file = File('img.jpg', '/path/to/docs', '/path/to/site', use_directory_urls=True) + expected = [ + 'img.jpg', # img.jpg relative to . + '../img.jpg', # img.jpg relative to foo/ + '../../img.jpg', # img.jpg relative to foo/bar/ + '../../../img.jpg', # img.jpg relative to foo/bar/baz/ + '../img.jpg', # img.jpg relative to foo + '../../img.jpg', # img.jpg relative to foo/bar + '../../../img.jpg' # img.jpg relative to foo/bar/baz + ] + + for i, filename in enumerate(to_files): + file = File(filename, '/path/to/docs', '/path/to/site', use_directory_urls=True) + self.assertEqual(from_file.url, 'img.jpg') + self.assertEqual(file.url, to_file_urls[i]) + self.assertEqual(from_file.url_relative_to(file.url), expected[i]) + self.assertEqual(from_file.url_relative_to(file), expected[i]) + + from_file = File('foo/img.jpg', '/path/to/docs', '/path/to/site', use_directory_urls=True) + expected = [ + 'foo/img.jpg', # foo/img.jpg relative to . + 'img.jpg', # foo/img.jpg relative to foo/ + '../img.jpg', # foo/img.jpg relative to foo/bar/ + '../../img.jpg', # foo/img.jpg relative to foo/bar/baz/ + 'img.jpg', # foo/img.jpg relative to foo + '../img.jpg', # foo/img.jpg relative to foo/bar + '../../img.jpg' # foo/img.jpg relative to foo/bar/baz + ] + + for i, filename in enumerate(to_files): + file = File(filename, '/path/to/docs', '/path/to/site', use_directory_urls=True) + self.assertEqual(from_file.url, 'foo/img.jpg') + self.assertEqual(file.url, to_file_urls[i]) + self.assertEqual(from_file.url_relative_to(file.url), expected[i]) + self.assertEqual(from_file.url_relative_to(file), expected[i]) + + from_file = File('index.html', '/path/to/docs', '/path/to/site', use_directory_urls=True) + expected = [ + '.', # . relative to . + '..', # . relative to foo/ + '../..', # . relative to foo/bar/ + '../../..', # . relative to foo/bar/baz/ + '..', # . relative to foo + '../..', # . relative to foo/bar + '../../..' # . relative to foo/bar/baz + ] + + for i, filename in enumerate(to_files): + file = File(filename, '/path/to/docs', '/path/to/site', use_directory_urls=True) + self.assertEqual(from_file.url, '.') + self.assertEqual(file.url, to_file_urls[i]) + self.assertEqual(from_file.url_relative_to(file.url), expected[i]) + self.assertEqual(from_file.url_relative_to(file), expected[i]) + + from_file = File('file.md', '/path/to/docs', '/path/to/site', use_directory_urls=True) + expected = [ + 'file/', # file relative to . + '../file/', # file relative to foo/ + '../../file/', # file relative to foo/bar/ + '../../../file/', # file relative to foo/bar/baz/ + '../file/', # file relative to foo + '../../file/', # file relative to foo/bar + '../../../file/' # file relative to foo/bar/baz + ] + + for i, filename in enumerate(to_files): + file = File(filename, '/path/to/docs', '/path/to/site', use_directory_urls=True) + self.assertEqual(from_file.url, 'file/') + self.assertEqual(file.url, to_file_urls[i]) + self.assertEqual(from_file.url_relative_to(file.url), expected[i]) + self.assertEqual(from_file.url_relative_to(file), expected[i]) + + def test_get_relative_url(self): + to_files = [ + 'index.md', + 'foo/index.md', + 'foo/bar/index.md', + 'foo/bar/baz/index.md', + 'foo.md', + 'foo/bar.md', + 'foo/bar/baz.md' + ] + + to_file_urls = [ + 'index.html', + 'foo/index.html', + 'foo/bar/index.html', + 'foo/bar/baz/index.html', + 'foo.html', + 'foo/bar.html', + 'foo/bar/baz.html' + ] + + from_file = File('img.jpg', '/path/to/docs', '/path/to/site', use_directory_urls=False) + expected = [ + 'img.jpg', # img.jpg relative to . + '../img.jpg', # img.jpg relative to foo/ + '../../img.jpg', # img.jpg relative to foo/bar/ + '../../../img.jpg', # img.jpg relative to foo/bar/baz/ + 'img.jpg', # img.jpg relative to foo.html + '../img.jpg', # img.jpg relative to foo/bar.html + '../../img.jpg' # img.jpg relative to foo/bar/baz.html + ] + + for i, filename in enumerate(to_files): + file = File(filename, '/path/to/docs', '/path/to/site', use_directory_urls=False) + self.assertEqual(from_file.url, 'img.jpg') + self.assertEqual(file.url, to_file_urls[i]) + self.assertEqual(from_file.url_relative_to(file.url), expected[i]) + self.assertEqual(from_file.url_relative_to(file), expected[i]) + + from_file = File('foo/img.jpg', '/path/to/docs', '/path/to/site', use_directory_urls=False) + expected = [ + 'foo/img.jpg', # foo/img.jpg relative to . + 'img.jpg', # foo/img.jpg relative to foo/ + '../img.jpg', # foo/img.jpg relative to foo/bar/ + '../../img.jpg', # foo/img.jpg relative to foo/bar/baz/ + 'foo/img.jpg', # foo/img.jpg relative to foo.html + 'img.jpg', # foo/img.jpg relative to foo/bar.html + '../img.jpg' # foo/img.jpg relative to foo/bar/baz.html + ] + + for i, filename in enumerate(to_files): + file = File(filename, '/path/to/docs', '/path/to/site', use_directory_urls=False) + self.assertEqual(from_file.url, 'foo/img.jpg') + self.assertEqual(file.url, to_file_urls[i]) + self.assertEqual(from_file.url_relative_to(file.url), expected[i]) + self.assertEqual(from_file.url_relative_to(file), expected[i]) + + from_file = File('index.html', '/path/to/docs', '/path/to/site', use_directory_urls=False) + expected = [ + 'index.html', # index.html relative to . + '../index.html', # index.html relative to foo/ + '../../index.html', # index.html relative to foo/bar/ + '../../../index.html', # index.html relative to foo/bar/baz/ + 'index.html', # index.html relative to foo.html + '../index.html', # index.html relative to foo/bar.html + '../../index.html' # index.html relative to foo/bar/baz.html + ] + + for i, filename in enumerate(to_files): + file = File(filename, '/path/to/docs', '/path/to/site', use_directory_urls=False) + self.assertEqual(from_file.url, 'index.html') + self.assertEqual(file.url, to_file_urls[i]) + self.assertEqual(from_file.url_relative_to(file.url), expected[i]) + self.assertEqual(from_file.url_relative_to(file), expected[i]) + + from_file = File('file.html', '/path/to/docs', '/path/to/site', use_directory_urls=False) + expected = [ + 'file.html', # file.html relative to . + '../file.html', # file.html relative to foo/ + '../../file.html', # file.html relative to foo/bar/ + '../../../file.html', # file.html relative to foo/bar/baz/ + 'file.html', # file.html relative to foo.html + '../file.html', # file.html relative to foo/bar.html + '../../file.html' # file.html relative to foo/bar/baz.html + ] + + for i, filename in enumerate(to_files): + file = File(filename, '/path/to/docs', '/path/to/site', use_directory_urls=False) + self.assertEqual(from_file.url, 'file.html') + self.assertEqual(file.url, to_file_urls[i]) + self.assertEqual(from_file.url_relative_to(file.url), expected[i]) + self.assertEqual(from_file.url_relative_to(file), expected[i]) + + @tempdir(files=[ + 'index.md', + 'bar.css', + 'bar.html', + 'bar.jpg', + 'bar.js', + 'bar.md', + '.dotfile', + 'templates/foo.html' + ]) + def test_get_files(self, tdir): + config = load_config(docs_dir=tdir, extra_css=['bar.css'], extra_javascript=['bar.js']) + files = get_files(config) + expected = ['index.md', 'bar.css', 'bar.html', 'bar.jpg', 'bar.js', 'bar.md'] + self.assertIsInstance(files, Files) + self.assertEqual(len(files), len(expected)) + self.assertEqual([f.src_path for f in files], expected) + + @tempdir() + @tempdir(files={'test.txt': 'source content'}) + def test_copy_file(self, src_dir, dest_dir): + file = File('test.txt', src_dir, dest_dir, use_directory_urls=False) + dest_path = os.path.join(dest_dir, 'test.txt') + self.assertPathNotExists(dest_path) + file.copy_file() + self.assertPathIsFile(dest_path) + + @tempdir(files={'test.txt': 'destination content'}) + @tempdir(files={'test.txt': 'source content'}) + def test_copy_file_clean_modified(self, src_dir, dest_dir): + file = File('test.txt', src_dir, dest_dir, use_directory_urls=False) + file.is_modified = mock.Mock(return_value=True) + dest_path = os.path.join(dest_dir, 'test.txt') + file.copy_file(dirty=False) + self.assertPathIsFile(dest_path) + with io.open(dest_path, 'r', encoding='utf-8') as f: + self.assertEqual(f.read(), 'source content') + + @tempdir(files={'test.txt': 'destination content'}) + @tempdir(files={'test.txt': 'source content'}) + def test_copy_file_dirty_modified(self, src_dir, dest_dir): + file = File('test.txt', src_dir, dest_dir, use_directory_urls=False) + file.is_modified = mock.Mock(return_value=True) + dest_path = os.path.join(dest_dir, 'test.txt') + file.copy_file(dirty=True) + self.assertPathIsFile(dest_path) + with io.open(dest_path, 'r', encoding='utf-8') as f: + self.assertEqual(f.read(), 'source content') + + @tempdir(files={'test.txt': 'destination content'}) + @tempdir(files={'test.txt': 'source content'}) + def test_copy_file_dirty_not_modified(self, src_dir, dest_dir): + file = File('test.txt', src_dir, dest_dir, use_directory_urls=False) + file.is_modified = mock.Mock(return_value=False) + dest_path = os.path.join(dest_dir, 'test.txt') + file.copy_file(dirty=True) + self.assertPathIsFile(dest_path) + with io.open(dest_path, 'r', encoding='utf-8') as f: + self.assertEqual(f.read(), 'destination content') diff --git a/mkdocs/tests/structure/nav_tests.py b/mkdocs/tests/structure/nav_tests.py new file mode 100644 index 0000000000..3596fb6f85 --- /dev/null +++ b/mkdocs/tests/structure/nav_tests.py @@ -0,0 +1,343 @@ +#!/usr/bin/env python +# coding: utf-8 + +from __future__ import unicode_literals +import sys +import unittest + +from mkdocs.structure.nav import get_navigation +from mkdocs.structure.files import File, Files +from mkdocs.structure.pages import Page +from mkdocs.tests.base import dedent, load_config + + +class SiteNavigationTests(unittest.TestCase): + + maxDiff = None + + def test_simple_nav(self): + nav_cfg = [ + {'Home': 'index.md'}, + {'About': 'about.md'} + ] + expected = dedent(""" + Page(title='Home', url='/') + Page(title='About', url='/about/') + """) + cfg = load_config(nav=nav_cfg, site_url='http://example.com/') + files = Files( + [File(list(item.values())[0], cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']) + for item in nav_cfg] + ) + site_navigation = get_navigation(files, cfg) + self.assertEqual(str(site_navigation).strip(), expected) + self.assertEqual(len(site_navigation.items), 2) + self.assertEqual(len(site_navigation.pages), 2) + self.assertEqual(repr(site_navigation.homepage), "Page(title='Home', url='/')") + + def test_nav_no_directory_urls(self): + nav_cfg = [ + {'Home': 'index.md'}, + {'About': 'about.md'} + ] + expected = dedent(""" + Page(title='Home', url='/index.html') + Page(title='About', url='/about.html') + """) + cfg = load_config(nav=nav_cfg, use_directory_urls=False, site_url='http://example.com/') + files = Files( + [File(list(item.values())[0], cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']) + for item in nav_cfg] + ) + site_navigation = get_navigation(files, cfg) + self.assertEqual(str(site_navigation).strip(), expected) + self.assertEqual(len(site_navigation.items), 2) + self.assertEqual(len(site_navigation.pages), 2) + + def test_nav_missing_page(self): + nav_cfg = [ + {'Home': 'index.md'} + ] + expected = dedent(""" + Page(title='Home', url='/') + """) + cfg = load_config(nav=nav_cfg, site_url='http://example.com/') + files = Files([ + File('index.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']), + File('page_not_in_nav.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']) + ]) + site_navigation = get_navigation(files, cfg) + self.assertEqual(str(site_navigation).strip(), expected) + self.assertEqual(len(site_navigation.items), 1) + self.assertEqual(len(site_navigation.pages), 1) + for file in files: + self.assertIsInstance(file.page, Page) + + def test_nav_no_title(self): + nav_cfg = [ + 'index.md', + {'About': 'about.md'} + ] + expected = dedent(""" + Page(title=[blank], url='/') + Page(title='About', url='/about/') + """) + cfg = load_config(nav=nav_cfg, site_url='http://example.com/') + files = Files([ + File(nav_cfg[0], cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']), + File(nav_cfg[1]['About'], cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']) + ]) + site_navigation = get_navigation(files, cfg) + self.assertEqual(str(site_navigation).strip(), expected) + self.assertEqual(len(site_navigation.items), 2) + self.assertEqual(len(site_navigation.pages), 2) + + def test_nav_external_links(self): + nav_cfg = [ + {'Home': 'index.md'}, + {'Local': '/local.html'}, + {'External': 'http://example.com/external.html'} + ] + expected = dedent(""" + Page(title='Home', url='/') + Link(title='Local', url='/local.html') + Link(title='External', url='http://example.com/external.html') + """) + cfg = load_config(nav=nav_cfg, site_url='http://example.com/') + files = Files([File('index.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls'])]) + site_navigation = get_navigation(files, cfg) + self.assertEqual(str(site_navigation).strip(), expected) + self.assertEqual(len(site_navigation.items), 3) + self.assertEqual(len(site_navigation.pages), 1) + + def test_indented_nav(self): + nav_cfg = [ + {'Home': 'index.md'}, + {'API Guide': [ + {'Running': 'api-guide/running.md'}, + {'Testing': 'api-guide/testing.md'}, + {'Debugging': 'api-guide/debugging.md'}, + {'Advanced': [ + {'Part 1': 'api-guide/advanced/part-1.md'}, + ]}, + ]}, + {'About': [ + {'Release notes': 'about/release-notes.md'}, + {'License': '/license.html'} + ]}, + {'External': 'https://example.com/'} + ] + expected = dedent(""" + Page(title='Home', url='/') + Section(title='API Guide') + Page(title='Running', url='/api-guide/running/') + Page(title='Testing', url='/api-guide/testing/') + Page(title='Debugging', url='/api-guide/debugging/') + Section(title='Advanced') + Page(title='Part 1', url='/api-guide/advanced/part-1/') + Section(title='About') + Page(title='Release notes', url='/about/release-notes/') + Link(title='License', url='/license.html') + Link(title='External', url='https://example.com/') + """) + cfg = load_config(nav=nav_cfg, site_url='http://example.com/') + files = Files([ + File('index.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']), + File('api-guide/running.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']), + File('api-guide/testing.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']), + File('api-guide/debugging.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']), + File('api-guide/advanced/part-1.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']), + File('about/release-notes.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']), + ]) + site_navigation = get_navigation(files, cfg) + self.assertEqual(str(site_navigation).strip(), expected) + self.assertEqual(len(site_navigation.items), 4) + self.assertEqual(len(site_navigation.pages), 6) + self.assertEqual(repr(site_navigation.homepage), "Page(title='Home', url='/')") + self.assertIsNone(site_navigation.items[0].parent) + self.assertEqual(site_navigation.items[0].ancestors, []) + self.assertIsNone(site_navigation.items[1].parent) + self.assertEqual(site_navigation.items[1].ancestors, []) + self.assertEqual(len(site_navigation.items[1].children), 4) + self.assertEqual(repr(site_navigation.items[1].children[0].parent), "Section(title='API Guide')") + self.assertEqual(site_navigation.items[1].children[0].ancestors, [site_navigation.items[1]]) + self.assertEqual(repr(site_navigation.items[1].children[1].parent), "Section(title='API Guide')") + self.assertEqual(site_navigation.items[1].children[1].ancestors, [site_navigation.items[1]]) + self.assertEqual(repr(site_navigation.items[1].children[2].parent), "Section(title='API Guide')") + self.assertEqual(site_navigation.items[1].children[2].ancestors, [site_navigation.items[1]]) + self.assertEqual(repr(site_navigation.items[1].children[3].parent), "Section(title='API Guide')") + self.assertEqual(site_navigation.items[1].children[3].ancestors, [site_navigation.items[1]]) + self.assertEqual(len(site_navigation.items[1].children[3].children), 1) + self.assertEqual(repr(site_navigation.items[1].children[3].children[0].parent), "Section(title='Advanced')") + self.assertEqual(site_navigation.items[1].children[3].children[0].ancestors, + [site_navigation.items[1].children[3], site_navigation.items[1]]) + self.assertIsNone(site_navigation.items[2].parent) + self.assertEqual(len(site_navigation.items[2].children), 2) + self.assertEqual(repr(site_navigation.items[2].children[0].parent), "Section(title='About')") + self.assertEqual(site_navigation.items[2].children[0].ancestors, [site_navigation.items[2]]) + self.assertEqual(repr(site_navigation.items[2].children[1].parent), "Section(title='About')") + self.assertEqual(site_navigation.items[2].children[1].ancestors, [site_navigation.items[2]]) + self.assertIsNone(site_navigation.items[3].parent) + self.assertEqual(site_navigation.items[3].ancestors, []) + self.assertIsNone(site_navigation.items[3].children) + + def test_nested_ungrouped_nav(self): + nav_cfg = [ + {'Home': 'index.md'}, + {'Contact': 'about/contact.md'}, + {'License Title': 'about/sub/license.md'}, + ] + expected = dedent(""" + Page(title='Home', url='/') + Page(title='Contact', url='/about/contact/') + Page(title='License Title', url='/about/sub/license/') + """) + cfg = load_config(nav=nav_cfg, site_url='http://example.com/') + files = Files( + [File(list(item.values())[0], cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']) + for item in nav_cfg] + ) + site_navigation = get_navigation(files, cfg) + self.assertEqual(str(site_navigation).strip(), expected) + self.assertEqual(len(site_navigation.items), 3) + self.assertEqual(len(site_navigation.pages), 3) + + def test_nested_ungrouped_nav_no_titles(self): + nav_cfg = [ + 'index.md', + 'about/contact.md', + 'about/sub/license.md' + ] + expected = dedent(""" + Page(title=[blank], url='/') + Page(title=[blank], url='/about/contact/') + Page(title=[blank], url='/about/sub/license/') + """) + + cfg = load_config(nav=nav_cfg, site_url='http://example.com/') + files = Files( + [File(item, cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']) for item in nav_cfg] + ) + site_navigation = get_navigation(files, cfg) + self.assertEqual(str(site_navigation).strip(), expected) + self.assertEqual(len(site_navigation.items), 3) + self.assertEqual(len(site_navigation.pages), 3) + self.assertEqual(repr(site_navigation.homepage), "Page(title=[blank], url='/')") + + @unittest.skipUnless(sys.platform.startswith("win"), "requires Windows") + def test_nested_ungrouped_no_titles_windows(self): + nav_cfg = [ + 'index.md', + 'about\\contact.md', + 'about\\sub\\license.md', + ] + expected = dedent(""" + Page(title=[blank], url='/') + Page(title=[blank], url='/about/contact/') + Page(title=[blank], url='/about/sub/license/') + """) + + cfg = load_config(nav=nav_cfg, site_url='http://example.com/') + files = Files( + [File(item, cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']) for item in nav_cfg] + ) + site_navigation = get_navigation(files, cfg) + self.assertEqual(str(site_navigation).strip(), expected) + self.assertEqual(len(site_navigation.items), 3) + self.assertEqual(len(site_navigation.pages), 3) + + def test_nav_from_files(self): + expected = dedent(""" + Page(title=[blank], url='/') + Page(title=[blank], url='/about/') + """) + cfg = load_config(site_url='http://example.com/') + files = Files([ + File('index.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']), + File('about.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']) + ]) + site_navigation = get_navigation(files, cfg) + self.assertEqual(str(site_navigation).strip(), expected) + self.assertEqual(len(site_navigation.items), 2) + self.assertEqual(len(site_navigation.pages), 2) + self.assertEqual(repr(site_navigation.homepage), "Page(title=[blank], url='/')") + + def test_nav_from_nested_files(self): + expected = dedent(""" + Page(title=[blank], url='/') + Section(title='About') + Page(title=[blank], url='/about/license/') + Page(title=[blank], url='/about/release-notes/') + Section(title='Api guide') + Page(title=[blank], url='/api-guide/debugging/') + Page(title=[blank], url='/api-guide/running/') + Page(title=[blank], url='/api-guide/testing/') + Section(title='Advanced') + Page(title=[blank], url='/api-guide/advanced/part-1/') + """) + cfg = load_config(site_url='http://example.com/') + files = Files([ + File('index.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']), + File('about/license.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']), + File('about/release-notes.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']), + File('api-guide/debugging.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']), + File('api-guide/running.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']), + File('api-guide/testing.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']), + File('api-guide/advanced/part-1.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']), + ]) + site_navigation = get_navigation(files, cfg) + self.assertEqual(str(site_navigation).strip(), expected) + self.assertEqual(len(site_navigation.items), 3) + self.assertEqual(len(site_navigation.pages), 7) + self.assertEqual(repr(site_navigation.homepage), "Page(title=[blank], url='/')") + + def test_active(self): + nav_cfg = [ + {'Home': 'index.md'}, + {'API Guide': [ + {'Running': 'api-guide/running.md'}, + {'Testing': 'api-guide/testing.md'}, + {'Debugging': 'api-guide/debugging.md'}, + {'Advanced': [ + {'Part 1': 'api-guide/advanced/part-1.md'}, + ]}, + ]}, + {'About': [ + {'Release notes': 'about/release-notes.md'}, + {'License': 'about/license.md'} + ]} + ] + cfg = load_config(nav=nav_cfg, site_url='http://example.com/') + files = Files([ + File('index.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']), + File('api-guide/running.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']), + File('api-guide/testing.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']), + File('api-guide/debugging.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']), + File('api-guide/advanced/part-1.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']), + File('about/release-notes.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']), + File('about/license.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']), + ]) + site_navigation = get_navigation(files, cfg) + # Confirm nothing is active + self.assertTrue(all(page.active is False for page in site_navigation.pages)) + self.assertTrue(all(item.active is False for item in site_navigation.items)) + # Activate + site_navigation.items[1].children[3].children[0].active = True + # Confirm ancestors are activated + self.assertTrue(site_navigation.items[1].children[3].children[0].active) + self.assertTrue(site_navigation.items[1].children[3].active) + self.assertTrue(site_navigation.items[1].active) + # Confirm non-ancestors are not activated + self.assertFalse(site_navigation.items[0].active) + self.assertFalse(site_navigation.items[1].children[0].active) + self.assertFalse(site_navigation.items[1].children[1].active) + self.assertFalse(site_navigation.items[1].children[2].active) + self.assertFalse(site_navigation.items[2].active) + self.assertFalse(site_navigation.items[2].children[0].active) + self.assertFalse(site_navigation.items[2].children[1].active) + # Deactivate + site_navigation.items[1].children[3].children[0].active = False + # Confirm ancestors are deactivated + self.assertFalse(site_navigation.items[1].children[3].children[0].active) + self.assertFalse(site_navigation.items[1].children[3].active) + self.assertFalse(site_navigation.items[1].active) diff --git a/mkdocs/tests/structure/page_tests.py b/mkdocs/tests/structure/page_tests.py new file mode 100644 index 0000000000..1557badbc8 --- /dev/null +++ b/mkdocs/tests/structure/page_tests.py @@ -0,0 +1,789 @@ +from __future__ import unicode_literals + +import unittest +import os +import sys +import mock +import io + +try: + # py>=3.2 + from tempfile import TemporaryDirectory +except ImportError: + from backports.tempfile import TemporaryDirectory + +from mkdocs.structure.pages import Page +from mkdocs.structure.files import File, Files +from mkdocs.tests.base import load_config, dedent, LogTestCase +from mkdocs.exceptions import MarkdownNotFound + + +class PageTests(unittest.TestCase): + + DOCS_DIR = os.path.join(os.path.abspath(os.path.dirname(__file__)), '../integration/subpages/docs') + + def test_homepage(self): + cfg = load_config(docs_dir=self.DOCS_DIR) + fl = File('index.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']) + self.assertIsNone(fl.page) + pg = Page('Foo', fl, cfg) + self.assertEqual(fl.page, pg) + self.assertEqual(pg.url, '') + self.assertEqual(pg.abs_url, None) + self.assertEqual(pg.canonical_url, None) + self.assertEqual(pg.edit_url, None) + self.assertEqual(pg.file, fl) + self.assertEqual(pg.content, None) + self.assertTrue(pg.is_homepage) + self.assertTrue(pg.is_index) + self.assertTrue(pg.is_page) + self.assertFalse(pg.is_section) + self.assertTrue(pg.is_top_level) + self.assertEqual(pg.markdown, None) + self.assertEqual(pg.meta, {}) + self.assertEqual(pg.next_page, None) + self.assertEqual(pg.parent, None) + self.assertEqual(pg.previous_page, None) + self.assertEqual(pg.title, 'Foo') + self.assertEqual(pg.toc, []) + + def test_nested_index_page(self): + cfg = load_config(docs_dir=self.DOCS_DIR) + fl = File('sub1/index.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']) + pg = Page('Foo', fl, cfg) + pg.parent = 'foo' + self.assertEqual(pg.url, 'sub1/') + self.assertEqual(pg.abs_url, None) + self.assertEqual(pg.canonical_url, None) + self.assertEqual(pg.edit_url, None) + self.assertEqual(pg.file, fl) + self.assertEqual(pg.content, None) + self.assertFalse(pg.is_homepage) + self.assertTrue(pg.is_index) + self.assertTrue(pg.is_page) + self.assertFalse(pg.is_section) + self.assertFalse(pg.is_top_level) + self.assertEqual(pg.markdown, None) + self.assertEqual(pg.meta, {}) + self.assertEqual(pg.next_page, None) + self.assertEqual(pg.parent, 'foo') + self.assertEqual(pg.previous_page, None) + self.assertEqual(pg.title, 'Foo') + self.assertEqual(pg.toc, []) + + def test_nested_nonindex_page(self): + cfg = load_config(docs_dir=self.DOCS_DIR) + fl = File('sub1/non-index.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']) + pg = Page('Foo', fl, cfg) + pg.parent = 'foo' + self.assertEqual(pg.url, 'sub1/non-index/') + self.assertEqual(pg.abs_url, None) + self.assertEqual(pg.canonical_url, None) + self.assertEqual(pg.edit_url, None) + self.assertEqual(pg.file, fl) + self.assertEqual(pg.content, None) + self.assertFalse(pg.is_homepage) + self.assertFalse(pg.is_index) + self.assertTrue(pg.is_page) + self.assertFalse(pg.is_section) + self.assertFalse(pg.is_top_level) + self.assertEqual(pg.markdown, None) + self.assertEqual(pg.meta, {}) + self.assertEqual(pg.next_page, None) + self.assertEqual(pg.parent, 'foo') + self.assertEqual(pg.previous_page, None) + self.assertEqual(pg.title, 'Foo') + self.assertEqual(pg.toc, []) + + def test_page_defaults(self): + cfg = load_config() + fl = File('testing.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']) + pg = Page('Foo', fl, cfg) + self.assertRegexpMatches(pg.update_date, r'\d{4}-\d{2}-\d{2}') + self.assertEqual(pg.url, 'testing/') + self.assertEqual(pg.abs_url, None) + self.assertEqual(pg.canonical_url, None) + self.assertEqual(pg.edit_url, None) + self.assertEqual(pg.file, fl) + self.assertEqual(pg.content, None) + self.assertFalse(pg.is_homepage) + self.assertFalse(pg.is_index) + self.assertTrue(pg.is_page) + self.assertFalse(pg.is_section) + self.assertTrue(pg.is_top_level) + self.assertEqual(pg.markdown, None) + self.assertEqual(pg.meta, {}) + self.assertEqual(pg.next_page, None) + self.assertEqual(pg.parent, None) + self.assertEqual(pg.previous_page, None) + self.assertEqual(pg.title, 'Foo') + self.assertEqual(pg.toc, []) + + def test_page_no_directory_url(self): + cfg = load_config(use_directory_urls=False) + fl = File('testing.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']) + pg = Page('Foo', fl, cfg) + self.assertEqual(pg.url, 'testing.html') + self.assertEqual(pg.abs_url, None) + self.assertEqual(pg.canonical_url, None) + self.assertEqual(pg.edit_url, None) + self.assertEqual(pg.file, fl) + self.assertEqual(pg.content, None) + self.assertFalse(pg.is_homepage) + self.assertFalse(pg.is_index) + self.assertTrue(pg.is_page) + self.assertFalse(pg.is_section) + self.assertTrue(pg.is_top_level) + self.assertEqual(pg.markdown, None) + self.assertEqual(pg.meta, {}) + self.assertEqual(pg.next_page, None) + self.assertEqual(pg.parent, None) + self.assertEqual(pg.previous_page, None) + self.assertEqual(pg.title, 'Foo') + self.assertEqual(pg.toc, []) + + def test_page_canonical_url(self): + cfg = load_config(site_url='http://example.com') + fl = File('testing.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']) + pg = Page('Foo', fl, cfg) + self.assertEqual(pg.url, 'testing/') + self.assertEqual(pg.abs_url, '/testing/') + self.assertEqual(pg.canonical_url, 'http://example.com/testing/') + self.assertEqual(pg.edit_url, None) + self.assertEqual(pg.file, fl) + self.assertEqual(pg.content, None) + self.assertFalse(pg.is_homepage) + self.assertFalse(pg.is_index) + self.assertTrue(pg.is_page) + self.assertFalse(pg.is_section) + self.assertTrue(pg.is_top_level) + self.assertEqual(pg.markdown, None) + self.assertEqual(pg.meta, {}) + self.assertEqual(pg.next_page, None) + self.assertEqual(pg.parent, None) + self.assertEqual(pg.previous_page, None) + self.assertEqual(pg.title, 'Foo') + self.assertEqual(pg.toc, []) + + def test_page_canonical_url_nested(self): + cfg = load_config(site_url='http://example.com/foo/') + fl = File('testing.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']) + pg = Page('Foo', fl, cfg) + self.assertEqual(pg.url, 'testing/') + self.assertEqual(pg.abs_url, '/foo/testing/') + self.assertEqual(pg.canonical_url, 'http://example.com/foo/testing/') + self.assertEqual(pg.edit_url, None) + self.assertEqual(pg.file, fl) + self.assertEqual(pg.content, None) + self.assertFalse(pg.is_homepage) + self.assertFalse(pg.is_index) + self.assertTrue(pg.is_page) + self.assertFalse(pg.is_section) + self.assertTrue(pg.is_top_level) + self.assertEqual(pg.markdown, None) + self.assertEqual(pg.meta, {}) + self.assertEqual(pg.next_page, None) + self.assertEqual(pg.parent, None) + self.assertEqual(pg.previous_page, None) + self.assertEqual(pg.title, 'Foo') + self.assertEqual(pg.toc, []) + + def test_page_canonical_url_nested_no_slash(self): + cfg = load_config(site_url='http://example.com/foo') + fl = File('testing.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']) + pg = Page('Foo', fl, cfg) + self.assertEqual(pg.url, 'testing/') + self.assertEqual(pg.abs_url, '/foo/testing/') + self.assertEqual(pg.canonical_url, 'http://example.com/foo/testing/') + self.assertEqual(pg.edit_url, None) + self.assertEqual(pg.file, fl) + self.assertEqual(pg.content, None) + self.assertFalse(pg.is_homepage) + self.assertFalse(pg.is_index) + self.assertTrue(pg.is_page) + self.assertFalse(pg.is_section) + self.assertTrue(pg.is_top_level) + self.assertEqual(pg.markdown, None) + self.assertEqual(pg.meta, {}) + self.assertEqual(pg.next_page, None) + self.assertEqual(pg.parent, None) + self.assertEqual(pg.previous_page, None) + self.assertEqual(pg.title, 'Foo') + self.assertEqual(pg.toc, []) + + def test_predefined_page_title(self): + cfg = load_config() + fl = File('testing.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']) + pg = Page('Page Title', fl, cfg) + pg.read_source(cfg) + self.assertEqual(pg.url, 'testing/') + self.assertEqual(pg.abs_url, None) + self.assertEqual(pg.canonical_url, None) + self.assertEqual(pg.edit_url, None) + self.assertEqual(pg.file, fl) + self.assertEqual(pg.content, None) + self.assertFalse(pg.is_homepage) + self.assertFalse(pg.is_index) + self.assertTrue(pg.is_page) + self.assertFalse(pg.is_section) + self.assertTrue(pg.is_top_level) + self.assertTrue(pg.markdown.startswith('# Welcome to MkDocs\n')) + self.assertEqual(pg.meta, {}) + self.assertEqual(pg.next_page, None) + self.assertEqual(pg.parent, None) + self.assertEqual(pg.previous_page, None) + self.assertEqual(pg.title, 'Page Title') + self.assertEqual(pg.toc, []) + + def test_page_title_from_markdown(self): + cfg = load_config() + fl = File('testing.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']) + pg = Page(None, fl, cfg) + pg.read_source(cfg) + self.assertEqual(pg.url, 'testing/') + self.assertEqual(pg.abs_url, None) + self.assertEqual(pg.canonical_url, None) + self.assertEqual(pg.edit_url, None) + self.assertEqual(pg.file, fl) + self.assertEqual(pg.content, None) + self.assertFalse(pg.is_homepage) + self.assertFalse(pg.is_index) + self.assertTrue(pg.is_page) + self.assertFalse(pg.is_section) + self.assertTrue(pg.is_top_level) + self.assertTrue(pg.markdown.startswith('# Welcome to MkDocs\n')) + self.assertEqual(pg.meta, {}) + self.assertEqual(pg.next_page, None) + self.assertEqual(pg.parent, None) + self.assertEqual(pg.previous_page, None) + self.assertEqual(pg.title, 'Welcome to MkDocs') + self.assertEqual(pg.toc, []) + + def test_page_title_from_meta(self): + cfg = load_config(docs_dir=self.DOCS_DIR) + fl = File('metadata.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']) + pg = Page(None, fl, cfg) + pg.read_source(cfg) + self.assertEqual(pg.url, 'metadata/') + self.assertEqual(pg.abs_url, None) + self.assertEqual(pg.canonical_url, None) + self.assertEqual(pg.edit_url, None) + self.assertEqual(pg.file, fl) + self.assertEqual(pg.content, None) + self.assertFalse(pg.is_homepage) + self.assertFalse(pg.is_index) + self.assertTrue(pg.is_page) + self.assertFalse(pg.is_section) + self.assertTrue(pg.is_top_level) + self.assertTrue(pg.markdown.startswith('# Welcome to MkDocs\n')) + self.assertEqual(pg.meta, {'title': 'A Page Title'}) + self.assertEqual(pg.next_page, None) + self.assertEqual(pg.parent, None) + self.assertEqual(pg.previous_page, None) + self.assertEqual(pg.title, 'A Page Title') + self.assertEqual(pg.toc, []) + + def test_page_title_from_filename(self): + cfg = load_config(docs_dir=self.DOCS_DIR) + fl = File('page-title.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']) + pg = Page(None, fl, cfg) + pg.read_source(cfg) + self.assertEqual(pg.url, 'page-title/') + self.assertEqual(pg.abs_url, None) + self.assertEqual(pg.canonical_url, None) + self.assertEqual(pg.edit_url, None) + self.assertEqual(pg.file, fl) + self.assertEqual(pg.content, None) + self.assertFalse(pg.is_homepage) + self.assertFalse(pg.is_index) + self.assertTrue(pg.is_page) + self.assertFalse(pg.is_section) + self.assertTrue(pg.is_top_level) + self.assertTrue(pg.markdown.startswith('Page content.\n')) + self.assertEqual(pg.meta, {}) + self.assertEqual(pg.next_page, None) + self.assertEqual(pg.parent, None) + self.assertEqual(pg.previous_page, None) + self.assertEqual(pg.title, 'Page title') + self.assertEqual(pg.toc, []) + + def test_page_title_from_capitalized_filename(self): + cfg = load_config(docs_dir=self.DOCS_DIR) + fl = File('pageTitle.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']) + pg = Page(None, fl, cfg) + pg.read_source(cfg) + self.assertEqual(pg.url, 'pageTitle/') + self.assertEqual(pg.abs_url, None) + self.assertEqual(pg.canonical_url, None) + self.assertEqual(pg.edit_url, None) + self.assertEqual(pg.file, fl) + self.assertEqual(pg.content, None) + self.assertFalse(pg.is_homepage) + self.assertFalse(pg.is_index) + self.assertTrue(pg.is_page) + self.assertFalse(pg.is_section) + self.assertTrue(pg.is_top_level) + self.assertTrue(pg.markdown.startswith('Page content.\n')) + self.assertEqual(pg.meta, {}) + self.assertEqual(pg.next_page, None) + self.assertEqual(pg.parent, None) + self.assertEqual(pg.previous_page, None) + self.assertEqual(pg.title, 'pageTitle') + self.assertEqual(pg.toc, []) + + def test_page_title_from_homepage_filename(self): + cfg = load_config(docs_dir=self.DOCS_DIR) + fl = File('index.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']) + pg = Page(None, fl, cfg) + pg.read_source(cfg) + self.assertEqual(pg.url, '') + self.assertEqual(pg.abs_url, None) + self.assertEqual(pg.canonical_url, None) + self.assertEqual(pg.edit_url, None) + self.assertEqual(pg.file, fl) + self.assertEqual(pg.content, None) + self.assertTrue(pg.is_homepage) + self.assertTrue(pg.is_index) + self.assertTrue(pg.is_page) + self.assertFalse(pg.is_section) + self.assertTrue(pg.is_top_level) + self.assertTrue(pg.markdown.startswith('## Test')) + self.assertEqual(pg.meta, {}) + self.assertEqual(pg.next_page, None) + self.assertEqual(pg.parent, None) + self.assertEqual(pg.previous_page, None) + self.assertEqual(pg.title, 'Home') + self.assertEqual(pg.toc, []) + + def test_page_eq(self): + cfg = load_config() + fl = File('testing.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']) + pg = Page('Foo', fl, cfg) + self.assertTrue(pg == Page('Foo', fl, cfg)) + + def test_page_ne(self): + cfg = load_config() + f1 = File('testing.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']) + f2 = File('index.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']) + pg = Page('Foo', f1, cfg) + # Different Title + self.assertTrue(pg != Page('Bar', f1, cfg)) + # Different File + self.assertTrue(pg != Page('Foo', f2, cfg)) + + def test_BOM(self): + md_src = '# An UTF-8 encoded file with a BOM' + with TemporaryDirectory() as docs_dir: + # We don't use mkdocs.tests.base.tempdir decorator here due to uniqueness of this test. + cfg = load_config(docs_dir=docs_dir) + fl = File('index.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']) + pg = Page(None, fl, cfg) + # Create an UTF-8 Encoded file with BOM (as Micorsoft editors do). See #1186 + with io.open(fl.abs_src_path, 'w', encoding='utf-8-sig') as f: + f.write(md_src) + # Now read the file. + pg.read_source(cfg) + # Ensure the BOM (`\ufeff`) is removed + self.assertNotIn('\ufeff', pg.markdown) + self.assertEqual(pg.markdown, md_src) + self.assertEqual(pg.meta, {}) + + def test_page_edit_url(self): + configs = [ + { + 'repo_url': 'http://github.com/mkdocs/mkdocs' + }, + { + 'repo_url': 'https://github.com/mkdocs/mkdocs/' + }, { + 'repo_url': 'http://example.com' + }, { + 'repo_url': 'http://example.com', + 'edit_uri': 'edit/master' + }, { + 'repo_url': 'http://example.com', + 'edit_uri': '/edit/master' + }, { + 'repo_url': 'http://example.com/foo/', + 'edit_uri': '/edit/master/' + }, { + 'repo_url': 'http://example.com/foo', + 'edit_uri': '/edit/master/' + }, { + 'repo_url': 'http://example.com/foo/', + 'edit_uri': '/edit/master' + }, { + 'repo_url': 'http://example.com/foo/', + 'edit_uri': 'edit/master/' + }, { + 'repo_url': 'http://example.com/foo', + 'edit_uri': 'edit/master/' + }, { + 'repo_url': 'http://example.com', + 'edit_uri': '?query=edit/master' + }, { + 'repo_url': 'http://example.com/', + 'edit_uri': '?query=edit/master/' + }, { + 'repo_url': 'http://example.com', + 'edit_uri': '#edit/master' + }, { + 'repo_url': 'http://example.com/', + 'edit_uri': '#edit/master/' + }, { + 'repo_url': 'http://example.com', + 'edit_uri': '' # Set to blank value + }, { + # Nothing defined + } + ] + + expected = [ + 'http://github.com/mkdocs/mkdocs/edit/master/docs/testing.md', + 'https://github.com/mkdocs/mkdocs/edit/master/docs/testing.md', + None, + 'http://example.com/edit/master/testing.md', + 'http://example.com/edit/master/testing.md', + 'http://example.com/edit/master/testing.md', + 'http://example.com/edit/master/testing.md', + 'http://example.com/edit/master/testing.md', + 'http://example.com/foo/edit/master/testing.md', + 'http://example.com/foo/edit/master/testing.md', + 'http://example.com?query=edit/master/testing.md', + 'http://example.com/?query=edit/master/testing.md', + 'http://example.com#edit/master/testing.md', + 'http://example.com/#edit/master/testing.md', + None, + None + ] + + for i, c in enumerate(configs): + cfg = load_config(**c) + fl = File('testing.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']) + pg = Page('Foo', fl, cfg) + self.assertEqual(pg.url, 'testing/') + self.assertEqual(pg.edit_url, expected[i]) + + def test_nested_page_edit_url(self): + configs = [ + { + 'repo_url': 'http://github.com/mkdocs/mkdocs' + }, + { + 'repo_url': 'https://github.com/mkdocs/mkdocs/' + }, { + 'repo_url': 'http://example.com' + }, { + 'repo_url': 'http://example.com', + 'edit_uri': 'edit/master' + }, { + 'repo_url': 'http://example.com', + 'edit_uri': '/edit/master' + }, { + 'repo_url': 'http://example.com/foo/', + 'edit_uri': '/edit/master/' + }, { + 'repo_url': 'http://example.com/foo', + 'edit_uri': '/edit/master/' + }, { + 'repo_url': 'http://example.com/foo/', + 'edit_uri': '/edit/master' + }, { + 'repo_url': 'http://example.com/foo/', + 'edit_uri': 'edit/master/' + }, { + 'repo_url': 'http://example.com/foo', + 'edit_uri': 'edit/master/' + }, { + 'repo_url': 'http://example.com', + 'edit_uri': '?query=edit/master' + }, { + 'repo_url': 'http://example.com/', + 'edit_uri': '?query=edit/master/' + }, { + 'repo_url': 'http://example.com', + 'edit_uri': '#edit/master' + }, { + 'repo_url': 'http://example.com/', + 'edit_uri': '#edit/master/' + } + ] + + expected = [ + 'http://github.com/mkdocs/mkdocs/edit/master/docs/sub1/non-index.md', + 'https://github.com/mkdocs/mkdocs/edit/master/docs/sub1/non-index.md', + None, + 'http://example.com/edit/master/sub1/non-index.md', + 'http://example.com/edit/master/sub1/non-index.md', + 'http://example.com/edit/master/sub1/non-index.md', + 'http://example.com/edit/master/sub1/non-index.md', + 'http://example.com/edit/master/sub1/non-index.md', + 'http://example.com/foo/edit/master/sub1/non-index.md', + 'http://example.com/foo/edit/master/sub1/non-index.md', + 'http://example.com?query=edit/master/sub1/non-index.md', + 'http://example.com/?query=edit/master/sub1/non-index.md', + 'http://example.com#edit/master/sub1/non-index.md', + 'http://example.com/#edit/master/sub1/non-index.md' + ] + + for i, c in enumerate(configs): + c['docs_dir'] = self.DOCS_DIR + cfg = load_config(**c) + fl = File('sub1/non-index.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']) + pg = Page('Foo', fl, cfg) + self.assertEqual(pg.url, 'sub1/non-index/') + self.assertEqual(pg.edit_url, expected[i]) + + @unittest.skipUnless(sys.platform.startswith("win"), "requires Windows") + def test_nested_page_edit_url_windows(self): + configs = [ + { + 'repo_url': 'http://github.com/mkdocs/mkdocs' + }, + { + 'repo_url': 'https://github.com/mkdocs/mkdocs/' + }, { + 'repo_url': 'http://example.com' + }, { + 'repo_url': 'http://example.com', + 'edit_uri': 'edit/master' + }, { + 'repo_url': 'http://example.com', + 'edit_uri': '/edit/master' + }, { + 'repo_url': 'http://example.com/foo/', + 'edit_uri': '/edit/master/' + }, { + 'repo_url': 'http://example.com/foo', + 'edit_uri': '/edit/master/' + }, { + 'repo_url': 'http://example.com/foo/', + 'edit_uri': '/edit/master' + }, { + 'repo_url': 'http://example.com/foo/', + 'edit_uri': 'edit/master/' + }, { + 'repo_url': 'http://example.com/foo', + 'edit_uri': 'edit/master/' + }, { + 'repo_url': 'http://example.com', + 'edit_uri': '?query=edit/master' + }, { + 'repo_url': 'http://example.com/', + 'edit_uri': '?query=edit/master/' + }, { + 'repo_url': 'http://example.com', + 'edit_uri': '#edit/master' + }, { + 'repo_url': 'http://example.com/', + 'edit_uri': '#edit/master/' + } + ] + + expected = [ + 'http://github.com/mkdocs/mkdocs/edit/master/docs/sub1/non-index.md', + 'https://github.com/mkdocs/mkdocs/edit/master/docs/sub1/non-index.md', + None, + 'http://example.com/edit/master/sub1/non-index.md', + 'http://example.com/edit/master/sub1/non-index.md', + 'http://example.com/edit/master/sub1/non-index.md', + 'http://example.com/edit/master/sub1/non-index.md', + 'http://example.com/edit/master/sub1/non-index.md', + 'http://example.com/foo/edit/master/sub1/non-index.md', + 'http://example.com/foo/edit/master/sub1/non-index.md', + 'http://example.com?query=edit/master/sub1/non-index.md', + 'http://example.com/?query=edit/master/sub1/non-index.md', + 'http://example.com#edit/master/sub1/non-index.md', + 'http://example.com/#edit/master/sub1/non-index.md' + ] + + for i, c in enumerate(configs): + c['docs_dir'] = self.DOCS_DIR + cfg = load_config(**c) + fl = File('sub1\\non-index.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']) + pg = Page('Foo', fl, cfg) + self.assertEqual(pg.url, 'sub1/non-index/') + self.assertEqual(pg.edit_url, expected[i]) + + def test_page_render(self): + cfg = load_config() + fl = File('testing.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']) + pg = Page('Foo', fl, cfg) + pg.read_source(cfg) + self.assertEqual(pg.content, None) + self.assertEqual(pg.toc, []) + pg.render(cfg, [fl]) + self.assertTrue(pg.content.startswith( + '

    Welcome to MkDocs

    \n' + )) + self.assertEqual(str(pg.toc).strip(), dedent(""" + Welcome to MkDocs - #welcome-to-mkdocs + Commands - #commands + Project layout - #project-layout + """)) + + def test_missing_page(self): + cfg = load_config() + fl = File('missing.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']) + pg = Page('Foo', fl, cfg) + self.assertRaises(IOError, pg.read_source, cfg) + + +class SourceDateEpochTests(unittest.TestCase): + + def setUp(self): + self.default = os.environ.get('SOURCE_DATE_EPOCH', None) + os.environ['SOURCE_DATE_EPOCH'] = '0' + + def test_source_date_epoch(self): + cfg = load_config() + fl = File('testing.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']) + pg = Page('Foo', fl, cfg) + self.assertEqual(pg.update_date, '1970-01-01') + + def tearDown(self): + if self.default is not None: + os.environ['SOURCE_DATE_EPOCH'] = self.default + else: + del os.environ['SOURCE_DATE_EPOCH'] + + +class RelativePathExtensionTests(LogTestCase): + + DOCS_DIR = os.path.join(os.path.abspath(os.path.dirname(__file__)), '../integration/subpages/docs') + + def get_rendered_result(self, files, strict=False): + cfg = load_config(docs_dir=self.DOCS_DIR, strict=strict) + fs = [] + for f in files: + fs.append(File(f.replace('/', os.sep), cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls'])) + pg = Page('Foo', fs[0], cfg) + pg.read_source(cfg) + pg.render(cfg, Files(fs)) + return pg.content + + @mock.patch('io.open', mock.mock_open(read_data='[link](non-index.md)')) + def test_relative_html_link(self): + self.assertEqual( + self.get_rendered_result(['index.md', 'non-index.md']), + '

    link

    ' # No trailing / + ) + + @mock.patch('io.open', mock.mock_open(read_data='[link](index.md)')) + def test_relative_html_link_index(self): + self.assertEqual( + self.get_rendered_result(['non-index.md', 'index.md']), + '

    link

    ' + ) + + @mock.patch('io.open', mock.mock_open(read_data='[link](sub2/index.md)')) + def test_relative_html_link_sub_index(self): + self.assertEqual( + self.get_rendered_result(['index.md', 'sub2/index.md']), + '

    link

    ' # No trailing / + ) + + @mock.patch('io.open', mock.mock_open(read_data='[link](sub2/non-index.md)')) + def test_relative_html_link_sub_page(self): + self.assertEqual( + self.get_rendered_result(['index.md', 'sub2/non-index.md']), + '

    link

    ' # No trailing / + ) + + @mock.patch('io.open', mock.mock_open(read_data='[link](../index.md)')) + def test_relative_html_link_parent_index(self): + self.assertEqual( + self.get_rendered_result(['sub2/non-index.md', 'index.md']), + '

    link

    ' + ) + + @mock.patch('io.open', mock.mock_open(read_data='[link](non-index.md#hash)')) + def test_relative_html_link_hash(self): + self.assertEqual( + self.get_rendered_result(['index.md', 'non-index.md']), + '

    link

    ' + ) + + @mock.patch('io.open', mock.mock_open(read_data='[link](sub2/index.md#hash)')) + def test_relative_html_link_sub_index_hash(self): + self.assertEqual( + self.get_rendered_result(['index.md', 'sub2/index.md']), + '

    link

    ' + ) + + @mock.patch('io.open', mock.mock_open(read_data='[link](sub2/non-index.md#hash)')) + def test_relative_html_link_sub_page_hash(self): + self.assertEqual( + self.get_rendered_result(['index.md', 'sub2/non-index.md']), + '

    link

    ' + ) + + @mock.patch('io.open', mock.mock_open(read_data='[link](#hash)')) + def test_relative_html_link_hash_only(self): + self.assertEqual( + self.get_rendered_result(['index.md']), + '

    link

    ' + ) + + @mock.patch('io.open', mock.mock_open(read_data='![image](image.png)')) + def test_relative_image_link_from_homepage(self): + self.assertEqual( + self.get_rendered_result(['index.md', 'image.png']), + '

    image

    ' # no opening ./ + ) + + @mock.patch('io.open', mock.mock_open(read_data='![image](../image.png)')) + def test_relative_image_link_from_subpage(self): + self.assertEqual( + self.get_rendered_result(['sub2/non-index.md', 'image.png']), + '

    image

    ' + ) + + @mock.patch('io.open', mock.mock_open(read_data='![image](image.png)')) + def test_relative_image_link_from_sibling(self): + self.assertEqual( + self.get_rendered_result(['non-index.md', 'image.png']), + '

    image

    ' + ) + + @mock.patch('io.open', mock.mock_open(read_data='*__not__ a link*.')) + def test_no_links(self): + self.assertEqual( + self.get_rendered_result(['index.md'], strict=True), + '

    not a link.

    ' + ) + + @mock.patch('io.open', mock.mock_open(read_data='[link](non-existant.md)')) + def test_bad_relative_html_link(self): + with self.assertLogs('mkdocs', level='WARNING') as cm: + self.assertEqual( + self.get_rendered_result(['index.md']), + '

    link

    ' + ) + self.assertEqual( + cm.output, + ["WARNING:mkdocs.structure.pages:Documentation file 'index.md' contains a link " + "to 'non-existant.md' which does not exist in the documentation directory."] + ) + + @mock.patch('io.open', mock.mock_open(read_data='[link](non-existant.md)')) + def test_bad_relative_html_link_strict(self): + self.assertRaises(MarkdownNotFound, self.get_rendered_result, ['index.md'], strict=True) + + @mock.patch('io.open', mock.mock_open(read_data='[external link](http://example.com/index.md)')) + def test_external_link(self): + self.assertEqual( + self.get_rendered_result(['index.md'], strict=True), + '

    external link

    ' + ) + + @mock.patch('io.open', mock.mock_open(read_data='')) + def test_email_link(self): + self.assertEqual( + self.get_rendered_result(['index.md'], strict=True), + # Markdown's default behavior is to obscure email addresses by entity-encoding them. + # The following is equivalent to: '

    mail@example.com

    ' + '

    mail@' + 'example.com

    ' + ) diff --git a/mkdocs/tests/toc_tests.py b/mkdocs/tests/structure/toc_tests.py similarity index 63% rename from mkdocs/tests/toc_tests.py rename to mkdocs/tests/structure/toc_tests.py index 16a1318901..96c684c56a 100644 --- a/mkdocs/tests/toc_tests.py +++ b/mkdocs/tests/structure/toc_tests.py @@ -3,12 +3,29 @@ from __future__ import unicode_literals import unittest - -from mkdocs.tests.base import dedent, markdown_to_toc +from mkdocs.structure.toc import get_toc +from mkdocs.tests.base import dedent, get_markdown_toc class TableOfContentsTests(unittest.TestCase): + def test_html_toc(self): + html = dedent(""" +
    + +
    + """) + expected = dedent(""" + Heading 1 - #foo + Heading 2 - #bar + """) + toc = get_toc(html) + self.assertEqual(str(toc).strip(), expected) + self.assertEqual(len(toc), 2) + def test_indented_toc(self): md = dedent(""" # Heading 1 @@ -20,8 +37,9 @@ def test_indented_toc(self): Heading 2 - #heading-2 Heading 3 - #heading-3 """) - toc = markdown_to_toc(md) + toc = get_toc(get_markdown_toc(md)) self.assertEqual(str(toc).strip(), expected) + self.assertEqual(len(toc), 1) def test_indented_toc_html(self): md = dedent(""" @@ -34,8 +52,9 @@ def test_indented_toc_html(self): Heading 2 - #heading-2 Heading 3 - #heading-3 """) - toc = markdown_to_toc(md) + toc = get_toc(get_markdown_toc(md)) self.assertEqual(str(toc).strip(), expected) + self.assertEqual(len(toc), 1) def test_flat_toc(self): md = dedent(""" @@ -48,8 +67,9 @@ def test_flat_toc(self): Heading 2 - #heading-2 Heading 3 - #heading-3 """) - toc = markdown_to_toc(md) + toc = get_toc(get_markdown_toc(md)) self.assertEqual(str(toc).strip(), expected) + self.assertEqual(len(toc), 3) def test_flat_h2_toc(self): md = dedent(""" @@ -62,8 +82,9 @@ def test_flat_h2_toc(self): Heading 2 - #heading-2 Heading 3 - #heading-3 """) - toc = markdown_to_toc(md) + toc = get_toc(get_markdown_toc(md)) self.assertEqual(str(toc).strip(), expected) + self.assertEqual(len(toc), 3) def test_mixed_toc(self): md = dedent(""" @@ -80,8 +101,9 @@ def test_mixed_toc(self): Heading 4 - #heading-4 Heading 5 - #heading-5 """) - toc = markdown_to_toc(md) + toc = get_toc(get_markdown_toc(md)) self.assertEqual(str(toc).strip(), expected) + self.assertEqual(len(toc), 2) def test_mixed_html(self): md = dedent(""" @@ -98,8 +120,9 @@ def test_mixed_html(self): Heading 4 - #heading-4 Heading 5 - #heading-5 """) - toc = markdown_to_toc(md) + toc = get_toc(get_markdown_toc(md)) self.assertEqual(str(toc).strip(), expected) + self.assertEqual(len(toc), 2) def test_nested_anchor(self): md = dedent(""" @@ -116,8 +139,9 @@ def test_nested_anchor(self): Heading 4 - #heading-4 Heading 5 - #heading-5 """) - toc = markdown_to_toc(md) + toc = get_toc(get_markdown_toc(md)) self.assertEqual(str(toc).strip(), expected) + self.assertEqual(len(toc), 2) def test_entityref(self): md = dedent(""" @@ -130,5 +154,27 @@ def test_entityref(self): Heading > 2 - #heading-2 Heading < 3 - #heading-3 """) - toc = markdown_to_toc(md) + toc = get_toc(get_markdown_toc(md)) + self.assertEqual(str(toc).strip(), expected) + self.assertEqual(len(toc), 1) + + def test_charref(self): + md = '# @Header' + expected = '@Header - #header' + toc = get_toc(get_markdown_toc(md)) + self.assertEqual(str(toc).strip(), expected) + self.assertEqual(len(toc), 1) + + def test_skip_no_href(self): + html = dedent(""" +
    + +
    + """) + expected = 'Header 2 - #foo' + toc = get_toc(html) self.assertEqual(str(toc).strip(), expected) + self.assertEqual(len(toc), 1) diff --git a/mkdocs/tests/utils/utils_tests.py b/mkdocs/tests/utils/utils_tests.py index 9dc33ab295..fca6c0a07d 100644 --- a/mkdocs/tests/utils/utils_tests.py +++ b/mkdocs/tests/utils/utils_tests.py @@ -10,7 +10,9 @@ import shutil import stat -from mkdocs import nav, utils, exceptions +from mkdocs import utils, exceptions +from mkdocs.structure.files import File +from mkdocs.structure.pages import Page from mkdocs.tests.base import dedent, load_config @@ -60,78 +62,116 @@ def test_is_html_file(self): self.assertEqual(is_html, expected_result) def test_create_media_urls(self): - pages = [ - {'Home': 'index.md'}, - {'About': 'about.md'}, - {'Sub': [ - {'Sub Home': 'index.md'}, - {'Sub About': 'about.md'}, - ]} - ] expected_results = { - 'https://media.cdn.org/jq.js': 'https://media.cdn.org/jq.js', - 'http://media.cdn.org/jquery.js': 'http://media.cdn.org/jquery.js', - '//media.cdn.org/jquery.js': '//media.cdn.org/jquery.js', - 'media.cdn.org/jquery.js': './media.cdn.org/jquery.js', - 'local/file/jquery.js': './local/file/jquery.js', - 'local\\windows\\file\\jquery.js': './local/windows/file/jquery.js', - 'image.png': './image.png', - 'style.css?v=20180308c': './style.css?v=20180308c' + 'https://media.cdn.org/jq.js': [ + 'https://media.cdn.org/jq.js', + 'https://media.cdn.org/jq.js', + 'https://media.cdn.org/jq.js' + ], + 'http://media.cdn.org/jquery.js': [ + 'http://media.cdn.org/jquery.js', + 'http://media.cdn.org/jquery.js', + 'http://media.cdn.org/jquery.js' + ], + '//media.cdn.org/jquery.js': [ + '//media.cdn.org/jquery.js', + '//media.cdn.org/jquery.js', + '//media.cdn.org/jquery.js' + ], + 'media.cdn.org/jquery.js': [ + 'media.cdn.org/jquery.js', + 'media.cdn.org/jquery.js', + '../media.cdn.org/jquery.js' + ], + 'local/file/jquery.js': [ + 'local/file/jquery.js', + 'local/file/jquery.js', + '../local/file/jquery.js' + ], + 'local\\windows\\file\\jquery.js': [ + 'local/windows/file/jquery.js', + 'local/windows/file/jquery.js', + '../local/windows/file/jquery.js' + ], + 'image.png': [ + 'image.png', + 'image.png', + '../image.png' + ], + 'style.css?v=20180308c': [ + 'style.css?v=20180308c', + 'style.css?v=20180308c', + '../style.css?v=20180308c' + ] } - site_navigation = nav.SiteNavigation(load_config(pages=pages)) - for path, expected_result in expected_results.items(): - urls = utils.create_media_urls(site_navigation, [path]) - self.assertEqual(urls[0], expected_result) - - def test_create_relative_media_url_sub_index(self): - ''' - test special case where there's a sub/index.md page - ''' + cfg = load_config(use_directory_urls=False) pages = [ - {'Home': 'index.md'}, - {'Sub': [ - {'Sub Home': '/subpage/index.md'}, - - ]} + Page('Home', File('index.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']), cfg), + Page('About', File('about.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']), cfg), + Page('FooBar', File('foo/bar.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']), cfg) ] - site_navigation = nav.SiteNavigation(load_config(pages=pages)) - site_navigation.url_context.set_current_url('/subpage/') - site_navigation.file_context.current_file = "subpage/index.md" - def assertPathGenerated(declared, expected): - url = utils.create_relative_media_url(site_navigation, declared) - self.assertEqual(url, expected) + for i, page in enumerate(pages): + urls = utils.create_media_urls(expected_results.keys(), page) + self.assertEqual([v[i] for v in expected_results.values()], urls) - assertPathGenerated("img.png", "./img.png") - assertPathGenerated("./img.png", "./img.png") - assertPathGenerated("/img.png", "../img.png") + def test_create_media_urls_use_directory_urls(self): - def test_create_relative_media_url_sub_index_windows(self): - ''' - test special case where there's a sub/index.md page and we are on Windows. - current_file paths uses backslash in Windows - ''' + expected_results = { + 'https://media.cdn.org/jq.js': [ + 'https://media.cdn.org/jq.js', + 'https://media.cdn.org/jq.js', + 'https://media.cdn.org/jq.js' + ], + 'http://media.cdn.org/jquery.js': [ + 'http://media.cdn.org/jquery.js', + 'http://media.cdn.org/jquery.js', + 'http://media.cdn.org/jquery.js' + ], + '//media.cdn.org/jquery.js': [ + '//media.cdn.org/jquery.js', + '//media.cdn.org/jquery.js', + '//media.cdn.org/jquery.js' + ], + 'media.cdn.org/jquery.js': [ + 'media.cdn.org/jquery.js', + '../media.cdn.org/jquery.js', + '../../media.cdn.org/jquery.js' + ], + 'local/file/jquery.js': [ + 'local/file/jquery.js', + '../local/file/jquery.js', + '../../local/file/jquery.js' + ], + 'local\\windows\\file\\jquery.js': [ + 'local/windows/file/jquery.js', + '../local/windows/file/jquery.js', + '../../local/windows/file/jquery.js' + ], + 'image.png': [ + 'image.png', + '../image.png', + '../../image.png' + ], + 'style.css?v=20180308c': [ + 'style.css?v=20180308c', + '../style.css?v=20180308c', + '../../style.css?v=20180308c' + ] + } + cfg = load_config(use_directory_urls=True) pages = [ - {'Home': 'index.md'}, - {'Sub': [ - {'Sub Home': '/level1/level2/index.md'}, - - ]} + Page('Home', File('index.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']), cfg), + Page('About', File('about.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']), cfg), + Page('FooBar', File('foo/bar.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']), cfg) ] - site_navigation = nav.SiteNavigation(load_config(pages=pages)) - site_navigation.url_context.set_current_url('/level1/level2') - site_navigation.file_context.current_file = "level1\\level2\\index.md" - - def assertPathGenerated(declared, expected): - url = utils.create_relative_media_url(site_navigation, declared) - self.assertEqual(url, expected) - assertPathGenerated("img.png", "./img.png") - assertPathGenerated("./img.png", "./img.png") - assertPathGenerated("/img.png", "../img.png") + for i, page in enumerate(pages): + urls = utils.create_media_urls(expected_results.keys(), page) + self.assertEqual([v[i] for v in expected_results.values()], urls) def test_reduce_list(self): self.assertEqual( diff --git a/mkdocs/themes/mkdocs/nav-sub.html b/mkdocs/themes/mkdocs/nav-sub.html index e4c265b5e2..a01d801b1c 100644 --- a/mkdocs/themes/mkdocs/nav-sub.html +++ b/mkdocs/themes/mkdocs/nav-sub.html @@ -1,6 +1,6 @@ {%- if not nav_item.children %}
  • - {{ nav_item.title }} + {{ nav_item.title }}
  • {%- else %} {%- else %}
  • - {{ nav_item.title }} + {{ nav_item.title }}
  • {%- endif %} {%- endfor %} @@ -58,12 +58,12 @@ {%- block next_prev %} {%- if page and (page.next_page or page.previous_page) %}
  • -
  • -
  • diff --git a/mkdocs/themes/readthedocs/base.html b/mkdocs/themes/readthedocs/base.html index c80d7d9fe7..b19c3c37e1 100644 --- a/mkdocs/themes/readthedocs/base.html +++ b/mkdocs/themes/readthedocs/base.html @@ -32,7 +32,7 @@ {% endif %} @@ -66,7 +66,7 @@