{"html_url": "https://github.com/simonw/datasette/issues/1072#issuecomment-719784606", "issue_url": "https://api.github.com/repos/simonw/datasette/issues/1072", "id": 719784606, "node_id": "MDEyOklzc3VlQ29tbWVudDcxOTc4NDYwNg==", "user": {"value": 9599, "label": "simonw"}, "created_at": "2020-10-30T20:35:33Z", "updated_at": "2020-10-30T20:35:33Z", "author_association": "OWNER", "body": "To fix this I think I need to move the `load_template` implementation into a Jinja template loader.\r\n\r\nI'm not sure I'll be able to keep the `Templates considered` comment working though:\r\n\r\nhttps://github.com/simonw/datasette/blob/a2a709072059c6b3da365df9a332ca744c2079e9/datasette/app.py#L745-L750", "reactions": "{\"total_count\": 0, \"+1\": 0, \"-1\": 0, \"laugh\": 0, \"hooray\": 0, \"confused\": 0, \"heart\": 0, \"rocket\": 0, \"eyes\": 0}", "issue": {"value": 733499930, "label": "load_template hook doesn't work for include/extends"}, "performed_via_github_app": null} {"html_url": "https://github.com/simonw/datasette/issues/1072#issuecomment-719785005", "issue_url": "https://api.github.com/repos/simonw/datasette/issues/1072", "id": 719785005, "node_id": "MDEyOklzc3VlQ29tbWVudDcxOTc4NTAwNQ==", "user": {"value": 9599, "label": "simonw"}, "created_at": "2020-10-30T20:36:22Z", "updated_at": "2020-10-30T20:36:22Z", "author_association": "OWNER", "body": "It should be easy enough to show a comment that says which original template names were considered, but I may not be able to show which one was actually used (or which ones came from plugins).", "reactions": "{\"total_count\": 0, \"+1\": 0, \"-1\": 0, \"laugh\": 0, \"hooray\": 0, \"confused\": 0, \"heart\": 0, \"rocket\": 0, \"eyes\": 0}", "issue": {"value": 733499930, "label": "load_template hook doesn't work for include/extends"}, "performed_via_github_app": null} {"html_url": "https://github.com/simonw/datasette/issues/1072#issuecomment-719803880", "issue_url": "https://api.github.com/repos/simonw/datasette/issues/1072", "id": 719803880, "node_id": "MDEyOklzc3VlQ29tbWVudDcxOTgwMzg4MA==", "user": {"value": 9599, "label": "simonw"}, "created_at": "2020-10-30T21:17:11Z", "updated_at": "2020-10-30T21:17:11Z", "author_association": "OWNER", "body": "Example from the Jinja docs: https://jinja.palletsprojects.com/en/2.11.x/api/#jinja2.BaseLoader\r\n\r\n```python\r\nfrom jinja2 import BaseLoader, TemplateNotFound\r\nfrom os.path import join, exists, getmtime\r\n\r\nclass MyLoader(BaseLoader):\r\n\r\n def __init__(self, path):\r\n self.path = path\r\n\r\n def get_source(self, environment, template):\r\n path = join(self.path, template)\r\n if not exists(path):\r\n raise TemplateNotFound(template)\r\n mtime = getmtime(path)\r\n with file(path) as f:\r\n source = f.read().decode('utf-8')\r\n return source, path, lambda: mtime == getmtime(path)\r\n```\r\nAlso available: `jinja2.FunctionLoader(load_func)` which lets me pass it a function like this one:\r\n```\r\n>>> def load_template(name):\r\n... if name == 'index.html':\r\n... return '...'\r\n...\r\n>>> loader = FunctionLoader(load_template)\r\n```\r\n\r\nJust one catch: I need to be able to load templates asynchronously, because they live in the database. Let's hope Jinja has a mechanism for that!", "reactions": "{\"total_count\": 0, \"+1\": 0, \"-1\": 0, \"laugh\": 0, \"hooray\": 0, \"confused\": 0, \"heart\": 0, \"rocket\": 0, \"eyes\": 0}", "issue": {"value": 733499930, "label": "load_template hook doesn't work for include/extends"}, "performed_via_github_app": null} {"html_url": "https://github.com/simonw/datasette/issues/1072#issuecomment-719807502", "issue_url": "https://api.github.com/repos/simonw/datasette/issues/1072", "id": 719807502, "node_id": "MDEyOklzc3VlQ29tbWVudDcxOTgwNzUwMg==", "user": {"value": 9599, "label": "simonw"}, "created_at": "2020-10-30T21:26:49Z", "updated_at": "2020-10-30T21:26:49Z", "author_association": "OWNER", "body": "It looks like Jinja does not have a mechanism for asynchronous template loading - the loader API is synchronous.\r\n\r\nOne option may be to figure out which templates are needed (including inherited templates and includes) before rendering the template. Then async load those templates from the database into a `DictLoader`, then pass that `DictLoader` to Jinja.", "reactions": "{\"total_count\": 0, \"+1\": 0, \"-1\": 0, \"laugh\": 0, \"hooray\": 0, \"confused\": 0, \"heart\": 0, \"rocket\": 0, \"eyes\": 0}", "issue": {"value": 733499930, "label": "load_template hook doesn't work for include/extends"}, "performed_via_github_app": null} {"html_url": "https://github.com/simonw/datasette/issues/1072#issuecomment-719809259", "issue_url": "https://api.github.com/repos/simonw/datasette/issues/1072", "id": 719809259, "node_id": "MDEyOklzc3VlQ29tbWVudDcxOTgwOTI1OQ==", "user": {"value": 9599, "label": "simonw"}, "created_at": "2020-10-30T21:31:10Z", "updated_at": "2020-10-30T21:31:10Z", "author_association": "OWNER", "body": "How can we tell what template Jinja will need to render?\r\n\r\nOne approach that could work:\r\n\r\n1. Set up a dummy template loader which records the name of the template that was requested\r\n2. Load the template\r\n3. Now we know the list of templates that were requested. Async load those\r\n4. The dummy template loader can now return the ones we have loaded. Load the template again.\r\n5. Did it request any more templates? If so, load those, and repeat.\r\n6. Keep on with this loop until a template load (which might even have to be a render) fails to request any templates that we have not yet loaded.\r\n7. Render the template.\r\n\r\nThis is GROSS. It feels like a huge waste of CPU, and it could lead to very weird behaviour if any template variables have side effects.", "reactions": "{\"total_count\": 0, \"+1\": 0, \"-1\": 0, \"laugh\": 0, \"hooray\": 0, \"confused\": 0, \"heart\": 0, \"rocket\": 0, \"eyes\": 0}", "issue": {"value": 733499930, "label": "load_template hook doesn't work for include/extends"}, "performed_via_github_app": null} {"html_url": "https://github.com/simonw/datasette/issues/1072#issuecomment-719809780", "issue_url": "https://api.github.com/repos/simonw/datasette/issues/1072", "id": 719809780, "node_id": "MDEyOklzc3VlQ29tbWVudDcxOTgwOTc4MA==", "user": {"value": 9599, "label": "simonw"}, "created_at": "2020-10-30T21:32:28Z", "updated_at": "2020-10-30T21:32:28Z", "author_association": "OWNER", "body": "Here's an alternative that would definitely work and would be a lot simpler, at the cost of a fair amount of RAM:\r\n\r\n1. Before rendering the template, load ALL of the most-recent-versions of the templates that are stored in the DB. Use those to populate a `DictLoader`.\r\n2. Render the template.\r\n\r\nThis does mean loading template bodies that we won't use. Provided an instance has less than 100 templates I imagine this will work just fine.", "reactions": "{\"total_count\": 0, \"+1\": 0, \"-1\": 0, \"laugh\": 0, \"hooray\": 0, \"confused\": 0, \"heart\": 0, \"rocket\": 0, \"eyes\": 0}", "issue": {"value": 733499930, "label": "load_template hook doesn't work for include/extends"}, "performed_via_github_app": null} {"html_url": "https://github.com/simonw/datasette/issues/1072#issuecomment-719810023", "issue_url": "https://api.github.com/repos/simonw/datasette/issues/1072", "id": 719810023, "node_id": "MDEyOklzc3VlQ29tbWVudDcxOTgxMDAyMw==", "user": {"value": 9599, "label": "simonw"}, "created_at": "2020-10-30T21:33:06Z", "updated_at": "2020-10-30T21:33:06Z", "author_association": "OWNER", "body": "The ideal solution is for Jinja to offer `async` template loading. I'll file a feature request, then I'll implement the second option above (async load all templates from the DB before each render).", "reactions": "{\"total_count\": 0, \"+1\": 0, \"-1\": 0, \"laugh\": 0, \"hooray\": 0, \"confused\": 0, \"heart\": 0, \"rocket\": 0, \"eyes\": 0}", "issue": {"value": 733499930, "label": "load_template hook doesn't work for include/extends"}, "performed_via_github_app": null} {"html_url": "https://github.com/simonw/datasette/issues/1072#issuecomment-719810533", "issue_url": "https://api.github.com/repos/simonw/datasette/issues/1072", "id": 719810533, "node_id": "MDEyOklzc3VlQ29tbWVudDcxOTgxMDUzMw==", "user": {"value": 9599, "label": "simonw"}, "created_at": "2020-10-30T21:34:38Z", "updated_at": "2020-10-30T21:34:38Z", "author_association": "OWNER", "body": "... no wait, my comments above assume that I'm just building the `datasette-edit-templates` plugin. Does this work as a general solution for all of Datasette? I don't think it does.\r\n\r\nThis may mean I need to delay the whole feature.", "reactions": "{\"total_count\": 0, \"+1\": 0, \"-1\": 0, \"laugh\": 0, \"hooray\": 0, \"confused\": 0, \"heart\": 0, \"rocket\": 0, \"eyes\": 0}", "issue": {"value": 733499930, "label": "load_template hook doesn't work for include/extends"}, "performed_via_github_app": null} {"html_url": "https://github.com/simonw/datasette/issues/1072#issuecomment-719811312", "issue_url": "https://api.github.com/repos/simonw/datasette/issues/1072", "id": 719811312, "node_id": "MDEyOklzc3VlQ29tbWVudDcxOTgxMTMxMg==", "user": {"value": 9599, "label": "simonw"}, "created_at": "2020-10-30T21:36:49Z", "updated_at": "2020-10-30T21:36:49Z", "author_association": "OWNER", "body": "There's one other option: in `datasette-edit-templates` I could maybe use `asyncio.get_event_loop().run_in_executor(...)` to load the templates asynchronously within the Jinja template loader mechanism.\r\n", "reactions": "{\"total_count\": 0, \"+1\": 0, \"-1\": 0, \"laugh\": 0, \"hooray\": 0, \"confused\": 0, \"heart\": 0, \"rocket\": 0, \"eyes\": 0}", "issue": {"value": 733499930, "label": "load_template hook doesn't work for include/extends"}, "performed_via_github_app": null} {"html_url": "https://github.com/simonw/datasette/issues/1072#issuecomment-719813212", "issue_url": "https://api.github.com/repos/simonw/datasette/issues/1072", "id": 719813212, "node_id": "MDEyOklzc3VlQ29tbWVudDcxOTgxMzIxMg==", "user": {"value": 9599, "label": "simonw"}, "created_at": "2020-10-30T21:42:35Z", "updated_at": "2020-10-30T21:42:35Z", "author_association": "OWNER", "body": "Filed a feature request here: https://github.com/pallets/jinja/issues/1304", "reactions": "{\"total_count\": 0, \"+1\": 0, \"-1\": 0, \"laugh\": 0, \"hooray\": 0, \"confused\": 0, \"heart\": 0, \"rocket\": 0, \"eyes\": 0}", "issue": {"value": 733499930, "label": "load_template hook doesn't work for include/extends"}, "performed_via_github_app": null} {"html_url": "https://github.com/simonw/datasette/issues/1072#issuecomment-719813970", "issue_url": "https://api.github.com/repos/simonw/datasette/issues/1072", "id": 719813970, "node_id": "MDEyOklzc3VlQ29tbWVudDcxOTgxMzk3MA==", "user": {"value": 9599, "label": "simonw"}, "created_at": "2020-10-30T21:44:40Z", "updated_at": "2020-10-30T21:44:40Z", "author_association": "OWNER", "body": "I'm pretty sure that `run_in_executor()` workaround won't work. https://github.com/django/asgiref/blob/7becc9daca2628c46af1cb7e46b4c47c1ea27adf/asgiref/sync.py#L83 for example says \"You cannot use AsyncToSync in the same thread as an async event loop\".", "reactions": "{\"total_count\": 0, \"+1\": 0, \"-1\": 0, \"laugh\": 0, \"hooray\": 0, \"confused\": 0, \"heart\": 0, \"rocket\": 0, \"eyes\": 0}", "issue": {"value": 733499930, "label": "load_template hook doesn't work for include/extends"}, "performed_via_github_app": null} {"html_url": "https://github.com/simonw/datasette/issues/1072#issuecomment-719814279", "issue_url": "https://api.github.com/repos/simonw/datasette/issues/1072", "id": 719814279, "node_id": "MDEyOklzc3VlQ29tbWVudDcxOTgxNDI3OQ==", "user": {"value": 9599, "label": "simonw"}, "created_at": "2020-10-30T21:45:33Z", "updated_at": "2020-10-30T21:45:33Z", "author_association": "OWNER", "body": "Sadly I'm going to bump `load_template` from Datasette 0.51 - I don't think I should block the release on resolving this issue.", "reactions": "{\"total_count\": 0, \"+1\": 0, \"-1\": 0, \"laugh\": 0, \"hooray\": 0, \"confused\": 0, \"heart\": 0, \"rocket\": 0, \"eyes\": 0}", "issue": {"value": 733499930, "label": "load_template hook doesn't work for include/extends"}, "performed_via_github_app": null} {"html_url": "https://github.com/simonw/datasette/issues/1072#issuecomment-719819234", "issue_url": "https://api.github.com/repos/simonw/datasette/issues/1072", "id": 719819234, "node_id": "MDEyOklzc3VlQ29tbWVudDcxOTgxOTIzNA==", "user": {"value": 9599, "label": "simonw"}, "created_at": "2020-10-30T22:00:21Z", "updated_at": "2020-10-30T22:00:21Z", "author_association": "OWNER", "body": "There might be a way to save this. Async template loading can't be supported, but what if you could define a `load_template()` hook which returned a sync function that returned templates...\r\n\r\nThen the `datasette-edit-templates` plugin could reply to `load_template` by loading all DB templates into memory and returning a `load_template` sync function that looked up the values in those already-loaded templates.\r\n\r\nIt could even maintain an in-memory cache that gets updated when a template is edited.\r\n\r\nIf I do this, I could remove the ability to return an `async` function from `load_template()` but add that in the future should Jinja implement a mechanism for async template loading.", "reactions": "{\"total_count\": 0, \"+1\": 0, \"-1\": 0, \"laugh\": 0, \"hooray\": 0, \"confused\": 0, \"heart\": 0, \"rocket\": 0, \"eyes\": 0}", "issue": {"value": 733499930, "label": "load_template hook doesn't work for include/extends"}, "performed_via_github_app": null} {"html_url": "https://github.com/simonw/datasette/issues/1072#issuecomment-719819331", "issue_url": "https://api.github.com/repos/simonw/datasette/issues/1072", "id": 719819331, "node_id": "MDEyOklzc3VlQ29tbWVudDcxOTgxOTMzMQ==", "user": {"value": 9599, "label": "simonw"}, "created_at": "2020-10-30T22:00:43Z", "updated_at": "2020-10-30T22:00:43Z", "author_association": "OWNER", "body": "I'll try getting that to work. If I can't get it to work I'll drop the plugin hook for the moment.", "reactions": "{\"total_count\": 0, \"+1\": 0, \"-1\": 0, \"laugh\": 0, \"hooray\": 0, \"confused\": 0, \"heart\": 0, \"rocket\": 0, \"eyes\": 0}", "issue": {"value": 733499930, "label": "load_template hook doesn't work for include/extends"}, "performed_via_github_app": null} {"html_url": "https://github.com/simonw/datasette/issues/1072#issuecomment-719832651", "issue_url": "https://api.github.com/repos/simonw/datasette/issues/1072", "id": 719832651, "node_id": "MDEyOklzc3VlQ29tbWVudDcxOTgzMjY1MQ==", "user": {"value": 9599, "label": "simonw"}, "created_at": "2020-10-30T22:46:25Z", "updated_at": "2020-10-30T22:46:25Z", "author_association": "OWNER", "body": "I tried using a `FunctionLoader` and got this error on startup:\r\n```python\r\n File \"/Users/simon/Dropbox/Development/datasette/datasette/app.py\", line 989, in __init__\r\n for filepath in self.ds.jinja_env.list_templates()\r\n File \"/Users/simon/.local/share/virtualenvs/datasette-edit-templates-agoZyE3x/lib/python3.8/site-packages/jinja2/environment.py\", line 810, in list_templates\r\n names = self.loader.list_templates()\r\n File \"/Users/simon/.local/share/virtualenvs/datasette-edit-templates-agoZyE3x/lib/python3.8/site-packages/jinja2/loaders.py\", line 434, in list_templates\r\n found.update(loader.list_templates())\r\n File \"/Users/simon/.local/share/virtualenvs/datasette-edit-templates-agoZyE3x/lib/python3.8/site-packages/jinja2/loaders.py\", line 99, in list_templates\r\n raise TypeError(\"this loader cannot iterate over all templates\")\r\nTypeError: this loader cannot iterate over all templates\r\n```\r\nSo if I'm going to define a custom Jinja loader I'll need to teach plugins to answer the \"list templates\" query.", "reactions": "{\"total_count\": 0, \"+1\": 0, \"-1\": 0, \"laugh\": 0, \"hooray\": 0, \"confused\": 0, \"heart\": 0, \"rocket\": 0, \"eyes\": 0}", "issue": {"value": 733499930, "label": "load_template hook doesn't work for include/extends"}, "performed_via_github_app": null} {"html_url": "https://github.com/simonw/datasette/issues/1072#issuecomment-719832853", "issue_url": "https://api.github.com/repos/simonw/datasette/issues/1072", "id": 719832853, "node_id": "MDEyOklzc3VlQ29tbWVudDcxOTgzMjg1Mw==", "user": {"value": 9599, "label": "simonw"}, "created_at": "2020-10-30T22:47:12Z", "updated_at": "2020-10-30T22:47:12Z", "author_association": "OWNER", "body": "Maybe I should ditch this hook entirely in favour of the existing `prepare_jinja2_environment` hook. Could that add new template loaders?", "reactions": "{\"total_count\": 0, \"+1\": 0, \"-1\": 0, \"laugh\": 0, \"hooray\": 0, \"confused\": 0, \"heart\": 0, \"rocket\": 0, \"eyes\": 0}", "issue": {"value": 733499930, "label": "load_template hook doesn't work for include/extends"}, "performed_via_github_app": null} {"html_url": "https://github.com/simonw/datasette/issues/1072#issuecomment-719833070", "issue_url": "https://api.github.com/repos/simonw/datasette/issues/1072", "id": 719833070, "node_id": "MDEyOklzc3VlQ29tbWVudDcxOTgzMzA3MA==", "user": {"value": 9599, "label": "simonw"}, "created_at": "2020-10-30T22:48:04Z", "updated_at": "2020-10-30T22:48:04Z", "author_association": "OWNER", "body": "https://github.com/simonw/datasette/blob/a2a709072059c6b3da365df9a332ca744c2079e9/datasette/app.py#L310-L318\r\n\r\nSo yeah that plugin hook can probably modify the list of loaders available to the `Environment`.", "reactions": "{\"total_count\": 0, \"+1\": 0, \"-1\": 0, \"laugh\": 0, \"hooray\": 0, \"confused\": 0, \"heart\": 0, \"rocket\": 0, \"eyes\": 0}", "issue": {"value": 733499930, "label": "load_template hook doesn't work for include/extends"}, "performed_via_github_app": null} {"html_url": "https://github.com/simonw/datasette/issues/1072#issuecomment-719833744", "issue_url": "https://api.github.com/repos/simonw/datasette/issues/1072", "id": 719833744, "node_id": "MDEyOklzc3VlQ29tbWVudDcxOTgzMzc0NA==", "user": {"value": 9599, "label": "simonw"}, "created_at": "2020-10-30T22:50:57Z", "updated_at": "2020-10-30T22:50:57Z", "author_association": "OWNER", "body": "Yeah I'm going to remove the `load_template` plugin hook and see if it's possible to build the edit templates extension against `prepare_jinja2_environment` instead.", "reactions": "{\"total_count\": 0, \"+1\": 0, \"-1\": 0, \"laugh\": 0, \"hooray\": 0, \"confused\": 0, \"heart\": 0, \"rocket\": 0, \"eyes\": 0}", "issue": {"value": 733499930, "label": "load_template hook doesn't work for include/extends"}, "performed_via_github_app": null} {"html_url": "https://github.com/simonw/datasette/issues/1072#issuecomment-719955491", "issue_url": "https://api.github.com/repos/simonw/datasette/issues/1072", "id": 719955491, "node_id": "MDEyOklzc3VlQ29tbWVudDcxOTk1NTQ5MQ==", "user": {"value": 9599, "label": "simonw"}, "created_at": "2020-10-31T16:20:58Z", "updated_at": "2020-10-31T16:20:58Z", "author_association": "OWNER", "body": "Here's the proof of concept `FunctionLoader` that showed me that this wasn't going to work:\r\n```diff\r\ndiff --git a/datasette/app.py b/datasette/app.py\r\nindex 4b28e71..b076be7 100644\r\n--- a/datasette/app.py\r\n+++ b/datasette/app.py\r\n@@ -21,7 +21,7 @@ from pathlib import Path\r\n from markupsafe import Markup\r\n from itsdangerous import URLSafeSerializer\r\n import jinja2\r\n-from jinja2 import ChoiceLoader, Environment, FileSystemLoader, PrefixLoader\r\n+from jinja2 import ChoiceLoader, Environment, FileSystemLoader, FunctionLoader, PrefixLoader\r\n from jinja2.environment import Template\r\n from jinja2.exceptions import TemplateNotFound\r\n import uvicorn\r\n@@ -300,6 +300,7 @@ class Datasette:\r\n template_paths.append(default_templates)\r\n template_loader = ChoiceLoader(\r\n [\r\n+ FunctionLoader(self._load_template_from_plugins),\r\n FileSystemLoader(template_paths),\r\n # Support {% extends \"default:table.html\" %}:\r\n PrefixLoader(\r\n@@ -322,6 +323,17 @@ class Datasette:\r\n self._root_token = secrets.token_hex(32)\r\n self.client = DatasetteClient(self)\r\n \r\n+ def _load_template_from_plugins(self, template):\r\n+ # \"If auto reloading is enabled it\u2019s called to check if the template changed\"\r\n+ uptodatefunc = lambda: True\r\n+ source = pm.hook.load_template(\r\n+ template=template,\r\n+ datasette=self,\r\n+ )\r\n+ if source is None:\r\n+ return None\r\n+ return source, template, uptodatefunc\r\n+\r\n @property\r\n def urls(self):\r\n return Urls(self)\r\n@@ -719,35 +731,7 @@ class Datasette:\r\n else:\r\n if isinstance(templates, str):\r\n templates = [templates]\r\n-\r\n- # Give plugins first chance at loading the template\r\n- break_outer = False\r\n- plugin_template_source = None\r\n- plugin_template_name = None\r\n- template_name = None\r\n- for template_name in templates:\r\n- if break_outer:\r\n- break\r\n- plugin_template_source = pm.hook.load_template(\r\n- template=template_name,\r\n- request=request,\r\n- datasette=self,\r\n- )\r\n- plugin_template_source = await await_me_maybe(plugin_template_source)\r\n- if plugin_template_source:\r\n- break_outer = True\r\n- plugin_template_name = template_name\r\n- break\r\n- if plugin_template_source is not None:\r\n- template = self.jinja_env.from_string(plugin_template_source)\r\n- else:\r\n- template = self.jinja_env.select_template(templates)\r\n- for template_name in templates:\r\n- from_plugin = template_name == plugin_template_name\r\n- used = from_plugin or template_name == template.name\r\n- templates_considered.append(\r\n- {\"name\": template_name, \"used\": used, \"from_plugin\": from_plugin}\r\n- )\r\n+ template = self.jinja_env.select_template(templates)\r\n body_scripts = []\r\n # pylint: disable=no-member\r\n for extra_script in pm.hook.extra_body_script(\r\ndiff --git a/datasette/hookspecs.py b/datasette/hookspecs.py\r\nindex ca84b35..7804def 100644\r\n--- a/datasette/hookspecs.py\r\n+++ b/datasette/hookspecs.py\r\n@@ -50,7 +50,7 @@ def extra_template_vars(\r\n \r\n \r\n @hookspec(firstresult=True)\r\n-def load_template(template, request, datasette):\r\n+def load_template(template, datasette):\r\n \"Load the specified template, returning the template code as a string\"\r\n \r\n \r\ndiff --git a/docs/plugin_hooks.rst b/docs/plugin_hooks.rst\r\nindex 3c57b6a..8f2704e 100644\r\n--- a/docs/plugin_hooks.rst\r\n+++ b/docs/plugin_hooks.rst\r\n@@ -273,15 +273,12 @@ Example: `datasette-cluster-map