refactor: rename lib/ to core/ for clearer core-module separation
Rename the top-level lib/ directory to core/ so the project root immediately communicates which code is core platform and which lives in modules/. PHP namespaces (MintyPHP\*) are unchanged — only the Composer PSR-4 path mapping moves from lib/ to core/. Module-internal lib/ directories (modules/*/lib/) are untouched. Updated across the full stack: - composer.json PSR-4 mapping - bootstrap entry points (web/index.php, bin/*, tests/bootstrap.php) - tooling configs (phpstan.neon, phpunit.xml, php-cs-fixer) - 26 architecture contract tests - enforcement-policy, guard-catalog, quality-gates - all documentation (CLAUDE.md, README, 25 docs/, .agents/skills/) - bin/qa-extended.sh search paths - .claude/settings.local.json permission paths Workflow: .agents/runs/CORE-LIB-RENAME-001/ (Analyst → Planner → Executor → Code Review (4 findings fixed) → Security Review → Acceptance Test → Finalizer) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
501
core/Http/ErrorRenderer.php
Normal file
501
core/Http/ErrorRenderer.php
Normal file
@@ -0,0 +1,501 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Http;
|
||||
|
||||
use MintyPHP\I18n;
|
||||
|
||||
final class ErrorRenderer
|
||||
{
|
||||
/**
|
||||
* Renders the full developer debug page with all four panels.
|
||||
*
|
||||
* @param array{exception: array<string, mixed>, request: array<string, mixed>, environment: array<string, mixed>, queries: array<string, mixed>} $data
|
||||
*/
|
||||
public static function renderDev(array $data): string
|
||||
{
|
||||
$exc = $data['exception'];
|
||||
$req = $data['request'];
|
||||
$env = $data['environment'];
|
||||
$qry = $data['queries'];
|
||||
|
||||
$requestId = self::esc((string) ($req['request_id'] ?? 'unknown'));
|
||||
$statusCode = http_response_code() ?: 500;
|
||||
$statusText = self::statusText($statusCode);
|
||||
$queryCount = $qry['count'] ?? count($qry['queries'] ?? []);
|
||||
|
||||
$css = self::loadAsset(__DIR__ . '/../../web/css/pages/app-error-debug.css');
|
||||
$js = self::loadAsset(__DIR__ . '/../../web/js/components/app-error-debug.js');
|
||||
|
||||
$excClass = self::esc($exc['class'] ?? self::tr('Exception'));
|
||||
$excMessage = self::esc((string) ($exc['message'] ?? self::tr('An error occurred')));
|
||||
$excLocation = self::esc(self::tr(
|
||||
'in %s on line %s',
|
||||
self::shortenPath((string) ($exc['file'] ?? '')),
|
||||
(string) ($exc['line'] ?? '?')
|
||||
));
|
||||
|
||||
$exceptionPanel = self::renderExceptionPanel($exc);
|
||||
$requestPanel = self::renderRequestPanel($req);
|
||||
$environmentPanel = self::renderEnvironmentPanel($env);
|
||||
$queryPanel = self::renderQueryPanel($qry);
|
||||
|
||||
$lang = self::esc(self::htmlLang());
|
||||
$statusTextEsc = self::esc($statusText);
|
||||
$requestIdLabel = self::esc(self::tr('Request ID'));
|
||||
$copyRequestIdTitle = self::esc(self::tr('Copy request ID'));
|
||||
$tabsLabel = self::esc(self::tr('Error details'));
|
||||
|
||||
$tabException = self::esc(self::tr('Exception'));
|
||||
$tabRequest = self::esc(self::tr('Request'));
|
||||
$tabEnvironment = self::esc(self::tr('Environment'));
|
||||
$tabQueries = self::esc(self::tr('Queries'));
|
||||
|
||||
return <<<HTML
|
||||
<!DOCTYPE html>
|
||||
<html lang="{$lang}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{$statusCode} {$statusTextEsc}</title>
|
||||
<style>{$css}</style>
|
||||
</head>
|
||||
<body class="app-error-debug">
|
||||
<header class="app-error-debug-header">
|
||||
<div class="app-error-debug-container app-error-debug-header__inner">
|
||||
<div class="app-error-debug-header__status">
|
||||
<span class="app-error-debug-header__code">{$statusCode}</span>
|
||||
<span class="app-error-debug-header__label">{$statusTextEsc}</span>
|
||||
</div>
|
||||
<div class="app-error-debug-request-id">
|
||||
{$requestIdLabel}: {$requestId}
|
||||
<button type="button" class="app-error-debug-copy-button" data-copy="{$requestId}" title="{$copyRequestIdTitle}" aria-label="{$copyRequestIdTitle}">
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M4 1.5H3a2 2 0 0 0-2 2V14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3.5a2 2 0 0 0-2-2h-1v1h1a1 1 0 0 1 1 1V14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3.5a1 1 0 0 1 1-1h1v-1z"/><path d="M9.5 1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5h3zm-3-1A1.5 1.5 0 0 0 5 1.5v1A1.5 1.5 0 0 0 6.5 4h3A1.5 1.5 0 0 0 11 2.5v-1A1.5 1.5 0 0 0 9.5 0h-3z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="app-error-debug-container">
|
||||
<div class="app-error-debug-summary">
|
||||
<div class="app-error-debug-summary__class">{$excClass}</div>
|
||||
<div class="app-error-debug-summary__message">{$excMessage}</div>
|
||||
<div class="app-error-debug-summary__location">{$excLocation}</div>
|
||||
</div>
|
||||
|
||||
<nav class="app-error-debug-tabs" role="tablist" aria-label="{$tabsLabel}">
|
||||
<button type="button" id="tab-exception" class="app-error-debug-tab active" role="tab" aria-selected="true" aria-controls="panel-exception" data-panel="panel-exception" tabindex="0">{$tabException}</button>
|
||||
<button type="button" id="tab-request" class="app-error-debug-tab" role="tab" aria-selected="false" aria-controls="panel-request" data-panel="panel-request" tabindex="-1">{$tabRequest}</button>
|
||||
<button type="button" id="tab-environment" class="app-error-debug-tab" role="tab" aria-selected="false" aria-controls="panel-environment" data-panel="panel-environment" tabindex="-1">{$tabEnvironment}</button>
|
||||
<button type="button" id="tab-queries" class="app-error-debug-tab" role="tab" aria-selected="false" aria-controls="panel-queries" data-panel="panel-queries" tabindex="-1">{$tabQueries} <span class="app-error-debug-tab__count">{$queryCount}</span></button>
|
||||
</nav>
|
||||
|
||||
<div id="panel-exception" class="app-error-debug-panel active" role="tabpanel" aria-labelledby="tab-exception">{$exceptionPanel}</div>
|
||||
<div id="panel-request" class="app-error-debug-panel" role="tabpanel" aria-labelledby="tab-request">{$requestPanel}</div>
|
||||
<div id="panel-environment" class="app-error-debug-panel" role="tabpanel" aria-labelledby="tab-environment">{$environmentPanel}</div>
|
||||
<div id="panel-queries" class="app-error-debug-panel" role="tabpanel" aria-labelledby="tab-queries">{$queryPanel}</div>
|
||||
</main>
|
||||
|
||||
<script>{$js}</script>
|
||||
</body>
|
||||
</html>
|
||||
HTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a clean production error page with request ID only.
|
||||
* Uses the same design tokens as the dev page for visual consistency.
|
||||
*/
|
||||
public static function renderProd(string $requestId): string
|
||||
{
|
||||
$statusCode = http_response_code() ?: 500;
|
||||
$requestId = self::esc($requestId);
|
||||
$css = self::loadAsset(__DIR__ . '/../../web/css/pages/app-error-debug.css');
|
||||
$js = self::loadAsset(__DIR__ . '/../../web/js/components/app-error-debug.js');
|
||||
|
||||
$lang = self::esc(self::htmlLang());
|
||||
$errorText = self::esc(self::tr('Error'));
|
||||
$requestIdLabel = self::esc(self::tr('Request ID'));
|
||||
$copyRequestIdTitle = self::esc(self::tr('Copy request ID'));
|
||||
$message = self::esc(self::tr('An unexpected error occurred. If the problem persists, please contact support with the reference below.'));
|
||||
$backLabel = self::esc(self::tr('Back to start'));
|
||||
|
||||
return <<<HTML
|
||||
<!DOCTYPE html>
|
||||
<html lang="{$lang}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{$statusCode} - {$errorText}</title>
|
||||
<style>
|
||||
{$css}
|
||||
.app-error-prod {
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
min-height: 100dvh; padding: calc(var(--app-spacing) * 2); text-align: center;
|
||||
font-family: var(--app-font-family); background: var(--app-background-color); color: var(--app-color);
|
||||
}
|
||||
.app-error-prod__code {
|
||||
font-size: var(--text-5xl); font-weight: var(--font-bold);
|
||||
line-height: var(--leading-none); color: var(--app-muted-color);
|
||||
}
|
||||
.app-error-prod__message {
|
||||
margin-top: var(--app-spacing); font-size: var(--text-lg);
|
||||
color: var(--app-color); max-width: 28rem;
|
||||
}
|
||||
.app-error-prod__back {
|
||||
display: inline-block; margin-top: calc(var(--app-spacing) * 2);
|
||||
padding: calc(var(--app-spacing) * 0.5) calc(var(--app-spacing) * 1.25);
|
||||
font-size: var(--text-sm); text-decoration: none;
|
||||
border: 1px solid var(--app-border); border-radius: var(--app-border-radius);
|
||||
color: var(--app-color); background: var(--app-card-background-color);
|
||||
}
|
||||
.app-error-prod__back:hover { opacity: 0.8; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-error-prod">
|
||||
<div class="app-error-prod__code">{$statusCode}</div>
|
||||
<div class="app-error-prod__message">{$message}</div>
|
||||
<div class="app-error-debug-request-id" style="margin-top: calc(var(--app-spacing) * 1.5);">
|
||||
{$requestIdLabel}: {$requestId}
|
||||
<button type="button" class="app-error-debug-copy-button" data-copy="{$requestId}" title="{$copyRequestIdTitle}" aria-label="{$copyRequestIdTitle}"><svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M4 1.5H3a2 2 0 0 0-2 2V14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3.5a2 2 0 0 0-2-2h-1v1h1a1 1 0 0 1 1 1V14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3.5a1 1 0 0 1 1-1h1v-1z"/><path d="M9.5 1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5h3zm-3-1A1.5 1.5 0 0 0 5 1.5v1A1.5 1.5 0 0 0 6.5 4h3A1.5 1.5 0 0 0 11 2.5v-1A1.5 1.5 0 0 0 9.5 0h-3z"/></svg></button>
|
||||
</div>
|
||||
<a class="app-error-prod__back" href="/">{$backLabel}</a>
|
||||
</div>
|
||||
<script>{$js}</script>
|
||||
</body>
|
||||
</html>
|
||||
HTML;
|
||||
}
|
||||
|
||||
private static function renderExceptionPanel(array $exceptionData): string
|
||||
{
|
||||
$html = '<div class="app-error-debug-section"><div class="app-error-debug-section__title">' . self::esc(self::tr('Stack Trace')) . '</div>';
|
||||
$html .= '<ol class="app-error-debug-trace">';
|
||||
|
||||
$frames = $exceptionData['trace'] ?? [];
|
||||
foreach ($frames as $i => $frame) {
|
||||
$file = self::esc(self::shortenPath((string) ($frame['file'] ?? '[internal]')));
|
||||
$line = self::esc((string) ($frame['line'] ?? '?'));
|
||||
$function = '';
|
||||
if (isset($frame['class'])) {
|
||||
$function = self::esc((string) $frame['class'] . '::' . (string) ($frame['function'] ?? '?'));
|
||||
} elseif (isset($frame['function'])) {
|
||||
$function = self::esc((string) $frame['function']);
|
||||
}
|
||||
|
||||
$codeHtml = '';
|
||||
$snippet = $frame['code_snippet'] ?? [];
|
||||
if (!empty($snippet)) {
|
||||
$highlightLine = (int) ($frame['line'] ?? 0);
|
||||
$codeHtml = self::renderCodeBlock($snippet, $highlightLine);
|
||||
}
|
||||
|
||||
$codeId = 'frame-code-' . $i;
|
||||
$toggleId = 'frame-toggle-' . $i;
|
||||
$isOpen = ($i === 0);
|
||||
$expanded = $isOpen ? 'true' : 'false';
|
||||
$openClass = $isOpen ? ' open' : '';
|
||||
|
||||
$html .= <<<FRAME
|
||||
<li class="app-error-debug-frame{$openClass}">
|
||||
<button type="button" id="{$toggleId}" class="app-error-debug-frame__header app-error-debug-frame__toggle" aria-expanded="{$expanded}" aria-controls="{$codeId}">
|
||||
<span class="app-error-debug-frame__index">#{$i}</span>
|
||||
<span class="app-error-debug-frame__file">{$file}</span>:<span class="app-error-debug-frame__line">{$line}</span>
|
||||
<span class="app-error-debug-frame__function">{$function}</span>
|
||||
</button>
|
||||
<div id="{$codeId}" class="app-error-debug-frame__code" role="region" aria-labelledby="{$toggleId}">{$codeHtml}</div>
|
||||
</li>
|
||||
FRAME;
|
||||
}
|
||||
|
||||
$html .= '</ol></div>';
|
||||
|
||||
// Chained exceptions.
|
||||
if (isset($exceptionData['previous'])) {
|
||||
$prev = $exceptionData['previous'];
|
||||
$html .= '<div class="app-error-debug-chain">';
|
||||
$html .= '<div class="app-error-debug-chain__label">' . self::esc(self::tr('Caused by')) . '</div>';
|
||||
$html .= '<div class="app-error-debug-summary__class">' . self::esc((string) ($prev['class'] ?? self::tr('Exception'))) . '</div>';
|
||||
$html .= '<div class="app-error-debug-summary__message">' . self::esc((string) ($prev['message'] ?? '')) . '</div>';
|
||||
$html .= '<div class="app-error-debug-summary__location">' . self::esc(self::tr(
|
||||
'in %s on line %s',
|
||||
self::shortenPath((string) ($prev['file'] ?? '')),
|
||||
(string) ($prev['line'] ?? '?')
|
||||
)) . '</div>';
|
||||
$html .= '</div>';
|
||||
}
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $lines
|
||||
*/
|
||||
private static function renderCodeBlock(array $lines, int $highlightLine): string
|
||||
{
|
||||
$html = '<table class="app-error-debug-code-table">';
|
||||
foreach ($lines as $lineNo => $code) {
|
||||
$isHighlight = ($lineNo === $highlightLine) ? ' highlight' : '';
|
||||
$lineNoEsc = self::esc((string) $lineNo);
|
||||
$codeEsc = self::esc($code);
|
||||
$html .= "<tr class=\"{$isHighlight}\"><td class=\"app-error-debug-code-table__line-no\">{$lineNoEsc}</td><td class=\"app-error-debug-code-table__code\">{$codeEsc}</td></tr>";
|
||||
}
|
||||
$html .= '</table>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private static function renderRequestPanel(array $requestData): string
|
||||
{
|
||||
$html = '';
|
||||
|
||||
$html .= self::renderInfoSection(self::tr('Request'), [
|
||||
self::tr('Method') => $requestData['method'] ?? 'GET',
|
||||
self::tr('URL') => $requestData['url'] ?? '',
|
||||
self::tr('IP') => $requestData['ip'] ?? '',
|
||||
self::tr('User Agent') => $requestData['user_agent'] ?? '',
|
||||
self::tr('Channel') => $requestData['channel'] ?? 'web',
|
||||
]);
|
||||
|
||||
$headers = $requestData['headers'] ?? [];
|
||||
if (!empty($headers)) {
|
||||
$html .= self::renderInfoSection(self::tr('Headers'), $headers);
|
||||
}
|
||||
|
||||
$get = $requestData['get'] ?? [];
|
||||
if (!empty($get)) {
|
||||
$html .= self::renderInfoSection(self::tr('GET Parameters'), array_map(
|
||||
fn ($v): string => is_string($v) ? $v : (string) json_encode($v, JSON_UNESCAPED_SLASHES),
|
||||
$get,
|
||||
));
|
||||
}
|
||||
|
||||
$post = $requestData['post'] ?? [];
|
||||
if (!empty($post)) {
|
||||
$html .= self::renderInfoSection(self::tr('POST Parameters'), array_map(
|
||||
fn ($v): string => is_string($v) ? $v : (string) json_encode($v, JSON_UNESCAPED_SLASHES),
|
||||
$post,
|
||||
));
|
||||
}
|
||||
|
||||
$session = $requestData['session_keys'] ?? [];
|
||||
if (!empty($session)) {
|
||||
$html .= self::renderInfoSection(self::tr('Session (keys + types)'), $session);
|
||||
}
|
||||
|
||||
$html .= self::renderInfoSection(self::tr('Context'), array_filter([
|
||||
self::tr('User ID') => $requestData['user_id'] ?? null,
|
||||
self::tr('Tenant ID') => $requestData['tenant_id'] ?? null,
|
||||
], fn ($v): bool => $v !== null));
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private static function renderEnvironmentPanel(array $envData): string
|
||||
{
|
||||
$html = '';
|
||||
|
||||
$html .= self::renderInfoSection(self::tr('PHP'), [
|
||||
self::tr('Version') => $envData['php_version'] ?? PHP_VERSION,
|
||||
self::tr('SAPI') => $envData['php_sapi'] ?? PHP_SAPI,
|
||||
self::tr('Memory (current)') => self::formatBytes((int) ($envData['memory_current'] ?? 0)),
|
||||
self::tr('Memory (peak)') => self::formatBytes((int) ($envData['memory_peak'] ?? 0)),
|
||||
]);
|
||||
|
||||
$env = $envData['env'] ?? [];
|
||||
if (!empty($env)) {
|
||||
$html .= self::renderInfoSection(self::tr('Application'), $env);
|
||||
}
|
||||
|
||||
$modules = $envData['modules'] ?? [];
|
||||
if (!empty($modules)) {
|
||||
$html .= self::renderInfoSection(self::tr('Active Modules'), array_combine(
|
||||
array_map(fn ($m): string => (string) $m, $modules),
|
||||
array_fill(0, count($modules), self::tr('Active')),
|
||||
) ?: []);
|
||||
}
|
||||
|
||||
$extensions = $envData['extensions'] ?? [];
|
||||
if (!empty($extensions)) {
|
||||
$html .= '<div class="app-error-debug-section"><div class="app-error-debug-section__title">'
|
||||
. self::esc(self::tr('Loaded Extensions (%d)', count($extensions)))
|
||||
. '</div>';
|
||||
$html .= '<div style="font-family: var(--app-font-family-monospace); font-size: var(--text-xs); color: var(--app-muted-color); padding: calc(var(--app-spacing) * 0.5); background: var(--app-card-background-color); border-radius: var(--app-border-radius); border: 1px solid var(--app-border);">';
|
||||
$html .= self::esc(implode(', ', $extensions));
|
||||
$html .= '</div></div>';
|
||||
}
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private static function renderQueryPanel(array $queryData): string
|
||||
{
|
||||
if (!($queryData['available'] ?? false)) {
|
||||
return '<div class="app-error-debug-empty">' . self::esc(self::tr('Query tracking is not available (Debugger disabled or not initialized).')) . '</div>';
|
||||
}
|
||||
|
||||
$queries = $queryData['queries'] ?? [];
|
||||
if (empty($queries)) {
|
||||
return '<div class="app-error-debug-empty">' . self::esc(self::tr('No queries were executed before the error occurred.')) . '</div>';
|
||||
}
|
||||
|
||||
$html = '<div class="app-error-debug-section"><div class="app-error-debug-section__title">'
|
||||
. self::esc(self::tr('Queries (%d)', count($queries)))
|
||||
. '</div>';
|
||||
|
||||
foreach ($queries as $i => $query) {
|
||||
$sql = is_string($query) ? $query : ($query[0] ?? $query['query'] ?? '?');
|
||||
$duration = '';
|
||||
if (is_array($query) && isset($query[1])) {
|
||||
$durationMs = round((float) $query[1] * 1000, 2);
|
||||
$duration = $durationMs . ' ms';
|
||||
}
|
||||
|
||||
$querySqlId = 'query-sql-' . $i;
|
||||
$queryToggleId = 'query-toggle-' . $i;
|
||||
|
||||
$html .= '<div class="app-error-debug-query">';
|
||||
$html .= '<button type="button" id="' . self::esc($queryToggleId) . '" class="app-error-debug-query__header app-error-debug-query__toggle" aria-expanded="false" aria-controls="' . self::esc($querySqlId) . '">';
|
||||
$html .= '<span>' . self::esc(self::truncate($sql, 120)) . '</span>';
|
||||
if ($duration !== '') {
|
||||
$html .= '<span class="app-error-debug-query__duration">' . self::esc($duration) . '</span>';
|
||||
}
|
||||
$html .= '</button>';
|
||||
$html .= '<div id="' . self::esc($querySqlId) . '" class="app-error-debug-query__sql" role="region" aria-labelledby="' . self::esc($queryToggleId) . '">' . self::esc($sql) . '</div>';
|
||||
$html .= '</div>';
|
||||
}
|
||||
|
||||
$html .= '</div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $items
|
||||
*/
|
||||
private static function renderInfoSection(string $title, array $items): string
|
||||
{
|
||||
if (empty($items)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$html = '<div class="app-error-debug-section"><div class="app-error-debug-section__title">' . self::esc($title) . '</div>';
|
||||
$html .= '<table class="app-error-debug-info-table">';
|
||||
|
||||
foreach ($items as $key => $value) {
|
||||
$keyEsc = self::esc((string) $key);
|
||||
$valueStr = (string) $value;
|
||||
$isRedacted = ($valueStr === '[REDACTED]');
|
||||
$valueEsc = $isRedacted
|
||||
? '<span class="app-error-debug-redacted">[REDACTED]</span>'
|
||||
: self::esc($valueStr);
|
||||
$html .= "<tr><th>{$keyEsc}</th><td>{$valueEsc}</td></tr>";
|
||||
}
|
||||
|
||||
$html .= '</table></div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private static function loadAsset(string $path): string
|
||||
{
|
||||
$resolved = realpath($path);
|
||||
if ($resolved === false || !is_file($resolved)) {
|
||||
return '/* asset not found: ' . basename($path) . ' */';
|
||||
}
|
||||
|
||||
return (string) file_get_contents($resolved);
|
||||
}
|
||||
|
||||
public static function esc(string $value): string
|
||||
{
|
||||
return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
}
|
||||
|
||||
private static function shortenPath(string $path): string
|
||||
{
|
||||
$root = getcwd();
|
||||
if ($root !== false && str_starts_with($path, $root . '/')) {
|
||||
return substr($path, strlen($root) + 1);
|
||||
}
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
private static function statusText(int $code): string
|
||||
{
|
||||
return match ($code) {
|
||||
400 => self::tr('Bad Request'),
|
||||
401 => self::tr('Unauthorized'),
|
||||
403 => self::tr('Forbidden'),
|
||||
404 => self::tr('Not Found'),
|
||||
405 => self::tr('Method Not Allowed'),
|
||||
408 => self::tr('Request Timeout'),
|
||||
422 => self::tr('Unprocessable Entity'),
|
||||
429 => self::tr('Too Many Requests'),
|
||||
500 => self::tr('Internal Server Error'),
|
||||
502 => self::tr('Bad Gateway'),
|
||||
503 => self::tr('Service Unavailable'),
|
||||
default => self::tr('Error'),
|
||||
};
|
||||
}
|
||||
|
||||
private static function formatBytes(int $bytes): string
|
||||
{
|
||||
if ($bytes < 1024) {
|
||||
return $bytes . ' B';
|
||||
}
|
||||
if ($bytes < 1024 * 1024) {
|
||||
return round($bytes / 1024, 1) . ' KB';
|
||||
}
|
||||
|
||||
return round($bytes / (1024 * 1024), 1) . ' MB';
|
||||
}
|
||||
|
||||
private static function truncate(string $text, int $length): string
|
||||
{
|
||||
if (mb_strlen($text) <= $length) {
|
||||
return $text;
|
||||
}
|
||||
|
||||
return mb_substr($text, 0, $length) . '...';
|
||||
}
|
||||
|
||||
private static function tr(string $text, mixed ...$args): string
|
||||
{
|
||||
if (function_exists('t')) {
|
||||
try {
|
||||
/** @var string $translated */
|
||||
$translated = t($text, ...$args);
|
||||
return $translated;
|
||||
} catch (\Throwable) {
|
||||
// Rendering must stay resilient, even if translation bootstrap failed.
|
||||
}
|
||||
}
|
||||
|
||||
if ($args === []) {
|
||||
return $text;
|
||||
}
|
||||
|
||||
return (string) @vsprintf($text, $args);
|
||||
}
|
||||
|
||||
private static function htmlLang(): string
|
||||
{
|
||||
$locale = trim((string) (I18n::$locale ?? I18n::$defaultLocale ?? 'en'));
|
||||
if ($locale === '') {
|
||||
return 'en';
|
||||
}
|
||||
|
||||
$locale = strtolower(str_replace('_', '-', $locale));
|
||||
if (preg_match('/^[a-z]{2,3}(?:-[a-z0-9]{2,8})*$/', $locale) === 1) {
|
||||
return $locale;
|
||||
}
|
||||
|
||||
if (preg_match('/^[a-z]{2,3}/', $locale, $matches) === 1) {
|
||||
return $matches[0];
|
||||
}
|
||||
|
||||
return 'en';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user