figpress-apps 2026.10.28__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.
- figpress_apps-2026.10.28/.gitignore +10 -0
- figpress_apps-2026.10.28/CHANGELOG.md +108 -0
- figpress_apps-2026.10.28/LICENSE +21 -0
- figpress_apps-2026.10.28/PKG-INFO +77 -0
- figpress_apps-2026.10.28/README.md +64 -0
- figpress_apps-2026.10.28/pyproject.toml +39 -0
- figpress_apps-2026.10.28/src/figpress_apps/__init__.py +9 -0
- figpress_apps-2026.10.28/src/figpress_apps/anywidget.py +41 -0
- figpress_apps-2026.10.28/src/figpress_apps/build.py +230 -0
- figpress_apps-2026.10.28/src/figpress_apps/cli.py +78 -0
- figpress_apps-2026.10.28/src/figpress_apps/data.py +213 -0
- figpress_apps-2026.10.28/src/figpress_apps/js/data.js +75 -0
- figpress_apps-2026.10.28/src/figpress_apps/js/geom.js +67 -0
- figpress_apps-2026.10.28/src/figpress_apps/js/globe.js +200 -0
- figpress_apps-2026.10.28/src/figpress_apps/js/index.css +35 -0
- figpress_apps-2026.10.28/src/figpress_apps/js/index.js +39 -0
- figpress_apps-2026.10.28/src/figpress_apps/js/model.js +26 -0
- figpress_apps-2026.10.28/src/figpress_apps/js/npy.js +39 -0
- figpress_apps-2026.10.28/src/figpress_apps/js/skypanel.js +86 -0
- figpress_apps-2026.10.28/src/figpress_apps/js/stage.js +113 -0
- figpress_apps-2026.10.28/src/figpress_apps/node/hooks.mjs +14 -0
- figpress_apps-2026.10.28/src/figpress_apps/node/register.mjs +5 -0
- figpress_apps-2026.10.28/src/figpress_apps/serve.py +42 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
Releases are dated: `YYYY.MM.DD`, and `YYYY.MM.DD.N` for a bug-fix release of it (0.1.0 and 0.2.0
|
|
4
|
+
predate this). The version does not say whether a release breaks things; the migration notes do.
|
|
5
|
+
|
|
6
|
+
Every release has a section. Every breaking change has a migration note with three parts, so the
|
|
7
|
+
`figpress-apps-upgrade` skill can apply it:
|
|
8
|
+
|
|
9
|
+
- **What changed**: one sentence.
|
|
10
|
+
- **Find**: how to locate affected code, e.g. `grep -rn "compute:" webapps/`.
|
|
11
|
+
- **Edit**: the exact change to make.
|
|
12
|
+
|
|
13
|
+
A release with a breaking change that has no migration note is not tagged.
|
|
14
|
+
|
|
15
|
+
## 2026.10.28 — 2026-10-28
|
|
16
|
+
|
|
17
|
+
figkit is now **figpress-apps**, a member of the [figpress](https://nstarkman.space/figpress/) family,
|
|
18
|
+
published on PyPI. Nothing else changes: same modules, same behaviour, same data format. Each rename
|
|
19
|
+
below has its migration note; the `figpress-apps-upgrade` skill applies them in order.
|
|
20
|
+
|
|
21
|
+
### Changed — migration
|
|
22
|
+
|
|
23
|
+
- **What changed**: the Python distribution, its import and its command: `figkit` →
|
|
24
|
+
`figpress-apps` (PyPI), `import figkit` → `import figpress_apps`, the command `figkit` →
|
|
25
|
+
`figpress-apps`, the widget `figkit.anywidget.Widget` → `figpress_apps.anywidget.Widget`.
|
|
26
|
+
**Find**: `grep -rnE "figkit( @|\\.|$| build| serve| test)|import figkit|from figkit" .`
|
|
27
|
+
**Edit**: pin `figpress-apps==2026.10.28` instead of `figkit @ git+…`; replace `import figkit` with
|
|
28
|
+
`import figpress_apps` and `figkit.` with `figpress_apps.`; run `figpress-apps build|serve|test`.
|
|
29
|
+
- **What changed**: browser module specifiers: `figkit/<module>.js` → `figpress-apps/<module>.js`,
|
|
30
|
+
and the entry module and stylesheet `figkit/figkit.js` → `figpress-apps/index.js`,
|
|
31
|
+
`figkit/figkit.css` → `figpress-apps/index.css`.
|
|
32
|
+
**Find**: `grep -rn "figkit/" webapps/`
|
|
33
|
+
**Edit**: replace `figkit/figkit.js` with `figpress-apps/index.js`, `figkit/figkit.css` with
|
|
34
|
+
`figpress-apps/index.css`, then every remaining `figkit/` with `figpress-apps/`.
|
|
35
|
+
- **What changed**: built pages: `<meta name="figkit-version">` → `<meta name="figpress-apps-version">`,
|
|
36
|
+
`vendor/figkit-<v>/` → `vendor/figpress-apps-<v>/`, `globalThis.FIGKIT_DATA` →
|
|
37
|
+
`globalThis.FIGPRESS_APPS_DATA`, the app element's `data-figkit-app` → `data-figpress-app`.
|
|
38
|
+
**Find**: `grep -rnE "figkit-version|vendor/figkit-|FIGKIT_DATA|figkitApp|data-figkit-app" .`
|
|
39
|
+
**Edit**: rebuild (`figpress-apps build`); in app code, replace `dataset.figkitApp` with
|
|
40
|
+
`dataset.figpressApp`. Commit the new `vendor/` folder if built output is tracked.
|
|
41
|
+
- **What changed**: datasets are written with `figpress_apps_data` / `figpress_apps` instead of
|
|
42
|
+
`figkit_data` / `figkit`. Datasets written by figkit still load.
|
|
43
|
+
**Find**: `grep -rn "figkit_data" .` (code that reads manifests itself)
|
|
44
|
+
**Edit**: none for datasets; in code that reads a manifest directly, read `figpress_apps_data`
|
|
45
|
+
(falling back to `figkit_data` for old data). JS `FigkitDataError` is now `DataError`.
|
|
46
|
+
- **What changed**: the skills `figkit` and `figkit-upgrade` are `figpress-apps` and
|
|
47
|
+
`figpress-apps-upgrade`; the repository is `nstarman/figpress-apps` (GitHub redirects the old
|
|
48
|
+
address) and the gallery `https://nstarkman.space/figpress-apps/`.
|
|
49
|
+
**Find**: `grep -rn "nstarman/figkit\|skills/figkit\|nstarkman.space/figkit" .`
|
|
50
|
+
**Edit**: use the new names and addresses.
|
|
51
|
+
|
|
52
|
+
Unchanged: the `.fk-*` CSS classes and `--fk-*` custom properties, the data format (1), and the
|
|
53
|
+
figkit releases `v0.1.0` / `v0.2.0` (still installable from their tags; fixes for them stay
|
|
54
|
+
`0.1.N` / `0.2.N`).
|
|
55
|
+
|
|
56
|
+
### Added
|
|
57
|
+
|
|
58
|
+
- Releases are published to PyPI from their tag (`release.yml`, trusted publishing).
|
|
59
|
+
|
|
60
|
+
### Changed
|
|
61
|
+
|
|
62
|
+
- Versioning: releases are dated (`vYYYY.MM.DD`, bug fixes `vYYYY.MM.DD.N`), and every bug fix is
|
|
63
|
+
backported to every released version that has it (constitution 2.0.0, AGENTS.md "Releasing").
|
|
64
|
+
No code changes; pins to `v0.1.0`/`v0.2.0` keep working, and the next release sorts after them.
|
|
65
|
+
|
|
66
|
+
## 0.2.0 — 2026-10-21
|
|
67
|
+
|
|
68
|
+
No breaking changes: no migration needed from 0.1.
|
|
69
|
+
|
|
70
|
+
### Added
|
|
71
|
+
|
|
72
|
+
- Responsive layout classes in `figkit.css`, driven by container queries (the app's own width,
|
|
73
|
+
so they also adapt in narrow notebook cells): `.fk-split` (two columns, stacked below 760 px)
|
|
74
|
+
and `.fk-grid-2` (a 2 × 2-style grid, one column below 320 px).
|
|
75
|
+
- `cssVar(el, name, fallback)`, `rgb(color)` (a frozen `[r, g, b]`) and `context2d(canvas)` (a clear error
|
|
76
|
+
instead of a null context) in `figkit/stage.js`, re-exported by `figkit.js`.
|
|
77
|
+
- `shade(camera, texture, size, out, base)`: optional background `[r, g, b]` (0–255) to composite over.
|
|
78
|
+
|
|
79
|
+
### Fixed
|
|
80
|
+
|
|
81
|
+
- Dark mode: the globe's disc, axes and labels and the panels' origin mark follow the theme
|
|
82
|
+
instead of staying white or near-black; canvases redraw when the system theme changes.
|
|
83
|
+
- Panel headers wrap on narrow screens: titles may wrap, readouts stay on one line.
|
|
84
|
+
- The globe example stacks the globe above its panels on phones.
|
|
85
|
+
- Gallery: each example page links back to the gallery (served and single file).
|
|
86
|
+
- All text files are read and written as UTF-8 (`figkit build`, `export`, the notebook widget):
|
|
87
|
+
non-ASCII characters in figkit's JS, CSS and generated READMEs broke on non-UTF-8 locales
|
|
88
|
+
(e.g. Windows cp1252). A test guards it.
|
|
89
|
+
|
|
90
|
+
### Development
|
|
91
|
+
|
|
92
|
+
- Pull requests get a live gallery preview on Cloudflare Pages and a single **CI Pass** gate.
|
|
93
|
+
- The `versions/vX.Y.x` release process (AGENTS.md).
|
|
94
|
+
|
|
95
|
+
## 0.1.0 — 2026-10-21
|
|
96
|
+
|
|
97
|
+
First release. Specified in `specs/001-figkit-v0-1/`.
|
|
98
|
+
|
|
99
|
+
### Added
|
|
100
|
+
|
|
101
|
+
- Data contract: `figkit.export` / `figkit.load` — a manifest plus plain `.npy` files, dtype rules,
|
|
102
|
+
checked opt-in float64 → float32 narrowing (data format 1).
|
|
103
|
+
- Browser library: `model`, `npy`, `data`, `geom`, `stage`, `globe`, `skypanel` and `figkit.css`.
|
|
104
|
+
- `figkit build` (served site with vendored library and import map; single self-contained HTML that
|
|
105
|
+
opens from `file://`), `figkit serve`, `figkit test`.
|
|
106
|
+
- `figkit.anywidget.Widget` (extra `notebook`): the same app in JupyterLab and marimo.
|
|
107
|
+
- The globe example (`examples/globe/`) and its gallery page.
|
|
108
|
+
- Skills: `figkit` (building a webapp) and `figkit-upgrade` (moving one to a newer tag).
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nathaniel Starkman
|
|
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,77 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: figpress-apps
|
|
3
|
+
Version: 2026.10.28
|
|
4
|
+
Summary: Toolbox for webapps that are the interactive counterparts of scientific papers
|
|
5
|
+
Author: Nathaniel Starkman
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Requires-Python: >=3.11
|
|
9
|
+
Requires-Dist: numpy>=1.24
|
|
10
|
+
Provides-Extra: notebook
|
|
11
|
+
Requires-Dist: anywidget<0.12,>=0.11; extra == 'notebook'
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# figpress-apps
|
|
15
|
+
|
|
16
|
+
*Formerly **figkit** (renamed in 2026.10.28; the [changelog](CHANGELOG.md) says how to move an app).
|
|
17
|
+
Part of the [figpress](https://nstarkman.space/figpress/) family.*
|
|
18
|
+
|
|
19
|
+
A toolbox for webapps that are the interactive counterparts of scientific papers: data, plots and
|
|
20
|
+
movies that readers can explore next to the static PDF, built to keep working for years.
|
|
21
|
+
|
|
22
|
+
- **Python** exports a paper's arrays as plain `.npy` files with a manifest (readers can `np.load`
|
|
23
|
+
them without figpress-apps), builds sites, serves previews and runs the app's JS tests.
|
|
24
|
+
- **Browser** code is plain ES modules and CSS — no build step, no framework, no CDN. Each site
|
|
25
|
+
vendors the exact figpress-apps version it was built with.
|
|
26
|
+
- **Two outputs** from one source: a site for GitHub Pages, and a single HTML file that opens from
|
|
27
|
+
disk, offline, for archives.
|
|
28
|
+
- **Same app in notebooks** (Jupyter, marimo) through anywidget, with state synced back to Python.
|
|
29
|
+
|
|
30
|
+
Gallery: <https://nstarkman.space/figpress-apps/> (published from releases).
|
|
31
|
+
|
|
32
|
+
## Install
|
|
33
|
+
|
|
34
|
+
Pin a release from [PyPI](https://pypi.org/project/figpress-apps/):
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
uv add "figpress-apps==2026.10.28"
|
|
38
|
+
uv add "figpress-apps[notebook]==2026.10.28" # notebooks
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
figkit's releases (`v0.1.0`, `v0.2.0`) stay installable from their git tags.
|
|
42
|
+
|
|
43
|
+
## Use
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
import figpress_apps
|
|
47
|
+
figpress_apps.export("build/data", "points", {"xyz": xyz}, meta={"title": "…"})
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
uv run figpress-apps build webapps/points --data build/data/points --out build/site/points --single-file build/points.html
|
|
52
|
+
uv run figpress-apps serve build/site
|
|
53
|
+
uv run figpress-apps test webapps/points
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
from figpress_apps.anywidget import Widget
|
|
58
|
+
Widget(app="webapps/points/widget.js", data="build/data/points")
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
An app is an `index.html`, a `widget.js` (an anywidget module composing figpress-apps components) and a
|
|
62
|
+
DOM-free `science.js`. See [`examples/globe/`](examples/globe/) and the agent skills in
|
|
63
|
+
[`skills/`](skills/): `figpress-apps` (building a webapp) and `figpress-apps-upgrade` (moving one to a newer tag).
|
|
64
|
+
|
|
65
|
+
## Development
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
uv sync --all-extras
|
|
69
|
+
uv run pytest # Python
|
|
70
|
+
uv run python tests/make_fixtures.py && node --test js-tests/*.test.js # JS
|
|
71
|
+
uv run playwright install chromium webkit && uv run pytest -m smoke --browser chromium --browser webkit
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Design and requirements: [`specs/001-figkit-v0-1/`](specs/001-figkit-v0-1/) (written as figkit). Rules:
|
|
75
|
+
[`.specify/memory/constitution.md`](.specify/memory/constitution.md).
|
|
76
|
+
|
|
77
|
+
License: MIT. The globe example's data: see [`examples/globe/SOURCE.md`](examples/globe/SOURCE.md).
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# figpress-apps
|
|
2
|
+
|
|
3
|
+
*Formerly **figkit** (renamed in 2026.10.28; the [changelog](CHANGELOG.md) says how to move an app).
|
|
4
|
+
Part of the [figpress](https://nstarkman.space/figpress/) family.*
|
|
5
|
+
|
|
6
|
+
A toolbox for webapps that are the interactive counterparts of scientific papers: data, plots and
|
|
7
|
+
movies that readers can explore next to the static PDF, built to keep working for years.
|
|
8
|
+
|
|
9
|
+
- **Python** exports a paper's arrays as plain `.npy` files with a manifest (readers can `np.load`
|
|
10
|
+
them without figpress-apps), builds sites, serves previews and runs the app's JS tests.
|
|
11
|
+
- **Browser** code is plain ES modules and CSS — no build step, no framework, no CDN. Each site
|
|
12
|
+
vendors the exact figpress-apps version it was built with.
|
|
13
|
+
- **Two outputs** from one source: a site for GitHub Pages, and a single HTML file that opens from
|
|
14
|
+
disk, offline, for archives.
|
|
15
|
+
- **Same app in notebooks** (Jupyter, marimo) through anywidget, with state synced back to Python.
|
|
16
|
+
|
|
17
|
+
Gallery: <https://nstarkman.space/figpress-apps/> (published from releases).
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
Pin a release from [PyPI](https://pypi.org/project/figpress-apps/):
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
uv add "figpress-apps==2026.10.28"
|
|
25
|
+
uv add "figpress-apps[notebook]==2026.10.28" # notebooks
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
figkit's releases (`v0.1.0`, `v0.2.0`) stay installable from their git tags.
|
|
29
|
+
|
|
30
|
+
## Use
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
import figpress_apps
|
|
34
|
+
figpress_apps.export("build/data", "points", {"xyz": xyz}, meta={"title": "…"})
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
uv run figpress-apps build webapps/points --data build/data/points --out build/site/points --single-file build/points.html
|
|
39
|
+
uv run figpress-apps serve build/site
|
|
40
|
+
uv run figpress-apps test webapps/points
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from figpress_apps.anywidget import Widget
|
|
45
|
+
Widget(app="webapps/points/widget.js", data="build/data/points")
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
An app is an `index.html`, a `widget.js` (an anywidget module composing figpress-apps components) and a
|
|
49
|
+
DOM-free `science.js`. See [`examples/globe/`](examples/globe/) and the agent skills in
|
|
50
|
+
[`skills/`](skills/): `figpress-apps` (building a webapp) and `figpress-apps-upgrade` (moving one to a newer tag).
|
|
51
|
+
|
|
52
|
+
## Development
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
uv sync --all-extras
|
|
56
|
+
uv run pytest # Python
|
|
57
|
+
uv run python tests/make_fixtures.py && node --test js-tests/*.test.js # JS
|
|
58
|
+
uv run playwright install chromium webkit && uv run pytest -m smoke --browser chromium --browser webkit
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Design and requirements: [`specs/001-figkit-v0-1/`](specs/001-figkit-v0-1/) (written as figkit). Rules:
|
|
62
|
+
[`.specify/memory/constitution.md`](.specify/memory/constitution.md).
|
|
63
|
+
|
|
64
|
+
License: MIT. The globe example's data: see [`examples/globe/SOURCE.md`](examples/globe/SOURCE.md).
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.25"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "figpress-apps"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Toolbox for webapps that are the interactive counterparts of scientific papers"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
authors = [{ name = "Nathaniel Starkman" }]
|
|
12
|
+
requires-python = ">=3.11"
|
|
13
|
+
dependencies = ["numpy>=1.24"]
|
|
14
|
+
|
|
15
|
+
[project.optional-dependencies]
|
|
16
|
+
notebook = ["anywidget>=0.11,<0.12"]
|
|
17
|
+
|
|
18
|
+
[project.scripts]
|
|
19
|
+
figpress-apps = "figpress_apps.cli:main"
|
|
20
|
+
|
|
21
|
+
[dependency-groups]
|
|
22
|
+
dev = ["pytest>=8", "pytest-playwright>=0.10"]
|
|
23
|
+
|
|
24
|
+
[tool.hatch.version]
|
|
25
|
+
path = "src/figpress_apps/__init__.py"
|
|
26
|
+
|
|
27
|
+
[tool.hatch.build.targets.wheel]
|
|
28
|
+
packages = ["src/figpress_apps"]
|
|
29
|
+
|
|
30
|
+
[tool.hatch.build.targets.wheel.force-include]
|
|
31
|
+
"CHANGELOG.md" = "figpress_apps/CHANGELOG.md"
|
|
32
|
+
|
|
33
|
+
[tool.hatch.build.targets.sdist]
|
|
34
|
+
include = ["src/figpress_apps", "README.md", "LICENSE", "CHANGELOG.md"]
|
|
35
|
+
|
|
36
|
+
[tool.pytest.ini_options]
|
|
37
|
+
testpaths = ["tests"]
|
|
38
|
+
markers = ["smoke: browser smoke tests (need Playwright browsers)"]
|
|
39
|
+
addopts = "-m 'not smoke'"
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""figpress-apps: webapps that are the interactive counterparts of scientific papers."""
|
|
2
|
+
|
|
3
|
+
__version__ = "2026.10.28"
|
|
4
|
+
|
|
5
|
+
from figpress_apps.build import BuildError, BuildResult, build, inline_modules # noqa: E402
|
|
6
|
+
from figpress_apps.data import DataError, Dataset, export, load # noqa: E402
|
|
7
|
+
|
|
8
|
+
__all__ = ["BuildError", "BuildResult", "DataError", "Dataset", "__version__", "build", "export",
|
|
9
|
+
"inline_modules", "load"]
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Run a figpress-apps app in Jupyter or marimo (extra: ``figpress-apps[notebook]``).
|
|
2
|
+
|
|
3
|
+
The app module is the same file the web page runs; its imports are inlined into nested ``data:``
|
|
4
|
+
URLs (anywidget loads modules without an import map). The dataset travels as synced traits:
|
|
5
|
+
``manifest``, ``arrays`` (entry name → the ``.npy`` file's bytes) and ``json``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json as _json
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import anywidget
|
|
14
|
+
import traitlets
|
|
15
|
+
|
|
16
|
+
from figpress_apps.build import JS_DIR, inline_modules
|
|
17
|
+
from figpress_apps.data import load
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Widget(anywidget.AnyWidget):
|
|
21
|
+
"""A figpress-apps app as a notebook widget; ``camera`` and ``views`` sync back to Python."""
|
|
22
|
+
|
|
23
|
+
camera = traitlets.List(traitlets.Float()).tag(sync=True)
|
|
24
|
+
views = traitlets.Dict().tag(sync=True)
|
|
25
|
+
manifest = traitlets.Dict().tag(sync=True)
|
|
26
|
+
arrays = traitlets.Dict().tag(sync=True)
|
|
27
|
+
json = traitlets.Dict().tag(sync=True)
|
|
28
|
+
|
|
29
|
+
def __init__(self, app: str | Path, data: str | Path, **kwargs):
|
|
30
|
+
app, data = Path(app), Path(data)
|
|
31
|
+
ds = load(data) # verifies the dataset (format version, checksums)
|
|
32
|
+
entries = ds.manifest["entries"]
|
|
33
|
+
# anywidget turns these attributes into synced traits during __init__
|
|
34
|
+
self._esm = inline_modules(f"app/{app.name}", roots={"figpress-apps/": JS_DIR, "app/": app.parent})
|
|
35
|
+
self._css = (JS_DIR / "index.css").read_text(encoding="utf-8")
|
|
36
|
+
super().__init__(
|
|
37
|
+
manifest=ds.manifest,
|
|
38
|
+
arrays={k: (data / e["file"]).read_bytes() for k, e in entries.items() if e["kind"] == "array"},
|
|
39
|
+
json={k: _json.loads((data / e["file"]).read_text(encoding="utf-8")) for k, e in entries.items() if e["kind"] == "json"},
|
|
40
|
+
**kwargs,
|
|
41
|
+
)
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""Build a webapp into a served site and/or a single self-contained HTML file.
|
|
2
|
+
|
|
3
|
+
Modules import each other only by bare specifiers: ``figpress-apps/…`` (the vendored library) and
|
|
4
|
+
``app/…`` (the app's own files). The served site resolves them with an import map; the single
|
|
5
|
+
file and the notebook wrapper rewrite them into nested ``data:`` URLs (research R1).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import base64
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
import shutil
|
|
14
|
+
from collections.abc import Iterable, Mapping
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from figpress_apps.data import FORMAT, DataError, read_manifest
|
|
19
|
+
|
|
20
|
+
JS_DIR = Path(__file__).parent / "js"
|
|
21
|
+
WEB_SUFFIXES = {".html", ".js", ".css", ".json", ".png", ".jpg", ".jpeg", ".svg", ".webp", ".mp4", ".webm"}
|
|
22
|
+
|
|
23
|
+
_STATIC = re.compile(r"""\bimport\s*(?:[\w*{}\s,$]+?\s*from\s*)?(['"])([^'"\n]+)\1""")
|
|
24
|
+
_EXPORT_FROM = re.compile(r"""\bexport\s*(?:\*\s*(?:as\s+[\w$]+\s*)?|\{[^}]*\})\s*from\s*(['"])([^'"\n]+)\1""")
|
|
25
|
+
_DYNAMIC = re.compile(r"""\bimport\s*\(\s*([^)]*?)\s*\)""")
|
|
26
|
+
_META_URL = re.compile(r"\bimport\.meta\.url\b")
|
|
27
|
+
_MODULE_SCRIPT = re.compile(r"""(<script\b[^>]*\btype=["']module["'][^>]*>)(.*?)(</script>)""", re.S | re.I)
|
|
28
|
+
_ASSET_ATTR = re.compile(r"""\b(href|src)=(["'])figpress-apps/""")
|
|
29
|
+
_STYLESHEET = re.compile(r"""<link\b[^>]*\brel=["']stylesheet["'][^>]*>""", re.I)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class BuildError(ValueError):
|
|
33
|
+
"""The app or its data cannot be built; the message names the file and line."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class BuildResult:
|
|
38
|
+
out: Path | None = None
|
|
39
|
+
single_file: Path | None = None
|
|
40
|
+
warnings: list[str] = field(default_factory=list)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class _Import:
|
|
45
|
+
start: int # offset of the specifier's text (inside the quotes)
|
|
46
|
+
end: int
|
|
47
|
+
spec: str
|
|
48
|
+
line: int
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _imports(source: str, file: str, offset_line: int = 0) -> list[_Import]:
|
|
52
|
+
"""Find every module specifier; reject what the single file and notebooks cannot resolve."""
|
|
53
|
+
line = lambda pos: offset_line + source.count("\n", 0, pos) + 1 # noqa: E731
|
|
54
|
+
if m := _META_URL.search(source):
|
|
55
|
+
raise BuildError(f"{file}:{line(m.start())}: import.meta.url is not allowed (modules may be inlined)")
|
|
56
|
+
found: list[_Import] = []
|
|
57
|
+
for rx in (_STATIC, _EXPORT_FROM):
|
|
58
|
+
found += [_Import(m.start(2), m.end(2), m.group(2), line(m.start())) for m in rx.finditer(source)]
|
|
59
|
+
for m in _DYNAMIC.finditer(source):
|
|
60
|
+
arg = m.group(1)
|
|
61
|
+
lit = re.fullmatch(r"""(['"])([^'"\n]+)\1""", arg)
|
|
62
|
+
if not lit:
|
|
63
|
+
raise BuildError(f"{file}:{line(m.start())}: non-literal dynamic import({arg}); use a string literal")
|
|
64
|
+
found.append(_Import(m.start(1) + 1, m.end(1) - 1, lit.group(2), line(m.start())))
|
|
65
|
+
for imp in found:
|
|
66
|
+
if imp.spec.startswith(("./", "../", "/")):
|
|
67
|
+
raise BuildError(f"{file}:{imp.line}: relative import {imp.spec!r}; use a bare specifier "
|
|
68
|
+
f"(figpress-apps/… or app/…)")
|
|
69
|
+
if re.match(r"^[a-z][a-z0-9+.-]*:|^//", imp.spec):
|
|
70
|
+
raise BuildError(f"{file}:{imp.line}: third-party URL {imp.spec!r}; vendor it instead "
|
|
71
|
+
f"(no relative or remote imports)")
|
|
72
|
+
return sorted(found, key=lambda i: i.start)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _resolve(spec: str, roots: Mapping[str, Path], file: str, line: int) -> Path:
|
|
76
|
+
for prefix, root in roots.items():
|
|
77
|
+
if spec.startswith(prefix):
|
|
78
|
+
path = Path(root) / spec[len(prefix):]
|
|
79
|
+
if path.is_file():
|
|
80
|
+
return path
|
|
81
|
+
raise BuildError(f"{file}:{line}: cannot resolve {spec!r} (known prefixes: {', '.join(roots)})")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _check_tree(sources: Iterable[tuple[str, str, int]], roots: Mapping[str, Path]) -> None:
|
|
85
|
+
"""Check every module reachable from the given (source, file, line offset) entries."""
|
|
86
|
+
seen: set[Path] = set()
|
|
87
|
+
stack = list(sources)
|
|
88
|
+
while stack:
|
|
89
|
+
source, file, off = stack.pop()
|
|
90
|
+
for imp in _imports(source, file, off):
|
|
91
|
+
path = _resolve(imp.spec, roots, file, imp.line)
|
|
92
|
+
if path not in seen:
|
|
93
|
+
seen.add(path)
|
|
94
|
+
stack.append((path.read_text(encoding="utf-8"), path.name, 0))
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _data_url(text: str | bytes, mime: str) -> str:
|
|
98
|
+
raw = text.encode() if isinstance(text, str) else text
|
|
99
|
+
return f"data:{mime};base64,{base64.b64encode(raw).decode()}"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _rewrite(source: str, file: str, roots: Mapping[str, Path], memo: dict, stack: tuple, off: int = 0) -> str:
|
|
103
|
+
out, last = [], 0
|
|
104
|
+
for imp in _imports(source, file, off):
|
|
105
|
+
path = _resolve(imp.spec, roots, file, imp.line)
|
|
106
|
+
out += [source[last:imp.start], _inline_path(path, roots, memo, stack)]
|
|
107
|
+
last = imp.end
|
|
108
|
+
return "".join(out) + source[last:]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _inline_path(path: Path, roots: Mapping[str, Path], memo: dict, stack: tuple) -> str:
|
|
112
|
+
if path in stack:
|
|
113
|
+
chain = " → ".join(p.name for p in (*stack, path))
|
|
114
|
+
raise BuildError(f"{path.name}: import cycle {chain}")
|
|
115
|
+
if path not in memo:
|
|
116
|
+
memo[path] = _data_url(_rewrite(path.read_text(encoding="utf-8"), path.name, roots, memo, (*stack, path)),
|
|
117
|
+
"text/javascript")
|
|
118
|
+
return memo[path]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def inline_modules(entry: str, *, roots: Mapping[str, Path]) -> str:
|
|
122
|
+
"""Return the entry module's source with every import rewritten to nested ``data:`` URLs.
|
|
123
|
+
|
|
124
|
+
``entry`` is a bare specifier (e.g. ``"app/widget.js"``); ``roots`` maps prefixes to folders,
|
|
125
|
+
e.g. ``{"figpress-apps/": apps_js_dir, "app/": app_dir}``. Identical modules become identical URLs,
|
|
126
|
+
so each module is instantiated once.
|
|
127
|
+
"""
|
|
128
|
+
path = _resolve(entry, roots, entry, 0)
|
|
129
|
+
return _rewrite(path.read_text(encoding="utf-8"), path.name, roots, {}, (path,))
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _insert_head(html: str, snippet: str) -> str:
|
|
133
|
+
m = re.search(r"<head\b[^>]*>", html, re.I)
|
|
134
|
+
if m:
|
|
135
|
+
return html[:m.end()] + "\n" + snippet + html[m.end():]
|
|
136
|
+
return snippet + "\n" + html
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _version_meta() -> str:
|
|
140
|
+
from figpress_apps import __version__
|
|
141
|
+
|
|
142
|
+
return f'<meta name="figpress-apps-version" content="{__version__}">'
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _check_data(data: Iterable[str | Path]) -> list[Path]:
|
|
146
|
+
paths = [Path(d) for d in data]
|
|
147
|
+
for d in paths:
|
|
148
|
+
try:
|
|
149
|
+
read_manifest(d)
|
|
150
|
+
except DataError as e:
|
|
151
|
+
raise BuildError(f"{e} (this figpress-apps reads figpress_apps_data {FORMAT})") from None
|
|
152
|
+
return paths
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def build(
|
|
156
|
+
app_dir: str | Path,
|
|
157
|
+
*,
|
|
158
|
+
data: Iterable[str | Path] = (),
|
|
159
|
+
out: str | Path | None = None,
|
|
160
|
+
single_file: str | Path | None = None,
|
|
161
|
+
size_warning_mb: float = 50.0,
|
|
162
|
+
) -> BuildResult:
|
|
163
|
+
"""Build ``app_dir`` (an ``index.html`` plus app modules) into a served site and/or one file."""
|
|
164
|
+
from figpress_apps import __version__
|
|
165
|
+
|
|
166
|
+
if out is None and single_file is None:
|
|
167
|
+
raise BuildError("nothing to build: give out and/or single_file")
|
|
168
|
+
app = Path(app_dir)
|
|
169
|
+
index = app / "index.html"
|
|
170
|
+
if not index.is_file():
|
|
171
|
+
raise BuildError(f"{index} not found")
|
|
172
|
+
roots = {"figpress-apps/": JS_DIR, "app/": app}
|
|
173
|
+
html = index.read_text(encoding="utf-8")
|
|
174
|
+
scripts = [(m.group(2), "index.html", html.count("\n", 0, m.start(2))) for m in _MODULE_SCRIPT.finditer(html)]
|
|
175
|
+
app_js = [(p.read_text(encoding="utf-8"), p.name, 0) for p in sorted(app.glob("*.js")) if not p.name.endswith(".test.js")]
|
|
176
|
+
lib_js = [(p.read_text(encoding="utf-8"), f"figpress-apps/{p.name}", 0) for p in sorted(JS_DIR.glob("*.js"))]
|
|
177
|
+
_check_tree([*scripts, *app_js, *lib_js], roots)
|
|
178
|
+
datasets = _check_data(data)
|
|
179
|
+
result = BuildResult()
|
|
180
|
+
|
|
181
|
+
if out is not None:
|
|
182
|
+
site = Path(out)
|
|
183
|
+
if site.exists():
|
|
184
|
+
shutil.rmtree(site)
|
|
185
|
+
site.mkdir(parents=True)
|
|
186
|
+
for f in app.iterdir():
|
|
187
|
+
if f.is_file() and f.suffix in WEB_SUFFIXES and not f.name.endswith(".test.js"):
|
|
188
|
+
shutil.copy2(f, site / f.name)
|
|
189
|
+
vendor = f"vendor/figpress-apps-{__version__}"
|
|
190
|
+
shutil.copytree(JS_DIR, site / vendor)
|
|
191
|
+
imap = json.dumps({"imports": {"figpress-apps/": f"./{vendor}/", "app/": "./"}})
|
|
192
|
+
page = _ASSET_ATTR.sub(lambda m: f"{m.group(1)}={m.group(2)}./{vendor}/", html)
|
|
193
|
+
page = _insert_head(page, f'{_version_meta()}\n<script type="importmap">{imap}</script>')
|
|
194
|
+
(site / "index.html").write_text(page, encoding="utf-8")
|
|
195
|
+
for d in datasets:
|
|
196
|
+
shutil.copytree(d, site / "data" / d.name)
|
|
197
|
+
result.out = site
|
|
198
|
+
|
|
199
|
+
if single_file is not None:
|
|
200
|
+
memo: dict = {}
|
|
201
|
+
page = _MODULE_SCRIPT.sub(
|
|
202
|
+
lambda m: m.group(1) + _rewrite(m.group(2), "index.html", roots, memo, (),
|
|
203
|
+
html.count("\n", 0, m.start(2))) + m.group(3),
|
|
204
|
+
html,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
def inline_css(m: re.Match) -> str:
|
|
208
|
+
href = re.search(r"""\bhref=["']([^"']+)["']""", m.group(0)).group(1)
|
|
209
|
+
path = JS_DIR / href[len("figpress-apps/"):] if href.startswith("figpress-apps/") else app / href
|
|
210
|
+
if not path.is_file():
|
|
211
|
+
raise BuildError(f"index.html: stylesheet {href!r} not found")
|
|
212
|
+
css = path.read_text(encoding="utf-8")
|
|
213
|
+
return f"<style>\n{css}</style>"
|
|
214
|
+
|
|
215
|
+
page = _STYLESHEET.sub(inline_css, page)
|
|
216
|
+
registry = {
|
|
217
|
+
d.name: {"name": d.name, "urls": {f.name: _data_url(f.read_bytes(), "application/octet-stream")
|
|
218
|
+
for f in sorted(d.iterdir()) if f.is_file()}}
|
|
219
|
+
for d in datasets
|
|
220
|
+
}
|
|
221
|
+
page = _insert_head(page, f"{_version_meta()}\n<script>globalThis.FIGPRESS_APPS_DATA = {json.dumps(registry)};</script>")
|
|
222
|
+
target = Path(single_file)
|
|
223
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
224
|
+
target.write_text(page, encoding="utf-8")
|
|
225
|
+
size_mb = target.stat().st_size / 1e6
|
|
226
|
+
if size_mb > size_warning_mb:
|
|
227
|
+
result.warnings.append(f"{target} is {size_mb:.1f} MB (above {size_warning_mb:g} MB); "
|
|
228
|
+
f"consider the served build or smaller data")
|
|
229
|
+
result.single_file = target
|
|
230
|
+
return result
|