build(frontend): убрать неподдерживаемый плагин иконок и EOL-линтеры, audit до нуля

vite-plugin-svg-icons не обновлялся с 2022 года и был единственным источником
половины оставшихся предупреждений: svgo 2.8, postcss 5.2.18, braces 2.3.2 и
image-size 0.5.5 — у последней advisory прямо сообщает `Patched versions:
<0.0.0`, то есть исправленной версии не существует.

Задача, которую он решал, заняла один модуль: import.meta.glob собирает
семнадцать локальных SVG в скрытый спрайт со <symbol>. Компонентный подход
(unplugin-icons) здесь не подходит — имя иконки часто вычисляется в рантайме
(onlyOneChild.meta.icon, isFullscreen ? ... : ...), а поиск по id нужен именно
для этого. API <svg-icon icon-class="..."> не изменился.

Проверка на реальных ассетах поймала то, что иначе уехало бы в релиз: три
иконки из семнадцати не объявляют viewBox, задавая только width/height. Без
viewBox <use> рисует иконку в натуральную величину и обрезает её. Плагин
синтезировал viewBox сам; теперь это делает sprite.ts, а инвариант закреплён
приёмкой — иконка без обоих способов задать координаты роняет сборку.

Отдельно: typecheck поймал две ошибки уже в самом sprite.ts (SVGSVGElement
против HTMLElement из getElementById). Ровно то, ради чего он возвращался.

eslint 8 объявлен EOL и оставался вторым источником предупреждений. Переход на
eslint 10 потребовал flat config: eslintrc в 9 работает только через переменную
окружения, а в 10 удалён, поэтому обновление версии без смены формата было бы
отсрочкой на релиз. Набор правил сохранён прежним; .eslintignore свёрнут в
ignores, как требует новый формат.

Проверено, что flat config действительно линтит, а не молча пропускает: пробный
файл с неиспользуемой переменной и необъявленным именем даёт обе ошибки и в
.ts, и в .vue.

Оставшиеся предупреждения приходили из графов самих eslint 10 и stylelint 17,
то есть уже последних версий, — закрыты pnpm.overrides. Override для ajv
ограничен `table>ajv`: глобальный ломал eslint, который использует ajv 6.

pnpm audit по всему графу: 33 предупреждения -> 0.
typecheck/eslint/stylelint/build: 0. go build и go test с встроенным dist: OK.
This commit is contained in:
2026-08-30 07:41:31 +05:00
parent ddd1020a3a
commit 32ff47731c
9 changed files with 567 additions and 1687 deletions
@@ -0,0 +1,134 @@
/**
* Спрайт локальных SVG-иконок.
*
* Заменяет `vite-plugin-svg-icons` и его `virtual:svg-icons-register`.
*
* Почему плагин убран. Он не обновлялся с 2022 года и тянул за собой цепочку,
* которую починить нельзя: `svgo@2.8.0`, `postcss@5.2.18`, `braces@2.3.2` и
* `image-size@0.5.5` — у последней в advisory прямо указано `Patched versions:
* <0.0.0`, то есть исправленной версии не существует. Один build-плагин
* оставался единственным источником половины оставшихся предупреждений
* `pnpm audit` во всём проекте.
*
* Задача, которую он решал, здесь занимает сорок строк: собрать семнадцать
* файлов в один скрытый `<svg>` со `<symbol>` внутри, чтобы `<use href="#...">`
* находил их по имени. Имя часто вычисляется в рантайме
* (`onlyOneChild.meta.icon`, `isFullscreen ? 'exit-fullscreen' : 'fullscreen'`),
* поэтому подход «компонент на иконку», как у unplugin-icons, здесь не
* подходит — спрайт с поиском по id нужен именно для этого.
*
* Оптимизация через SVGO при этом потеряна. Для семнадцати вручную отобранных
* иконок это несколько килобайт, и они не стоят неисправимой зависимости в
* сборке.
*/
const SYMBOL_PREFIX = "icon";
const SPRITE_ELEMENT_ID = "__hy2xs_svg_sprite__";
// eager: файлы читаются на этапе сборки и попадают в бандл строками, сетевых
// запросов за иконками не возникает.
const sources = import.meta.glob<string>("@/assets/icons/*.svg", {
query: "?raw",
import: "default",
eager: true,
});
function iconName(filePath: string): string {
return filePath.replace(/^.*\//, "").replace(/\.svg$/, "");
}
/**
* Превращает содержимое файла в `<symbol>`.
*
* Отбрасываются XML-пролог и DOCTYPE: внутри уже существующего документа они
* не только бесполезны, но и делают разметку невалидной. `width` и `height`
* тоже отбрасываются — размер задаёт компонент.
*
* `viewBox` обязателен: без него `<use>` не знает систему координат иконки и
* рисует её в натуральную величину, обрезая по размеру родительского `<svg>`.
* Три иконки из семнадцати (eye, fullscreen, exit-fullscreen) его не имеют и
* задают только width/height, поэтому viewBox для них синтезируется — ровно
* так же, как это делал заменённый плагин.
*/
function toSymbol(raw: string, name: string): string {
const withoutProlog = raw
.replace(/<\?xml[\s\S]*?\?>/gi, "")
.replace(/<!DOCTYPE[\s\S]*?>/gi, "")
.replace(/<!--[\s\S]*?-->/g, "")
.trim();
const openTag = withoutProlog.match(/<svg\b[^>]*>/i);
if (!openTag) {
return "";
}
const body = withoutProlog
.replace(/^<svg\b[^>]*>/i, "")
.replace(/<\/svg>\s*$/i, "");
const viewBoxAttr = resolveViewBox(openTag[0]);
return `<symbol id="${SYMBOL_PREFIX}-${name}"${viewBoxAttr}>${body}</symbol>`;
}
function resolveViewBox(openTag: string): string {
const declared = openTag.match(/viewBox="([^"]+)"/i);
if (declared) {
return ` viewBox="${declared[1]}"`;
}
const width = numericAttribute(openTag, "width");
const height = numericAttribute(openTag, "height");
if (width !== null && height !== null) {
return ` viewBox="0 0 ${width} ${height}"`;
}
return "";
}
/** Читает размер, игнорируя единицы измерения: `128`, `128px`, `128pt`. */
function numericAttribute(openTag: string, name: string): number | null {
const match = openTag.match(new RegExp(`${name}="([\\d.]+)[a-z%]*"`, "i"));
if (!match) {
return null;
}
const value = Number.parseFloat(match[1]);
return Number.isFinite(value) && value > 0 ? value : null;
}
/**
* Вставляет спрайт в документ. Идемпотентна: повторный вызов заменяет
* содержимое, а не добавляет второй элемент с теми же id.
*/
export function registerSvgIcons(): void {
if (typeof document === "undefined") {
return;
}
const symbols = Object.entries(sources)
.map(([filePath, raw]) => toSymbol(raw, iconName(filePath)))
.filter(Boolean)
.join("");
// Тип уточняется явно: getElementById возвращает HTMLElement | null, а
// спрайт — SVGSVGElement, и без этого он не примет ни собственный
// createElementNS, ни последующие обращения.
let sprite = document.getElementById(
SPRITE_ELEMENT_ID
) as SVGSVGElement | null;
if (!sprite) {
sprite = document.createElementNS("http://www.w3.org/2000/svg", "svg");
sprite.id = SPRITE_ELEMENT_ID;
sprite.setAttribute("aria-hidden", "true");
// Скрываем так же, как это делал плагин: элемент обязан остаться в
// документе, иначе `<use>` не найдёт symbol, поэтому display:none не
// подходит — нужен нулевой размер.
sprite.setAttribute(
"style",
"position:absolute;width:0;height:0;overflow:hidden"
);
document.body.insertBefore(sprite, document.body.firstChild);
}
sprite.innerHTML = symbols;
}
+3 -1
View File
@@ -7,7 +7,7 @@ import { setupDirective } from "@/directive";
import "@/permission";
// Локальные SVG-иконки
import "virtual:svg-icons-register";
import { registerSvgIcons } from "@/components/SvgIcon/sprite";
// Локализация
import i18n from "@/lang/index";
@@ -16,6 +16,8 @@ import i18n from "@/lang/index";
import "@/styles/index.scss";
import "uno.css";
registerSvgIcons();
const app = createApp(App);
// Глобальная регистрация пользовательских директив
setupDirective(app);