Quelltext des aktuellen Standes
<?php
declare(strict_types=1);
/**
* Abdeckung der Übersetzung messen.
*
* php bin/i18n-scan.php Übersicht je Verzeichnis
* php bin/i18n-scan.php --files Übersicht je Datei (offene zuerst)
* php bin/i18n-scan.php templates/tickets nur diesen Pfad
* php bin/i18n-scan.php --open <datei> offene Stellen einer Datei mit Zeilennummer
* php bin/i18n-scan.php --catalog übersetzte Texte ohne Eintrag in lang/en.php
*
* Gezählt werden sichtbare Texte: Textknoten im HTML und die Beschriftungen in
* placeholder, title, aria-label und alt. Der Zähler ist eine Orientierung, kein
* Beweis – er findet großzügig und meldet im Zweifel eine Stelle zu viel.
*/
require __DIR__ . '/../src/Core/TemplateScan.php';
use OpenNIT\Core\TemplateScan;
$wurzel = dirname(__DIR__);
$args = array_slice($argv, 1);
$modus = 'dirs';
$pfade = [];
$ziel = null;
foreach ($args as $arg) {
if ($arg === '--files') {
$modus = 'files';
} elseif ($arg === '--catalog') {
$modus = 'catalog';
} elseif ($arg === '--open') {
$modus = 'open';
} elseif ($modus === 'open' && $ziel === null) {
$ziel = $arg;
} else {
$pfade[] = $arg;
}
}
/** Alle Vorlagen unterhalb eines Pfades. */
$sammle = static function (string $basis): array {
if (is_file($basis)) {
return [$basis];
}
$dateien = [];
$lauf = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($basis, FilesystemIterator::SKIP_DOTS));
foreach ($lauf as $datei) {
if ($datei->isFile() && $datei->getExtension() === 'php') {
$dateien[] = $datei->getPathname();
}
}
sort($dateien);
return $dateien;
};
if ($modus === 'open') {
if ($ziel === null || !is_file($ziel)) {
fwrite(STDERR, "Datei fehlt oder ist nicht lesbar.\n");
exit(1);
}
$offen = TemplateScan::offeneTexte((string)file_get_contents($ziel));
foreach ($offen as $stelle) {
printf("%5d %-12s %s\n", $stelle['zeile'], $stelle['art'], $stelle['text']);
}
printf("\n%d offene Stelle(n) in %s\n", count($offen), $ziel);
exit(0);
}
if ($modus === 'catalog') {
$katalog = require $wurzel . '/lang/en.php';
$fehlend = [];
/**
* Alle Schlüssel eines t()/te()-Aufrufs.
*
* Gelesen werden Aufrufe über `$this` wie über `$view`: Eine Vorlage, die
* ihren Inhalt in einer benannten Funktion aufbaut, bekommt die Ansicht als
* Parameter herein und ruft darüber auf.
*
* Der erste Parameter ist nicht immer eine einzelne Zeichenkette – eine
* Auswahl wie `te($einzahl ? 'Anfrage' : 'Anfragen')` bringt zwei
* Schlüssel mit. Gelesen wird deshalb der gesamte erste Parameter; die
* Platzhalterwerte dahinter bleiben außen vor, ihre Namen sind keine
* Oberflächentexte.
*
* @return list<string>
*/
$schluessel = static function (string $quelle): array {
// Maskierte Anführungszeichen gehören zum Text: 'Let\'s Encrypt' ist
// eine Zeichenkette, keine zwei.
$literal = '\'(?:[^\'\\\\]|\\\\.)*\'|"(?:[^"\\\\]|\\\\.)*"';
$muster = '/\$(?:this|view)->te?\s*\(((?:' . $literal . '|[^(),\'"]|\((?:' . $literal . '|[^()\'"])*\))*)/s';
if (preg_match_all($muster, $quelle, $funde, PREG_SET_ORDER) === 0) {
return [];
}
$texte = [];
// Texte, die ein Skript selbst zusammensetzt, stehen als Liste im Layout.
if (preg_match('/jsTranslations\\(\\[(.*?)\\]\\)/s', $quelle, $liste) === 1
&& preg_match_all('/' . $literal . '/s', $liste[1], $eintraege) > 0) {
foreach ($eintraege[0] as $eintrag) {
$texte[] = stripcslashes(substr($eintrag, 1, -1));
}
}
foreach ($funde as $fund) {
if (preg_match_all('/' . $literal . '/s', $fund[1], $teile, PREG_OFFSET_CAPTURE) === 0) {
continue;
}
foreach ($teile[0] as [$teil, $pos]) {
// Ein Feldname in `$wert['schluessel']` und ein Vergleichswert
// stehen in der Bedingung, nicht in der Beschriftung.
$davor = rtrim(substr($fund[1], 0, $pos));
if ($davor !== '' && preg_match('/(\[|={2,3}|!={1,2})$/', $davor) === 1) {
continue;
}
$texte[] = stripcslashes(substr($teil, 1, -1));
}
}
return $texte;
};
foreach ($sammle($wurzel . '/templates') as $datei) {
foreach ($schluessel((string)file_get_contents($datei)) as $text) {
if ($text !== '' && !isset($katalog[$text])) {
$fehlend[$text][] = basename($datei);
}
}
}
ksort($fehlend);
foreach ($fehlend as $text => $dateien) {
printf("%-60s %s\n", mb_substr($text, 0, 58), implode(', ', array_unique($dateien)));
}
printf("\n%d übersetzte Texte ohne Eintrag in lang/en.php\n", count($fehlend));
exit(count($fehlend) === 0 ? 0 : 1);
}
$basen = $pfade ?: [$wurzel . '/templates'];
$zeilen = [];
$gesamtOffen = 0;
$gesamtUebersetzt = 0;
foreach ($basen as $basis) {
foreach ($sammle($basis) as $datei) {
$ergebnis = TemplateScan::pruefe((string)file_get_contents($datei));
$relativ = str_replace($wurzel . '/', '', $datei);
$gruppe = $modus === 'files' ? $relativ : dirname($relativ);
$zeilen[$gruppe]['offen'] = ($zeilen[$gruppe]['offen'] ?? 0) + $ergebnis['offen'];
$zeilen[$gruppe]['uebersetzt'] = ($zeilen[$gruppe]['uebersetzt'] ?? 0) + $ergebnis['uebersetzt'];
$gesamtOffen += $ergebnis['offen'];
$gesamtUebersetzt += $ergebnis['uebersetzt'];
}
}
uasort($zeilen, static fn(array $a, array $b): int => $b['offen'] <=> $a['offen']);
printf("%-52s %8s %8s %7s\n", 'Pfad', 'offen', 'übers.', 'Quote');
printf("%s\n", str_repeat('-', 78));
foreach ($zeilen as $gruppe => $werte) {
$summe = $werte['offen'] + $werte['uebersetzt'];
printf(
"%-52s %8d %8d %6.0f%%\n",
mb_strlen($gruppe) > 52 ? '…' . mb_substr($gruppe, -51) : $gruppe,
$werte['offen'],
$werte['uebersetzt'],
$summe > 0 ? $werte['uebersetzt'] / $summe * 100 : 100
);
}
$summe = $gesamtOffen + $gesamtUebersetzt;
printf("%s\n", str_repeat('-', 78));
printf(
"%-52s %8d %8d %6.0f%%\n",
'Gesamt',
$gesamtOffen,
$gesamtUebersetzt,
$summe > 0 ? $gesamtUebersetzt / $summe * 100 : 100
);