reflex-reactbits 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
File without changes
@@ -0,0 +1,236 @@
1
+ """Fetch React Bits component sources into the Reflex app that uses them.
2
+
3
+ React Bits (https://reactbits.dev) is distributed as copy-paste source code,
4
+ not as an npm package, and its license (MIT + Commons Clause) does not allow
5
+ redistributing the components themselves in a bundle. This package therefore
6
+ ships only the Python wrappers: the first time a component is used, its
7
+ original JSX/CSS source is copied into the app's ``assets/reactbits/``
8
+ directory, exactly as the official ``shadcn``/``jsrepo`` CLIs would copy it
9
+ into a React project. The files are then owned by the app and can be edited
10
+ freely; existing files are never overwritten.
11
+
12
+ Sources are resolved in this order:
13
+
14
+ 1. ``REACTBITS_SOURCE_DIR`` - path to a local clone of the React Bits repository.
15
+ 2. GitHub raw files of ``DavidHDev/react-bits`` at ``REACTBITS_REF`` (defaults to
16
+ the commit these wrappers were generated and tested against).
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import logging
22
+ import os
23
+ import shutil
24
+ import urllib.error
25
+ import urllib.request
26
+ from collections.abc import Iterable
27
+ from pathlib import Path
28
+
29
+ logger = logging.getLogger("reflex_reactbits")
30
+
31
+ #: Commit of DavidHDev/react-bits the wrappers were generated from.
32
+ PINNED_REF = "5fc9addb5b2362043332ad6d403bb436f2596318"
33
+
34
+ #: Directory (inside the app's ``assets/``) that receives the component sources.
35
+ ASSETS_SUBDIR = "reactbits"
36
+
37
+ RAW_URL = "https://raw.githubusercontent.com/DavidHDev/react-bits/{ref}/{path}"
38
+
39
+ # Files a component loads from the React Bits site's public/ folder at runtime.
40
+ # They are copied next to the component and imported with Vite's ``?url``.
41
+ EXTRA_FILES: dict[tuple[str, str], dict[str, str]] = {
42
+ ("Components", "FluidGlass"): {
43
+ "lens.glb": "public/assets/3d/lens.glb",
44
+ "cube.glb": "public/assets/3d/cube.glb",
45
+ "bar.glb": "public/assets/3d/bar.glb",
46
+ },
47
+ }
48
+
49
+ _TEXT_SUFFIXES = {".js", ".jsx", ".css", ".ts", ".tsx", ".json"}
50
+
51
+ # Small, surgical patches so the plain JS-CSS variant runs inside a Reflex app
52
+ # (which has no react-router, Chakra UI or Tailwind by default).
53
+ PATCHES: dict[tuple[str, str], list[tuple[str, str]]] = {
54
+ # Ballpit renders its own <canvas> through a React ref, so React keeps and
55
+ # reuses that DOM node across remounts. forceContextLoss() loses the context
56
+ # of that canvas permanently: on the next mount getContext() hands back the
57
+ # same lost context, gl.getShaderPrecisionFormat() returns null and three.js
58
+ # throws "Cannot read properties of null (reading 'precision')" while
59
+ # building WebGLCapabilities. dispose() alone already frees the GPU
60
+ # resources, and reusing the live context avoids leaking one per remount.
61
+ ("Backgrounds", "Ballpit.jsx"): [
62
+ (
63
+ " this.renderer.dispose();\n this.renderer.forceContextLoss();\n",
64
+ " this.renderer.dispose();\n",
65
+ ),
66
+ ],
67
+ # Hyperspeed loads its SMAA assets asynchronously and then calls init().
68
+ # If the component unmounts during that window (React StrictMode remounts
69
+ # every effect in dev), the cleanup already ran dispose(), which calls
70
+ # renderer.forceContextLoss(). The pending promise then initialises a dead
71
+ # App: postprocessing reads renderer.getContext().getContextAttributes(),
72
+ # which is null on a lost context, and throws "can't access property alpha".
73
+ ("Backgrounds", "Hyperspeed.jsx"): [
74
+ (
75
+ " init() {\n this.initPasses();",
76
+ " init() {\n if (this.disposed) return;\n this.initPasses();",
77
+ ),
78
+ ],
79
+ ("TextAnimations", "BlurText.jsx"): [
80
+ (
81
+ 'className="inline-block will-change-[transform,filter,opacity]"',
82
+ "style={{ display: 'inline-block', willChange: 'transform, filter, opacity' }}",
83
+ ),
84
+ ],
85
+ ("Components", "PillNav.jsx"): [
86
+ ("import { Link } from 'react-router-dom';\n", ""),
87
+ ("<Link", "<a"),
88
+ ("</Link>", "</a>"),
89
+ (" to={", " href={"),
90
+ ],
91
+ ("Components", "ElasticSlider.jsx"): [
92
+ ("import { Icon } from '@chakra-ui/react';\n", ""),
93
+ ("<Icon as={RiVolumeDownFill} />", "<RiVolumeDownFill />"),
94
+ ("<Icon as={RiVolumeUpFill} />", "<RiVolumeUpFill />"),
95
+ ],
96
+ ("Components", "Lanyard.jsx"): [
97
+ ("from './card.glb';", "from './card.glb?url';"),
98
+ ],
99
+ ("Components", "FluidGlass.jsx"): [
100
+ (
101
+ "import * as THREE from 'three';\n",
102
+ "import * as THREE from 'three';\n"
103
+ "import lensGlb from './lens.glb?url';\n"
104
+ "import cubeGlb from './cube.glb?url';\n"
105
+ "import barGlb from './bar.glb?url';\n",
106
+ ),
107
+ ('glb="/assets/3d/lens.glb"', "glb={lensGlb}"),
108
+ ('glb="/assets/3d/cube.glb"', "glb={cubeGlb}"),
109
+ ('glb="/assets/3d/bar.glb"', "glb={barGlb}"),
110
+ ],
111
+ }
112
+
113
+
114
+ def app_assets_dir() -> Path:
115
+ """Return the ``assets/`` directory of the Reflex app being compiled."""
116
+ return Path.cwd() / "assets"
117
+
118
+
119
+ def component_dir(category: str, name: str, assets_dir: Path | None = None) -> Path:
120
+ """Return where a component's source lives inside the app."""
121
+ return (assets_dir or app_assets_dir()) / ASSETS_SUBDIR / category / name
122
+
123
+
124
+ def _apply_patches(category: str, file: str, data: bytes) -> bytes:
125
+ patches = PATCHES.get((category, file))
126
+ if not patches:
127
+ return data
128
+ text = data.decode("utf-8")
129
+ for old, new in patches:
130
+ if old not in text:
131
+ # The patch anchors on upstream code at PINNED_REF. A miss means the
132
+ # vendored file keeps an import or a call the wrappers do not expect
133
+ # (for instance a dependency listed in DROPPED_DEPS, which is then
134
+ # never installed), so say so here rather than fail later in Vite.
135
+ logger.warning(
136
+ "React Bits patch for %s/%s no longer matches upstream and was skipped; "
137
+ "the vendored source may not work with this wrapper.", category, file,
138
+ )
139
+ continue
140
+ text = text.replace(old, new)
141
+ return text.encode("utf-8")
142
+
143
+
144
+ def _repo_path(category: str, name: str, file: str) -> str:
145
+ extra = EXTRA_FILES.get((category, name), {})
146
+ return extra.get(file) or f"src/content/{category}/{name}/{file}"
147
+
148
+
149
+ def _read_local(repo_path: str) -> bytes | None:
150
+ root = os.environ.get("REACTBITS_SOURCE_DIR")
151
+ if not root:
152
+ return None
153
+ path = Path(root).expanduser() / repo_path
154
+ return path.read_bytes() if path.exists() else None
155
+
156
+
157
+ def _read_remote(repo_path: str, ref: str) -> bytes:
158
+ url = RAW_URL.format(ref=ref, path=repo_path)
159
+ # RAW_URL is a constant https template, so the scheme can never be attacker-chosen.
160
+ request = urllib.request.Request(url, headers={"User-Agent": "reflex-reactbits"}) # noqa: S310
161
+ try:
162
+ with urllib.request.urlopen(request, timeout=30) as response: # noqa: S310 # nosec B310
163
+ return response.read()
164
+ except urllib.error.URLError as err:
165
+ msg = (
166
+ f"Could not download React Bits source {url}: {err}. Run "
167
+ "`reactbits add <Name>` with network access, or set REACTBITS_SOURCE_DIR "
168
+ "to a local clone of https://github.com/DavidHDev/react-bits."
169
+ )
170
+ local = os.environ.get("REACTBITS_SOURCE_DIR")
171
+ if local:
172
+ msg = (
173
+ f"REACTBITS_SOURCE_DIR is set to {local}, but it has no {repo_path}, "
174
+ f"so the download from {url} was attempted and failed: {err}. Check that "
175
+ "the clone is complete and matches the expected layout."
176
+ )
177
+ raise RuntimeError(msg) from err
178
+
179
+
180
+ def backend_only() -> bool:
181
+ """Whether this process runs without a frontend to copy component sources for."""
182
+ return os.environ.get("REFLEX_BACKEND_ONLY", "").lower() in {"1", "true", "yes"}
183
+
184
+
185
+ def ensure_component_source(
186
+ category: str,
187
+ name: str,
188
+ files: Iterable[str],
189
+ *,
190
+ assets_dir: Path | None = None,
191
+ ref: str | None = None,
192
+ force: bool = False,
193
+ ) -> list[Path]:
194
+ """Make sure the component's source files exist in the app's assets.
195
+
196
+ Args:
197
+ category: React Bits category folder (e.g. ``TextAnimations``).
198
+ name: Component folder name (e.g. ``BlurText``).
199
+ files: File names that make up the component.
200
+ assets_dir: Override the app assets directory.
201
+ ref: Git ref of the React Bits repository to download from.
202
+ force: Re-download and overwrite existing files.
203
+
204
+ Returns:
205
+ The list of files written during this call.
206
+ """
207
+ target = component_dir(category, name, assets_dir)
208
+ ref = ref or os.environ.get("REACTBITS_REF") or PINNED_REF
209
+ written: list[Path] = []
210
+ for file in [*files, *EXTRA_FILES.get((category, name), {})]:
211
+ dest = target / file
212
+ if dest.exists() and not force:
213
+ continue
214
+ repo_path = _repo_path(category, name, file)
215
+ data = _read_local(repo_path)
216
+ if data is None:
217
+ data = _read_remote(repo_path, ref)
218
+ if Path(file).suffix in _TEXT_SUFFIXES:
219
+ data = _apply_patches(category, file, data)
220
+ dest.parent.mkdir(parents=True, exist_ok=True)
221
+ tmp = dest.with_suffix(dest.suffix + ".tmp")
222
+ tmp.write_bytes(data)
223
+ tmp.replace(dest)
224
+ written.append(dest)
225
+ if written:
226
+ logger.info("reflex-reactbits: added %s/%s to %s", category, name, target)
227
+ return written
228
+
229
+
230
+ def remove_component_source(category: str, name: str, assets_dir: Path | None = None) -> bool:
231
+ """Delete a component's copied source from the app assets."""
232
+ target = component_dir(category, name, assets_dir)
233
+ if target.exists():
234
+ shutil.rmtree(target)
235
+ return True
236
+ return False