framejs 0.4.0__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.
- framejs-0.4.0/.gitignore +3 -0
- framejs-0.4.0/LICENSE +21 -0
- framejs-0.4.0/PKG-INFO +141 -0
- framejs-0.4.0/README.md +110 -0
- framejs-0.4.0/pyproject.toml +41 -0
- framejs-0.4.0/src/framejs/__init__.py +3 -0
- framejs-0.4.0/src/framejs/_esm.py +208 -0
- framejs-0.4.0/src/framejs/_widget.py +151 -0
- framejs-0.4.0/tests/test_widget.py +211 -0
framejs-0.4.0/.gitignore
ADDED
framejs-0.4.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Metapages
|
|
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.
|
framejs-0.4.0/PKG-INFO
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: framejs
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: Embed framejs frames as interactive widgets in Jupyter and marimo notebooks
|
|
5
|
+
Project-URL: Homepage, https://framejs.io/docs/integrations/jupyter
|
|
6
|
+
Project-URL: Repository, https://github.com/metapages/framejs.io
|
|
7
|
+
Project-URL: Issues, https://github.com/metapages/framejs.io/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/metapages/framejs.io/blob/main/python/README.md
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Classifier: Framework :: Jupyter
|
|
12
|
+
Classifier: Framework :: Jupyter :: JupyterLab
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Requires-Python: >=3.8
|
|
22
|
+
Requires-Dist: anywidget>=0.9.0
|
|
23
|
+
Requires-Dist: traitlets>=5.0.0
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
26
|
+
Provides-Extra: jupyter
|
|
27
|
+
Requires-Dist: jupyterlab; extra == 'jupyter'
|
|
28
|
+
Provides-Extra: marimo
|
|
29
|
+
Requires-Dist: marimo; extra == 'marimo'
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
# framejs
|
|
33
|
+
|
|
34
|
+
An [anywidget](https://anywidget.dev/) for embedding [framejs](https://framejs.io/docs)
|
|
35
|
+
frames (and [metapage](https://docs.metapage.io/) URLs) in Jupyter and marimo
|
|
36
|
+
notebooks.
|
|
37
|
+
|
|
38
|
+
> **Renamed in 0.4.0.** This package was published as `metaframe-widget`, and the
|
|
39
|
+
> class as `MetaframeWidget`. Both names still work — `metaframe-widget` now
|
|
40
|
+
> installs this package, and `MetaframeWidget` is an alias of `Frame` — but new
|
|
41
|
+
> code should use `framejs` and `Frame`.
|
|
42
|
+
|
|
43
|
+
## Install
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
pip install framejs
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
With environment extras:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
pip install "framejs[jupyter]" # includes jupyterlab
|
|
53
|
+
pip install "framejs[marimo]" # includes marimo
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Quick start
|
|
57
|
+
|
|
58
|
+
### Jupyter
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
from framejs import Frame
|
|
62
|
+
|
|
63
|
+
w = Frame(url="https://framejs.io/#?js=...", height="300px")
|
|
64
|
+
w
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### marimo
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
import marimo as mo
|
|
71
|
+
from framejs import Frame
|
|
72
|
+
|
|
73
|
+
w = Frame(url="https://framejs.io/#?js=...", height="300px")
|
|
74
|
+
mo.ui.anywidget(w)
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
A widget is always created from a URL. To embed your own code, build and save it
|
|
78
|
+
at [framejs.io](https://framejs.io/) — the editor mints a short URL you can paste
|
|
79
|
+
into `url=`. The URL is the portable, saveable form of a frame; the code itself
|
|
80
|
+
lives behind it rather than being inlined in your notebook.
|
|
81
|
+
|
|
82
|
+
### URL forms
|
|
83
|
+
|
|
84
|
+
Any of these work as `url=`:
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
# Raw / full URL — the code is inlined in the hash (can get very long)
|
|
88
|
+
Frame(url="https://framejs.io/#?js=...")
|
|
89
|
+
|
|
90
|
+
# Expiring snapshot — content-addressed, kept ~30 days, then garbage-collected
|
|
91
|
+
# (editor: "Create expiring snapshot")
|
|
92
|
+
Frame(url="https://framejs.io/j/<sha256>")
|
|
93
|
+
|
|
94
|
+
# Durable, editable frame — permanent, tied to your account (editor: "Save")
|
|
95
|
+
# Paste either host: a framejs.app/j/<uuid> URL is loaded from framejs.io, the
|
|
96
|
+
# runtime that serves frames with CORS headers (a notebook cannot load the
|
|
97
|
+
# framejs.app one directly). `w.url` still reads back what you passed.
|
|
98
|
+
Frame(url="https://framejs.app/j/<uuid>")
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Prefer the durable `/j/<uuid>` form in notebooks you keep. See
|
|
102
|
+
[Short URLs](https://framejs.io/docs/guide/short-urls) for the full comparison.
|
|
103
|
+
|
|
104
|
+
## API reference
|
|
105
|
+
|
|
106
|
+
| Parameter | Type | Default | Description |
|
|
107
|
+
|-----------|------|---------|-------------|
|
|
108
|
+
| `url` | `str` | `""` | Full frame URL (including hash params) |
|
|
109
|
+
| `inputs` | `dict` | `{}` | Dict of inputs to push to the frame |
|
|
110
|
+
| `outputs` | `dict` | `{}` | Dict of outputs received from the frame (read-only) |
|
|
111
|
+
| `width` | `str` | `"100%"` | CSS width for the widget container |
|
|
112
|
+
| `height` | `str` | `"400px"` | CSS height for the widget container |
|
|
113
|
+
| `allow` | `str` | `""` | iframe `allow` attribute (e.g. `"camera; microphone"`) |
|
|
114
|
+
|
|
115
|
+
### Methods
|
|
116
|
+
|
|
117
|
+
- **`set_inputs(d)`** — merge a dict into current inputs
|
|
118
|
+
- **`set_input(key, value)`** — set a single input key
|
|
119
|
+
- **`on_outputs_change(callback)`** — register a callback for output changes
|
|
120
|
+
- **`on_saved_url_change(callback)`** — register a callback for when the user creates an expiring snapshot (`/j/<sha256>`) inside the widget
|
|
121
|
+
- **`pipe_to(target, output_key, input_key=None)`** — connect an output to another widget's input
|
|
122
|
+
|
|
123
|
+
## Piping widgets
|
|
124
|
+
|
|
125
|
+
```python
|
|
126
|
+
source = Frame(url="...")
|
|
127
|
+
sink = Frame(url="...")
|
|
128
|
+
source.pipe_to(sink, output_key="result", input_key="data")
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## No anywidget? Use a plain iframe
|
|
132
|
+
|
|
133
|
+
To send data **into** a frame and nothing more, you need no package at all —
|
|
134
|
+
encode the inputs into the URL and show it with `IPython.display.IFrame`. See
|
|
135
|
+
[the Jupyter guide](https://framejs.io/docs/integrations/jupyter#without-anywidget-a-plain-iframe).
|
|
136
|
+
|
|
137
|
+
## Links
|
|
138
|
+
|
|
139
|
+
- [GitHub](https://github.com/metapages/framejs.io)
|
|
140
|
+
- [Jupyter examples](https://github.com/metapages/framejs.io/tree/main/examples/jupyter)
|
|
141
|
+
- [marimo examples](https://github.com/metapages/framejs.io/tree/main/examples/marimo)
|
framejs-0.4.0/README.md
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# framejs
|
|
2
|
+
|
|
3
|
+
An [anywidget](https://anywidget.dev/) for embedding [framejs](https://framejs.io/docs)
|
|
4
|
+
frames (and [metapage](https://docs.metapage.io/) URLs) in Jupyter and marimo
|
|
5
|
+
notebooks.
|
|
6
|
+
|
|
7
|
+
> **Renamed in 0.4.0.** This package was published as `metaframe-widget`, and the
|
|
8
|
+
> class as `MetaframeWidget`. Both names still work — `metaframe-widget` now
|
|
9
|
+
> installs this package, and `MetaframeWidget` is an alias of `Frame` — but new
|
|
10
|
+
> code should use `framejs` and `Frame`.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pip install framejs
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
With environment extras:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pip install "framejs[jupyter]" # includes jupyterlab
|
|
22
|
+
pip install "framejs[marimo]" # includes marimo
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Quick start
|
|
26
|
+
|
|
27
|
+
### Jupyter
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
from framejs import Frame
|
|
31
|
+
|
|
32
|
+
w = Frame(url="https://framejs.io/#?js=...", height="300px")
|
|
33
|
+
w
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### marimo
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
import marimo as mo
|
|
40
|
+
from framejs import Frame
|
|
41
|
+
|
|
42
|
+
w = Frame(url="https://framejs.io/#?js=...", height="300px")
|
|
43
|
+
mo.ui.anywidget(w)
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
A widget is always created from a URL. To embed your own code, build and save it
|
|
47
|
+
at [framejs.io](https://framejs.io/) — the editor mints a short URL you can paste
|
|
48
|
+
into `url=`. The URL is the portable, saveable form of a frame; the code itself
|
|
49
|
+
lives behind it rather than being inlined in your notebook.
|
|
50
|
+
|
|
51
|
+
### URL forms
|
|
52
|
+
|
|
53
|
+
Any of these work as `url=`:
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
# Raw / full URL — the code is inlined in the hash (can get very long)
|
|
57
|
+
Frame(url="https://framejs.io/#?js=...")
|
|
58
|
+
|
|
59
|
+
# Expiring snapshot — content-addressed, kept ~30 days, then garbage-collected
|
|
60
|
+
# (editor: "Create expiring snapshot")
|
|
61
|
+
Frame(url="https://framejs.io/j/<sha256>")
|
|
62
|
+
|
|
63
|
+
# Durable, editable frame — permanent, tied to your account (editor: "Save")
|
|
64
|
+
# Paste either host: a framejs.app/j/<uuid> URL is loaded from framejs.io, the
|
|
65
|
+
# runtime that serves frames with CORS headers (a notebook cannot load the
|
|
66
|
+
# framejs.app one directly). `w.url` still reads back what you passed.
|
|
67
|
+
Frame(url="https://framejs.app/j/<uuid>")
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Prefer the durable `/j/<uuid>` form in notebooks you keep. See
|
|
71
|
+
[Short URLs](https://framejs.io/docs/guide/short-urls) for the full comparison.
|
|
72
|
+
|
|
73
|
+
## API reference
|
|
74
|
+
|
|
75
|
+
| Parameter | Type | Default | Description |
|
|
76
|
+
|-----------|------|---------|-------------|
|
|
77
|
+
| `url` | `str` | `""` | Full frame URL (including hash params) |
|
|
78
|
+
| `inputs` | `dict` | `{}` | Dict of inputs to push to the frame |
|
|
79
|
+
| `outputs` | `dict` | `{}` | Dict of outputs received from the frame (read-only) |
|
|
80
|
+
| `width` | `str` | `"100%"` | CSS width for the widget container |
|
|
81
|
+
| `height` | `str` | `"400px"` | CSS height for the widget container |
|
|
82
|
+
| `allow` | `str` | `""` | iframe `allow` attribute (e.g. `"camera; microphone"`) |
|
|
83
|
+
|
|
84
|
+
### Methods
|
|
85
|
+
|
|
86
|
+
- **`set_inputs(d)`** — merge a dict into current inputs
|
|
87
|
+
- **`set_input(key, value)`** — set a single input key
|
|
88
|
+
- **`on_outputs_change(callback)`** — register a callback for output changes
|
|
89
|
+
- **`on_saved_url_change(callback)`** — register a callback for when the user creates an expiring snapshot (`/j/<sha256>`) inside the widget
|
|
90
|
+
- **`pipe_to(target, output_key, input_key=None)`** — connect an output to another widget's input
|
|
91
|
+
|
|
92
|
+
## Piping widgets
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
source = Frame(url="...")
|
|
96
|
+
sink = Frame(url="...")
|
|
97
|
+
source.pipe_to(sink, output_key="result", input_key="data")
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## No anywidget? Use a plain iframe
|
|
101
|
+
|
|
102
|
+
To send data **into** a frame and nothing more, you need no package at all —
|
|
103
|
+
encode the inputs into the URL and show it with `IPython.display.IFrame`. See
|
|
104
|
+
[the Jupyter guide](https://framejs.io/docs/integrations/jupyter#without-anywidget-a-plain-iframe).
|
|
105
|
+
|
|
106
|
+
## Links
|
|
107
|
+
|
|
108
|
+
- [GitHub](https://github.com/metapages/framejs.io)
|
|
109
|
+
- [Jupyter examples](https://github.com/metapages/framejs.io/tree/main/examples/jupyter)
|
|
110
|
+
- [marimo examples](https://github.com/metapages/framejs.io/tree/main/examples/marimo)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "framejs"
|
|
7
|
+
version = "0.4.0"
|
|
8
|
+
description = "Embed framejs frames as interactive widgets in Jupyter and marimo notebooks"
|
|
9
|
+
license = "MIT"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
readme = "README.md"
|
|
12
|
+
dependencies = [
|
|
13
|
+
"anywidget>=0.9.0",
|
|
14
|
+
"traitlets>=5.0.0",
|
|
15
|
+
]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Framework :: Jupyter",
|
|
18
|
+
"Framework :: Jupyter :: JupyterLab",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3.8",
|
|
21
|
+
"Programming Language :: Python :: 3.9",
|
|
22
|
+
"Programming Language :: Python :: 3.10",
|
|
23
|
+
"Programming Language :: Python :: 3.11",
|
|
24
|
+
"Programming Language :: Python :: 3.12",
|
|
25
|
+
"Programming Language :: Python :: 3.13",
|
|
26
|
+
"License :: OSI Approved :: MIT License",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[project.urls]
|
|
30
|
+
Homepage = "https://framejs.io/docs/integrations/jupyter"
|
|
31
|
+
Repository = "https://github.com/metapages/framejs.io"
|
|
32
|
+
Issues = "https://github.com/metapages/framejs.io/issues"
|
|
33
|
+
Changelog = "https://github.com/metapages/framejs.io/blob/main/python/README.md"
|
|
34
|
+
|
|
35
|
+
[project.optional-dependencies]
|
|
36
|
+
jupyter = ["jupyterlab"]
|
|
37
|
+
marimo = ["marimo"]
|
|
38
|
+
dev = ["pytest"]
|
|
39
|
+
|
|
40
|
+
[tool.hatch.build.targets.wheel]
|
|
41
|
+
packages = ["src/framejs"]
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
ESM = """
|
|
2
|
+
const CDN_URL = "https://cdn.jsdelivr.net/npm/@metapages/metapage@1.10.14/+esm";
|
|
3
|
+
|
|
4
|
+
let _renderMetapage = null;
|
|
5
|
+
|
|
6
|
+
async function loadRenderMetapage() {
|
|
7
|
+
if (!_renderMetapage) {
|
|
8
|
+
const mod = await import(CDN_URL);
|
|
9
|
+
_renderMetapage = mod.renderMetapage;
|
|
10
|
+
}
|
|
11
|
+
return _renderMetapage;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export default {
|
|
15
|
+
async render({ model, el }) {
|
|
16
|
+
// el's own `display` is managed by the notebook host — JupyterLab's
|
|
17
|
+
// ipywidgets layer resets el.style.display to "block" AFTER render, which
|
|
18
|
+
// silently kills a flex column set directly on el (that's why the widget
|
|
19
|
+
// renders full-height standalone but collapses to ~40px in a notebook).
|
|
20
|
+
// So we don't lay out el itself; we only give it a definite size, then own
|
|
21
|
+
// an inner `root` wrapper. root's height:100% resolves against el's definite
|
|
22
|
+
// height regardless of el's display, and root's flex column (which the host
|
|
23
|
+
// never touches) lets the metapage fill the space with an optional
|
|
24
|
+
// "saved URL" footer beneath it.
|
|
25
|
+
el.style.boxSizing = "border-box";
|
|
26
|
+
el.style.width = model.get("width") || "100%";
|
|
27
|
+
el.style.height = model.get("height") || "400px";
|
|
28
|
+
|
|
29
|
+
const root = document.createElement("div");
|
|
30
|
+
root.style.cssText = "display: flex; flex-direction: column; width: 100%; height: 100%; box-sizing: border-box;";
|
|
31
|
+
el.appendChild(root);
|
|
32
|
+
|
|
33
|
+
// Inject CSS to remove iframe borders and inline gaps
|
|
34
|
+
const style = document.createElement("style");
|
|
35
|
+
// renderMetapage lays the metaframe out in a react-grid-layout grid and
|
|
36
|
+
// stamps an inline `min-height` floor on the grid container (default h=3 *
|
|
37
|
+
// rowHeight 100 + margins => 350px) and on the item wrapper (300px). The
|
|
38
|
+
// grid rows are `1fr`, so nothing else pins the size — only those floors.
|
|
39
|
+
// When the widget's height is set below 350px, the floor wins and the
|
|
40
|
+
// content is cut off at 350px. Override the floors so the metaframe fills
|
|
41
|
+
// our explicit height at any size (large heights already fill via 1fr).
|
|
42
|
+
style.textContent = ".framejs-frame-container { box-sizing: border-box; flex: 1 1 auto; min-height: 0; width: 100%; } .framejs-frame-container * { box-sizing: border-box; min-height: 0 !important; } .framejs-frame-container > div { height: 100%; } .framejs-frame-container iframe { border: none; display: block; margin: 0; padding: 0; box-sizing: border-box; width: 100%; height: 100%; }";
|
|
43
|
+
root.appendChild(style);
|
|
44
|
+
|
|
45
|
+
const container = document.createElement("div");
|
|
46
|
+
container.className = "framejs-frame-container metaframe-widget-container";
|
|
47
|
+
root.appendChild(container);
|
|
48
|
+
|
|
49
|
+
// Footer showing the latest short URL produced by editing + saving.
|
|
50
|
+
const footer = document.createElement("div");
|
|
51
|
+
footer.style.cssText = "flex: 0 0 auto; display: none; align-items: center; gap: 6px; padding: 4px 6px; font-family: sans-serif; font-size: 12px; color: #555; background: #f7f7f7; border-top: 1px solid #e0e0e0; overflow: hidden;";
|
|
52
|
+
const footerLabel = document.createElement("span");
|
|
53
|
+
footerLabel.textContent = "Saved:";
|
|
54
|
+
footerLabel.style.flex = "0 0 auto";
|
|
55
|
+
const footerLink = document.createElement("a");
|
|
56
|
+
footerLink.target = "_blank";
|
|
57
|
+
footerLink.rel = "noopener noreferrer";
|
|
58
|
+
footerLink.style.cssText = "flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;";
|
|
59
|
+
const footerCopy = document.createElement("button");
|
|
60
|
+
footerCopy.textContent = "copy";
|
|
61
|
+
footerCopy.style.cssText = "flex: 0 0 auto; cursor: pointer; font-size: 11px; padding: 1px 6px;";
|
|
62
|
+
footer.appendChild(footerLabel);
|
|
63
|
+
footer.appendChild(footerLink);
|
|
64
|
+
footer.appendChild(footerCopy);
|
|
65
|
+
root.appendChild(footer);
|
|
66
|
+
|
|
67
|
+
function renderSavedUrl() {
|
|
68
|
+
const savedUrl = model.get("saved_url") || "";
|
|
69
|
+
if (savedUrl) {
|
|
70
|
+
footerLink.textContent = savedUrl;
|
|
71
|
+
footerLink.href = savedUrl;
|
|
72
|
+
footer.style.display = "flex";
|
|
73
|
+
} else {
|
|
74
|
+
footer.style.display = "none";
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
footerCopy.addEventListener("click", () => {
|
|
78
|
+
const savedUrl = model.get("saved_url") || "";
|
|
79
|
+
if (savedUrl && navigator.clipboard) {
|
|
80
|
+
navigator.clipboard.writeText(savedUrl);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
renderSavedUrl();
|
|
84
|
+
|
|
85
|
+
const renderMetapage = await loadRenderMetapage();
|
|
86
|
+
|
|
87
|
+
let currentResult = null;
|
|
88
|
+
|
|
89
|
+
function applyAllowToIframes() {
|
|
90
|
+
const allow = model.get("allow") || "";
|
|
91
|
+
if (allow) {
|
|
92
|
+
container.querySelectorAll("iframe").forEach(iframe => {
|
|
93
|
+
iframe.allow = allow;
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function createMetapage() {
|
|
99
|
+
if (currentResult) {
|
|
100
|
+
currentResult.dispose();
|
|
101
|
+
currentResult = null;
|
|
102
|
+
}
|
|
103
|
+
container.innerHTML = "";
|
|
104
|
+
|
|
105
|
+
// The runtime URL, not the one the user typed -- see runtime_url()
|
|
106
|
+
// in _widget.py. Falls back to `url` so an empty trait is harmless.
|
|
107
|
+
const url = model.get("_runtime_url") || model.get("url");
|
|
108
|
+
if (!url) return;
|
|
109
|
+
|
|
110
|
+
const definition = {
|
|
111
|
+
version: "0.3",
|
|
112
|
+
metaframes: {
|
|
113
|
+
mf: { url },
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const result = await renderMetapage({
|
|
118
|
+
definition,
|
|
119
|
+
rootDiv: container,
|
|
120
|
+
onOutputs: (outputs) => {
|
|
121
|
+
const mfOutputs = outputs.mf || {};
|
|
122
|
+
model.set("outputs", JSON.parse(JSON.stringify(mfOutputs)));
|
|
123
|
+
model.save_changes();
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
currentResult = result;
|
|
127
|
+
|
|
128
|
+
applyAllowToIframes();
|
|
129
|
+
|
|
130
|
+
// Push current inputs if any
|
|
131
|
+
const inputs = model.get("inputs");
|
|
132
|
+
if (inputs && Object.keys(inputs).length > 0) {
|
|
133
|
+
result.setInputs({ mf: inputs });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// NOTE: the message `type` below is a WIRE PROTOCOL string shared with the
|
|
138
|
+
// framejs editor (editor/src/.../ButtonShortenUrl.tsx). It is deliberately
|
|
139
|
+
// NOT renamed with the package: the editor is deployed once and talks to
|
|
140
|
+
// every installed version of this package, including releases that predate
|
|
141
|
+
// the framejs rename, so changing it here would make old installs stop
|
|
142
|
+
// receiving snapshot URLs.
|
|
143
|
+
//
|
|
144
|
+
// The embedded editor (same-origin to the URL-shortening worker) mints an
|
|
145
|
+
// expiring /j/<sha256> snapshot when the user clicks "Create expiring
|
|
146
|
+
// snapshot", then postMessages it here. We record it in `saved_url`
|
|
147
|
+
// WITHOUT touching `url`, so the live iframe the user is editing is never
|
|
148
|
+
// torn down and reloaded.
|
|
149
|
+
function expectedOrigin() {
|
|
150
|
+
try {
|
|
151
|
+
// Must be the URL the iframe actually loaded, or a snapshot
|
|
152
|
+
// message from the embedded editor is dropped as cross-origin.
|
|
153
|
+
return new URL(model.get("_runtime_url") || model.get("url") || "").origin;
|
|
154
|
+
} catch {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
const onMessage = (event) => {
|
|
159
|
+
const data = event.data;
|
|
160
|
+
if (!data || data.type !== "metaframe-widget:shorturl" || !data.url) {
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
// Route to the correct widget (one notebook may hold several) and
|
|
164
|
+
// verify the message came from our metaframe's origin.
|
|
165
|
+
const iframe = container.querySelector("iframe");
|
|
166
|
+
if (!iframe || event.source !== iframe.contentWindow) return;
|
|
167
|
+
const origin = expectedOrigin();
|
|
168
|
+
if (origin && event.origin !== origin) return;
|
|
169
|
+
|
|
170
|
+
model.set("saved_url", data.url);
|
|
171
|
+
model.save_changes();
|
|
172
|
+
};
|
|
173
|
+
window.addEventListener("message", onMessage);
|
|
174
|
+
|
|
175
|
+
// `_runtime_url` is derived from `url`, so this covers a url change too --
|
|
176
|
+
// and a change that maps to the SAME frame (the .app and .io spellings of
|
|
177
|
+
// one URL) correctly does not tear the iframe down.
|
|
178
|
+
model.on("change:_runtime_url", createMetapage);
|
|
179
|
+
model.on("change:saved_url", renderSavedUrl);
|
|
180
|
+
|
|
181
|
+
model.on("change:inputs", () => {
|
|
182
|
+
if (currentResult) {
|
|
183
|
+
const inputs = model.get("inputs");
|
|
184
|
+
currentResult.setInputs({ mf: inputs });
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
model.on("change:width", () => {
|
|
189
|
+
el.style.width = model.get("width") || "100%";
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
model.on("change:height", () => {
|
|
193
|
+
el.style.height = model.get("height") || "400px";
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
model.on("change:allow", applyAllowToIframes);
|
|
197
|
+
|
|
198
|
+
await createMetapage();
|
|
199
|
+
|
|
200
|
+
return () => {
|
|
201
|
+
window.removeEventListener("message", onMessage);
|
|
202
|
+
if (currentResult) {
|
|
203
|
+
currentResult.dispose();
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
},
|
|
207
|
+
};
|
|
208
|
+
"""
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import urllib.parse
|
|
2
|
+
|
|
3
|
+
import anywidget
|
|
4
|
+
import traitlets
|
|
5
|
+
|
|
6
|
+
from ._esm import ESM
|
|
7
|
+
|
|
8
|
+
#: Hosts serving the framejs *account* layer. Their /j/<id> URLs are the ones
|
|
9
|
+
#: people copy and share, but they are not what a notebook should load.
|
|
10
|
+
_APP_HOSTS = frozenset({"framejs.app", "www.framejs.app"})
|
|
11
|
+
#: The host serving the frame *runtime*.
|
|
12
|
+
_RUNTIME_HOST = "framejs.io"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def runtime_url(url: str) -> str:
|
|
16
|
+
"""Map a shareable ``framejs.app`` frame URL onto the ``framejs.io`` runtime.
|
|
17
|
+
|
|
18
|
+
``framejs.app/j/<id>`` and ``framejs.io/j/<id>`` are the same frame, but only
|
|
19
|
+
framejs.io serves it as an embeddable metaframe with permissive CORS. The
|
|
20
|
+
framejs.app URL answers a framed request with a branded wrapper page, and its
|
|
21
|
+
``/j/<id>/metaframe.json`` — which the metapage client fetches before it
|
|
22
|
+
renders anything — carries no ``Access-Control-Allow-Origin``. A notebook on
|
|
23
|
+
``http://localhost:8888`` therefore gets::
|
|
24
|
+
|
|
25
|
+
Access to fetch at 'https://framejs.app/j/<id>/metaframe.json' from origin
|
|
26
|
+
'http://localhost:8888' has been blocked by CORS policy
|
|
27
|
+
|
|
28
|
+
and no widget. Normalising here means a user can paste either URL — the one
|
|
29
|
+
the share panel gives them is the framejs.app one — and it just works.
|
|
30
|
+
|
|
31
|
+
Anything that is not a framejs.app ``/j/`` URL is returned unchanged, so
|
|
32
|
+
full ``#?js=`` URLs, framejs.io URLs and local dev stacks are untouched.
|
|
33
|
+
Query and fragment are preserved (``?v=<sha256>`` pins a published version,
|
|
34
|
+
``#?inputs=`` feeds the frame).
|
|
35
|
+
"""
|
|
36
|
+
if not url:
|
|
37
|
+
return url
|
|
38
|
+
try:
|
|
39
|
+
parts = urllib.parse.urlsplit(url)
|
|
40
|
+
except ValueError: # malformed; hand it back and let the browser complain
|
|
41
|
+
return url
|
|
42
|
+
host = (parts.hostname or "").lower()
|
|
43
|
+
if host not in _APP_HOSTS:
|
|
44
|
+
return url
|
|
45
|
+
if parts.path != "/j" and not parts.path.startswith("/j/"):
|
|
46
|
+
return url
|
|
47
|
+
return urllib.parse.urlunsplit(parts._replace(netloc=_RUNTIME_HOST))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Frame(anywidget.AnyWidget):
|
|
51
|
+
"""Widget that renders a framejs frame in an iframe (Jupyter and marimo).
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
url: Full frame URL (including hash params).
|
|
55
|
+
inputs: Dict of inputs to push to the frame.
|
|
56
|
+
outputs: Dict of outputs received from the frame (read-only).
|
|
57
|
+
width: CSS width for the widget container (default "100%").
|
|
58
|
+
height: CSS height for the widget container (default "400px").
|
|
59
|
+
allow: iframe allow attribute string (e.g. "camera; microphone").
|
|
60
|
+
saved_url: Latest expiring snapshot URL (/j/<sha256>) minted by the
|
|
61
|
+
"Create expiring snapshot" button inside the widget (read-only).
|
|
62
|
+
Handy for a quick capture; for anything you want to keep, Save a
|
|
63
|
+
durable /j/<uuid> frame and use that URL instead.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
_esm = ESM
|
|
67
|
+
|
|
68
|
+
url = traitlets.Unicode("").tag(sync=True)
|
|
69
|
+
inputs = traitlets.Dict({}).tag(sync=True)
|
|
70
|
+
outputs = traitlets.Dict({}).tag(sync=True)
|
|
71
|
+
width = traitlets.Unicode("100%").tag(sync=True)
|
|
72
|
+
height = traitlets.Unicode("400px").tag(sync=True)
|
|
73
|
+
allow = traitlets.Unicode("").tag(sync=True)
|
|
74
|
+
# The URL the iframe ACTUALLY loads: `url` mapped through runtime_url().
|
|
75
|
+
# Kept as its own synced trait rather than rewriting `url`, so reading back
|
|
76
|
+
# `w.url` returns exactly what the caller passed. The ESM uses this for both
|
|
77
|
+
# the iframe src and the postMessage origin check -- those two must agree or
|
|
78
|
+
# snapshot URLs from the embedded editor are silently discarded.
|
|
79
|
+
_runtime_url = traitlets.Unicode("").tag(sync=True)
|
|
80
|
+
# Set by the ESM when the user clicks "Create expiring snapshot" inside the
|
|
81
|
+
# embedded editor. Read-only from Python's perspective; updating it does not
|
|
82
|
+
# reload the iframe (so editing is not interrupted).
|
|
83
|
+
saved_url = traitlets.Unicode("").tag(sync=True)
|
|
84
|
+
|
|
85
|
+
def __init__(self, *args, **kwargs):
|
|
86
|
+
super().__init__(*args, **kwargs)
|
|
87
|
+
# Sync layout.height/width so ipywidgets' LayoutView doesn't clear
|
|
88
|
+
# the styles that the ESM sets on the element.
|
|
89
|
+
self.layout.height = self.height
|
|
90
|
+
self.layout.width = self.width
|
|
91
|
+
self.observe(self._sync_layout_height, names=["height"])
|
|
92
|
+
self.observe(self._sync_layout_width, names=["width"])
|
|
93
|
+
self._runtime_url = runtime_url(self.url)
|
|
94
|
+
self.observe(self._sync_runtime_url, names=["url"])
|
|
95
|
+
|
|
96
|
+
def _sync_runtime_url(self, change):
|
|
97
|
+
self._runtime_url = runtime_url(change["new"])
|
|
98
|
+
|
|
99
|
+
def _sync_layout_height(self, change):
|
|
100
|
+
self.layout.height = change["new"]
|
|
101
|
+
|
|
102
|
+
def _sync_layout_width(self, change):
|
|
103
|
+
self.layout.width = change["new"]
|
|
104
|
+
|
|
105
|
+
def set_inputs(self, d: dict):
|
|
106
|
+
"""Merge a dict into the current inputs."""
|
|
107
|
+
self.inputs = {**self.inputs, **d}
|
|
108
|
+
|
|
109
|
+
def set_input(self, key: str, value):
|
|
110
|
+
"""Set a single input key."""
|
|
111
|
+
self.inputs = {**self.inputs, key: value}
|
|
112
|
+
|
|
113
|
+
def on_outputs_change(self, callback):
|
|
114
|
+
"""Register a callback for when outputs change.
|
|
115
|
+
|
|
116
|
+
The callback receives a change dict with 'old' and 'new' keys.
|
|
117
|
+
"""
|
|
118
|
+
self.observe(callback, names=["outputs"])
|
|
119
|
+
|
|
120
|
+
def on_saved_url_change(self, callback):
|
|
121
|
+
"""Register a callback for when the user creates an expiring snapshot.
|
|
122
|
+
|
|
123
|
+
Fires after the user clicks "Create expiring snapshot" inside the
|
|
124
|
+
embedded editor. The callback receives a change dict whose 'new' key
|
|
125
|
+
holds the freshly-minted /j/<sha256> snapshot URL.
|
|
126
|
+
"""
|
|
127
|
+
self.observe(callback, names=["saved_url"])
|
|
128
|
+
|
|
129
|
+
def pipe_to(self, target: "Frame", output_key: str, input_key: str = None):
|
|
130
|
+
"""Connect an output of this widget to an input of another.
|
|
131
|
+
|
|
132
|
+
When this widget's output_key changes, push the value to
|
|
133
|
+
target's input_key (defaults to output_key if not specified).
|
|
134
|
+
"""
|
|
135
|
+
if input_key is None:
|
|
136
|
+
input_key = output_key
|
|
137
|
+
|
|
138
|
+
def _forward(change):
|
|
139
|
+
new_outputs = change.get("new", {})
|
|
140
|
+
if output_key in new_outputs:
|
|
141
|
+
target.set_input(input_key, new_outputs[output_key])
|
|
142
|
+
|
|
143
|
+
self.observe(_forward, names=["outputs"])
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
#: Deprecated alias. The class was called ``MetaframeWidget`` while the package
|
|
147
|
+
#: was published as ``metaframe-widget``; both were renamed in 0.4.0. Kept so
|
|
148
|
+
#: notebooks and copied snippets written against the old name keep running --
|
|
149
|
+
#: it is the same class, not a subclass, so ``isinstance`` and ``pipe_to``
|
|
150
|
+
#: between the two names behave identically.
|
|
151
|
+
MetaframeWidget = Frame
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
from framejs import Frame
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_create_widget():
|
|
5
|
+
w = Frame(url="https://framejs.io/#?js=abc")
|
|
6
|
+
assert w.url == "https://framejs.io/#?js=abc"
|
|
7
|
+
assert w.inputs == {}
|
|
8
|
+
assert w.outputs == {}
|
|
9
|
+
assert w.height == "400px"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_set_inputs():
|
|
13
|
+
w = Frame()
|
|
14
|
+
w.set_inputs({"a": 1, "b": 2})
|
|
15
|
+
assert w.inputs == {"a": 1, "b": 2}
|
|
16
|
+
w.set_inputs({"b": 3, "c": 4})
|
|
17
|
+
assert w.inputs == {"a": 1, "b": 3, "c": 4}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_set_input():
|
|
21
|
+
w = Frame()
|
|
22
|
+
w.set_input("x", 42)
|
|
23
|
+
assert w.inputs == {"x": 42}
|
|
24
|
+
w.set_input("y", "hello")
|
|
25
|
+
assert w.inputs == {"x": 42, "y": "hello"}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_on_outputs_change():
|
|
29
|
+
w = Frame()
|
|
30
|
+
changes = []
|
|
31
|
+
w.on_outputs_change(lambda change: changes.append(change))
|
|
32
|
+
w.outputs = {"key": "value"}
|
|
33
|
+
assert len(changes) == 1
|
|
34
|
+
assert changes[0]["new"] == {"key": "value"}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_pipe_to():
|
|
38
|
+
source = Frame()
|
|
39
|
+
sink = Frame()
|
|
40
|
+
source.pipe_to(sink, output_key="doubled", input_key="data")
|
|
41
|
+
source.outputs = {"doubled": [2, 4, 6]}
|
|
42
|
+
assert sink.inputs == {"data": [2, 4, 6]}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_pipe_to_default_key():
|
|
46
|
+
source = Frame()
|
|
47
|
+
sink = Frame()
|
|
48
|
+
source.pipe_to(sink, output_key="result")
|
|
49
|
+
source.outputs = {"result": 42}
|
|
50
|
+
assert sink.inputs == {"result": 42}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_pipe_to_ignores_unrelated_keys():
|
|
54
|
+
source = Frame()
|
|
55
|
+
sink = Frame()
|
|
56
|
+
source.pipe_to(sink, output_key="x")
|
|
57
|
+
source.outputs = {"y": 99}
|
|
58
|
+
assert sink.inputs == {}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_layout_height_synced_on_init():
|
|
62
|
+
"""layout.height must match the height trait so ipywidgets doesn't clear it."""
|
|
63
|
+
w = Frame()
|
|
64
|
+
assert w.layout.height == "400px"
|
|
65
|
+
assert w.layout.width == "100%"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_layout_height_synced_with_custom_value():
|
|
69
|
+
w = Frame(height="600px", width="80%")
|
|
70
|
+
assert w.layout.height == "600px"
|
|
71
|
+
assert w.layout.width == "80%"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_layout_height_updates_on_change():
|
|
75
|
+
"""Changing height trait after init must propagate to layout."""
|
|
76
|
+
w = Frame()
|
|
77
|
+
w.height = "500px"
|
|
78
|
+
assert w.layout.height == "500px"
|
|
79
|
+
w.width = "50%"
|
|
80
|
+
assert w.layout.width == "50%"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_pipe_chain_five_widgets():
|
|
84
|
+
"""Five widgets piped in a chain propagate data end-to-end."""
|
|
85
|
+
w1 = Frame()
|
|
86
|
+
w2 = Frame()
|
|
87
|
+
w3 = Frame()
|
|
88
|
+
w4 = Frame()
|
|
89
|
+
w5 = Frame()
|
|
90
|
+
|
|
91
|
+
w1.pipe_to(w2, output_key="data")
|
|
92
|
+
w2.pipe_to(w3, output_key="data")
|
|
93
|
+
w3.pipe_to(w4, output_key="data")
|
|
94
|
+
w4.pipe_to(w5, output_key="data")
|
|
95
|
+
|
|
96
|
+
# Simulate w1 producing output
|
|
97
|
+
w1.outputs = {"data": [1, 2, 3]}
|
|
98
|
+
assert w2.inputs == {"data": [1, 2, 3]}
|
|
99
|
+
|
|
100
|
+
# Simulate w2 producing output
|
|
101
|
+
w2.outputs = {"data": [2, 4, 6]}
|
|
102
|
+
assert w3.inputs == {"data": [2, 4, 6]}
|
|
103
|
+
|
|
104
|
+
# Simulate w3 producing output
|
|
105
|
+
w3.outputs = {"data": [6, 12, 18]}
|
|
106
|
+
assert w4.inputs == {"data": [6, 12, 18]}
|
|
107
|
+
|
|
108
|
+
# Simulate w4 producing output
|
|
109
|
+
w4.outputs = {"data": [16, 22, 28]}
|
|
110
|
+
assert w5.inputs == {"data": [16, 22, 28]}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# --- the rename (0.4.0: metaframe-widget/MetaframeWidget -> framejs/Frame) ----
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_metaframe_widget_alias_is_the_same_class():
|
|
117
|
+
"""`MetaframeWidget` is an ALIAS, not a subclass.
|
|
118
|
+
|
|
119
|
+
Old notebooks keep working, and a widget built under either name pipes to,
|
|
120
|
+
and isinstance-checks against, the other.
|
|
121
|
+
"""
|
|
122
|
+
from framejs import MetaframeWidget
|
|
123
|
+
|
|
124
|
+
assert MetaframeWidget is Frame
|
|
125
|
+
|
|
126
|
+
source = MetaframeWidget()
|
|
127
|
+
sink = Frame()
|
|
128
|
+
assert isinstance(source, Frame)
|
|
129
|
+
source.pipe_to(sink, output_key="x")
|
|
130
|
+
source.outputs = {"x": 1}
|
|
131
|
+
assert sink.inputs == {"x": 1}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_package_exports():
|
|
135
|
+
import framejs
|
|
136
|
+
|
|
137
|
+
assert sorted(framejs.__all__) == ["Frame", "MetaframeWidget", "runtime_url"]
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# --- framejs.app -> framejs.io normalisation ---------------------------------
|
|
141
|
+
# A notebook must load the RUNTIME (framejs.io), not the account layer: only
|
|
142
|
+
# framejs.io serves /j/<id>/metaframe.json with CORS headers, and the metapage
|
|
143
|
+
# client fetches that before it renders anything. Pasting the framejs.app URL —
|
|
144
|
+
# which is the one the share panel hands out — otherwise fails with a bare CORS
|
|
145
|
+
# error and no widget.
|
|
146
|
+
|
|
147
|
+
import pytest
|
|
148
|
+
|
|
149
|
+
from framejs import runtime_url
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@pytest.mark.parametrize(
|
|
153
|
+
"given,expected",
|
|
154
|
+
[
|
|
155
|
+
# the case from the bug report
|
|
156
|
+
(
|
|
157
|
+
"https://framejs.app/j/019f61392e0778f8aba8dd7ffe35a83c",
|
|
158
|
+
"https://framejs.io/j/019f61392e0778f8aba8dd7ffe35a83c",
|
|
159
|
+
),
|
|
160
|
+
# a pinned version and hash inputs must survive the swap
|
|
161
|
+
(
|
|
162
|
+
"https://framejs.app/j/abc?v=deadbeef#?inputs=eyJhIjoxfQ==",
|
|
163
|
+
"https://framejs.io/j/abc?v=deadbeef#?inputs=eyJhIjoxfQ==",
|
|
164
|
+
),
|
|
165
|
+
("https://www.framejs.app/j/abc", "https://framejs.io/j/abc"),
|
|
166
|
+
("https://FrameJS.App/j/abc", "https://framejs.io/j/abc"),
|
|
167
|
+
],
|
|
168
|
+
)
|
|
169
|
+
def test_app_frame_urls_are_mapped_to_the_runtime(given, expected):
|
|
170
|
+
assert runtime_url(given) == expected
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@pytest.mark.parametrize(
|
|
174
|
+
"url",
|
|
175
|
+
[
|
|
176
|
+
"",
|
|
177
|
+
# already the runtime
|
|
178
|
+
"https://framejs.io/j/abc",
|
|
179
|
+
# a full inline-code URL has no /j/ path
|
|
180
|
+
"https://framejs.io/#?js=YWxwaGE=",
|
|
181
|
+
# not a frame route: /f/ is the file endpoint
|
|
182
|
+
"https://framejs.app/f/abc",
|
|
183
|
+
"https://framejs.app/dashboard",
|
|
184
|
+
# must not be fooled by a lookalike host
|
|
185
|
+
"https://framejs.app.evil.com/j/abc",
|
|
186
|
+
"https://notframejs.app/j/abc",
|
|
187
|
+
# a local dev stack has no framejs.io counterpart we could name
|
|
188
|
+
"https://framejs-app.localhost:13829/j/abc",
|
|
189
|
+
],
|
|
190
|
+
)
|
|
191
|
+
def test_other_urls_are_left_alone(url):
|
|
192
|
+
assert runtime_url(url) == url
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def test_widget_exposes_both_the_given_and_the_runtime_url():
|
|
196
|
+
app = "https://framejs.app/j/019f61392e0778f8aba8dd7ffe35a83c"
|
|
197
|
+
io = "https://framejs.io/j/019f61392e0778f8aba8dd7ffe35a83c"
|
|
198
|
+
|
|
199
|
+
w = Frame(url=app)
|
|
200
|
+
# What the caller passed is read back unchanged...
|
|
201
|
+
assert w.url == app
|
|
202
|
+
# ...while the iframe loads the runtime.
|
|
203
|
+
assert w._runtime_url == io
|
|
204
|
+
# The ESM needs it over the wire, or it falls back to the CORS-blocked URL.
|
|
205
|
+
assert "_runtime_url" in w.keys
|
|
206
|
+
|
|
207
|
+
# It tracks later assignments too.
|
|
208
|
+
w.url = "https://framejs.app/j/other"
|
|
209
|
+
assert w._runtime_url == "https://framejs.io/j/other"
|
|
210
|
+
w.url = "https://framejs.io/#?js=YWxwaGE="
|
|
211
|
+
assert w._runtime_url == w.url
|