diff --git a/datasette/app.py b/datasette/app.py index 6beb4549..17ae28ac 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -942,6 +942,16 @@ class DatasetteRouter: ((re.compile(pattern) if isinstance(pattern, str) else pattern), view) for pattern, view in routes ] + # Build a list of pages/blah/{name}.html matching expressions + pattern_templates = [ + filepath + for filepath in self.ds.jinja_env.list_templates() + if "{" in filepath and filepath.startswith("pages/") + ] + self.page_routes = [ + (route_pattern_from_filepath(filepath[len("pages/") :]), filepath) + for filepath in pattern_templates + ] async def __call__(self, scope, receive, send): # Because we care about "foo/bar" v.s. "foo%2Fbar" we decode raw_path ourselves @@ -1002,6 +1012,7 @@ class DatasetteRouter: async def handle_404(self, request, send, exception=None): # If URL has a trailing slash, redirect to URL without it path = request.scope.get("raw_path", request.scope["path"].encode("utf8")) + context = {} if path.endswith(b"/"): path = path.rstrip(b"/") if request.scope["query_string"]: @@ -1016,6 +1027,15 @@ class DatasetteRouter: template = self.ds.jinja_env.select_template([template_path]) except TemplateNotFound: template = None + if template is None: + # Try for a pages/blah/{name}.html template match + for regex, wildcard_template in self.page_routes: + match = regex.match(request.scope["path"]) + if match is not None: + context.update(match.groupdict()) + template = wildcard_template + break + if template: headers = {} status = [200] @@ -1033,13 +1053,16 @@ class DatasetteRouter: headers["Location"] = location return "" - body = await self.ds.render_template( - template, + context.update( { "custom_header": custom_header, "custom_status": custom_status, "custom_redirect": custom_redirect, - }, + } + ) + body = await self.ds.render_template( + template, + context, request=request, view_name="page", ) @@ -1160,3 +1183,19 @@ def wrap_view(view_fn, datasette): return response return async_view_fn + + +_curly_re = re.compile("(\{.*?\})") + + +def route_pattern_from_filepath(filepath): + # Drop the ".html" suffix + if filepath.endswith(".html"): + filepath = filepath[: -len(".html")] + re_bits = ["/"] + for bit in _curly_re.split(filepath): + if _curly_re.match(bit): + re_bits.append("(?P<{}>[^/]*)".format(bit[1:-1])) + else: + re_bits.append(re.escape(bit)) + return re.compile("".join(re_bits)) diff --git a/docs/custom_templates.rst b/docs/custom_templates.rst index adbfbc25..32dd6657 100644 --- a/docs/custom_templates.rst +++ b/docs/custom_templates.rst @@ -281,6 +281,25 @@ For example, to add a custom page that is served at ``http://localhost/about`` y 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_pages_parameters: + +Path parameters for pages +~~~~~~~~~~~~~~~~~~~~~~~~~ + +You can define custom pages that match multiple paths by creating files with ``{variable}`` definitions in their filenames. + +For example, to capture any request to a URL matching ``/about/*``, you would create a template in the following location:: + + templates/pages/about/{slug}.html + +A hit to ``/about/news`` would render that template and pass in a variable called ``slug`` with a value of ``"news"``. + +If you use this mechanism don't forget to return a 404 status code if the page should not be considered a valid page. You can do this using ``{{ custom_status(404) }}`` described below. + +Templates defined using custom page routes work particularly well with the ``sql()`` template function from `datasette-template-sql `__ or the ``graphql()`` template function from `datasette-graphql `__. + +.. _custom_pages_headers: + Custom headers and status codes ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -316,6 +335,8 @@ You can verify this is working using ``curl`` like this:: x-teapot: I am content-type: text/html; charset=utf-8 +.. _custom_pages_redirects: + Custom redirects ~~~~~~~~~~~~~~~~ diff --git a/tests/test_custom_pages.py b/tests/test_custom_pages.py index cfe86c80..dc3be844 100644 --- a/tests/test_custom_pages.py +++ b/tests/test_custom_pages.py @@ -25,6 +25,9 @@ def custom_pages_client(tmp_path_factory): (pages_dir / "redirect2.html").write_text( '{{ custom_redirect("/example", 301) }}', "utf-8" ) + (pages_dir / "route_{name}.html").write_text( + "

Hello from {{ name }}

", "utf-8" + ) nested_dir = pages_dir / "nested" nested_dir.mkdir() (nested_dir / "nest.html").write_text("Nest!", "utf-8") @@ -83,3 +86,9 @@ def test_redirect2(custom_pages_client): response = custom_pages_client.get("/redirect2", allow_redirects=False) assert 301 == response.status assert "/example" == response.headers["Location"] + + +def test_custom_route_pattern(custom_pages_client): + response = custom_pages_client.get("/route_Sally") + assert response.status == 200 + assert response.text == "

Hello from Sally

"