dev
This commit is contained in:
parent
b516ca07dc
commit
b65603b4d0
5
.claude/memory/MEMORY.md
Normal file
5
.claude/memory/MEMORY.md
Normal 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 везде
|
||||
12
.claude/memory/feedback_update_claude_md.md
Normal file
12
.claude/memory/feedback_update_claude_md.md
Normal 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 в рамках того же ответа. Не выносить обновление в отдельный шаг.
|
||||
49
.claude/memory/project_bicycle.md
Normal file
49
.claude/memory/project_bicycle.md
Normal 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
12
.gitignore
vendored
@ -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
9
.htaccess
Normal 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]
|
||||
@ -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-телом
|
||||
|
||||
40
CLAUDE.md
40
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 — все проходят.**
|
||||
|
||||
|
||||
@ -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
|
||||
{
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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]);
|
||||
|
||||
@ -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,6 +125,12 @@ class Core
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает значение константы класса по её имени.
|
||||
*
|
||||
* @param string $name Имя константы (например, 'PRODUCTION')
|
||||
* @return mixed
|
||||
*/
|
||||
public static function getConst(string $name): mixed
|
||||
{
|
||||
return constant("self::$name");
|
||||
|
||||
@ -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()));
|
||||
|
||||
@ -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 = [];
|
||||
|
||||
@ -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; }
|
||||
}
|
||||
|
||||
@ -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
|
||||
{
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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()) {
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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 [
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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
|
||||
{
|
||||
|
||||
@ -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
BIN
composer.phar
Normal file
Binary file not shown.
@ -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();
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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
244
tools/sort_html_attrs.php
Executable 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;
|
||||
Loading…
Reference in New Issue
Block a user