Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost

APP_LOCALE=en
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=en_US
APP_LOCALE=es
APP_FALLBACK_LOCALE=es
APP_FAKER_LOCALE=es_MX

APP_MAINTENANCE_DRIVER=file
# APP_MAINTENANCE_STORE=database
Expand Down
92 changes: 67 additions & 25 deletions .githooks/pre-commit
Original file line number Diff line number Diff line change
@@ -1,41 +1,83 @@
#!/usr/bin/env bash
# Tekitl pre-commit hook
# Blocks commits whose staged JS/TS changes fail ESLint. Honors Constitution
# (Principle VI Code Hygiene + Development Workflow lint gate).
# Blocks commits whose staged JS/TS changes fail ESLint and whose staged
# user-facing files contain hard-coded literals (i18n audit). Honors
# Constitution (Principle VI Code Hygiene + Development Workflow lint gate).
#
# Activated automatically by the `prepare` npm script which sets
# `git config core.hooksPath .githooks` after dependency install.

set -euo pipefail

# ---- Stage 1: ESLint on staged JS/TS ------------------------------------

# Collect staged JS/TS files (Added, Copied, Modified, Renamed).
mapfile -t staged < <(git diff --cached --name-only --diff-filter=ACMR \
-- '*.js' '*.jsx' '*.ts' '*.tsx' '*.cjs' '*.mjs')

if [ "${#staged[@]}" -eq 0 ]; then
exit 0
fi
if [ "${#staged[@]}" -gt 0 ]; then
# Reject partially staged JS/TS files: ESLint reads the working tree, so
# linting partially staged content would produce a false pass or fail and
# undermine the staged-content gate. Force the developer to stage the full
# file (or stash unstaged hunks) so the lint result matches what is committed.
if ! git diff --quiet -- "${staged[@]}"; then
echo "[pre-commit] ERROR: partially staged JS/TS files detected." >&2
echo "[pre-commit] Stage full files (or stash unstaged hunks) before commit." >&2
exit 1
fi

# Reject partially staged JS/TS files: ESLint reads the working tree, so
# linting partially staged content would produce a false pass or fail and
# undermine the staged-content gate. Force the developer to stage the full
# file (or stash unstaged hunks) so the lint result matches what is committed.
if ! git diff --quiet -- "${staged[@]}"; then
echo "[pre-commit] ERROR: partially staged JS/TS files detected." >&2
echo "[pre-commit] Stage full files (or stash unstaged hunks) before commit." >&2
exit 1
echo "[pre-commit] Running ESLint on ${#staged[@]} staged file(s)..."

# `--` terminates option parsing so a filename starting with `-` cannot be
# misread as an ESLint flag.
if command -v bun >/dev/null 2>&1; then
bun x eslint --no-warn-ignored -- "${staged[@]}"
elif command -v npx >/dev/null 2>&1; then
npx --no-install eslint --no-warn-ignored -- "${staged[@]}"
else
echo "[pre-commit] ERROR: neither 'bun' nor 'npx' found in PATH." >&2
echo "[pre-commit] Install Bun or Node.js and run 'bun install' (or 'npm install')." >&2
exit 1
fi
fi

echo "[pre-commit] Running ESLint on ${#staged[@]} staged file(s)..."

# `--` terminates option parsing so a filename starting with `-` cannot be
# misread as an ESLint flag.
if command -v bun >/dev/null 2>&1; then
bun x eslint --no-warn-ignored -- "${staged[@]}"
elif command -v npx >/dev/null 2>&1; then
npx --no-install eslint --no-warn-ignored -- "${staged[@]}"
else
echo "[pre-commit] ERROR: neither 'bun' nor 'npx' found in PATH." >&2
echo "[pre-commit] Install Bun or Node.js and run 'bun install' (or 'npm install')." >&2
exit 1
# ---- Stage 2: i18n audit on staged user-facing files --------------------
# Contract: specs/002-i18n-spanish-baseline/contracts/audit-cli.md
# § "Pre-commit Hook Integration"

mapfile -t i18n_staged < <(git diff --cached --name-only --diff-filter=ACMR \
-- '*.php' '*.blade.php' '*.tsx' '*.ts' '*.jsx' '*.js')

# Drop paths the audit config excludes (tests, fixtures, vendored bundles, the
# i18n helper itself). Keeping these in sync with `tools/i18n/audit.config.json`
# is intentional: the hook is a thin staged-subset gate over the same audit
# binary, and explicit positional paths bypass the binary's glob-level exclude.
i18n_filtered=()
for staged_path in "${i18n_staged[@]}"; do
case "$staged_path" in
tests/*|tools/i18n/fixtures/*|node_modules/*|vendor/*|storage/*|bootstrap/cache/*|public/build/*) ;;
resources/js/components/examples/*|resources/js/wayfinder/*|resources/js/types/*) ;;
resources/js/actions/*|resources/js/routes/*|resources/js/lib/i18n.ts) ;;
*) i18n_filtered+=("$staged_path") ;;
esac
done

if [ "${#i18n_filtered[@]}" -gt 0 ]; then
if ! git diff --quiet -- "${i18n_filtered[@]}"; then
echo "[pre-commit] ERROR: partially staged user-facing files detected." >&2
echo "[pre-commit] Stage full files (or stash unstaged hunks) before commit." >&2
exit 1
fi

echo "[pre-commit] Running i18n audit on ${#i18n_filtered[@]} staged file(s)..."

if command -v bun >/dev/null 2>&1; then
bun run i18n:audit -- "${i18n_filtered[@]}"
elif command -v node >/dev/null 2>&1; then
node tools/i18n/audit.mjs -- "${i18n_filtered[@]}"
else
echo "[pre-commit] ERROR: neither 'bun' nor 'node' found in PATH." >&2
echo "[pre-commit] Install Bun or Node.js to run the i18n audit." >&2
exit 1
fi
fi
3 changes: 3 additions & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ jobs:
- name: Lint Frontend
run: npm run lint

- name: i18n Audit
run: npm run i18n:audit

# - name: Commit Changes
# uses: stefanzweifel/git-auto-commit-action@v7
# with:
Expand Down
2 changes: 1 addition & 1 deletion .specify/feature.json
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"feature_directory":"specs/001-project-lifecycle-timeline"}
{"feature_directory":"specs/002-i18n-spanish-baseline"}
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ This project has domain-specific skills available. You MUST activate the relevan
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming.
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
- Check for existing components to reuse before writing a new one.
- User-facing copy lives only in `lang/<locale>/`. Use `__()` / `@lang` (PHP/Blade) or `t()` / `tChoice()` from `@/lib/i18n` (React). Procedure: [`specs/002-i18n-spanish-baseline/quickstart.md`](specs/002-i18n-spanish-baseline/quickstart.md).

## Verification Scripts

Expand Down Expand Up @@ -293,6 +294,6 @@ Wayfinder generates TypeScript functions for Laravel routes. Import from `@/acti
</laravel-boost-guidelines>

<!-- SPECKIT START -->
Active feature plan: [specs/001-project-lifecycle-timeline/plan.md](specs/001-project-lifecycle-timeline/plan.md)
Active feature plan: [specs/002-i18n-spanish-baseline/plan.md](specs/002-i18n-spanish-baseline/plan.md)
Read the plan for technical context, project structure, and constitutional compliance for the in-flight feature.
<!-- SPECKIT END -->
17 changes: 17 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Contributing to Tekitl

## Adding a new string

User-facing copy (Spanish or any other locale) MUST live in `lang/<locale>/`. Hard-coded literals in PHP, Blade, or React are blocked by the i18n audit (`bun run i18n:audit`) and rejected at the pre-commit hook plus CI.

1. Add the key to `lang/es/<domain>.php` (e.g., `lang/es/projects.php`). Keys are dotted paths in `snake_case`.
2. Run `php artisan i18n:scaffold-en` to mirror the key into `lang/en/<domain>.php` with an empty value.
3. Reference the key from code:
- **PHP / Blade**: `__('domain.section.key')` or `@lang('domain.section.key')`.
- **React / TS**: `import { t, tChoice } from '@/lib/i18n'` then `t('domain.section.key')`.
4. Run `bun run i18n:audit`; expect `0 finding(s).`
5. Commit. The pre-commit hook re-runs ESLint and the audit on the staged subset.

Full procedure, plural rules, allow-list policy, and false-positive handling: [`specs/002-i18n-spanish-baseline/quickstart.md`](specs/002-i18n-spanish-baseline/quickstart.md).

The translation helper contract (signatures, failure modes, type surface): [`specs/002-i18n-spanish-baseline/contracts/translation-helper.md`](specs/002-i18n-spanish-baseline/contracts/translation-helper.md).
6 changes: 3 additions & 3 deletions app/ConfidenceLevel.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

enum ConfidenceLevel: string
{
case Aprendiz = 'aprendiz';
case Autosuficiente = 'autosuficiente';
case Maestro = 'maestro';
case Apprentice = 'aprendiz';
case SelfSufficient = 'autosuficiente';
case Master = 'maestro';
}
152 changes: 152 additions & 0 deletions app/Console/Commands/I18nReport.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
<?php

declare(strict_types=1);

namespace App\Console\Commands;

use Illuminate\Console\Command;

class I18nReport extends Command
{
protected $signature = 'i18n:report
{--locale= : Limit the report to a single locale (defaults to every non-default locale)}
{--format=text : Output format (text|json)}
{--strict : Exit non-zero when any key is untranslated}';

protected $description = 'List untranslated keys in lang/<locale>/.';

public function handle(): int
{
$base = $this->laravel->langPath();
$defaultLocale = (string) config('app.locale', 'es');

$locales = $this->resolveLocales($base, $defaultLocale);
$format = (string) $this->option('format');

if (! in_array($format, ['text', 'json'], true)) {
$this->components->error("Unknown --format value: {$format}");

return self::FAILURE;
}

$reports = [];
$totalUntranslated = 0;

foreach ($locales as $locale) {
$untranslated = $this->collectUntranslated($base, $locale);
sort($untranslated);

$reports[] = [
'locale' => $locale,
'untranslated' => $untranslated,
'total' => count($untranslated),
];

$totalUntranslated += count($untranslated);
}

if ($format === 'json') {
$payload = count($reports) === 1 ? $reports[0] : ['locales' => $reports, 'total' => $totalUntranslated];
$this->line((string) json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
} else {
foreach ($reports as $report) {
$this->line(sprintf('[%s] %d untranslated key(s):', $report['locale'], $report['total']));
foreach ($report['untranslated'] as $key) {
$this->line(' - '.$key);
}
}
}

if ((bool) $this->option('strict') && $totalUntranslated > 0) {
return self::FAILURE;
}

return self::SUCCESS;
}

/**
* @return array<int, string>
*/
private function resolveLocales(string $base, string $defaultLocale): array
{
$explicit = $this->option('locale');
if (is_string($explicit) && $explicit !== '') {
return [$explicit];
}

$found = [];
foreach (glob($base.DIRECTORY_SEPARATOR.'*', GLOB_ONLYDIR) ?: [] as $dir) {
$name = basename($dir);
if ($name !== $defaultLocale) {
$found[] = $name;
}
}

sort($found);

return $found;
}

/**
* @return array<int, string>
*/
private function collectUntranslated(string $base, string $locale): array
{
$localeDir = $base.DIRECTORY_SEPARATOR.$locale;

if (! is_dir($localeDir)) {
return [];
}

$sourceDir = $base.DIRECTORY_SEPARATOR.((string) config('app.locale', 'es'));

$untranslated = [];

foreach (glob($localeDir.DIRECTORY_SEPARATOR.'*.php') ?: [] as $path) {
$domain = basename($path, '.php');
$tree = require $path;

if (! is_array($tree)) {
continue;
}

$sourceTree = is_file($sourceDir.DIRECTORY_SEPARATOR.$domain.'.php')
? (array) require $sourceDir.DIRECTORY_SEPARATOR.$domain.'.php'
: [];

$this->walk($tree, $sourceTree, $domain, $untranslated);
}

return $untranslated;
}

/**
* @param array<string, mixed> $tree
* @param array<string, mixed> $sourceTree
* @param array<int, string> $untranslated
*/
private function walk(array $tree, array $sourceTree, string $prefix, array &$untranslated): void
{
foreach ($tree as $key => $value) {
$path = $prefix.'.'.$key;
$sourceValue = is_array($sourceTree) ? ($sourceTree[$key] ?? null) : null;

if (is_array($value)) {
$childSource = is_array($sourceValue) ? $sourceValue : [];
$this->walk($value, $childSource, $path, $untranslated);

continue;
}

if (! is_string($value) || $value === '') {
$untranslated[] = $path;

continue;
}

if (is_string($sourceValue) && $sourceValue !== '' && $value === $sourceValue) {
$untranslated[] = $path;
}
}
}
}
Loading
Loading