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.
@@ -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
@@ -0,0 +1,3 @@
1
+ from .views import event_stream
2
+
3
+ __all__ = ["event_stream"]
@@ -0,0 +1,10 @@
1
+ from pathlib import Path
2
+
3
+ from django.apps import AppConfig
4
+
5
+
6
+ class FlowTokenConfig(AppConfig):
7
+ name = "py_flowtoken.adapters.django"
8
+ label = "py_flowtoken"
9
+ verbose_name = "Python FlowToken"
10
+ path = str(Path(__file__).resolve().parents[2])
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,6 @@
1
+ from django.urls import path
2
+
3
+ from .views import asset
4
+
5
+ app_name = "py_flowtoken"
6
+ urlpatterns = [path("<str:filename>", asset, name="asset")]
@@ -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
+ ]
@@ -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}">'
@@ -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
+ ![FlowToken animation demo](https://nextjs-omega-five-46.vercel.app/demo.gif)
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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.