Два пути к SVG-спрайту в Iconly

11 Июл 2026

После 3.0 у iconly появился довольно понятный контракт загрузки: createIconly() проверяет кэш, при необходимости получает внешний SVG-файл и вставляет его в DOM. Но в этой схеме всё ещё зашито одно предположение: источник иконок — обязательно готовый файл со спрайтом.

Для части проектов это удобно. Для другой части иконки уже живут как ESM-модули рядом с остальным кодом, и собирать из них отдельный файл только затем, чтобы iconly снова его загрузил, выглядит лишним кругом. Поэтому в 4.1.0 я добавил второй публичный путь — iconly/sprite. Он принимает уже импортированные объекты иконок, собирает из них SVG и при необходимости вставляет его в тот же DOM-контейнер.

Через несколько дней, в 4.2.0, я отдельно занялся другой общей границей: любая строка SVG, которую библиотека собирается добавить в DOM страницы, должна проходить хотя бы базовую защитную обработку. При этом встроенную очистку я сознательно не называю полноценной санитизацией и оставляю хук для специализированной обработки.

Перед вторым входом в пакет

В 4.0.0 iconly заодно переехал на общий для портфолио стек сборки и проверки: tsdown, ESM/CJS, smoke-тесты и проверка tarball, публикация через Trusted Publishing. Подробно описывать этот инфраструктурный переход здесь не буду. Для следующего изменения важна одна вещь: пакет уже проверяет не только корневой импорт, поэтому второй публичный подпуть можно добавить как полноценную часть exports, типов и smoke-тестов.

Внешний файл больше не единственный источник

До 4.1 обычный сценарий остаётся таким:

import { createIconly } from 'iconly';

const iconLoader = createIconly({
  file: './sprite.svg',
  version: '1.0',
  storage: 'indexeddb',
});

const result = await iconLoader.init();

Здесь iconly занимается всем жизненным циклом внешнего ресурса: кэшем, загрузкой, разбором SVG и вставкой в DOM.

Но если в приложении иконки уже представлены модулями, мне нужен другой уровень API. Не загрузчик, а небольшой сборщик, которому можно передать только нужные иконки:

import { createSprite } from 'iconly/sprite';
import { search, user, trash } from './icons';

const sprite = createSprite({
  icons: [search, user, trash],
  container: '#app',
});

const result = sprite.render();

if (!result.ok) {
  console.error(result.error);
}

Формат одной иконки намеренно маленький:

export interface IconlyIcon {
  name: string;
  viewBox: string;
  body: string;
}

name становится id у <symbol>, viewBox переносится в одноимённый SVG-атрибут, body остаётся содержимым <symbol>.

Новый путь вообще не использует сеть и хранилище. Он работает с данными, которые приложение уже импортировало.

Отдельный подпуть вместо расширения основного импорта

Я не добавляю сборщик спрайта в корневой импорт. Для него появляется отдельный подпуть:

import { createSprite, buildSpriteString } from 'iconly/sprite';

В package.json это отдельный экспорт:

{
  "exports": {
    "./sprite": {
      "import": {
        "types": "./dist/sprite.d.ts",
        "default": "./dist/sprite.js"
      },
      "require": {
        "types": "./dist/sprite.d.cts",
        "default": "./dist/sprite.cjs"
      }
    }
  }
}

А tsdown получает вторую точку входа:

export default defineLibrary({
  platform: 'browser',
  entry: {
    index: 'src/index.ts',
    sprite: 'src/sprite.ts',
  },
});

Это позволяет не смешивать две модели использования. iconly остаётся API загрузки внешнего файла. iconly/sprite — API сборки спрайта из уже имеющихся объектов иконок.

Заодно smoke-тесты проверяют четыре новых артефакта: ESM, CJS и оба файла типов. После работы в 4.0 добавление нового подпути уже не заканчивается на “локально импортируется”.

buildSpriteString не требует DOM

Второй API внутри подпути ещё проще:

import { buildSpriteString } from 'iconly/sprite';

const svg = buildSpriteString([search, user, trash]);

Реализация почти буквально собирает <symbol>:

export const buildSpriteString = (icons: IconlyIcon[]): string => {
  const symbols = icons
    .map(
      (icon) =>
        `<symbol id="${escapeAttr(icon.name)}" viewBox="${escapeAttr(icon.viewBox)}">${icon.body}</symbol>`,
    )
    .join('');

  return `<svg xmlns="http://www.w3.org/2000/svg">${symbols}</svg>`;
};

Здесь нет обращения к document, сети или хранилищу. Поэтому функцию можно использовать там, где нужен только результат строкой, например в SSR или в тесте.

Пустой массив тоже имеет определённый результат:

<svg xmlns="http://www.w3.org/2000/svg"></svg>

Для name и viewBox сборщик экранирует специальные символы. body при этом не превращается в текст, потому что это и есть SVG-разметка конкретной иконки.

Tree-shaking начинается до iconly

Одна из причин держать этот путь на уровне объектов иконок — он естественно сочетается с ESM-импортами:

import { search, user } from './icons';

buildSpriteString([search, user]);

Но здесь важно точно разделять ответственность. Сам iconly не запускает tree-shaking и не анализирует каталог иконок. Он получает уже выбранный массив.

Удалить неиспользованные экспорты может сборщик приложения, если пакет иконок организован подходящим образом. Задача iconly/sprite гораздо проще: не заставлять потребителя сначала собирать общий внешний файл спрайта, если нужные объекты иконок уже находятся в графе модулей.

Два пути используют одну вставку в DOM

При добавлении createSprite() я не хочу получить вторую реализацию вставки SVG.

В 4.1 resolveContainer() переезжает из core.ts в dom.ts, теперь его используют оба сценария. Сам createSprite() заканчивается тем же insertSvg(), что и загрузчик:

return insertSvg(
  containerResult.value,
  buildSpriteString(icons),
);

Получаются две разные цепочки до DOM:

createIconly()
  кэш -> fetch -> строка SVG
                    |
                    v
                 insertSvg()

createSprite()
  объекты иконок -> buildSpriteString()
                    |
                    v
                 insertSvg()

Для меня это важнее, чем просто вынести ещё пару функций в отдельный файл. Источник данных может быть разным, но правила выбора контейнера, разбора SVG и вставки не должны расходиться без причины.

Перед вставкой SVG нужна отдельная граница

В 2.0 я уже ушёл от insertAdjacentHTML к DOMParser, но разбор сам по себе не делает входной SVG безопасным. К 4.2.0 я добавил отдельную обработку перед тем, как разобранный элемент попадёт в DOM страницы.

Теперь insertSvg() принимает необязательные параметры:

export interface InsertSvgOptions {
  sanitize?: SvgSanitizer;
}

Порядок такой:

SVG string
  -> пользовательская функция sanitize, если задана
  -> DOMParser
  -> встроенная защитная обработка SVG
  -> document.importNode()
  -> DOM страницы

Один и тот же путь используется и для внешнего файла, и для createSprite().render().

Публично sanitize подключается одинаково:

const iconLoader = createIconly({
  file: './sprite.svg',
  sanitize,
});
const sprite = createSprite({
  icons: [search, user],
  sanitize,
});

Пользовательская функция вызывается до разбора SVG. Это оставляет возможность подключить специализированный санитайзер или собственные правила обработки строки, не зашивая стороннюю зависимость в iconly.

Что делает встроенная защитная обработка

После разбора библиотека рекурсивно проходит дерево SVG.

В текущей версии удаляются:

  • <script>
  • <foreignObject>
  • атрибуты вида onerror, onclick и другие обработчики on*
  • href и xlink:href со схемами javascript: и data:text/html
  • элементы SMIL-анимации, которые пытаются менять href или xlink:href.

Последний пункт сделан не как запрет всей SVG-анимации. Например, animate для opacity остаётся допустимым. Тесты отдельно фиксируют это различие.

Есть и интеграционные тесты для обоих публичных сценариев. Внешний SVG с onerror проходит через createIconly(), body иконки с тем же атрибутом — через createSprite(). В обоих случаях обработчик должен исчезнуть до вставки.

То есть защитная обработка привязана не к тому, откуда пришёл SVG, а к общей операции “сейчас эта структура попадёт в DOM”.

Это не полноценный санитайзер

Здесь я не хочу “переобещать”.

Встроенный проход закрывает несколько очевидных опасных конструкций, но не пытается реализовать полноценный санитайзер для всего пространства SVG/HTML. В README я прямо оставил примеры того, что он не покрывает полностью: CSS-векторы внутри <style> и внешние ссылки в <use> или <image>.

Поэтому для недоверенного содержимого есть хук sanitize. Например, туда можно подключить специализированную библиотеку, а встроенный проход оставить дополнительным слоем перед вставкой.

Есть ещё одна граница: buildSpriteString() возвращает строку как есть и вообще не касается DOM. Поэтому встроенная защитная обработка там не выполняется.

const svg = buildSpriteString(untrustedIcons);

Если такую строку дальше использовать самостоятельно, ответственность за очистку остаётся у вызывающего кода. В 4.2 сборщик дополнительно экранирует > в name и viewBox, но icon.body по-прежнему остаётся SVG-разметкой.