kctl-react 0.6.2__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.
- kctl_react/__init__.py +3 -0
- kctl_react/__main__.py +5 -0
- kctl_react/cli.py +201 -0
- kctl_react/commands/__init__.py +0 -0
- kctl_react/commands/a11y.py +78 -0
- kctl_react/commands/affected.py +170 -0
- kctl_react/commands/apps.py +353 -0
- kctl_react/commands/build.py +376 -0
- kctl_react/commands/bundle_cmd.py +217 -0
- kctl_react/commands/cap.py +1465 -0
- kctl_react/commands/clean.py +76 -0
- kctl_react/commands/codegen.py +491 -0
- kctl_react/commands/compliance.py +587 -0
- kctl_react/commands/config_cmd.py +368 -0
- kctl_react/commands/dashboard.py +163 -0
- kctl_react/commands/deploy.py +318 -0
- kctl_react/commands/deps.py +792 -0
- kctl_react/commands/dev.py +96 -0
- kctl_react/commands/docker_cmd.py +73 -0
- kctl_react/commands/doctor.py +170 -0
- kctl_react/commands/e2e.py +343 -0
- kctl_react/commands/env.py +155 -0
- kctl_react/commands/i18n.py +310 -0
- kctl_react/commands/lint.py +306 -0
- kctl_react/commands/maintenance.py +308 -0
- kctl_react/commands/monitor_cmd.py +50 -0
- kctl_react/commands/observe.py +34 -0
- kctl_react/commands/packages.py +129 -0
- kctl_react/commands/perf.py +762 -0
- kctl_react/commands/pipeline.py +289 -0
- kctl_react/commands/pwa.py +193 -0
- kctl_react/commands/scaffold.py +323 -0
- kctl_react/commands/security.py +660 -0
- kctl_react/commands/skill_cmd.py +54 -0
- kctl_react/commands/state.py +254 -0
- kctl_react/commands/test_cmd.py +418 -0
- kctl_react/commands/ui_audit.py +889 -0
- kctl_react/core/__init__.py +0 -0
- kctl_react/core/analyzers.py +200 -0
- kctl_react/core/callbacks.py +70 -0
- kctl_react/core/compliance/__init__.py +3 -0
- kctl_react/core/compliance/api_check/__init__.py +3 -0
- kctl_react/core/compliance/api_check/checks/__init__.py +18 -0
- kctl_react/core/compliance/api_check/checks/endpoints.py +53 -0
- kctl_react/core/compliance/api_check/checks/envelope.py +44 -0
- kctl_react/core/compliance/api_check/checks/naming.py +60 -0
- kctl_react/core/compliance/api_check/checks/params.py +44 -0
- kctl_react/core/compliance/api_check/checks/requests.py +57 -0
- kctl_react/core/compliance/api_check/checks/types.py +55 -0
- kctl_react/core/compliance/api_check/hooks.py +133 -0
- kctl_react/core/compliance/api_check/matcher.py +55 -0
- kctl_react/core/compliance/api_check/schema.py +151 -0
- kctl_react/core/compliance/api_health/__init__.py +35 -0
- kctl_react/core/compliance/api_health/checks/__init__.py +9 -0
- kctl_react/core/compliance/api_health/checks/auth.py +72 -0
- kctl_react/core/compliance/api_health/checks/reachable.py +44 -0
- kctl_react/core/compliance/api_health/checks/response.py +55 -0
- kctl_react/core/compliance/api_health/checks/timing.py +38 -0
- kctl_react/core/compliance/api_health/client.py +99 -0
- kctl_react/core/compliance/api_health/sampler.py +16 -0
- kctl_react/core/compliance/checks/__init__.py +47 -0
- kctl_react/core/compliance/checks/api.py +101 -0
- kctl_react/core/compliance/checks/codegen.py +94 -0
- kctl_react/core/compliance/checks/darkmode.py +57 -0
- kctl_react/core/compliance/checks/errors.py +68 -0
- kctl_react/core/compliance/checks/features.py +66 -0
- kctl_react/core/compliance/checks/i18n_check.py +105 -0
- kctl_react/core/compliance/checks/imports.py +86 -0
- kctl_react/core/compliance/checks/navigation.py +62 -0
- kctl_react/core/compliance/checks/practices.py +122 -0
- kctl_react/core/compliance/checks/providers.py +85 -0
- kctl_react/core/compliance/checks/pwa.py +101 -0
- kctl_react/core/compliance/checks/responsive.py +47 -0
- kctl_react/core/compliance/checks/scripts.py +85 -0
- kctl_react/core/compliance/checks/shadcn.py +51 -0
- kctl_react/core/compliance/checks/structure.py +76 -0
- kctl_react/core/compliance/checks/testing.py +83 -0
- kctl_react/core/compliance/checks/theme.py +92 -0
- kctl_react/core/compliance/checks/ui_standard.py +185 -0
- kctl_react/core/compliance/checks/vite.py +83 -0
- kctl_react/core/compliance/engine.py +87 -0
- kctl_react/core/compliance/exceptions_map.py +15 -0
- kctl_react/core/compliance/fixes/__init__.py +33 -0
- kctl_react/core/compliance/fixes/codegen_fix.py +28 -0
- kctl_react/core/compliance/fixes/i18n_fix.py +61 -0
- kctl_react/core/compliance/fixes/imports_fix.py +36 -0
- kctl_react/core/compliance/fixes/structure_fix.py +20 -0
- kctl_react/core/compliance/fixes/theme_fix.py +29 -0
- kctl_react/core/compliance/models.py +106 -0
- kctl_react/core/config.py +201 -0
- kctl_react/core/discovery.py +185 -0
- kctl_react/core/exceptions.py +17 -0
- kctl_react/core/git.py +146 -0
- kctl_react/core/history.py +121 -0
- kctl_react/core/output.py +5 -0
- kctl_react/core/plugins.py +13 -0
- kctl_react/core/runner.py +34 -0
- kctl_react/py.typed +0 -0
- kctl_react-0.6.2.dist-info/METADATA +17 -0
- kctl_react-0.6.2.dist-info/RECORD +102 -0
- kctl_react-0.6.2.dist-info/WHEEL +4 -0
- kctl_react-0.6.2.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,762 @@
|
|
|
1
|
+
"""Performance profiling commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from datetime import UTC
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Annotated
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
|
|
12
|
+
from kctl_react.core.callbacks import AppContext
|
|
13
|
+
from kctl_react.core.discovery import get_app_dir
|
|
14
|
+
from kctl_react.core.exceptions import CommandError
|
|
15
|
+
from kctl_react.core.runner import run
|
|
16
|
+
|
|
17
|
+
app = typer.Typer(help="Performance profiling and bundle analysis.")
|
|
18
|
+
|
|
19
|
+
# Default Lighthouse performance budget
|
|
20
|
+
_DEFAULT_BUDGET: dict[str, int] = {
|
|
21
|
+
"performance": 80,
|
|
22
|
+
"accessibility": 90,
|
|
23
|
+
"best-practices": 80,
|
|
24
|
+
"seo": 80,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
_LIGHTHOUSE_HISTORY_FILE = "lighthouse.json"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _lighthouse_history_path(root: Path) -> Path:
|
|
31
|
+
return root / ".kctl-react" / _LIGHTHOUSE_HISTORY_FILE
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _load_lighthouse_history(root: Path) -> list[dict]:
|
|
35
|
+
path = _lighthouse_history_path(root)
|
|
36
|
+
if not path.exists():
|
|
37
|
+
return []
|
|
38
|
+
try:
|
|
39
|
+
return json.loads(path.read_text())
|
|
40
|
+
except Exception:
|
|
41
|
+
return []
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _save_lighthouse_result(root: Path, result: dict) -> None:
|
|
45
|
+
from datetime import datetime
|
|
46
|
+
|
|
47
|
+
history = _load_lighthouse_history(root)
|
|
48
|
+
result["timestamp"] = datetime.now(UTC).isoformat()
|
|
49
|
+
history.append(result)
|
|
50
|
+
if len(history) > 50:
|
|
51
|
+
history = history[-50:]
|
|
52
|
+
|
|
53
|
+
path = _lighthouse_history_path(root)
|
|
54
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
55
|
+
path.write_text(json.dumps(history, indent=2))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@app.command()
|
|
59
|
+
def lighthouse(
|
|
60
|
+
ctx: typer.Context,
|
|
61
|
+
app_name: Annotated[str, typer.Argument(help="App name")],
|
|
62
|
+
budget: Annotated[str | None, typer.Option("--budget", help="JSON budget file")] = None,
|
|
63
|
+
url: Annotated[str | None, typer.Option("--url", help="Override URL")] = None,
|
|
64
|
+
) -> None:
|
|
65
|
+
"""Run Lighthouse CI and compare against performance budget."""
|
|
66
|
+
actx: AppContext = ctx.obj
|
|
67
|
+
out = actx.output
|
|
68
|
+
root = actx.project_root
|
|
69
|
+
|
|
70
|
+
actx.validate_app(app_name)
|
|
71
|
+
app_info = actx.apps[app_name]
|
|
72
|
+
target_url = url or f"http://localhost:{app_info['port']}"
|
|
73
|
+
|
|
74
|
+
# Load budget
|
|
75
|
+
thresholds = dict(_DEFAULT_BUDGET)
|
|
76
|
+
if budget:
|
|
77
|
+
budget_path = Path(budget)
|
|
78
|
+
if budget_path.exists():
|
|
79
|
+
try:
|
|
80
|
+
thresholds.update(json.loads(budget_path.read_text()))
|
|
81
|
+
except Exception as e:
|
|
82
|
+
out.warn(f"Cannot parse budget file: {e} — using defaults")
|
|
83
|
+
else:
|
|
84
|
+
out.warn(f"Budget file not found: {budget} — using defaults")
|
|
85
|
+
|
|
86
|
+
out.info(f"Running Lighthouse on {target_url}...")
|
|
87
|
+
|
|
88
|
+
# Run lighthouse via npx
|
|
89
|
+
try:
|
|
90
|
+
result = run(
|
|
91
|
+
[
|
|
92
|
+
"npx",
|
|
93
|
+
"--yes",
|
|
94
|
+
"lighthouse",
|
|
95
|
+
target_url,
|
|
96
|
+
"--output=json",
|
|
97
|
+
"--chrome-flags=--headless --no-sandbox",
|
|
98
|
+
"--quiet",
|
|
99
|
+
],
|
|
100
|
+
cwd=root,
|
|
101
|
+
capture=True,
|
|
102
|
+
timeout=120,
|
|
103
|
+
)
|
|
104
|
+
lh_data = json.loads(result.stdout)
|
|
105
|
+
except CommandError as e:
|
|
106
|
+
out.error(f"Lighthouse failed: {e}")
|
|
107
|
+
raise typer.Exit(1) from None
|
|
108
|
+
except json.JSONDecodeError:
|
|
109
|
+
out.error("Failed to parse Lighthouse output")
|
|
110
|
+
raise typer.Exit(1) from None
|
|
111
|
+
|
|
112
|
+
# Extract scores
|
|
113
|
+
categories = lh_data.get("categories", {})
|
|
114
|
+
scores: dict[str, int] = {}
|
|
115
|
+
for key in ("performance", "accessibility", "best-practices", "seo"):
|
|
116
|
+
cat = categories.get(key, {})
|
|
117
|
+
scores[key] = int((cat.get("score", 0) or 0) * 100)
|
|
118
|
+
|
|
119
|
+
# Compare against budget
|
|
120
|
+
rows: list[list[str]] = []
|
|
121
|
+
json_data: list[dict] = []
|
|
122
|
+
all_pass = True
|
|
123
|
+
|
|
124
|
+
for category, score in scores.items():
|
|
125
|
+
threshold = thresholds.get(category, 0)
|
|
126
|
+
passed = score >= threshold
|
|
127
|
+
if not passed:
|
|
128
|
+
all_pass = False
|
|
129
|
+
status = "[green]PASS[/green]" if passed else "[red]FAIL[/red]"
|
|
130
|
+
rows.append([category, str(score), str(threshold), status])
|
|
131
|
+
json_data.append({"category": category, "score": score, "threshold": threshold, "passed": passed})
|
|
132
|
+
|
|
133
|
+
out.table(
|
|
134
|
+
f"Lighthouse — {app_name} ({target_url})",
|
|
135
|
+
[("Category", "cyan"), ("Score", "green"), ("Budget", "dim"), ("Status", "")],
|
|
136
|
+
rows,
|
|
137
|
+
data_for_json=json_data,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
# Save to history
|
|
141
|
+
_save_lighthouse_result(root, {"app": app_name, "url": target_url, "scores": scores})
|
|
142
|
+
|
|
143
|
+
if all_pass:
|
|
144
|
+
out.success("All categories meet budget thresholds")
|
|
145
|
+
else:
|
|
146
|
+
out.warn("Some categories below budget — see FAIL items above")
|
|
147
|
+
raise typer.Exit(1)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@app.command("history")
|
|
151
|
+
def perf_history(
|
|
152
|
+
ctx: typer.Context,
|
|
153
|
+
app_name: Annotated[str, typer.Argument(help="App name")],
|
|
154
|
+
limit: Annotated[int, typer.Option("--limit", "-n", help="Number of records to show")] = 10,
|
|
155
|
+
) -> None:
|
|
156
|
+
"""Show Lighthouse score and build size trends over time."""
|
|
157
|
+
from kctl_react.commands.build import _format_size
|
|
158
|
+
from kctl_react.core.history import load_history as load_build_history
|
|
159
|
+
|
|
160
|
+
actx: AppContext = ctx.obj
|
|
161
|
+
out = actx.output
|
|
162
|
+
root = actx.project_root
|
|
163
|
+
|
|
164
|
+
actx.validate_app(app_name)
|
|
165
|
+
|
|
166
|
+
# Lighthouse history
|
|
167
|
+
lh_records = [r for r in _load_lighthouse_history(root) if r.get("app") == app_name]
|
|
168
|
+
|
|
169
|
+
if lh_records:
|
|
170
|
+
rows: list[list[str]] = []
|
|
171
|
+
json_data: list[dict] = []
|
|
172
|
+
|
|
173
|
+
for record in lh_records[-limit:]:
|
|
174
|
+
ts = record.get("timestamp", "")[:19].replace("T", " ")
|
|
175
|
+
scores = record.get("scores", {})
|
|
176
|
+
perf = str(scores.get("performance", "-"))
|
|
177
|
+
a11y = str(scores.get("accessibility", "-"))
|
|
178
|
+
bp = str(scores.get("best-practices", "-"))
|
|
179
|
+
seo = str(scores.get("seo", "-"))
|
|
180
|
+
rows.append([ts, perf, a11y, bp, seo])
|
|
181
|
+
json_data.append({"timestamp": record.get("timestamp"), "scores": scores})
|
|
182
|
+
|
|
183
|
+
out.table(
|
|
184
|
+
f"Lighthouse History — {app_name}",
|
|
185
|
+
[("Timestamp", "dim"), ("Perf", "green"), ("A11y", "cyan"), ("Best Prac", ""), ("SEO", "")],
|
|
186
|
+
rows,
|
|
187
|
+
data_for_json=json_data,
|
|
188
|
+
)
|
|
189
|
+
else:
|
|
190
|
+
out.info(f"No Lighthouse history for {app_name}. Run `kctl-react perf lighthouse {app_name}` first.")
|
|
191
|
+
|
|
192
|
+
# Build size history
|
|
193
|
+
build_records = load_build_history(root)
|
|
194
|
+
if build_records:
|
|
195
|
+
rows = []
|
|
196
|
+
json_data = []
|
|
197
|
+
for record in build_records[-limit:]:
|
|
198
|
+
ts = record.get("timestamp", "")[:19].replace("T", " ")
|
|
199
|
+
apps_data = record.get("apps", {})
|
|
200
|
+
app_size = apps_data.get(app_name, 0)
|
|
201
|
+
if app_size > 0:
|
|
202
|
+
rows.append([ts, _format_size(app_size)])
|
|
203
|
+
json_data.append({"timestamp": record.get("timestamp"), "bytes": app_size})
|
|
204
|
+
|
|
205
|
+
if rows:
|
|
206
|
+
out.table(
|
|
207
|
+
f"Build Size History — {app_name}",
|
|
208
|
+
[("Timestamp", "dim"), ("Size", "green")],
|
|
209
|
+
rows,
|
|
210
|
+
data_for_json=json_data,
|
|
211
|
+
)
|
|
212
|
+
else:
|
|
213
|
+
out.info(f"No build size data for {app_name}.")
|
|
214
|
+
else:
|
|
215
|
+
out.info("No build history. Run `kctl-react build --analyze` to start tracking.")
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _check_pwa_readiness(app_dir: Path) -> dict:
|
|
219
|
+
"""Check PWA readiness for a single app directory."""
|
|
220
|
+
result: dict[str, bool] = {
|
|
221
|
+
"manifest": False,
|
|
222
|
+
"service_worker": False,
|
|
223
|
+
"pwa_plugin": False,
|
|
224
|
+
"icons": False,
|
|
225
|
+
"offline": False,
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
# Manifest check: public/ or dist/
|
|
229
|
+
for manifest_name in ("manifest.webmanifest", "manifest.json"):
|
|
230
|
+
if (app_dir / "public" / manifest_name).exists() or (app_dir / "dist" / manifest_name).exists():
|
|
231
|
+
result["manifest"] = True
|
|
232
|
+
break
|
|
233
|
+
|
|
234
|
+
# Service worker check
|
|
235
|
+
for sw_name in ("sw.js", "service-worker.js", "sw.ts"):
|
|
236
|
+
for search_dir in (app_dir / "dist", app_dir / "src", app_dir / "public"):
|
|
237
|
+
if search_dir.is_dir() and (search_dir / sw_name).exists():
|
|
238
|
+
result["service_worker"] = True
|
|
239
|
+
break
|
|
240
|
+
if result["service_worker"]:
|
|
241
|
+
break
|
|
242
|
+
# Also check dist for generated SW files
|
|
243
|
+
dist = app_dir / "dist"
|
|
244
|
+
if dist.is_dir():
|
|
245
|
+
for _f in dist.rglob("sw*.js"):
|
|
246
|
+
result["service_worker"] = True
|
|
247
|
+
break
|
|
248
|
+
|
|
249
|
+
# PWA plugin check (Vite: vite-plugin-pwa, Next.js: next-pwa or @ducanh2912/next-pwa)
|
|
250
|
+
vite_config = app_dir / "vite.config.ts"
|
|
251
|
+
if not vite_config.exists():
|
|
252
|
+
vite_config = app_dir / "vite.config.js"
|
|
253
|
+
if vite_config.exists():
|
|
254
|
+
try:
|
|
255
|
+
content = vite_config.read_text()
|
|
256
|
+
if "VitePWA" in content or "@vite-pwa" in content or "vite-plugin-pwa" in content:
|
|
257
|
+
result["pwa_plugin"] = True
|
|
258
|
+
result["service_worker"] = True
|
|
259
|
+
except Exception:
|
|
260
|
+
pass
|
|
261
|
+
# Next.js PWA check
|
|
262
|
+
for next_config_name in ("next.config.ts", "next.config.js", "next.config.mjs"):
|
|
263
|
+
next_config = app_dir / next_config_name
|
|
264
|
+
if next_config.exists():
|
|
265
|
+
try:
|
|
266
|
+
content = next_config.read_text()
|
|
267
|
+
if "next-pwa" in content or "withPWA" in content:
|
|
268
|
+
result["pwa_plugin"] = True
|
|
269
|
+
result["service_worker"] = True
|
|
270
|
+
except Exception:
|
|
271
|
+
pass
|
|
272
|
+
break
|
|
273
|
+
|
|
274
|
+
# Icons check
|
|
275
|
+
icons_dir = app_dir / "public" / "icons"
|
|
276
|
+
if icons_dir.is_dir() and any(icons_dir.iterdir()):
|
|
277
|
+
result["icons"] = True
|
|
278
|
+
else:
|
|
279
|
+
# Check manifest for icons array
|
|
280
|
+
for manifest_name in ("manifest.webmanifest", "manifest.json"):
|
|
281
|
+
manifest_path = app_dir / "public" / manifest_name
|
|
282
|
+
if manifest_path.exists():
|
|
283
|
+
try:
|
|
284
|
+
import json as _json
|
|
285
|
+
|
|
286
|
+
manifest = _json.loads(manifest_path.read_text())
|
|
287
|
+
if manifest.get("icons"):
|
|
288
|
+
result["icons"] = True
|
|
289
|
+
except Exception:
|
|
290
|
+
pass
|
|
291
|
+
break
|
|
292
|
+
|
|
293
|
+
# Offline fallback
|
|
294
|
+
if (app_dir / "public" / "offline.html").exists():
|
|
295
|
+
result["offline"] = True
|
|
296
|
+
elif result["pwa_plugin"]:
|
|
297
|
+
# vite-plugin-pwa with registerType: "prompt" handles offline via SW
|
|
298
|
+
result["offline"] = True
|
|
299
|
+
|
|
300
|
+
return result
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
@app.command()
|
|
304
|
+
def pwa(
|
|
305
|
+
ctx: typer.Context,
|
|
306
|
+
app_name: Annotated[str | None, typer.Argument(help="App name (omit for all)")] = None,
|
|
307
|
+
) -> None:
|
|
308
|
+
"""Check PWA readiness for each app."""
|
|
309
|
+
actx: AppContext = ctx.obj
|
|
310
|
+
out = actx.output
|
|
311
|
+
root = actx.project_root
|
|
312
|
+
|
|
313
|
+
if app_name:
|
|
314
|
+
actx.validate_app(app_name)
|
|
315
|
+
apps_to_check = [app_name]
|
|
316
|
+
else:
|
|
317
|
+
apps_to_check = actx.app_names
|
|
318
|
+
|
|
319
|
+
out.info("Checking PWA readiness...")
|
|
320
|
+
|
|
321
|
+
rows: list[list[str]] = []
|
|
322
|
+
json_data: list[dict] = []
|
|
323
|
+
|
|
324
|
+
for name in apps_to_check:
|
|
325
|
+
app_dir = get_app_dir(root, name)
|
|
326
|
+
checks = _check_pwa_readiness(app_dir)
|
|
327
|
+
|
|
328
|
+
def _icon(val: bool) -> str:
|
|
329
|
+
return "[green]yes[/green]" if val else "[red]no[/red]"
|
|
330
|
+
|
|
331
|
+
score = sum(checks.values())
|
|
332
|
+
total = len(checks)
|
|
333
|
+
status = f"[green]{score}/{total}[/green]" if score == total else f"[yellow]{score}/{total}[/yellow]"
|
|
334
|
+
|
|
335
|
+
rows.append(
|
|
336
|
+
[
|
|
337
|
+
name,
|
|
338
|
+
_icon(checks["manifest"]),
|
|
339
|
+
_icon(checks["service_worker"]),
|
|
340
|
+
_icon(checks["pwa_plugin"]),
|
|
341
|
+
_icon(checks["icons"]),
|
|
342
|
+
_icon(checks["offline"]),
|
|
343
|
+
status,
|
|
344
|
+
]
|
|
345
|
+
)
|
|
346
|
+
json_data.append({"app": name, **checks, "score": score, "total": total})
|
|
347
|
+
|
|
348
|
+
out.table(
|
|
349
|
+
"PWA Readiness",
|
|
350
|
+
[
|
|
351
|
+
("App", "cyan"),
|
|
352
|
+
("Manifest", ""),
|
|
353
|
+
("SW", ""),
|
|
354
|
+
("Plugin", ""),
|
|
355
|
+
("Icons", ""),
|
|
356
|
+
("Offline", ""),
|
|
357
|
+
("Score", ""),
|
|
358
|
+
],
|
|
359
|
+
rows,
|
|
360
|
+
data_for_json=json_data,
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
@app.command("bundle")
|
|
365
|
+
def bundle(
|
|
366
|
+
ctx: typer.Context,
|
|
367
|
+
app_name: Annotated[str, typer.Argument(help="App name")],
|
|
368
|
+
budget_kb: Annotated[
|
|
369
|
+
int | None, typer.Option("--budget", help="Warn when total JS exceeds this KB threshold")
|
|
370
|
+
] = None,
|
|
371
|
+
) -> None:
|
|
372
|
+
"""Report bundle size breakdown for a built app (reads dist/assets/)."""
|
|
373
|
+
from kctl_react.commands.build import _format_size
|
|
374
|
+
|
|
375
|
+
actx: AppContext = ctx.obj
|
|
376
|
+
out = actx.output
|
|
377
|
+
root = actx.project_root
|
|
378
|
+
|
|
379
|
+
actx.validate_app(app_name)
|
|
380
|
+
|
|
381
|
+
app_dir = get_app_dir(root, app_name)
|
|
382
|
+
|
|
383
|
+
# Gather files from build output — Vite: dist/assets, Next.js: .next/static
|
|
384
|
+
search_dir: Path | None = None
|
|
385
|
+
if actx.is_nextjs(app_name):
|
|
386
|
+
for candidate in (app_dir / ".next" / "static", app_dir / ".next"):
|
|
387
|
+
if candidate.is_dir():
|
|
388
|
+
search_dir = candidate
|
|
389
|
+
break
|
|
390
|
+
else:
|
|
391
|
+
for candidate in (app_dir / "dist" / "assets", app_dir / "dist"):
|
|
392
|
+
if candidate.is_dir():
|
|
393
|
+
search_dir = candidate
|
|
394
|
+
break
|
|
395
|
+
|
|
396
|
+
if search_dir is None:
|
|
397
|
+
out.info(f"{app_name} has not been built yet")
|
|
398
|
+
if out.json_mode:
|
|
399
|
+
out.raw_json([])
|
|
400
|
+
return
|
|
401
|
+
|
|
402
|
+
# Collect files
|
|
403
|
+
rows: list[list[str]] = []
|
|
404
|
+
json_data: list[dict] = []
|
|
405
|
+
total_js = 0
|
|
406
|
+
total_css = 0
|
|
407
|
+
|
|
408
|
+
for f in sorted(search_dir.rglob("*")):
|
|
409
|
+
if not f.is_file():
|
|
410
|
+
continue
|
|
411
|
+
size = f.stat().st_size
|
|
412
|
+
ext = f.suffix.lower()
|
|
413
|
+
file_type = "JS" if ext in (".js", ".mjs") else "CSS" if ext == ".css" else ext.lstrip(".").upper() or "other"
|
|
414
|
+
if ext in (".js", ".mjs"):
|
|
415
|
+
total_js += size
|
|
416
|
+
elif ext == ".css":
|
|
417
|
+
total_css += size
|
|
418
|
+
|
|
419
|
+
rel = f.relative_to(search_dir)
|
|
420
|
+
rows.append([str(rel), file_type, _format_size(size)])
|
|
421
|
+
json_data.append({"file": str(rel), "type": file_type, "bytes": size})
|
|
422
|
+
|
|
423
|
+
if not rows:
|
|
424
|
+
out.info(f"{app_name}: dist/assets is empty")
|
|
425
|
+
if out.json_mode:
|
|
426
|
+
out.raw_json([])
|
|
427
|
+
return
|
|
428
|
+
|
|
429
|
+
out.table(
|
|
430
|
+
f"Bundle Size — {app_name}",
|
|
431
|
+
[("File", "cyan"), ("Type", "dim"), ("Size", "green")],
|
|
432
|
+
rows,
|
|
433
|
+
data_for_json=json_data,
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
summary = f"JS: {_format_size(total_js)} CSS: {_format_size(total_css)}"
|
|
437
|
+
if budget_kb and total_js > budget_kb * 1024:
|
|
438
|
+
out.warn(f"Total JS ({_format_size(total_js)}) exceeds budget ({budget_kb} KB) — {summary}")
|
|
439
|
+
else:
|
|
440
|
+
out.success(f"Bundle sizes: {summary}")
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
@app.command()
|
|
444
|
+
def vitals(
|
|
445
|
+
ctx: typer.Context,
|
|
446
|
+
app_name: Annotated[str, typer.Argument(help="App name")],
|
|
447
|
+
) -> None:
|
|
448
|
+
"""Check Core Web Vitals readiness (code splitting, lazy images, SVGs, scripts)."""
|
|
449
|
+
actx: AppContext = ctx.obj
|
|
450
|
+
out = actx.output
|
|
451
|
+
root = actx.project_root
|
|
452
|
+
|
|
453
|
+
actx.validate_app(app_name)
|
|
454
|
+
app_dir = get_app_dir(root, app_name)
|
|
455
|
+
src_dir = app_dir / "src"
|
|
456
|
+
|
|
457
|
+
checks: list[tuple[str, bool, str]] = []
|
|
458
|
+
|
|
459
|
+
# 1. React.lazy / lazy() usage (route code splitting)
|
|
460
|
+
has_lazy = False
|
|
461
|
+
if src_dir.is_dir():
|
|
462
|
+
for tsx_file in src_dir.rglob("*.tsx"):
|
|
463
|
+
try:
|
|
464
|
+
content = tsx_file.read_text()
|
|
465
|
+
if "React.lazy(" in content or "lazy(" in content:
|
|
466
|
+
has_lazy = True
|
|
467
|
+
break
|
|
468
|
+
except Exception:
|
|
469
|
+
pass
|
|
470
|
+
checks.append(
|
|
471
|
+
(
|
|
472
|
+
"Route code splitting (React.lazy)",
|
|
473
|
+
has_lazy,
|
|
474
|
+
"lazy() found in src" if has_lazy else "No React.lazy() usage detected",
|
|
475
|
+
)
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
# 2. Large inline SVGs (>5KB) in .tsx files
|
|
479
|
+
large_svgs: list[str] = []
|
|
480
|
+
if src_dir.is_dir():
|
|
481
|
+
for tsx_file in src_dir.rglob("*.tsx"):
|
|
482
|
+
try:
|
|
483
|
+
content = tsx_file.read_text()
|
|
484
|
+
# Count bytes of inline SVG blocks
|
|
485
|
+
import re
|
|
486
|
+
|
|
487
|
+
for match in re.finditer(r"<svg[\s>].*?</svg>", content, re.DOTALL):
|
|
488
|
+
if len(match.group().encode()) > 5120:
|
|
489
|
+
large_svgs.append(tsx_file.name)
|
|
490
|
+
break
|
|
491
|
+
except Exception:
|
|
492
|
+
pass
|
|
493
|
+
has_no_large_svgs = len(large_svgs) == 0
|
|
494
|
+
detail = "No large inline SVGs" if has_no_large_svgs else f"Large inline SVGs in: {', '.join(large_svgs[:3])}"
|
|
495
|
+
checks.append(("No large inline SVGs (>5KB)", has_no_large_svgs, detail))
|
|
496
|
+
|
|
497
|
+
# 3. Images have loading="lazy"
|
|
498
|
+
imgs_without_lazy: list[str] = []
|
|
499
|
+
if src_dir.is_dir():
|
|
500
|
+
for tsx_file in src_dir.rglob("*.tsx"):
|
|
501
|
+
try:
|
|
502
|
+
content = tsx_file.read_text()
|
|
503
|
+
import re
|
|
504
|
+
|
|
505
|
+
for match in re.finditer(r"<img\b[^>]*>", content, re.DOTALL):
|
|
506
|
+
tag = match.group()
|
|
507
|
+
if "loading=" not in tag:
|
|
508
|
+
imgs_without_lazy.append(tsx_file.name)
|
|
509
|
+
break
|
|
510
|
+
except Exception:
|
|
511
|
+
pass
|
|
512
|
+
has_lazy_images = len(imgs_without_lazy) == 0
|
|
513
|
+
detail = (
|
|
514
|
+
"All <img> tags have loading attribute"
|
|
515
|
+
if has_lazy_images
|
|
516
|
+
else f"Missing loading= in: {', '.join(imgs_without_lazy[:3])}"
|
|
517
|
+
)
|
|
518
|
+
checks.append(('Images use loading="lazy"', has_lazy_images, detail))
|
|
519
|
+
|
|
520
|
+
# 4. No synchronous external scripts (no async/defer) — Vite SPAs only
|
|
521
|
+
sync_scripts: list[str] = []
|
|
522
|
+
index_html = app_dir / "index.html"
|
|
523
|
+
if index_html.exists() and not actx.is_nextjs(app_name):
|
|
524
|
+
try:
|
|
525
|
+
import re
|
|
526
|
+
|
|
527
|
+
html = index_html.read_text()
|
|
528
|
+
for match in re.finditer(r'<script\b[^>]*src=["\'][^"\']*["\'][^>]*>', html):
|
|
529
|
+
tag = match.group()
|
|
530
|
+
if (
|
|
531
|
+
"async" not in tag
|
|
532
|
+
and "defer" not in tag
|
|
533
|
+
and 'type="module"' not in tag
|
|
534
|
+
and "type='module'" not in tag
|
|
535
|
+
):
|
|
536
|
+
sync_scripts.append(tag[:60])
|
|
537
|
+
except Exception:
|
|
538
|
+
pass
|
|
539
|
+
has_no_sync_scripts = len(sync_scripts) == 0
|
|
540
|
+
detail = (
|
|
541
|
+
"No synchronous external scripts"
|
|
542
|
+
if has_no_sync_scripts
|
|
543
|
+
else f"{len(sync_scripts)} sync script(s) without async/defer"
|
|
544
|
+
)
|
|
545
|
+
checks.append(("External scripts use async/defer", has_no_sync_scripts, detail))
|
|
546
|
+
|
|
547
|
+
rows: list[list[str]] = []
|
|
548
|
+
json_data: list[dict] = []
|
|
549
|
+
for check_name, ok, detail_text in checks:
|
|
550
|
+
status = "[green]OK[/green]" if ok else "[yellow]WARN[/yellow]"
|
|
551
|
+
rows.append([check_name, status, detail_text])
|
|
552
|
+
json_data.append({"check": check_name, "status": "OK" if ok else "WARN", "detail": detail_text})
|
|
553
|
+
|
|
554
|
+
out.table(
|
|
555
|
+
f"Core Web Vitals Readiness — {app_name}",
|
|
556
|
+
[("Check", "cyan"), ("Status", ""), ("Detail", "dim")],
|
|
557
|
+
rows,
|
|
558
|
+
data_for_json=json_data,
|
|
559
|
+
)
|
|
560
|
+
|
|
561
|
+
warnings = sum(1 for _, ok, _ in checks if not ok)
|
|
562
|
+
if warnings == 0:
|
|
563
|
+
out.success("All vitals checks passed")
|
|
564
|
+
else:
|
|
565
|
+
out.warn(f"{warnings} check(s) need attention")
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
@app.command()
|
|
569
|
+
def images(
|
|
570
|
+
ctx: typer.Context,
|
|
571
|
+
app_name: Annotated[str, typer.Argument(help="App name")],
|
|
572
|
+
max_size: Annotated[int, typer.Option("--max-size", help="PNG/JPG size threshold in KB for WebP suggestion")] = 200,
|
|
573
|
+
) -> None:
|
|
574
|
+
"""Audit image assets in public/ and src/assets/ directories."""
|
|
575
|
+
from kctl_react.commands.build import _format_size
|
|
576
|
+
|
|
577
|
+
actx: AppContext = ctx.obj
|
|
578
|
+
out = actx.output
|
|
579
|
+
root = actx.project_root
|
|
580
|
+
|
|
581
|
+
actx.validate_app(app_name)
|
|
582
|
+
app_dir = get_app_dir(root, app_name)
|
|
583
|
+
|
|
584
|
+
image_extensions = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif", ".svg", ".ico"}
|
|
585
|
+
search_dirs = [app_dir / "public", app_dir / "src" / "assets"]
|
|
586
|
+
|
|
587
|
+
rows: list[list[str]] = []
|
|
588
|
+
json_data: list[dict] = []
|
|
589
|
+
|
|
590
|
+
for search_dir in search_dirs:
|
|
591
|
+
if not search_dir.is_dir():
|
|
592
|
+
continue
|
|
593
|
+
for img_file in sorted(search_dir.rglob("*")):
|
|
594
|
+
if not img_file.is_file():
|
|
595
|
+
continue
|
|
596
|
+
ext = img_file.suffix.lower()
|
|
597
|
+
if ext not in image_extensions:
|
|
598
|
+
continue
|
|
599
|
+
|
|
600
|
+
size = img_file.stat().st_size
|
|
601
|
+
fmt = ext.lstrip(".").upper()
|
|
602
|
+
suggestion = ""
|
|
603
|
+
|
|
604
|
+
if ext in (".png", ".jpg", ".jpeg") and size > max_size * 1024:
|
|
605
|
+
suggestion = f"Convert to WebP/AVIF (>{max_size}KB)"
|
|
606
|
+
elif ext == ".svg" and size > 50 * 1024:
|
|
607
|
+
suggestion = "Run SVGO optimization (>50KB)"
|
|
608
|
+
|
|
609
|
+
rel = img_file.relative_to(app_dir)
|
|
610
|
+
rows.append([str(rel), fmt, _format_size(size), suggestion or "—"])
|
|
611
|
+
json_data.append({"file": str(rel), "format": fmt, "bytes": size, "suggestion": suggestion})
|
|
612
|
+
|
|
613
|
+
if not rows:
|
|
614
|
+
out.info(f"{app_name}: No image files found in public/ or src/assets/")
|
|
615
|
+
if out.json_mode:
|
|
616
|
+
out.raw_json([])
|
|
617
|
+
return
|
|
618
|
+
|
|
619
|
+
out.table(
|
|
620
|
+
f"Image Assets — {app_name}",
|
|
621
|
+
[("File", "cyan"), ("Format", "dim"), ("Size", "green"), ("Suggestion", "yellow")],
|
|
622
|
+
rows,
|
|
623
|
+
data_for_json=json_data,
|
|
624
|
+
)
|
|
625
|
+
|
|
626
|
+
suggestions = sum(1 for r in rows if r[3] != "—")
|
|
627
|
+
if suggestions == 0:
|
|
628
|
+
out.success("All images look good")
|
|
629
|
+
else:
|
|
630
|
+
out.warn(f"{suggestions} image(s) have optimization suggestions")
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
@app.command()
|
|
634
|
+
def fonts(
|
|
635
|
+
ctx: typer.Context,
|
|
636
|
+
app_name: Annotated[str, typer.Argument(help="App name")],
|
|
637
|
+
) -> None:
|
|
638
|
+
"""Check font loading strategy (preload, self-hosted, font-display, woff2)."""
|
|
639
|
+
actx: AppContext = ctx.obj
|
|
640
|
+
out = actx.output
|
|
641
|
+
root = actx.project_root
|
|
642
|
+
|
|
643
|
+
actx.validate_app(app_name)
|
|
644
|
+
app_dir = get_app_dir(root, app_name)
|
|
645
|
+
|
|
646
|
+
checks: list[tuple[str, bool, str]] = []
|
|
647
|
+
|
|
648
|
+
# 1. Font preload in index.html (Vite) or layout.tsx (Next.js)
|
|
649
|
+
has_font_preload = False
|
|
650
|
+
index_html = app_dir / "index.html"
|
|
651
|
+
if actx.is_nextjs(app_name):
|
|
652
|
+
# Next.js uses next/font — check for font imports in layout files
|
|
653
|
+
for layout_file in app_dir.rglob("layout.tsx"):
|
|
654
|
+
try:
|
|
655
|
+
content = layout_file.read_text()
|
|
656
|
+
if "next/font" in content or "@next/font" in content:
|
|
657
|
+
has_font_preload = True
|
|
658
|
+
break
|
|
659
|
+
except Exception:
|
|
660
|
+
pass
|
|
661
|
+
elif index_html.exists():
|
|
662
|
+
try:
|
|
663
|
+
html = index_html.read_text()
|
|
664
|
+
if 'rel="preload"' in html and "font" in html:
|
|
665
|
+
has_font_preload = True
|
|
666
|
+
except Exception:
|
|
667
|
+
pass
|
|
668
|
+
preload_detail = (
|
|
669
|
+
"next/font import found in layout"
|
|
670
|
+
if actx.is_nextjs(app_name) and has_font_preload
|
|
671
|
+
else 'rel="preload" + font found'
|
|
672
|
+
if has_font_preload
|
|
673
|
+
else "No font preload/next-font found"
|
|
674
|
+
)
|
|
675
|
+
checks.append(("Font preload", has_font_preload, preload_detail))
|
|
676
|
+
|
|
677
|
+
# 2. No Google Fonts CDN (suggest self-hosting)
|
|
678
|
+
uses_google_fonts = False
|
|
679
|
+
css_dirs = [app_dir / "src", app_dir / "public"]
|
|
680
|
+
google_fonts_files: list[str] = []
|
|
681
|
+
if index_html.exists():
|
|
682
|
+
try:
|
|
683
|
+
html = index_html.read_text()
|
|
684
|
+
if "fonts.googleapis.com" in html or "fonts.gstatic.com" in html:
|
|
685
|
+
uses_google_fonts = True
|
|
686
|
+
google_fonts_files.append("index.html")
|
|
687
|
+
except Exception:
|
|
688
|
+
pass
|
|
689
|
+
for css_dir in css_dirs:
|
|
690
|
+
if css_dir.is_dir():
|
|
691
|
+
for css_file in css_dir.rglob("*.css"):
|
|
692
|
+
try:
|
|
693
|
+
content = css_file.read_text()
|
|
694
|
+
if "fonts.googleapis.com" in content or "fonts.gstatic.com" in content:
|
|
695
|
+
uses_google_fonts = True
|
|
696
|
+
google_fonts_files.append(css_file.name)
|
|
697
|
+
except Exception:
|
|
698
|
+
pass
|
|
699
|
+
no_google_fonts = not uses_google_fonts
|
|
700
|
+
detail = (
|
|
701
|
+
"No Google Fonts CDN usage"
|
|
702
|
+
if no_google_fonts
|
|
703
|
+
else f"Google Fonts CDN in: {', '.join(google_fonts_files[:3])} — consider self-hosting"
|
|
704
|
+
)
|
|
705
|
+
checks.append(("No Google Fonts CDN (self-host instead)", no_google_fonts, detail))
|
|
706
|
+
|
|
707
|
+
# 3. font-display: swap in CSS
|
|
708
|
+
has_font_display_swap = False
|
|
709
|
+
for css_dir in css_dirs:
|
|
710
|
+
if css_dir.is_dir():
|
|
711
|
+
for css_file in css_dir.rglob("*.css"):
|
|
712
|
+
try:
|
|
713
|
+
content = css_file.read_text()
|
|
714
|
+
if "font-display" in content and "swap" in content:
|
|
715
|
+
has_font_display_swap = True
|
|
716
|
+
break
|
|
717
|
+
except Exception:
|
|
718
|
+
pass
|
|
719
|
+
if has_font_display_swap:
|
|
720
|
+
break
|
|
721
|
+
checks.append(
|
|
722
|
+
(
|
|
723
|
+
"font-display: swap in CSS",
|
|
724
|
+
has_font_display_swap,
|
|
725
|
+
"font-display: swap found" if has_font_display_swap else "No font-display: swap declaration found",
|
|
726
|
+
)
|
|
727
|
+
)
|
|
728
|
+
|
|
729
|
+
# 4. Self-hosted .woff2 font files
|
|
730
|
+
has_woff2 = False
|
|
731
|
+
woff2_dirs = [app_dir / "public", app_dir / "src" / "assets", app_dir / "src" / "fonts"]
|
|
732
|
+
for woff2_dir in woff2_dirs:
|
|
733
|
+
if woff2_dir.is_dir() and any(woff2_dir.rglob("*.woff2")):
|
|
734
|
+
has_woff2 = True
|
|
735
|
+
break
|
|
736
|
+
checks.append(
|
|
737
|
+
(
|
|
738
|
+
"Self-hosted .woff2 fonts",
|
|
739
|
+
has_woff2,
|
|
740
|
+
"woff2 font files found" if has_woff2 else "No .woff2 files in public/ or src/",
|
|
741
|
+
)
|
|
742
|
+
)
|
|
743
|
+
|
|
744
|
+
rows: list[list[str]] = []
|
|
745
|
+
json_data: list[dict] = []
|
|
746
|
+
for check_name, ok, detail_text in checks:
|
|
747
|
+
status = "[green]OK[/green]" if ok else "[yellow]WARN[/yellow]"
|
|
748
|
+
rows.append([check_name, status, detail_text])
|
|
749
|
+
json_data.append({"check": check_name, "status": "OK" if ok else "WARN", "detail": detail_text})
|
|
750
|
+
|
|
751
|
+
out.table(
|
|
752
|
+
f"Font Loading Strategy — {app_name}",
|
|
753
|
+
[("Check", "cyan"), ("Status", ""), ("Detail", "dim")],
|
|
754
|
+
rows,
|
|
755
|
+
data_for_json=json_data,
|
|
756
|
+
)
|
|
757
|
+
|
|
758
|
+
warnings = sum(1 for _, ok, _ in checks if not ok)
|
|
759
|
+
if warnings == 0:
|
|
760
|
+
out.success("Font loading strategy is well optimized")
|
|
761
|
+
else:
|
|
762
|
+
out.warn(f"{warnings} font check(s) need attention")
|