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.
@@ -0,0 +1,10 @@
1
+ __pycache__/
2
+ *.egg-info/
3
+ .venv/
4
+ dist/
5
+ site/
6
+ node_modules/
7
+ .DS_Store
8
+ js-tests/fixtures/
9
+ build/
10
+ .pytest_cache/
@@ -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