From 8ab3a169d42d096f2c7979c6d3d7746618d30f0b Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 29 Nov 2017 23:09:54 -0800 Subject: [PATCH] CSS styling hooks as classes on the body Refs #153 Every template now gets CSS classes in the body designed to support custom styling. The index template (the top level page at /) gets this: The database template (/dbname/) gets this: The table template (/dbname/tablename) gets: The row template (/dbname/tablename/rowid) gets: The db-x and table-x classes use the database or table names themselves IF they are valid CSS identifiers. If they aren't, we strip any invalid characters out and append a 6 character md5 digest of the original name, in order to ensure that multiple tables which resolve to the same stripped character version still have different CSS classes. Some examples (extracted from the unit tests): "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" --- datasette/app.py | 2 ++ datasette/templates/base.html | 2 +- datasette/templates/database.html | 2 ++ datasette/templates/index.html | 2 ++ datasette/templates/row.html | 2 ++ datasette/templates/table.html | 2 ++ datasette/utils.py | 28 ++++++++++++++++++++++++++++ tests/test_app.py | 20 ++++++++++++++++++++ tests/test_utils.py | 13 +++++++++++++ 9 files changed, 72 insertions(+), 1 deletion(-) 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 %} 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 %} 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)