diff --git a/datasette/app.py b/datasette/app.py
index 052131d0..e263cc48 100644
--- a/datasette/app.py
+++ b/datasette/app.py
@@ -2,7 +2,6 @@ import asyncio
import click
import collections
import hashlib
-import importlib
import itertools
import os
import sqlite3
@@ -14,7 +13,6 @@ from concurrent import futures
from pathlib import Path
from markupsafe import Markup
-import pluggy
from jinja2 import ChoiceLoader, Environment, FileSystemLoader, PrefixLoader
from sanic import Sanic, response
from sanic.exceptions import InvalidUsage, NotFound
@@ -28,7 +26,6 @@ from .views.index import IndexView
from .views.special import JsonDataView
from .views.table import RowView, TableView
-from . import hookspecs
from .utils import (
InterruptedError,
Results,
@@ -40,26 +37,13 @@ from .utils import (
to_css_class
)
from .inspect import inspect_hash, inspect_views, inspect_tables
+from .plugins import pm
from .version import __version__
-default_plugins = (
- "datasette.publish.heroku",
- "datasette.publish.now",
-)
-
app_root = Path(__file__).parent.parent
connections = threading.local()
-pm = pluggy.PluginManager("datasette")
-pm.add_hookspecs(hookspecs)
-pm.load_setuptools_entrypoints("datasette")
-
-# Load default plugins
-for plugin in default_plugins:
- mod = importlib.import_module(plugin)
- pm.register(mod, plugin)
-
ConfigOption = collections.namedtuple(
"ConfigOption", ("name", "default", "help")
diff --git a/datasette/hookspecs.py b/datasette/hookspecs.py
index 9546eebf..233a9aa0 100644
--- a/datasette/hookspecs.py
+++ b/datasette/hookspecs.py
@@ -28,3 +28,8 @@ def extra_js_urls():
@hookspec
def publish_subcommand(publish):
"Subcommands for 'datasette publish'"
+
+
+@hookspec(firstresult=True)
+def render_cell(value):
+ "Customize rendering of HTML table cell values"
diff --git a/datasette/plugins.py b/datasette/plugins.py
new file mode 100644
index 00000000..e416c07d
--- /dev/null
+++ b/datasette/plugins.py
@@ -0,0 +1,17 @@
+import importlib
+import pluggy
+from . import hookspecs
+
+default_plugins = (
+ "datasette.publish.heroku",
+ "datasette.publish.now",
+)
+
+pm = pluggy.PluginManager("datasette")
+pm.add_hookspecs(hookspecs)
+pm.load_setuptools_entrypoints("datasette")
+
+# Load default plugins
+for plugin in default_plugins:
+ mod = importlib.import_module(plugin)
+ pm.register(mod, plugin)
diff --git a/datasette/utils.py b/datasette/utils.py
index 8ecd9025..29360b35 100644
--- a/datasette/utils.py
+++ b/datasette/utils.py
@@ -16,7 +16,6 @@ import shutil
import urllib
import numbers
-
# From https://www.sqlite.org/lang_keywords.html
reserved_words = set((
'abort action add after all alter analyze and as asc attach autoincrement '
diff --git a/datasette/views/base.py b/datasette/views/base.py
index 45bc8183..f376c327 100644
--- a/datasette/views/base.py
+++ b/datasette/views/base.py
@@ -13,6 +13,7 @@ from sanic.exceptions import NotFound
from sanic.views import HTTPMethodView
from datasette import __version__
+from datasette.plugins import pm
from datasette.utils import (
CustomJSONEncoder,
InterruptedError,
@@ -493,14 +494,19 @@ class BaseView(RenderMixin):
display_row = []
for value in row:
display_value = value
- if value in ("", None):
- display_value = jinja2.Markup(" ")
- elif is_url(str(value).strip()):
- display_value = jinja2.Markup(
- '{url}'.format(
- url=jinja2.escape(value.strip())
+ # Let the plugins have a go
+ plugin_value = pm.hook.render_cell(value=value)
+ if plugin_value is not None:
+ display_value = plugin_value
+ else:
+ if value in ("", None):
+ display_value = jinja2.Markup(" ")
+ elif is_url(str(display_value).strip()):
+ display_value = jinja2.Markup(
+ '{url}'.format(
+ url=jinja2.escape(value.strip())
+ )
)
- )
display_row.append(display_value)
display_rows.append(display_row)
return {
diff --git a/datasette/views/table.py b/datasette/views/table.py
index 654e60fa..ae71d33d 100644
--- a/datasette/views/table.py
+++ b/datasette/views/table.py
@@ -5,6 +5,7 @@ import jinja2
from sanic.exceptions import NotFound
from sanic.request import RequestParameters
+from datasette.plugins import pm
from datasette.utils import (
CustomRow,
Filters,
@@ -22,7 +23,6 @@ from datasette.utils import (
urlsafe_components,
value_as_boolean,
)
-
from .base import BaseView, DatasetteError, ureg
LINK_WITH_LABEL = '{label} {id}'
@@ -166,7 +166,11 @@ class RowTableShared(BaseView):
# already shown in the link column.
continue
- if isinstance(value, dict):
+ # First let the plugins have a go
+ plugin_display_value = pm.hook.render_cell(value=value)
+ if plugin_display_value is not None:
+ display_value = plugin_display_value
+ elif isinstance(value, dict):
# It's an expanded foreign key - display link to other row
label = value["label"]
value = value["value"]
diff --git a/docs/plugins.rst b/docs/plugins.rst
index fc351bf6..f69fed95 100644
--- a/docs/plugins.rst
+++ b/docs/plugins.rst
@@ -267,3 +267,56 @@ command. Datasette uses this hook internally to implement the default ``now``
and ``heroku`` subcommands, so you can read
`their source `_
to see examples of this hook in action.
+
+render_cell(value)
+~~~~~~~~~~~~~~~~~~
+
+Lets you customize the display of values within table cells in the HTML table view.
+
+``value`` is the value that was loaded from the database.
+
+If your hook returns ``None``, it will be ignored. Use this to indicate that your hook is not able to custom render this particular value.
+
+If the hook returns a string, that string will be rendered in the table cell.
+
+If you want to return HTML markup you can do so by returning a ``jinja2.Markup`` object.
+
+Here is an example of a custom ``render_cell()`` plugin which looks for values that are a JSON string matching the following format::
+
+ {"href": "https://www.example.com/", "label": "Name"}
+
+If the value matches that pattern, the plugin returns an HTML link element:
+
+.. code-block:: python
+
+ from datasette import hookimpl
+ import jinja2
+ import json
+
+
+ @hookimpl
+ def render_cell(value):
+ # Render {"href": "...", "label": "..."} as link
+ if not isinstance(value, str):
+ return None
+ stripped = value.strip()
+ if not stripped.startswith("{") and stripped.endswith("}"):
+ return None
+ try:
+ data = json.loads(value)
+ except ValueError:
+ return None
+ if not isinstance(data, dict):
+ return None
+ if set(data.keys()) != {"href", "label"}:
+ return None
+ href = data["href"]
+ if not (
+ href.startswith("/") or href.startswith("http://")
+ or href.startswith("https://")
+ ):
+ return None
+ return jinja2.Markup('{label}'.format(
+ href=jinja2.escape(data["href"]),
+ label=jinja2.escape(data["label"] or "") or " "
+ ))
diff --git a/tests/fixtures.py b/tests/fixtures.py
index 6335494a..ffacfa51 100644
--- a/tests/fixtures.py
+++ b/tests/fixtures.py
@@ -208,6 +208,8 @@ def extra_js_urls():
PLUGIN2 = '''
from datasette import hookimpl
+import jinja2
+import json
@hookimpl
@@ -216,6 +218,34 @@ def extra_js_urls():
'url': 'https://example.com/jquery.js',
'sri': 'SRIHASH',
}, 'https://example.com/plugin2.js']
+
+
+@hookimpl
+def render_cell(value):
+ # Render {"href": "...", "label": "..."} as link
+ if not isinstance(value, str):
+ return None
+ stripped = value.strip()
+ if not stripped.startswith("{") and stripped.endswith("}"):
+ return None
+ try:
+ data = json.loads(value)
+ except ValueError:
+ return None
+ if not isinstance(data, dict):
+ return None
+ if set(data.keys()) != {"href", "label"}:
+ return None
+ href = data["href"]
+ if not (
+ href.startswith("/") or href.startswith("http://")
+ or href.startswith("https://")
+ ):
+ return None
+ return jinja2.Markup('{label}'.format(
+ href=jinja2.escape(data["href"]),
+ label=jinja2.escape(data["label"] or "") or " "
+ ))
'''
TABLES = '''
@@ -363,9 +393,12 @@ INSERT INTO "searchable_fts" (rowid, text1, text2, [name with . and spaces])
CREATE TABLE [select] (
[group] text,
[having] text,
- [and] text
+ [and] text,
+ [json] text
+);
+INSERT INTO [select] VALUES ('group', 'having', 'and',
+ '{"href": "http://example.com/", "label":"Example"}'
);
-INSERT INTO [select] VALUES ('group', 'having', 'and');
CREATE TABLE infinity (
value REAL
diff --git a/tests/test_api.py b/tests/test_api.py
index 8f67a9eb..f76795fe 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -231,7 +231,7 @@ def test_database_page(app_client):
],
},
}, {
- 'columns': ['group', 'having', 'and'],
+ 'columns': ['group', 'having', 'and', 'json'],
'name': 'select',
'count': 1,
'hidden': False,
@@ -599,6 +599,7 @@ def test_table_with_reserved_word_name(app_client):
'group': 'group',
'having': 'having',
'and': 'and',
+ 'json': '{"href": "http://example.com/", "label":"Example"}'
}]
diff --git a/tests/test_plugins.py b/tests/test_plugins.py
index 487aa10c..fee27c24 100644
--- a/tests/test_plugins.py
+++ b/tests/test_plugins.py
@@ -3,6 +3,7 @@ from .fixtures import ( # noqa
app_client,
)
import pytest
+import urllib
def test_plugins_dir_plugin(app_client):
@@ -67,3 +68,20 @@ def test_plugins_with_duplicate_js_urls(app_client):
) < srcs.index(
'https://example.com/plugin2.js'
)
+
+
+def test_plugins_render_cell(app_client):
+ sql = """
+ select '{"href": "http://example.com/", "label":"Example"}'
+ """.strip()
+ path = "/fixtures?" + urllib.parse.urlencode({
+ "sql": sql,
+ })
+ response = app_client.get(path)
+ td = Soup(
+ response.body, "html.parser"
+ ).find("table").find("tbody").find("td")
+ a = td.find("a")
+ assert a is not None, str(a)
+ assert a.attrs["href"] == "http://example.com/"
+ assert a.text == "Example"