TQ-Slator/bin/build-tqs-phar.php
Stephan Morand 9025c64c0b Socle complet de TQ-Slator : éditeur, API, CLI, administration
Système de gestion de traductions pensé en CMS headless : un back-office
pour ceux qui traduisent, une API pour ce qui consomme.

Architecture
- Symfony 7.4 / API Platform 4.3 / MariaDB 11.4, SPA React 19 servie en
  même origine — ce qui rend viable le cookie de session plutôt qu'un
  jeton en localStorage.
- Deux APIs séparées : Management (session ou clé) et Delivery
  (stateless, clé seule). Les fusionner ferait porter à chaque lecture de
  bundle le coût de la session.
- Stockage canonique en ICU MessageFormat, sérialisation par plateforme.
  Le format d'une plateforme ne contamine pas la base.
- Publication par releases immuables ; le déploiement est un déplacement
  de pointeur, donc le rollback aussi.
- Isolation multi-organisation par filtre Doctrine, avec un test
  d'architecture qui casse la CI si une entité échappe à l'invariant.

Éditeur, deux vues
- Par langue : source et cible, jamais douze colonnes. Grille virtualisée,
  saisie sans bouton « Enregistrer », panneau de contexte permanent.
- Par clé : une clé, toutes ses langues empilées et repliées. Répond à
  « ce libellé est-il prêt partout ? ».
- Mode Focus dans les deux : une file à vider, ⌘↵ pour enchaîner.
- Le traducteur ne voit jamais d'ICU : pastilles de variables, un champ
  par catégorie CLDR de la langue cible.

Administration
- Deux niveaux : projet (membres, clés API, plateformes) et organisation
  (annuaire des comptes, création de projets).
- Invitations par e-mail, jeton 256 bits stocké haché.
- Désactiver un compte coupe les sessions en cours, pas seulement les
  connexions suivantes.
- Les plateformes s'archivent ; ni elles ni les environnements ne se
  suppriment — la trace explique pourquoi telle clé existe.

CLI tqs
- PHAR autonome de 3 Mo, autoloader généré : le dépôt client ne dépend
  ni de Composer ni de la disponibilité de TQ-Slator.
- init / push / pull / status ; le sync est non destructif par défaut et
  son prune est scopé plateforme.

126 tests, PHPStan niveau 8.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 08:16:05 +02:00

256 lines
7.9 KiB
PHP

#!/usr/bin/env php
<?php
declare(strict_types=1);
/*
* Construit `tqs.phar` — un fichier unique, distribuable tel quel.
*
* Écrit à la main plutôt qu'avec Box : le besoin tient en cinquante lignes
* (empaqueter src/Cli et les quelques dépendances qu'il utilise), et ajouter un
* outil de build à maintenir pour cela serait disproportionné.
*
* Le PHAR n'embarque QUE ce dont le CLI a besoin : Console, HttpClient et leurs
* dépendances. Ni Doctrine, ni API Platform, ni le kernel — le CLI ne parle
* qu'HTTP, et l'embarquer entier multiplierait sa taille par dix pour rien.
*
* Usage :
* php -d phar.readonly=0 bin/build-tqs-phar.php
*/
$root = \dirname(__DIR__);
$target = $root.'/build/tqs.phar';
if (\ini_get('phar.readonly')) {
fwrite(\STDERR, "phar.readonly est actif.\n\nRelancez :\n php -d phar.readonly=0 bin/build-tqs-phar.php\n");
exit(1);
}
@mkdir($root.'/build', 0o775, true);
@unlink($target);
$phar = new Phar($target, 0, 'tqs.phar');
$phar->startBuffering();
// Dépendances du CLI, calculées depuis le graphe réel de Composer.
//
// Une liste écrite à la main est fatalement incomplète : l'autoloader charge
// d'emblée des polyfills que personne ne pense à citer, et le PHAR échoue à
// l'exécution — après le build, donc chez l'utilisateur. Partir de
// `installed.json` rend l'ensemble correct par construction.
$roots = ['symfony/console', 'symfony/http-client'];
$installed = json_decode((string) file_get_contents($root.'/vendor/composer/installed.json'), true);
if (!\is_array($installed) || !isset($installed['packages'])) {
fwrite(\STDERR, "vendor/composer/installed.json illisible. Lancez « composer install ».\n");
exit(1);
}
$byName = [];
foreach ($installed['packages'] as $package) {
$byName[$package['name']] = $package;
}
/** Fermeture transitive des dépendances, hors extensions et PHP lui-même. */
$collect = static function (string $name, array &$seen) use (&$collect, $byName): void {
if (isset($seen[$name]) || !isset($byName[$name])) {
return;
}
$seen[$name] = true;
foreach (array_keys($byName[$name]['require'] ?? []) as $dependency) {
if (!str_contains($dependency, '/')) {
continue; // php, ext-*, lib-*
}
$collect($dependency, $seen);
}
};
$seen = [];
foreach ($roots as $rootPackage) {
$collect($rootPackage, $seen);
}
// L'autoloader requiert d'office tous les fichiers déclarés en `autoload_files`.
// On y prend les polyfills — indispensables — mais SURTOUT PAS les dépendances
// de développement : PHPStan pèse à lui seul 47 Mo, et se retrouverait embarqué
// dans un binaire qui n'en a aucun usage.
//
// `dev-package-names` est maintenu par Composer : s'appuyer dessus vaut mieux
// que deviner ce qui relève du développement.
$devPackages = array_flip((array) ($installed['dev-package-names'] ?? []));
$eager = require $root.'/vendor/composer/autoload_files.php';
$eagerKept = [];
foreach ($eager as $hash => $file) {
$normalised = str_replace('\\', '/', (string) $file);
if (1 !== preg_match('#/vendor/([^/]+/[^/]+)/#', $normalised, $matches)) {
continue;
}
if (isset($devPackages[$matches[1]])) {
continue;
}
$seen[$matches[1]] = true;
$eagerKept[$hash] = $matches[0].substr($normalised, strpos($normalised, $matches[0]) + \strlen($matches[0]));
$eagerKept[$hash] = substr($normalised, strpos($normalised, '/vendor/') + \strlen('/vendor/'));
}
$vendors = array_keys($seen);
sort($vendors);
$added = 0;
/**
* Filtre d'inclusion.
*
* On garde TOUS les types de fichiers, pas seulement le PHP : Console lit ses
* gabarits de complétion shell depuis un répertoire Resources/, et les omettre
* produit un PHAR qui plante au lancement — pas au build. Ne sont écartés que
* les tests et la documentation, qui ne servent jamais à l'exécution.
*/
$keep = static function (SplFileInfo $file): bool {
$path = str_replace('\\', '/', $file->getPathname());
foreach (['/Tests/', '/tests/', '/docs/', '/.github/'] as $excluded) {
if (str_contains($path, $excluded)) {
return false;
}
}
return !\in_array(strtolower($file->getExtension()), ['md', 'dist', 'lock'], true);
};
// Uniquement src/Cli : l'autoloader de Composer n'est PAS embarqué. Il
// référence des paquets écartés du binaire, et sa seule présence suffirait à
// faire échouer le démarrage si quelque chose venait à le requérir.
foreach (['src/Cli'] as $relative) {
$base = $root.'/'.$relative;
$files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($base, FilesystemIterator::SKIP_DOTS));
foreach ($files as $file) {
if (!$file instanceof SplFileInfo || !$keep($file)) {
continue;
}
$phar->addFile($file->getPathname(), $relative.'/'.substr($file->getPathname(), \strlen($base) + 1));
++$added;
}
}
foreach ($vendors as $vendor) {
$base = $root.'/vendor/'.$vendor;
if (!is_dir($base)) {
// Un paquet « replace » (les polyfills remplacés par PHP lui-même) n'a
// pas de répertoire : c'est normal, on passe.
continue;
}
$files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($base, FilesystemIterator::SKIP_DOTS));
foreach ($files as $file) {
if (!$file instanceof SplFileInfo || !$keep($file)) {
continue;
}
$phar->addFile($file->getPathname(), 'vendor/'.$vendor.'/'.substr($file->getPathname(), \strlen($base) + 1));
++$added;
}
}
$phar->addFile($root.'/bin/tqs', 'bin/tqs');
++$added;
// Autoloader DÉDIÉ, généré ici.
//
// Réutiliser celui de l'application ne marche pas : Composer inline la liste des
// fichiers à charger dans `autoload_static.php`, si bien que réécrire
// `autoload_files.php` ne change rien — le PHAR tente alors de requérir des
// paquets de développement absents, et meurt au démarrage.
//
// Trente lignes générées valent mieux qu'un rafistolage du générateur d'un
// autre outil : ce qui est embarqué est exactement ce qui est déclaré.
$psr4 = ['App\\Cli\\' => 'src/Cli/'];
foreach ($vendors as $vendor) {
$package = $byName[$vendor] ?? null;
foreach (($package['autoload']['psr-4'] ?? []) as $namespace => $paths) {
foreach ((array) $paths as $path) {
$psr4[$namespace] = 'vendor/'.$vendor.('' === $path ? '/' : '/'.trim($path, '/').'/');
}
}
}
$eagerFiles = [];
foreach ($vendors as $vendor) {
foreach (($byName[$vendor]['autoload']['files'] ?? []) as $file) {
$eagerFiles[] = 'vendor/'.$vendor.'/'.ltrim($file, '/');
}
}
$phar->addFromString('autoload.php', sprintf(
<<<'PHP'
<?php
// Généré par bin/build-tqs-phar.php — ne pas modifier à la main.
$base = 'phar://tqs.phar/';
$psr4 = %s;
spl_autoload_register(static function (string $class) use ($base, $psr4): void {
foreach ($psr4 as $prefix => $directory) {
if (!str_starts_with($class, $prefix)) {
continue;
}
$relative = str_replace('\\', '/', substr($class, strlen($prefix)));
$file = $base . $directory . $relative . '.php';
if (is_file($file)) {
require $file;
return;
}
}
});
$files = %s;
foreach ($files as $file) {
require $base . $file;
}
PHP,
var_export($psr4, true),
var_export($eagerFiles, true),
));
$phar->setStub(<<<'STUB'
#!/usr/bin/env php
<?php
Phar::mapPhar('tqs.phar');
require 'phar://tqs.phar/autoload.php';
require 'phar://tqs.phar/bin/tqs';
__HALT_COMPILER();
STUB);
$phar->stopBuffering();
chmod($target, 0o755);
printf(
"tqs.phar construit : %s\n %d fichiers · %d dépendances · %s\n",
$target,
$added,
\count($vendors),
number_format(filesize($target) / 1024, 0, ',', ' ').' Ko',
);