diff --git a/datasette/app.py b/datasette/app.py index 8cff6577..4b28e715 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -21,7 +21,7 @@ from pathlib import Path from markupsafe import Markup from itsdangerous import URLSafeSerializer import jinja2 -from jinja2 import ChoiceLoader, Environment, FileSystemLoader, PrefixLoader, escape +from jinja2 import ChoiceLoader, Environment, FileSystemLoader, PrefixLoader from jinja2.environment import Template from jinja2.exceptions import TemplateNotFound import uvicorn @@ -713,12 +713,41 @@ class Datasette: self, templates, context=None, request=None, view_name=None ): context = context or {} + templates_considered = [] if isinstance(templates, Template): template = templates else: if isinstance(templates, str): templates = [templates] - template = self.jinja_env.select_template(templates) + + # Give plugins first chance at loading the template + break_outer = False + plugin_template_source = None + plugin_template_name = None + template_name = None + for template_name in templates: + if break_outer: + break + plugin_template_source = pm.hook.load_template( + template=template_name, + request=request, + datasette=self, + ) + plugin_template_source = await await_me_maybe(plugin_template_source) + if plugin_template_source: + break_outer = True + plugin_template_name = template_name + break + if plugin_template_source is not None: + template = self.jinja_env.from_string(plugin_template_source) + else: + template = self.jinja_env.select_template(templates) + for template_name in templates: + from_plugin = template_name == plugin_template_name + used = from_plugin or template_name == template.name + templates_considered.append( + {"name": template_name, "used": used, "from_plugin": from_plugin} + ) body_scripts = [] # pylint: disable=no-member for extra_script in pm.hook.extra_body_script( @@ -783,6 +812,7 @@ class Datasette: ), "base_url": self.config("base_url"), "csrftoken": request.scope["csrftoken"] if request else lambda: "", + "templates_considered": templates_considered, }, **extra_template_vars, } diff --git a/datasette/hookspecs.py b/datasette/hookspecs.py index 78070e67..ca84b355 100644 --- a/datasette/hookspecs.py +++ b/datasette/hookspecs.py @@ -49,6 +49,11 @@ def extra_template_vars( "Extra template variables to be made available to the template - can return dict or callable or awaitable" +@hookspec(firstresult=True) +def load_template(template, request, datasette): + "Load the specified template, returning the template code as a string" + + @hookspec def publish_subcommand(publish): "Subcommands for 'datasette publish'" diff --git a/datasette/templates/base.html b/datasette/templates/base.html index d860df37..e29c2ea5 100644 --- a/datasette/templates/base.html +++ b/datasette/templates/base.html @@ -79,6 +79,10 @@ document.body.addEventListener('click', (ev) => { {% endfor %} -{% if select_templates %}{% endif %} +{% if templates_considered %} + +{% endif %} diff --git a/datasette/views/base.py b/datasette/views/base.py index 6ca78934..ed2631c5 100644 --- a/datasette/views/base.py +++ b/datasette/views/base.py @@ -8,7 +8,6 @@ import urllib import pint from datasette import __version__ -from datasette.plugins import pm from datasette.database import QueryInterrupted from datasette.utils import ( await_me_maybe, @@ -119,22 +118,15 @@ class BaseView: async def render(self, templates, request, context=None): context = context or {} - template = self.ds.jinja_env.select_template(templates) template_context = { **context, **{ "database_color": self.database_color, - "select_templates": [ - "{}{}".format( - "*" if template_name == template.name else "", template_name - ) - for template_name in templates - ], }, } return Response.html( await self.ds.render_template( - template, template_context, request=request, view_name=self.name + templates, template_context, request=request, view_name=self.name ) ) diff --git a/docs/plugin_hooks.rst b/docs/plugin_hooks.rst index 1c28c72e..3c57b6a8 100644 --- a/docs/plugin_hooks.rst +++ b/docs/plugin_hooks.rst @@ -271,6 +271,24 @@ You can also return an awaitable function that returns a string. Example: `datasette-cluster-map `_ +.. _plugin_hook_load_template: + +load_template(template, request, datasette) +------------------------------------------- + +``template`` - string + The template that is being rendered, e.g. ``database.html`` + +``request`` - object or None + The current HTTP :ref:`internals_request`. This can be ``None`` if the request object is not available. + +``datasette`` - :ref:`internals_datasette` + You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)`` + +Load the source code for a template from a custom location. Hooks should return a string, or ``None`` if the template is not found. + +Datasette will fall back to serving templates from files on disk if the requested template cannot be loaded by any plugins. + .. _plugin_hook_publish_subcommand: publish_subcommand(publish) diff --git a/tests/fixtures.py b/tests/fixtures.py index 2f8383ef..9f3052b7 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -43,6 +43,7 @@ EXPECTED_PLUGINS = [ "extra_js_urls", "extra_template_vars", "forbidden", + "load_template", "menu_links", "permission_allowed", "prepare_connection", diff --git a/tests/plugins/my_plugin.py b/tests/plugins/my_plugin.py index 8fc6a1b4..9dbb3f40 100644 --- a/tests/plugins/my_plugin.py +++ b/tests/plugins/my_plugin.py @@ -308,3 +308,9 @@ def table_actions(datasette, database, table, actor): }, {"href": datasette.urls.instance(), "label": "Table: {}".format(table)}, ] + + +@hookimpl +def load_template(template, request): + if template == "show_json.html" and request.args.get("_special"): + return "

Special show_json: {{ filename }}

" diff --git a/tests/test_plugins.py b/tests/test_plugins.py index be36a517..f8888798 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -801,3 +801,8 @@ def test_hook_table_actions(app_client): {"label": "Database: fixtures", "href": "/"}, {"label": "Table: facetable", "href": "/"}, ] + + +def test_hook_load_template(app_client): + response = app_client.get("/-/databases?_special=1") + assert response.text == "

Special show_json: databases.json

"