Plugins
An Otter Wiki can be extended with plugins. It uses pluggy to expose a set of function hooks that a plugin implements to add or change behaviour, from rewriting markdown before it is rendered to injecting HTML into the sidebar or reacting to page changes.
The plugin API is experimental and still limited. If you want to write a plugin and cannot find a matching hook, please open an issue.
Example plugins
Same example plugins live in
docs/plugin_examples
in the source repository. Each is a self-contained package you can copy as a
starting point, and they come with a docker-compose.yaml for trying them out
and a test suite.
- noemojis removes all emojis from pages.
- htmlinjection demonstrates the HTML injection and per-element rendering hooks.
- referencingpages shows which pages reference the current page via WikiLinks.
- sidebarpageindex demonstrates the sidebar page index filter and sort hooks.
- authorsignature adds a footer with the original author and last editor.
- redlinks marks WikiLinks to non-existent pages in red, like MediaWiki's redlinks.
What a plugin is
A plugin is a normal, pip-installable Python package that registers one or more hook implementations. In practice that means three things:
- a class whose methods are decorated with
@hookimpl, - a call to
plugin_manager.register(...)so the wiki finds it, - an
otterwikientry point in the package metadata.
The smallest complete example, the noemojis plugin, strips emojis from every
page before it is rendered:
from otterwiki.plugins import hookimpl, plugin_manager class NoEmojiPlugin: @hookimpl def renderer_markdown_preprocess(self, md): return self.emojis.sub('', md) # needed so the plugin_manager finds the plugin plugin_manager.register(NoEmojiPlugin())
with a pyproject.toml that declares the entry point:
[project.entry-points.otterwiki] noemojis = "otterwiki_noemojis"
A plugin that surfaces info() and help() also appears in the in-app plugin
help at /-/help/plugins, next to the built-in embeddings.
Installing a plugin
A plugin must be installed into the same (virtual) environment that runs the flask app.
In the docker image
On container start entrypoint.sh installs every plugin directory it finds
under /app-data/plugins/ and /plugins/ with pip install -U .. So there
are two ways to deploy a plugin:
- drop the plugin directory into
plugins/inside the volume that already holds yourapp-data(alongsidedb.sqliteandrepository), or - keep your plugins in a separate directory and mount it into the container at
/plugins.
For example, mounting a plugins directory next to the usual app-data volume:
services: otterwiki: image: redimp/otterwiki:2 volumes: - ./app-data:/app-data - ./plugins:/plugins
A plugin with dependencies that are not already in the image will fail to
install. In that case build a custom image that bundles the plugin and its
requirements. In environments with SELINUX=enforcing the bind mounts need
adjusting, see the FAQ.
From a source install
Activate the virtual environment that runs the app and install the plugin directory into it:
venv/bin/pip install .
Uninstalling
From a source install run pip uninstall <plugin-name>. For the docker image,
remove the plugin directory and recreate the container for a clean environment.
Developing Plugins
The quickest way to start is to copy one of the example plugins
above and adapt it: each is a minimal, self-contained package with a working
pyproject.toml, the otterwiki entry point and a test to build on.
A plugin package is a directory holding the module and a pyproject.toml. In the
module you implement the hooks you need as methods on a class, decorate each with
@hookimpl, and register an instance with plugin_manager.register(...) (the
What a plugin is example shows the smallest version).
Implement only the hooks you need; the Available hooks list
below summarises them, and otterwiki/plugins.py carries the full signatures.
A few things worth knowing when writing a hook:
- Plugins that need the Flask app, database or git storage receive them through
the
setup(app, db, storage)hook; rendering-only plugins can ignore it. - Some hooks are chained, each plugin's return value feeding the next, so the
order plugins load in matters. The markdown and HTML pre/post-processing hooks
(such as
renderer_markdown_preprocess) work this way. - Some hooks return the first non-
Noneresult and then stop, for exampleembedding_render, so a plugin can claim a single embedding name. - The two
sidebar_page_index_*hooks mutate the entries list in place rather than returning a new one.
For the full mechanism, the hookspec docstrings in otterwiki/plugins.py are the reference, and the example plugins above show each hook in use.
Available hooks
The authoritative list of hooks, with full signatures and documentation, is the
OtterWikiPluginSpec class in
otterwiki/plugins.py.
A plugin implements only the hooks it needs. Grouped by purpose:
Setup
setup(app, db, storage)receives the Flask app, database and git storage to initialise the plugin.
Rendering (pre/post processing)
renderer_markdown_preprocess(md)transforms the raw markdown before rendering (chained across plugins).renderer_html_postprocess(html)transforms the HTML after the page has been rendered.renderer_javascript()adds JavaScript to the rendered page.page_view_htmlcontent_postprocess(html, page)transforms a page's rendered content just before display.page_render_context(page, preview)receives the page currently being rendered, and whether it is a preview.
Embeddings
embedding_parse(embedding, options, args)parses an embedding and returns its HTML.embedding_render(embedding, args)renders a{{name ...}}embedding to HTML.
Template injection points
template_html_head_inject(page)injects HTML into the<head>.template_html_body_inject(page)injects HTML before the closing</body>.template_html_sidebar_left_inject(page)appends HTML to the left sidebar (menu and page index).template_html_sidebar_right_inject(page)appends HTML to the right sidebar (the "On this page" block).
Per-element rendering
renderer_process_link(...)modifies each rendered markdown link.renderer_process_image(...)modifies each rendered image.renderer_process_heading(...)modifies each rendered heading.renderer_process_wikilink(...)modifies each rendered WikiLink.
Repository and page events
repository_changed(changed_files)reacts to any repository change, including the git web server and automatic pulls (read-only).page_saved(pagepath, content, author, message)runs after a page's content changed.page_deleted(pagepath, author, message)runs after a page was deleted.page_renamed(old_pagepath, new_pagepath, author, message)runs after a page was renamed.
Info and help
info()returns(name, description, category)used to group the plugin in the user help.help(plugin)returns the plugin's documentation shown under/-/help.help_category_prelude(category)returns introductory text for a help category.
Static CSS
static_css()returns CSS added to every page via the layout template.
URL routes
url_request(plugin, extra, method, values)handles requests to/-/plugin/<name>/<extra>.url_admin_request(plugin, extra, method, values)handles admin requests to/-/admin/plugin/<name>/<extra>.
Sidebar page index
sidebar_page_index_filter_entries(entries, mode)filters the sidebar page index entries in place.sidebar_page_index_sort_entries(entries, mode)sorts the sidebar page index entries in place.
Testing a plugin
You do not need to install a plugin to test it. The example plugins use an
example_plugin_loader fixture (in
docs/plugin_examples/tests)
that loads a plugin straight from its directory and unregisters it again on
teardown. Add a test_<name>.py next to the existing ones, load your plugin with
example_plugin_loader("plugin_<name>"), and if it implements setup() call it
on the loaded instance. Run the suite with:
OTTERWIKI_SETTINGS="" venv/bin/pytest docs/plugin_examples/tests
