diff --git a/MANIFEST.in b/MANIFEST.in index caaaa0e9..0a3afb35 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,5 +1,5 @@ include README.md include LICENSE -recursive-include mkdocs *.ico *.js *.css *.png *.html *.eot *.svg *.ttf *.woff *.woff2 *.xml *.mustache +recursive-include mkdocs *.ico *.js *.css *.png *.html *.eot *.svg *.ttf *.woff *.woff2 *.xml *.mustache *mkdocs_theme.yml recursive-exclude * __pycache__ recursive-exclude * *.py[co] diff --git a/docs/about/release-notes.md b/docs/about/release-notes.md index c55b4248..a40eb37b 100644 --- a/docs/about/release-notes.md +++ b/docs/about/release-notes.md @@ -25,6 +25,40 @@ The current and past members of the MkDocs team. ### Major Additions to Version 1.0.0 +#### Theme Customization. (#1164) + +Support had been added to provide theme specific customizations. Theme authors +can define default options as documented in [Theme Configuration]. A theme can +now inherit from another theme, define various static templates to be rendered, +and define arbitrary default variables to control behavior in the templates. + +Users can override those defaults under the [theme] configuration option, which +now accepts nested options. One such nested option is the [custom_dir] option, +which replaces the now deprecated `theme_dir` option. If users had previously +set the `theme_dir` option, a warning will be issued, with an error expected in +a future release. + +If a configuration previously defined a `theme_dir` like this: + +```yaml +theme: mkdocs +theme_dir: custom +``` + +Then the configuration should be adjusted as follows: + +```yaml +theme: + name: mkdocs + custom_dir: custom +``` + +See the [theme] configuration option documentation for details. + +[Theme Configuration]: ../user-guide/custom-themes.md#theme-configuration +[theme]: ../user-guide/configuration.md#theme +[custom_dir]: ../user-guide/configuration.md#custom_dir + #### Previously deprecated Template variables removed. (#1168) ##### Page Template diff --git a/docs/css/extra.css b/docs/css/extra.css index 5602f86b..81947f02 100644 --- a/docs/css/extra.css +++ b/docs/css/extra.css @@ -4,7 +4,7 @@ div.col-md-9 h1:first-of-type { font-weight: 300; } -div.col-md-9 p:first-of-type { +div.col-md-9>p:first-of-type { text-align: center; } diff --git a/docs/user-guide/configuration.md b/docs/user-guide/configuration.md index ddb3eb94..c76f0b85 100644 --- a/docs/user-guide/configuration.md +++ b/docs/user-guide/configuration.md @@ -170,25 +170,57 @@ sub-directories. If none are found it will be `[]` (an empty list). ### theme -Sets the theme of your documentation site, for a list of available themes visit -[styling your docs]. +Sets the theme and theme specific configuration of your documentation site. +May be either a string or a set of key/value pairs. + +If a string, it must be the string name of a known installed theme. For a list +of available themes visit [styling your docs]. + +An example set of key/value pairs might look something like this: + +```yaml +theme: + name: mkdocs + custom_dir: my_theme_customizations/ + static_templates: + - sitemap.html + include_sidebar: false +``` + +If a set of key/value pairs, the following nested keys can be defined: + +!!! block "" + + #### name: + + The string name of a known installed theme. For a list of available themes + visit [styling your docs]. + + #### custom_dir: + + A directory to custom a theme. This can either be a relative directory, in + which case it is resolved relative to the directory containing your + configuration file, or it can be an absolute directory path. + + See [styling your docs][theme_dir] for details if you would like to tweak an + existing theme. + + See [custom themes] if you would like to build your own theme from the + ground up. + + #### static_templates: + + A list of templates to render as static pages. The templates must be located + in either the theme's template directory or in the `custom_dir` defined in + the theme configuration. + + #### (theme specific keywords) + + Any additional keywords supported by the theme can also be defined. See the + documentation for the theme you are using for details. **default**: `'mkdocs'` -### theme_dir - -Lets you set a directory to a custom theme. This can either be a relative -directory, in which case it is resolved relative to the directory containing -your configuration file, or it can be an absolute directory path. - -See [styling your docs][theme_dir] for details if you would like to tweak an -existing theme. - -See [custom themes] if you would like to build your own theme from the ground -up. - -**default**: `null` - ### docs_dir Lets you set the directory containing the documentation source markdown files. diff --git a/docs/user-guide/custom-themes.md b/docs/user-guide/custom-themes.md index eb221622..cea08b0c 100644 --- a/docs/user-guide/custom-themes.md +++ b/docs/user-guide/custom-themes.md @@ -19,12 +19,12 @@ and their usage. ## Creating a custom theme -The bare minimum required for a custom theme is a `main.html` [Jinja2 -template] file. This should be placed in a directory which will be the -`theme_dir` and it should be created next to the `mkdocs.yml` configuration -file. Within `mkdocs.yml`, specify the `theme_dir` option and set it to the -name of the directory containing `main.html`. For example, given this example -project layout: +The bare minimum required for a custom theme is a `main.html` [Jinja2 template] +file. This should be placed in a directory which will be the `theme_dir` and it +should be created next to the `mkdocs.yml` configuration file. Within +`mkdocs.yml`, specify the theme `custom_dir` option and set it to the name of +the directory containing `main.html`. For example, given this example project +layout: mkdocs.yml docs/ @@ -37,23 +37,24 @@ project layout: You would include the following settings in `mkdocs.yml` to use the custom theme directory: - theme: null - theme_dir: 'custom_theme' + theme: + name: null + custom_dir: 'custom_theme' !!! Note - Generally, when building your own custom theme, the `theme` configuration - setting would be set to `null`. However, if used in combination with the - `theme_dir` configuration value a custom theme can be used to replace only - specific parts of a built-in theme. For example, with the above layout and - if you set `theme: "mkdocs"` then the `main.html` file in the `theme_dir` - would replace that in the theme but otherwise the `mkdocs` theme would - remain the same. This is useful if you want to make small adjustments to an - existing theme. + Generally, when building your own custom theme, the theme `name` + configuration setting would be set to `null`. However, if used in + combination with the `custom_dir` configuration value a custom theme can be + used to replace only specific parts of a built-in theme. For example, with + the above layout and if you set `name: "mkdocs"` then the `main.html` file + in the `custom_dir` would replace that in the theme but otherwise the + `mkdocs` theme would remain the same. This is useful if you want to make + small adjustments to an existing theme. For more specific information, see [styling your docs]. -[styling your docs]: ./styling-your-docs.md#using-the-theme_dir +[styling your docs]: ./styling-your-docs.md#using-the-theme-custom_dir ## Basic theme @@ -84,7 +85,7 @@ the [built-in themes] and modify it accordingly. power of Jinja, including [template inheritance]. You may notice that the themes included with MkDocs make extensive use of template inheritance and blocks, allowing users to easily override small bits and pieces of the - templates from the [theme_dir]. Therefore, the built-in themes are + templates from the theme [custom_dir]. Therefore, the built-in themes are implemented in a `base.html` file, which `main.html` extends. Although not required, third party template authors are encouraged to follow a similar pattern and may want to define the same [blocks] as are used in the built-in @@ -380,11 +381,11 @@ Bootswatch theme]. !!! Note It is not strictly necessary to package a theme, as the entire theme - can be contained in the `theme_dir`. If you have created a "one-off theme," - that should be sufficent. However, if you intend to distribute your theme + can be contained in the `custom_dir`. If you have created a "one-off theme," + that should be sufficient. However, if you intend to distribute your theme for others to use, packaging the theme has some advantages. By packaging - your theme, your users can more easily install it and they can them take - advantage of the [theme_dir] to make tweaks to your theme to better suit + your theme, your users can more easily install it and they can then take + advantage of the [custom_dir] to make tweaks to your theme to better suit their needs. [Python packaging]: https://packaging.python.org/en/latest/ @@ -394,14 +395,16 @@ Bootswatch theme]. ### Package Layout The following layout is recommended for themes. Two files at the top level -directory called `MANIFEST.in` amd `setup.py` beside the theme directory which -contains an empty `__init__.py` file and your template and media files. +directory called `MANIFEST.in` and `setup.py` beside the theme directory which +contains an empty `__init__.py` file, a theme configuration file +(`mkdocs-theme.yml`), and your template and media files. ```no-highlight . |-- MANIFEST.in |-- theme_name | |-- __init__.py +| |-- mkdocs-theme.yml | |-- main.html | |-- styles.css `-- setup.py @@ -462,6 +465,57 @@ it includes a `main.html` for the theme. It **must** also include a `__init__.py` file which should be empty, this file tells Python that the directory is a package. +### Theme Configuration + +A packaged theme is required to include a configuration file named +`mkdocs.theme.yml` which is placed in the root of your template files. The file +should contain default configuration options for the theme. However, if the +theme offers no configuration options, the file is still required and can be +left blank. + +The theme author is free to define any arbitrary options deemed necessary and +those options will be made available in the templates to control behavior. +For example, a theme might want to make a sidebar optional and include the +following in the `mkdocs-theme.yml` file: + +```yaml +show_sidebar: true +``` + +Then in a template, that config option could be referenced: + +```django +{% if config.theme.show_sidebar %} + +{% endif %} +``` + +And the user could override the default in their project's `mkdocs.yml` config +file: + +```yaml +theme: + name: themename + show_sidebar: false +``` + +In addition to arbitrary options defined by the theme, MkDocs defines a few +special options which alters its behavior: + +!!! block "" + + #### static_templates + + This option mirrors the [theme] config option of the same name and allows + some defaults to be set by the theme. Note that while the user can add + templates to this list, the user cannot remove templates included in the + theme's config. + + #### extends + + Defines a parent theme that this theme inherits from. The value should be + the string name of the parent theme. Normal Jinja inheritance rules apply. + ### Distributing Themes With the above changes, your theme should now be ready to install. This can be @@ -481,3 +535,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 diff --git a/docs/user-guide/styling-your-docs.md b/docs/user-guide/styling-your-docs.md index 79b013e3..0d9314a0 100644 --- a/docs/user-guide/styling-your-docs.md +++ b/docs/user-guide/styling-your-docs.md @@ -41,8 +41,8 @@ have created your own, please feel free to add it to the list. If you would like to make a few tweaks to an existing theme, there is no need to create your own theme from scratch. For minor tweaks which only require some CSS and/or JavaScript, you can use the [docs_dir]. However, for more complex -customizations, including overriding templates, you will need to use the -[theme_dir]. +customizations, including overriding templates, you will need to use the theme +[custom_dir] setting. ### Using the docs_dir @@ -78,24 +78,25 @@ changes were automatically picked up and the documentation will be updated. Any extra CSS or JavaScript files will be added to the generated HTML document after the page content. If you desire to include a JavaScript library, you may have better success including the library by using the - [theme_dir]. + theme [custom_dir]. -### Using the theme_dir +### Using the theme custom_dir -The [theme_dir] configuration option can be used to point to a directory of -files which override the files in the theme set on the [theme] configuration -option. Any file in the `theme_dir` with the same name as a file in the `theme` -will replace the file of the same name in the `theme`. Any additional files in -the `theme_dir` will be added to the `theme`. The contents of the `theme_dir` -should mirror the directory structure of the `theme`. You may include templates, -JavaScript files, CSS files, images, fonts, or any other media included in a -theme. +The theme.[custom_dir] configuration option can be used to point to a directory +of files which override the files in a parent theme. The parent theme would be +the theme defined in the theme.[name] configuration option. Any file in the +`custom_dir` with the same name as a file in the parent theme will replace the +file of the same name in the parent theme. Any additional files in the +`custom_dir` will be added to the parent theme. The contents of the `custom_dir` +should mirror the directory structure of the parent theme. You may include +templates, JavaScript files, CSS files, images, fonts, or any other media +included in a theme. !!! Note - For this to work, the `theme` setting must be set to a known installed theme. - If the `theme` setting is instead set to `null` (or not defined), then there - is no theme to override and the contents of the `theme_dir` must be a + For this to work, the theme `name` setting must be set to a known installed theme. + If the `name` setting is instead set to `null` (or not defined), then there + is no theme to override and the contents of the `custom_dir` must be a complete, standalone theme. See [Custom Themes][custom theme] for more information. @@ -127,7 +128,9 @@ mkdir custom_theme And then point your `mkdocs.yml` configuration file at the new directory: ```yaml -theme_dir: custom_theme +theme: + name: mkdocs + custom_dir: custom_theme ``` To override the 404 error page ("file not found"), add a new template file named @@ -156,16 +159,17 @@ Your directory structure should now look like this: !!! Note - Any files included in the `theme` but not included in the `theme_dir` will - still be utilized. The `theme_dir` will only override/replace files in the - `theme`. If you want to remove files, or build a theme from scratch, then - you should review the documentation for building a [custom theme]. + Any files included in the parent theme (defined in `name`) but not included + in the `custom_dir` will still be utilized. The `custom_dir` will only + override/replace files in the parent theme. If you want to remove files, or + build a theme from scratch, then you should review the documentation for + building a [custom theme]. #### Overriding Template Blocks The built-in themes implement many of their parts inside template blocks which can be individually overridden in the `main.html` template. Simply create a -`main.html` template file in your `theme_dir` and define replacement blocks +`main.html` template file in your `custom_dir` and define replacement blocks within that file. Just make sure that the `main.html` extends `base.html`. For example, to alter the title of the MkDocs theme, your replacement `main.html` template would contain the following: @@ -205,11 +209,11 @@ following blocks: You may need to view the source template files to ensure your modifications will work with the structure of the site. See [Template Variables] for a list of variables you can use within your custom blocks. For a more complete -explaination of blocks, consult the [Jinja documentation]. +explanation of blocks, consult the [Jinja documentation]. -#### Combining the theme_dir and Template Blocks +#### Combining the custom_dir and Template Blocks -Adding a JavaScript library to the `theme_dir` will make it available, but +Adding a JavaScript library to the `custom_dir` will make it available, but won't include it in the pages generated by MkDocs. Therefore, a link needs to be added to the library from the HTML. @@ -243,8 +247,8 @@ Note that the [base_url] template variable was used to ensure that the link is always relative to the current page. Now the generated pages will include links to the template provided libraries as -well as the library included in the `theme_dir`. The same would be required for -any additional CSS files included in the `theme_dir`. +well as the library included in the `custom_dir`. The same would be required for +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 @@ -259,8 +263,8 @@ any additional CSS files included in the `theme_dir`. [mkdocs]: #mkdocs [ReadTheDocs]: ./deploying-your-docs.md#readthedocs [Template Variables]: ./custom-themes.md#template-variables -[theme]: ./configuration/#theme -[theme_dir]: ./configuration/#theme_dir +[custom_dir]: ./configuration/#custom_dir +[name]: ./configuration/#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/mkdocs/commands/build.py b/mkdocs/commands/build.py index ae622bfb..db8f0d46 100644 --- a/mkdocs/commands/build.py +++ b/mkdocs/commands/build.py @@ -11,7 +11,6 @@ from jinja2.exceptions import TemplateNotFound import jinja2 from mkdocs import nav, search, utils -from mkdocs.utils import filters import mkdocs @@ -81,9 +80,9 @@ def build_template(template_name, env, config, site_navigation=None): return True -def build_error_templates(templates, env, config, site_navigation): +def build_error_template(template, env, config, site_navigation): """ - Build error templates. + Build error template. 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. @@ -94,8 +93,7 @@ def build_error_templates(templates, env, config, site_navigation): default_base = site_navigation.url_context.base_path site_navigation.url_context.base_path = utils.urlparse(config['site_url']).path - for template in templates: - build_template(template, env, config, site_navigation) + build_template(template, env, config, site_navigation) # Reset nav behavior to the default site_navigation.url_context.force_abs_urls = False @@ -147,23 +145,19 @@ def build_pages(config, dirty=False): """ Build all pages and write them into the build directory. """ site_navigation = nav.SiteNavigation(config) - loader = jinja2.FileSystemLoader(config['theme_dir'] + [config['mkdocs_templates'], ]) - env = jinja2.Environment(loader=loader) + env = config['theme'].get_env() - env.filters['tojson'] = filters.tojson search_index = search.SearchIndex() - build_error_templates(['404.html'], env, config, site_navigation) - - if not build_template('search.html', env, config, site_navigation): - log.debug("Search is enabled but the theme doesn't contain a " - "search.html file. Assuming the theme implements search " - "within a modal.") - - build_template('sitemap.xml', env, config, 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 @@ -202,16 +196,15 @@ def build(config, live_server=False, dirty=False): # 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_dir']): - log.debug("Copying static assets from theme: %s", theme_dir) + 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'], dirty=dirty + theme_dir, config['site_dir'], exclude=['*.py', '*.pyc', '*.html', 'mkdocs_theme.yml'], dirty=dirty ) log.debug("Copying static assets from the docs dir.") utils.copy_media_files(config['docs_dir'], config['site_dir'], dirty=dirty) - log.debug("Building markdown pages.") build_pages(config, dirty=dirty) diff --git a/mkdocs/commands/serve.py b/mkdocs/commands/serve.py index 3f57d818..b0d3da8f 100644 --- a/mkdocs/commands/serve.py +++ b/mkdocs/commands/serve.py @@ -49,7 +49,7 @@ def _livereload(host, port, config, builder, site_dir): server.watch(config['docs_dir'], builder) server.watch(config['config_file_path'], builder) - for d in config['theme_dir']: + for d in config['theme'].dirs: server.watch(d, builder) server.serve(root=site_dir, host=host, port=port, restart_delay=0) diff --git a/mkdocs/config/config_options.py b/mkdocs/config/config_options.py index 15a06a0e..2da5273e 100644 --- a/mkdocs/config/config_options.py +++ b/mkdocs/config/config_options.py @@ -2,7 +2,7 @@ from __future__ import unicode_literals import os from collections import namedtuple -from mkdocs import utils +from mkdocs import utils, theme from mkdocs.config.base import Config, ValidationError @@ -311,56 +311,77 @@ class SiteDir(Dir): class ThemeDir(Dir): """ - ThemeDir Config Option - - Post validation, verify the theme_dir and do some path munging. - - TODO: This could probably be improved and/or moved from here. It's a tad - gross really. + ThemeDir Config Option. Deprecated """ + def pre_validation(self, config, key_name): + + if config.get(key_name) is None: + return + + warning = ('The configuration option {0} has been deprecated and will ' + 'be removed in a future release of MkDocs.') + self.warnings.append(warning) + def post_validation(self, config, key_name): - - theme_in_config = any(['theme' in c for c in config.user_configs]) - - package_dir = os.path.abspath( - os.path.join(os.path.dirname(__file__), '..')) - theme_dir = [utils.get_theme_dir(config['theme']), ] - config['mkdocs_templates'] = os.path.join(package_dir, 'templates') - - if config['theme_dir'] is not None: - # If the user has given us a custom theme but not a - # builtin theme name then we don't want to merge them. - if not theme_in_config: - theme_dir = [] - theme_dir.insert(0, config['theme_dir']) - - config['theme_dir'] = theme_dir - - # Add the search assets to the theme_dir, this means that - # they will then we copied into the output directory but can - # be overwritten by themes if needed. - search_assets = os.path.join(package_dir, 'assets', 'search') - config['theme_dir'].append(search_assets) + # The validation in the parent class this inherits from is not relevant here. + pass -class Theme(OptionallyRequired): +class Theme(BaseConfigOption): """ Theme Config Option - Validate that the theme is one of the builtin Mkdocs theme names. + Validate that the theme exists and build Theme instance. """ - def run_validation(self, value): + def __init__(self, default=None): + super(Theme, self).__init__() + self.default = default + + def validate(self, value): + if value is None and self.default is not None: + value = {'name': self.default} + + if isinstance(value, utils.string_types): + value = {'name': value} + themes = utils.get_theme_names() - if value in themes: - return value + if isinstance(value, dict): + if 'name' in value: + if value['name'] is None or value['name'] in themes: + return value - raise ValidationError( - "Unrecognised theme '{0}'. The available installed themes " - "are: {1}".format(value, ', '.join(themes)) - ) + raise ValidationError( + "Unrecognised theme name: '{0}'. The available installed themes " + "are: {1}".format(value['name'], ', '.join(themes)) + ) + + raise ValidationError("No theme name set.") + + raise ValidationError('Invalid type "{0}". Expected a string or key/value pairs.'.format(type(value))) + + def post_validation(self, config, key_name): + theme_config = config[key_name] + + # TODO: Remove when theme_dir is fully deprecated. + if config['theme_dir'] is not None: + if 'custom_dir' not in theme_config: + # Only pass in 'theme_dir' if it is set and 'custom_dir' is not set. + theme_config['custom_dir'] = config['theme_dir'] + if not any(['theme' in c for c in config.user_configs]): + # If the user did not define a theme, but did define theme_dir, then remove default set in validate. + theme_config['name'] = None + + if not theme_config['name'] and 'custom_dir' not in theme_config: + raise ValidationError("At least one of 'theme.name' or 'theme.custom_dir' must be defined.") + + # Ensure custom_dir is an absolute path + if 'custom_dir' in theme_config and not os.path.isabs(theme_config['custom_dir']): + theme_config['custom_dir'] = os.path.abspath(theme_config['custom_dir']) + + config[key_name] = theme.Theme(**theme_config) class Extras(OptionallyRequired): diff --git a/mkdocs/tests/config/config_options_tests.py b/mkdocs/tests/config/config_options_tests.py index 2c99c14f..1eac59de 100644 --- a/mkdocs/tests/config/config_options_tests.py +++ b/mkdocs/tests/config/config_options_tests.py @@ -339,18 +339,78 @@ class SiteDirTest(unittest.TestCase): class ThemeTest(unittest.TestCase): - def test_theme(self): + def test_theme_as_string(self): option = config_options.Theme() value = option.validate("mkdocs") - self.assertEqual("mkdocs", value) + self.assertEqual({'name': 'mkdocs'}, value) - def test_theme_invalid(self): + def test_uninstalled_theme_as_string(self): option = config_options.Theme() self.assertRaises(config_options.ValidationError, option.validate, "mkdocs2") + def test_theme_default(self): + option = config_options.Theme(default='mkdocs') + value = option.validate(None) + self.assertEqual({'name': 'mkdocs'}, value) + + def test_theme_as_simple_config(self): + + config = { + 'name': 'mkdocs' + } + option = config_options.Theme() + value = option.validate(config) + self.assertEqual(config, value) + + def test_theme_as_complex_config(self): + + config = { + 'name': 'mkdocs', + 'custom_dir': 'custom', + 'static_templates': ['sitemap.html'], + 'show_sidebar': False + } + option = config_options.Theme() + value = option.validate(config) + self.assertEqual(config, value) + + def test_theme_name_is_none(self): + + config = { + 'name': None + } + option = config_options.Theme() + value = option.validate(config) + self.assertEqual(config, value) + + def test_theme_config_missing_name(self): + + config = { + 'custom_dir': 'custom', + } + option = config_options.Theme() + self.assertRaises(config_options.ValidationError, + option.validate, config) + + def test_uninstalled_theme_as_config(self): + + config = { + 'name': 'mkdocs2' + } + option = config_options.Theme() + self.assertRaises(config_options.ValidationError, + option.validate, config) + + def test_theme_invalid_type(self): + + config = ['mkdocs2'] + option = config_options.Theme() + self.assertRaises(config_options.ValidationError, + option.validate, config) + class ExtrasTest(unittest.TestCase): diff --git a/mkdocs/tests/config/config_tests.py b/mkdocs/tests/config/config_tests.py index 9fcbdd90..5c076fb7 100644 --- a/mkdocs/tests/config/config_tests.py +++ b/mkdocs/tests/config/config_tests.py @@ -7,6 +7,7 @@ import shutil import tempfile import unittest +import mkdocs from mkdocs import config from mkdocs import utils from mkdocs.config import config_options @@ -102,19 +103,59 @@ class ConfigTests(unittest.TestCase): {"theme": "readthedocs"}, # builtin theme {"theme_dir": mytheme}, # custom only {"theme": "readthedocs", "theme_dir": custom}, # builtin and custom + {"theme": {'name': 'readthedocs'}}, # builtin as complex + {"theme": {'name': None, 'custom_dir': mytheme}}, # custom only as complex + {"theme": {'name': 'readthedocs', 'custom_dir': custom}}, # builtin and custom as complex + { # user defined variables + 'theme': { + 'name': 'mkdocs', + 'static_templates': ['foo.html'], + 'show_sidebar': False, + 'some_var': 'bar' + } + } ] - abs_path = os.path.abspath(os.path.dirname(__file__)) - mkdocs_dir = os.path.abspath(os.path.join(abs_path, '..', '..')) + mkdocs_dir = os.path.abspath(os.path.dirname(mkdocs.__file__)) + mkdocs_templates_dir = os.path.join(mkdocs_dir, 'templates') theme_dir = os.path.abspath(os.path.join(mkdocs_dir, 'themes')) search_asset_dir = os.path.abspath(os.path.join( mkdocs_dir, 'assets', 'search')) results = ( - [os.path.join(theme_dir, 'mkdocs'), search_asset_dir], - [os.path.join(theme_dir, 'readthedocs'), search_asset_dir], - [mytheme, search_asset_dir], - [custom, os.path.join(theme_dir, 'readthedocs'), search_asset_dir], + { + 'dirs': [os.path.join(theme_dir, 'mkdocs'), mkdocs_templates_dir, search_asset_dir], + 'static_templates': ['404.html', 'sitemap.xml'], + 'vars': {} + }, { + 'dirs': [os.path.join(theme_dir, 'readthedocs'), mkdocs_templates_dir, search_asset_dir], + 'static_templates': ['search.html', 'sitemap.xml'], + 'vars': {} + }, { + 'dirs': [mytheme, mkdocs_templates_dir, search_asset_dir], + 'static_templates': ['sitemap.xml'], + 'vars': {} + }, { + 'dirs': [custom, os.path.join(theme_dir, 'readthedocs'), mkdocs_templates_dir, search_asset_dir], + 'static_templates': ['search.html', 'sitemap.xml'], + 'vars': {} + }, { + 'dirs': [os.path.join(theme_dir, 'readthedocs'), mkdocs_templates_dir, search_asset_dir], + 'static_templates': ['search.html', 'sitemap.xml'], + 'vars': {} + }, { + 'dirs': [mytheme, mkdocs_templates_dir, search_asset_dir], + 'static_templates': ['sitemap.xml'], + 'vars': {} + }, { + 'dirs': [custom, os.path.join(theme_dir, 'readthedocs'), mkdocs_templates_dir, search_asset_dir], + 'static_templates': ['search.html', 'sitemap.xml'], + 'vars': {} + }, { + 'dirs': [os.path.join(theme_dir, 'mkdocs'), mkdocs_templates_dir, search_asset_dir], + 'static_templates': ['404.html', 'sitemap.xml', 'foo.html'], + 'vars': {'show_sidebar': False, 'some_var': 'bar'} + } ) for config_contents, result in zip(configs, results): @@ -124,8 +165,11 @@ class ConfigTests(unittest.TestCase): ('theme_dir', config_options.ThemeDir(exists=True)), )) c.load_dict(config_contents) - c.validate() - self.assertEqual(c['theme_dir'], result) + errors, warnings = c.validate() + self.assertEqual(len(errors), 0) + self.assertEqual(c['theme'].dirs, result['dirs']) + 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): tmp_dir = tempfile.mkdtemp() diff --git a/mkdocs/tests/integration/complicated_config/mkdocs.yml b/mkdocs/tests/integration/complicated_config/mkdocs.yml index ddfc71e4..d5307f7e 100644 --- a/mkdocs/tests/integration/complicated_config/mkdocs.yml +++ b/mkdocs/tests/integration/complicated_config/mkdocs.yml @@ -15,8 +15,9 @@ site_url: http://www.mkdocs.org/ docs_dir: documentation site_dir: output -theme: mkdocs -theme_dir: theme_tweaks +theme: + name: mkdocs + custom_dir: theme_tweaks copyright: "Dougal Matthews" google_analytics: ["1", "2"] diff --git a/mkdocs/tests/theme_tests.py b/mkdocs/tests/theme_tests.py new file mode 100644 index 00000000..df0f6b21 --- /dev/null +++ b/mkdocs/tests/theme_tests.py @@ -0,0 +1,97 @@ +from __future__ import unicode_literals + +import os +import tempfile +import unittest +import mock + +import mkdocs +from mkdocs.theme import Theme + +abs_path = os.path.abspath(os.path.dirname(__file__)) +mkdocs_dir = os.path.abspath(os.path.dirname(mkdocs.__file__)) +mkdocs_templates_dir = os.path.join(mkdocs_dir, 'templates') +theme_dir = os.path.abspath(os.path.join(mkdocs_dir, 'themes')) +search_asset_dir = os.path.abspath(os.path.join(mkdocs_dir, 'assets', 'search')) + + +def get_vars(theme): + """ Return dict of theme vars. """ + return dict([(k, theme[k]) for k in iter(theme)]) + + +class ThemeTests(unittest.TestCase): + + def test_simple_theme(self): + theme = Theme(name='mkdocs') + self.assertEqual( + theme.dirs, + [os.path.join(theme_dir, 'mkdocs'), mkdocs_templates_dir, search_asset_dir] + ) + self.assertEqual(theme.static_templates, set(['404.html', 'sitemap.xml'])) + self.assertEqual(get_vars(theme), {}) + + def test_custom_dir(self): + custom = tempfile.mkdtemp() + theme = Theme(name='mkdocs', custom_dir=custom) + self.assertEqual( + theme.dirs, + [ + custom, + os.path.join(theme_dir, 'mkdocs'), + mkdocs_templates_dir, + search_asset_dir + ] + ) + + def test_custom_dir_only(self): + custom = tempfile.mkdtemp() + theme = Theme(name=None, custom_dir=custom) + self.assertEqual( + theme.dirs, + [custom, mkdocs_templates_dir, search_asset_dir] + ) + + def static_templates(self): + theme = Theme(name='mkdocs', static_templates='foo.html') + self.assertEqual( + theme.static_templates, + set(['404.html', 'sitemap.xml', 'foo.html']) + ) + + def test_vars(self): + theme = Theme(name='mkdocs', foo='bar', baz=True) + self.assertEqual(theme['foo'], 'bar') + self.assertEqual(theme['baz'], True) + self.assertTrue('new' not in theme) + self.assertRaises(KeyError, lambda t, k: t[k], theme, 'new') + theme['new'] = 42 + self.assertTrue('new' in theme) + self.assertEqual(theme['new'], 42) + + @mock.patch('mkdocs.utils.yaml_load', return_value={}) + def test_no_theme_config(self, m): + theme = Theme(name='mkdocs') + self.assertEqual(m.call_count, 1) + self.assertEqual(theme.static_templates, set(['sitemap.xml'])) + + def test_inherited_theme(self): + m = mock.Mock(side_effect=[ + {'extends': 'readthedocs', 'static_templates': ['child.html']}, + {'static_templates': ['parent.html']} + ]) + with mock.patch('mkdocs.utils.yaml_load', m) as m: + theme = Theme(name='mkdocs') + self.assertEqual(m.call_count, 2) + self.assertEqual( + theme.dirs, + [ + os.path.join(theme_dir, 'mkdocs'), + os.path.join(theme_dir, 'readthedocs'), + mkdocs_templates_dir, + search_asset_dir + ] + ) + self.assertEqual( + theme.static_templates, set(['sitemap.xml', 'child.html', 'parent.html']) + ) diff --git a/mkdocs/theme.py b/mkdocs/theme.py new file mode 100644 index 00000000..73a233c9 --- /dev/null +++ b/mkdocs/theme.py @@ -0,0 +1,118 @@ +# coding: utf-8 + +from __future__ import unicode_literals +import os +import jinja2 +import logging + +from mkdocs import utils +from mkdocs.utils import filters +from mkdocs.config.base import ValidationError + +log = logging.getLogger(__name__) + + +class Theme(object): + """ + A Theme object. + + Keywords: + + name: The name of the theme as defined by its entrypoint. + + custom_dir: User defined directory for custom templates. + + static_templates: A list of templates to render as static pages. + + All other keywords are passed as-is and made available as a key/value mapping. + + """ + + def __init__(self, name=None, **user_config): + self.name = name + self._vars = {} + + # MkDocs provided static templates are always included + package_dir = os.path.abspath(os.path.dirname(__file__)) + mkdocs_templates = os.path.join(package_dir, 'templates') + self.static_templates = set(os.listdir(mkdocs_templates)) + + # Build self.dirs from various sources in order of precedence + self.dirs = [] + + if 'custom_dir' in user_config: + self.dirs.append(user_config.pop('custom_dir')) + + if self.name: + self._load_theme_config(name) + + # Include templates provided directly by MkDocs (outside any theme) + self.dirs.append(mkdocs_templates) + + # Add the search assets to the theme_dir, so that they will be copied + # into the output directory but can still be overwritten by themes. + self.dirs.append(os.path.join(package_dir, 'assets', 'search')) + + # Handle remaining user configs. Override theme configs (if set) + self.static_templates.update(user_config.pop('static_templates', [])) + self._vars.update(user_config) + + def __repr__(self): + return "{0}(name='{1}', dirs={2}, static_templates={3}, {4})".format( + self.__class__.__name__, self.name, self.dirs, list(self.static_templates), + ', '.join('{0}={1}'.format(k, repr(v)) for k, v in self._vars.items()) + ) + + def __getitem__(self, key): + return self._vars[key] + + def __setitem__(self, key, value): + self._vars[key] = value + + def __contains__(self, item): + return item in self._vars + + def __iter__(self): + return iter(self._vars) + + def _load_theme_config(self, name): + """ Recursively load theme and any parent themes. """ + + theme_dir = utils.get_theme_dir(name) + self.dirs.append(theme_dir) + + try: + file_path = os.path.join(theme_dir, 'mkdocs_theme.yml') + with open(file_path, 'rb') as f: + theme_config = utils.yaml_load(f) + except IOError as e: + log.debug(e) + # TODO: Change this warning to an error in a future version + log.warning( + "The theme '{0}' does not appear to have a configuration file. " + "Please upgrade to a current version of the theme.".format(name) + ) + return + + log.debug("Loaded theme configuration for '%s' from '%s': %s", name, file_path, theme_config) + + parent_theme = theme_config.pop('extends', None) + if parent_theme: + themes = utils.get_theme_names() + if parent_theme not in themes: + raise ValidationError( + "The theme '{0}' inherits from '{1}', which does not appear to be installed. " + "The available installed themes are: {2}".format(name, parent_theme, ', '.join(themes)) + ) + self._load_theme_config(parent_theme) + + self.static_templates.update(theme_config.pop('static_templates', [])) + self._vars.update(theme_config) + + def get_env(self): + """ Return a Jinja environment for the theme. """ + + loader = jinja2.FileSystemLoader(self.dirs) + env = jinja2.Environment(loader=loader) + env.filters['tojson'] = filters.tojson + return env diff --git a/mkdocs/themes/mkdocs/mkdocs_theme.yml b/mkdocs/themes/mkdocs/mkdocs_theme.yml new file mode 100644 index 00000000..f6850cf5 --- /dev/null +++ b/mkdocs/themes/mkdocs/mkdocs_theme.yml @@ -0,0 +1,4 @@ +# Config options for 'mkdocs' theme + +static_templates: + - 404.html diff --git a/mkdocs/themes/readthedocs/mkdocs_theme.yml b/mkdocs/themes/readthedocs/mkdocs_theme.yml new file mode 100644 index 00000000..de2340b3 --- /dev/null +++ b/mkdocs/themes/readthedocs/mkdocs_theme.yml @@ -0,0 +1,4 @@ +# Config options for 'readthedocs' theme + +static_templates: + - search.html diff --git a/mkdocs/utils/__init__.py b/mkdocs/utils/__init__.py index 1017180a..a252beba 100644 --- a/mkdocs/utils/__init__.py +++ b/mkdocs/utils/__init__.py @@ -275,6 +275,16 @@ def is_template_file(path): ] +_ERROR_TEMPLATE_RE = re.compile(r'^\d{3}\.html?$') + + +def is_error_template(path): + """ + Return True if the given file path is an HTTP error template. + """ + return bool(_ERROR_TEMPLATE_RE.match(path)) + + def create_media_urls(nav, path_list): """ Return a list of URLs that have been processed correctly for inclusion in