@maccesar/aiskills 1.7.0
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.
- package/README.md +531 -0
- package/bin/aiskills.js +76 -0
- package/lib/cache.js +49 -0
- package/lib/cleanup.js +77 -0
- package/lib/commands/auto-update.js +131 -0
- package/lib/commands/doctor.js +139 -0
- package/lib/commands/list.js +77 -0
- package/lib/commands/skills.js +263 -0
- package/lib/commands/status.js +94 -0
- package/lib/commands/uninstall.js +182 -0
- package/lib/commands/update.js +149 -0
- package/lib/config.js +90 -0
- package/lib/downloader.js +110 -0
- package/lib/hooks.js +74 -0
- package/lib/installer.js +114 -0
- package/lib/platform.js +112 -0
- package/lib/prompts/checkboxCancel.js +264 -0
- package/lib/prompts/selectCancel.js +204 -0
- package/lib/symlink.js +154 -0
- package/lib/utils.js +49 -0
- package/package.json +61 -0
- package/skills/humaniza/SKILL.md +51 -0
- package/skills/humaniza/agents/openai.yaml +4 -0
- package/skills/humaniza/references/ai-patterns-es.md +51 -0
- package/skills/humaniza/references/checklist.md +9 -0
- package/skills/humaniza/references/examples.md +17 -0
- package/skills/humaniza/references/lexicon-es-mx.md +36 -0
- package/skills/humaniza/references/modes-es-mx.md +41 -0
- package/skills/humaniza/references/voice-es-mx.md +24 -0
- package/skills/refactoring-ui/SKILL.md +59 -0
- package/skills/refactoring-ui/references/01-design-process.md +72 -0
- package/skills/refactoring-ui/references/02-visual-hierarchy.md +84 -0
- package/skills/refactoring-ui/references/03-layout-spacing.md +69 -0
- package/skills/refactoring-ui/references/04-typography.md +70 -0
- package/skills/refactoring-ui/references/05-color.md +96 -0
- package/skills/refactoring-ui/references/06-depth-shadows.md +74 -0
- package/skills/refactoring-ui/references/07-images.md +75 -0
- package/skills/refactoring-ui/references/08-finishing-touches.md +91 -0
- package/skills/stitch-showcase/SKILL.md +411 -0
- package/skills/stitch-showcase/references/01-navbar.md +52 -0
- package/skills/stitch-showcase/references/02-hero.md +56 -0
- package/skills/stitch-showcase/references/03-design-system.md +102 -0
- package/skills/stitch-showcase/references/04-screen-gallery.md +102 -0
- package/skills/stitch-showcase/references/05-viewer-web.md +105 -0
- package/skills/stitch-showcase/references/06-viewer-mobile.md +104 -0
- package/skills/stitch-showcase/references/07-theme-system.md +77 -0
- package/skills/stitch-showcase/references/08-type-detection.md +81 -0
- package/skills/stitch-showcase/references/09-quality-standards.md +126 -0
- package/skills/stitch-showcase/references/10-component-standardization.md +40 -0
- package/skills/stitch-showcase/references/11-component-catalog.md +70 -0
- package/skills/stitch-showcase/references/catalog-template.html +841 -0
- package/skills/stitch-showcase/references/index.html +299 -0
- package/skills/stitch-showcase/references/viewer.html +412 -0
- package/skills/stitch-showcase/scripts/__pycache__/build_showcase.cpython-314.pyc +0 -0
- package/skills/stitch-showcase/scripts/__pycache__/component_utils.cpython-313.pyc +0 -0
- package/skills/stitch-showcase/scripts/__pycache__/detect_components.cpython-313.pyc +0 -0
- package/skills/stitch-showcase/scripts/__pycache__/extract_catalog.cpython-313.pyc +0 -0
- package/skills/stitch-showcase/scripts/__pycache__/extract_text.cpython-313.pyc +0 -0
- package/skills/stitch-showcase/scripts/__pycache__/extract_zips.cpython-313.pyc +0 -0
- package/skills/stitch-showcase/scripts/__pycache__/parse_design_md.cpython-313.pyc +0 -0
- package/skills/stitch-showcase/scripts/apply_canonical.py +238 -0
- package/skills/stitch-showcase/scripts/build_showcase.py +2103 -0
- package/skills/stitch-showcase/scripts/component_utils.py +398 -0
- package/skills/stitch-showcase/scripts/detect_components.py +284 -0
- package/skills/stitch-showcase/scripts/extract_catalog.py +913 -0
- package/skills/stitch-showcase/scripts/extract_text.py +268 -0
- package/skills/stitch-showcase/scripts/extract_zips.py +178 -0
- package/skills/stitch-showcase/scripts/parse_design_md.py +397 -0
- package/skills/vscode-extension-dev/SKILL.md +114 -0
- package/skills/vscode-extension-dev/references/api-patterns.md +625 -0
- package/skills/vscode-extension-dev/references/architecture.md +287 -0
- package/skills/vscode-extension-dev/references/package-json-schema.md +345 -0
- package/skills/vscode-extension-dev/references/publishing.md +251 -0
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
"""
|
|
2
|
+
extract_text.py — Extract visible text from Stitch HTML files.
|
|
3
|
+
|
|
4
|
+
Produces a compact text summary per screen, suitable for LLM consumption
|
|
5
|
+
without reading the full HTML. This dramatically reduces token usage when
|
|
6
|
+
generating DESIGN.md descriptions.
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
# As a module (from build_showcase.py):
|
|
10
|
+
from extract_text import extract_visible_text, extract_all_screens_text
|
|
11
|
+
|
|
12
|
+
# Standalone:
|
|
13
|
+
python extract_text.py /path/to/assets/ → prints summaries to stdout
|
|
14
|
+
"""
|
|
15
|
+
import re
|
|
16
|
+
import sys
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from html import unescape
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def extract_visible_text(html_path: Path) -> dict:
|
|
22
|
+
"""
|
|
23
|
+
Extract visible text content from a Stitch HTML file.
|
|
24
|
+
|
|
25
|
+
Returns a dict with:
|
|
26
|
+
- headings: list of h1-h6 text
|
|
27
|
+
- paragraphs: list of paragraph text
|
|
28
|
+
- buttons: list of button/link text
|
|
29
|
+
- lists: list of li text
|
|
30
|
+
- inputs: list of placeholder/label text
|
|
31
|
+
- meta_title: <title> text if any
|
|
32
|
+
- meta_desc: <meta description> if any
|
|
33
|
+
- css_colors: list of hex colors found in CSS
|
|
34
|
+
- css_fonts: list of font families found in CSS
|
|
35
|
+
"""
|
|
36
|
+
if not html_path.exists():
|
|
37
|
+
return {}
|
|
38
|
+
|
|
39
|
+
raw = html_path.read_text(encoding="utf-8", errors="replace")
|
|
40
|
+
|
|
41
|
+
# Extract meta info before stripping
|
|
42
|
+
meta_title = _extract_meta_title(raw)
|
|
43
|
+
meta_desc = _extract_meta_desc(raw)
|
|
44
|
+
css_colors = _extract_css_colors(raw)
|
|
45
|
+
css_fonts = _extract_css_fonts(raw)
|
|
46
|
+
|
|
47
|
+
# Strip non-visible content
|
|
48
|
+
clean = _strip_invisible(raw)
|
|
49
|
+
|
|
50
|
+
# Extract structured text
|
|
51
|
+
headings = _extract_by_tag(clean, r"<h[1-6][^>]*>(.*?)</h[1-6]>")
|
|
52
|
+
paragraphs = _extract_by_tag(clean, r"<p[^>]*>(.*?)</p>")
|
|
53
|
+
buttons = _extract_buttons(clean)
|
|
54
|
+
lists = _extract_by_tag(clean, r"<li[^>]*>(.*?)</li>")
|
|
55
|
+
inputs = _extract_inputs(clean)
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
"headings": headings,
|
|
59
|
+
"paragraphs": paragraphs,
|
|
60
|
+
"buttons": buttons,
|
|
61
|
+
"lists": lists,
|
|
62
|
+
"inputs": inputs,
|
|
63
|
+
"meta_title": meta_title,
|
|
64
|
+
"meta_desc": meta_desc,
|
|
65
|
+
"css_colors": css_colors,
|
|
66
|
+
"css_fonts": css_fonts,
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def extract_all_screens_text(assets_dir: Path) -> list[dict]:
|
|
71
|
+
"""
|
|
72
|
+
Extract text from all HTML files in an assets directory.
|
|
73
|
+
|
|
74
|
+
Returns a list of dicts, each with 'slug' and 'text' keys.
|
|
75
|
+
"""
|
|
76
|
+
results = []
|
|
77
|
+
html_files = sorted(assets_dir.glob("*.html"))
|
|
78
|
+
|
|
79
|
+
for html_path in html_files:
|
|
80
|
+
slug = html_path.stem
|
|
81
|
+
text_data = extract_visible_text(html_path)
|
|
82
|
+
if text_data:
|
|
83
|
+
results.append({"slug": slug, "text": text_data})
|
|
84
|
+
|
|
85
|
+
return results
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def format_screen_summary(slug: str, text_data: dict) -> str:
|
|
89
|
+
"""
|
|
90
|
+
Format extracted text into a compact summary string for LLM consumption.
|
|
91
|
+
|
|
92
|
+
Produces ~10-30 lines per screen instead of 200+ lines of raw HTML.
|
|
93
|
+
"""
|
|
94
|
+
lines = [f"## {slug}"]
|
|
95
|
+
|
|
96
|
+
if text_data.get("meta_title"):
|
|
97
|
+
lines.append(f"Title: {text_data['meta_title']}")
|
|
98
|
+
|
|
99
|
+
if text_data.get("meta_desc"):
|
|
100
|
+
lines.append(f"Description: {text_data['meta_desc']}")
|
|
101
|
+
|
|
102
|
+
if text_data.get("headings"):
|
|
103
|
+
lines.append(f"Headings: {' | '.join(text_data['headings'][:10])}")
|
|
104
|
+
|
|
105
|
+
if text_data.get("paragraphs"):
|
|
106
|
+
# Truncate long paragraphs
|
|
107
|
+
paras = [p[:150] for p in text_data["paragraphs"][:5]]
|
|
108
|
+
lines.append(f"Text: {' // '.join(paras)}")
|
|
109
|
+
|
|
110
|
+
if text_data.get("buttons"):
|
|
111
|
+
lines.append(f"Buttons: {', '.join(text_data['buttons'][:15])}")
|
|
112
|
+
|
|
113
|
+
if text_data.get("lists"):
|
|
114
|
+
lines.append(f"List items: {', '.join(text_data['lists'][:15])}")
|
|
115
|
+
|
|
116
|
+
if text_data.get("inputs"):
|
|
117
|
+
lines.append(f"Inputs: {', '.join(text_data['inputs'][:10])}")
|
|
118
|
+
|
|
119
|
+
if text_data.get("css_colors"):
|
|
120
|
+
lines.append(f"Colors: {', '.join(text_data['css_colors'][:10])}")
|
|
121
|
+
|
|
122
|
+
if text_data.get("css_fonts"):
|
|
123
|
+
lines.append(f"Fonts: {', '.join(text_data['css_fonts'][:5])}")
|
|
124
|
+
|
|
125
|
+
return "\n".join(lines)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def format_all_summaries(screen_texts: list[dict]) -> str:
|
|
129
|
+
"""Format all screen summaries into a single string."""
|
|
130
|
+
parts = []
|
|
131
|
+
for item in screen_texts:
|
|
132
|
+
summary = format_screen_summary(item["slug"], item["text"])
|
|
133
|
+
parts.append(summary)
|
|
134
|
+
return "\n\n".join(parts)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# ─── Internal helpers ──────────────────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _strip_invisible(html: str) -> str:
|
|
141
|
+
"""Remove script, style, svg, noscript, and HTML comments."""
|
|
142
|
+
# Remove script blocks
|
|
143
|
+
html = re.sub(r"<script[^>]*>.*?</script>", "", html, flags=re.DOTALL | re.IGNORECASE)
|
|
144
|
+
# Remove style blocks
|
|
145
|
+
html = re.sub(r"<style[^>]*>.*?</style>", "", html, flags=re.DOTALL | re.IGNORECASE)
|
|
146
|
+
# Remove SVG blocks
|
|
147
|
+
html = re.sub(r"<svg[^>]*>.*?</svg>", "", html, flags=re.DOTALL | re.IGNORECASE)
|
|
148
|
+
# Remove noscript
|
|
149
|
+
html = re.sub(r"<noscript[^>]*>.*?</noscript>", "", html, flags=re.DOTALL | re.IGNORECASE)
|
|
150
|
+
# Remove HTML comments
|
|
151
|
+
html = re.sub(r"<!--.*?-->", "", html, flags=re.DOTALL)
|
|
152
|
+
return html
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _strip_tags(text: str) -> str:
|
|
156
|
+
"""Remove all HTML tags and decode entities."""
|
|
157
|
+
text = re.sub(r"<[^>]+>", " ", text)
|
|
158
|
+
text = unescape(text)
|
|
159
|
+
text = re.sub(r"\s+", " ", text).strip()
|
|
160
|
+
return text
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _extract_by_tag(html: str, pattern: str) -> list[str]:
|
|
164
|
+
"""Extract and clean text matching a regex pattern."""
|
|
165
|
+
matches = re.findall(pattern, html, flags=re.DOTALL | re.IGNORECASE)
|
|
166
|
+
results = []
|
|
167
|
+
for m in matches:
|
|
168
|
+
clean = _strip_tags(m).strip()
|
|
169
|
+
if clean and len(clean) > 1:
|
|
170
|
+
results.append(clean)
|
|
171
|
+
return results
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _extract_buttons(html: str) -> list[str]:
|
|
175
|
+
"""Extract text from buttons and clickable links."""
|
|
176
|
+
patterns = [
|
|
177
|
+
r"<button[^>]*>(.*?)</button>",
|
|
178
|
+
r'<a[^>]*class="[^"]*btn[^"]*"[^>]*>(.*?)</a>',
|
|
179
|
+
r'<a[^>]*role="button"[^>]*>(.*?)</a>',
|
|
180
|
+
r'<input[^>]*type="submit"[^>]*value="([^"]*)"',
|
|
181
|
+
]
|
|
182
|
+
results = []
|
|
183
|
+
for pat in patterns:
|
|
184
|
+
for m in re.findall(pat, html, flags=re.DOTALL | re.IGNORECASE):
|
|
185
|
+
clean = _strip_tags(m).strip()
|
|
186
|
+
if clean and len(clean) > 1:
|
|
187
|
+
results.append(clean)
|
|
188
|
+
return results
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _extract_inputs(html: str) -> list[str]:
|
|
192
|
+
"""Extract placeholder text and labels from form elements."""
|
|
193
|
+
results = []
|
|
194
|
+
# Placeholders
|
|
195
|
+
for m in re.findall(r'placeholder="([^"]*)"', html, flags=re.IGNORECASE):
|
|
196
|
+
if m.strip():
|
|
197
|
+
results.append(m.strip())
|
|
198
|
+
# Labels
|
|
199
|
+
for m in re.findall(r"<label[^>]*>(.*?)</label>", html, flags=re.DOTALL | re.IGNORECASE):
|
|
200
|
+
clean = _strip_tags(m).strip()
|
|
201
|
+
if clean and len(clean) > 1:
|
|
202
|
+
results.append(clean)
|
|
203
|
+
return results
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _extract_meta_title(html: str) -> str:
|
|
207
|
+
"""Extract <title> content."""
|
|
208
|
+
m = re.search(r"<title[^>]*>(.*?)</title>", html, flags=re.DOTALL | re.IGNORECASE)
|
|
209
|
+
if m:
|
|
210
|
+
title = _strip_tags(m.group(1)).strip()
|
|
211
|
+
# Skip generic titles
|
|
212
|
+
if title.lower() not in ("untitled", "index", "screen", "document", ""):
|
|
213
|
+
return title
|
|
214
|
+
return ""
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _extract_meta_desc(html: str) -> str:
|
|
218
|
+
"""Extract meta description."""
|
|
219
|
+
m = re.search(r'<meta[^>]*name="description"[^>]*content="([^"]*)"', html, flags=re.IGNORECASE)
|
|
220
|
+
if not m:
|
|
221
|
+
m = re.search(r'<meta[^>]*property="og:description"[^>]*content="([^"]*)"', html, flags=re.IGNORECASE)
|
|
222
|
+
return m.group(1).strip() if m else ""
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _extract_css_colors(html: str) -> list[str]:
|
|
226
|
+
"""Extract unique hex colors from inline styles and style blocks."""
|
|
227
|
+
colors = set()
|
|
228
|
+
for m in re.findall(r"#([0-9a-fA-F]{6})\b", html):
|
|
229
|
+
hex_val = f"#{m.upper()}"
|
|
230
|
+
# Skip near-black and near-white (too common/generic)
|
|
231
|
+
if hex_val not in ("#000000", "#FFFFFF", "#000", "#FFF"):
|
|
232
|
+
colors.add(hex_val)
|
|
233
|
+
return sorted(colors)[:10]
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _extract_css_fonts(html: str) -> list[str]:
|
|
237
|
+
"""Extract font-family declarations from CSS."""
|
|
238
|
+
fonts = set()
|
|
239
|
+
for m in re.findall(r"font-family:\s*['\"]?([^;'\"}{,]+)", html, flags=re.IGNORECASE):
|
|
240
|
+
font = m.strip().strip("'\"")
|
|
241
|
+
if font.lower() not in ("inherit", "initial", "unset", "sans-serif", "serif", "monospace", "system-ui"):
|
|
242
|
+
fonts.add(font)
|
|
243
|
+
# Also check Google Fonts links
|
|
244
|
+
for m in re.findall(r"fonts\.googleapis\.com/css2?\?family=([^&\"' ]+)", html):
|
|
245
|
+
font = m.replace("+", " ").split(":")[0]
|
|
246
|
+
fonts.add(font)
|
|
247
|
+
return sorted(fonts)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
# ─── CLI ───────────────────────────────────────────────────────────────────────
|
|
251
|
+
|
|
252
|
+
if __name__ == "__main__":
|
|
253
|
+
if len(sys.argv) < 2:
|
|
254
|
+
print("Usage: python extract_text.py /path/to/assets/", file=sys.stderr)
|
|
255
|
+
sys.exit(1)
|
|
256
|
+
|
|
257
|
+
assets = Path(sys.argv[1]).resolve()
|
|
258
|
+
if not assets.is_dir():
|
|
259
|
+
print(f"Error: '{assets}' is not a directory.", file=sys.stderr)
|
|
260
|
+
sys.exit(1)
|
|
261
|
+
|
|
262
|
+
screen_texts = extract_all_screens_text(assets)
|
|
263
|
+
if not screen_texts:
|
|
264
|
+
print("No HTML files found.", file=sys.stderr)
|
|
265
|
+
sys.exit(1)
|
|
266
|
+
|
|
267
|
+
print(format_all_summaries(screen_texts))
|
|
268
|
+
print(f"\n--- {len(screen_texts)} screens extracted ---", file=sys.stderr)
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""
|
|
2
|
+
extract_zips.py — Extracts Stitch zips and renames files for stitch-showcase.
|
|
3
|
+
|
|
4
|
+
Each Stitch zip contains:
|
|
5
|
+
code.html → renamed to {zip_name}.html
|
|
6
|
+
screen.png → renamed to {zip_name}.png
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
python extract_zips.py /path/to/source /path/to/output/assets
|
|
10
|
+
→ extracts all .zip files in /path/to/source to /path/to/output/assets/
|
|
11
|
+
"""
|
|
12
|
+
import sys
|
|
13
|
+
import shutil
|
|
14
|
+
import zipfile
|
|
15
|
+
import tempfile
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def extract_all(source_dir: str, assets_dir: str) -> list[dict]:
|
|
20
|
+
"""
|
|
21
|
+
Extract and rename all Stitch zips in source_dir.
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
List of dicts with {slug, html_path, png_path} per processed screen.
|
|
25
|
+
"""
|
|
26
|
+
source = Path(source_dir)
|
|
27
|
+
assets = Path(assets_dir)
|
|
28
|
+
assets.mkdir(parents=True, exist_ok=True)
|
|
29
|
+
|
|
30
|
+
screens = []
|
|
31
|
+
|
|
32
|
+
# Collect sources: zips + already-extracted folders
|
|
33
|
+
zips = sorted(source.glob("*.zip"))
|
|
34
|
+
extracted_dirs = [d for d in sorted(source.iterdir())
|
|
35
|
+
if d.is_dir() and (d / "code.html").exists()]
|
|
36
|
+
|
|
37
|
+
# Process zips
|
|
38
|
+
for zip_path in zips:
|
|
39
|
+
slug = _slug_from_name(zip_path.stem)
|
|
40
|
+
result = _process_zip(zip_path, slug, assets)
|
|
41
|
+
if result:
|
|
42
|
+
screens.append(result)
|
|
43
|
+
|
|
44
|
+
# Process already-extracted folders (if zip was unpacked previously)
|
|
45
|
+
already_processed = {s["slug"] for s in screens}
|
|
46
|
+
for dir_path in extracted_dirs:
|
|
47
|
+
slug = _slug_from_name(dir_path.name)
|
|
48
|
+
if slug in already_processed:
|
|
49
|
+
continue
|
|
50
|
+
result = _process_dir(dir_path, slug, assets)
|
|
51
|
+
if result:
|
|
52
|
+
screens.append(result)
|
|
53
|
+
|
|
54
|
+
# Sort by slug name
|
|
55
|
+
screens.sort(key=lambda s: s["slug"])
|
|
56
|
+
return screens
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _process_zip(zip_path: Path, slug: str, assets: Path) -> dict | None:
|
|
60
|
+
"""Extract a Stitch zip and copy renamed files to assets/."""
|
|
61
|
+
html_dst = assets / f"{slug}.html"
|
|
62
|
+
png_dst = assets / f"{slug}.png"
|
|
63
|
+
|
|
64
|
+
# Incremental: skip if output is newer than the zip
|
|
65
|
+
if html_dst.exists() and html_dst.stat().st_mtime > zip_path.stat().st_mtime:
|
|
66
|
+
print(f" ↩ {slug} (unchanged)")
|
|
67
|
+
return {
|
|
68
|
+
"slug": slug,
|
|
69
|
+
"html_path": str(html_dst),
|
|
70
|
+
"png_path": str(png_dst) if png_dst.exists() else None,
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
74
|
+
tmp_path = Path(tmp)
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
with zipfile.ZipFile(zip_path, "r") as zf:
|
|
78
|
+
zf.extractall(tmp_path)
|
|
79
|
+
except zipfile.BadZipFile:
|
|
80
|
+
print(f" ⚠ {zip_path.name}: not a valid zip, skipping.", file=sys.stderr)
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
# Find code.html and screen.png (may be inside a subdirectory)
|
|
84
|
+
html_src = _find_file(tmp_path, "code.html")
|
|
85
|
+
png_src = _find_file(tmp_path, "screen.png")
|
|
86
|
+
|
|
87
|
+
if not html_src:
|
|
88
|
+
print(f" ⚠ {zip_path.name}: does not contain code.html, skipping.", file=sys.stderr)
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
_copy_html(html_src, html_dst)
|
|
92
|
+
|
|
93
|
+
png_dst_result = None
|
|
94
|
+
if png_src:
|
|
95
|
+
shutil.copy2(png_src, png_dst)
|
|
96
|
+
png_dst_result = png_dst
|
|
97
|
+
else:
|
|
98
|
+
print(f" ⚠ {zip_path.name}: does not contain screen.png.", file=sys.stderr)
|
|
99
|
+
|
|
100
|
+
print(f" ✓ {slug}")
|
|
101
|
+
return {
|
|
102
|
+
"slug": slug,
|
|
103
|
+
"html_path": str(html_dst),
|
|
104
|
+
"png_path": str(png_dst_result) if png_dst_result else None,
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _process_dir(dir_path: Path, slug: str, assets: Path) -> dict | None:
|
|
109
|
+
"""Copy files from an already-extracted folder to assets/."""
|
|
110
|
+
html_src = dir_path / "code.html"
|
|
111
|
+
png_src = dir_path / "screen.png"
|
|
112
|
+
html_dst = assets / f"{slug}.html"
|
|
113
|
+
png_dst = assets / f"{slug}.png"
|
|
114
|
+
|
|
115
|
+
# Incremental: skip if output is newer than source
|
|
116
|
+
if html_dst.exists() and html_dst.stat().st_mtime > html_src.stat().st_mtime:
|
|
117
|
+
print(f" ↩ {slug} (unchanged)")
|
|
118
|
+
return {
|
|
119
|
+
"slug": slug,
|
|
120
|
+
"html_path": str(html_dst),
|
|
121
|
+
"png_path": str(png_dst) if png_dst.exists() else None,
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
_copy_html(html_src, html_dst)
|
|
125
|
+
|
|
126
|
+
png_dst_result = None
|
|
127
|
+
if png_src.exists():
|
|
128
|
+
shutil.copy2(png_src, png_dst)
|
|
129
|
+
png_dst_result = png_dst
|
|
130
|
+
|
|
131
|
+
print(f" ✓ {slug} (folder)")
|
|
132
|
+
return {
|
|
133
|
+
"slug": slug,
|
|
134
|
+
"html_path": str(html_dst),
|
|
135
|
+
"png_path": str(png_dst_result) if png_dst_result else None,
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
_NO_SCROLLBAR_CSS = '<style>*::-webkit-scrollbar{display:none!important}*{scrollbar-width:none!important;-ms-overflow-style:none!important}</style>'
|
|
140
|
+
|
|
141
|
+
def _copy_html(src: Path, dst: Path) -> None:
|
|
142
|
+
"""Copy HTML injecting scrollbar-hiding CSS into <head>."""
|
|
143
|
+
text = src.read_text(encoding="utf-8", errors="ignore")
|
|
144
|
+
if "</head>" in text:
|
|
145
|
+
text = text.replace("</head>", f"{_NO_SCROLLBAR_CSS}</head>", 1)
|
|
146
|
+
elif "<body" in text:
|
|
147
|
+
text = text.replace("<body", f"{_NO_SCROLLBAR_CSS}<body", 1)
|
|
148
|
+
dst.write_text(text, encoding="utf-8")
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _find_file(root: Path, filename: str) -> Path | None:
|
|
152
|
+
"""Recursively find a file by name inside root."""
|
|
153
|
+
matches = list(root.rglob(filename))
|
|
154
|
+
return matches[0] if matches else None
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _slug_from_name(name: str) -> str:
|
|
158
|
+
"""
|
|
159
|
+
Convert filename/folder name to a slug.
|
|
160
|
+
'01-splash-screen' → '01_splash_screen'
|
|
161
|
+
'Login Screen' → 'login_screen'
|
|
162
|
+
"""
|
|
163
|
+
import re
|
|
164
|
+
s = name.strip().lower()
|
|
165
|
+
s = re.sub(r"[\s\-]+", "_", s)
|
|
166
|
+
s = re.sub(r"[^a-z0-9_]", "", s)
|
|
167
|
+
s = re.sub(r"_+", "_", s).strip("_")
|
|
168
|
+
return s
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
if __name__ == "__main__":
|
|
172
|
+
if len(sys.argv) < 3:
|
|
173
|
+
print("Usage: python extract_zips.py /path/to/source /path/to/output/assets", file=sys.stderr)
|
|
174
|
+
sys.exit(1)
|
|
175
|
+
|
|
176
|
+
import json
|
|
177
|
+
screens = extract_all(sys.argv[1], sys.argv[2])
|
|
178
|
+
print(json.dumps(screens, ensure_ascii=False, indent=2))
|