This commit is contained in:
Egor Isaev 2026-06-23 10:44:18 +03:00
parent b516ca07dc
commit b65603b4d0
34 changed files with 944 additions and 75 deletions

5
.claude/memory/MEMORY.md Normal file
View File

@ -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 везде

View File

@ -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 в рамках того же ответа. Не выносить обновление в отдельный шаг.

View File

@ -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`

12
.gitignore vendored
View File

@ -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/*

9
.htaccess Normal file
View File

@ -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]

View File

@ -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-телом

View File

@ -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 — все проходят.**

View File

@ -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
{

View File

@ -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();
}
}
}

View File

@ -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]);

View File

@ -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");
}
}
}

View File

@ -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()));

View File

@ -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 = [];

View File

@ -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<string,string> Заголовки запроса */
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<string,string> Заголовки */
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; }
}

View File

@ -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
{

View File

@ -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);

View File

@ -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);

View File

@ -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);

View File

@ -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);

View File

@ -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()) {

View File

@ -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();
}

View File

@ -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);
}

View File

@ -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;
}
}
}
}

View File

@ -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;

View File

@ -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) {

View File

@ -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 [

View File

@ -8,15 +8,27 @@
namespace System\Classes;
/**
* Синглтон сессии с поддержкой именованных сессий.
*/
class Session
{
/** @var array<string,static> Пул экземпляров по имени сессии */
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');

View File

@ -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
);
}
}
}
}

View File

@ -1,6 +1,6 @@
<?php
/**
* @package MMH
* @package Bicycle
* @author Egor Isaev
* @description error.html
* @copyright (c) 12/12/2023

BIN
composer.phar Normal file

Binary file not shown.

View File

@ -24,9 +24,9 @@ setlocale(LC_ALL, 'ru_RU.UTF-8', 'Rus');
require_once DOCROOT . '/vendor/autoload.php';
if (getenv('MMH_ENV'))
if (getenv('APP_ENV'))
{
Core::$environment = Core::getConst(strtoupper(getenv('MMH_ENV')));
Core::$environment = Core::getConst(strtoupper(getenv('APP_ENV')));
}
Core::init();

View File

@ -46,7 +46,7 @@ class HTTPExceptionTest extends TestCase
public function testGetResponseReturns404Response(): void
{
$e = HTTPException::factory(404);
$response = $e->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);
}

View File

@ -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

244
tools/sort_html_attrs.php Executable file
View File

@ -0,0 +1,244 @@
#!/usr/bin/env php
<?php
/**
* @package Bicycle
* @author Egor Isaev
* @description sort_html_attrs.php
* @copyright (c) 23/09/2025
*
*
* Сортирует атрибуты ТОЛЬКО внутри HTML-тегов.
* Не меняет табы/пробелы/переносы/PHP-блоки.
*
* Порядок: id class href type name data-* aria-* role остальные (в исходном порядке).
*
* Использование:
* php tools/sort_html_attrs.php path/to/file.html [--inplace]
*/
if ($argc < 2) {
fwrite(STDERR, "Usage: php sort_html_attrs.php file.[html] [--inplace]\n");
exit(1);
}
$file_path = $argv[1];
$inplace = in_array('--inplace', $argv, true);
$code = @file_get_contents($file_path);
if ($code === false) {
fwrite(STDERR, "Cannot read: $file_path\n");
exit(1);
}
function TokenizeAttributes(string $s): array
{
$tokens = [];
$i = 0;
$n = strlen($s);
while ($i < $n) {
// ведущий разделитель (сохраняем как есть)
$sep_start = $i;
while ($i < $n && preg_match('/\s/u', $s[$i])) {
$i++;
}
$sep = substr($s, $sep_start, $i - $sep_start);
if ($i >= $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;