py-flowtoken 0.3.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- py_flowtoken/__init__.py +28 -0
- py_flowtoken/adapters/__init__.py +0 -0
- py_flowtoken/adapters/django/__init__.py +3 -0
- py_flowtoken/adapters/django/apps.py +10 -0
- py_flowtoken/adapters/django/templatetags/__init__.py +0 -0
- py_flowtoken/adapters/django/templatetags/flowtoken.py +46 -0
- py_flowtoken/adapters/django/urls.py +6 -0
- py_flowtoken/adapters/django/views.py +29 -0
- py_flowtoken/adapters/fasthtml.py +33 -0
- py_flowtoken/adapters/flask.py +82 -0
- py_flowtoken/assets.py +13 -0
- py_flowtoken/components.py +116 -0
- py_flowtoken/core.py +17 -0
- py_flowtoken/options.py +60 -0
- py_flowtoken/policy.py +92 -0
- py_flowtoken/rendering.py +114 -0
- py_flowtoken/schema.json +131 -0
- py_flowtoken/static/py_flowtoken/flowtoken.css +26 -0
- py_flowtoken/static/py_flowtoken/flowtoken.js +1 -0
- py_flowtoken/streaming.py +89 -0
- py_flowtoken-0.3.1.dist-info/METADATA +105 -0
- py_flowtoken-0.3.1.dist-info/RECORD +25 -0
- py_flowtoken-0.3.1.dist-info/WHEEL +4 -0
- py_flowtoken-0.3.1.dist-info/licenses/LICENSE +21 -0
- py_flowtoken-0.3.1.dist-info/licenses/NOTICE +11 -0
py_flowtoken/__init__.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from .components import Component
|
|
2
|
+
from .core import (
|
|
3
|
+
ANIMATIONS,
|
|
4
|
+
AnimatedImage,
|
|
5
|
+
AnimatedMarkdown,
|
|
6
|
+
AnimatedText,
|
|
7
|
+
Options,
|
|
8
|
+
TextStream,
|
|
9
|
+
Update,
|
|
10
|
+
asset_path,
|
|
11
|
+
assets,
|
|
12
|
+
tokenize,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"Component",
|
|
17
|
+
"ANIMATIONS",
|
|
18
|
+
"AnimatedImage",
|
|
19
|
+
"AnimatedMarkdown",
|
|
20
|
+
"AnimatedText",
|
|
21
|
+
"Options",
|
|
22
|
+
"TextStream",
|
|
23
|
+
"Update",
|
|
24
|
+
"asset_path",
|
|
25
|
+
"assets",
|
|
26
|
+
"tokenize",
|
|
27
|
+
]
|
|
28
|
+
__version__ = "0.3.1"
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from django import template
|
|
2
|
+
from django.conf import settings
|
|
3
|
+
from django.templatetags.static import static
|
|
4
|
+
from django.urls import reverse
|
|
5
|
+
from django.utils.html import format_html
|
|
6
|
+
from django.utils.safestring import mark_safe
|
|
7
|
+
|
|
8
|
+
from ....assets import asset_path
|
|
9
|
+
from ....rendering import AnimatedImage, AnimatedMarkdown, AnimatedText
|
|
10
|
+
|
|
11
|
+
register = template.Library()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@register.simple_tag
|
|
15
|
+
def flowtoken_asset_url(filename):
|
|
16
|
+
asset_path(filename)
|
|
17
|
+
mode = getattr(settings, "FLOWTOKEN_ASSET_MODE", "static")
|
|
18
|
+
if mode == "static":
|
|
19
|
+
return static("py_flowtoken/" + filename)
|
|
20
|
+
if mode == "route":
|
|
21
|
+
return reverse("py_flowtoken:asset", kwargs={"filename": filename})
|
|
22
|
+
raise ValueError("FLOWTOKEN_ASSET_MODE must be static or route")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@register.simple_tag
|
|
26
|
+
def flowtoken_assets():
|
|
27
|
+
return format_html(
|
|
28
|
+
'<link rel="stylesheet" href="{}"><script type="module" src="{}"></script>',
|
|
29
|
+
flowtoken_asset_url("flowtoken.css"),
|
|
30
|
+
flowtoken_asset_url("flowtoken.js"),
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@register.simple_tag
|
|
35
|
+
def flowtoken_text(content="", **options):
|
|
36
|
+
return mark_safe(AnimatedText(content, **options).render())
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@register.simple_tag
|
|
40
|
+
def flowtoken_markdown(content="", **options):
|
|
41
|
+
return mark_safe(AnimatedMarkdown(content, **options).render())
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@register.simple_tag
|
|
45
|
+
def flowtoken_image(src, alt="", **options):
|
|
46
|
+
return mark_safe(AnimatedImage(src, alt, **options).render())
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from django.http import Http404, HttpResponse, StreamingHttpResponse
|
|
2
|
+
from django.views.decorators.cache import cache_control
|
|
3
|
+
from django.views.decorators.http import require_safe
|
|
4
|
+
|
|
5
|
+
from ...assets import asset_path
|
|
6
|
+
from ...streaming import aiter_sse, iter_sse
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@require_safe
|
|
10
|
+
@cache_control(public=True, max_age=3600)
|
|
11
|
+
def asset(request, filename):
|
|
12
|
+
try:
|
|
13
|
+
content = asset_path(filename).read_bytes()
|
|
14
|
+
except ValueError as error:
|
|
15
|
+
raise Http404("Unknown FlowToken asset") from error
|
|
16
|
+
return HttpResponse(
|
|
17
|
+
content,
|
|
18
|
+
content_type="text/javascript" if filename.endswith(".js") else "text/css",
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def event_stream(events):
|
|
23
|
+
encoded = aiter_sse(events) if hasattr(events, "__aiter__") else iter_sse(events)
|
|
24
|
+
response = StreamingHttpResponse(
|
|
25
|
+
encoded, content_type="text/event-stream; charset=utf-8"
|
|
26
|
+
)
|
|
27
|
+
response.headers["Cache-Control"] = "no-cache"
|
|
28
|
+
response.headers["X-Accel-Buffering"] = "no"
|
|
29
|
+
return response
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from fasthtml.common import Link, Script
|
|
4
|
+
from starlette.responses import StreamingResponse
|
|
5
|
+
from starlette.staticfiles import StaticFiles
|
|
6
|
+
|
|
7
|
+
from ..assets import asset_path
|
|
8
|
+
from ..streaming import aiter_sse, iter_sse
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def asset_tags(url_prefix="/flowtoken"):
|
|
12
|
+
prefix = url_prefix.rstrip("/")
|
|
13
|
+
return (
|
|
14
|
+
Link(rel="stylesheet", href=prefix + "/flowtoken.css"),
|
|
15
|
+
Script(src=prefix + "/flowtoken.js", type="module"),
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def mount_assets(app, url_prefix="/flowtoken"):
|
|
20
|
+
app.mount(
|
|
21
|
+
url_prefix.rstrip("/"),
|
|
22
|
+
StaticFiles(directory=str(Path(str(asset_path("flowtoken.js"))).parent)),
|
|
23
|
+
name="py_flowtoken",
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def event_stream(events):
|
|
28
|
+
encoded = aiter_sse(events) if hasattr(events, "__aiter__") else iter_sse(events)
|
|
29
|
+
return StreamingResponse(
|
|
30
|
+
encoded,
|
|
31
|
+
media_type="text/event-stream",
|
|
32
|
+
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
|
33
|
+
)
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
from io import BytesIO
|
|
2
|
+
from hashlib import sha256
|
|
3
|
+
|
|
4
|
+
from flask import Blueprint, Response, abort, send_file, stream_with_context, url_for
|
|
5
|
+
from markupsafe import Markup, escape
|
|
6
|
+
|
|
7
|
+
from ..assets import asset_path
|
|
8
|
+
from ..rendering import AnimatedImage, AnimatedMarkdown, AnimatedText
|
|
9
|
+
from ..streaming import iter_sse
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def create_blueprint(url_prefix="/flowtoken"):
|
|
13
|
+
blueprint = Blueprint("py_flowtoken", __name__, url_prefix=url_prefix)
|
|
14
|
+
|
|
15
|
+
@blueprint.get("/<filename>")
|
|
16
|
+
def asset(filename):
|
|
17
|
+
try:
|
|
18
|
+
content = asset_path(filename).read_bytes()
|
|
19
|
+
except ValueError:
|
|
20
|
+
abort(404)
|
|
21
|
+
return send_file(
|
|
22
|
+
BytesIO(content),
|
|
23
|
+
download_name=filename,
|
|
24
|
+
mimetype="text/javascript" if filename.endswith(".js") else "text/css",
|
|
25
|
+
conditional=True,
|
|
26
|
+
etag=sha256(content).hexdigest(),
|
|
27
|
+
max_age=3600,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
return blueprint
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def asset_url(filename):
|
|
34
|
+
asset_path(filename)
|
|
35
|
+
return url_for("py_flowtoken.asset", filename=filename)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def asset_tags():
|
|
39
|
+
return Markup(
|
|
40
|
+
'<link rel="stylesheet" href="{}"><script type="module" src="{}"></script>'
|
|
41
|
+
).format(escape(asset_url("flowtoken.css")), escape(asset_url("flowtoken.js")))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def animated_text(content="", **options):
|
|
45
|
+
return Markup(AnimatedText(content, **options).render())
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def animated_markdown(content="", **options):
|
|
49
|
+
return Markup(AnimatedMarkdown(content, **options).render())
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def animated_image(src, alt="", **options):
|
|
53
|
+
return Markup(AnimatedImage(src, alt, **options).render())
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class FlowToken:
|
|
57
|
+
def __init__(self, app=None, *, url_prefix="/flowtoken"):
|
|
58
|
+
if app is not None:
|
|
59
|
+
self.init_app(app, url_prefix=url_prefix)
|
|
60
|
+
|
|
61
|
+
def init_app(self, app, *, url_prefix="/flowtoken"):
|
|
62
|
+
if "py_flowtoken" in app.extensions:
|
|
63
|
+
raise ValueError("py-flowtoken is already registered on this Flask app")
|
|
64
|
+
app.register_blueprint(create_blueprint(url_prefix))
|
|
65
|
+
app.jinja_env.globals.update(
|
|
66
|
+
flowtoken_assets=asset_tags,
|
|
67
|
+
flowtoken_asset_url=asset_url,
|
|
68
|
+
flowtoken_text=animated_text,
|
|
69
|
+
flowtoken_markdown=animated_markdown,
|
|
70
|
+
flowtoken_image=animated_image,
|
|
71
|
+
)
|
|
72
|
+
app.extensions["py_flowtoken"] = self
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def event_stream(events):
|
|
76
|
+
if hasattr(events, "__aiter__"):
|
|
77
|
+
raise TypeError("Flask event_stream requires a synchronous iterable")
|
|
78
|
+
return Response(
|
|
79
|
+
stream_with_context(iter_sse(events)),
|
|
80
|
+
content_type="text/event-stream; charset=utf-8",
|
|
81
|
+
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
|
82
|
+
)
|
py_flowtoken/assets.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import html
|
|
2
|
+
from importlib.resources import files
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def asset_path(name: str):
|
|
6
|
+
if name not in {"flowtoken.js", "flowtoken.css"}:
|
|
7
|
+
raise ValueError("Unknown asset")
|
|
8
|
+
return files("py_flowtoken").joinpath("static", "py_flowtoken", name)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def assets(base_url: str = "/static/flowtoken") -> str:
|
|
12
|
+
base_url = html.escape(base_url.rstrip("/"), quote=True)
|
|
13
|
+
return f'<link rel="stylesheet" href="{base_url}/flowtoken.css"><script type="module" src="{base_url}/flowtoken.js"></script>'
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from html.parser import HTMLParser
|
|
4
|
+
from typing import Callable, Mapping
|
|
5
|
+
|
|
6
|
+
from markdown_it import MarkdownIt
|
|
7
|
+
from markdown_it.rules_inline import backtick
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def mask_code(source: str) -> str:
|
|
11
|
+
engine = MarkdownIt('commonmark')
|
|
12
|
+
ranges = []
|
|
13
|
+
offsets = [0]
|
|
14
|
+
for match in re.finditer('\n', source):
|
|
15
|
+
offsets.append(match.end())
|
|
16
|
+
offsets.append(len(source))
|
|
17
|
+
for token in engine.parse(source):
|
|
18
|
+
if token.type in ('fence', 'code_block'):
|
|
19
|
+
start, end = token.map
|
|
20
|
+
ranges.append((offsets[start], offsets[end]))
|
|
21
|
+
def capture_backticks(state, silent):
|
|
22
|
+
start, count = state.pos, len(state.tokens)
|
|
23
|
+
matched = backtick(state, silent)
|
|
24
|
+
if len(state.tokens) > count and state.tokens[-1].type == 'code_inline':
|
|
25
|
+
ranges.append((start, state.pos))
|
|
26
|
+
return matched
|
|
27
|
+
|
|
28
|
+
engine.inline.ruler.at('backticks', capture_backticks)
|
|
29
|
+
engine.inline.parse(source, engine, {}, [])
|
|
30
|
+
masked = list(source)
|
|
31
|
+
for start, end in ranges:
|
|
32
|
+
masked[start:end] = ['\n' if char == '\n' else ' ' for char in source[start:end]]
|
|
33
|
+
return ''.join(masked)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class Component:
|
|
38
|
+
tag: str
|
|
39
|
+
attributes: Mapping[str, str | None]
|
|
40
|
+
content: str
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ComponentParser(HTMLParser):
|
|
44
|
+
def __init__(self, source: str, renderers: Mapping[str, Callable[[Component], str]]):
|
|
45
|
+
super().__init__(convert_charrefs=False)
|
|
46
|
+
self.source = source
|
|
47
|
+
self.renderers = {key.lower(): value for key, value in renderers.items()}
|
|
48
|
+
self.offsets = [0]
|
|
49
|
+
for match in re.finditer('\n', source):
|
|
50
|
+
self.offsets.append(match.end())
|
|
51
|
+
self.active = None
|
|
52
|
+
self.depth = 0
|
|
53
|
+
self.replacements = []
|
|
54
|
+
|
|
55
|
+
def position(self):
|
|
56
|
+
line, column = self.getpos()
|
|
57
|
+
return self.offsets[line - 1] + column
|
|
58
|
+
|
|
59
|
+
def handle_starttag(self, tag, attrs):
|
|
60
|
+
if self.active and tag == self.active[0]:
|
|
61
|
+
self.depth += 1
|
|
62
|
+
elif not self.active and tag in self.renderers:
|
|
63
|
+
start = self.position()
|
|
64
|
+
self.active = (tag, dict(attrs), start, start + len(self.get_starttag_text()))
|
|
65
|
+
self.depth = 1
|
|
66
|
+
|
|
67
|
+
def handle_endtag(self, tag):
|
|
68
|
+
if self.active and tag == self.active[0]:
|
|
69
|
+
self.depth -= 1
|
|
70
|
+
if self.depth == 0:
|
|
71
|
+
name, attrs, start, content_start = self.active
|
|
72
|
+
end_start = self.position()
|
|
73
|
+
end = self.source.find('>', end_start) + 1
|
|
74
|
+
rendered = self.renderers[name](Component(name, attrs, self.source[content_start:end_start]))
|
|
75
|
+
if not isinstance(rendered, str):
|
|
76
|
+
raise TypeError('Custom component renderers must return HTML strings')
|
|
77
|
+
self.replacements.append((start, end, rendered))
|
|
78
|
+
self.active = None
|
|
79
|
+
|
|
80
|
+
def handle_startendtag(self, tag, attrs):
|
|
81
|
+
if not self.active and tag in self.renderers:
|
|
82
|
+
start = self.position()
|
|
83
|
+
rendered = self.renderers[tag](Component(tag, dict(attrs), ''))
|
|
84
|
+
if not isinstance(rendered, str):
|
|
85
|
+
raise TypeError('Custom component renderers must return HTML strings')
|
|
86
|
+
self.replacements.append((start, start + len(self.get_starttag_text()), rendered))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def extract_components(source: str, renderers: Mapping[str, Callable[[Component], str]]):
|
|
90
|
+
if not renderers:
|
|
91
|
+
return source, {}
|
|
92
|
+
for name in renderers:
|
|
93
|
+
if not re.fullmatch(r'[a-zA-Z][\w-]*', name):
|
|
94
|
+
raise ValueError('Invalid custom component tag')
|
|
95
|
+
parser = ComponentParser(source, renderers)
|
|
96
|
+
masked = mask_code(source)
|
|
97
|
+
parser.feed(masked)
|
|
98
|
+
limit = parser.active[2] if parser.active else len(source)
|
|
99
|
+
tail = masked.rfind('<', 0, limit)
|
|
100
|
+
if tail >= 0 and '>' not in masked[tail:limit]:
|
|
101
|
+
partial = masked[tail + 1:limit].lstrip('/').lower()
|
|
102
|
+
if any(name.lower().startswith(partial) or re.match(re.escape(name.lower())+r'\s', partial) for name in renderers):
|
|
103
|
+
limit = tail
|
|
104
|
+
output, rendered, cursor = [], {}, 0
|
|
105
|
+
prefix = 'PYFLOWTOKENCOMPONENT'
|
|
106
|
+
while prefix in source:
|
|
107
|
+
prefix += 'X'
|
|
108
|
+
for start, end, value in parser.replacements:
|
|
109
|
+
if end > limit:
|
|
110
|
+
break
|
|
111
|
+
marker = f'{prefix}{len(rendered)}END'
|
|
112
|
+
output.extend([source[cursor:start], marker])
|
|
113
|
+
rendered[marker] = value
|
|
114
|
+
cursor = end
|
|
115
|
+
output.append(source[cursor:limit])
|
|
116
|
+
return ''.join(output), rendered
|
py_flowtoken/core.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from .assets import asset_path, assets
|
|
2
|
+
from .options import ANIMATIONS, Options
|
|
3
|
+
from .rendering import AnimatedImage, AnimatedMarkdown, AnimatedText
|
|
4
|
+
from .streaming import TextStream, Update, tokenize
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"ANIMATIONS",
|
|
8
|
+
"AnimatedImage",
|
|
9
|
+
"AnimatedMarkdown",
|
|
10
|
+
"AnimatedText",
|
|
11
|
+
"Options",
|
|
12
|
+
"TextStream",
|
|
13
|
+
"Update",
|
|
14
|
+
"asset_path",
|
|
15
|
+
"assets",
|
|
16
|
+
"tokenize",
|
|
17
|
+
]
|
py_flowtoken/options.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import html
|
|
2
|
+
import math
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
from .policy import SCHEMA
|
|
7
|
+
|
|
8
|
+
ANIMATIONS = {name: 'ft-'+name for name in SCHEMA['animations']}
|
|
9
|
+
DEFAULTS = SCHEMA['defaults']
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def valid_easing(value: str) -> bool:
|
|
13
|
+
if value in ('ease', 'ease-in', 'ease-out', 'ease-in-out', 'linear', 'step-start', 'step-end'):
|
|
14
|
+
return True
|
|
15
|
+
match = re.fullmatch(r'cubic-bezier\(\s*(-?\d*\.?\d+)\s*,\s*(-?\d*\.?\d+)\s*,\s*(-?\d*\.?\d+)\s*,\s*(-?\d*\.?\d+)\s*\)', value)
|
|
16
|
+
if match:
|
|
17
|
+
points = [float(number) for number in match.groups()]
|
|
18
|
+
return all(math.isfinite(number) for number in points) and 0 <= points[0] <= 1 and 0 <= points[2] <= 1
|
|
19
|
+
match = re.fullmatch(r'steps\(\s*([1-9]\d*)\s*(?:,\s*(start|end|jump-start|jump-end|jump-none|jump-both)\s*)?\)', value)
|
|
20
|
+
return bool(match and (match[2] != 'jump-none' or int(match[1]) > 1))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def dimension(value: str | None) -> str | None:
|
|
24
|
+
if value is not None and value != 'auto' and not re.fullmatch(r'\d+(?:\.\d+)?(?:px|rem|em|%|vh|vw)', value):
|
|
25
|
+
raise ValueError('Invalid image dimension')
|
|
26
|
+
return value
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class Options:
|
|
31
|
+
sep: str = DEFAULTS['sep']
|
|
32
|
+
animation: str | None = DEFAULTS['animation']
|
|
33
|
+
animation_duration: str = DEFAULTS['duration']
|
|
34
|
+
animation_timing_function: str = DEFAULTS['easing']
|
|
35
|
+
interval_ms: int = DEFAULTS['interval']
|
|
36
|
+
animate_initial: bool = DEFAULTS['animateInitial']
|
|
37
|
+
|
|
38
|
+
def __post_init__(self):
|
|
39
|
+
if self.sep not in SCHEMA['separators']:
|
|
40
|
+
raise ValueError('Invalid separator')
|
|
41
|
+
if self.animation is not None and not re.fullmatch(r'[a-zA-Z_][\w-]*', self.animation):
|
|
42
|
+
raise ValueError('Invalid animation name')
|
|
43
|
+
if not re.fullmatch(r'(?:\d+(?:\.\d+)?|\.\d+)(?:ms|s)', self.animation_duration):
|
|
44
|
+
raise ValueError('Invalid animation duration')
|
|
45
|
+
if not valid_easing(self.animation_timing_function):
|
|
46
|
+
raise ValueError('Invalid animation timing function')
|
|
47
|
+
if not isinstance(self.interval_ms, (int, float)) or not math.isfinite(self.interval_ms) or not 0 <= self.interval_ms <= 1000:
|
|
48
|
+
raise ValueError('interval_ms must be between 0 and 1000')
|
|
49
|
+
if not isinstance(self.animate_initial, bool):
|
|
50
|
+
raise ValueError('animate_initial must be a boolean')
|
|
51
|
+
|
|
52
|
+
def attributes(self) -> str:
|
|
53
|
+
values = {'data-sep':self.sep, 'data-animation':self.animation or 'none',
|
|
54
|
+
'data-duration':self.animation_duration, 'data-easing':self.animation_timing_function,
|
|
55
|
+
'data-interval':str(self.interval_ms), 'data-animate-initial':str(self.animate_initial).lower()}
|
|
56
|
+
return ' '.join(f'{key}="{html.escape(value, quote=True)}"' for key, value in values.items())
|
|
57
|
+
|
|
58
|
+
def style(self, index: int = 0) -> str:
|
|
59
|
+
name = ANIMATIONS.get(self.animation, self.animation) or 'none'
|
|
60
|
+
return f'animation-name:{name};animation-duration:{self.animation_duration};animation-timing-function:{self.animation_timing_function};animation-iteration-count:1;animation-fill-mode:both'
|
py_flowtoken/policy.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import html
|
|
2
|
+
import json
|
|
3
|
+
import re
|
|
4
|
+
from html.parser import HTMLParser
|
|
5
|
+
from importlib.resources import files
|
|
6
|
+
from urllib.parse import unquote, urlsplit
|
|
7
|
+
|
|
8
|
+
SCHEMA = json.loads(files('py_flowtoken').joinpath('schema.json').read_text())
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def safe_url(value: str) -> str | None:
|
|
12
|
+
value = value.strip()
|
|
13
|
+
if re.search(r'[\x00-\x20\x7f]', value):
|
|
14
|
+
return None
|
|
15
|
+
try:
|
|
16
|
+
parsed = urlsplit(value)
|
|
17
|
+
if parsed.scheme and parsed.scheme.lower() not in SCHEMA['protocols']:
|
|
18
|
+
return None
|
|
19
|
+
if parsed.scheme in ('http', 'https') or value.startswith('//'):
|
|
20
|
+
if not parsed.hostname or re.search(r'[\s%\[\]<>]', unquote(parsed.hostname)):
|
|
21
|
+
return None
|
|
22
|
+
parsed.port
|
|
23
|
+
except ValueError:
|
|
24
|
+
return None
|
|
25
|
+
return value
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def safe_style(value: str) -> str:
|
|
29
|
+
result = []
|
|
30
|
+
for declaration in value.split(';'):
|
|
31
|
+
name, separator, content = declaration.partition(':')
|
|
32
|
+
name, content = name.strip().lower(), content.strip()
|
|
33
|
+
if separator and name in SCHEMA['styles'] and re.fullmatch(r'[\w\s#.,()%/-]+', content) and not re.search(r'url|expression|javascript', content, re.I):
|
|
34
|
+
result.append(f'{name}:{content}')
|
|
35
|
+
return ';'.join(result)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class SemanticHTML(HTMLParser):
|
|
39
|
+
def __init__(self):
|
|
40
|
+
super().__init__(convert_charrefs=True)
|
|
41
|
+
self.output = []
|
|
42
|
+
self.stack = []
|
|
43
|
+
|
|
44
|
+
def handle_starttag(self, tag, attrs):
|
|
45
|
+
if tag not in SCHEMA['tags']:
|
|
46
|
+
return
|
|
47
|
+
values = {}
|
|
48
|
+
for name, value in attrs:
|
|
49
|
+
if name == 'style':
|
|
50
|
+
clean = safe_style(value or '')
|
|
51
|
+
if clean:
|
|
52
|
+
values[name] = clean
|
|
53
|
+
elif name in SCHEMA['attributes']:
|
|
54
|
+
if name in ('href', 'src'):
|
|
55
|
+
value = safe_url(value or '')
|
|
56
|
+
if value is None:
|
|
57
|
+
continue
|
|
58
|
+
values[name] = value
|
|
59
|
+
if tag == 'input':
|
|
60
|
+
values.update(type='checkbox', disabled=None)
|
|
61
|
+
if tag == 'button':
|
|
62
|
+
values['type'] = 'button'
|
|
63
|
+
if tag == 'a' and values.get('target') == '_blank':
|
|
64
|
+
values['rel'] = 'noopener noreferrer'
|
|
65
|
+
attributes = ''.join(f' {name}' if value is None else f' {name}="{html.escape(value, quote=True)}"' for name, value in values.items())
|
|
66
|
+
self.output.append(f'<{tag}{attributes}>')
|
|
67
|
+
if tag not in SCHEMA['voidTags']:
|
|
68
|
+
self.stack.append(tag)
|
|
69
|
+
|
|
70
|
+
def handle_startendtag(self, tag, attrs):
|
|
71
|
+
self.handle_starttag(tag, attrs)
|
|
72
|
+
self.handle_endtag(tag)
|
|
73
|
+
|
|
74
|
+
def handle_endtag(self, tag):
|
|
75
|
+
if tag in self.stack:
|
|
76
|
+
while self.stack:
|
|
77
|
+
opened = self.stack.pop()
|
|
78
|
+
self.output.append(f'</{opened}>')
|
|
79
|
+
if opened == tag:
|
|
80
|
+
break
|
|
81
|
+
|
|
82
|
+
def handle_data(self, data):
|
|
83
|
+
self.output.append(html.escape(data))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def sanitize_html(value: str) -> str:
|
|
87
|
+
parser = SemanticHTML()
|
|
88
|
+
parser.feed(value)
|
|
89
|
+
parser.close()
|
|
90
|
+
for tag in reversed(parser.stack):
|
|
91
|
+
parser.output.append(f'</{tag}>')
|
|
92
|
+
return ''.join(parser.output)
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import html
|
|
2
|
+
from collections.abc import Callable, Mapping
|
|
3
|
+
|
|
4
|
+
from markdown_it import MarkdownIt
|
|
5
|
+
from mdit_py_plugins.tasklists import tasklists_plugin
|
|
6
|
+
from pygments import highlight
|
|
7
|
+
from pygments.formatters import HtmlFormatter
|
|
8
|
+
from pygments.lexers import TextLexer, get_lexer_by_name
|
|
9
|
+
from pygments.token import Text
|
|
10
|
+
from pygments.util import ClassNotFound
|
|
11
|
+
|
|
12
|
+
from .components import Component, extract_components
|
|
13
|
+
from .options import Options, dimension
|
|
14
|
+
from .policy import SCHEMA, safe_url, sanitize_html
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Renderable:
|
|
18
|
+
def __html__(self):
|
|
19
|
+
return self.render()
|
|
20
|
+
|
|
21
|
+
def __ft__(self):
|
|
22
|
+
from fastcore.xml import NotStr
|
|
23
|
+
return NotStr(self.render())
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def identity(value):
|
|
27
|
+
return f' id="{html.escape(value, quote=True)}"' if value else ''
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class AnimatedText(Renderable):
|
|
31
|
+
def __init__(self, content: str = '', *, id: str | None = None, **options):
|
|
32
|
+
self.content, self.id = content, id
|
|
33
|
+
self.options = Options(**options)
|
|
34
|
+
|
|
35
|
+
def render(self) -> str:
|
|
36
|
+
return f'<span class="ft-text" data-flowtoken="text" {self.options.attributes()}{identity(self.id)}>{html.escape(self.content)}</span>'
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class AnimatedMarkdown(Renderable):
|
|
40
|
+
def __init__(self, content: str = '', *, id: str | None = None, code_style: str = 'default',
|
|
41
|
+
img_height: str = '20rem', img_width: str | None = None, object_fit: str = 'contain',
|
|
42
|
+
custom_components: Mapping[str, Callable[[Component], str]] | None = None,
|
|
43
|
+
trusted_html: bool = False, **options):
|
|
44
|
+
for key, value in SCHEMA['markdownDefaults'].items():
|
|
45
|
+
argument = {'duration':'animation_duration','easing':'animation_timing_function'}.get(key, key)
|
|
46
|
+
options.setdefault(argument, value)
|
|
47
|
+
self.options = Options(**options)
|
|
48
|
+
self.content, self.id = content, id
|
|
49
|
+
self.formatter = HtmlFormatter(style=code_style, nowrap=True, noclasses=True)
|
|
50
|
+
self.code_style = code_style
|
|
51
|
+
self.img_height, self.img_width = dimension(img_height), dimension(img_width)
|
|
52
|
+
if object_fit not in ('cover','contain','fill','none','scale-down'):
|
|
53
|
+
raise ValueError('Invalid image fit')
|
|
54
|
+
self.object_fit, self.custom_components, self.trusted_html = object_fit, custom_components or {}, trusted_html
|
|
55
|
+
|
|
56
|
+
def render(self) -> str:
|
|
57
|
+
source, components = extract_components(self.content, self.custom_components)
|
|
58
|
+
engine = MarkdownIt('commonmark', {'html': self.trusted_html, 'linkify':True}).enable(['table','strikethrough','linkify'])
|
|
59
|
+
engine.use(tasklists_plugin)
|
|
60
|
+
|
|
61
|
+
def render_fence(tokens, index, options, env):
|
|
62
|
+
token = tokens[index]
|
|
63
|
+
language = token.info.strip().split(maxsplit=1)[0] if token.info.strip() else ''
|
|
64
|
+
try:
|
|
65
|
+
lexer = get_lexer_by_name(language, stripnl=False, ensurenl=False) if language else TextLexer(stripnl=False, ensurenl=False)
|
|
66
|
+
except ClassNotFound:
|
|
67
|
+
lexer = TextLexer(stripnl=False, ensurenl=False)
|
|
68
|
+
body = highlight(token.content, lexer, self.formatter)
|
|
69
|
+
language_class = f' class="language-{html.escape(language, quote=True)}"' if language else ''
|
|
70
|
+
background = html.escape(self.formatter.style.background_color, quote=True)
|
|
71
|
+
foreground = self.formatter.style.style_for_token(Text)['color'] or '000000'
|
|
72
|
+
return f'<div class="ft-code" data-ft-code data-ft-source="{html.escape(token.content, quote=True)}" data-ft-theme="{html.escape(self.code_style, quote=True)}" style="background-color:{background};color:#{foreground}"><button type="button" data-ft-copy aria-label="Copy code">Copy code</button><pre><code{language_class}>{body}</code></pre></div>\n'
|
|
73
|
+
|
|
74
|
+
def render_image(tokens, index, options, env):
|
|
75
|
+
token = tokens[index]
|
|
76
|
+
source = token.attrGet('src') or ''
|
|
77
|
+
if safe_url(source) is None:
|
|
78
|
+
return html.escape(token.content)
|
|
79
|
+
image = AnimatedImage(source, token.content, height=self.img_height, width=self.img_width, object_fit=self.object_fit, animation=self.options.animation, animation_duration=self.options.animation_duration, animation_timing_function=self.options.animation_timing_function)
|
|
80
|
+
return image.render()
|
|
81
|
+
|
|
82
|
+
def render_link(tokens, index, options, env):
|
|
83
|
+
token = tokens[index]
|
|
84
|
+
target = safe_url(token.attrGet('href') or '')
|
|
85
|
+
if target is None:
|
|
86
|
+
token.attrs.pop('href', None)
|
|
87
|
+
token.attrSet('target', '_blank')
|
|
88
|
+
token.attrSet('rel', 'noopener noreferrer')
|
|
89
|
+
return engine.renderer.renderToken(tokens, index, options, env)
|
|
90
|
+
|
|
91
|
+
engine.renderer.rules.update(fence=render_fence, code_block=render_fence, image=render_image, link_open=render_link)
|
|
92
|
+
rendered = engine.render(source)
|
|
93
|
+
for marker, component in components.items():
|
|
94
|
+
rendered = rendered.replace(f"<p>{marker}</p>", component).replace(marker, component)
|
|
95
|
+
body = sanitize_html(rendered)
|
|
96
|
+
return f'<div class="ft-markdown" data-flowtoken="markdown" {self.options.attributes()}{identity(self.id)}>{body}</div>'
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class AnimatedImage(Renderable):
|
|
100
|
+
def __init__(self, src: str, alt: str = '', *, height: str | None = '20rem', width: str | None = None,
|
|
101
|
+
object_fit: str = 'contain', animation: str | None = 'fadeIn',
|
|
102
|
+
animation_duration: str = '0.25s', animation_timing_function: str = 'ease-out'):
|
|
103
|
+
if safe_url(src) is None:
|
|
104
|
+
raise ValueError('Unsafe image source')
|
|
105
|
+
if object_fit not in ('cover','contain','fill','none','scale-down'):
|
|
106
|
+
raise ValueError('Invalid image fit')
|
|
107
|
+
self.src, self.alt = src, alt
|
|
108
|
+
self.height, self.width, self.object_fit = dimension(height), dimension(width), object_fit
|
|
109
|
+
self.options = Options(animation=animation, animation_duration=animation_duration,
|
|
110
|
+
animation_timing_function=animation_timing_function)
|
|
111
|
+
|
|
112
|
+
def render(self):
|
|
113
|
+
sizing = ';'.join(f'{key}:{value}' for key, value in [('height',self.height),('width',self.width),('max-width','100%'),('object-fit',self.object_fit)] if value)
|
|
114
|
+
return f'<img class="ft-image" data-ft-image {self.options.attributes()} src="{html.escape(self.src, quote=True)}" alt="{html.escape(self.alt, quote=True)}" style="{sizing}">'
|
py_flowtoken/schema.json
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
{
|
|
2
|
+
"animations": [
|
|
3
|
+
"fadeIn",
|
|
4
|
+
"slideUp",
|
|
5
|
+
"wave",
|
|
6
|
+
"elastic",
|
|
7
|
+
"bounceIn",
|
|
8
|
+
"rotateIn",
|
|
9
|
+
"colorTransition",
|
|
10
|
+
"fadeAndScale",
|
|
11
|
+
"slideInFromLeft",
|
|
12
|
+
"blurIn",
|
|
13
|
+
"typewriter",
|
|
14
|
+
"highlight",
|
|
15
|
+
"blurAndSharpen",
|
|
16
|
+
"dropIn"
|
|
17
|
+
],
|
|
18
|
+
"separators": [
|
|
19
|
+
"word",
|
|
20
|
+
"char",
|
|
21
|
+
"diff"
|
|
22
|
+
],
|
|
23
|
+
"defaults": {
|
|
24
|
+
"sep": "word",
|
|
25
|
+
"animation": "fadeIn",
|
|
26
|
+
"duration": "0.25s",
|
|
27
|
+
"easing": "ease-out",
|
|
28
|
+
"interval": 35,
|
|
29
|
+
"animateInitial": true
|
|
30
|
+
},
|
|
31
|
+
"markdownDefaults": {
|
|
32
|
+
"sep": "diff",
|
|
33
|
+
"duration": "1s",
|
|
34
|
+
"easing": "ease-in-out"
|
|
35
|
+
},
|
|
36
|
+
"tags": [
|
|
37
|
+
"div",
|
|
38
|
+
"p",
|
|
39
|
+
"span",
|
|
40
|
+
"h1",
|
|
41
|
+
"h2",
|
|
42
|
+
"h3",
|
|
43
|
+
"h4",
|
|
44
|
+
"h5",
|
|
45
|
+
"h6",
|
|
46
|
+
"strong",
|
|
47
|
+
"em",
|
|
48
|
+
"s",
|
|
49
|
+
"del",
|
|
50
|
+
"blockquote",
|
|
51
|
+
"ul",
|
|
52
|
+
"ol",
|
|
53
|
+
"li",
|
|
54
|
+
"a",
|
|
55
|
+
"img",
|
|
56
|
+
"pre",
|
|
57
|
+
"code",
|
|
58
|
+
"hr",
|
|
59
|
+
"br",
|
|
60
|
+
"table",
|
|
61
|
+
"thead",
|
|
62
|
+
"tbody",
|
|
63
|
+
"tr",
|
|
64
|
+
"th",
|
|
65
|
+
"td",
|
|
66
|
+
"input",
|
|
67
|
+
"button",
|
|
68
|
+
"mark",
|
|
69
|
+
"section",
|
|
70
|
+
"article",
|
|
71
|
+
"aside",
|
|
72
|
+
"figure",
|
|
73
|
+
"figcaption"
|
|
74
|
+
],
|
|
75
|
+
"attributes": [
|
|
76
|
+
"id",
|
|
77
|
+
"class",
|
|
78
|
+
"title",
|
|
79
|
+
"href",
|
|
80
|
+
"src",
|
|
81
|
+
"alt",
|
|
82
|
+
"width",
|
|
83
|
+
"height",
|
|
84
|
+
"start",
|
|
85
|
+
"type",
|
|
86
|
+
"checked",
|
|
87
|
+
"disabled",
|
|
88
|
+
"target",
|
|
89
|
+
"rel",
|
|
90
|
+
"colspan",
|
|
91
|
+
"rowspan",
|
|
92
|
+
"aria-label",
|
|
93
|
+
"data-ft-key",
|
|
94
|
+
"data-ft-code",
|
|
95
|
+
"data-ft-copy",
|
|
96
|
+
"data-ft-image",
|
|
97
|
+
"data-ft-state",
|
|
98
|
+
"data-ft-theme",
|
|
99
|
+
"data-ft-source",
|
|
100
|
+
"data-animation",
|
|
101
|
+
"data-duration",
|
|
102
|
+
"data-easing",
|
|
103
|
+
"data-interval",
|
|
104
|
+
"data-sep",
|
|
105
|
+
"data-animate-initial"
|
|
106
|
+
],
|
|
107
|
+
"styles": [
|
|
108
|
+
"text-align",
|
|
109
|
+
"color",
|
|
110
|
+
"background",
|
|
111
|
+
"background-color",
|
|
112
|
+
"font-weight",
|
|
113
|
+
"font-style",
|
|
114
|
+
"text-decoration",
|
|
115
|
+
"height",
|
|
116
|
+
"width",
|
|
117
|
+
"max-width",
|
|
118
|
+
"object-fit"
|
|
119
|
+
],
|
|
120
|
+
"protocols": [
|
|
121
|
+
"http",
|
|
122
|
+
"https",
|
|
123
|
+
"mailto"
|
|
124
|
+
],
|
|
125
|
+
"voidTags": [
|
|
126
|
+
"img",
|
|
127
|
+
"hr",
|
|
128
|
+
"br",
|
|
129
|
+
"input"
|
|
130
|
+
]
|
|
131
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
.ft-text,.ft-markdown{white-space:pre-wrap;overflow-wrap:anywhere}
|
|
2
|
+
.ft-markdown{white-space:normal}
|
|
3
|
+
.ft-leaf,.ft-chunk{display:contents}
|
|
4
|
+
.ft-token{display:inline-block;max-width:100%;overflow-wrap:anywhere;animation-fill-mode:both}
|
|
5
|
+
.ft-markdown pre{white-space:pre;overflow:auto;margin:0;padding:1rem}
|
|
6
|
+
.ft-markdown pre .ft-token{white-space:pre;overflow-wrap:normal}
|
|
7
|
+
.ft-code{position:relative;border:1px solid #ddd;border-radius:.4rem;overflow:hidden;margin:1rem 0}
|
|
8
|
+
.ft-code [data-ft-copy]{position:absolute;right:.5rem;top:.5rem;z-index:1;border:1px solid #aaa;border-radius:.3rem;padding:.25rem .5rem;background:#fff;color:#222;cursor:pointer}
|
|
9
|
+
.ft-image{max-width:100%;object-fit:contain}
|
|
10
|
+
.ft-image[data-ft-state=pending]{opacity:0}
|
|
11
|
+
.ft-image[data-ft-state=error]{opacity:1}
|
|
12
|
+
@keyframes ft-fadeIn{from{opacity:0}to{opacity:1}}
|
|
13
|
+
@keyframes ft-slideUp{from{opacity:0;transform:translateY(10%)}to{opacity:1;transform:translateY(0)}}
|
|
14
|
+
@keyframes ft-wave{0%,100%{transform:translateY(0)}50%{transform:translateY(-10%)}}
|
|
15
|
+
@keyframes ft-elastic{0%,100%{transform:scale(1)}10%{transform:scale(1.2)}}
|
|
16
|
+
@keyframes ft-bounceIn{0%,40%,80%,100%{transform:translateY(0)}20%{transform:translateY(-10%)}60%{transform:translateY(-5%)}}
|
|
17
|
+
@keyframes ft-rotateIn{from{opacity:0;transform:rotate(-360deg)}to{opacity:1;transform:rotate(0)}}
|
|
18
|
+
@keyframes ft-colorTransition{from{color:red}to{color:black}}
|
|
19
|
+
@keyframes ft-fadeAndScale{from{opacity:0;transform:scale(.5)}to{opacity:1;transform:scale(1)}}
|
|
20
|
+
@keyframes ft-slideInFromLeft{from{opacity:0;transform:translateX(-100%)}to{opacity:1;transform:translateX(0%)}}
|
|
21
|
+
@keyframes ft-blurIn{from{opacity:0;filter:blur(5px)}to{opacity:1;filter:blur(0)}}
|
|
22
|
+
@keyframes ft-typewriter{from{clip-path:inset(0 100% 0 0)}to{clip-path:inset(0)}}
|
|
23
|
+
@keyframes ft-highlight{from{background-color:yellow}to{background-color:transparent}}
|
|
24
|
+
@keyframes ft-blurAndSharpen{from{filter:blur(5px);opacity:0}to{filter:blur(0);opacity:1}}
|
|
25
|
+
@keyframes ft-dropIn{from{opacity:0;transform:translateY(-10%)}to{opacity:1;transform:translateY(0)}}
|
|
26
|
+
@media(prefers-reduced-motion:reduce){.ft-token,.ft-image{animation:none!important;transform:none!important;filter:none!important}.ft-image[data-ft-state=pending]{opacity:0}}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
var o={animations:["fadeIn","slideUp","wave","elastic","bounceIn","rotateIn","colorTransition","fadeAndScale","slideInFromLeft","blurIn","typewriter","highlight","blurAndSharpen","dropIn"],separators:["word","char","diff"],defaults:{sep:"word",animation:"fadeIn",duration:"0.25s",easing:"ease-out",interval:35,animateInitial:!0},markdownDefaults:{sep:"diff",duration:"1s",easing:"ease-in-out"},tags:["div","p","span","h1","h2","h3","h4","h5","h6","strong","em","s","del","blockquote","ul","ol","li","a","img","pre","code","hr","br","table","thead","tbody","tr","th","td","input","button","mark","section","article","aside","figure","figcaption"],attributes:["id","class","title","href","src","alt","width","height","start","type","checked","disabled","target","rel","colspan","rowspan","aria-label","data-ft-key","data-ft-code","data-ft-copy","data-ft-image","data-ft-state","data-ft-theme","data-ft-source","data-animation","data-duration","data-easing","data-interval","data-sep","data-animate-initial"],styles:["text-align","color","background","background-color","font-weight","font-style","text-decoration","height","width","max-width","object-fit"],protocols:["http","https","mailto"],voidTags:["img","hr","br","input"]};var N=Object.fromEntries(o.animations.map(i=>[i,`ft-${i}`]));function l(i,t="word"){if(t==="word")return i.split(/(\s+)/u).filter(Boolean);if(t==="char")return Array.from(i);if(t==="diff")return i?[i]:[];throw new Error("Invalid separator")}function d(i,t={}){let s=i.dataset,e={...o.defaults,sep:s.sep??o.defaults.sep,animation:s.animation??o.defaults.animation,duration:s.duration??o.defaults.duration,easing:s.easing??o.defaults.easing,interval:Number(s.interval??o.defaults.interval),animateInitial:s.animateInitial!=="false",reducedMotion:matchMedia("(prefers-reduced-motion: reduce)").matches,...t};if(!o.separators.includes(e.sep))throw new Error("Invalid separator");if(!Number.isFinite(e.interval)||e.interval<0||e.interval>1e3)throw new Error("Invalid interval");if(typeof e.animateInitial!="boolean")throw new Error("Invalid animateInitial");if(e.animation!==null&&!/^[a-zA-Z_][\w-]*$/.test(e.animation))throw new Error("Invalid animation");if(!/^(?:\d+(?:\.\d+)?|\.\d+)(?:ms|s)$/.test(e.duration))throw new Error("Invalid duration");if(!CSS.supports("animation-timing-function",e.easing))throw new Error("Invalid easing");return e}function f(i,t){t.reducedMotion||!t.animation||t.animation==="none"||(i.style.animationName=N[t.animation]||t.animation,i.style.animationDuration=t.duration,i.style.animationTimingFunction=t.easing,i.style.animationIterationCount="1",i.style.animationFillMode="both",i.addEventListener("animationend",()=>{i.style.animationName="none"},{once:!0}))}var c=class{constructor(t,s,e=()=>{}){this.element=t,this.options=s,this.notify=e,this.target="",this.parts=[],this.nodes=[],this.frame=null,this.lastPaint=0,this.disposed=!1,this.waiters=[]}seed(t,s=!0){if(s){this.setText(t);return}this.target=t,this.parts=l(t,this.options.sep),this.flush(!1)}configure(t){let s=this.options.sep!==t.sep;if(this.options=t,s&&(this.parts=l(this.target,t.sep),this.nodes=[],this.element.replaceChildren(),this.flush(!1)),t.reducedMotion||!t.animation||t.animation==="none")for(let e of this.element.querySelectorAll(".ft-token"))e.style.animationName="none";this.immediate&&this.flush()}get immediate(){return this.options.reducedMotion||!this.options.animation||this.options.animation==="none"||this.options.interval===0}setText(t){if(this.check(t),t===this.target)return;if(t.startsWith(this.target)){this.append(t.slice(this.target.length));return}let s=l(t,this.options.sep);this.notify("before-render");let e=0;for(;e<this.nodes.length&&e<s.length&&this.nodes[e].textContent===s[e];)e++;for(let r of this.nodes.splice(e))r.remove();this.target=t,this.parts=s,this.notify("render"),this.start()}append(t){if(this.check(t),!!t){if(this.notify("before-render"),this.target+=t,this.options.sep==="diff")this.parts.push(t);else{let s=Math.max(0,this.parts.length-1),e=(this.parts[s]||"")+t,r=l(e,this.options.sep);this.parts.length=s;for(let n of r)this.parts.push(n);if(s<this.nodes.length){let n=this.nodes[s];if(r[0]&&r[0].startsWith(n.textContent)&&this.isWhitespace(r[0])===this.isWhitespace(n.textContent)){n.textContent=r[0];for(let a of this.nodes.splice(s+1))a.remove()}else for(let a of this.nodes.splice(s))a.remove()}}this.notify("render"),this.start()}}check(t){if(this.disposed)throw new Error("FlowToken has been destroyed");if(typeof t!="string")throw new TypeError("Expected text")}isWhitespace(t){return/^\s+$/u.test(t)}makeNode(t,s){if(this.isWhitespace(t))return document.createTextNode(t);if(this.options.sep!=="diff"){let r=document.createElement("span");return r.className="ft-token",r.textContent=t,s&&f(r,this.options),r}let e=document.createElement("span");e.className="ft-chunk",e.dataset.ftChunk="";for(let r of l(t,"word"))if(this.isWhitespace(r))e.append(document.createTextNode(r));else{let n=document.createElement("span");n.className="ft-token",n.textContent=r,s&&f(n,this.options),e.append(n)}return e}reveal(t){this.notify("before-render");let s=!1;for(;this.nodes.length<this.parts.length;){let e=this.parts[this.nodes.length];if(s&&!this.isWhitespace(e))break;let r=this.makeNode(e,t);this.element.append(r),this.nodes.push(r),this.isWhitespace(e)||(s=!0)}this.notify("render")}start(){if(this.immediate){this.flush(!this.options.reducedMotion);return}if(this.frame===null){if(this.nodes.length===this.parts.length){this.complete();return}this.frame=requestAnimationFrame(t=>{this.frame=null,!this.disposed&&((!this.lastPaint||t-this.lastPaint>=this.options.interval)&&(this.reveal(!0),this.lastPaint=t),this.start())})}}flush(t=!1){for(this.frame!==null&&cancelAnimationFrame(this.frame),this.frame=null;!this.disposed&&this.nodes.length<this.parts.length;)this.reveal(t);this.complete()}complete(){for(let t of this.waiters.splice(0))t();this.notify("idle")}whenIdle(){return this.disposed||this.nodes.length===this.parts.length?Promise.resolve():new Promise(t=>this.waiters.push(t))}destroy(){this.frame!==null&&cancelAnimationFrame(this.frame),this.frame=null,this.disposed=!0,this.complete()}};function C(i){try{if(i=i.trim(),/[\u0000-\u0020\u007f]/u.test(i))return null;let t=new URL(i,"https://flowtoken.invalid/");return o.protocols.includes(t.protocol.slice(0,-1))?i:null}catch{return null}}function I(i){return i.split(";").flatMap(t=>{let s=t.indexOf(":");if(s<0)return[];let e=t.slice(0,s).trim().toLowerCase(),r=t.slice(s+1).trim();return!o.styles.includes(e)||/url|expression|javascript/i.test(r)||!/^[\w\s#.,()%/-]+$/.test(r)?[]:[`${e}:${r}`]}).join(";")}function E(i){let t=new DOMParser().parseFromString(i,"text/html"),s=t.querySelector('[data-flowtoken="markdown"]');if(!s)throw new Error("Expected AnimatedMarkdown.render() output");for(let e of[...s.querySelectorAll("*")]){if(e.classList.contains("ft-token")||e.hasAttribute("data-ft-token")){e.replaceWith(t.createTextNode(e.textContent));continue}if(!o.tags.includes(e.tagName.toLowerCase())){e.replaceWith(t.createTextNode(e.textContent));continue}for(let r of[...e.attributes])if(r.name==="style"){let n=I(r.value);n?e.setAttribute("style",n):e.removeAttribute("style")}else o.attributes.includes(r.name)?["href","src"].includes(r.name)&&C(r.value)===null&&e.removeAttribute(r.name):e.removeAttribute(r.name);e.tagName==="INPUT"&&(e.type="checkbox",e.disabled=!0),e.tagName==="BUTTON"&&(e.type="button"),e.tagName==="A"&&e.target==="_blank"&&(e.rel="noopener noreferrer")}return s.normalize(),s}function b(i){return i.nodeType!==Node.ELEMENT_NODE?null:i.getAttribute("data-ft-key")||i.id||(i.tagName==="IMG"?`image:${i.getAttribute("src")}`:null)}function w(i,t,s){return t.nodeType===Node.TEXT_NODE?s.has(i):!s.has(i)&&i?.nodeType===t.nodeType&&i.nodeName===t.nodeName&&b(i)===b(t)}function x(i,t,s){let e=i.firstChild;for(let r of[...t.childNodes]){let n=e,a=b(r);if(a&&!w(n,r,s.leaves)&&(n=[...i.childNodes].find(v=>b(v)===a&&w(v,r,s.leaves))||e),n&&w(n,r,s.leaves))n!==e&&i.insertBefore(n,e),r.nodeType===Node.TEXT_NODE?s.leaves.get(n).setText(r.textContent):(s.attributes(n,r),n.hasAttribute("data-ft-copy")||x(n,r,s),s.behaviors.bind(n)),e=n.nextSibling;else{let v=s.hydrate(r);i.insertBefore(v,e)}}for(;e;){let r=e.nextSibling;s.remove(e),e.remove(),e=r}}var u=class{constructor(t){this.options=t,this.bound=new Map}bind(t){t.hasAttribute("data-ft-copy")&&this.copy(t),t.tagName==="IMG"&&this.image(t)}copy(t){if(this.bound.has(t))return;let s={controller:new AbortController,timer:null};this.bound.set(t,s),t.addEventListener("click",async()=>{let e=t.closest("[data-ft-code]");try{if(await navigator.clipboard.writeText(e?.dataset.ftSource??e?.querySelector("code")?.textContent??""),s.controller.signal.aborted)return;t.textContent="Copied!",t.setAttribute("aria-label","Copied!")}catch{if(s.controller.signal.aborted)return;t.textContent="Copy failed",t.setAttribute("aria-label","Copy failed; try again")}clearTimeout(s.timer),s.timer=setTimeout(()=>{t.textContent="Copy code",t.setAttribute("aria-label","Copy code")},2e3)},{signal:s.controller.signal})}image(t){let s=t.getAttribute("src");if(this.bound.get(t)?.source===s)return;this.release(t);let r={source:s,controller:new AbortController};this.bound.set(t,r),t.dataset.ftState="pending";let n=()=>{t.dataset.ftState="loaded",f(t,d(t,this.options))},a=()=>{t.dataset.ftState="error",t.style.animationName="none"};t.addEventListener("load",n,{once:!0,signal:r.controller.signal}),t.addEventListener("error",a,{once:!0,signal:r.controller.signal}),t.complete&&(t.naturalWidth?n():a())}release(t){let s=this.bound.get(t);s&&(s.controller.abort(),clearTimeout(s.timer),this.bound.delete(t))}releaseTree(t){this.release(t);for(let s of t.querySelectorAll?.("*")||[])this.release(s)}destroy(){for(let t of[...this.bound.keys()])this.release(t)}};var p=class{constructor(t,s){this.element=t,this.overrides={...s},this.options=d(t,s),this.disposed=!1}event(t){t==="before-render"&&this.element.setAttribute("aria-busy","true"),t==="idle"&&this.element.setAttribute("aria-busy","false"),this.element.dispatchEvent(new Event(`flowtoken:${t}`))}check(){if(this.disposed)throw new Error("FlowToken has been destroyed")}configure(t){this.check();let s={...this.overrides,...t},e=d(this.element,s);this.overrides=s,this.options=e}finishDestroy(){this.disposed=!0,this.element.setAttribute("aria-busy","false"),this.element.dispatchEvent(new Event("flowtoken:destroy"))}},m=class extends p{constructor(t,s={}){super(t,s);let e=t.textContent||"";t.replaceChildren(),this.renderer=new c(t,this.options,r=>this.event(r)),this.renderer.seed(e,this.options.animateInitial)}get text(){return this.renderer.target}setText(t){this.check(),this.renderer.setText(t)}append(t){this.check(),this.renderer.append(t)}flush(){this.check(),this.renderer.flush()}whenIdle(){return this.renderer.whenIdle()}configure(t){super.configure(t),this.renderer.configure(this.options)}destroy(){this.disposed||(this.renderer.destroy(),this.finishDestroy())}},y=class extends p{constructor(t,s={}){super(t,s),this.leaves=new Map,this.behaviors=new u(this.options);let e=[...t.childNodes];t.replaceChildren(...e.map(r=>this.hydrate(r))),this.settle()}hydrate(t){if(t.nodeType===Node.TEXT_NODE){let e=document.createElement("span");e.className="ft-leaf";let r=new c(e,this.options,n=>{n!=="idle"?this.event(n):this.settle()});return this.leaves.set(e,r),r.seed(t.textContent,this.options.animateInitial),e}if(t.nodeType!==Node.ELEMENT_NODE)return t.cloneNode();let s=t.cloneNode(!1);if(t.hasAttribute("data-ft-copy"))s.textContent=t.textContent;else for(let e of t.childNodes)s.append(this.hydrate(e));return this.behaviors.bind(s),s}attributes(t,s){for(let e of[...t.attributes])e.name!=="style"&&e.name!=="data-ft-state"&&!s.hasAttribute(e.name)&&t.removeAttribute(e.name);for(let e of s.attributes)e.name!=="style"&&t.getAttribute(e.name)!==e.value&&t.setAttribute(e.name,e.value);for(let e of o.styles){let r=s.style.getPropertyValue(e);r?t.style.setProperty(e,r):t.style.removeProperty(e)}}remove(t){let s=[t,...t.querySelectorAll?.(".ft-leaf")||[]];for(let e of s){let r=this.leaves.get(e);r&&(r.destroy(),this.leaves.delete(e))}this.behaviors.releaseTree(t)}setRenderedHTML(t){this.check();let s=E(t);this.options=d(s,this.overrides),this.behaviors.options=this.options;for(let e of this.leaves.values())e.configure(this.options);this.event("before-render"),x(this.element,s,this),this.event("render"),this.settle()}settle(){this.leaves&&[...this.leaves.values()].every(t=>t.nodes.length===t.parts.length)&&this.event("idle")}whenIdle(){return Promise.all([...this.leaves.values()].map(t=>t.whenIdle()))}flush(){this.check();for(let t of this.leaves.values())t.flush();this.settle()}configure(t){super.configure(t),this.behaviors.options=this.options;for(let s of this.leaves.values())s.configure(this.options)}destroy(){if(!this.disposed){for(let t of this.leaves.values())t.destroy();this.leaves.clear(),this.behaviors.destroy(),this.finishDestroy()}}},g=class extends p{constructor(t,s={}){super(t,s),this.behaviors=new u(this.options),this.behaviors.bind(t)}configure(t){super.configure(t),this.behaviors.options=this.options}destroy(){this.disposed||(this.behaviors.destroy(),this.finishDestroy())}};var h=new WeakMap;function T(i,t={}){if(!h.has(i)){let s=i.hasAttribute("data-ft-image")?new g(i,t):i.dataset.flowtoken==="markdown"?new y(i,t):new m(i,t);h.set(i,s),i.addEventListener("flowtoken:destroy",()=>{h.get(i)===s&&h.delete(i)},{once:!0})}return h.get(i)}function H(i){let t=h.get(i);h.delete(i),t?.destroy()}function k(i=document){i.matches?.("[data-flowtoken], [data-ft-image]")&&T(i);for(let t of i.querySelectorAll("[data-flowtoken], [data-ft-image]"))t.hasAttribute("data-ft-image")&&t.closest('[data-flowtoken="markdown"]')||T(t)}typeof document<"u"&&(document.readyState==="loading"?document.addEventListener("DOMContentLoaded",()=>k(),{once:!0}):k(),document.addEventListener("htmx:afterSwap",i=>k(i.detail?.target||i.target)));export{g as FlowImage,y as FlowMarkdown,m as FlowToken,N as animations,T as mount,k as mountAll,l as tokenize,H as unmount};
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import re
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def tokenize(text: str, sep: str = "word") -> list[str]:
|
|
7
|
+
if sep == "word":
|
|
8
|
+
return [part for part in re.split(r"(\s+)", text) if part]
|
|
9
|
+
if sep == "char":
|
|
10
|
+
return list(text)
|
|
11
|
+
if sep == "diff":
|
|
12
|
+
return [text] if text else []
|
|
13
|
+
raise ValueError('sep must be "word", "char", or "diff"')
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class Update:
|
|
18
|
+
text: str
|
|
19
|
+
delta: str
|
|
20
|
+
reset: bool
|
|
21
|
+
revision: int
|
|
22
|
+
|
|
23
|
+
def as_dict(self):
|
|
24
|
+
return {
|
|
25
|
+
"text": self.text,
|
|
26
|
+
"delta": self.delta,
|
|
27
|
+
"reset": self.reset,
|
|
28
|
+
"revision": self.revision,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
def sse(self, event: str = "flowtoken") -> str:
|
|
32
|
+
if not re.fullmatch(r"[\w-]+", event):
|
|
33
|
+
raise ValueError("Invalid SSE event name")
|
|
34
|
+
return f"event: {event}\ndata: {json.dumps(self.as_dict(), ensure_ascii=False)}\n\n"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class TextStream:
|
|
39
|
+
text: str = ""
|
|
40
|
+
revision: int = 0
|
|
41
|
+
chunks: list[str] = field(default_factory=list)
|
|
42
|
+
|
|
43
|
+
def __post_init__(self):
|
|
44
|
+
if self.text and not self.chunks:
|
|
45
|
+
self.chunks = [self.text]
|
|
46
|
+
|
|
47
|
+
def update(self, text: str) -> Update:
|
|
48
|
+
if not isinstance(text, str):
|
|
49
|
+
raise TypeError('Expected text')
|
|
50
|
+
if text == self.text:
|
|
51
|
+
return Update(self.text, '', False, self.revision)
|
|
52
|
+
if text.startswith(self.text):
|
|
53
|
+
return self.append(text[len(self.text):])
|
|
54
|
+
self.text = text
|
|
55
|
+
self.chunks.clear()
|
|
56
|
+
if text:
|
|
57
|
+
self.chunks.append(text)
|
|
58
|
+
self.revision += 1
|
|
59
|
+
return Update(text, text, True, self.revision)
|
|
60
|
+
|
|
61
|
+
def append(self, delta: str) -> Update:
|
|
62
|
+
if not isinstance(delta, str):
|
|
63
|
+
raise TypeError('Expected text delta')
|
|
64
|
+
if delta:
|
|
65
|
+
self.text += delta
|
|
66
|
+
self.chunks.append(delta)
|
|
67
|
+
self.revision += 1
|
|
68
|
+
return Update(self.text, delta, False, self.revision)
|
|
69
|
+
|
|
70
|
+
def tokens(self, sep: str = "diff") -> list[str]:
|
|
71
|
+
return self.chunks.copy() if sep == "diff" else tokenize(self.text, sep)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def encode_event(event: Update | str | bytes) -> str | bytes:
|
|
75
|
+
if isinstance(event, Update):
|
|
76
|
+
return event.sse()
|
|
77
|
+
if isinstance(event, (str, bytes)):
|
|
78
|
+
return event
|
|
79
|
+
raise TypeError("Stream events must be Update, str, or bytes")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def iter_sse(events):
|
|
83
|
+
for event in events:
|
|
84
|
+
yield encode_event(event)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
async def aiter_sse(events):
|
|
88
|
+
async for event in events:
|
|
89
|
+
yield encode_event(event)
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: py-flowtoken
|
|
3
|
+
Version: 0.3.1
|
|
4
|
+
Summary: A Python adaptation of FlowToken for animated text and Markdown streaming without React
|
|
5
|
+
Project-URL: Repository, https://github.com/jcllobet/py-flowtoken
|
|
6
|
+
Project-URL: Documentation, https://github.com/jcllobet/py-flowtoken#readme
|
|
7
|
+
Project-URL: Issues, https://github.com/jcllobet/py-flowtoken/issues
|
|
8
|
+
Project-URL: Upstream, https://github.com/Ephibbs/flowtoken
|
|
9
|
+
Author: py-flowtoken contributors
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
License-File: NOTICE
|
|
13
|
+
Requires-Python: >=3.10
|
|
14
|
+
Requires-Dist: linkify-it-py<3,>=2
|
|
15
|
+
Requires-Dist: markdown-it-py<4,>=3
|
|
16
|
+
Requires-Dist: mdit-py-plugins<0.5,>=0.4
|
|
17
|
+
Requires-Dist: pygments<3,>=2.19
|
|
18
|
+
Provides-Extra: dev
|
|
19
|
+
Requires-Dist: build<2,>=1.2; extra == 'dev'
|
|
20
|
+
Requires-Dist: hatchling<2,>=1.27; extra == 'dev'
|
|
21
|
+
Requires-Dist: pytest<9,>=8; extra == 'dev'
|
|
22
|
+
Provides-Extra: django
|
|
23
|
+
Requires-Dist: django<5.3,>=5.2; extra == 'django'
|
|
24
|
+
Provides-Extra: fasthtml
|
|
25
|
+
Requires-Dist: python-fasthtml>=0.14.17; extra == 'fasthtml'
|
|
26
|
+
Provides-Extra: flask
|
|
27
|
+
Requires-Dist: flask<4,>=3.1; extra == 'flask'
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# py-flowtoken
|
|
31
|
+
|
|
32
|
+
Smooth, word-by-word text and Markdown streaming for **FastHTML, Flask, and Django**. Python 3.10+. No React or Node runtime required.
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
pip install py-flowtoken
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Choose your framework to install its dependencies and follow the setup guide:
|
|
41
|
+
|
|
42
|
+
| Framework | Install | Setup |
|
|
43
|
+
| --- | --- | --- |
|
|
44
|
+
| FastHTML | `pip install "py-flowtoken[fasthtml]"` | [FastHTML guide](https://github.com/jcllobet/py-flowtoken/blob/main/docs/fasthtml.md) |
|
|
45
|
+
| Flask | `pip install "py-flowtoken[flask]"` | [Flask guide](https://github.com/jcllobet/py-flowtoken/blob/main/docs/flask.md) |
|
|
46
|
+
| Django | `pip install "py-flowtoken[django]"` | [Django guide](https://github.com/jcllobet/py-flowtoken/blob/main/docs/django.md) |
|
|
47
|
+
|
|
48
|
+

|
|
49
|
+
|
|
50
|
+
*Demo from [FlowToken](https://github.com/Ephibbs/flowtoken), the original library this package adapts.*
|
|
51
|
+
|
|
52
|
+
## Try it with FastHTML
|
|
53
|
+
|
|
54
|
+
Install the FastHTML extra above, then save this as `demo.py`:
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
from fasthtml.common import FastHTML
|
|
58
|
+
from py_flowtoken import AnimatedText
|
|
59
|
+
from py_flowtoken.adapters.fasthtml import asset_tags, mount_assets
|
|
60
|
+
|
|
61
|
+
app = FastHTML(hdrs=asset_tags())
|
|
62
|
+
mount_assets(app)
|
|
63
|
+
|
|
64
|
+
@app.get('/')
|
|
65
|
+
def home():
|
|
66
|
+
return AnimatedText('Hello from Python, one word at a time.', id='answer')
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Run `uvicorn demo:app --reload` and open **http://127.0.0.1:8000**. The text animates as the page loads.
|
|
70
|
+
|
|
71
|
+
## Stream updates
|
|
72
|
+
|
|
73
|
+
Connect incoming text to the browser renderer:
|
|
74
|
+
|
|
75
|
+
```javascript
|
|
76
|
+
import {mount} from '/flowtoken/flowtoken.js';
|
|
77
|
+
|
|
78
|
+
const view = mount(document.getElementById('answer'));
|
|
79
|
+
view.setText('Hello');
|
|
80
|
+
view.setText('Hello world');
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Use `setText(snapshot)` for the full current text, or `append(chunk)` for new text only. Existing words stay in place; corrections update the changed portion. The [streaming guide](https://github.com/jcllobet/py-flowtoken/blob/main/docs/streaming.md) connects a Python generator to the browser with SSE.
|
|
84
|
+
|
|
85
|
+
## Configure text or Markdown
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
from py_flowtoken import AnimatedMarkdown
|
|
89
|
+
|
|
90
|
+
answer = AnimatedMarkdown(
|
|
91
|
+
'**Hello**, world!',
|
|
92
|
+
id='answer',
|
|
93
|
+
sep='word',
|
|
94
|
+
animation='fadeIn',
|
|
95
|
+
animation_duration='0.25s',
|
|
96
|
+
interval_ms=35,
|
|
97
|
+
code_style='monokai',
|
|
98
|
+
)
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Use `sep='word'` for word-by-word animation, `'char'` for characters, or `'diff'` for appended chunks. Text defaults to word mode; Markdown defaults to diff mode. Set `animation=None` to disable animation. Reduced-motion preferences are respected automatically.
|
|
102
|
+
|
|
103
|
+
Markdown includes tables, task lists, syntax highlighting, code-copy buttons, images, and Python custom tags. See the [API and configuration reference](https://github.com/jcllobet/py-flowtoken/blob/main/docs/api.md) for animation names, image sizing, custom components, and lifecycle methods.
|
|
104
|
+
|
|
105
|
+
[MIT license](https://github.com/jcllobet/py-flowtoken/blob/main/LICENSE). An independent Python adaptation of [Ephibbs/flowtoken](https://github.com/Ephibbs/flowtoken), not an official release from its maintainers.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
py_flowtoken/__init__.py,sha256=VxLo3hcfJses0CCjRah9kCpjUV4hM_2F0RjrskRgxqA,446
|
|
2
|
+
py_flowtoken/assets.py,sha256=1UopjvYj07Cmb5Dhw_MNj2TttlLtraTm5fWGqfGNnPY,499
|
|
3
|
+
py_flowtoken/components.py,sha256=Y8EBDYwyEpDczJVbc9MIv9BOgSslP3xEy1ldA6G6UXs,4515
|
|
4
|
+
py_flowtoken/core.py,sha256=_JlCAlSTW2napG6FsxBT20s71owAYzz1zpy0PO89_Do,394
|
|
5
|
+
py_flowtoken/options.py,sha256=hr5NiAQjSS7GDkjaVZpI83L1jl-Br70TDKdLaAGe1zg,3053
|
|
6
|
+
py_flowtoken/policy.py,sha256=OLg6nXFyHNlUChjCgOuzCLeE3oG1GA_NwRwn3vVmUqI,3148
|
|
7
|
+
py_flowtoken/rendering.py,sha256=30RcVbZ8ut_sTl8bwAlDTghY5P1-wVpJBXgO3-1hCDU,6380
|
|
8
|
+
py_flowtoken/schema.json,sha256=MaxscG1nRc_llzTJitHT_SuQqLNTp0NPR3cxn64INd4,1887
|
|
9
|
+
py_flowtoken/streaming.py,sha256=3hQyKjvEEIZt2n9pFhMSutU3fGtHeKP6053JDzExXWU,2538
|
|
10
|
+
py_flowtoken/adapters/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
11
|
+
py_flowtoken/adapters/fasthtml.py,sha256=PdYRyDEIOjOqW0NKJMeMq_fiZEMEsxNG2Nef9czBdfM,958
|
|
12
|
+
py_flowtoken/adapters/flask.py,sha256=RzLtFdja2dnJOPxpzEPoVX3XHNBg0LyJDhyEEQvV1CM,2644
|
|
13
|
+
py_flowtoken/adapters/django/__init__.py,sha256=UqTSy2-UXsCNo1ffgbkb-fQJG22sFrsmJNioDDFw07Y,60
|
|
14
|
+
py_flowtoken/adapters/django/apps.py,sha256=5e0V8jyTMu5hgpdKy-VaFdu5TzajaUyLRpp31qq6oTY,255
|
|
15
|
+
py_flowtoken/adapters/django/urls.py,sha256=QiqBANoqe7iVurzZOZtY_AVPjEM9CJlCCy8DTkiPvWI,142
|
|
16
|
+
py_flowtoken/adapters/django/views.py,sha256=kVsX8ANBNRaW6TPwpBWNvHmPCPUjETT7rHw9k5Yczy0,974
|
|
17
|
+
py_flowtoken/adapters/django/templatetags/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
18
|
+
py_flowtoken/adapters/django/templatetags/flowtoken.py,sha256=SUW1GvaflzLBjRLy06LjxGpi358xyy4w3bVWGzAOvP0,1410
|
|
19
|
+
py_flowtoken/static/py_flowtoken/flowtoken.css,sha256=Q1AEF87tXfViDXnS3lfJTlEIf8LcFza6bLYa392rSaA,2207
|
|
20
|
+
py_flowtoken/static/py_flowtoken/flowtoken.js,sha256=e36KbCg8VkWLOk9c6ZB_T9FG6NKULiruAyh37zedH2M,14170
|
|
21
|
+
py_flowtoken-0.3.1.dist-info/METADATA,sha256=ujJ7IUyLm8p8QolTrZm-APkEvGFTqt4IFnXkMf3kYQg,4155
|
|
22
|
+
py_flowtoken-0.3.1.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
23
|
+
py_flowtoken-0.3.1.dist-info/licenses/LICENSE,sha256=au4mobkA9c4PK76n_idRfoUBONsdbcNyuowHBo8lA84,1082
|
|
24
|
+
py_flowtoken-0.3.1.dist-info/licenses/NOTICE,sha256=8_wuejU6HAs5Ps_Dn-xkHZfXeiPR-Dxm147lMCSe9Ec,571
|
|
25
|
+
py_flowtoken-0.3.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 py-flowtoken contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
py-flowtoken is an independent Python adaptation of FlowToken by Ephibbs
|
|
2
|
+
and contributors: https://github.com/Ephibbs/flowtoken
|
|
3
|
+
|
|
4
|
+
The named animation effects and their keyframe definitions are adapted from
|
|
5
|
+
FlowToken. The README demonstration GIF comes from the original FlowToken
|
|
6
|
+
README: https://nextjs-omega-five-46.vercel.app/demo.gif
|
|
7
|
+
|
|
8
|
+
Python rendering, framework adapters, streaming state, and the browser
|
|
9
|
+
renderer are implemented in this repository. No upstream React runtime is
|
|
10
|
+
included in the distribution. py-flowtoken is released under the MIT License
|
|
11
|
+
in LICENSE.
|