Add cache-busted static asset helper (#2804)

* Add cache-busted static asset helper

Add a static() helper for Datasette, plugin, and mounted static assets that appends content-based hashes, caches hashes in production, and serves matching hashed asset URLs with immutable far-future cache headers.

Closes #2800
This commit is contained in:
Simon Willison 2026-06-23 13:44:58 -07:00 committed by GitHub
commit 5eca46a4bc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 361 additions and 65 deletions

View file

@ -15,7 +15,8 @@ Unreleased
- Create and alter table dialogs share their column-editing controls, including literal and expression defaults, custom column types, foreign keys and column ordering.
- The "Write to this database" page now includes a Create table starter template, alongside the existing Insert, Update and Delete templates. (:pr:`2794`)
- New :ref:`template_context` documentation listing the variables available to custom templates for Datasette's core pages. Variables documented there are treated as a stable API for custom templates until Datasette 2.0. The documentation is generated from dataclass definitions next to the view code, with tests that compare the documented fields against the actual contexts rendered by the database, table, query and row pages. (:issue:`1510`, :issue:`2127`, :issue:`1477`, :pr:`2803`)
- Database and table pages now use the ``count_truncated`` template context value to display capped row counts as ``>N rows`.
- New ``static()`` template function and ``datasette.static()`` method for generating cache-busting static asset URLs based on the file contents. Static assets served with a matching ``?_hash=`` parameter now receive far-future immutable cache headers. This works for Datasette's bundled static assets, plugin static assets and directories mounted using ``--static``. See :ref:`customization_static_files`.
- Database and table pages now use the ``count_truncated`` template context value to display capped row counts as ``>N rows``.
- Significant visual improvements to the table filter form UI, plus working add/remove filter buttons. (:issue:`2798`)
- Improved edit row icon on table pages. (:issue:`2796`)

View file

@ -157,6 +157,9 @@ If you want to change Datasette's Python code you can use the ``--reload`` optio
uv run datasette --reload fixtures.db
This also enables development mode for static asset cache busting, described in
:ref:`customization_static_files`.
You can also use the ``fixtures.py`` script to recreate the testing version of ``metadata.json`` used by the unit tests. To do that::
uv run python tests/fixtures.py fixtures.db fixtures-metadata.json

View file

@ -151,6 +151,45 @@ You can reference those files from ``datasette.yaml`` like this, see :ref:`custo
}
.. [[[end]]]
If you reference those files from a custom template, use the ``static()``
template function to create a cache-busting URL based on the file contents.
Pass the mount point as the ``mount=`` argument, and pass a path relative to
that mounted directory:
.. code-block:: html+jinja
<link rel="stylesheet" href="{{ static('styles.css', mount='assets') }}">
<script src="{{ static('app.js', mount='assets') }}" defer></script>
The returned URL will include a ``?_hash=`` parameter and will take the
``base_url`` setting into account. When that hash matches the current file
contents, Datasette will serve the static asset with a far-future immutable
``Cache-Control`` header. You can also use ``urls.path()`` if you want to link
to the mounted file without adding a content hash:
.. code-block:: html+jinja
<link rel="stylesheet" href="{{ urls.path('/assets/styles.css') }}">
When Datasette is run using ``--reload``, the file contents are hashed every time
the template is rendered, so edits to static files will update their URLs
without restarting Datasette. Without ``--reload``, the hash is cached for the
lifetime of the Datasette process.
You can use the same function for Datasette's bundled static assets, omitting
``mount=``:
.. code-block:: html+jinja
<link rel="stylesheet" href="{{ static('app.css') }}">
For plugin static assets, pass the plugin name using ``plugin=`` and a path
relative to the plugin's ``static/`` directory:
.. code-block:: html+jinja
<script src="{{ static('plugin.js', plugin='datasette_plugin_name') }}" defer></script>
Publishing static assets
~~~~~~~~~~~~~~~~~~~~~~~~

View file

@ -451,6 +451,52 @@ await .render_template(template, context=None, request=None)
Renders a `Jinja template <https://jinja.palletsprojects.com/en/2.11.x/>`__ using Datasette's preconfigured instance of Jinja and returns the resulting string. The template will have access to Datasette's default template functions and any functions that have been made available by other plugins.
.. _datasette_static:
.static(path, plugin=None, mount=None)
--------------------------------------
``path`` - string
The path to the static asset, relative to the selected static directory.
``plugin`` - string, optional
The plugin name, for linking to an asset in that plugin's ``static/``
directory.
``mount`` - string, optional
The ``--static`` mount name, for linking to an asset in a directory mounted
using ``datasette --static mount_name:directory``.
Returns a URL for a static asset with a ``?_hash=`` parameter based on the file
contents. That URL takes the ``base_url`` setting into account.
When the ``?_hash=`` parameter matches the current file contents, Datasette will
serve the asset with ``Cache-Control: max-age=31536000, immutable, public``.
Call this with just ``path`` for one of Datasette's bundled static assets:
.. code-block:: python
datasette.static("app.css")
Use ``plugin=`` for plugin static assets:
.. code-block:: python
datasette.static(
"plugin.js", plugin="datasette_plugin_name"
)
Use ``mount=`` for static directories mounted using the ``--static`` option:
.. code-block:: python
datasette.static("styles.css", mount="assets")
``plugin`` and ``mount`` are mutually exclusive. The same feature is available
to Jinja templates as the ``static()`` template function, described in
:ref:`customization_static_files`.
.. _datasette_actors_from_ids:
await .actors_from_ids(actor_ids)

View file

@ -1968,9 +1968,8 @@ Here is a minimal plugin example that adds a button to a table page and loads Ja
@hookimpl
def extra_js_urls(datasette):
return [
datasette.urls.static_plugins(
"datasette_show_table",
"show-table.js",
datasette.static(
"show-table.js", plugin="datasette_show_table"
)
]

View file

@ -47,15 +47,6 @@ These variables are available on every page rendered by Datasette, including pag
``show_logout``
True if the logout link should be shown in the navigation menu
``app_css_hash``
Hash of Datasette's app.css contents, used for cache busting
``edit_tools_js_hash``
Hash of Datasette's edit-tools.js contents, used for cache busting
``table_js_hash``
Hash of Datasette's table.js contents, used for cache busting
``zip``
Python's ``zip()`` builtin, made available to template logic

View file

@ -145,7 +145,16 @@ If your plugin has a ``static/`` directory, Datasette will automatically configu
/-/static-plugins/NAME_OF_PLUGIN_PACKAGE/yourfile.js
Use the ``datasette.urls.static_plugins(plugin_name, path)`` method to generate URLs to that asset that take the ``base_url`` setting into account, see :ref:`internals_datasette_urls`.
Use the ``datasette.static(path, plugin=plugin_name)`` method to generate
cache-busting URLs to those assets that take the ``base_url`` setting into
account, see :ref:`datasette_static`.
This can also be used from plugin templates as the ``static()`` template
function:
.. code-block:: html+jinja
<script src="{{ static('plugin.js', plugin='datasette_plugin_name') }}" defer></script>
To bundle the static assets for a plugin in the package that you publish to PyPI, add the following to the plugin's ``setup.py``: