'ok'|'info'|'warn'|'fail', 'message' => string]. * * @return array */ 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/ for the mail components, vendor// 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; } }