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>
256 lines
7.9 KiB
PHP
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',
|
|
);
|