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.
Files changed (102) hide show
  1. kctl_react/__init__.py +3 -0
  2. kctl_react/__main__.py +5 -0
  3. kctl_react/cli.py +201 -0
  4. kctl_react/commands/__init__.py +0 -0
  5. kctl_react/commands/a11y.py +78 -0
  6. kctl_react/commands/affected.py +170 -0
  7. kctl_react/commands/apps.py +353 -0
  8. kctl_react/commands/build.py +376 -0
  9. kctl_react/commands/bundle_cmd.py +217 -0
  10. kctl_react/commands/cap.py +1465 -0
  11. kctl_react/commands/clean.py +76 -0
  12. kctl_react/commands/codegen.py +491 -0
  13. kctl_react/commands/compliance.py +587 -0
  14. kctl_react/commands/config_cmd.py +368 -0
  15. kctl_react/commands/dashboard.py +163 -0
  16. kctl_react/commands/deploy.py +318 -0
  17. kctl_react/commands/deps.py +792 -0
  18. kctl_react/commands/dev.py +96 -0
  19. kctl_react/commands/docker_cmd.py +73 -0
  20. kctl_react/commands/doctor.py +170 -0
  21. kctl_react/commands/e2e.py +343 -0
  22. kctl_react/commands/env.py +155 -0
  23. kctl_react/commands/i18n.py +310 -0
  24. kctl_react/commands/lint.py +306 -0
  25. kctl_react/commands/maintenance.py +308 -0
  26. kctl_react/commands/monitor_cmd.py +50 -0
  27. kctl_react/commands/observe.py +34 -0
  28. kctl_react/commands/packages.py +129 -0
  29. kctl_react/commands/perf.py +762 -0
  30. kctl_react/commands/pipeline.py +289 -0
  31. kctl_react/commands/pwa.py +193 -0
  32. kctl_react/commands/scaffold.py +323 -0
  33. kctl_react/commands/security.py +660 -0
  34. kctl_react/commands/skill_cmd.py +54 -0
  35. kctl_react/commands/state.py +254 -0
  36. kctl_react/commands/test_cmd.py +418 -0
  37. kctl_react/commands/ui_audit.py +889 -0
  38. kctl_react/core/__init__.py +0 -0
  39. kctl_react/core/analyzers.py +200 -0
  40. kctl_react/core/callbacks.py +70 -0
  41. kctl_react/core/compliance/__init__.py +3 -0
  42. kctl_react/core/compliance/api_check/__init__.py +3 -0
  43. kctl_react/core/compliance/api_check/checks/__init__.py +18 -0
  44. kctl_react/core/compliance/api_check/checks/endpoints.py +53 -0
  45. kctl_react/core/compliance/api_check/checks/envelope.py +44 -0
  46. kctl_react/core/compliance/api_check/checks/naming.py +60 -0
  47. kctl_react/core/compliance/api_check/checks/params.py +44 -0
  48. kctl_react/core/compliance/api_check/checks/requests.py +57 -0
  49. kctl_react/core/compliance/api_check/checks/types.py +55 -0
  50. kctl_react/core/compliance/api_check/hooks.py +133 -0
  51. kctl_react/core/compliance/api_check/matcher.py +55 -0
  52. kctl_react/core/compliance/api_check/schema.py +151 -0
  53. kctl_react/core/compliance/api_health/__init__.py +35 -0
  54. kctl_react/core/compliance/api_health/checks/__init__.py +9 -0
  55. kctl_react/core/compliance/api_health/checks/auth.py +72 -0
  56. kctl_react/core/compliance/api_health/checks/reachable.py +44 -0
  57. kctl_react/core/compliance/api_health/checks/response.py +55 -0
  58. kctl_react/core/compliance/api_health/checks/timing.py +38 -0
  59. kctl_react/core/compliance/api_health/client.py +99 -0
  60. kctl_react/core/compliance/api_health/sampler.py +16 -0
  61. kctl_react/core/compliance/checks/__init__.py +47 -0
  62. kctl_react/core/compliance/checks/api.py +101 -0
  63. kctl_react/core/compliance/checks/codegen.py +94 -0
  64. kctl_react/core/compliance/checks/darkmode.py +57 -0
  65. kctl_react/core/compliance/checks/errors.py +68 -0
  66. kctl_react/core/compliance/checks/features.py +66 -0
  67. kctl_react/core/compliance/checks/i18n_check.py +105 -0
  68. kctl_react/core/compliance/checks/imports.py +86 -0
  69. kctl_react/core/compliance/checks/navigation.py +62 -0
  70. kctl_react/core/compliance/checks/practices.py +122 -0
  71. kctl_react/core/compliance/checks/providers.py +85 -0
  72. kctl_react/core/compliance/checks/pwa.py +101 -0
  73. kctl_react/core/compliance/checks/responsive.py +47 -0
  74. kctl_react/core/compliance/checks/scripts.py +85 -0
  75. kctl_react/core/compliance/checks/shadcn.py +51 -0
  76. kctl_react/core/compliance/checks/structure.py +76 -0
  77. kctl_react/core/compliance/checks/testing.py +83 -0
  78. kctl_react/core/compliance/checks/theme.py +92 -0
  79. kctl_react/core/compliance/checks/ui_standard.py +185 -0
  80. kctl_react/core/compliance/checks/vite.py +83 -0
  81. kctl_react/core/compliance/engine.py +87 -0
  82. kctl_react/core/compliance/exceptions_map.py +15 -0
  83. kctl_react/core/compliance/fixes/__init__.py +33 -0
  84. kctl_react/core/compliance/fixes/codegen_fix.py +28 -0
  85. kctl_react/core/compliance/fixes/i18n_fix.py +61 -0
  86. kctl_react/core/compliance/fixes/imports_fix.py +36 -0
  87. kctl_react/core/compliance/fixes/structure_fix.py +20 -0
  88. kctl_react/core/compliance/fixes/theme_fix.py +29 -0
  89. kctl_react/core/compliance/models.py +106 -0
  90. kctl_react/core/config.py +201 -0
  91. kctl_react/core/discovery.py +185 -0
  92. kctl_react/core/exceptions.py +17 -0
  93. kctl_react/core/git.py +146 -0
  94. kctl_react/core/history.py +121 -0
  95. kctl_react/core/output.py +5 -0
  96. kctl_react/core/plugins.py +13 -0
  97. kctl_react/core/runner.py +34 -0
  98. kctl_react/py.typed +0 -0
  99. kctl_react-0.6.2.dist-info/METADATA +17 -0
  100. kctl_react-0.6.2.dist-info/RECORD +102 -0
  101. kctl_react-0.6.2.dist-info/WHEEL +4 -0
  102. 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")