diff --git a/.claude/memory/MEMORY.md b/.claude/memory/MEMORY.md new file mode 100644 index 0000000..4c92a15 --- /dev/null +++ b/.claude/memory/MEMORY.md @@ -0,0 +1,5 @@ +# Memory Index + +- [Обзор проекта Bicycle](project_bicycle.md) — текущее состояние фреймворка, реализованные классы, план, ограничения +- [Обновлять CLAUDE.md без напоминаний](feedback_update_claude_md.md) — после каждого изменения кода обновлять CLAUDE.md автоматически +- [Правила именования и PHPDoc](feedback_naming_phpdoc.md) — snake_case переменные, camelCase методы, PascalCase классы, PHPDoc везде diff --git a/.claude/memory/feedback_update_claude_md.md b/.claude/memory/feedback_update_claude_md.md new file mode 100644 index 0000000..2ceee44 --- /dev/null +++ b/.claude/memory/feedback_update_claude_md.md @@ -0,0 +1,12 @@ +--- +name: feedback-update-claude-md +description: Всегда обновлять CLAUDE.md после изменений в коде — без напоминаний пользователя +metadata: + type: feedback +--- + +После каждого изменения кода (новый класс, новый тест, исправление архитектуры) — обновлять CLAUDE.md самостоятельно, не спрашивая и не ожидая команды. + +**Why:** Пользователь явно сказал «обновляй CLAUDE.md по умолчанию сам не спрашивая меня и чтобы я тебе это не говорил». + +**How to apply:** При любом коммите или завершении задачи, затрагивающей архитектуру, классы или тесты — сразу редактировать CLAUDE.md в рамках того же ответа. Не выносить обновление в отдельный шаг. diff --git a/.claude/memory/project_bicycle.md b/.claude/memory/project_bicycle.md new file mode 100644 index 0000000..5b78fea --- /dev/null +++ b/.claude/memory/project_bicycle.md @@ -0,0 +1,49 @@ +--- +name: project-bicycle-overview +description: "Текущее состояние проекта Bicycle — PHP MVC micro-framework, реализованные классы и планы" +metadata: + type: project +--- + +Проект Bicycle — самописный PHP MVC micro-framework (PHP >= 8.2), автор Egor Isaev. + +**Why:** Учебный/экспериментальный проект ("изобрести велосипед"). Не предлагать заменить на Laravel/Symfony. + +**How to apply:** При добавлении фич ориентироваться на стиль существующих классов. Всегда обновлять CLAUDE.md после изменений без напоминаний ([[feedback-update-claude-md]]). + +### Уже реализовано (по состоянию на 2026-06-23) + +**Ядро MVC:** +- `Core`, `Route`, `Request`, `Response`, `Controller`, `View`, `MyException` + +**HTTP-инфраструктура:** +- `HTTP` (redirect, request_headers) +- `HTTP\Header` (ArrayObject, send) +- `HTTP\Message` (интерфейс) +- `HTTP\Request` (интерфейс, константы методов) +- `HTTP\HTTPException` (factory, get_response) +- `HTTP\Exception\HTTPException_302/403/404` +- `HTTP\Client\{Request, Curl, Response}` (исходящие запросы) + +**Состояние и конфиг:** +- `Session` (синглтон, именованные сессии) +- `Cookie` (статический хелпер) +- `Config` (lazy-load + merge config.local.php) + +**Приложение:** `IndexController` (`indexAction` GET + `postAction` POST — демо HTTP-клиента к jsonplaceholder), шаблон `App/view/Index/index.html`. + +**Тесты:** 111 тестов, все проходят (`docker exec bicycle vendor/bin/phpunit`). + +### Не реализовано / заготовки + +- `Services/DataBase`, `Services/Mail`, `Services/PDF` — пустые каталоги, кода нет (зависимости PDO/PHPMailer/dompdf в composer.json есть). +- `App/Classes/` — отсутствует. +- Запланировано: `Model`, `BaseController` (before/after), `Database`+`PdoDriver` (PDO singleton с failover), `Statement` (showQuery/sq), `Repository` (CRUD + транзакции/SAVEPOINT), `Profiler`/`ProfilerPDO`. + +### Важные ограничения + +- Переменная окружения среды — `APP_ENV` (раньше была `MMH_ENV`), задаётся в `.htaccess` через `SetEnv`. +- **Никогда не трогать** `/home/isaevea/http/docker-dev/docker/php-apache/8.2.8/Dockerfile` +- Apache config: `/home/isaevea/http/docker-dev/conf/Bicycle/apache2/apache2.conf` +- `Core::init()` не вызывается в тестовом bootstrap (конфликт с PHPUnit handlers) +- PHPStorm: volume `/home/isaevea/http/Bicycle` → `/opt/project`, autoload `/opt/project/vendor/autoload.php` diff --git a/.gitignore b/.gitignore index 5936158..e467e3e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,18 +1,12 @@ .idea/ /.git -*.bat -config.php +/vendor/ +.phpunit.result.cache + config.json App/config/config.local.php -.htaccess - -/App/media/img/no_img/* -!/App/media/img/no_img/no_image.png - - **/files/**/* !**/files/**/ !**/files/**/.gitkeep -/tools/* diff --git a/.htaccess b/.htaccess new file mode 100644 index 0000000..41eedd4 --- /dev/null +++ b/.htaccess @@ -0,0 +1,9 @@ +SetEnv APP_ENV DEVELOPMENT + +RewriteEngine On + +RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] + +RewriteCond %{REQUEST_FILENAME} !-f +RewriteCond %{REQUEST_FILENAME} !-d +RewriteRule .* index.php [PT,QSA,L] diff --git a/App/Controller/IndexController.php b/App/Controller/IndexController.php index 7c254a9..24425c8 100644 --- a/App/Controller/IndexController.php +++ b/App/Controller/IndexController.php @@ -12,8 +12,17 @@ use System\Classes\Controller; use System\Classes\HTTP\Client\Curl; use System\Classes\HTTP\Client\Request as HttpRequest; +/** + * Демонстрационный контроллер главной страницы. + */ class IndexController extends Controller { + /** + * Главная страница: GET-запрос к внешнему API, вывод todo в шаблон. + * + * @return string + * @throws \System\Classes\MyException + */ public function indexAction(): string { // GET-запрос @@ -26,6 +35,12 @@ class IndexController extends Controller return $this->render('index', ['todo' => $todo]); } + /** + * Демонстрация POST-запроса с JSON-телом к внешнему API. + * + * @return string + * @throws \System\Classes\MyException + */ public function postAction(): string { // POST с JSON-телом diff --git a/CLAUDE.md b/CLAUDE.md index 7073fea..0c59003 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,10 +15,16 @@ composer install # Обновление автозагрузчика после добавления классов composer dump-autoload -# Запуск тестов через Docker +# Запуск всех тестов через Docker (контейнер уже должен быть запущен) docker exec bicycle vendor/bin/phpunit + +# Один файл / один тест +docker exec bicycle vendor/bin/phpunit tests/Unit/RouteTest.php +docker exec bicycle vendor/bin/phpunit --filter testControllerAndAction tests/Unit/RouteTest.php ``` +Composer-скриптов нет — phpunit вызывается напрямую. Запускать тесты надо именно внутри контейнера: PHP-интерпретатор живёт в Docker, volume `/home/isaevea/http/Bicycle` → `/opt/project`. + Проект запускается как веб-приложение через Apache + PHP 8.2 в контейнере `bicycle`. ## Architecture @@ -32,17 +38,17 @@ System/Classes/HTTP/ — HTTP-инфраструктура: инт System/Classes/HTTP/Client/ — исходящие HTTP-запросы (Curl, Request, Response) System/Classes/HTTP/Exception/ — HTTPException_302/403/404 App/Controller/ — контроллеры приложения (namespace App\Controller\) -App/Classes/ — вспомогательные классы приложения (namespace App\Classes\) App/config/ — конфиги (config.php; config.local.php — в .gitignore) App/view/ — шаблоны приложения (.html файлы с PHP-кодом) App/media/ — статические ресурсы (js, css, img) -Services/DataBase/ — сервис базы данных (PDO) -Services/Mail/ — сервис почты (PHPMailer) -Services/PDF/ — генерация PDF (dompdf) +Services/DataBase/, Mail/, PDF/ — пустые каталоги-заготовки под будущие сервисы (PDO / PHPMailer / dompdf); кода пока нет System/view/ — системные шаблоны (ошибки 404/500/403, exception) +tools/sort_html_attrs.php — CLI-утилита сортировки HTML-атрибутов (gitignored: /tools/* в .gitignore) tests/ — PHPUnit тесты ``` +> Каталоги `App/Classes/` и наполнение `Services/*` ещё не созданы — namespace-конвенции ниже описывают, *куда* класть код, когда он появится, а не существующие файлы. + ### Autoload (composer.json) PSR-4 с относительными путями: @@ -71,17 +77,17 @@ PhpStorm может показывать предупреждение «Namespac | `System\Classes\Controller` | Базовый контроллер; `render()` строит двухуровневый вывод: layout + content | | `System\Classes\View` | Рендеринг `.html`-шаблонов через `ob_start` + `extract` + `include` | | `System\Classes\Route` | Автоматический роутинг: парсит URI, ищет контроллер в `App/Controller/` | -| `System\Classes\Request` | Входящий HTTP-запрос; реализует `HTTP\Request`; `post()`, `query()`, `body()`, `is_ajax()` | +| `System\Classes\Request` | Входящий HTTP-запрос; реализует `HTTP\Request`; `post()`, `query()`, `body()`, `isAjax()` | | `System\Classes\Response` | HTTP-ответ; `body()` — геттер/сеттер содержимого | | `System\Classes\MyException` | Кастомное исключение; зарегистрировано через `set_exception_handler` | | `System\Classes\Cookie` | Статический хелпер для работы с куками: `get()`, `set()`, `delete()` | | `System\Classes\Session` | Синглтон сессии: `instance(string $name)`, `get/set/delete/destroy/regenerate` | | `System\Classes\Config` | Статический конфиг: lazy-load `App/config/config.php` + merge `config.local.php` | -| `System\Classes\HTTP` | `redirect()`, `request_headers()` | +| `System\Classes\HTTP` | `redirect()`, `requestHeaders()` | | `System\Classes\HTTP\Header` | Extends `ArrayObject`; `send()`, `__toString()` → RFC-формат | | `System\Classes\HTTP\Message` | Интерфейс: `protocol()`, `headers()`, `body()`, `render()` | | `System\Classes\HTTP\Request` | Интерфейс входящего запроса; константы методов GET/POST/PUT/... | -| `System\Classes\HTTP\HTTPException` | HTTP-исключение; `factory(int $code)`, `get_response(): Response` | +| `System\Classes\HTTP\HTTPException` | HTTP-исключение; `factory(int $code)`, `getResponse(): Response` | | `System\Classes\HTTP\Exception\HTTPException_302` | Редирект через `HTTP::redirect()` | | `System\Classes\HTTP\Exception\HTTPException_403` | 403 Forbidden | | `System\Classes\HTTP\Exception\HTTPException_404` | 404 Not Found | @@ -94,7 +100,7 @@ PhpStorm может показывать предупреждение «Namespac ``` index.php → Core::init() - → Request::factory() # detect_uri(), Route::resolve(), заполняет post/query/body/method + → Request::factory() # detectUri(), Route::resolve(), заполняет post/query/body/method → Request::execute() # require_once контроллера, вызывает {action}Action() → Response->body() # строка с HTML → echo @@ -110,8 +116,8 @@ index.php - `query($key?, $value?)` — getter/setter GET-параметров - `body($content?)` — getter/setter тела запроса - `method($method?)` — getter/setter HTTP-метода -- `is_ajax()` — проверяет `X-Requested-With: XMLHttpRequest` -- `detect_uri()` — определяет URI через `PATH_INFO` → `REQUEST_URI` → `PHP_SELF` +- `isAjax()` — проверяет `X-Requested-With: XMLHttpRequest` +- `detectUri()` — определяет URI через `PATH_INFO` → `REQUEST_URI` → `PHP_SELF` ### External HTTP Client (System\Classes\HTTP\Client\*) @@ -181,8 +187,8 @@ Config::set('foo', ['bar' => 'baz']); // переопредел use System\Classes\HTTP\HTTPException; throw HTTPException::factory(404, 'Страница :url не найдена', [':url' => $uri]); -// или через get_response() без throw: -$response = HTTPException::factory(403)->get_response(); +// или через getResponse() без throw: +$response = HTTPException::factory(403)->getResponse(); ``` `factory(int $code)` создаёт `HTTPException_302/403/404` или базовый класс для неизвестных кодов. @@ -211,8 +217,8 @@ $response = HTTPException::factory(403)->get_response(); ### Environment -Среда задаётся через `SetEnv MMH_ENV` в `.htaccess` (PRODUCTION / STAGING / TESTING / DEVELOPMENT). -По умолчанию — `DEVELOPMENT`. Значение читается в `index.php` через `getenv('MMH_ENV')`. +Среда задаётся через `SetEnv APP_ENV` в `.htaccess` (PRODUCTION / STAGING / TESTING / DEVELOPMENT). +По умолчанию — `DEVELOPMENT`. Значение читается в `index.php` через `getenv('APP_ENV')`. ### Apache @@ -232,13 +238,13 @@ PHPUnit 11 в Docker-контейнере `bicycle`. Bootstrap: `tests/bootstrap | `tests/Unit/ResponseTest.php` | `body()`, `status()`, fluent chain | | `tests/Unit/MyExceptionTest.php` | `strtr` замена, code, previous | | `tests/Unit/CoreTest.php` | `findFile()`, `getConst()`, environment | -| `tests/Unit/RequestTest.php` | `factory()`, `post()`, `query()`, `body()`, `is_ajax()`, `method()` | +| `tests/Unit/RequestTest.php` | `factory()`, `post()`, `query()`, `body()`, `isAjax()`, `method()` | | `tests/Unit/ViewTest.php` | `render()`, `setFilename()`, data extract | | `tests/Unit/HttpClientRequestTest.php` | `HTTP\Client\Request` builder, json, fluent | | `tests/Unit/CookieTest.php` | `get()`, `set()`, `delete()`, defaults | | `tests/Unit/SessionTest.php` | singleton, `get/set/delete/destroy/regenerate/bind` | | `tests/Unit/ConfigTest.php` | `get()`, `set()`, `merge()`, lazy-load | -| `tests/Unit/HTTPExceptionTest.php` | `factory()`, subclasses, `get_response()`, codes | +| `tests/Unit/HTTPExceptionTest.php` | `factory()`, subclasses, `getResponse()`, codes | **111 тестов, 171 assertion — все проходят.** diff --git a/System/Classes/Config.php b/System/Classes/Config.php index eeb55d4..d17d78e 100644 --- a/System/Classes/Config.php +++ b/System/Classes/Config.php @@ -8,10 +8,20 @@ namespace System\Classes; +/** + * Статический конфиг: lazy-load App/config/config.php + * с глубоким merge App/config/config.local.php поверх. + */ class Config { + /** @var array|null Загруженный конфиг (null до первой загрузки) */ private static ?array $config = null; + /** + * Лениво загружает базовый конфиг и мержит локальный поверх. + * + * @return void + */ private static function load(): void { if (self::$config !== null) { @@ -24,12 +34,22 @@ class Config $config = file_exists($base) ? (include $base) : []; if (file_exists($local)) { - $config = self::merge($config, include $local); + $local_config = include $local; + if (is_array($local_config)) { + $config = self::merge($config, $local_config); + } } self::$config = $config; } + /** + * Возвращает раздел конфига или вложенный ключ. + * + * @param string $name Имя раздела + * @param string|null $group Вложенный ключ внутри раздела + * @return mixed null, если не найдено + */ public static function get(string $name, ?string $group = null): mixed { self::load(); @@ -41,6 +61,13 @@ class Config return self::$config[$name][$group] ?? null; } + /** + * Переопределяет раздел конфига в runtime. + * + * @param string $name Имя раздела + * @param mixed $value Значение + * @return void + */ public static function set(string $name, mixed $value): void { self::load(); @@ -49,6 +76,10 @@ class Config /** * Глубокое слияние двух массивов конфигурации. + * + * @param array $base Базовый массив + * @param array $override Массив-переопределение + * @return array */ public static function merge(array $base, array $override): array { diff --git a/System/Classes/Controller.php b/System/Classes/Controller.php index 435ada9..dda6b36 100644 --- a/System/Classes/Controller.php +++ b/System/Classes/Controller.php @@ -8,11 +8,23 @@ namespace System\Classes; +/** + * Базовый контроллер. render() строит двухуровневый вывод: layout + content. + */ class Controller { + /** @var string Имя layout-шаблона в System/view/views */ protected string $_layout = 'layout'; /** + * Рендерит шаблон контента внутри layout. + * Если $dir пуст — определяется автоматически из имени класса + * (App\Controller\FooController → view/Foo). + * + * @param string $template Имя шаблона контента без расширения + * @param array $data Данные, передаваемые в шаблон + * @param string $dir Каталог шаблона относительно view/ (опционально) + * @return string Готовый HTML * @throws MyException */ protected function render(string $template, array $data = [], string $dir = ''): string @@ -26,4 +38,4 @@ class Controller 'content' => (new View($template, $dir, $data))->render() ]))->render(); } -} \ No newline at end of file +} diff --git a/System/Classes/Cookie.php b/System/Classes/Cookie.php index 6577558..c0f5d61 100644 --- a/System/Classes/Cookie.php +++ b/System/Classes/Cookie.php @@ -8,20 +8,42 @@ namespace System\Classes; +/** + * Статический хелпер для работы с cookie. + */ class Cookie { + /** @var int Время жизни по умолчанию в секундах (0 — сессионная) */ public static int $expiration = 0; + /** @var string Путь cookie */ public static string $path = '/'; + /** @var string|null Домен cookie */ public static ?string $domain = null; + /** @var bool Передавать только по HTTPS */ public static bool $secure = false; + /** @var bool Недоступна для JavaScript */ public static bool $httponly = true; + /** @var string Политика SameSite */ public static string $samesite = 'Lax'; + /** + * @param string $key Имя cookie + * @param string|null $default Значение по умолчанию + * @return string|null + */ public static function get(string $key, ?string $default = null): ?string { return $_COOKIE[$key] ?? $default; } + /** + * Устанавливает cookie. + * + * @param string $name Имя + * @param string $value Значение + * @param int|null $lifetime Время жизни в секундах; null → self::$expiration + * @return bool + */ public static function set(string $name, string $value, ?int $lifetime = null): bool { $lifetime = $lifetime ?? self::$expiration; @@ -37,6 +59,12 @@ class Cookie ]); } + /** + * Удаляет cookie. + * + * @param string $name Имя + * @return bool + */ public static function delete(string $name): bool { unset($_COOKIE[$name]); diff --git a/System/Classes/Core.php b/System/Classes/Core.php index 12b8a56..afbe74f 100644 --- a/System/Classes/Core.php +++ b/System/Classes/Core.php @@ -10,12 +10,19 @@ namespace System\Classes; use ErrorException; +/** + * Ядро фреймворка: bootstrap, регистрация обработчиков ошибок/исключений + * и поиск файлов по путям APPPATH → SYSPATH. + */ class Core { - // Среда работы системы + /** @var int Боевая среда */ public const PRODUCTION = 10; + /** @var int Промежуточная (staging) среда */ public const STAGING = 20; + /** @var int Тестовая среда */ public const TESTING = 30; + /** @var int Среда разработки */ public const DEVELOPMENT = 40; /** @@ -34,10 +41,16 @@ class Core protected static bool $_init = false; /** - * @var array + * @var array Список базовых путей для поиска файлов (APPPATH, SYSPATH) */ protected static array $_paths = []; + /** + * Инициализирует ядро: регистрирует обработчики исключений и ошибок. + * Повторный вызов игнорируется. + * + * @return void + */ public static function init(): void { if (self::$_init) { @@ -51,6 +64,14 @@ class Core set_error_handler([__CLASS__, 'errorHandler']); } + /** + * Ищет файл в APPPATH, затем в SYSPATH. + * + * @param string $folder Подкаталог относительно базового пути + * @param string $file Имя файла без расширения + * @param string|null $ext Расширение без точки; null → EXT, '' → без расширения + * @return bool|string Полный путь к файлу или false, если не найден + */ public static function findFile($folder, $file, $ext = null): bool|string { if (empty(self::$_paths)) { @@ -83,6 +104,13 @@ class Core } /** + * Обработчик ошибок PHP: преобразует ошибку в ErrorException. + * + * @param int $code Уровень ошибки + * @param string $error Текст ошибки + * @param string|null $file Файл, где произошла ошибка + * @param int|null $line Строка, где произошла ошибка + * @return bool * @throws ErrorException */ public static function errorHandler($code, $error, $file = null, $line = null): bool @@ -97,8 +125,14 @@ class Core return true; } + /** + * Возвращает значение константы класса по её имени. + * + * @param string $name Имя константы (например, 'PRODUCTION') + * @return mixed + */ public static function getConst(string $name): mixed { return constant("self::$name"); } -} \ No newline at end of file +} diff --git a/System/Classes/HTTP.php b/System/Classes/HTTP.php index 5b989a1..8bcc936 100644 --- a/System/Classes/HTTP.php +++ b/System/Classes/HTTP.php @@ -10,8 +10,18 @@ namespace System\Classes; use System\Classes\HTTP\Header; +/** + * HTTP-хелперы: редирект и сбор входящих заголовков. + */ class HTTP { + /** + * Выполняет редирект и завершает выполнение. + * + * @param string $uri Целевой URL + * @param int $code HTTP-код редиректа + * @return never + */ public static function redirect(string $uri, int $code = 302): never { http_response_code($code); @@ -19,7 +29,13 @@ class HTTP exit; } - public static function request_headers(): Header + /** + * Возвращает заголовки входящего запроса (через apache_request_headers + * или реконструкцию из $_SERVER). + * + * @return Header + */ + public static function requestHeaders(): Header { if (function_exists('apache_request_headers')) { return new Header(array_change_key_case(apache_request_headers())); diff --git a/System/Classes/HTTP/Client/Curl.php b/System/Classes/HTTP/Client/Curl.php index 8e8185e..6cecf8b 100644 --- a/System/Classes/HTTP/Client/Curl.php +++ b/System/Classes/HTTP/Client/Curl.php @@ -10,8 +10,18 @@ namespace System\Classes\HTTP\Client; use RuntimeException; +/** + * Исполнитель исходящих HTTP-запросов через cURL. + */ class Curl { + /** + * Выполняет запрос и возвращает ответ. + * + * @param Request $request Подготовленный исходящий запрос + * @return Response + * @throws RuntimeException При ошибке cURL + */ public function execute(Request $request): Response { $headers = []; diff --git a/System/Classes/HTTP/Client/Request.php b/System/Classes/HTTP/Client/Request.php index 1804fd5..3fc52d0 100644 --- a/System/Classes/HTTP/Client/Request.php +++ b/System/Classes/HTTP/Client/Request.php @@ -8,42 +8,82 @@ namespace System\Classes\HTTP\Client; +/** + * Билдер исходящего HTTP-запроса (fluent-интерфейс). + */ class Request { + /** @var string HTTP-метод */ private string $method = 'GET'; + /** @var string Целевой URL */ private string $url; + /** @var array Заголовки запроса */ private array $headers = []; + /** @var string|null Тело запроса */ private ?string $body = null; + /** @var int Таймаут в секундах */ private int $timeout = 30; + /** + * @param string $url Целевой URL + */ public function __construct(string $url) { $this->url = $url; } + /** + * Создаёт новый билдер запроса. + * + * @param string $url Целевой URL + * @return static + */ public static function factory(string $url): static { return new static($url); } + /** + * @param string $method HTTP-метод + * @return static + */ public function method(string $method): static { $this->method = strtoupper($method); return $this; } + /** + * Добавляет заголовок. + * + * @param string $name Имя заголовка + * @param string $value Значение + * @return static + */ public function header(string $name, string $value): static { $this->headers[$name] = $value; return $this; } + /** + * Устанавливает сырое тело запроса. + * + * @param string $body Тело + * @return static + */ public function body(string $body): static { $this->body = $body; return $this; } + /** + * Устанавливает JSON-тело и соответствующие заголовки. + * + * @param array $data Данные для кодирования в JSON + * @return static + */ public function json(array $data): static { $this->header('Content-Type', 'application/json'); @@ -52,15 +92,24 @@ class Request return $this; } + /** + * @param int $seconds Таймаут в секундах + * @return static + */ public function timeout(int $seconds): static { $this->timeout = $seconds; return $this; } + /** @return string HTTP-метод */ public function getMethod(): string { return $this->method; } + /** @return string Целевой URL */ public function getUrl(): string { return $this->url; } + /** @return array Заголовки */ public function getHeaders(): array { return $this->headers; } + /** @return string|null Тело запроса */ public function getBody(): ?string { return $this->body; } + /** @return int Таймаут в секундах */ public function getTimeout(): int { return $this->timeout; } } diff --git a/System/Classes/HTTP/Client/Response.php b/System/Classes/HTTP/Client/Response.php index bcbce7a..d720475 100644 --- a/System/Classes/HTTP/Client/Response.php +++ b/System/Classes/HTTP/Client/Response.php @@ -10,30 +10,49 @@ namespace System\Classes\HTTP\Client; use JsonException; +/** + * Ответ внешнего HTTP-запроса. + */ class Response { + /** + * @param int $status HTTP-статус ответа + * @param string $body Тело ответа + */ public function __construct( private readonly int $status, private readonly string $body ) {} + /** + * @return int HTTP-статус + */ public function status(): int { return $this->status; } + /** + * @return string Тело ответа + */ public function body(): string { return $this->body; } + /** + * @return bool true, если статус в диапазоне 2xx + */ public function isSuccess(): bool { return $this->status >= 200 && $this->status < 300; } /** - * @throws JsonException + * Декодирует тело ответа как JSON. + * + * @return array Пустой массив, если тело пустое + * @throws JsonException При некорректном JSON */ public function json(): array { diff --git a/System/Classes/HTTP/Exception/HTTPException_302.php b/System/Classes/HTTP/Exception/HTTPException_302.php index 590b6e8..c45ebbf 100644 --- a/System/Classes/HTTP/Exception/HTTPException_302.php +++ b/System/Classes/HTTP/Exception/HTTPException_302.php @@ -12,11 +12,20 @@ use System\Classes\HTTP; use System\Classes\HTTP\HTTPException; use System\Classes\Response; +/** + * 302 Found: редирект на адрес из сообщения исключения. + */ class HTTPException_302 extends HTTPException { + /** @var int HTTP-код */ protected int $_code = 302; - public function get_response(): Response + /** + * Выполняет редирект через HTTP::redirect() и завершает выполнение. + * + * @return Response Никогда не возвращается (редирект завершает скрипт) + */ + public function getResponse(): Response { $location = $this->getMessage() ?: '/'; HTTP::redirect($location, 302); diff --git a/System/Classes/HTTP/Exception/HTTPException_403.php b/System/Classes/HTTP/Exception/HTTPException_403.php index e2f02de..f54c71e 100644 --- a/System/Classes/HTTP/Exception/HTTPException_403.php +++ b/System/Classes/HTTP/Exception/HTTPException_403.php @@ -12,11 +12,20 @@ use System\Classes\Core; use System\Classes\HTTP\HTTPException; use System\Classes\Response; +/** + * 403 Forbidden: рендерит шаблон view/errors/403.html. + */ class HTTPException_403 extends HTTPException { + /** @var int HTTP-код */ protected int $_code = 403; - public function get_response(): Response + /** + * Формирует Response 403 с телом из шаблона ошибки. + * + * @return Response + */ + public function getResponse(): Response { http_response_code(403); $response = new Response(403); diff --git a/System/Classes/HTTP/Exception/HTTPException_404.php b/System/Classes/HTTP/Exception/HTTPException_404.php index 6c6365a..d6df3bb 100644 --- a/System/Classes/HTTP/Exception/HTTPException_404.php +++ b/System/Classes/HTTP/Exception/HTTPException_404.php @@ -12,11 +12,20 @@ use System\Classes\Core; use System\Classes\HTTP\HTTPException; use System\Classes\Response; +/** + * 404 Not Found: рендерит шаблон view/errors/404.html. + */ class HTTPException_404 extends HTTPException { + /** @var int HTTP-код */ protected int $_code = 404; - public function get_response(): Response + /** + * Формирует Response 404 с телом из шаблона ошибки. + * + * @return Response + */ + public function getResponse(): Response { http_response_code(404); $response = new Response(404); diff --git a/System/Classes/HTTP/HTTPException.php b/System/Classes/HTTP/HTTPException.php index 9e4ff8c..35abe3b 100644 --- a/System/Classes/HTTP/HTTPException.php +++ b/System/Classes/HTTP/HTTPException.php @@ -11,20 +11,41 @@ namespace System\Classes\HTTP; use System\Classes\Core; use System\Classes\MyException; use System\Classes\Response; +use Throwable; +/** + * Базовое HTTP-исключение. factory() подбирает подкласс по коду, + * getResponse() рендерит соответствующий шаблон ошибки. + */ class HTTPException extends MyException { + /** @var int HTTP-код исключения */ protected int $_code = 0; - public function __construct(string $message = '', ?array $variables = null, ?\Throwable $previous = null, int $codeOverride = 0) + /** + * @param string $message Сообщение (поддерживает плейсхолдеры) + * @param array|null $variables Карта замен для strtr + * @param Throwable|null $previous Предыдущее исключение + * @param int $code_override Код, переопределяющий $_code (для неизвестных кодов) + */ + public function __construct(string $message = '', ?array $variables = null, ?Throwable $previous = null, int $code_override = 0) { - if ($codeOverride !== 0) { - $this->_code = $codeOverride; + if ($code_override !== 0) { + $this->_code = $code_override; } parent::__construct($message, $variables, $this->_code, $previous); } - public static function factory(int $code, string $message = '', ?array $variables = null, ?\Throwable $previous = null): static + /** + * Создаёт HTTPException_{code} или базовый класс для неизвестных кодов. + * + * @param int $code HTTP-код + * @param string $message Сообщение + * @param array|null $variables Карта замен для strtr + * @param Throwable|null $previous Предыдущее исключение + * @return static + */ + public static function factory(int $code, string $message = '', ?array $variables = null, ?Throwable $previous = null): static { $class = 'System\\Classes\\HTTP\\Exception\\HTTPException_' . $code; @@ -35,7 +56,12 @@ class HTTPException extends MyException return new static($message, $variables, $previous, $code); } - public function get_response(): Response + /** + * Формирует Response с телом из шаблона view/errors/{code}.html. + * + * @return Response + */ + public function getResponse(): Response { $code = $this->_code ?: 500; http_response_code($code); diff --git a/System/Classes/HTTP/Header.php b/System/Classes/HTTP/Header.php index 8ecc612..ff6fba6 100644 --- a/System/Classes/HTTP/Header.php +++ b/System/Classes/HTTP/Header.php @@ -10,13 +10,24 @@ namespace System\Classes\HTTP; use ArrayObject; +/** + * Коллекция HTTP-заголовков. Умеет отдавать себя в RFC-формате и отправлять. + */ class Header extends ArrayObject { + /** + * @param array $input Ассоциативный массив заголовков + */ public function __construct(array $input = []) { parent::__construct($input); } + /** + * Сериализует заголовки в RFC-формат (key: value, разделённые CRLF). + * + * @return string + */ public function __toString(): string { $output = ''; @@ -28,6 +39,11 @@ class Header extends ArrayObject return $output . "\r\n"; } + /** + * Отправляет заголовки клиенту, если они ещё не отправлены. + * + * @return void + */ public function send(): void { if (headers_sent()) { diff --git a/System/Classes/HTTP/Message.php b/System/Classes/HTTP/Message.php index 024d353..3757098 100644 --- a/System/Classes/HTTP/Message.php +++ b/System/Classes/HTTP/Message.php @@ -8,10 +8,40 @@ namespace System\Classes\HTTP; +/** + * Общий контракт HTTP-сообщения (запрос/ответ). + */ interface Message { + /** + * Геттер/сеттер версии протокола. + * + * @param string|null $protocol Версия или null для чтения + * @return mixed + */ public function protocol($protocol = null); + + /** + * Геттер/сеттер заголовков. + * + * @param string|null $key Имя заголовка или null + * @param string|null $value Значение при записи + * @return mixed + */ public function headers($key = null, $value = null); + + /** + * Геттер/сеттер тела сообщения. + * + * @param string|null $content Тело или null для чтения + * @return mixed + */ public function body($content = null); + + /** + * Возвращает сообщение в виде строки. + * + * @return mixed + */ public function render(); } diff --git a/System/Classes/HTTP/Request.php b/System/Classes/HTTP/Request.php index dc69741..51e06e2 100644 --- a/System/Classes/HTTP/Request.php +++ b/System/Classes/HTTP/Request.php @@ -8,6 +8,9 @@ namespace System\Classes\HTTP; +/** + * Контракт входящего HTTP-запроса и константы методов. + */ interface Request { public const GET = 'GET'; @@ -18,8 +21,34 @@ interface Request public const HEAD = 'HEAD'; public const OPTIONS = 'OPTIONS'; + /** + * Геттер/сеттер HTTP-метода. + * + * @param string|null $method Метод или null для чтения + * @return mixed + */ public function method(?string $method = null); + + /** + * @return mixed URI запроса + */ public function uri(); + + /** + * Геттер/сеттер GET-параметров. + * + * @param string|array|null $key Ключ, массив или null + * @param mixed $value Значение при записи + * @return mixed + */ public function query(null|string|array $key = null, mixed $value = null); + + /** + * Геттер/сеттер POST-данных. + * + * @param string|array|null $key Ключ, массив или null + * @param mixed $value Значение при записи + * @return mixed + */ public function post(null|string|array $key = null, mixed $value = null); } diff --git a/System/Classes/MyException.php b/System/Classes/MyException.php index e7614a8..888cb81 100644 --- a/System/Classes/MyException.php +++ b/System/Classes/MyException.php @@ -11,13 +11,17 @@ namespace System\Classes; use Exception; use Throwable; +/** + * Кастомное исключение с подстановкой переменных в сообщение (strtr). + * Зарегистрировано через set_exception_handler в Core::init(). + */ class MyException extends Exception { /** - * @param $message - * @param array|null $variables - * @param $code - * @param Exception|null $previous + * @param string $message Сообщение, может содержать плейсхолдеры для $variables + * @param array|null $variables Карта замен вида [':url' => '/foo'] для strtr + * @param int $code Код исключения + * @param Throwable|null $previous Предыдущее исключение */ public function __construct(string $message = "", ?array $variables = null, int $code = 0, ?Throwable $previous = null) { @@ -28,10 +32,17 @@ class MyException extends Exception parent::__construct($message, $code, $previous); } + /** + * Обработчик неперехваченных исключений: выводит шаблон ошибки. + * В DEVELOPMENT показывает детали, иначе — страницу 500. + * + * @param Throwable $e Перехваченное исключение + * @return void + */ public static function handler(Throwable $e): void { - $httpCode = ($e->getCode() >= 100 && $e->getCode() <= 599) ? $e->getCode() : 500; - http_response_code($httpCode); + $http_code = ($e->getCode() >= 100 && $e->getCode() <= 599) ? $e->getCode() : 500; + http_response_code($http_code); if (Core::$environment >= Core::DEVELOPMENT) { $class = get_class($e); @@ -52,4 +63,4 @@ class MyException extends Exception include $view500; } } -} \ No newline at end of file +} diff --git a/System/Classes/Request.php b/System/Classes/Request.php index 5158bf8..febf0e3 100644 --- a/System/Classes/Request.php +++ b/System/Classes/Request.php @@ -11,21 +11,38 @@ namespace System\Classes; use System\Classes\HTTP\HTTPException; use System\Classes\HTTP\Request as HTTPRequest; +/** + * Входящий HTTP-запрос от браузера. Реализует System\Classes\HTTP\Request. + */ class Request implements HTTPRequest { + /** @var static|null Первый созданный запрос */ public static ?self $initial = null; + /** @var static|null Текущий запрос */ public static ?self $current = null; + /** @var string Исходный URI запроса */ private string $_uri; + /** @var string Имя контроллера */ private string $_controller = 'Index'; + /** @var string Имя действия */ private string $_action = 'index'; + /** @var array Позиционные параметры из URI */ private array $_params = []; + /** @var string HTTP-метод */ private string $_method = HTTPRequest::GET; + /** @var array POST-данные */ private array $_post = []; + /** @var array GET-параметры */ private array $_get = []; + /** @var string|null Тело запроса */ private ?string $_body = null; + /** @var string Значение заголовка X-Requested-With (lowercase) */ private string $_requested_with = ''; + /** + * @param string $uri URI запроса; сразу резолвится в контроллер/действие/параметры + */ private function __construct(string $uri) { $this->_uri = $uri; @@ -35,9 +52,14 @@ class Request implements HTTPRequest $this->_params = $route['params']; } + /** + * Создаёт запрос из суперглобалов: URI, метод, $_GET, $_POST, тело, X-Requested-With. + * + * @return static + */ public static function factory(): static { - $uri = self::detect_uri(); + $uri = self::detectUri(); $instance = new static($uri); if (self::$initial === null) { @@ -60,7 +82,12 @@ class Request implements HTTPRequest return $instance; } - public static function detect_uri(): string + /** + * Определяет URI через PATH_INFO → REQUEST_URI → PHP_SELF. + * + * @return string URI без ведущего/замыкающего слэша + */ + public static function detectUri(): string { if (!empty($_SERVER['PATH_INFO'])) { $uri = $_SERVER['PATH_INFO']; @@ -79,6 +106,11 @@ class Request implements HTTPRequest return trim($uri, '/'); } + /** + * Подключает контроллер и вызывает {action}Action(). При отсутствии — 404. + * + * @return Response + */ public function execute(): Response { $method = lcfirst($this->_action) . 'Action'; @@ -86,7 +118,7 @@ class Request implements HTTPRequest $file = DOCROOT . '/App/Controller/' . implode('/', array_map('ucfirst', $parts)) . 'Controller.php'; if (!file_exists($file)) { - return HTTPException::factory(404)->get_response(); + return HTTPException::factory(404)->getResponse(); } require_once $file; @@ -94,12 +126,18 @@ class Request implements HTTPRequest $class = 'App\\Controller\\' . implode('\\', array_map('ucfirst', $parts)) . 'Controller'; if (!method_exists($class, $method)) { - return HTTPException::factory(404)->get_response(); + return HTTPException::factory(404)->getResponse(); } return (new Response())->body((new $class())->$method()); } + /** + * Геттер/сеттер HTTP-метода. + * + * @param string|null $method Новый метод или null для чтения + * @return string|static + */ public function method(?string $method = null): string|static { if ($method === null) { @@ -109,6 +147,13 @@ class Request implements HTTPRequest return $this; } + /** + * Геттер/сеттер POST-данных. + * + * @param string|array|null $key Массив (замена всех), ключ (чтение/запись) или null (все) + * @param mixed $value Значение при записи по ключу + * @return array|string|static|null + */ public function post(null|string|array $key = null, mixed $value = null): null|array|string|static { if (is_array($key)) { @@ -125,6 +170,13 @@ class Request implements HTTPRequest return $this; } + /** + * Геттер/сеттер GET-параметров. + * + * @param string|array|null $key Массив (замена всех), ключ (чтение/запись) или null (все) + * @param mixed $value Значение при записи по ключу + * @return array|string|static|null + */ public function query(null|string|array $key = null, mixed $value = null): null|array|string|static { if (is_array($key)) { @@ -141,6 +193,12 @@ class Request implements HTTPRequest return $this; } + /** + * Геттер/сеттер тела запроса. + * + * @param string|null $content Новое тело или null для чтения + * @return string|static + */ public function body(?string $content = null): string|static { if ($content === null) { @@ -150,7 +208,13 @@ class Request implements HTTPRequest return $this; } - public function requested_with(?string $value = null): string|static + /** + * Геттер/сеттер заголовка X-Requested-With. + * + * @param string|null $value Новое значение или null для чтения + * @return string|static + */ + public function requestedWith(?string $value = null): string|static { if ($value === null) { return $this->_requested_with; @@ -159,16 +223,32 @@ class Request implements HTTPRequest return $this; } - public function is_ajax(): bool + /** + * Проверяет, является ли запрос AJAX (X-Requested-With: XMLHttpRequest). + * + * @return bool + */ + public function isAjax(): bool { return $this->_requested_with === 'xmlhttprequest'; } + /** @return string Имя контроллера */ public function controller(): string { return $this->_controller; } + /** @return string Имя действия */ public function action(): string { return $this->_action; } + /** @return array Позиционные параметры */ public function params(): array { return $this->_params; } + /** @return string Исходный URI */ public function uri(): string { return $this->_uri; } + /** + * Возвращает позиционный параметр по индексу. + * + * @param int $index Индекс параметра + * @param mixed $default Значение по умолчанию + * @return mixed + */ public function param(int $index, mixed $default = null): mixed { return $this->_params[$index] ?? $default; diff --git a/System/Classes/Response.php b/System/Classes/Response.php index 5574a7f..022ce18 100644 --- a/System/Classes/Response.php +++ b/System/Classes/Response.php @@ -8,16 +8,30 @@ namespace System\Classes; +/** + * HTTP-ответ приложения: содержит тело и статус-код. + */ class Response { + /** @var int HTTP-статус ответа */ private int $_status = 200; + /** @var string Тело ответа */ private string $_body = ''; + /** + * @param int $status Начальный HTTP-статус + */ public function __construct(int $status = 200) { $this->_status = $status; } + /** + * Геттер/сеттер тела ответа. + * + * @param string|null $content Новое тело или null для чтения + * @return string|static Тело при чтении, $this при записи + */ public function body(?string $content = null): string|static { if ($content === null) { @@ -27,6 +41,12 @@ class Response return $this; } + /** + * Геттер/сеттер статус-кода. При установке вызывает http_response_code(). + * + * @param int|null $code Новый код или null для чтения + * @return int|static Код при чтении, $this при записи + */ public function status(?int $code = null): int|static { if ($code === null) { diff --git a/System/Classes/Route.php b/System/Classes/Route.php index 65a4093..5299bf3 100644 --- a/System/Classes/Route.php +++ b/System/Classes/Route.php @@ -8,6 +8,10 @@ namespace System\Classes; +/** + * Автоматический роутинг без конфигурации: разбирает URI и находит + * контроллер на файловой системе в App/Controller (регистронезависимо). + */ class Route { /** @@ -19,6 +23,9 @@ class Route * /controller/action → Controller::action * /controller/action/p1/p2 → Controller::action + params * /subdir/controller/action → Subdir/Controller::action (если subdir — папка в App/Controller) + * + * @param string $uri URI без ведущего/замыкающего слэша + * @return array{controller:string, action:string, params:array} */ public static function resolve(string $uri): array { @@ -38,11 +45,11 @@ class Route ); } - $subdirController = self::findController($parts[1], $parts[0]); + $subdir_controller = self::findController($parts[1], $parts[0]); - if ($subdirController !== null) { + if ($subdir_controller !== null) { return self::make( - $subdirController, + $subdir_controller, $parts[2] ?? 'index', array_slice($parts, 3) ); @@ -59,6 +66,10 @@ class Route /** * Ищет файл {name}Controller.php в App/Classes[/subdir] без учёта регистра. * Возвращает имя контроллера (или subdir/имя) без суффикса Controller, либо null. + * + * @param string $name Базовое имя контроллера из URI + * @param string $subdir Имя подкаталога или '' для плоского поиска + * @return string|null */ private static function findController(string $name, string $subdir): ?string { @@ -101,6 +112,14 @@ class Route return null; } + /** + * Собирает результат роутинга в единый формат. + * + * @param string $controller Имя контроллера (возможно с subdir через /) + * @param string $action Имя действия без суффикса Action + * @param array $params Позиционные параметры из URI + * @return array{controller:string, action:string, params:array} + */ private static function make(string $controller, string $action, array $params): array { return [ diff --git a/System/Classes/Session.php b/System/Classes/Session.php index 2c0b1af..0f312b5 100644 --- a/System/Classes/Session.php +++ b/System/Classes/Session.php @@ -8,15 +8,27 @@ namespace System\Classes; +/** + * Синглтон сессии с поддержкой именованных сессий. + */ class Session { + /** @var array Пул экземпляров по имени сессии */ private static array $instances = []; + /** @var bool Была ли сессия уничтожена */ protected bool $_destroyed = false; + /** @var string Имя сессии */ protected string $_name = 'main'; + /** @var int Время жизни cookie сессии в секундах */ protected int $_lifetime = 0; + /** @var array Ссылка на данные сессии ($_SESSION) */ protected array $_data = []; + /** + * @param string $name Имя сессии + * @param array $config Параметры cookie (lifetime, secure, httponly, samesite) + */ private function __construct(string $name, array $config) { $this->_name = $name; @@ -36,6 +48,12 @@ class Session $this->read(); } + /** + * Возвращает (создавая при необходимости) именованный экземпляр сессии. + * + * @param string $name Имя сессии (регистронезависимо) + * @return static + */ public static function instance(string $name = 'main'): static { $name = strtolower($name); @@ -47,6 +65,11 @@ class Session return self::$instances[$name]; } + /** + * Запускает сессию, проверяет TTL и связывает $_data с $_SESSION. + * + * @return void + */ public function read(): void { session_cache_limiter(false); @@ -63,23 +86,42 @@ class Session $this->_data = &$_SESSION; } + /** + * @param string $key Ключ + * @param mixed $default Значение по умолчанию + * @return mixed + */ public function get(string $key, mixed $default = null): mixed { return array_key_exists($key, $this->_data) ? $this->_data[$key] : $default; } + /** + * @param string $key Ключ + * @param mixed $value Значение + * @return static + */ public function set(string $key, mixed $value): static { $this->_data[$key] = $value; return $this; } + /** + * @param string $key Ключ + * @return static + */ public function delete(string $key): static { unset($this->_data[$key]); return $this; } + /** + * Уничтожает сессию и связанные cookie. + * + * @return bool true, если сессия уничтожена + */ public function destroy(): bool { if (!$this->_destroyed) { @@ -96,6 +138,11 @@ class Session return $this->_destroyed; } + /** + * Генерирует новый session_id, сохраняя данные. + * + * @return false|string Новый идентификатор или false + */ public function regenerate(): false|string { session_regenerate_id(true); @@ -104,6 +151,11 @@ class Session return $new_id; } + /** + * Записывает и закрывает сессию. + * + * @return bool false, если сессия уничтожена или заголовки уже отправлены + */ public function write(): bool { if ($this->_destroyed || headers_sent()) { @@ -114,22 +166,40 @@ class Session return true; } + /** + * Привязывает ключ сессии к переменной по ссылке. + * + * @param string $key Ключ + * @param mixed $value Переменная по ссылке + * @return static + */ public function bind(string $key, mixed &$value): static { $this->_data[$key] = &$value; return $this; } + /** + * @return false|string Текущий session_id + */ public function id(): false|string { return session_id(); } + /** + * @return array Все данные сессии + */ public function getData(): array { return $this->_data; } + /** + * Возвращает (создавая при необходимости) хэш устройства из cookie. + * + * @return string + */ public static function getDeviceHash(): string { $hash = Cookie::get('device_hash'); diff --git a/System/Classes/View.php b/System/Classes/View.php index 3291d4b..58c6586 100644 --- a/System/Classes/View.php +++ b/System/Classes/View.php @@ -10,23 +10,26 @@ namespace System\Classes; use Throwable; +/** + * Рендеринг .html-шаблонов через ob_start + extract + include. + */ class View { /** - * @var string + * @var string Полный путь к найденному файлу шаблона */ public string $_file; /** - * @var array + * @var array Данные, доступные внутри шаблона */ public array $_data = []; /** - * @param string|null $file - * @param string $dir - * @param array|null $data + * @param string|null $file Имя файла шаблона без расширения + * @param string $dir Каталог шаблона относительно базовых путей + * @param array|null $data Данные для шаблона * @throws MyException */ public function __construct(?string $file = null, string $dir = '', ?array $data = null) @@ -42,10 +45,12 @@ class View } /** - * @param string $file - * @param string $dir + * Устанавливает файл шаблона, проверяя его существование. + * + * @param string $file Имя файла шаблона без расширения + * @param string $dir Каталог шаблона (обязателен) * @return View - * @throws MyException + * @throws MyException Если $dir пуст или файл не найден */ public function setFilename(string $file, string $dir = ''): View { @@ -64,7 +69,10 @@ class View } /** - * @throws MyException + * Выполняет шаблон и возвращает результат как строку. + * + * @return string Отрендеренный HTML + * @throws MyException Если файл не установлен или ошибка при рендеринге */ public function render(): string { @@ -87,4 +95,4 @@ class View ); } } -} \ No newline at end of file +} diff --git a/System/view/exception/error.html b/System/view/exception/error.html index f379e9b..3d18842 100644 --- a/System/view/exception/error.html +++ b/System/view/exception/error.html @@ -1,6 +1,6 @@ get_response(); + $response = $e->getResponse(); $this->assertInstanceOf(Response::class, $response); $this->assertSame(404, $response->status()); } @@ -54,7 +54,7 @@ class HTTPExceptionTest extends TestCase public function testGetResponseReturns403Response(): void { $e = HTTPException::factory(403); - $response = $e->get_response(); + $response = $e->getResponse(); $this->assertInstanceOf(Response::class, $response); $this->assertSame(403, $response->status()); } @@ -62,7 +62,7 @@ class HTTPExceptionTest extends TestCase public function testGetResponseBodyContainsHtml(): void { $e = HTTPException::factory(404); - $body = $e->get_response()->body(); + $body = $e->getResponse()->body(); $this->assertStringContainsString('404', $body); } diff --git a/tests/Unit/RequestTest.php b/tests/Unit/RequestTest.php index b98b408..38be8cd 100644 --- a/tests/Unit/RequestTest.php +++ b/tests/Unit/RequestTest.php @@ -173,7 +173,7 @@ class RequestTest extends TestCase { $_SERVER['REQUEST_URI'] = '/'; $req = Request::factory(); - $this->assertFalse($req->is_ajax()); + $this->assertFalse($req->isAjax()); } public function testIsAjaxTrueWhenHeaderSet(): void @@ -181,7 +181,7 @@ class RequestTest extends TestCase $_SERVER['REQUEST_URI'] = '/'; $_SERVER['HTTP_X_REQUESTED_WITH'] = 'XMLHttpRequest'; $req = Request::factory(); - $this->assertTrue($req->is_ajax()); + $this->assertTrue($req->isAjax()); } public function testMethodSetter(): void diff --git a/tools/sort_html_attrs.php b/tools/sort_html_attrs.php new file mode 100755 index 0000000..c97fa81 --- /dev/null +++ b/tools/sort_html_attrs.php @@ -0,0 +1,244 @@ +#!/usr/bin/env php += $n) { // хвостовой сепаратор + if ($sep !== '') { + $tokens[] = ['sep' => $sep, 'attr' => '', 'name' => null]; + } + break; + } + + // имя атрибута + $name_start = $i; + while ($i < $n && preg_match('/[^\s=>\/]/u', $s[$i])) { + $i++; + } + $raw_name = substr($s, $name_start, $i - $name_start); + if ($raw_name === '') { + // мусорный символ — сохранить как часть sep + $sep .= $s[$i]; + $i++; + $tokens[] = ['sep' => $sep, 'attr' => '', 'name' => null]; + continue; + } + $name_lc = strtolower($raw_name); + + // пробелы до '=' + $probe = $i; + while ($probe < $n && preg_match('/\s/u', $s[$probe])) { + $probe++; + } + + $attr_text = $raw_name; + $i = $probe; + + if ($i < $n && $s[$i] === '=') { + // '=' и пробелы после + $attr_text .= substr($s, $probe, $i - $probe + 1); + $i++; + $sp = $i; + while ($i < $n && preg_match('/\s/u', $s[$i])) { + $i++; + } + $attr_text .= substr($s, $sp, $i - $sp); + + // значение + if ($i < $n && ($s[$i] === '"' || $s[$i] === "'")) { + $q = $s[$i]; + $attr_text .= $q; + $i++; + while ($i < $n) { + $ch = $s[$i]; + $attr_text .= $ch; + $i++; + if ($ch === $q) { + break; + } + } + } else { + $v0 = $i; + while ($i < $n && !preg_match('/[\s>]/u', $s[$i])) { + $i++; + } + $attr_text .= substr($s, $v0, $i - $v0); + } + } + + $tokens[] = ['sep' => $sep, 'attr' => $attr_text, 'name' => $name_lc]; + } + return $tokens; +} + +function SortTokens(array $tokens): array +{ + $prio_exact = ['id', 'class', 'href', 'type', 'name']; + $b = ['exact' => [], 'data' => [], 'aria' => [], 'role' => [], 'rest' => [], 'tail' => []]; + + foreach ($tokens as $t) { + if ($t['name'] === null) { + $b['tail'][] = $t; + continue; + } + $n = $t['name']; + if (in_array($n, $prio_exact, true)) { + $b['exact'][] = $t; + } elseif (str_starts_with($n, 'data-')) { + $b['data'][] = $t; + } elseif (str_starts_with($n, 'aria-')) { + $b['aria'][] = $t; + } elseif ($n === 'role') { + $b['role'][] = $t; + } else { + $b['rest'][] = $t; + } + } + // exact в порядке prio_exact + $exact_sorted = []; + foreach ($prio_exact as $want) { + foreach ($b['exact'] as $t) { + if ($t['name'] === $want) { + $exact_sorted[] = $t; + } + } + } + $ordered = array_merge($exact_sorted, $b['data'], $b['aria'], $b['role'], $b['rest']); + foreach ($b['tail'] as $t) { + $ordered[] = $t; + } // хвостовые сепараторы + return $ordered; +} + +function ReorderTagAttributes(string $head): string +{ + // имя тега + остальное + if (!preg_match('/^([a-zA-Z][\w:-]*)(.*)$/s', $head, $m)) { + return $head; + } + $tag = $m[1]; + $rest = $m[2]; + if ($rest === '') { + return $head; + } + + $tokens = TokenizeAttributes($rest); + if (!$tokens) { + return $head; + } + + $ordered = SortTokens($tokens); + + // собрать обратно без изменения пробелов: имя + (sep+attr)… + $out = $tag; + foreach ($ordered as $t) { + $out .= $t['sep'] . $t['attr']; + } + return $out; +} + +function Process(string $code): string +{ + $out = ''; + $i = 0; + $n = strlen($code); + while ($i < $n) { + // PHP-блок: копируем как есть + if ($i + 1 < $n && $code[$i] === '<' && $code[$i + 1] === '?') { + $start = $i; + $i += 2; + while ($i + 1 < $n && !($code[$i] === '?' && $code[$i + 1] === '>')) { + $i++; + } + $i = min($i + 2, $n); + $out .= substr($code, $start, $i - $start); + continue; + } + // Тег? + if ($code[$i] === '<') { + $tag_start = $i; + $i++; + // комментарии/doctype/закрывающий — копируем + if ($i < $n && ($code[$i] === '!' || $code[$i] === '/')) { + while ($i < $n && $code[$i] !== '>') { + $i++; + } + if ($i < $n) { + $i++; + } + $out .= substr($code, $tag_start, $i - $tag_start); + continue; + } + // обычный тег до '>' + $h0 = $i; + while ($i < $n && $code[$i] !== '>') { + $i++; + } + if ($i >= $n) { + $out .= substr($code, $tag_start); + break; + } + $head = substr($code, $h0, $i - $h0); + $i++; // пропустить '>' + + // переставить атрибуты только в head + $new_head = ReorderTagAttributes($head); + $out .= '<' . $new_head . '>'; + continue; + } + // просто текст + $out .= $code[$i]; + $i++; + } + return $out; +} + +$out = Process($code); + +if ($inplace) { + if (@file_put_contents($file_path, $out) === false) { + fwrite(STDERR, "Cannot write: $file_path\n"); + exit(1); + } + exit(0); +} +echo $out;