Files

221 lines
9.0 KiB
PHP

<?php
namespace App\Services;
use Illuminate\Filesystem\Filesystem;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\View;
use Illuminate\Translation\FileLoader;
/**
* What the platform folder (platform_path(), see PlatformServiceProvider) overrides, compared with the code.
* Overrides go stale without anyone noticing: a translation key is the English source sentence, so a reworded
* sentence leaves its override unused, and a copied template misses later fixes to its original. check() reports
* that; platform:override copies a template with a fingerprint of its original, so a later change shows.
*/
class PlatformOverrides
{
/** First line of a template copied by platform:override: the original's path and its sha1 at the time. */
public const FINGERPRINT = '/^\{\{-- platform:override of (\S+) sha1:([0-9a-f]{40}) --\}\}/';
public function __construct(private Filesystem $files)
{
}
/**
* Findings, each ['level' => 'ok'|'info'|'warn'|'fail', 'message' => string].
*
* @return array<int, array{level: string, message: string}>
*/
public function check(): array
{
$root = platform_path();
if (!is_dir($root)) {
return [['level' => 'ok', 'message' => "No platform folder ({$root}): this installation runs on config/ and the files in the code."]];
}
return [
...$this->checkSources(),
...$this->checkPublicLink(),
...$this->checkTranslations(),
...$this->checkTemplates(),
];
}
private function checkSources(): array
{
$found = [];
$name = (string) config('app.timebank_config');
if (is_file(platform_path('config.php'))) {
$found[] = is_file(config_path("{$name}.php"))
? ['level' => 'warn', 'message' => "Both platform/config.php and config/{$name}.php exist; the platform folder wins. Remove config/{$name}.php once the site runs well."]
: ['level' => 'ok', 'message' => 'Platform config: platform/config.php'];
} else {
$found[] = ['level' => 'info', 'message' => "No platform/config.php: the platform config is config/{$name}.php."];
}
if (is_file(platform_path('theme.php'))) {
$theme = require platform_path('theme.php');
$found[] = isset($theme['id'])
? ['level' => 'ok', 'message' => "Platform theme: platform/theme.php (id {$theme['id']})"]
: ['level' => 'warn', 'message' => "platform/theme.php has no 'id': it runs as theme 'platform'. Give it an id."];
}
return $found;
}
private function checkPublicLink(): array
{
$target = platform_path('public');
if (!is_dir($target)) {
return [];
}
$link = public_path('platform');
return is_link($link) && readlink($link) === $target
? [['level' => 'ok', 'message' => 'Platform public files linked at /platform.']]
: [['level' => 'warn', 'message' => 'platform/public is not served: run php artisan platform:link.']];
}
/** The translations as the code alone defines them: every path the translator has, except the platform's. */
private function baseLoader(): FileLoader
{
$loader = app('translator')->getLoader();
$platformLang = platform_path('lang');
$base = new FileLoader($this->files, array_values(array_filter($loader->paths(), fn ($path) => $path !== $platformLang)));
foreach ($loader->namespaces() as $namespace => $hint) {
$base->addNamespace($namespace, $hint);
}
foreach ($loader->jsonPaths() as $path) {
$base->addJsonPath($path);
}
return $base;
}
private function checkTranslations(): array
{
$lang = platform_path('lang');
if (!is_dir($lang)) {
return [];
}
$base = $this->baseLoader();
$platformViews = $this->platformTemplateSource();
$found = [];
foreach ($this->files->allFiles($lang) as $file) {
$relative = str_replace('\\', '/', $file->getRelativePathname());
if (preg_match('#^([A-Za-z_-]+)\.json$#', $relative, $m)) {
$overrides = json_decode($this->files->get($file->getPathname()), true);
if (!is_array($overrides)) {
$found[] = ['level' => 'fail', 'message' => "lang/{$relative} is not valid JSON: none of its texts are used."];
continue;
}
// A key the platform's own templates use is the platform's own text, not a stale override
$baseTexts = $base->load($m[1], '*', '*');
$unused = array_values(array_filter(array_keys($overrides), fn ($key) => !array_key_exists($key, $baseTexts)
&& !str_contains($platformViews, $key)));
} elseif (preg_match('#^vendor/([^/]+)/([^/]+)/(.+)\.php$#', $relative, $m)) {
$unused = $this->unusedKeys($file->getPathname(), $base->load($m[2], $m[3], $m[1]));
} elseif (preg_match('#^([^/]+)/(.+)\.php$#', $relative, $m)) {
$unused = $this->unusedKeys($file->getPathname(), $base->load($m[1], $m[2]));
} else {
continue;
}
if ($unused) {
$found[] = ['level' => 'warn', 'message' => "lang/{$relative}: " . count($unused) . ' key(s) not in the code (reworded or removed there): '
. implode(' | ', array_map(fn ($key) => mb_strimwidth($key, 0, 60, '...'), array_slice($unused, 0, 5)))
. (count($unused) > 5 ? ' ...' : '')];
}
}
return $found ?: [['level' => 'ok', 'message' => 'Translation overrides: none stale.']];
}
private function unusedKeys(string $file, array $base): array
{
$overrides = require $file;
return is_array($overrides) ? array_keys(array_diff_key(Arr::dot($overrides), Arr::dot($base))) : [];
}
private function platformTemplateSource(): string
{
$views = platform_path('views');
return is_dir($views)
? implode("\n", array_map(fn ($file) => $this->files->get($file->getPathname()), $this->files->allFiles($views)))
: '';
}
private function checkTemplates(): array
{
$views = platform_path('views');
if (!is_dir($views)) {
return [];
}
$found = [];
foreach ($this->files->allFiles($views) as $file) {
$relative = str_replace('\\', '/', $file->getRelativePathname());
$original = $this->originalOf($relative);
if ($original === null) {
$found[] = ['level' => 'info', 'message' => "views/{$relative}: the platform's own template (no original in the code)."];
continue;
}
$firstLine = strtok($this->files->get($file->getPathname()), "\n");
if (!preg_match(self::FINGERPRINT, (string) $firstLine, $m)) {
$found[] = ['level' => 'info', 'message' => "views/{$relative}: copied without a fingerprint; cannot tell whether the original changed since (copy with php artisan platform:override)."];
} elseif (sha1_file($original) !== $m[2]) {
$found[] = ['level' => 'warn', 'message' => "views/{$relative}: the original changed since it was copied. Compare with " . $this->shown($original) . ', then copy the changes over and update the fingerprint.'];
}
}
return $found ?: [['level' => 'ok', 'message' => 'Template overrides: none stale.']];
}
/**
* The code's file that a platform template overrides, or null. $relative is the path inside platform/views:
* mail/<file> for the mail components, vendor/<package>/<file> for package views, else an app view.
*/
public function originalOf(string $relative): ?string
{
$platformViews = platform_path('views');
if (str_starts_with($relative, 'mail/')) {
$candidates = [
...array_filter(config('mail.markdown.paths', []), fn ($path) => $path !== "{$platformViews}/mail"),
base_path('vendor/laravel/framework/src/Illuminate/Mail/resources/views'),
];
$file = substr($relative, strlen('mail/'));
} elseif (preg_match('#^vendor/([^/]+)/(.+)$#', $relative, $m)) {
$candidates = array_filter(View::getFinder()->getHints()[$m[1]] ?? [], fn ($path) => !str_starts_with($path, $platformViews));
$file = $m[2];
} else {
$candidates = array_filter(View::getFinder()->getPaths(), fn ($path) => $path !== $platformViews);
$file = $relative;
}
foreach ($candidates as $path) {
if (is_file("{$path}/{$file}")) {
return "{$path}/{$file}";
}
}
return null;
}
public function shown(string $path): string
{
return str_starts_with($path, base_path() . '/') ? substr($path, strlen(base_path()) + 1) : $path;
}
}