diff --git a/datasette/app.py b/datasette/app.py
index 2a5d569e..baddfc9f 100644
--- a/datasette/app.py
+++ b/datasette/app.py
@@ -17,6 +17,7 @@ from markupsafe import Markup
import jinja2
from jinja2 import ChoiceLoader, Environment, FileSystemLoader, PrefixLoader, escape
from jinja2.environment import Template
+from jinja2.exceptions import TemplateNotFound
import uvicorn
from .views.base import DatasetteError, ureg, AsgiRouter
@@ -745,7 +746,7 @@ class DatasetteRouter(AsgiRouter):
path = "/" + path[len(base_url) :]
return await super().route_path(scope, receive, send, path)
- async def handle_404(self, scope, receive, send):
+ async def handle_404(self, scope, receive, send, exception=None):
# If URL has a trailing slash, redirect to URL without it
path = scope.get("raw_path", scope["path"].encode("utf8"))
if path.endswith(b"/"):
@@ -754,7 +755,54 @@ class DatasetteRouter(AsgiRouter):
path += b"?" + scope["query_string"]
await asgi_send_redirect(send, path.decode("latin1"))
else:
- await super().handle_404(scope, receive, send)
+ # Is there a pages/* template matching this path?
+ template_path = os.path.join("pages", *scope["path"].split("/")) + ".html"
+ try:
+ template = self.ds.jinja_env.select_template([template_path])
+ except TemplateNotFound:
+ template = None
+ if template:
+ headers = {}
+ status = [200]
+
+ def custom_header(name, value):
+ headers[name] = value
+ return ""
+
+ def custom_status(code):
+ status[0] = code
+ return ""
+
+ def custom_redirect(location, code=302):
+ status[0] = code
+ headers["Location"] = location
+ return ""
+
+ body = await self.ds.render_template(
+ template,
+ {
+ "custom_header": custom_header,
+ "custom_status": custom_status,
+ "custom_redirect": custom_redirect,
+ },
+ view_name="page",
+ )
+ # Pull content-type out into separate parameter
+ content_type = "text/html"
+ matches = [k for k in headers if k.lower() == "content-type"]
+ if matches:
+ content_type = headers[matches[0]]
+ await asgi_send(
+ send,
+ body,
+ status=status[0],
+ headers=headers,
+ content_type=content_type,
+ )
+ else:
+ await self.handle_500(
+ scope, receive, send, exception or NotFound("404")
+ )
async def handle_500(self, scope, receive, send, exception):
title = None
diff --git a/datasette/utils/asgi.py b/datasette/utils/asgi.py
index 817c7dd8..ed3138d0 100644
--- a/datasette/utils/asgi.py
+++ b/datasette/utils/asgi.py
@@ -119,11 +119,13 @@ class AsgiRouter:
new_scope = dict(scope, url_route={"kwargs": match.groupdict()})
try:
return await view(new_scope, receive, send)
+ except NotFound as exception:
+ return await self.handle_404(scope, receive, send, exception)
except Exception as exception:
return await self.handle_500(scope, receive, send, exception)
return await self.handle_404(scope, receive, send)
- async def handle_404(self, scope, receive, send):
+ async def handle_404(self, scope, receive, send, exception=None):
await send(
{
"type": "http.response.start",
diff --git a/docs/custom_templates.rst b/docs/custom_templates.rst
index 59f09138..4a300e82 100644
--- a/docs/custom_templates.rst
+++ b/docs/custom_templates.rst
@@ -265,3 +265,64 @@ Here is an example of a custom ``_table.html`` template::
Category: {{ row.display("category_id") }}
{% endfor %}
+
+.. _custom_pages:
+
+Custom pages
+------------
+
+You can add templated pages to your Datasette instance by creating HTML files in a ``pages`` directory within your ``templates`` directory.
+
+For example, to add a custom page that is served at ``http://localhost/about`` you would create a file in ``templates/pages/about.html``, then start Datasette like this::
+
+ $ datasette mydb.db --template-dir=templates/
+
+You can nest directories within pages to create a nested structure. To create a ``http://localhost:8001/about/map`` page you would create ``templates/pages/about/map.html``.
+
+Custom headers and status codes
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Custom pages default to being served with a content-type of ``text/html`` and a ``200`` status code. You can change these by calling a custom function from within your template.
+
+For example, to serve a custom page with a ``418 I'm a teapot`` HTTP status code, create a file in ``pages/teapot.html`` containing the following::
+
+ {{ custom_status(418) }}
+
+ Teapot
+
+ I'm a teapot
+
+
+
+To serve a custom HTTP header, add a ``custom_header(name, value)`` function call. For example::
+
+ {{ custom_status(418) }}
+ {{ custom_header("x-teapot", "I am") }}
+
+ Teapot
+
+ I'm a teapot
+
+
+
+You can verify this is working using ``curl`` like this::
+
+ $ curl -I 'http://127.0.0.1:8001/teapot'
+ HTTP/1.1 418
+ date: Sun, 26 Apr 2020 18:38:30 GMT
+ server: uvicorn
+ x-teapot: I am
+ content-type: text/html
+
+Custom redirects
+~~~~~~~~~~~~~~~~
+
+You can use the ``custom_redirect(location)`` function to redirect users to another page, for example in a file called ``pages/datasette.html``::
+
+ {{ custom_redirect("https://github.com/simonw/datasette") }}
+
+Now requests to ``http://localhost:8001/datasette`` will result in a redirect.
+
+These redirects are served with a ``301 Found`` status code by default. You can send a ``301 Moved Permanently`` code by passing ``301`` as the second argument to the function::
+
+ {{ custom_redirect("https://github.com/simonw/datasette", 301) }}
diff --git a/tests/fixtures.py b/tests/fixtures.py
index 8459fd4f..0284ff9c 100644
--- a/tests/fixtures.py
+++ b/tests/fixtures.py
@@ -109,6 +109,7 @@ def make_app_client(
inspect_data=None,
static_mounts=None,
template_dir=None,
+ extra_plugins=None,
):
with tempfile.TemporaryDirectory() as tmpdir:
filepath = os.path.join(tmpdir, filename)
@@ -133,6 +134,8 @@ def make_app_client(
os.mkdir(plugins_dir)
open(os.path.join(plugins_dir, "my_plugin.py"), "w").write(PLUGIN1)
open(os.path.join(plugins_dir, "my_plugin_2.py"), "w").write(PLUGIN2)
+ for filename, content in (extra_plugins or {}).items():
+ open(os.path.join(plugins_dir, filename), "w").write(content)
config = config or {}
config.update(
{
diff --git a/tests/test_custom_pages.py b/tests/test_custom_pages.py
new file mode 100644
index 00000000..871dd69c
--- /dev/null
+++ b/tests/test_custom_pages.py
@@ -0,0 +1,90 @@
+import pytest
+from .fixtures import make_app_client
+
+VIEW_NAME_PLUGIN = """
+from datasette import hookimpl
+
+@hookimpl
+def extra_template_vars(view_name):
+ return {
+ "view_name": view_name,
+ }
+"""
+
+
+@pytest.fixture(scope="session")
+def custom_pages_client(tmp_path_factory):
+ template_dir = tmp_path_factory.mktemp("page-templates")
+ extra_plugins = {"view_name.py": VIEW_NAME_PLUGIN}
+ pages_dir = template_dir / "pages"
+ pages_dir.mkdir()
+ (pages_dir / "about.html").write_text("ABOUT! view_name:{{ view_name }}", "utf-8")
+ (pages_dir / "202.html").write_text("{{ custom_status(202) }}202!", "utf-8")
+ (pages_dir / "headers.html").write_text(
+ '{{ custom_header("x-this-is-foo", "foo") }}FOO'
+ '{{ custom_header("x-this-is-bar", "bar") }}BAR',
+ "utf-8",
+ )
+ (pages_dir / "atom.html").write_text(
+ '{{ custom_header("content-type", "application/xml") }}', "utf-8",
+ )
+ (pages_dir / "redirect.html").write_text(
+ '{{ custom_redirect("/example") }}', "utf-8"
+ )
+ (pages_dir / "redirect2.html").write_text(
+ '{{ custom_redirect("/example", 301) }}', "utf-8"
+ )
+ nested_dir = pages_dir / "nested"
+ nested_dir.mkdir()
+ (nested_dir / "nest.html").write_text("Nest!", "utf-8")
+ for client in make_app_client(
+ template_dir=str(template_dir), extra_plugins=extra_plugins
+ ):
+ yield client
+
+
+def test_custom_pages_view_name(custom_pages_client):
+ response = custom_pages_client.get("/about")
+ assert 200 == response.status
+ assert "ABOUT! view_name:page" == response.text
+
+
+def test_custom_pages_nested(custom_pages_client):
+ response = custom_pages_client.get("/nested/nest")
+ assert 200 == response.status
+ assert "Nest!" == response.text
+ response = custom_pages_client.get("/nested/nest2")
+ assert 404 == response.status
+
+
+def test_custom_status(custom_pages_client):
+ response = custom_pages_client.get("/202")
+ assert 202 == response.status
+ assert "202!" == response.text
+
+
+def test_custom_headers(custom_pages_client):
+ response = custom_pages_client.get("/headers")
+ assert 200 == response.status
+ assert "foo" == response.headers["x-this-is-foo"]
+ assert "bar" == response.headers["x-this-is-bar"]
+ assert "FOOBAR" == response.text
+
+
+def test_custom_content_type(custom_pages_client):
+ response = custom_pages_client.get("/atom")
+ assert 200 == response.status
+ assert response.headers["content-type"] == "application/xml"
+ assert "" == response.text
+
+
+def test_redirect(custom_pages_client):
+ response = custom_pages_client.get("/redirect", allow_redirects=False)
+ assert 302 == response.status
+ assert "/example" == response.headers["Location"]
+
+
+def test_redirect2(custom_pages_client):
+ response = custom_pages_client.get("/redirect2", allow_redirects=False)
+ assert 301 == response.status
+ assert "/example" == response.headers["Location"]
diff --git a/tests/test_html.py b/tests/test_html.py
index 1b675d3c..b8dc543c 100644
--- a/tests/test_html.py
+++ b/tests/test_html.py
@@ -11,6 +11,7 @@ import json
import pathlib
import pytest
import re
+import textwrap
import urllib.parse