-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
0 parents
commit 4c8d629
Showing
14 changed files
with
597 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,27 @@ | ||
name: CI | ||
|
||
on: [pull_request] | ||
|
||
jobs: | ||
build: | ||
|
||
runs-on: ubuntu-18.04 | ||
|
||
strategy: | ||
matrix: | ||
python: ["3.6", "3.7", "3.8", "3.9", "3.10"] | ||
|
||
steps: | ||
- uses: actions/checkout@v2 | ||
- name: Set up Python ${{ matrix.python }} | ||
uses: actions/setup-python@v2 | ||
with: | ||
python-version: ${{ matrix.python }} | ||
- name: Install package | ||
run: pip install -e . | ||
- name: Install dependencies | ||
run: pip install -r dev-requirements.txt | ||
- name: Check if blacken-docs needs to be run | ||
run: blacken-docs -l 60 docs/*.rst | ||
- name: Run tests | ||
run: ./runtests |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,26 @@ | ||
name: Upload Python Package | ||
|
||
on: | ||
release: | ||
types: [created] | ||
|
||
jobs: | ||
deploy: | ||
runs-on: ubuntu-latest | ||
steps: | ||
- uses: actions/checkout@v2 | ||
- name: Set up Python | ||
uses: actions/setup-python@v2 | ||
with: | ||
python-version: '3.8' | ||
- name: Install dependencies | ||
run: | | ||
python -m pip install --upgrade pip | ||
pip install setuptools wheel twine | ||
- name: Build and publish | ||
env: | ||
TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} | ||
TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} | ||
run: | | ||
python setup.py sdist bdist_wheel | ||
twine upload dist/* |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,11 @@ | ||
*.pyc | ||
*.db | ||
.coverage | ||
MANIFEST | ||
dist/ | ||
build/ | ||
env/ | ||
html/ | ||
htmlcov/ | ||
*.egg-info/ | ||
.tox/ |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,8 @@ | ||
[settings] | ||
multi_line_output=3 | ||
include_trailing_comma=True | ||
force_grid_wrap=0 | ||
use_parentheses=True | ||
line_length=88 | ||
force_alphabetical_sort=True | ||
lines_between_types=0 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,23 @@ | ||
Copyright (c) 2022, Jamie Matthews | ||
All rights reserved. | ||
|
||
Redistribution and use in source and binary forms, with or without | ||
modification, are permitted provided that the following conditions are met: | ||
|
||
* Redistributions of source code must retain the above copyright notice, this | ||
list of conditions and the following disclaimer. | ||
|
||
* Redistributions in binary form must reproduce the above copyright notice, | ||
this list of conditions and the following disclaimer in the documentation | ||
and/or other materials provided with the distribution. | ||
|
||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" | ||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE | ||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE | ||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE | ||
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL | ||
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR | ||
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER | ||
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, | ||
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE | ||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,188 @@ | ||
# hotmetal | ||
|
||
**A tiny HTML generator** | ||
|
||
## Installation | ||
|
||
Install from PyPI | ||
|
||
pip install hotmetal | ||
|
||
## What is hotmetal? | ||
|
||
`hotmetal` is a tiny library that lets you generate HTML directly from Python primitive data structures without using any sort of text-based template language. It is an alternative to [Jinja](https://jinja.palletsprojects.com/), [Django templates](https://docs.djangoproject.com/en/4.0/topics/templates/), etc. It is loosely inspired by ideas from [React](https://reactjs.org/), [Mithril](https://mithril.js.org/vnodes.html) and other JavaScript libraries. It is also similar to [Hyperpython](https://github.com/ejplatform/hyperpython), but it's even simpler. It attempts to stay as close as possible to [the HTML spec](https://html.spec.whatwg.org/). | ||
|
||
## How does it work? | ||
|
||
First, read [a quick introduction to HTML](https://html.spec.whatwg.org/#a-quick-introduction-to-html). Go on, it's important. | ||
|
||
An HTML document represents a tree of nodes. Each node (called an "element") consists of exactly three things: a **tag name** (`div`, `p`, `title` etc), a collection of **attributes** (a mapping of keys to values) and a list of **children** (other elements or text nested inside the node). | ||
|
||
So: that's a **tag name** (a string), **attributes** (a mapping), and **children** (a list). | ||
|
||
The simplest way to represent that structure in Python is a tuple of three elements: | ||
|
||
```python | ||
("", {}, []) | ||
``` | ||
|
||
Here's an example of an anchor element (a link) with a text node inside it: | ||
|
||
```python | ||
link_element = ("a", {"href": "somewhere.html"}, ["click me!"]) | ||
``` | ||
|
||
Here's an example of a full HTML document (note that the `DOCTYPE` is missing, we'll come back to that later): | ||
|
||
```python | ||
document = ( | ||
"html", | ||
{"lang": "en"}, | ||
[ | ||
("head", {}, [("title", {}, ["Sample page"])]), | ||
( | ||
"body", | ||
{}, | ||
[ | ||
("h1", {}, ["Sample page"]), | ||
( | ||
"p", | ||
{}, | ||
["This is a ", ("a", {"href": "demo.html"}, ["simple"]), " sample"], | ||
), | ||
], | ||
), | ||
], | ||
) | ||
``` | ||
|
||
Once we've created a structure like this, we can use `hotmetal` to _render_ it into a string: | ||
|
||
```pycon | ||
>>> from hotmetal import render | ||
>>> print(render(document, indent=2)) | ||
<html lang="en"> | ||
<head> | ||
<title> | ||
Sample page | ||
</title> | ||
</head> | ||
<body> | ||
<h1> | ||
Sample page | ||
</h1> | ||
<p> | ||
This is a | ||
<a href="demo.html"> | ||
simple | ||
</a> | ||
sample | ||
</p> | ||
</body> | ||
</html> | ||
``` | ||
|
||
In essence, _that's it_. That's all that `hotmetal` does. | ||
|
||
## Components | ||
|
||
But this all looks pretty fiddly, right? If you always needed to painstakingly assemble a huge tree of nested tuples to render every page on your web app, that would be annoyingly verbose and difficult to read. | ||
|
||
So here's the clever bit: instead, **write functions with meaningful names that return element nodes, and build your web app with those**. Let's call these functions that return nodes _components_ (if you're familiar with React you might see where this is going). | ||
|
||
```python | ||
def menu_item(href, text): | ||
return ("li", {}, [("a", {"href": href}, [text])]) | ||
|
||
|
||
def menu(items): | ||
return ("ul", {}, [menu_item(href, text) for href, text in items]) | ||
|
||
|
||
menu_node = menu( | ||
[ | ||
("/home/", "Home"), | ||
("/blog/", "Blog"), | ||
] | ||
) | ||
``` | ||
|
||
... and so on, right down to the `<html>`. | ||
|
||
For a good explanation of some useful patterns to use when composing markup in this way, have a read of the React docs on [Composition vs Inheritance](https://reactjs.org/docs/composition-vs-inheritance.html). The concepts should be directly transferable. | ||
|
||
## Fragments | ||
|
||
Earlier, we brushed over the fact that the example we used was missing its `DOCTYPE`. That's because the root of an HTML document is really two things: the `<html>` element itself, and before it [the `DOCTYPE` preamble](https://html.spec.whatwg.org/#the-doctype). We can express this structure in `hotmetal` by using a _fragment_. Again, the [same concept exists in React](https://reactjs.org/docs/fragments.html), but the syntax is simpler in `hotmetal`. Just use a node with an empty tag name and attributes: | ||
|
||
```python | ||
from hotmetal import safe | ||
|
||
|
||
document = ( | ||
"", | ||
{}, | ||
[ | ||
safe("<!DOCTYPE html>"), | ||
( | ||
"html", | ||
{"lang": "en"}, | ||
[...], # head, body etc | ||
), | ||
], | ||
) | ||
``` | ||
|
||
When rendering, `hotmetal` will "optimise away" the empty node, leaving only the two consecutive child nodes. This is also often useful during component composition: you might create a component that accepts a single child node as an argument, but you can still pass it a list of multiple nodes by wrapping them in a fragment. | ||
|
||
But what's that `safe` thing about? | ||
|
||
## Escaping | ||
|
||
You have to be careful when generating HTML. If any part of your markup or text content can be supplied by a user (which is common in web applications), you may be vulnerable to cross-site scripting (XSS) attacks. The Django docs have [a good explanation of why this is important](https://docs.djangoproject.com/en/4.0/ref/templates/language/#automatic-html-escaping-1). | ||
|
||
By default, `hotmetal` escapes _every_ string it renders using Python's built-in [`html.escape`](https://docs.python.org/3/library/html.html#html.escape) functionality. So you can add user-generated content to your documents without worrying. | ||
|
||
If you want to add some raw markup that you _know_ is safe (some hand-crafted HTML, or the rendered output of another templating system that already escapes unsafe content) then you can wrap those strings in the `safe` function. See the above section for an example. | ||
|
||
## Context | ||
|
||
The [React docs on context](https://reactjs.org/docs/context.html) say: | ||
|
||
> Context provides a way to pass data through the component tree without having to pass props down manually at every level. | ||
Exactly the same concept exists in `hotmetal`, but again the implementation is a little simpler. | ||
|
||
If you're familiar with the concept of context in Django or Jinja templates, this is _not quite the same thing_. In those languages, context is the _only_ way to pass variables into a template. In `hotmetal` there's no need to do this: you can just create component functions that accept arguments: | ||
|
||
```python | ||
def header(title): | ||
return ("h1", {}, [title]) | ||
``` | ||
|
||
So where would you use context? Just like in React: | ||
|
||
> Context is primarily used when some data needs to be accessible by many components at different nesting levels. Apply it sparingly because it makes component reuse more difficult. | ||
A good example might be if you need to access the `request` object or the currently logged in user somewhere deep in your component tree, but you don't want to explicitly pass it down through the component hierarchy from the root. | ||
|
||
To use context in `hotmetal`, you pass a dictionary to the `render` function: | ||
|
||
```python | ||
render(some_node, context={"logged_in_user_email": "[email protected]"}) | ||
``` | ||
|
||
To access the context from inside the tree, replace any node in the tree with a _callable_ (a named function or a lambda) that returns that node. During rendering, `hotmetal` will call your function, passing the `context` dictionary as its single argument. | ||
|
||
```python | ||
def current_user_panel(): | ||
return lambda context: ( | ||
"p", | ||
{}, | ||
[f"Hello, {context['logged_in_user_email']}"], | ||
) | ||
``` | ||
|
||
## Indentation control | ||
|
||
The `render` function takes an `indent` argument, which is a integer used to control how many spaces are used as indentation in the generated HTML. The default is 0, meaning the entire HTML string will be returned on a single line. You may wish to use (say) `indent=2` for development, and `indent=0` for production (essentially minifying your HTML). |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
black==22.3.0 | ||
blacken-docs==1.12.1 | ||
flake8==4.0.1 | ||
isort==5.10.1 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
#!/usr/bin/env bash | ||
|
||
set -e | ||
|
||
black hotmetal tests | ||
isort hotmetal tests | ||
blacken-docs README.md |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,68 @@ | ||
import html | ||
|
||
# https://html.spec.whatwg.org/#void-elements | ||
VOID_ELEMENTS = set( | ||
"area,base,br,col,embed,hr,img,input,link,meta,param,source,track,wbr".split(",") | ||
) | ||
|
||
|
||
class safe(str): | ||
pass | ||
|
||
|
||
def esc(string): | ||
return string if isinstance(string, safe) else safe(html.escape(string)) | ||
|
||
|
||
def _render(*, tree_or_text_or_callable, context, indent, level, is_root): | ||
breaker = "\n" if indent else "" | ||
indenter = " " * indent * level | ||
tree_or_text = ( | ||
tree_or_text_or_callable(context) | ||
if callable(tree_or_text_or_callable) | ||
else tree_or_text_or_callable | ||
) | ||
if not tree_or_text: | ||
return "" | ||
if isinstance(tree_or_text, str): | ||
return f"{breaker}{indenter}{esc(tree_or_text)}" | ||
tag, attrs, children = tree_or_text | ||
breaker = breaker if tag else "" | ||
attrs = ( | ||
( | ||
" " | ||
+ " ".join( | ||
esc(k) if v == "" else f'{esc(k)}="{esc(v)}"' for k, v in attrs.items() | ||
) | ||
) | ||
if attrs | ||
else "" | ||
) | ||
is_void = tag.lower() in VOID_ELEMENTS | ||
self_closer = " /" if is_void else "" | ||
opener = f"<{esc(tag)}{attrs}{self_closer}>" if tag else "" | ||
closer = f"</{esc(tag)}>" if (tag and not is_void) else "" | ||
next_level = level + 1 if tag else level | ||
children = "".join( | ||
_render( | ||
tree_or_text_or_callable=child, | ||
context=context, | ||
indent=indent, | ||
level=next_level, | ||
is_root=False, | ||
) | ||
for child in children | ||
) | ||
contents = f"{children}{breaker}{indenter}" if children else "" | ||
rendered = f"{breaker}{indenter}{opener}{contents}{closer}" | ||
return rendered.strip("\n") if is_root else rendered | ||
|
||
|
||
def render(node, context=None, indent=0): | ||
return _render( | ||
tree_or_text_or_callable=node, | ||
context=context or {}, | ||
indent=indent, | ||
level=0, | ||
is_root=True, | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,8 @@ | ||
#!/usr/bin/env bash | ||
|
||
set -e | ||
|
||
black --check hotmetal tests | ||
flake8 hotmetal tests | ||
isort --check --diff hotmetal tests | ||
python -m unittest $@ |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
[flake8] | ||
extend_ignore=E128,E501,W503 |
Oops, something went wrong.