py-flowtoken 0.3.1__tar.gz

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.
Files changed (63) hide show
  1. py_flowtoken-0.3.1/.github/workflows/publish.yml +43 -0
  2. py_flowtoken-0.3.1/.github/workflows/tests.yml +39 -0
  3. py_flowtoken-0.3.1/.gitignore +15 -0
  4. py_flowtoken-0.3.1/LICENSE +21 -0
  5. py_flowtoken-0.3.1/NOTICE +11 -0
  6. py_flowtoken-0.3.1/PKG-INFO +105 -0
  7. py_flowtoken-0.3.1/README.md +76 -0
  8. py_flowtoken-0.3.1/browser/behaviors.js +61 -0
  9. py_flowtoken-0.3.1/browser/dom.js +89 -0
  10. py_flowtoken-0.3.1/browser/index.js +30 -0
  11. py_flowtoken-0.3.1/browser/renderers.js +140 -0
  12. py_flowtoken-0.3.1/browser/settings.js +41 -0
  13. py_flowtoken-0.3.1/browser/text.js +167 -0
  14. py_flowtoken-0.3.1/docs/api.md +120 -0
  15. py_flowtoken-0.3.1/docs/django.md +97 -0
  16. py_flowtoken-0.3.1/docs/fasthtml.md +52 -0
  17. py_flowtoken-0.3.1/docs/flask.md +83 -0
  18. py_flowtoken-0.3.1/docs/publishing.md +26 -0
  19. py_flowtoken-0.3.1/docs/reviews/thermo-nuclear-v0.2.0.md +185 -0
  20. py_flowtoken-0.3.1/docs/reviews/v0.3.0-resolution.md +35 -0
  21. py_flowtoken-0.3.1/docs/streaming.md +51 -0
  22. py_flowtoken-0.3.1/examples/django_demo/demo/__init__.py +0 -0
  23. py_flowtoken-0.3.1/examples/django_demo/demo/settings.py +21 -0
  24. py_flowtoken-0.3.1/examples/django_demo/demo/templates/chat.html +23 -0
  25. py_flowtoken-0.3.1/examples/django_demo/demo/urls.py +9 -0
  26. py_flowtoken-0.3.1/examples/django_demo/demo/views.py +25 -0
  27. py_flowtoken-0.3.1/examples/django_demo/manage.py +7 -0
  28. py_flowtoken-0.3.1/examples/fasthtml_chat.py +48 -0
  29. py_flowtoken-0.3.1/examples/flask_chat.py +34 -0
  30. py_flowtoken-0.3.1/examples/templates/flask_chat.html +21 -0
  31. py_flowtoken-0.3.1/package-lock.json +2745 -0
  32. py_flowtoken-0.3.1/package.json +19 -0
  33. py_flowtoken-0.3.1/pyproject.toml +32 -0
  34. py_flowtoken-0.3.1/src/py_flowtoken/__init__.py +28 -0
  35. py_flowtoken-0.3.1/src/py_flowtoken/adapters/__init__.py +0 -0
  36. py_flowtoken-0.3.1/src/py_flowtoken/adapters/django/__init__.py +3 -0
  37. py_flowtoken-0.3.1/src/py_flowtoken/adapters/django/apps.py +10 -0
  38. py_flowtoken-0.3.1/src/py_flowtoken/adapters/django/templatetags/__init__.py +0 -0
  39. py_flowtoken-0.3.1/src/py_flowtoken/adapters/django/templatetags/flowtoken.py +46 -0
  40. py_flowtoken-0.3.1/src/py_flowtoken/adapters/django/urls.py +6 -0
  41. py_flowtoken-0.3.1/src/py_flowtoken/adapters/django/views.py +29 -0
  42. py_flowtoken-0.3.1/src/py_flowtoken/adapters/fasthtml.py +33 -0
  43. py_flowtoken-0.3.1/src/py_flowtoken/adapters/flask.py +82 -0
  44. py_flowtoken-0.3.1/src/py_flowtoken/assets.py +13 -0
  45. py_flowtoken-0.3.1/src/py_flowtoken/components.py +116 -0
  46. py_flowtoken-0.3.1/src/py_flowtoken/core.py +17 -0
  47. py_flowtoken-0.3.1/src/py_flowtoken/options.py +60 -0
  48. py_flowtoken-0.3.1/src/py_flowtoken/policy.py +92 -0
  49. py_flowtoken-0.3.1/src/py_flowtoken/rendering.py +114 -0
  50. py_flowtoken-0.3.1/src/py_flowtoken/schema.json +131 -0
  51. py_flowtoken-0.3.1/src/py_flowtoken/static/py_flowtoken/flowtoken.css +26 -0
  52. py_flowtoken-0.3.1/src/py_flowtoken/static/py_flowtoken/flowtoken.js +1 -0
  53. py_flowtoken-0.3.1/src/py_flowtoken/streaming.py +89 -0
  54. py_flowtoken-0.3.1/tests/browser/parity.js +65 -0
  55. py_flowtoken-0.3.1/tests/browser/runtime.js +138 -0
  56. py_flowtoken-0.3.1/tests/browser.mjs +93 -0
  57. py_flowtoken-0.3.1/tests/django_urls.py +3 -0
  58. py_flowtoken-0.3.1/tests/fixtures.json +5 -0
  59. py_flowtoken-0.3.1/tests/frameworks.mjs +50 -0
  60. py_flowtoken-0.3.1/tests/oracle.js +12 -0
  61. py_flowtoken-0.3.1/tests/render_fixtures.py +24 -0
  62. py_flowtoken-0.3.1/tests/test_adapters.py +256 -0
  63. py_flowtoken-0.3.1/tests/test_core.py +211 -0
@@ -0,0 +1,43 @@
1
+ name: Publish to PyPI
2
+ on:
3
+ release:
4
+ types: [published]
5
+ workflow_dispatch:
6
+ permissions:
7
+ contents: read
8
+ jobs:
9
+ build:
10
+ if: github.ref_type == 'tag'
11
+ runs-on: ubuntu-latest
12
+ steps:
13
+ - uses: actions/checkout@v4
14
+ - uses: actions/setup-python@v5
15
+ with:
16
+ python-version: '3.12'
17
+ - run: pip install '.[dev,fasthtml,flask,django]' twine
18
+ - name: Check release version
19
+ env:
20
+ RELEASE_TAG: ${{ github.ref_name }}
21
+ run: python -c "import os; from py_flowtoken import __version__; assert os.environ['RELEASE_TAG'] == 'v' + __version__"
22
+ - run: pytest -q
23
+ - run: python -m build --no-isolation
24
+ - run: twine check --strict dist/*
25
+ - uses: actions/upload-artifact@v4
26
+ with:
27
+ name: distributions
28
+ path: dist/*
29
+ if-no-files-found: error
30
+ publish:
31
+ needs: build
32
+ runs-on: ubuntu-latest
33
+ environment:
34
+ name: pypi
35
+ url: https://pypi.org/p/py-flowtoken
36
+ permissions:
37
+ id-token: write
38
+ steps:
39
+ - uses: actions/download-artifact@v4
40
+ with:
41
+ name: distributions
42
+ path: dist/
43
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,39 @@
1
+ name: Test Python port and upstream parity
2
+ on:
3
+ push:
4
+ branches: [main]
5
+ pull_request:
6
+ jobs:
7
+ python:
8
+ runs-on: ubuntu-latest
9
+ strategy:
10
+ matrix:
11
+ python: ['3.10', '3.12']
12
+ steps:
13
+ - uses: actions/checkout@v4
14
+ - uses: actions/setup-python@v5
15
+ with:
16
+ python-version: '${{ matrix.python }}'
17
+ - run: pip install '.[dev,fasthtml,flask,django]'
18
+ - run: pytest -q
19
+ - run: python -m build --no-isolation
20
+ - run: pip install --force-reinstall --no-deps dist/*.whl
21
+ - run: python -c "from py_flowtoken import AnimatedMarkdown, asset_path; assert 'data-ft-copy' in AnimatedMarkdown(' hello').render(); assert asset_path('flowtoken.js').is_file()"
22
+ working-directory: /tmp
23
+ browser:
24
+ runs-on: ubuntu-latest
25
+ env:
26
+ PYTHON: python
27
+ steps:
28
+ - uses: actions/checkout@v4
29
+ - uses: actions/setup-python@v5
30
+ with:
31
+ python-version: '3.12'
32
+ - run: pip install '.[dev,fasthtml,flask,django]'
33
+ - uses: actions/setup-node@v4
34
+ with:
35
+ node-version: '22'
36
+ - run: npm ci --ignore-scripts
37
+ - run: npx playwright install --with-deps chromium
38
+ - run: npm test
39
+ - run: git diff --exit-code -- src/py_flowtoken/static/py_flowtoken/flowtoken.js
@@ -0,0 +1,15 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.py[cod]
4
+ .pytest_cache/
5
+ node_modules/
6
+ dist/
7
+ .local/
8
+ *.egg-info/
9
+ .env
10
+ .env.*
11
+ .DS_Store
12
+
13
+ .sesskey
14
+
15
+ .static/
@@ -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.
@@ -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,76 @@
1
+ # py-flowtoken
2
+
3
+ Smooth, word-by-word text and Markdown streaming for **FastHTML, Flask, and Django**. Python 3.10+. No React or Node runtime required.
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ pip install py-flowtoken
9
+ ```
10
+
11
+ Choose your framework to install its dependencies and follow the setup guide:
12
+
13
+ | Framework | Install | Setup |
14
+ | --- | --- | --- |
15
+ | FastHTML | `pip install "py-flowtoken[fasthtml]"` | [FastHTML guide](https://github.com/jcllobet/py-flowtoken/blob/main/docs/fasthtml.md) |
16
+ | Flask | `pip install "py-flowtoken[flask]"` | [Flask guide](https://github.com/jcllobet/py-flowtoken/blob/main/docs/flask.md) |
17
+ | Django | `pip install "py-flowtoken[django]"` | [Django guide](https://github.com/jcllobet/py-flowtoken/blob/main/docs/django.md) |
18
+
19
+ ![FlowToken animation demo](https://nextjs-omega-five-46.vercel.app/demo.gif)
20
+
21
+ *Demo from [FlowToken](https://github.com/Ephibbs/flowtoken), the original library this package adapts.*
22
+
23
+ ## Try it with FastHTML
24
+
25
+ Install the FastHTML extra above, then save this as `demo.py`:
26
+
27
+ ```python
28
+ from fasthtml.common import FastHTML
29
+ from py_flowtoken import AnimatedText
30
+ from py_flowtoken.adapters.fasthtml import asset_tags, mount_assets
31
+
32
+ app = FastHTML(hdrs=asset_tags())
33
+ mount_assets(app)
34
+
35
+ @app.get('/')
36
+ def home():
37
+ return AnimatedText('Hello from Python, one word at a time.', id='answer')
38
+ ```
39
+
40
+ Run `uvicorn demo:app --reload` and open **http://127.0.0.1:8000**. The text animates as the page loads.
41
+
42
+ ## Stream updates
43
+
44
+ Connect incoming text to the browser renderer:
45
+
46
+ ```javascript
47
+ import {mount} from '/flowtoken/flowtoken.js';
48
+
49
+ const view = mount(document.getElementById('answer'));
50
+ view.setText('Hello');
51
+ view.setText('Hello world');
52
+ ```
53
+
54
+ 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.
55
+
56
+ ## Configure text or Markdown
57
+
58
+ ```python
59
+ from py_flowtoken import AnimatedMarkdown
60
+
61
+ answer = AnimatedMarkdown(
62
+ '**Hello**, world!',
63
+ id='answer',
64
+ sep='word',
65
+ animation='fadeIn',
66
+ animation_duration='0.25s',
67
+ interval_ms=35,
68
+ code_style='monokai',
69
+ )
70
+ ```
71
+
72
+ 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.
73
+
74
+ 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.
75
+
76
+ [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,61 @@
1
+ import {animate, settings} from './settings.js';
2
+
3
+ export class Behaviors {
4
+ constructor(options) {this.options = options; this.bound = new Map();}
5
+
6
+ bind(element) {
7
+ if (element.hasAttribute('data-ft-copy')) this.copy(element);
8
+ if (element.tagName === 'IMG') this.image(element);
9
+ }
10
+
11
+ copy(button) {
12
+ if (this.bound.has(button)) return;
13
+ const state = {controller:new AbortController(),timer:null};
14
+ this.bound.set(button, state);
15
+ button.addEventListener('click', async () => {
16
+ const block = button.closest('[data-ft-code]');
17
+ try {
18
+ await navigator.clipboard.writeText(block?.dataset.ftSource ?? block?.querySelector('code')?.textContent ?? '');
19
+ if (state.controller.signal.aborted) return;
20
+ button.textContent = 'Copied!';button.setAttribute('aria-label','Copied!');
21
+ } catch {
22
+ if (state.controller.signal.aborted) return;
23
+ button.textContent = 'Copy failed';button.setAttribute('aria-label','Copy failed; try again');
24
+ }
25
+ clearTimeout(state.timer);
26
+ state.timer = setTimeout(() => {button.textContent='Copy code';button.setAttribute('aria-label','Copy code');},2000);
27
+ }, {signal:state.controller.signal});
28
+ }
29
+
30
+ image(image) {
31
+ const source = image.getAttribute('src');
32
+ const old = this.bound.get(image);
33
+ if (old?.source === source) return;
34
+ this.release(image);
35
+ const state = {source,controller:new AbortController()};
36
+ this.bound.set(image,state);
37
+ image.dataset.ftState = 'pending';
38
+ const loaded = () => {image.dataset.ftState='loaded';animate(image,settings(image,this.options));};
39
+ const failed = () => {image.dataset.ftState='error';image.style.animationName='none';};
40
+ image.addEventListener('load', loaded, {once:true,signal:state.controller.signal});
41
+ image.addEventListener('error', failed, {once:true,signal:state.controller.signal});
42
+ if (image.complete) {
43
+ if (image.naturalWidth) loaded();
44
+ else failed();
45
+ }
46
+ }
47
+
48
+ release(element) {
49
+ const state = this.bound.get(element);
50
+ if (state) {state.controller.abort();clearTimeout(state.timer);this.bound.delete(element);}
51
+ }
52
+
53
+ releaseTree(element) {
54
+ this.release(element);
55
+ for (const child of element.querySelectorAll?.('*') || []) this.release(child);
56
+ }
57
+
58
+ destroy() {
59
+ for (const element of [...this.bound.keys()]) this.release(element);
60
+ }
61
+ }
@@ -0,0 +1,89 @@
1
+ import {schema} from './settings.js';
2
+
3
+ export function safeURL(value) {
4
+ try {
5
+ value = value.trim();
6
+ if (/[\u0000-\u0020\u007f]/u.test(value)) return null;
7
+ const parsed = new URL(value, 'https://flowtoken.invalid/');
8
+ return schema.protocols.includes(parsed.protocol.slice(0, -1)) ? value : null;
9
+ } catch {return null;}
10
+ }
11
+
12
+ export function safeStyle(value) {
13
+ return value.split(';').flatMap(declaration => {
14
+ const index = declaration.indexOf(':');
15
+ if (index < 0) return [];
16
+ const name = declaration.slice(0, index).trim().toLowerCase();
17
+ const content = declaration.slice(index + 1).trim();
18
+ if (!schema.styles.includes(name) || /url|expression|javascript/i.test(content) || !/^[\w\s#.,()%/-]+$/.test(content)) return [];
19
+ return [`${name}:${content}`];
20
+ }).join(';');
21
+ }
22
+
23
+ export function parseHTML(html) {
24
+ const document = new DOMParser().parseFromString(html, 'text/html');
25
+ const source = document.querySelector('[data-flowtoken="markdown"]');
26
+ if (!source) throw new Error('Expected AnimatedMarkdown.render() output');
27
+ for (const node of [...source.querySelectorAll('*')]) {
28
+ if (node.classList.contains('ft-token') || node.hasAttribute('data-ft-token')) {
29
+ node.replaceWith(document.createTextNode(node.textContent));
30
+ continue;
31
+ }
32
+ if (!schema.tags.includes(node.tagName.toLowerCase())) {
33
+ node.replaceWith(document.createTextNode(node.textContent));
34
+ continue;
35
+ }
36
+ for (const attr of [...node.attributes]) {
37
+ if (attr.name === 'style') {
38
+ const style = safeStyle(attr.value);
39
+ if (style) node.setAttribute('style', style); else node.removeAttribute('style');
40
+ } else if (!schema.attributes.includes(attr.name)) node.removeAttribute(attr.name);
41
+ else if (['href','src'].includes(attr.name) && safeURL(attr.value) === null) node.removeAttribute(attr.name);
42
+ }
43
+ if (node.tagName === 'INPUT') {node.type = 'checkbox'; node.disabled = true;}
44
+ if (node.tagName === 'BUTTON') node.type = 'button';
45
+ if (node.tagName === 'A' && node.target === '_blank') node.rel = 'noopener noreferrer';
46
+ }
47
+ source.normalize();
48
+ return source;
49
+ }
50
+
51
+ function key(node) {
52
+ if (node.nodeType !== Node.ELEMENT_NODE) return null;
53
+ return node.getAttribute('data-ft-key') || node.id || (node.tagName === 'IMG' ? `image:${node.getAttribute('src')}` : null);
54
+ }
55
+
56
+ function matches(current, desired, leaves) {
57
+ if (desired.nodeType === Node.TEXT_NODE) return leaves.has(current);
58
+ return !leaves.has(current) && current?.nodeType === desired.nodeType && current.nodeName === desired.nodeName && key(current) === key(desired);
59
+ }
60
+
61
+ export function reconcile(parent, desiredParent, owner) {
62
+ let cursor = parent.firstChild;
63
+ for (const desired of [...desiredParent.childNodes]) {
64
+ let current = cursor;
65
+ const identity = key(desired);
66
+ if (identity && !matches(current, desired, owner.leaves)) {
67
+ current = [...parent.childNodes].find(node => key(node) === identity && matches(node, desired, owner.leaves)) || cursor;
68
+ }
69
+ if (current && matches(current, desired, owner.leaves)) {
70
+ if (current !== cursor) parent.insertBefore(current, cursor);
71
+ if (desired.nodeType === Node.TEXT_NODE) owner.leaves.get(current).setText(desired.textContent);
72
+ else {
73
+ owner.attributes(current, desired);
74
+ if (!current.hasAttribute('data-ft-copy')) reconcile(current, desired, owner);
75
+ owner.behaviors.bind(current);
76
+ }
77
+ cursor = current.nextSibling;
78
+ } else {
79
+ const fresh = owner.hydrate(desired);
80
+ parent.insertBefore(fresh, cursor);
81
+ }
82
+ }
83
+ while (cursor) {
84
+ const next = cursor.nextSibling;
85
+ owner.remove(cursor);
86
+ cursor.remove();
87
+ cursor = next;
88
+ }
89
+ }
@@ -0,0 +1,30 @@
1
+ export {animations,tokenize} from './settings.js';
2
+ export {FlowToken,FlowMarkdown,FlowImage} from './renderers.js';
3
+ import {FlowToken,FlowMarkdown,FlowImage} from './renderers.js';
4
+
5
+ const mounted=new WeakMap();
6
+ export function mount(element, options={}) {
7
+ if(!mounted.has(element)) {
8
+ const renderer=element.hasAttribute('data-ft-image')?new FlowImage(element,options):element.dataset.flowtoken==='markdown'?new FlowMarkdown(element,options):new FlowToken(element,options);
9
+ mounted.set(element,renderer);
10
+ element.addEventListener('flowtoken:destroy',()=>{if(mounted.get(element)===renderer)mounted.delete(element);},{once:true});
11
+ }
12
+ return mounted.get(element);
13
+ }
14
+ export function unmount(element) {
15
+ const renderer=mounted.get(element);
16
+ mounted.delete(element);
17
+ renderer?.destroy();
18
+ }
19
+ export function mountAll(root=document) {
20
+ if(root.matches?.('[data-flowtoken], [data-ft-image]'))mount(root);
21
+ for(const element of root.querySelectorAll('[data-flowtoken], [data-ft-image]')) {
22
+ if(element.hasAttribute('data-ft-image') && element.closest('[data-flowtoken="markdown"]'))continue;
23
+ mount(element);
24
+ }
25
+ }
26
+ if(typeof document!=='undefined') {
27
+ if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',()=>mountAll(),{once:true});
28
+ else mountAll();
29
+ document.addEventListener('htmx:afterSwap',event=>mountAll(event.detail?.target||event.target));
30
+ }
@@ -0,0 +1,140 @@
1
+ import {settings, schema} from './settings.js';
2
+ import {TextRenderer} from './text.js';
3
+ import {parseHTML,reconcile} from './dom.js';
4
+ import {Behaviors} from './behaviors.js';
5
+
6
+ class Renderer {
7
+ constructor(element, options) {
8
+ this.element=element;
9
+ this.overrides={...options};
10
+ this.options=settings(element,options);
11
+ this.disposed=false;
12
+ }
13
+
14
+ event(name) {
15
+ if (name==='before-render') this.element.setAttribute('aria-busy','true');
16
+ if (name==='idle') this.element.setAttribute('aria-busy','false');
17
+ this.element.dispatchEvent(new Event(`flowtoken:${name}`));
18
+ }
19
+
20
+ check() {if(this.disposed) throw new Error('FlowToken has been destroyed');}
21
+
22
+ configure(options) {
23
+ this.check();
24
+ const overrides={...this.overrides,...options};
25
+ const next=settings(this.element,overrides);
26
+ this.overrides=overrides;this.options=next;
27
+ }
28
+
29
+ finishDestroy() {
30
+ this.disposed=true;
31
+ this.element.setAttribute('aria-busy','false');
32
+ this.element.dispatchEvent(new Event('flowtoken:destroy'));
33
+ }
34
+ }
35
+
36
+ export class FlowToken extends Renderer {
37
+ constructor(element, options={}) {
38
+ super(element, options);
39
+ const initial=element.textContent || '';
40
+ element.replaceChildren();
41
+ this.renderer=new TextRenderer(element,this.options,name=>this.event(name));
42
+ this.renderer.seed(initial,this.options.animateInitial);
43
+ }
44
+
45
+ get text() {return this.renderer.target;}
46
+ setText(text) {this.check();this.renderer.setText(text);}
47
+ append(delta) {this.check();this.renderer.append(delta);}
48
+ flush() {this.check();this.renderer.flush();}
49
+ whenIdle() {return this.renderer.whenIdle();}
50
+ configure(options) {
51
+ super.configure(options);this.renderer.configure(this.options);
52
+ }
53
+ destroy() {if(!this.disposed){this.renderer.destroy();this.finishDestroy();}}
54
+ }
55
+
56
+ export class FlowMarkdown extends Renderer {
57
+ constructor(element, options={}) {
58
+ super(element,options);
59
+ this.leaves=new Map();
60
+ this.behaviors=new Behaviors(this.options);
61
+ const children=[...element.childNodes];
62
+ element.replaceChildren(...children.map(child=>this.hydrate(child)));
63
+ this.settle();
64
+ }
65
+
66
+ hydrate(node) {
67
+ if (node.nodeType===Node.TEXT_NODE) {
68
+ const leaf=document.createElement('span');leaf.className='ft-leaf';
69
+ const renderer=new TextRenderer(leaf,this.options,name=>{if(name!=='idle')this.event(name);else this.settle();});
70
+ this.leaves.set(leaf,renderer);
71
+ renderer.seed(node.textContent,this.options.animateInitial);
72
+ return leaf;
73
+ }
74
+ if (node.nodeType!==Node.ELEMENT_NODE) return node.cloneNode();
75
+ const element=node.cloneNode(false);
76
+ if (node.hasAttribute('data-ft-copy')) element.textContent=node.textContent;
77
+ else for(const child of node.childNodes) element.append(this.hydrate(child));
78
+ this.behaviors.bind(element);
79
+ return element;
80
+ }
81
+
82
+ attributes(current, desired) {
83
+ for(const attr of [...current.attributes]) {
84
+ if(attr.name!=='style'&&attr.name!=='data-ft-state'&&!desired.hasAttribute(attr.name)) current.removeAttribute(attr.name);
85
+ }
86
+ for(const attr of desired.attributes) {
87
+ if(attr.name!=='style'&&current.getAttribute(attr.name)!==attr.value) current.setAttribute(attr.name,attr.value);
88
+ }
89
+ for(const property of schema.styles) {
90
+ const value=desired.style.getPropertyValue(property);
91
+ if(value) current.style.setProperty(property,value);
92
+ else current.style.removeProperty(property);
93
+ }
94
+ }
95
+
96
+ remove(node) {
97
+ const elements=[node,...(node.querySelectorAll?.('.ft-leaf')||[])];
98
+ for(const element of elements) {
99
+ const renderer=this.leaves.get(element);
100
+ if(renderer){renderer.destroy();this.leaves.delete(element);}
101
+ }
102
+ this.behaviors.releaseTree(node);
103
+ }
104
+
105
+ setRenderedHTML(html) {
106
+ this.check();
107
+ const source=parseHTML(html);
108
+ this.options=settings(source,this.overrides);
109
+ this.behaviors.options=this.options;
110
+ for(const renderer of this.leaves.values()) renderer.configure(this.options);
111
+ this.event('before-render');
112
+ reconcile(this.element,source,this);
113
+ this.event('render');
114
+ this.settle();
115
+ }
116
+
117
+ settle() {
118
+ if(this.leaves&&[...this.leaves.values()].every(renderer=>renderer.nodes.length===renderer.parts.length)) this.event('idle');
119
+ }
120
+
121
+ whenIdle() {return Promise.all([...this.leaves.values()].map(renderer=>renderer.whenIdle()));}
122
+ flush() {this.check();for(const renderer of this.leaves.values())renderer.flush();this.settle();}
123
+ configure(options) {
124
+ super.configure(options);
125
+ this.behaviors.options=this.options;for(const renderer of this.leaves.values())renderer.configure(this.options);
126
+ }
127
+ destroy() {
128
+ if(this.disposed)return;
129
+ for(const renderer of this.leaves.values())renderer.destroy();
130
+ this.leaves.clear();this.behaviors.destroy();this.finishDestroy();
131
+ }
132
+ }
133
+
134
+ export class FlowImage extends Renderer {
135
+ constructor(element, options={}) {
136
+ super(element,options);this.behaviors=new Behaviors(this.options);this.behaviors.bind(element);
137
+ }
138
+ configure(options){super.configure(options);this.behaviors.options=this.options;}
139
+ destroy(){if(!this.disposed){this.behaviors.destroy();this.finishDestroy();}}
140
+ }