245 lines
6.9 KiB
PHP
Executable File
245 lines
6.9 KiB
PHP
Executable File
#!/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;
|