221 lines
9.0 KiB
PHP
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;
|
|
}
|
|
}
|