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,155 @@
|
|
|
1
|
+
"""Environment variable management commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from kctl_react.core.callbacks import AppContext
|
|
11
|
+
from kctl_react.core.discovery import get_app_dir
|
|
12
|
+
|
|
13
|
+
app = typer.Typer(help="Environment variable management.")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _find_env_files(app_dir: Path) -> list[Path]:
|
|
17
|
+
"""Find all .env* files in an app directory."""
|
|
18
|
+
return sorted(f for f in app_dir.iterdir() if f.name.startswith(".env") and f.is_file())
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _parse_env_file(path: Path) -> dict[str, str]:
|
|
22
|
+
"""Parse a .env file into key-value pairs."""
|
|
23
|
+
result: dict[str, str] = {}
|
|
24
|
+
if not path.exists():
|
|
25
|
+
return result
|
|
26
|
+
for line in path.read_text().splitlines():
|
|
27
|
+
line = line.strip()
|
|
28
|
+
if not line or line.startswith("#"):
|
|
29
|
+
continue
|
|
30
|
+
if "=" in line:
|
|
31
|
+
key, _, value = line.partition("=")
|
|
32
|
+
result[key.strip()] = value.strip().strip('"').strip("'")
|
|
33
|
+
return result
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@app.command()
|
|
37
|
+
def show(
|
|
38
|
+
ctx: typer.Context,
|
|
39
|
+
app_name: Annotated[str, typer.Argument(help="App name")],
|
|
40
|
+
) -> None:
|
|
41
|
+
"""Show environment variables for an app."""
|
|
42
|
+
actx: AppContext = ctx.obj
|
|
43
|
+
out = actx.output
|
|
44
|
+
root = actx.project_root
|
|
45
|
+
|
|
46
|
+
actx.validate_app(app_name)
|
|
47
|
+
app_dir = get_app_dir(root, app_name)
|
|
48
|
+
|
|
49
|
+
env_files = _find_env_files(app_dir)
|
|
50
|
+
if not env_files:
|
|
51
|
+
out.warn(f"No .env files found in apps/{app_name}/")
|
|
52
|
+
return
|
|
53
|
+
|
|
54
|
+
for env_file in env_files:
|
|
55
|
+
env_vars = _parse_env_file(env_file)
|
|
56
|
+
sections: list[tuple[str, list[tuple[str, str]]]] = [
|
|
57
|
+
(env_file.name, [(k, v) for k, v in sorted(env_vars.items())]),
|
|
58
|
+
]
|
|
59
|
+
out.detail(f"{app_name} — {env_file.name}", sections, data_for_json=env_vars)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@app.command()
|
|
63
|
+
def diff(
|
|
64
|
+
ctx: typer.Context,
|
|
65
|
+
app1: Annotated[str, typer.Argument(help="First app name")],
|
|
66
|
+
app2: Annotated[str, typer.Argument(help="Second app name")],
|
|
67
|
+
) -> None:
|
|
68
|
+
"""Compare .env files between two apps."""
|
|
69
|
+
actx: AppContext = ctx.obj
|
|
70
|
+
out = actx.output
|
|
71
|
+
root = actx.project_root
|
|
72
|
+
|
|
73
|
+
actx.validate_app(app1)
|
|
74
|
+
actx.validate_app(app2)
|
|
75
|
+
|
|
76
|
+
env1 = _parse_env_file(get_app_dir(root, app1) / ".env")
|
|
77
|
+
env2 = _parse_env_file(get_app_dir(root, app2) / ".env")
|
|
78
|
+
|
|
79
|
+
if not env1:
|
|
80
|
+
env1 = _parse_env_file(get_app_dir(root, app1) / ".env.local")
|
|
81
|
+
if not env2:
|
|
82
|
+
env2 = _parse_env_file(get_app_dir(root, app2) / ".env.local")
|
|
83
|
+
|
|
84
|
+
all_keys = sorted(set(env1.keys()) | set(env2.keys()))
|
|
85
|
+
|
|
86
|
+
rows: list[list[str]] = []
|
|
87
|
+
for key in all_keys:
|
|
88
|
+
v1 = env1.get(key, "[dim]--[/dim]")
|
|
89
|
+
v2 = env2.get(key, "[dim]--[/dim]")
|
|
90
|
+
match = "[green]=[/green]" if v1 == v2 else "[yellow]![/yellow]"
|
|
91
|
+
rows.append([key, v1, v2, match])
|
|
92
|
+
|
|
93
|
+
out.table(
|
|
94
|
+
f"Env Diff: {app1} vs {app2}",
|
|
95
|
+
[("Key", "cyan"), (app1, ""), (app2, ""), ("Match", "")],
|
|
96
|
+
rows,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@app.command()
|
|
101
|
+
def validate(
|
|
102
|
+
ctx: typer.Context,
|
|
103
|
+
app_name: Annotated[str | None, typer.Argument(help="App name (omit for all)")] = None,
|
|
104
|
+
) -> None:
|
|
105
|
+
"""Validate .env files exist and have required keys."""
|
|
106
|
+
actx: AppContext = ctx.obj
|
|
107
|
+
out = actx.output
|
|
108
|
+
root = actx.project_root
|
|
109
|
+
|
|
110
|
+
apps_to_check = [app_name] if app_name else actx.app_names
|
|
111
|
+
if app_name:
|
|
112
|
+
actx.validate_app(app_name)
|
|
113
|
+
|
|
114
|
+
# Common expected keys for Kodemeio React apps
|
|
115
|
+
expected_keys = {"VITE_ODOO_URL", "VITE_ODOO_DB", "VITE_AUTH_MODE"}
|
|
116
|
+
|
|
117
|
+
rows: list[list[str]] = []
|
|
118
|
+
issues = 0
|
|
119
|
+
|
|
120
|
+
for name in apps_to_check:
|
|
121
|
+
app_dir = get_app_dir(root, name)
|
|
122
|
+
env_files = _find_env_files(app_dir)
|
|
123
|
+
|
|
124
|
+
if not env_files:
|
|
125
|
+
rows.append([name, "[red]missing[/red]", "[dim]--[/dim]", "[red]no .env file[/red]"])
|
|
126
|
+
issues += 1
|
|
127
|
+
continue
|
|
128
|
+
|
|
129
|
+
env = _parse_env_file(env_files[0])
|
|
130
|
+
missing = expected_keys - set(env.keys())
|
|
131
|
+
file_name = env_files[0].name
|
|
132
|
+
|
|
133
|
+
if missing:
|
|
134
|
+
rows.append(
|
|
135
|
+
[
|
|
136
|
+
name,
|
|
137
|
+
f"[green]{file_name}[/green]",
|
|
138
|
+
str(len(env)),
|
|
139
|
+
f"[yellow]missing: {', '.join(sorted(missing))}[/yellow]",
|
|
140
|
+
]
|
|
141
|
+
)
|
|
142
|
+
issues += 1
|
|
143
|
+
else:
|
|
144
|
+
rows.append([name, f"[green]{file_name}[/green]", str(len(env)), "[green]OK[/green]"])
|
|
145
|
+
|
|
146
|
+
out.table(
|
|
147
|
+
"Env Validation",
|
|
148
|
+
[("App", "cyan"), ("File", ""), ("Keys", ""), ("Status", "")],
|
|
149
|
+
rows,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
if issues:
|
|
153
|
+
out.warn(f"{issues} app(s) have env issues")
|
|
154
|
+
else:
|
|
155
|
+
out.success("All apps have valid env files")
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
"""Translation management for react-i18next apps."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Annotated
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from kctl_react.core.analyzers import check_interpolation_vars
|
|
12
|
+
from kctl_react.core.callbacks import AppContext
|
|
13
|
+
|
|
14
|
+
app = typer.Typer(help="Translation management (react-i18next).")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _find_i18n_dir(app_dir: Path) -> Path | None:
|
|
18
|
+
"""Find the i18n directory — src/i18n/ (react-i18next) or messages/ (next-intl)."""
|
|
19
|
+
for candidate in (app_dir / "src" / "i18n", app_dir / "messages"):
|
|
20
|
+
if candidate.is_dir() and any(candidate.glob("*.json")):
|
|
21
|
+
return candidate
|
|
22
|
+
return None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _load_translations(app_dir: Path) -> dict[str, dict]:
|
|
26
|
+
"""Load translation JSON files from app's i18n directory."""
|
|
27
|
+
i18n_dir = _find_i18n_dir(app_dir)
|
|
28
|
+
if i18n_dir is None:
|
|
29
|
+
return {}
|
|
30
|
+
result = {}
|
|
31
|
+
for f in i18n_dir.glob("*.json"):
|
|
32
|
+
lang = f.stem
|
|
33
|
+
try:
|
|
34
|
+
result[lang] = json.loads(f.read_text())
|
|
35
|
+
except json.JSONDecodeError:
|
|
36
|
+
result[lang] = {}
|
|
37
|
+
return result
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _flatten_keys(d: dict, prefix: str = "") -> set[str]:
|
|
41
|
+
"""Flatten nested dict into dot-separated key set."""
|
|
42
|
+
keys = set()
|
|
43
|
+
for k, v in d.items():
|
|
44
|
+
full_key = f"{prefix}.{k}" if prefix else k
|
|
45
|
+
if isinstance(v, dict):
|
|
46
|
+
keys.update(_flatten_keys(v, full_key))
|
|
47
|
+
else:
|
|
48
|
+
keys.add(full_key)
|
|
49
|
+
return keys
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@app.command()
|
|
53
|
+
def coverage(
|
|
54
|
+
ctx: typer.Context,
|
|
55
|
+
app_name: Annotated[str | None, typer.Argument(help="App name")] = None,
|
|
56
|
+
all_apps: Annotated[bool, typer.Option("--all", help="All apps")] = False,
|
|
57
|
+
) -> None:
|
|
58
|
+
"""Translation coverage — compare en.json vs id.json."""
|
|
59
|
+
actx: AppContext = ctx.obj
|
|
60
|
+
out = actx.output
|
|
61
|
+
apps_to_check = list(actx.apps.keys()) if all_apps else ([app_name] if app_name else [])
|
|
62
|
+
if not apps_to_check:
|
|
63
|
+
out.error("Specify app name or --all")
|
|
64
|
+
raise typer.Exit(1) from None
|
|
65
|
+
rows, json_data = [], []
|
|
66
|
+
for name in apps_to_check:
|
|
67
|
+
actx.validate_app(name)
|
|
68
|
+
translations = _load_translations(actx.get_app_dir(name))
|
|
69
|
+
en_keys = _flatten_keys(translations.get("en", {}))
|
|
70
|
+
id_keys = _flatten_keys(translations.get("id", {}))
|
|
71
|
+
total = len(en_keys)
|
|
72
|
+
translated = len(en_keys & id_keys)
|
|
73
|
+
pct = (translated / total * 100) if total > 0 else 0
|
|
74
|
+
missing = len(en_keys - id_keys)
|
|
75
|
+
rows.append([name, str(total), str(translated), f"{pct:.0f}%", str(missing)])
|
|
76
|
+
json_data.append(
|
|
77
|
+
{
|
|
78
|
+
"app": name,
|
|
79
|
+
"total_keys": total,
|
|
80
|
+
"translated": translated,
|
|
81
|
+
"coverage_pct": round(pct, 1),
|
|
82
|
+
"missing": missing,
|
|
83
|
+
}
|
|
84
|
+
)
|
|
85
|
+
out.table(
|
|
86
|
+
"i18n Coverage",
|
|
87
|
+
[("App", "cyan"), ("Total", ""), ("Translated", "green"), ("Coverage", ""), ("Missing", "red")],
|
|
88
|
+
rows,
|
|
89
|
+
data_for_json=json_data,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@app.command()
|
|
94
|
+
def missing(ctx: typer.Context, app_name: Annotated[str, typer.Argument(help="App name")]) -> None:
|
|
95
|
+
"""List keys in en.json but not in id.json."""
|
|
96
|
+
actx: AppContext = ctx.obj
|
|
97
|
+
out = actx.output
|
|
98
|
+
actx.validate_app(app_name)
|
|
99
|
+
translations = _load_translations(actx.get_app_dir(app_name))
|
|
100
|
+
en_keys = _flatten_keys(translations.get("en", {}))
|
|
101
|
+
id_keys = _flatten_keys(translations.get("id", {}))
|
|
102
|
+
missing_keys = sorted(en_keys - id_keys)
|
|
103
|
+
if not missing_keys:
|
|
104
|
+
out.success("All keys translated")
|
|
105
|
+
return
|
|
106
|
+
rows = [[k] for k in missing_keys]
|
|
107
|
+
out.table(f"Missing Translations — {app_name}", [("Key", "yellow")], rows)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@app.command()
|
|
111
|
+
def unused(ctx: typer.Context, app_name: Annotated[str, typer.Argument(help="App name")]) -> None:
|
|
112
|
+
"""Find translation keys not referenced in source code."""
|
|
113
|
+
actx: AppContext = ctx.obj
|
|
114
|
+
out = actx.output
|
|
115
|
+
actx.validate_app(app_name)
|
|
116
|
+
app_dir = actx.get_app_dir(app_name)
|
|
117
|
+
translations = _load_translations(app_dir)
|
|
118
|
+
all_keys = _flatten_keys(translations.get("en", {}))
|
|
119
|
+
# Grep source for t("key") or t('key') patterns
|
|
120
|
+
import re
|
|
121
|
+
|
|
122
|
+
used_keys: set[str] = set()
|
|
123
|
+
src_dir = app_dir / "src"
|
|
124
|
+
for f in src_dir.rglob("*.tsx"):
|
|
125
|
+
content = f.read_text(errors="ignore")
|
|
126
|
+
used_keys.update(re.findall(r't\(["\']([^"\']+)["\']\)', content))
|
|
127
|
+
unused_keys = sorted(all_keys - used_keys)
|
|
128
|
+
if not unused_keys:
|
|
129
|
+
out.success("No unused keys found")
|
|
130
|
+
return
|
|
131
|
+
rows = [[k] for k in unused_keys[:50]]
|
|
132
|
+
out.table(
|
|
133
|
+
f"Unused Keys — {app_name} (showing {min(50, len(unused_keys))}/{len(unused_keys)})", [("Key", "dim")], rows
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@app.command()
|
|
138
|
+
def sort(ctx: typer.Context, app_name: Annotated[str, typer.Argument(help="App name")]) -> None:
|
|
139
|
+
"""Sort translation JSON keys alphabetically."""
|
|
140
|
+
actx: AppContext = ctx.obj
|
|
141
|
+
out = actx.output
|
|
142
|
+
actx.validate_app(app_name)
|
|
143
|
+
i18n_dir = _find_i18n_dir(actx.get_app_dir(app_name))
|
|
144
|
+
if i18n_dir is None:
|
|
145
|
+
out.error(f"No i18n directory found for {app_name}")
|
|
146
|
+
raise typer.Exit(1) from None
|
|
147
|
+
for f in i18n_dir.glob("*.json"):
|
|
148
|
+
data = json.loads(f.read_text())
|
|
149
|
+
sorted_data = json.dumps(dict(sorted(data.items())), indent=2, ensure_ascii=False) + "\n"
|
|
150
|
+
f.write_text(sorted_data)
|
|
151
|
+
out.success(f"Sorted {f.name}")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _sync_stub_recursive(en: dict, id_: dict) -> int:
|
|
155
|
+
"""Recursively add missing keys from en to id with [ID] prefix."""
|
|
156
|
+
added = 0
|
|
157
|
+
for key, value in en.items():
|
|
158
|
+
if key not in id_:
|
|
159
|
+
if isinstance(value, dict):
|
|
160
|
+
id_[key] = {}
|
|
161
|
+
added += _sync_stub_recursive(value, id_[key])
|
|
162
|
+
else:
|
|
163
|
+
id_[key] = f"[ID] {value}"
|
|
164
|
+
added += 1
|
|
165
|
+
elif isinstance(value, dict) and isinstance(id_[key], dict):
|
|
166
|
+
added += _sync_stub_recursive(value, id_[key])
|
|
167
|
+
return added
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@app.command()
|
|
171
|
+
def diff(
|
|
172
|
+
ctx: typer.Context,
|
|
173
|
+
app_name: Annotated[str, typer.Argument(help="App name")],
|
|
174
|
+
base: Annotated[str, typer.Option("--base", help="Git ref to compare")] = "HEAD~1",
|
|
175
|
+
) -> None:
|
|
176
|
+
"""Show keys added/removed between git refs."""
|
|
177
|
+
actx: AppContext = ctx.obj
|
|
178
|
+
out = actx.output
|
|
179
|
+
actx.validate_app(app_name)
|
|
180
|
+
from kctl_react.core.runner import run_quiet
|
|
181
|
+
|
|
182
|
+
i18n_dir = _find_i18n_dir(actx.get_app_dir(app_name))
|
|
183
|
+
if i18n_dir is None:
|
|
184
|
+
out.info("No i18n directory found")
|
|
185
|
+
return
|
|
186
|
+
en_path = str((i18n_dir / "en.json").relative_to(actx.project_root))
|
|
187
|
+
result = run_quiet(["git", "diff", base, "--", en_path], cwd=actx.project_root)
|
|
188
|
+
if result.returncode != 0 or not result.stdout.strip():
|
|
189
|
+
out.info("No translation changes")
|
|
190
|
+
return
|
|
191
|
+
out.text(result.stdout)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@app.command()
|
|
195
|
+
def validate(ctx: typer.Context, app_name: Annotated[str, typer.Argument(help="App name")]) -> None:
|
|
196
|
+
"""Validate all translation JSON files for syntax and required keys."""
|
|
197
|
+
actx: AppContext = ctx.obj
|
|
198
|
+
out = actx.output
|
|
199
|
+
actx.validate_app(app_name)
|
|
200
|
+
i18n_dir = _find_i18n_dir(actx.get_app_dir(app_name))
|
|
201
|
+
|
|
202
|
+
if i18n_dir is None:
|
|
203
|
+
out.error(f"No i18n directory found for {app_name} (checked src/i18n/ and messages/)")
|
|
204
|
+
raise typer.Exit(1) from None
|
|
205
|
+
|
|
206
|
+
issues: list[list[str]] = []
|
|
207
|
+
json_data: list[dict] = []
|
|
208
|
+
|
|
209
|
+
for f in sorted(i18n_dir.glob("*.json")):
|
|
210
|
+
try:
|
|
211
|
+
data = json.loads(f.read_text())
|
|
212
|
+
if not isinstance(data, dict):
|
|
213
|
+
issues.append([f.name, "root is not an object"])
|
|
214
|
+
json_data.append({"file": f.name, "valid": False, "issue": "root is not an object"})
|
|
215
|
+
else:
|
|
216
|
+
json_data.append({"file": f.name, "valid": True, "issue": ""})
|
|
217
|
+
except json.JSONDecodeError as exc:
|
|
218
|
+
issues.append([f.name, str(exc)])
|
|
219
|
+
json_data.append({"file": f.name, "valid": False, "issue": str(exc)})
|
|
220
|
+
|
|
221
|
+
# Check required files
|
|
222
|
+
for required in ("en.json", "id.json"):
|
|
223
|
+
if not (i18n_dir / required).exists():
|
|
224
|
+
issues.append([required, "file missing"])
|
|
225
|
+
json_data.append({"file": required, "valid": False, "issue": "file missing"})
|
|
226
|
+
|
|
227
|
+
if issues:
|
|
228
|
+
out.table(
|
|
229
|
+
f"i18n Validation Issues — {app_name}",
|
|
230
|
+
[("File", "yellow"), ("Issue", "red")],
|
|
231
|
+
issues,
|
|
232
|
+
data_for_json=json_data,
|
|
233
|
+
)
|
|
234
|
+
raise typer.Exit(1) from None
|
|
235
|
+
|
|
236
|
+
out.success(f"All translation files valid for {app_name}")
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
@app.command()
|
|
240
|
+
def interpolation(ctx: typer.Context, app_name: Annotated[str, typer.Argument(help="App name")]) -> None:
|
|
241
|
+
"""Check {{var}} placeholder consistency between en.json and id.json."""
|
|
242
|
+
actx: AppContext = ctx.obj
|
|
243
|
+
out = actx.output
|
|
244
|
+
actx.validate_app(app_name)
|
|
245
|
+
app_dir = actx.get_app_dir(app_name)
|
|
246
|
+
|
|
247
|
+
translations = _load_translations(app_dir)
|
|
248
|
+
en = translations.get("en", {})
|
|
249
|
+
id_ = translations.get("id", {})
|
|
250
|
+
|
|
251
|
+
mismatches = check_interpolation_vars(en, id_)
|
|
252
|
+
|
|
253
|
+
if not mismatches:
|
|
254
|
+
out.success(f"No interpolation mismatches in {app_name}")
|
|
255
|
+
return
|
|
256
|
+
|
|
257
|
+
rows = [
|
|
258
|
+
[
|
|
259
|
+
m["key"],
|
|
260
|
+
", ".join(sorted(m["en_vars"])) or "(none)", # type: ignore[arg-type]
|
|
261
|
+
", ".join(sorted(m["id_vars"])) or "(none)", # type: ignore[arg-type]
|
|
262
|
+
", ".join(sorted(m["missing_in_id"])) or "", # type: ignore[arg-type]
|
|
263
|
+
]
|
|
264
|
+
for m in mismatches
|
|
265
|
+
]
|
|
266
|
+
json_data = [
|
|
267
|
+
{
|
|
268
|
+
"key": m["key"],
|
|
269
|
+
"en_vars": sorted(m["en_vars"]), # type: ignore[arg-type]
|
|
270
|
+
"id_vars": sorted(m["id_vars"]), # type: ignore[arg-type]
|
|
271
|
+
"missing_in_id": sorted(m["missing_in_id"]), # type: ignore[arg-type]
|
|
272
|
+
}
|
|
273
|
+
for m in mismatches
|
|
274
|
+
]
|
|
275
|
+
out.table(
|
|
276
|
+
f"Interpolation Mismatches — {app_name}",
|
|
277
|
+
[("Key", "cyan"), ("EN vars", ""), ("ID vars", ""), ("Missing in ID", "red")],
|
|
278
|
+
rows,
|
|
279
|
+
data_for_json=json_data,
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
@app.command(name="sync-stub")
|
|
284
|
+
def sync_stub(ctx: typer.Context, app_name: Annotated[str, typer.Argument(help="App name")]) -> None:
|
|
285
|
+
"""Generate stub entries in id.json for keys missing from en.json."""
|
|
286
|
+
actx: AppContext = ctx.obj
|
|
287
|
+
out = actx.output
|
|
288
|
+
actx.validate_app(app_name)
|
|
289
|
+
i18n_dir = _find_i18n_dir(actx.get_app_dir(app_name))
|
|
290
|
+
if i18n_dir is None:
|
|
291
|
+
out.error(f"No i18n directory found for {app_name}")
|
|
292
|
+
raise typer.Exit(1) from None
|
|
293
|
+
en_file = i18n_dir / "en.json"
|
|
294
|
+
id_file = i18n_dir / "id.json"
|
|
295
|
+
|
|
296
|
+
if not en_file.exists():
|
|
297
|
+
out.error(f"en.json not found: {en_file}")
|
|
298
|
+
raise typer.Exit(1) from None
|
|
299
|
+
|
|
300
|
+
en = json.loads(en_file.read_text())
|
|
301
|
+
id_ = json.loads(id_file.read_text()) if id_file.exists() else {}
|
|
302
|
+
|
|
303
|
+
added = _sync_stub_recursive(en, id_)
|
|
304
|
+
|
|
305
|
+
id_file.write_text(json.dumps(id_, indent=2, ensure_ascii=False) + "\n")
|
|
306
|
+
|
|
307
|
+
if added == 0:
|
|
308
|
+
out.success(f"No missing keys in {app_name} — id.json is complete")
|
|
309
|
+
else:
|
|
310
|
+
out.success(f"Added {added} stub entr{'y' if added == 1 else 'ies'} to id.json for {app_name}")
|