diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0582988 --- /dev/null +++ b/.gitignore @@ -0,0 +1,47 @@ +*.py[cod] + +# C extensions +*.so + +# Packages +*.egg +*.egg-info +dist +build +eggs +parts +var +sdist +develop-eggs +.installed.cfg +lib +lib64 +__pycache__ + +# Installer logs +pip-log.txt + +# Unit test / coverage reports +.coverage +.tox +nosetests.xml + +# Translations +*.mo + +# Mr Developer +.mr.developer.cfg +.project +.pydevproject + +docs/_build +venv* +.eggs +.ropeproject +tags +.idea +.vscode +htmlcov +*.swp +node_modules +.python-version diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e9da658 --- /dev/null +++ b/LICENSE @@ -0,0 +1,20 @@ +The MIT License (MIT) + +Copyright (c) 2021 Miguel Grinberg + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..5a898f2 --- /dev/null +++ b/README.md @@ -0,0 +1,90 @@ +turbo-flask +=========== + +Integration of Hotwire's Turbo library with Flask. + +How to Install +-------------- + +```bash +pip install turbo-flask +``` + +How to Add to your Project +-------------------------- + +Direct initialization: + +```python +from flask import Flask +from turbo_flask import Turbo + +app = Flask(__name__) +turbo = Turbo(app) +``` + +Factory function initialization: + +```python +from flask import Flask +from turbo_flask import Turbo + +turbo = Turbo() + +def create_app(): + app = Flask(__name__) + turbo.init_app(app) + + return app +``` + +To add Turbo-Flask to your pages, include `{{ turbo() }}` in the `` +element of your main Jinja template: + +```html + + + + {{ turbo() }} + + + ... + + +``` + +How to Use +---------- + +See the [turbo.js documentation](https://turbo.hotwire.dev/) to learn how to +take advantage of this library. + +If you decide to use the Turbo Streams feature, this extension has helper +functions to generate the correct Flask responses. Here is an example with a +single streamed response: + +```python + if turbo.can_stream(): + return turbo.stream( + turbo.append(render_template('_todo.html', todo=todo), target='todos'), + ) + else: + return render_template('index.html', todos=todos) +``` + +And here is another with a list of them: + +```python + if turbo.can_stream(): + return turbo.stream([ + turbo.append(render_template('_todo.html', todo=todo), target='todos'), + turbo.update(render_template('_todo_input.html'), target='form') + ]) + else: + return render_template('index.html', todos=todos) +``` + +WebSocket Streaming +------------------- + +This feature of turbo.js has not been implemented at this time. diff --git a/examples/todos/app.py b/examples/todos/app.py new file mode 100644 index 0000000..9229742 --- /dev/null +++ b/examples/todos/app.py @@ -0,0 +1,58 @@ +from flask import Flask, render_template, request, redirect, url_for, abort +from turbo_flask import Turbo +from models import Todo + +app = Flask(__name__) +turbo = Turbo(app) + +todos = [Todo('buy eggs'), Todo('walk the dog')] + + +def get_todo_by_id(id): + todo = [todo for todo in todos if todo.id == id] + if len(todo) == 0: + abort(404) + return todo[0] + + +@app.route('/', methods=['GET', 'POST']) +def index(): + if request.method == 'POST': + todo = Todo(request.form['task']) + todos.append(todo) + if turbo.can_stream(): + return turbo.stream([ + turbo.append( + render_template('_todo.html', todo=todo), target='todos'), + turbo.update( + render_template('_todo_input.html'), target='form')]) + return render_template('index.html', todos=todos) + + +@app.route('/toggle/', methods=['POST']) +def toggle(id): + todo = get_todo_by_id(id) + todo.completed = not todo.completed + if turbo.can_stream(): + return turbo.stream( + turbo.replace(render_template('_todo.html', todo=todo), + target=f'todo-{todo.id}')) + return redirect(url_for('index')) + + +@app.route('/edit/', methods=['GET', 'POST']) +def edit(id): + todo = get_todo_by_id(id) + if request.method == 'POST': + todo.task = request.form['task'] + return redirect(url_for('index')) + return render_template('index.html', todos=todos, edit_id=todo.id) + + +@app.route('/delete/', methods=['POST']) +def delete(id): + todo = get_todo_by_id(id) + todos.remove(todo) + if turbo.can_stream(): + return turbo.stream(turbo.remove(target=f'todo-{todo.id}')) + return redirect(url_for('index')) diff --git a/examples/todos/models.py b/examples/todos/models.py new file mode 100644 index 0000000..a77a096 --- /dev/null +++ b/examples/todos/models.py @@ -0,0 +1,8 @@ +import uuid + + +class Todo: + def __init__(self, task): + self.id = uuid.uuid4().hex + self.task = task + self.completed = False diff --git a/examples/todos/static/base.css b/examples/todos/static/base.css new file mode 100644 index 0000000..da65968 --- /dev/null +++ b/examples/todos/static/base.css @@ -0,0 +1,141 @@ +hr { + margin: 20px 0; + border: 0; + border-top: 1px dashed #c5c5c5; + border-bottom: 1px dashed #f7f7f7; +} + +.learn a { + font-weight: normal; + text-decoration: none; + color: #b83f45; +} + +.learn a:hover { + text-decoration: underline; + color: #787e7e; +} + +.learn h3, +.learn h4, +.learn h5 { + margin: 10px 0; + font-weight: 500; + line-height: 1.2; + color: #000; +} + +.learn h3 { + font-size: 24px; +} + +.learn h4 { + font-size: 18px; +} + +.learn h5 { + margin-bottom: 0; + font-size: 14px; +} + +.learn ul { + padding: 0; + margin: 0 0 30px 25px; +} + +.learn li { + line-height: 20px; +} + +.learn p { + font-size: 15px; + font-weight: 300; + line-height: 1.3; + margin-top: 0; + margin-bottom: 0; +} + +#issue-count { + display: none; +} + +.quote { + border: none; + margin: 20px 0 60px 0; +} + +.quote p { + font-style: italic; +} + +.quote p:before { + content: '“'; + font-size: 50px; + opacity: .15; + position: absolute; + top: -20px; + left: 3px; +} + +.quote p:after { + content: '”'; + font-size: 50px; + opacity: .15; + position: absolute; + bottom: -42px; + right: 3px; +} + +.quote footer { + position: absolute; + bottom: -40px; + right: 0; +} + +.quote footer img { + border-radius: 3px; +} + +.quote footer a { + margin-left: 5px; + vertical-align: middle; +} + +.speech-bubble { + position: relative; + padding: 10px; + background: rgba(0, 0, 0, .04); + border-radius: 5px; +} + +.speech-bubble:after { + content: ''; + position: absolute; + top: 100%; + right: 30px; + border: 13px solid transparent; + border-top-color: rgba(0, 0, 0, .04); +} + +.learn-bar > .learn { + position: absolute; + width: 272px; + top: 8px; + left: -300px; + padding: 10px; + border-radius: 5px; + background-color: rgba(255, 255, 255, .6); + transition-property: left; + transition-duration: 500ms; +} + +@media (min-width: 899px) { + .learn-bar { + width: auto; + padding-left: 300px; + } + + .learn-bar > .learn { + left: 8px; + } +} diff --git a/examples/todos/static/edit.svg b/examples/todos/static/edit.svg new file mode 100644 index 0000000..ec7b4ca --- /dev/null +++ b/examples/todos/static/edit.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/examples/todos/static/index.css b/examples/todos/static/index.css new file mode 100644 index 0000000..ac542c4 --- /dev/null +++ b/examples/todos/static/index.css @@ -0,0 +1,407 @@ +html, +body { + margin: 0; + padding: 0; +} + +button { + margin: 0; + padding: 0; + border: 0; + background: none; + font-size: 100%; + vertical-align: baseline; + font-family: inherit; + font-weight: inherit; + color: inherit; + -webkit-appearance: none; + appearance: none; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +body { + font: 14px 'Helvetica Neue', Helvetica, Arial, sans-serif; + line-height: 1.4em; + background: #f5f5f5; + color: #4d4d4d; + min-width: 230px; + max-width: 550px; + margin: 0 auto; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + font-weight: 300; +} + +:focus { + outline: 0; +} + +.hidden { + display: none; +} + +.todoapp { + background: #fff; + margin: 130px 0 40px 0; + position: relative; + box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.2), + 0 25px 50px 0 rgba(0, 0, 0, 0.1); +} + +.todoapp input::-webkit-input-placeholder { + font-style: italic; + font-weight: 300; + color: #e6e6e6; +} + +.todoapp input::-moz-placeholder { + font-style: italic; + font-weight: 300; + color: #e6e6e6; +} + +.todoapp input::input-placeholder { + font-style: italic; + font-weight: 300; + color: #e6e6e6; +} + +.todoapp h1 { + position: absolute; + top: -155px; + width: 100%; + font-size: 100px; + font-weight: 100; + text-align: center; + color: rgba(175, 47, 47, 0.15); + -webkit-text-rendering: optimizeLegibility; + -moz-text-rendering: optimizeLegibility; + text-rendering: optimizeLegibility; +} + +.new-todo, +.edit { + position: relative; + margin: 0; + width: 100%; + font-size: 24px; + font-family: inherit; + font-weight: inherit; + line-height: 1.4em; + border: 0; + color: inherit; + padding: 6px; + border: 1px solid #999; + box-shadow: inset 0 -1px 5px 0 rgba(0, 0, 0, 0.2); + box-sizing: border-box; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +.new-todo { + padding: 16px 16px 16px 60px; + border: none; + background: rgba(0, 0, 0, 0.003); + box-shadow: inset 0 -2px 1px rgba(0,0,0,0.03); +} + +.main { + position: relative; + z-index: 2; + border-top: 1px solid #e6e6e6; +} + +.toggle-all { + text-align: center; + border: none; /* Mobile Safari */ + opacity: 0; + position: absolute; +} + +.toggle-all + label { + width: 60px; + height: 34px; + font-size: 0; + position: absolute; + top: -52px; + left: -13px; + -webkit-transform: rotate(90deg); + transform: rotate(90deg); +} + +.toggle-all + label:before { + content: '❯'; + font-size: 22px; + color: #e6e6e6; + padding: 10px 27px 10px 27px; +} + +.toggle-all:checked + label:before { + color: #737373; +} + +.todo-list { + margin: 0; + padding: 0; + list-style: none; +} + +.todo-list li { + position: relative; + font-size: 24px; + border-bottom: 1px solid #ededed; +} + +.todo-list li:last-child { + border-bottom: none; +} + +.todo-list li.editing { + border-bottom: none; + padding: 0; +} + +.todo-list li.editing .edit { + display: block; + width: 506px; + padding: 12px 16px; + margin: 0 0 0 43px; +} + +.todo-list li.editing .view { + display: none; +} + +.todo-list li .toggle { + text-align: center; + width: 40px; + /* auto, since non-WebKit browsers doesn't support input styling */ + height: auto; + position: absolute; + top: 0; + bottom: 0; + margin: auto 0; + border: none; /* Mobile Safari */ + -webkit-appearance: none; + appearance: none; +} + +.todo-list li .toggle { + opacity: 0; + z-index: 1; +} + +.todo-list li .toggle + label { + /* + Firefox requires `#` to be escaped - https://bugzilla.mozilla.org/show_bug.cgi?id=922433 + IE and Edge requires *everything* to be escaped to render, so we do that instead of just the `#` - https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/7157459/ + */ + background-image: url('data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%23ededed%22%20stroke-width%3D%223%22/%3E%3C/svg%3E'); + background-repeat: no-repeat; + background-position: center left; + position: absolute; + top: 12px; + z-index: 0; +} + +.todo-list li .toggle.checked + label { + background-image: url('data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%23bddad5%22%20stroke-width%3D%223%22/%3E%3Cpath%20fill%3D%22%235dc2af%22%20d%3D%22M72%2025L42%2071%2027%2056l-4%204%2020%2020%2034-52z%22/%3E%3C/svg%3E'); +} + +.todo-list li label { + word-break: break-all; + padding: 15px 15px 15px 60px; + display: block; + line-height: 1.2; + transition: color 0.4s; +} + +.todo-list li.completed label { + color: #d9d9d9; + text-decoration: line-through; +} + +.todo-list li .change { + display: none; + position: absolute; + top: 0; + right: 40px; + bottom: -3px; + width: 40px; + height: 40px; + margin: auto 0; + font-size: 25px; + color: #cc9a9a; + margin-bottom: 11px; + transition: color 0.2s ease-out; +} + +.todo-list li .change:hover { + color: #af5b5e; +} + +.todo-list li .change:after { + content: '✎'; +} + +.todo-list li:hover .change { + display: block; +} + +.todo-list li .destroy { + display: none; + position: absolute; + top: 0; + right: 10px; + bottom: 0; + width: 40px; + height: 40px; + margin: auto 0; + font-size: 30px; + color: #cc9a9a; + margin-bottom: 11px; + transition: color 0.2s ease-out; +} + +.todo-list li .destroy:hover { + color: #af5b5e; +} + +.todo-list li .destroy:after { + content: '×'; +} + +.todo-list li:hover .destroy { + display: block; +} + +.todo-list li .edit { + display: none; +} + +.todo-list li.editing:last-child { + margin-bottom: -1px; +} + +.footer { + color: #777; + padding: 10px 15px; + height: 20px; + text-align: center; + border-top: 1px solid #e6e6e6; +} + +.footer:before { + content: ''; + position: absolute; + right: 0; + bottom: 0; + left: 0; + height: 50px; + overflow: hidden; + box-shadow: 0 1px 1px rgba(0, 0, 0, 0.2), + 0 8px 0 -3px #f6f6f6, + 0 9px 1px -3px rgba(0, 0, 0, 0.2), + 0 16px 0 -6px #f6f6f6, + 0 17px 2px -6px rgba(0, 0, 0, 0.2); +} + +.todo-count { + float: left; + text-align: left; +} + +.todo-count strong { + font-weight: 300; +} + +.filters { + margin: 0; + padding: 0; + list-style: none; + position: absolute; + right: 0; + left: 0; +} + +.filters li { + display: inline; +} + +.filters li a { + color: inherit; + margin: 3px; + padding: 3px 7px; + text-decoration: none; + border: 1px solid transparent; + border-radius: 3px; +} + +.filters li a:hover { + border-color: rgba(175, 47, 47, 0.1); +} + +.filters li a.selected { + border-color: rgba(175, 47, 47, 0.2); +} + +.clear-completed, +html .clear-completed:active { + float: right; + position: relative; + line-height: 20px; + text-decoration: none; + cursor: pointer; +} + +.clear-completed:hover { + text-decoration: underline; +} + +.info { + margin: 65px auto 0; + color: #bfbfbf; + font-size: 10px; + text-shadow: 0 1px 0 rgba(255, 255, 255, 0.5); + text-align: center; +} + +.info p { + line-height: 1; +} + +.info a { + color: inherit; + text-decoration: none; + font-weight: 400; +} + +.info a:hover { + text-decoration: underline; +} + +/* + Hack to remove background from Mobile Safari. + Can't use it globally since it destroys checkboxes in Firefox +*/ +@media screen and (-webkit-min-device-pixel-ratio:0) { + .toggle-all, + .todo-list li .toggle { + background: none; + } + + .todo-list li .toggle { + height: 40px; + } +} + +@media (max-width: 430px) { + .footer { + height: 50px; + } + + .filters { + bottom: 10px; + } +} diff --git a/examples/todos/static/trash.svg b/examples/todos/static/trash.svg new file mode 100644 index 0000000..f24d55b --- /dev/null +++ b/examples/todos/static/trash.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/examples/todos/templates/_todo.html b/examples/todos/templates/_todo.html new file mode 100644 index 0000000..68e9d4a --- /dev/null +++ b/examples/todos/templates/_todo.html @@ -0,0 +1,24 @@ + + + {% if not edit_id %} +
+ + +
+ {% else %} +
+ + +
+ {% endif %} + + {% if not edit_id %} +
+ +
+
+ +
+ {% endif %} + +
diff --git a/examples/todos/templates/_todo_edit.html b/examples/todos/templates/_todo_edit.html new file mode 100644 index 0000000..7f4fd8d --- /dev/null +++ b/examples/todos/templates/_todo_edit.html @@ -0,0 +1,11 @@ + +
  • +
    + + +
    +
    + +
    +
  • +
    diff --git a/examples/todos/templates/_todo_input.html b/examples/todos/templates/_todo_input.html new file mode 100644 index 0000000..7c9cf38 --- /dev/null +++ b/examples/todos/templates/_todo_input.html @@ -0,0 +1 @@ + diff --git a/examples/todos/templates/index.html b/examples/todos/templates/index.html new file mode 100644 index 0000000..5cd876d --- /dev/null +++ b/examples/todos/templates/index.html @@ -0,0 +1,29 @@ + + + + + + {{ turbo() }} + + +
    +

    todos

    + +
    + {% include '_todo_input.html' %} +
    +
      + + {% for todo in todos %} + {% if todo.id != edit_id %} + {% include '_todo.html' %} + {% else %} + {% include '_todo_edit.html' %} + {% endif %} + {% endfor %} + +
    +
    +
    + + diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..017ed74 --- /dev/null +++ b/setup.py @@ -0,0 +1,36 @@ +""" +Turbo-Flask +----------- + +Use Hotwire Turbo in your Flask application. +""" +from setuptools import setup + + +setup( + name='Turbo-Flask', + version='0.0.1', + url='http://github.com/miguelgrinberg/turbo-flask/', + license='MIT', + author='Miguel Grinberg', + author_email='miguel.grinberg@gmail.com', + description='Use Hotwire Turbo in your Flask application', + long_description=__doc__, + packages=['turbo_flask'], + zip_safe=False, + include_package_data=True, + platforms='any', + install_requires=[ + 'Flask' + ], + classifiers=[ + 'Environment :: Web Environment', + 'Intended Audience :: Developers', + 'License :: OSI Approved :: MIT License', + 'Operating System :: OS Independent', + 'Programming Language :: Python', + 'Programming Language :: Python :: 3', + 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', + 'Topic :: Software Development :: Libraries :: Python Modules' + ] +) diff --git a/turbo_flask/__init__.py b/turbo_flask/__init__.py new file mode 100644 index 0000000..10e4707 --- /dev/null +++ b/turbo_flask/__init__.py @@ -0,0 +1 @@ +from turbo_flask.turbo import Turbo diff --git a/turbo_flask/turbo.py b/turbo_flask/turbo.py new file mode 100644 index 0000000..f894f5b --- /dev/null +++ b/turbo_flask/turbo.py @@ -0,0 +1,88 @@ +from flask import request, current_app +from jinja2 import Markup + + +_CDN = 'https://cdn.skypack.dev' +_PKG = '@hotwired/turbo' +_VER = 'v7.0.0-beta.4-TQFv5Y2xd4hn2VnTxVul' + + +class Turbo: + def __init__(self, app=None): + if app: + self.init_app(app) + + def init_app(self, app): + app.context_processor(self.context_processor) + + def turbo(self, version=_VER, url=None): + """Add turbo.js to the template. + + Add `{{ turbo() }}` in the `` section of your main template. + """ + if url is None: + url = f'{_CDN}/pin/{_PKG}@{version}/min/{_PKG}.js' + return Markup(f'') + + def context_processor(self): + return {'turbo': self.turbo} + + def can_stream(self): + """Returns `True` if the client accepts turbo streams.""" + stream_mimetype = 'text/vnd.turbo-stream.html' + best = request.accept_mimetypes.best_match([ + stream_mimetype, 'text/html']) + return best == stream_mimetype + + def _make_stream(self, action, content, target): + return (f'' + f'') + + def append(self, content, target): + """Create an append stream. + + :param content: the HTML content to include in the stream. + :param target: the target ID for this change. + """ + return self._make_stream('append', content, target) + + def prepend(self, content, target): + """Create a prepend stream. + + :param content: the HTML content to include in the stream. + :param target: the target ID for this change. + """ + return self._make_stream('prepend', content, target) + + def replace(self, content, target): + """Create a replace stream. + + :param content: the HTML content to include in the stream. + :param target: the target ID for this change. + """ + return self._make_stream('replace', content, target) + + def update(self, content, target): + """Create an update stream. + + :param content: the HTML content to include in the stream. + :param target: the target ID for this change. + """ + return self._make_stream('update', content, target) + + def remove(self, target): + """Create a remove stream. + + :param target: the target ID for this change. + """ + return self._make_stream('remove', '', target) + + def stream(self, response_stream): + """Create a turbo stream response. + + :param response_stream: one or a list of streamed responses generated + by the `append()`, `prepend()`, `replace()`, + `update()` and `remove()` methods. + """ + return current_app.response_class( + response_stream, mimetype='text/vnd.turbo-stream.html')