diff --git a/datasette/app.py b/datasette/app.py index 397450f8..acba9ffc 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -31,6 +31,7 @@ from .utils import ( path_with_added_args, path_with_ext, sqlite_timelimit, + to_css_class, validate_sql_select, ) from .version import __version__ @@ -897,6 +898,7 @@ class Datasette: self.jinja.add_env('escape_css_string', escape_css_string, 'filters') self.jinja.add_env('quote_plus', lambda u: urllib.parse.quote_plus(u), 'filters') self.jinja.add_env('escape_table_name', escape_sqlite_table_name, 'filters') + self.jinja.add_env('to_css_class', to_css_class, 'filters') app.add_route(IndexView.as_view(self), '/') # TODO: /favicon.ico and /-/static/ deserve far-future cache expires app.add_route(favicon, '/favicon.ico') diff --git a/datasette/templates/base.html b/datasette/templates/base.html index 7db54460..3614a790 100644 --- a/datasette/templates/base.html +++ b/datasette/templates/base.html @@ -12,7 +12,7 @@ {% endfor %} {% block extra_head %}{% endblock %} - + {% block content %} {% endblock %} diff --git a/datasette/templates/database.html b/datasette/templates/database.html index af3ecbe1..c1876ea9 100644 --- a/datasette/templates/database.html +++ b/datasette/templates/database.html @@ -21,6 +21,8 @@ {% endblock %} +{% block body_class %}db db-{{ database|to_css_class }}{% endblock %} + {% block content %}
home{% if query %} / {{ database }}{% endif %}
diff --git a/datasette/templates/index.html b/datasette/templates/index.html index 5f75ce2a..85987d0c 100644 --- a/datasette/templates/index.html +++ b/datasette/templates/index.html @@ -2,6 +2,8 @@ {% block title %}{{ metadata.title or "Datasette" }}: {% for database in databases %}{{ database.name }}{% if not loop.last %}, {% endif %}{% endfor %}{% endblock %} +{% block body_class %}index{% endblock %} + {% block content %}

{{ metadata.title or "Datasette" }}

{% if metadata.license or metadata.source_url %} diff --git a/datasette/templates/row.html b/datasette/templates/row.html index b2d6ed70..2fd025c2 100644 --- a/datasette/templates/row.html +++ b/datasette/templates/row.html @@ -12,6 +12,8 @@ {% endblock %} +{% block body_class %}row db-{{ database|to_css_class }} table-{{ table|to_css_class }}{% endblock %} + {% block content %}
home / {{ database }} / {{ table }}
diff --git a/datasette/templates/table.html b/datasette/templates/table.html index 77d4d8b6..3e252e0d 100644 --- a/datasette/templates/table.html +++ b/datasette/templates/table.html @@ -13,6 +13,8 @@ {% endblock %} +{% block body_class %}table db-{{ database|to_css_class }} table-{{ table|to_css_class }}{% endblock %} + {% block content %}
home / {{ database }}
diff --git a/datasette/utils.py b/datasette/utils.py index 2b567a05..92104125 100644 --- a/datasette/utils.py +++ b/datasette/utils.py @@ -1,5 +1,6 @@ from contextlib import contextmanager import base64 +import hashlib import json import os import re @@ -456,3 +457,30 @@ def is_url(value): if whitespace_re.search(value): return False return True + + +css_class_re = re.compile(r'^[a-zA-Z]+[_a-zA-Z0-9-]*$') +css_invalid_chars_re = re.compile(r'[^a-zA-Z0-9_\-]') + + +def to_css_class(s): + """ + Given a string (e.g. a table name) returns a valid unique CSS class. + For simple cases, just returns the string again. If the string is not a + valid CSS class (we disallow - and _ prefixes even though they are valid + as they may be confused with browser prefixes) we strip invalid characters + and add a 6 char md5 sum suffix, to make sure two tables with identical + names after stripping characters don't end up with the same CSS class. + """ + if css_class_re.match(s): + return s + md5_suffix = hashlib.md5(s.encode('utf8')).hexdigest()[:6] + # Strip leading _, - + s = s.lstrip('_').lstrip('-') + # Replace any whitespace with hyphens + s = '-'.join(s.split()) + # Remove any remaining invalid characters + s = css_invalid_chars_re.sub('', s) + # Attach the md5 suffix + bits = [b for b in (s, md5_suffix) if b] + return '-'.join(bits) diff --git a/tests/test_app.py b/tests/test_app.py index 4dc912de..99cb9ffb 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -1,6 +1,7 @@ from datasette.app import Datasette import os import pytest +import re import sqlite3 import tempfile import time @@ -421,6 +422,25 @@ def test_empty_search_parameter_gets_removed(app_client): ) +@pytest.mark.parametrize('path,expected_classes', [ + ('/', ['index']), + ('/test_tables', ['db', 'db-test_tables']), + ('/test_tables/simple_primary_key', [ + 'table', 'db-test_tables', 'table-simple_primary_key' + ]), + ('/test_tables/table%2Fwith%2Fslashes.csv', [ + 'table', 'db-test_tables', 'table-tablewithslashescsv-fa7563' + ]), + ('/test_tables/simple_primary_key/1', [ + 'row', 'db-test_tables', 'table-simple_primary_key' + ]), +]) +def test_css_classes_on_body(app_client, path, expected_classes): + response = app_client.get(path, gather_request=False) + classes = re.search(r'', response.text).group(1).split() + assert classes == expected_classes + + TABLES = ''' CREATE TABLE simple_primary_key ( pk varchar(30) primary key, diff --git a/tests/test_utils.py b/tests/test_utils.py index 18ce0971..9ff6acf7 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -161,3 +161,16 @@ def test_detect_fts(): ]) def test_is_url(url, expected): assert expected == utils.is_url(url) + + +@pytest.mark.parametrize('s,expected', [ + ('simple', 'simple'), + ('MixedCase', 'MixedCase'), + ('-no-leading-hyphens', 'no-leading-hyphens-65bea6'), + ('_no-leading-underscores', 'no-leading-underscores-b921bc'), + ('no spaces', 'no-spaces-7088d7'), + ('-', '336d5e'), + ('no $ characters', 'no--characters-59e024'), +]) +def test_to_css_class(s, expected): + assert expected == utils.to_css_class(s)