diff --git a/.jshintignore b/.jshintignore index 6807d0ce..7980294a 100644 --- a/.jshintignore +++ b/.jshintignore @@ -2,6 +2,6 @@ mkdocs/themes/**/js/highlight.pack.js mkdocs/themes/**/js/jquery-**.min.js mkdocs/themes/**/js/bootstrap-**.min.js mkdocs/themes/**/js/modernizr-**.min.js -mkdocs/assets/search/mkdocs/js/require.js -mkdocs/assets/search/mkdocs/js/mustache.min.js -mkdocs/assets/search/mkdocs/js/lunr.min.js +mkdocs/contrib/legacy_search/templates/search/require.js +mkdocs/contrib/legacy_search/templates/search/mustache.min.js +mkdocs/contrib/legacy_search/templates/search/lunr.min.js diff --git a/docs/about/release-notes.md b/docs/about/release-notes.md index a40eb37b..aa584910 100644 --- a/docs/about/release-notes.md +++ b/docs/about/release-notes.md @@ -25,6 +25,23 @@ The current and past members of the MkDocs team. ### Major Additions to Version 1.0.0 +#### Plugin API. (#206) + +A new [Plugin API] has been added to MkDocs which allows users to define their +own custom behaviors. See the included documentation for a full explaination of +the API. + +The previously built-in search functionality has been removed and wrapped in a +plugin (named "legacy_search") with no changes in behavior. As there are no +'default' plugins, you need to explcitly enable the legacy_search plugin if you +would like to continue using it. To do so, adding the following to your +`mkdocs.yml` file: + + plugins: + - legacy_search + +[Plugin API]: ../user-guide/plugins.md + #### Theme Customization. (#1164) Support had been added to provide theme specific customizations. Theme authors diff --git a/docs/css/extra.css b/docs/css/extra.css index 81947f02..29f4d9a4 100644 --- a/docs/css/extra.css +++ b/docs/css/extra.css @@ -19,3 +19,9 @@ div.col-md-9 h1:first-of-type .headerlink { code.no-highlight { color: black; } + +/* Definition List styles */ + +dd { + padding-left: 20px; +} diff --git a/docs/user-guide/configuration.md b/docs/user-guide/configuration.md index c76f0b85..50dbfb16 100644 --- a/docs/user-guide/configuration.md +++ b/docs/user-guide/configuration.md @@ -409,7 +409,14 @@ markdown_extensions: the documentation provided by those extensions for installation instructions and available configuration options. -**default**: `[]` +**default**: `[]` (an empty list). + +### plugins + +A list of plugins (with optional configuration settings) to use when building +the site . See the [Plugins] documentation for details. + +**default**: `[]` (an empty list). [custom themes]: custom-themes.md [variables that are available]: custom-themes.md#template-variables @@ -422,3 +429,4 @@ markdown_extensions: [theme_dir]: styling-your-docs.md#using-the-theme_dir [styling your docs]: styling-your-docs.md [extra_css]: #extra_css +[Plugins]: plugins.md diff --git a/docs/user-guide/plugins.md b/docs/user-guide/plugins.md new file mode 100644 index 00000000..cc0cdc28 --- /dev/null +++ b/docs/user-guide/plugins.md @@ -0,0 +1,357 @@ +# MkDocs Plugins + +A Guide to installing, using and creating MkDocs Plugins + +--- + +## Installing Plugins + +Before a plugin can be used, it must be installed on the system. If you are +using a plugin which comes with MkDocs, then it was installed when you installed +MkDocs. However, to install third party plugins, you need to determine the +appropriate package name and install it using `pip`: + +```python +pip install mkdocs-foo-plugin +``` + +Once a plugin has been successfully installed, it is ready to use. It just needs +to be [enabled](#using-plugins) in the configuration file. + +## Using Plugins + +The [`plugins`][config] configuration option should contain a list of plugins to +use when building the site. Each "plugin" must be a string name assigned to the +plugin (see the documentation for a given plugin to determine its "name"). A +plugin listed here must already be [installed](#installing-plugins). + +```yaml +plugins: + - search +``` + +Some plugins may provide configuration options of their own. If you would like +to set any configuration options, then you can nest a key/value mapping +(`option_name: option value`) of any options that a given plugin supports. Note +that a colon (`:`) must follow the plugin name and then on a new line the option +name and value must be indented and separated by a colon. If you would like to +define multiple options for a single plugin, each option must be defined on a +separate line. + +```yaml +plugins: + -search: + lang: en + foo: bar +``` + +For information regarding the configuration options available for a given plugin, +see that plugin's documentation. + +## Developing Plugins + +Like MkDocs, plugins must be written in Python. It is generally expected that +each plugin would be distributed as a separate Python module, although it is +possible to define multiple plugins in the same module. At a minimum, a MkDocs +Plugin must consist of a [BasePlugin] subclass and an [entry point] which +points to it. + +### BasePlugin + +A subclass of `mkdocs.pluhgins.BasePlugin` should define the behavior of the plugin. +The class generally consists of actions to perform on specific events in the build +process as well as a configuration scheme for the plugin. + +All `BasePlugin` subclasses contain the following attributes: + +#### config_scheme + +: A tuple of configuration validation class instances (to be defined in a subclass). + +#### config + +: A dictionary of configuration options for the plugin which is populated by the + `load_config` method. + +All `BasePlugin` subclasses contain the following method(s): + +#### load_config(options) + +: Loads configuration from a dictionary of options. Returns a tuple of `(errors, + warnings)`. + +#### on_<event_name>() + +: Optional methods which define the behavior for specific [events]. The plugin + should define its behavior within these methods. Replace `` with + the actual name of the event. For example, the `pre_build` event would be + defined in the `on_pre_build` method. + + Most events accept one positional argument and various keyword arguments. It + is generally expected that the positional argument would be modified (or + replaced) by the plugin and returned. If nothing is returned (the method + returns `None`), then the original, unmodified object is used. The keyword + arguments are simply provided to give context and/or supply data which may + be used to determine how the positional argument should be modified. It is + good practice to accept keyword arguments as `**kwargs`. In the event that + additional keywords are provided to an event in a future version of MkDocs, + there will be no need to alter your plugin. + + For example, the following event would add an additional static_template to + the theme config: + + class MyPlugin(BasePlugin): + def on_config(self, config, **kwargs): + config['theme'].static_templates.add('my_template.html') + return config + +### Events + +There are three kinds of events: [Global Events], [Page Events] and +[Template Events]. + +#### Global Events + +Global events are called once per build at either the beginning or end of the +build process. Any changes made in these events will have a global effect on the +entire site. + +##### on_serve + +: 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. + + Parameters: + : __server:__ `livereload.Server` instance + : __config:__ global configuration object + + Returns: + : `livereload.Server` instance + +##### on_config + +: The `config` event is the first event called on build and is run immediately + after the user configuration is loaded and validated. Any alterations to the + config should be made here. + + Parameters: + : __config:__ global configuration object + + Returns: + : global configuration object + +##### on_pre_build + +: The `pre_build` event does not alter any variables. Use this event to call + pre-build scripts. + + Parameters: + : __config:__ global configuration object + +##### 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 + : __config:__ global configuration object + + Returns: + : global navigation object + +##### on_env + +: The `env` event is called after the Jinja template environment is created + and can be used to alter the Jinja environment. + + Parameters: + : __env:__ global Jinja environment + : __config:__ global configuration object + : __site_navigation__: global navigation object + + Returns: + : global Jinja Environment + +##### on_post_build + +: The `post_build` event does not alter any variables. Use this event to call + post-build scripts. + + Parameters: + : __config:__ global configuration object + +#### Template Events + +Template events are called once for each non-page template. Each template event +will be called for each template defined in the [extra_templates] config setting +as well as any [static_templates] defined in the theme. All template events are +called after the [env] event and before any [page events]. + +##### on_pre_template + +: The `pre_template` event is called immediately after the subject template is + loaded and can be used to alter the content of the template. + + Parameters: + : __template__: the template contents as string + : __template_name__: string filename of template + : __config:__ global configuration object + + Returns: + : template contents as string + +##### on_template_context + +: The `template_context` event is called immediately after the context is created + for the subject template and can be used to alter the context for that specific + template only. + + Parameters: + : __context__: dict of template context variables + : __template_name__: string filename of template + : __config:__ global configuration object + + Returns: + : dict of template context variables + +##### on_post_template + +: The `post_template` event is called after the template is rendered, but before + it is written to disc and can be used to alter the output of the template. + If an empty string is returned, the template is skipped and nothing is is + written to disc. + + Parameters: + : __output_content__: output of rendered template as string + : __template_name__: string filename of template + : __config:__ global configuration object + + Returns: + : output of rendered template as string + +#### 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. + +##### on_pre_page + +: The `pre_page` event is called before any actions are taken on the subject + page and can be used to alter the `Page` instance. + + Parameters: + : __page:__ `mkdocs.nav.Page` instance + : __config:__ global configuration object + : __site_navigation__: global navigation object + + Returns: + : `mkdocs.nav.Page` instance + +##### on_page_markdown + +: The `page_markdown` event is called after the page is loaded from file and + can be used to alter the Markdown source text. + + Parameters: + : __markdown:__ Markdown source text of page as string + : __page:__ `mkdocs.nav.Page` instance + : __config:__ global configuration object + : __site_navigation__: global navigation object + + Returns: + : Markdown source text of page as string + +##### on_page_content + +: The `page_content` event is called after the Markdown text is rendered to + HTML (but before being passed to a template) and can be used to alter the + HTML body of the page. + + Parameters: + : __html:__ HTML rendered from Markdown source as string + : __page:__ `mkdocs.nav.Page` instance + : __config:__ global configuration object + : __site_navigation__: global navigation object + + Returns: + : HTML rendered from Markdown source as string + +##### on_page_context + +: The `page_context` event is called after the context for a page is created + and can be used to alter the context for that specific page only. + + Parameters: + : __context__: dict of template context variables + : __page:__ `mkdocs.nav.Page` instance + : __config:__ global configuration object + : __site_navigation__: global navigation object + + Returns: + : dict of template context variables + +##### on_post_page + +: The `post_template` event is called after the template is rendered, but + before it is written to disc and can be used to alter the output of the + page. If an empty string is returned, the page is skipped and nothing is + written to disc. + + Parameters: + : __output_content:__ output of rendered template as string + : __page:__ `mkdocs.nav.Page` instance + : __config:__ global configuration object + : __site_navigation__: global navigation object + + Returns: + : output of rendered template as string + +### Entry Point + +Plugins need to be packaged as Python libraries (distributed on PyPI separate +from MkDocs) and each must register as a Plugin via a setuptools entry_point. +Add the following to your `setup.py` script: + +```python +entry_points={ + 'mkdocs.plugins': [ + 'pluginname = path.to.some_plugin:SomePluginClass', + ] +} +``` + +The `pluginname` would be the name used by users (in the config file) and +`path.to.some_plugin:SomePluginClass` would be the importable plugin itself +(`from path.to.some_plugin import SomePluginClass`) where `SomePluginClass` is a +subclass of [BasePlugin] which defines the plugin behavior. Naturally, multiple +Plugin classes could exist in the same module. Simply define each as a separate +entry_point. + +```python +entry_points={ + 'mkdocs.plugins': [ + 'featureA = path.to.my_plugins:PluginA', + 'featureB = path.to.my_plugins:PluginB' + ] +} +``` + +Note that registering a plugin does not activate it. The user still needs to +tell MkDocs to use if via the config. + +[BasePlugin]:#baseplugin +[entry point]: #entry-point +[config]: configuration.md#plugins +[events]: #events +[Global Events]: #global-events +[Page Events]: #page-events +[Template Events]: #template-events +[static_templates]: configuration.md#static_templates +[extra_templates]: configuration.md#extra_templates +[env]: #on_env +[post_template]: #on_post_template +[post_build]: #on_post_build \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml index 58f3c868..8adb9607 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -13,6 +13,7 @@ pages: - Configuration: user-guide/configuration.md - Deploying Your Docs: user-guide/deploying-your-docs.md - Custom Themes: user-guide/custom-themes.md + - Plugins: user-guide/plugins.md - About: - Release Notes: about/release-notes.md - Contributing: about/contributing.md @@ -24,7 +25,11 @@ extra_css: markdown_extensions: - toc: permalink:  - - admonition: + - admonition + - def_list copyright: Copyright © 2014 Tom Christie, Maintained by the MkDocs Team. google_analytics: ['UA-27795084-5', 'mkdocs.org'] + +plugins: + - legacy_search diff --git a/mkdocs/commands/build.py b/mkdocs/commands/build.py index db8f0d46..d69cbcb1 100644 --- a/mkdocs/commands/build.py +++ b/mkdocs/commands/build.py @@ -10,7 +10,7 @@ import os from jinja2.exceptions import TemplateNotFound import jinja2 -from mkdocs import nav, search, utils +from mkdocs import nav, utils import mkdocs @@ -70,14 +70,33 @@ def build_template(template_name, env, config, site_navigation=None): try: template = env.get_template(template_name) except TemplateNotFound: - return False + log.info("Template skipped: '{}'. Not found in template directories.".format(template_name)) + return + + # Run `pre_template` plugin events. + template = config['plugins'].run_event( + 'pre_template', template, template_name=template_name, config=config + ) context = get_context(site_navigation, config) + # Run `template_context` plugin events. + context = config['plugins'].run_event( + 'template_context', context, template_name=template_name, config=config + ) + output_content = template.render(context) - output_path = os.path.join(config['site_dir'], template_name) - utils.write_file(output_content.encode('utf-8'), output_path) - return True + + # Run `post_template` plugin events. + output_content = config['plugins'].run_event( + 'post_template', output_content, template_name=template_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) + else: + log.info("Template skipped: '{}'. Generated empty output.".format(template_name)) def build_error_template(template, env, config, site_navigation): @@ -103,10 +122,25 @@ def build_error_template(template, env, config, site_navigation): def _build_page(page, config, site_navigation, env, dirty=False): """ Build a Markdown page and pass to theme template. """ - # Process the markdown text + # Run the `pre_page` plugin event + page = config['plugins'].run_event( + 'page_markdown', page, config=config, site_navigation=site_navigation + ) + page.load_markdown() + + # Run `page_markdown` plugin events. + page.markdown = config['plugins'].run_event( + 'page_markdown', page.markdown, page=page, config=config, site_navigation=site_navigation + ) + page.render(config, site_navigation) + # Run `page_content` plugin events. + page.content = config['plugins'].run_event( + 'page_content', page.content, page=page, config=config, site_navigation=site_navigation + ) + context = get_context(site_navigation, config, page) # Allow 'template:' override in md source files. @@ -115,11 +149,24 @@ def _build_page(page, config, site_navigation, env, dirty=False): else: template = env.get_template('main.html') + # Run `page_context` plugin events. + context = config['plugins'].run_event( + 'page_context', context, page=page, config=config, site_navigation=site_navigation + ) + # Render the template. output_content = template.render(context) + # Run `post_page` plugin events. + output_content = config['plugins'].run_event( + 'post_page', output_content, page=page, config=config + ) + # Write the output file. - utils.write_file(output_content.encode('utf-8'), page.abs_output_path) + 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)) def build_extra_templates(extra_templates, config, site_navigation=None): @@ -134,20 +181,46 @@ def build_extra_templates(extra_templates, config, site_navigation=None): with io.open(input_path, 'r', encoding='utf-8') as template_file: template = jinja2.Template(template_file.read()) + # Run `pre_template` plugin events. + template = config['plugins'].run_event( + 'pre_template', template, template_name=extra_template, config=config + ) + context = get_context(site_navigation, config) + # Run `template_context` plugin events. + context = config['plugins'].run_event( + 'template_context', context, template_name=extra_template, config=config + ) + output_content = template.render(context) - output_path = os.path.join(config['site_dir'], extra_template) - utils.write_file(output_content.encode('utf-8'), output_path) + + # Run `post_template` plugin events. + output_content = config['plugins'].run_event( + 'post_template', output_content, template_name=extra_template, 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) + 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) + + # Run `nav` plugin events. + site_navigation = config['plugins'].run_event('nav', site_navigation, config=config) + env = config['theme'].get_env() - search_index = search.SearchIndex() + # 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): @@ -167,19 +240,20 @@ def build_pages(config, dirty=False): log.debug("Building page %s", page.input_path) _build_page(page, config, site_navigation, env) - search_index.add_entry_from_context(page) except Exception: log.error("Error building page %s", page.input_path) raise - search_index = search_index.generate_search_index() - json_output_path = os.path.join(config['site_dir'], 'mkdocs', 'search_index.json') - utils.write_file(search_index.encode('utf-8'), json_output_path) - def build(config, live_server=False, dirty=False): """ Perform a full site build. """ + # Run `config` plugin events. + config = config['plugins'].run_event('config', config) + + # Run `pre_build` plugin events. + config['plugins'].run_event('pre_build', config) + if not dirty: log.info("Cleaning site directory") utils.clean_directory(config['site_dir']) @@ -207,6 +281,9 @@ def build(config, live_server=False, dirty=False): build_pages(config, dirty=dirty) + # Run `post_build` plugin events. + config['plugins'].run_event('post_build', config) + def site_directory_contains_stale_files(site_directory): """ Check if the site directory contains stale files from a previous build. """ diff --git a/mkdocs/commands/serve.py b/mkdocs/commands/serve.py index b0d3da8f..340a3f6f 100644 --- a/mkdocs/commands/serve.py +++ b/mkdocs/commands/serve.py @@ -52,6 +52,9 @@ def _livereload(host, port, config, builder, site_dir): for d in config['theme'].dirs: server.watch(d, builder) + # Run `serve` plugin events. + server = config['plugins'].run_event('serve', server, config=config) + 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 2da5273e..025970c6 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, theme +from mkdocs import utils, theme, plugins from mkdocs.config.base import Config, ValidationError @@ -544,3 +544,56 @@ class MarkdownExtensions(OptionallyRequired): def post_validation(self, config, key_name): config[self.configkey] = self.configdata + + +class Plugins(OptionallyRequired): + """ + Plugins config option. + + A list of plugins. If a plugin defines config options those are used when + initializing the plugin class. + """ + + def __init__(self, **kwargs): + super(Plugins, self).__init__(**kwargs) + self.installed_plugins = plugins.get_plugins() + + def run_validation(self, value): + if not isinstance(value, (list, tuple)): + raise ValidationError('Invalid Plugins configuration. Expected a list of plugins') + plgins = plugins.PluginCollection() + for item in value: + if isinstance(item, dict): + if len(item) > 1: + raise ValidationError('Invalid Plugins configuration') + name, cfg = item.popitem() + cfg = cfg or {} # Users may define a null (None) config + if not isinstance(cfg, dict): + raise ValidationError('Invalid config options for ' + 'the "{0}" plugin.'.format(name)) + plgins[name] = self.load_plugin(name, cfg) + elif isinstance(item, utils.string_types): + plgins[item] = self.load_plugin(item, {}) + else: + raise ValidationError('Invalid Plugins configuration') + return plgins + + def load_plugin(self, name, config): + if name in self.installed_plugins: + Plugin = self.installed_plugins[name].load() + if issubclass(Plugin, plugins.BasePlugin): + plugin = Plugin() + errors, warnings = plugin.load_config(config) + self.warnings.extend(warnings) + for cfg_name, error in errors: + # TODO: retain all errors if there are more than one + raise ValidationError("Plugin value: '%s'. Error: %s", cfg_name, error) + return plugin + else: + raise ValidationError('{0}.{1} must be a subclass of ' + '{2}.{3}'.format(Plugin.__module__, + Plugin.__name__, + plugins.BasePlugin.__module__, + plugins.BasePlugin.__name__)) + else: + raise ValidationError('The "{0}" plugin is not installed'.format(name)) diff --git a/mkdocs/config/defaults.py b/mkdocs/config/defaults.py index 8bcdbd7a..d1a50530 100644 --- a/mkdocs/config/defaults.py +++ b/mkdocs/config/defaults.py @@ -113,4 +113,9 @@ DEFAULT_SCHEMA = ( # MkDocs itself. A good example here would be including the current # project version. ('extra', config_options.SubConfig()), + + # a list of plugins. Each item may contain a string name or a key value pair. + # A key value pair should be the string name (as the key) and a dict of config + # options (as the value). + ('plugins', config_options.Plugins(default=[])), ) diff --git a/mkdocs/contrib/__init__.py b/mkdocs/contrib/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/mkdocs/contrib/legacy_search/__init__.py b/mkdocs/contrib/legacy_search/__init__.py new file mode 100644 index 00000000..1c1effc5 --- /dev/null +++ b/mkdocs/contrib/legacy_search/__init__.py @@ -0,0 +1,39 @@ +# coding: utf-8 + +from __future__ import unicode_literals + +import os +import logging +from mkdocs import utils +from mkdocs.plugins import BasePlugin + +from .search_index import SearchIndex + +log = logging.getLogger(__name__) + + +class SearchPlugin(BasePlugin): + """ Add a search feature to MkDocs. """ + + def on_config(self, config, **kwargs): + "Add plugin templates and scripts to config." + path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'templates') + config['theme'].dirs.append(path) + config['theme'].static_templates.add('search.html') + config['extra_javascript'].append('search/require.js') + config['extra_javascript'].append('search/search.js') + return config + + def on_pre_build(self, config, **kwargs): + "Create search index instance for later use." + self.search_index = SearchIndex() + + def on_page_context(self, context, **kwargs): + "Add page to search index." + self.search_index.add_entry_from_context(context['page']) + + def on_post_build(self, config, **kwargs): + "Build search index." + search_index = self.search_index.generate_search_index() + json_output_path = os.path.join(config['site_dir'], 'search', 'search_index.json') + utils.write_file(search_index.encode('utf-8'), json_output_path) diff --git a/mkdocs/search.py b/mkdocs/contrib/legacy_search/search_index.py similarity index 99% rename from mkdocs/search.py rename to mkdocs/contrib/legacy_search/search_index.py index a4021919..ba26f024 100644 --- a/mkdocs/search.py +++ b/mkdocs/contrib/legacy_search/search_index.py @@ -1,3 +1,5 @@ +# coding: utf-8 + from __future__ import unicode_literals import json diff --git a/mkdocs/assets/search/mkdocs/js/lunr.min.js b/mkdocs/contrib/legacy_search/templates/search/lunr.min.js similarity index 100% rename from mkdocs/assets/search/mkdocs/js/lunr.min.js rename to mkdocs/contrib/legacy_search/templates/search/lunr.min.js diff --git a/mkdocs/assets/search/mkdocs/js/mustache.min.js b/mkdocs/contrib/legacy_search/templates/search/mustache.min.js similarity index 100% rename from mkdocs/assets/search/mkdocs/js/mustache.min.js rename to mkdocs/contrib/legacy_search/templates/search/mustache.min.js diff --git a/mkdocs/assets/search/mkdocs/js/require.js b/mkdocs/contrib/legacy_search/templates/search/require.js similarity index 100% rename from mkdocs/assets/search/mkdocs/js/require.js rename to mkdocs/contrib/legacy_search/templates/search/require.js diff --git a/mkdocs/assets/search/mkdocs/js/search-results-template.mustache b/mkdocs/contrib/legacy_search/templates/search/search-results-template.mustache similarity index 100% rename from mkdocs/assets/search/mkdocs/js/search-results-template.mustache rename to mkdocs/contrib/legacy_search/templates/search/search-results-template.mustache diff --git a/mkdocs/assets/search/mkdocs/js/search.js b/mkdocs/contrib/legacy_search/templates/search/search.js similarity index 92% rename from mkdocs/assets/search/mkdocs/js/search.js rename to mkdocs/contrib/legacy_search/templates/search/search.js index d5c86616..2283930c 100644 --- a/mkdocs/assets/search/mkdocs/js/search.js +++ b/mkdocs/contrib/legacy_search/templates/search/search.js @@ -1,8 +1,12 @@ +require.config({ + baseUrl: base_url + "/search/" +}); + require([ - base_url + '/mkdocs/js/mustache.min.js', - base_url + '/mkdocs/js/lunr.min.js', + 'mustache.min', + 'lunr.min', 'text!search-results-template.mustache', - 'text!../search_index.json', + 'text!search_index.json', ], function (Mustache, lunr, results_template, data) { "use strict"; @@ -83,6 +87,6 @@ require([ search(); } - search_input.addEventListener("keyup", search); + if (search_input){search_input.addEventListener("keyup", search);} }); diff --git a/mkdocs/assets/search/mkdocs/js/text.js b/mkdocs/contrib/legacy_search/templates/search/text.js similarity index 100% rename from mkdocs/assets/search/mkdocs/js/text.js rename to mkdocs/contrib/legacy_search/templates/search/text.js diff --git a/mkdocs/plugins.py b/mkdocs/plugins.py new file mode 100644 index 00000000..76841adc --- /dev/null +++ b/mkdocs/plugins.py @@ -0,0 +1,94 @@ +# coding: utf-8 + +""" +Implements the plugin API for MkDocs. + +""" + +from __future__ import unicode_literals + +import pkg_resources +import logging +from collections import OrderedDict + +from mkdocs.config.base import Config + + +log = logging.getLogger('mkdocs.plugins') + + +def get_plugins(): + """ Return a dict of all installed Plugins by name. """ + + plugins = pkg_resources.iter_entry_points(group='mkdocs.plugins') + + return dict((plugin.name, plugin) for plugin in plugins) + + +class BasePlugin(object): + """ + Plugin base class. + + All plugins should subclass this class. + """ + + config_scheme = () + config = {} + + def load_config(self, options): + """ Load config from a dict of options. Returns a tuple of (errors, warnings).""" + + self.config = Config(schema=self.config_scheme) + self.config.load_dict(options) + + return self.config.validate() + + +class PluginCollection(OrderedDict): + """ + A collection of plugins. + + In addition to being a dict of Plugin instances, each event method is registered + upon being added. All registered methods for a given event can then be run in order + by calling `run_event`. + """ + + def __init__(self, *args, **kwargs): + super(PluginCollection, self).__init__(*args, **kwargs) + events = [ + 'config', 'pre_build', 'nav', 'env', 'pre_template', 'template_context', + 'post_template', 'pre_page', 'page_markdown', 'page_content', 'page_context', + 'post_page', 'post_build', 'serve' + ] + self.events = dict((x, []) for x in events) + + def _register_event(self, event_name, method): + """ Register a method for an event. """ + self.events[event_name].append(method) + + def __setitem__(self, key, value, **kwargs): + if not isinstance(value, BasePlugin): + raise TypeError('{0}.{1} only accepts values which are instances' + ' of {3}.{4} sublcasses'.format(self.__module__, + self.__name__, + BasePlugin.__module__, + BasePlugin.__name__)) + super(PluginCollection, self).__setitem__(key, value, **kwargs) + # Register all of the event methods defined for this Plugin. + for event_name in dir(value): + if event_name.startswith('on_'): + self._register_event(event_name[3:], getattr(value, event_name)) + + def run_event(self, name, item, **kwargs): + """ + Run all registered methods of an event. + + `item` is the object to be modified and returned by the event method. + All other keywords are variables for context, but would not generally + be modified by the event method. + """ + + for method in self.events[name]: + # method may return None. + item = method(item, **kwargs) or item + return item diff --git a/mkdocs/tests/config/config_tests.py b/mkdocs/tests/config/config_tests.py index e783d225..a41ee0f1 100644 --- a/mkdocs/tests/config/config_tests.py +++ b/mkdocs/tests/config/config_tests.py @@ -119,40 +119,38 @@ class ConfigTests(unittest.TestCase): 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 = ( { - 'dirs': [os.path.join(theme_dir, 'mkdocs'), mkdocs_templates_dir, search_asset_dir], + 'dirs': [os.path.join(theme_dir, 'mkdocs'), mkdocs_templates_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', '404.html', 'sitemap.xml'], + 'dirs': [os.path.join(theme_dir, 'readthedocs'), mkdocs_templates_dir], + 'static_templates': ['404.html', 'sitemap.xml'], 'vars': {} }, { - 'dirs': [mytheme, mkdocs_templates_dir, search_asset_dir], + 'dirs': [mytheme, mkdocs_templates_dir], 'static_templates': ['sitemap.xml'], 'vars': {} }, { - 'dirs': [custom, os.path.join(theme_dir, 'readthedocs'), mkdocs_templates_dir, search_asset_dir], - 'static_templates': ['search.html', '404.html', 'sitemap.xml'], + 'dirs': [custom, os.path.join(theme_dir, 'readthedocs'), mkdocs_templates_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', '404.html', 'sitemap.xml'], + 'dirs': [os.path.join(theme_dir, 'readthedocs'), mkdocs_templates_dir], + 'static_templates': ['404.html', 'sitemap.xml'], 'vars': {} }, { - 'dirs': [mytheme, mkdocs_templates_dir, search_asset_dir], + 'dirs': [mytheme, mkdocs_templates_dir], 'static_templates': ['sitemap.xml'], 'vars': {} }, { - 'dirs': [custom, os.path.join(theme_dir, 'readthedocs'), mkdocs_templates_dir, search_asset_dir], - 'static_templates': ['search.html', '404.html', 'sitemap.xml'], + 'dirs': [custom, os.path.join(theme_dir, 'readthedocs'), mkdocs_templates_dir], + 'static_templates': ['404.html', 'sitemap.xml'], 'vars': {} }, { - 'dirs': [os.path.join(theme_dir, 'mkdocs'), mkdocs_templates_dir, search_asset_dir], + 'dirs': [os.path.join(theme_dir, 'mkdocs'), mkdocs_templates_dir], 'static_templates': ['404.html', 'sitemap.xml', 'foo.html'], 'vars': {'show_sidebar': False, 'some_var': 'bar'} } diff --git a/mkdocs/tests/nav_tests.py b/mkdocs/tests/nav_tests.py index 3eade7b7..388a4d04 100644 --- a/mkdocs/tests/nav_tests.py +++ b/mkdocs/tests/nav_tests.py @@ -359,24 +359,19 @@ class SiteNavigationTests(unittest.TestCase): def test_invalid_pages_config(self): - bad_pages = [ - set(), # should be dict or string only - {"a": "index.md", "b": "index.md"} # extra key - ] + bad_page = {"a": "index.md", "b": "index.md"} # extra key - for bad_page in bad_pages: + def _test(): + return nav._generate_site_navigation(load_config(pages=[bad_page, ]), None) - def _test(): - return nav._generate_site_navigation({'pages': (bad_page, )}, None) - - self.assertRaises(ConfigurationError, _test) + self.assertRaises(ConfigurationError, _test) def test_pages_config(self): bad_page = {} # empty def _test(): - return nav._generate_site_navigation({'pages': (bad_page, )}, None) + return nav._generate_site_navigation(load_config(pages=[bad_page, ]), None) self.assertRaises(ConfigurationError, _test) diff --git a/mkdocs/tests/plugin_tests.py b/mkdocs/tests/plugin_tests.py new file mode 100644 index 00000000..479ff9d7 --- /dev/null +++ b/mkdocs/tests/plugin_tests.py @@ -0,0 +1,203 @@ +#!/usr/bin/env python +# coding: utf-8 + +from __future__ import unicode_literals + +import unittest +import mock + +from mkdocs import plugins +from mkdocs import utils +from mkdocs import config + + +class DummyPlugin(plugins.BasePlugin): + config_scheme = ( + ('foo', config.config_options.Type(utils.string_types, default='default foo')), + ('bar', config.config_options.Type(int, default=0)) + ) + + def on_pre_page(self, content, **kwargs): + """ prepend `foo` config value to page content. """ + return ' '.join((self.config['foo'], content)) + + def on_nav(self, item, **kwargs): + """ do nothing (return None) to not modify item. """ + return None + + +class TestPluginClass(unittest.TestCase): + + def test_valid_plugin_options(self): + + options = { + 'foo': 'some value' + } + + expected = { + 'foo': 'some value', + 'bar': 0 + } + + plugin = DummyPlugin() + errors, warnings = plugin.load_config(options) + self.assertEqual(plugin.config, expected) + self.assertEqual(errors, []) + self.assertEqual(warnings, []) + + def test_invalid_plugin_options(self): + + plugin = DummyPlugin() + errors, warnings = plugin.load_config({'foo': 42}) + self.assertEqual(len(errors), 1) + self.assertIn('foo', errors[0]) + self.assertEqual(warnings, []) + + errors, warnings = plugin.load_config({'bar': 'a string'}) + self.assertEqual(len(errors), 1) + self.assertIn('bar', errors[0]) + self.assertEqual(warnings, []) + + errors, warnings = plugin.load_config({'invalid_key': 'value'}) + self.assertEqual(errors, []) + self.assertEqual(len(warnings), 1) + self.assertIn('invalid_key', warnings[0]) + + +class TestPluginCollection(unittest.TestCase): + + def test_set_plugin_on_collection(self): + collection = plugins.PluginCollection() + plugin = DummyPlugin() + collection['foo'] = plugin + self.assertEqual([(k, v) for k, v in collection.items()], [('foo', plugin)]) + + def test_set_multiple_plugins_on_collection(self): + collection = plugins.PluginCollection() + plugin1 = DummyPlugin() + collection['foo'] = plugin1 + plugin2 = DummyPlugin() + collection['bar'] = plugin2 + self.assertEqual([(k, v) for k, v in collection.items()], [('foo', plugin1), ('bar', plugin2)]) + + def test_run_event_on_collection(self): + collection = plugins.PluginCollection() + plugin = DummyPlugin() + plugin.load_config({'foo': 'new'}) + collection['foo'] = plugin + self.assertEqual(collection.run_event('pre_page', 'page content'), 'new page content') + + def test_run_event_twice_on_collection(self): + collection = plugins.PluginCollection() + plugin1 = DummyPlugin() + plugin1.load_config({'foo': 'new'}) + collection['foo'] = plugin1 + plugin2 = DummyPlugin() + plugin2.load_config({'foo': 'second'}) + collection['bar'] = plugin2 + self.assertEqual(collection.run_event('pre_page', 'page content'), + 'second new page content') + + def test_event_returns_None(self): + collection = plugins.PluginCollection() + plugin = DummyPlugin() + plugin.load_config({'foo': 'new'}) + collection['foo'] = plugin + self.assertEqual(collection.run_event('nav', 'nav item'), 'nav item') + + def test_run_undefined_event_on_collection(self): + collection = plugins.PluginCollection() + self.assertEqual(collection.run_event('pre_page', 'page content'), 'page content') + + def test_run_unknown_event_on_collection(self): + collection = plugins.PluginCollection() + self.assertRaises(KeyError, collection.run_event, 'unknown', 'page content') + + +MockEntryPoint = mock.Mock() +MockEntryPoint.configure_mock(**{'name': 'sample', 'load.return_value': DummyPlugin}) + + +@mock.patch('pkg_resources.iter_entry_points', return_value=[MockEntryPoint]) +class TestPluginConfig(unittest.TestCase): + + def test_plugin_config_without_options(self, mock_class): + + cfg = {'plugins': ['sample']} + option = config.config_options.Plugins() + cfg['plugins'] = option.validate(cfg['plugins']) + + self.assertIsInstance(cfg['plugins'], plugins.PluginCollection) + self.assertIn('sample', cfg['plugins']) + self.assertIsInstance(cfg['plugins']['sample'], plugins.BasePlugin) + expected = { + 'foo': 'default foo', + 'bar': 0 + } + self.assertEqual(cfg['plugins']['sample'].config, expected) + + def test_plugin_config_with_options(self, mock_class): + + cfg = { + 'plugins': [{ + 'sample': { + 'foo': 'foo value', + 'bar': 42 + } + }] + } + option = config.config_options.Plugins() + cfg['plugins'] = option.validate(cfg['plugins']) + + self.assertIsInstance(cfg['plugins'], plugins.PluginCollection) + self.assertIn('sample', cfg['plugins']) + self.assertIsInstance(cfg['plugins']['sample'], plugins.BasePlugin) + expected = { + 'foo': 'foo value', + 'bar': 42 + } + self.assertEqual(cfg['plugins']['sample'].config, expected) + + def test_plugin_config_uninstalled(self, mock_class): + + cfg = {'plugins': ['uninstalled']} + option = config.config_options.Plugins() + self.assertRaises(config.base.ValidationError, option.validate, cfg['plugins']) + + def test_plugin_config_not_list(self, mock_class): + + cfg = {'plugins': 'sample'} # should be a list + option = config.config_options.Plugins() + self.assertRaises(config.base.ValidationError, option.validate, cfg['plugins']) + + def test_plugin_config_multivalue_dict(self, mock_class): + + cfg = { + 'plugins': [{ + 'sample': { + 'foo': 'foo value', + 'bar': 42 + }, + 'extra_key': 'baz' + }] + } + option = config.config_options.Plugins() + self.assertRaises(config.base.ValidationError, option.validate, cfg['plugins']) + + def test_plugin_config_not_string_or_dict(self, mock_class): + + cfg = { + 'plugins': [('not a string or dict',)] + } + option = config.config_options.Plugins() + self.assertRaises(config.base.ValidationError, option.validate, cfg['plugins']) + + def test_plugin_config_options_not_dict(self, mock_class): + + cfg = { + 'plugins': [{ + 'sample': 'not a dict' + }] + } + option = config.config_options.Plugins() + self.assertRaises(config.base.ValidationError, option.validate, cfg['plugins']) diff --git a/mkdocs/tests/search_tests.py b/mkdocs/tests/search_tests.py index e0a1947f..ca4c4e37 100644 --- a/mkdocs/tests/search_tests.py +++ b/mkdocs/tests/search_tests.py @@ -5,7 +5,7 @@ from __future__ import unicode_literals import unittest from mkdocs import nav -from mkdocs import search +from mkdocs.contrib.legacy_search import search_index as search from mkdocs.tests.base import dedent, markdown_to_toc, load_config diff --git a/mkdocs/tests/theme_tests.py b/mkdocs/tests/theme_tests.py index df0f6b21..8c368fa3 100644 --- a/mkdocs/tests/theme_tests.py +++ b/mkdocs/tests/theme_tests.py @@ -12,7 +12,6 @@ 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): @@ -26,7 +25,7 @@ class ThemeTests(unittest.TestCase): theme = Theme(name='mkdocs') self.assertEqual( theme.dirs, - [os.path.join(theme_dir, 'mkdocs'), mkdocs_templates_dir, search_asset_dir] + [os.path.join(theme_dir, 'mkdocs'), mkdocs_templates_dir] ) self.assertEqual(theme.static_templates, set(['404.html', 'sitemap.xml'])) self.assertEqual(get_vars(theme), {}) @@ -39,8 +38,7 @@ class ThemeTests(unittest.TestCase): [ custom, os.path.join(theme_dir, 'mkdocs'), - mkdocs_templates_dir, - search_asset_dir + mkdocs_templates_dir ] ) @@ -49,7 +47,7 @@ class ThemeTests(unittest.TestCase): theme = Theme(name=None, custom_dir=custom) self.assertEqual( theme.dirs, - [custom, mkdocs_templates_dir, search_asset_dir] + [custom, mkdocs_templates_dir] ) def static_templates(self): @@ -88,8 +86,7 @@ class ThemeTests(unittest.TestCase): [ os.path.join(theme_dir, 'mkdocs'), os.path.join(theme_dir, 'readthedocs'), - mkdocs_templates_dir, - search_asset_dir + mkdocs_templates_dir ] ) self.assertEqual( diff --git a/mkdocs/theme.py b/mkdocs/theme.py index 73a233c9..e67f56a7 100644 --- a/mkdocs/theme.py +++ b/mkdocs/theme.py @@ -49,10 +49,6 @@ class Theme(object): # 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) diff --git a/mkdocs/themes/mkdocs/base.html b/mkdocs/themes/mkdocs/base.html index cbed1b20..9eaadc15 100644 --- a/mkdocs/themes/mkdocs/base.html +++ b/mkdocs/themes/mkdocs/base.html @@ -78,14 +78,13 @@ {%- block scripts %} - {%- for path in extra_javascript %} {%- endfor %} {%- endblock %} - {%- include "search-modal.html" %} + {% if 'legacy_search' in config['plugins'] %}{%- include "search-modal.html" %}{% endif %} diff --git a/mkdocs/themes/mkdocs/nav.html b/mkdocs/themes/mkdocs/nav.html index 58a34c6d..48deb385 100644 --- a/mkdocs/themes/mkdocs/nav.html +++ b/mkdocs/themes/mkdocs/nav.html @@ -46,11 +46,13 @@