0883155432
- frontend/DESIGN_SYSTEM.md: contrato del @fn_library (regla suprema para Claude Design y agentes). - frontend/design_prompts/: 11 plantillas de prompt (onboarding, dashboard, crud, detail, settings, auth, error, custom, handoff_integrate) + questionnaire numerado para arranque rapido. - .claude/commands/extract-design.md: workflow de 10 pasos para extraer componentes nuevos y mejoras desde exports "standalone" de Claude Design al registry, sync al espejo fn-design-system y push a gitea+github. - .claude/scripts/extract_design_bundle.py: decodificador del bundle (base64+gzip en manifest, nombra JSX por heuristica de header). - .gitignore: ignorar subrepos/*/ (el mirror fn-design-system es repo propio con remotes dataforge/fn-design-system + gutierenmanuel/fn-design-system). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
160 lines
5.0 KiB
Python
Executable File
160 lines
5.0 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
Extract Claude Design "standalone" HTML exports.
|
|
|
|
Claude Design packs the whole React app as base64+gzip blobs inside
|
|
<script type="__bundler/manifest"> tags. This script decompresses them
|
|
and writes each asset (JSX, CSS, fonts) to a target directory.
|
|
|
|
Usage:
|
|
python3 extract_design_bundle.py <path/to/export.html> <output_dir>
|
|
|
|
The output dir will contain:
|
|
data.jsx (if detected by header comment)
|
|
fn_library_emu.jsx (lib emulation)
|
|
charts_emu.jsx (charts emulation)
|
|
app.jsx (main tree)
|
|
<uuid>.<ext> (anything else — fonts, unknown js)
|
|
manifest.json (summary of all assets: uuid, mime, bytes, filename)
|
|
|
|
JSX files are named heuristically from their leading comment. If names
|
|
cannot be inferred from headers, they keep their uuid prefix.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import gzip
|
|
import json
|
|
import pathlib
|
|
import re
|
|
import sys
|
|
|
|
|
|
MIME_TO_EXT = {
|
|
"text/javascript": "js",
|
|
"application/javascript": "js",
|
|
"text/babel": "jsx",
|
|
"application/json": "json",
|
|
"text/css": "css",
|
|
"image/svg+xml": "svg",
|
|
"font/woff2": "woff2",
|
|
"font/woff": "woff",
|
|
"text/html": "html",
|
|
}
|
|
|
|
# Order matters: first matching hint wins. Put MORE SPECIFIC patterns first.
|
|
HEADER_HINTS = [
|
|
("charts_emu.jsx", [r"Emulaci(ó|o)n de @fn_library/\{", r"LineChart, AreaChart, BarChart"]),
|
|
("fn_library_emu.jsx", [r"Emulaci(ó|o)n visual de @fn_library"]),
|
|
("data.jsx", [r"mock data \(determinista\)", r"window\.\w+Data\s*="]),
|
|
("app.jsx", [r"ReactDOM\.createRoot", r"arbol principal", r"function App\s*\("]),
|
|
]
|
|
|
|
|
|
def pick_name(content: str, used_names: set[str]) -> str | None:
|
|
head = content[:2000]
|
|
for name, patterns in HEADER_HINTS:
|
|
if name in used_names:
|
|
continue
|
|
if any(re.search(p, head, re.IGNORECASE) for p in patterns):
|
|
return name
|
|
return None
|
|
|
|
|
|
def grab_script(html: str, kind: str) -> str | None:
|
|
m = re.search(
|
|
r'<script type="__bundler/' + kind + r'">\s*(.*?)\s*</script>',
|
|
html, re.DOTALL,
|
|
)
|
|
return m.group(1) if m else None
|
|
|
|
|
|
def extract(html_path: pathlib.Path, out_dir: pathlib.Path) -> dict:
|
|
html = html_path.read_text(encoding="utf-8")
|
|
|
|
manifest_raw = grab_script(html, "manifest")
|
|
if not manifest_raw:
|
|
raise SystemExit(f"No <script type='__bundler/manifest'> found in {html_path}")
|
|
manifest = json.loads(manifest_raw)
|
|
|
|
ext_raw = grab_script(html, "ext_resources")
|
|
ext_resources = json.loads(ext_raw) if ext_raw else []
|
|
id_map = {e["uuid"]: e.get("id", e["uuid"]) for e in ext_resources}
|
|
|
|
out_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
summary = []
|
|
used_names: set[str] = set()
|
|
|
|
# First pass: decode all assets and collect jsx blobs (so we can name them by header hint)
|
|
decoded: list[tuple[str, str, bytes]] = [] # (uuid, mime, bytes)
|
|
for uuid, entry in manifest.items():
|
|
raw = base64.b64decode(entry["data"])
|
|
if entry.get("compressed"):
|
|
raw = gzip.decompress(raw)
|
|
decoded.append((uuid, entry.get("mime", "application/octet-stream"), raw))
|
|
|
|
# Second pass: write files with heuristic names for known jsx
|
|
for uuid, mime, raw in decoded:
|
|
ext = MIME_TO_EXT.get(mime, "bin")
|
|
filename = None
|
|
|
|
# Heuristic for JSX / JS that represents the app
|
|
if ext in ("jsx", "js"):
|
|
try:
|
|
text = raw.decode("utf-8", errors="replace")
|
|
name = pick_name(text, used_names)
|
|
if name:
|
|
filename = name
|
|
used_names.add(name)
|
|
except Exception:
|
|
pass
|
|
|
|
if not filename:
|
|
# Fall back to ext_resources id if present, or uuid
|
|
base = id_map.get(uuid, uuid)
|
|
safe = re.sub(r"[^A-Za-z0-9._-]", "_", base)[:80]
|
|
filename = f"{safe}.{ext}"
|
|
|
|
path = out_dir / filename
|
|
# Avoid collisions
|
|
i = 2
|
|
while path.exists():
|
|
stem = path.stem
|
|
path = out_dir / f"{stem}_{i}.{ext}"
|
|
i += 1
|
|
path.write_bytes(raw)
|
|
summary.append({
|
|
"uuid": uuid,
|
|
"mime": mime,
|
|
"bytes": len(raw),
|
|
"filename": path.name,
|
|
})
|
|
|
|
(out_dir / "manifest.json").write_text(
|
|
json.dumps({"source": str(html_path), "assets": summary}, indent=2),
|
|
encoding="utf-8",
|
|
)
|
|
return {"assets": summary, "out": str(out_dir)}
|
|
|
|
|
|
def main():
|
|
if len(sys.argv) < 3:
|
|
print(__doc__)
|
|
sys.exit(2)
|
|
html = pathlib.Path(sys.argv[1])
|
|
out = pathlib.Path(sys.argv[2])
|
|
if not html.exists():
|
|
sys.exit(f"Input not found: {html}")
|
|
result = extract(html, out)
|
|
print(f"✓ Extracted {len(result['assets'])} assets to {result['out']}")
|
|
print(f" Manifest: {out}/manifest.json")
|
|
print()
|
|
# Short preview per asset
|
|
for a in result["assets"]:
|
|
print(f" {a['mime']:28s} {a['bytes']:>8} B {a['filename']}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|