├── .github └── workflows │ ├── publish.yml │ └── test.yml ├── README.md ├── datasette_css_properties └── __init__.py ├── setup.py └── tests └── test_css_properties.py /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish Python Package 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | test: 9 | runs-on: ubuntu-latest 10 | strategy: 11 | matrix: 12 | python-version: [3.6, 3.7, 3.8, 3.9] 13 | steps: 14 | - uses: actions/checkout@v2 15 | - name: Set up Python ${{ matrix.python-version }} 16 | uses: actions/setup-python@v2 17 | with: 18 | python-version: ${{ matrix.python-version }} 19 | - uses: actions/cache@v2 20 | name: Configure pip caching 21 | with: 22 | path: ~/.cache/pip 23 | key: ${{ runner.os }}-pip-${{ hashFiles('**/setup.py') }} 24 | restore-keys: | 25 | ${{ runner.os }}-pip- 26 | - name: Install dependencies 27 | run: | 28 | pip install -e '.[test]' 29 | - name: Run tests 30 | run: | 31 | pytest 32 | deploy: 33 | runs-on: ubuntu-latest 34 | needs: [test] 35 | steps: 36 | - uses: actions/checkout@v2 37 | - name: Set up Python 38 | uses: actions/setup-python@v2 39 | with: 40 | python-version: '3.9' 41 | - uses: actions/cache@v2 42 | name: Configure pip caching 43 | with: 44 | path: ~/.cache/pip 45 | key: ${{ runner.os }}-publish-pip-${{ hashFiles('**/setup.py') }} 46 | restore-keys: | 47 | ${{ runner.os }}-publish-pip- 48 | - name: Install dependencies 49 | run: | 50 | pip install setuptools wheel twine 51 | - name: Publish 52 | env: 53 | TWINE_USERNAME: __token__ 54 | TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} 55 | run: | 56 | python setup.py sdist bdist_wheel 57 | twine upload dist/* 58 | 59 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: [push] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | python-version: [3.6, 3.7, 3.8, 3.9] 11 | steps: 12 | - uses: actions/checkout@v2 13 | - name: Set up Python ${{ matrix.python-version }} 14 | uses: actions/setup-python@v2 15 | with: 16 | python-version: ${{ matrix.python-version }} 17 | - uses: actions/cache@v2 18 | name: Configure pip caching 19 | with: 20 | path: ~/.cache/pip 21 | key: ${{ runner.os }}-pip-${{ hashFiles('**/setup.py') }} 22 | restore-keys: | 23 | ${{ runner.os }}-pip- 24 | - name: Install dependencies 25 | run: | 26 | pip install -e '.[test]' 27 | - name: Run tests 28 | run: | 29 | pytest 30 | 31 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # datasette-css-properties 2 | 3 | [](https://pypi.org/project/datasette-css-properties/) 4 | [](https://github.com/simonw/datasette-css-properties/releases) 5 | [](https://github.com/simonw/datasette-css-properties/actions?query=workflow%3ATest) 6 | [](https://github.com/simonw/datasette-css-properties/blob/main/LICENSE) 7 | 8 | Extremely experimental Datasette output plugin using CSS properties, inspired by [Custom Properties as State](https://css-tricks.com/custom-properties-as-state/) by Chris Coyier. 9 | 10 | More about this project: [APIs from CSS without JavaScript: the datasette-css-properties plugin](https://simonwillison.net/2021/Jan/7/css-apis-no-javascript/) 11 | 12 | ## Installation 13 | 14 | Install this plugin in the same environment as Datasette. 15 | 16 | $ datasette install datasette-css-properties 17 | 18 | ## Usage 19 | 20 | Once installed, this plugin adds a `.css` output format to every query result. This will return the first row in the query as a valid CSS file, defining each column as a custom property: 21 | 22 | Example: https://latest-with-plugins.datasette.io/fixtures/roadside_attractions.css produces: 23 | 24 | ```css 25 | :root { 26 | --pk: '1'; 27 | --name: 'The Mystery Spot'; 28 | --address: '465 Mystery Spot Road, Santa Cruz, CA 95065'; 29 | --latitude: '37.0167'; 30 | --longitude: '-122.0024'; 31 | } 32 | ``` 33 | 34 | If you link this stylesheet to your page you can then do things like this; 35 | 36 | ```html 37 | 38 | 41 |
Attraction name:
42 | ``` 43 | 44 | Values will be quoted as CSS strings by default. If you want to return a "raw" value without the quotes - for example to set a CSS property that is numeric or a color, you can specify that column name using the `?_raw=column-name` parameter. This can be passed multiple times. 45 | 46 | Consider [this example query](https://latest-with-plugins.datasette.io/github?sql=select%0D%0A++%27%23%27+||+substr(sha%2C+0%2C+6)+as+[custom-bg]%0D%0Afrom%0D%0A++commits%0D%0Aorder+by%0D%0A++author_date+desc%0D%0Alimit%0D%0A++1%3B): 47 | 48 | ```sql 49 | select 50 | '#' || substr(sha, 0, 6) as [custom-bg] 51 | from 52 | commits 53 | order by 54 | author_date desc 55 | limit 56 | 1; 57 | ``` 58 | 59 | This returns the first 6 characters of the most recently authored commit with a `#` prefix. The `.css` [output rendered version](https://latest-with-plugins.datasette.io/github.css?sql=select%0D%0A++%27%23%27+||+substr(sha%2C+0%2C+6)+as+[custom-bg]%0D%0Afrom%0D%0A++commits%0D%0Aorder+by%0D%0A++author_date+desc%0D%0Alimit%0D%0A++1%3B) looks like this: 60 | 61 | ```css 62 | :root { 63 | --custom-bg: '#97fb1'; 64 | } 65 | ``` 66 | 67 | Adding `?_raw=custom-bg` to the URL produces [this instead](https://latest-with-plugins.datasette.io/github.css?sql=select%0D%0A++%27%23%27+||+substr(sha%2C+0%2C+6)+as+[custom-bg]%0D%0Afrom%0D%0A++commits%0D%0Aorder+by%0D%0A++author_date+desc%0D%0Alimit%0D%0A++1%3B&_raw=custom-bg): 68 | 69 | ```css 70 | :root { 71 | --custom-bg: #97fb1; 72 | } 73 | ``` 74 | 75 | This can then be used as a color value like so: 76 | 77 | ```css 78 | h1 { 79 | background-color: var(--custom-bg); 80 | } 81 | ``` 82 | 83 | ## Development 84 | 85 | To set up this plugin locally, first checkout the code. Then create a new virtual environment: 86 | 87 | cd datasette-css-properties 88 | python3 -mvenv venv 89 | source venv/bin/activate 90 | 91 | Or if you are using `pipenv`: 92 | 93 | pipenv shell 94 | 95 | Now install the dependencies and tests: 96 | 97 | pip install -e '.[test]' 98 | 99 | To run the tests: 100 | 101 | pytest 102 | -------------------------------------------------------------------------------- /datasette_css_properties/__init__.py: -------------------------------------------------------------------------------- 1 | from datasette import hookimpl 2 | from datasette.utils.asgi import Response 3 | from datasette.utils import escape_css_string, to_css_class 4 | 5 | 6 | def css_response(css): 7 | return Response( 8 | css, 9 | content_type="text/css; charset=utf-8", 10 | headers={"X-Content-Type-Options": "nosniff"}, 11 | ) 12 | 13 | 14 | def render_css(request, rows): 15 | try: 16 | row = rows[0] 17 | except IndexError: 18 | return css_response("") 19 | raw_keys = request.args.getlist("_raw") 20 | lines = [":root {"] 21 | for key, value in dict(row).items(): 22 | if key not in raw_keys: 23 | value = "'{}'".format( 24 | escape_css_string(str(value) if value is not None else "") 25 | ) 26 | lines.append(" --{}: {};".format(to_css_class(key), value)) 27 | lines.append("}") 28 | return css_response("\n".join(lines)) 29 | 30 | 31 | @hookimpl 32 | def register_output_renderer(datasette): 33 | return { 34 | "extension": "css", 35 | "render": render_css, 36 | } 37 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | import os 3 | 4 | VERSION = "0.2" 5 | 6 | 7 | def get_long_description(): 8 | with open( 9 | os.path.join(os.path.dirname(os.path.abspath(__file__)), "README.md"), 10 | encoding="utf8", 11 | ) as fp: 12 | return fp.read() 13 | 14 | 15 | setup( 16 | name="datasette-css-properties", 17 | description="Experimental Datasette output plugin using CSS properties", 18 | long_description=get_long_description(), 19 | long_description_content_type="text/markdown", 20 | author="Simon Willison", 21 | url="https://github.com/simonw/datasette-css-properties", 22 | project_urls={ 23 | "Issues": "https://github.com/simonw/datasette-css-properties/issues", 24 | "CI": "https://github.com/simonw/datasette-css-properties/actions", 25 | "Changelog": "https://github.com/simonw/datasette-css-properties/releases", 26 | }, 27 | license="Apache License, Version 2.0", 28 | version=VERSION, 29 | packages=["datasette_css_properties"], 30 | entry_points={"datasette": ["css_properties = datasette_css_properties"]}, 31 | install_requires=["datasette"], 32 | extras_require={"test": ["pytest", "pytest-asyncio"]}, 33 | tests_require=["datasette-css-properties[test]"], 34 | python_requires=">=3.6", 35 | ) 36 | -------------------------------------------------------------------------------- /tests/test_css_properties.py: -------------------------------------------------------------------------------- 1 | from datasette.app import Datasette 2 | import pytest 3 | import urllib 4 | 5 | 6 | @pytest.mark.asyncio 7 | @pytest.mark.parametrize( 8 | "query,raw,expected_css", 9 | [ 10 | ( 11 | "select 'hello' as foo, 2 as bar, null as baz", 12 | [], 13 | ":root {\n --foo: 'hello';\n --bar: '2';\n --baz: '';\n}", 14 | ), 15 | ( 16 | """select 'hello '' this has quotes '' ' as foo""", 17 | [], 18 | ":root {\n --foo: 'hello \\000027 this has quotes \\000027 ';\n}", 19 | ), 20 | ("select 'hello' as foo", ["foo"], ":root {\n --foo: hello;\n}"), 21 | ], 22 | ) 23 | async def test_css_output(query, raw, expected_css): 24 | datasette = Datasette([], memory=True) 25 | response = await datasette.client.get( 26 | "/:memory:.css?" 27 | + urllib.parse.urlencode( 28 | { 29 | "sql": query, 30 | "_raw": raw, 31 | }, 32 | doseq=True, 33 | ) 34 | ) 35 | assert response.status_code == 200 36 | assert response.text == expected_css 37 | assert response.headers["x-content-type-options"] == "nosniff" 38 | --------------------------------------------------------------------------------