Skip to content

Commit

Permalink
CSS styling hooks as classes on the body
Browse files Browse the repository at this point in the history
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:

    <body class="index">

The database template (/dbname/) gets this:

    <body class="db db-dbname">

The table template (/dbname/tablename) gets:

    <body class="table db-dbname table-tablename">

The row template (/dbname/tablename/rowid) gets:

    <body class="row db-dbname table-tablename">

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"
  • Loading branch information
simonw committed Nov 30, 2017
1 parent b67890d commit 8ab3a16
Show file tree
Hide file tree
Showing 9 changed files with 72 additions and 1 deletion.
2 changes: 2 additions & 0 deletions datasette/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
path_with_added_args,
path_with_ext,
sqlite_timelimit,
to_css_class,
validate_sql_select,
)
from .version import __version__
Expand Down Expand Up @@ -897,6 +898,7 @@ def app(self):
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), '/<as_json:(.jsono?)?$>')
# TODO: /favicon.ico and /-/static/ deserve far-future cache expires
app.add_route(favicon, '/favicon.ico')
Expand Down
2 changes: 1 addition & 1 deletion datasette/templates/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
{% endfor %}
{% block extra_head %}{% endblock %}
</head>
<body>
<body class="{% block body_class %}{% endblock %}">

{% block content %}
{% endblock %}
Expand Down
2 changes: 2 additions & 0 deletions datasette/templates/database.html
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
</style>
{% endblock %}

{% block body_class %}db db-{{ database|to_css_class }}{% endblock %}

{% block content %}
<div class="hd"><a href="/">home</a>{% if query %} / <a href="/{{ database }}-{{ database_hash }}">{{ database }}</a>{% endif %}</div>

Expand Down
2 changes: 2 additions & 0 deletions datasette/templates/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -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 %}
<h1>{{ metadata.title or "Datasette" }}</h1>
{% if metadata.license or metadata.source_url %}
Expand Down
2 changes: 2 additions & 0 deletions datasette/templates/row.html
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
</style>
{% endblock %}

{% block body_class %}row db-{{ database|to_css_class }} table-{{ table|to_css_class }}{% endblock %}

{% block content %}
<div class="hd"><a href="/">home</a> / <a href="/{{ database }}-{{ database_hash }}">{{ database }}</a> / <a href="/{{ database }}-{{ database_hash }}/{{ table|quote_plus }}">{{ table }}</a></div>

Expand Down
2 changes: 2 additions & 0 deletions datasette/templates/table.html
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
</style>
{% endblock %}

{% block body_class %}table db-{{ database|to_css_class }} table-{{ table|to_css_class }}{% endblock %}

{% block content %}
<div class="hd"><a href="/">home</a> / <a href="/{{ database }}-{{ database_hash }}">{{ database }}</a></div>

Expand Down
28 changes: 28 additions & 0 deletions datasette/utils.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from contextlib import contextmanager
import base64
import hashlib
import json
import os
import re
Expand Down Expand Up @@ -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)
20 changes: 20 additions & 0 deletions tests/test_app.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from datasette.app import Datasette
import os
import pytest
import re
import sqlite3
import tempfile
import time
Expand Down Expand Up @@ -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'<body class="(.*)">', response.text).group(1).split()
assert classes == expected_classes


TABLES = '''
CREATE TABLE simple_primary_key (
pk varchar(30) primary key,
Expand Down
13 changes: 13 additions & 0 deletions tests/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

0 comments on commit 8ab3a16

Please sign in to comment.