У инженеров, которые проектируют электронные устройства, документация в виде PDF пользуется большой популярностью. Вспомним, к примеру, даташиты к электронным компонентам. Поэтому и наши САПР поставляются с комплектом PDF-файлов, которые рассказывают про особенности установки и эксплуатации. Долгое время документация выпускалась только так: вместе с каждым релизом продукта в виде набора PDF. Это было устроено не из соображений удобства, а в силу ограничений самого процесса, и со временем эти ограничения стали ощутимы.
В этой статье мы расскажем, как развивали систему документации, сохранив за техническими писателями привычный инструмент, какие трудности возникли с производительностью генератора сайта и как в итоге появился портал docs.eremex.ru. При этом привычный инженерам формат PDF мы сохранили: новый портал не заменяет его, а дополняет, и документация по-прежнему доступна в виде файлов для тех, кому так удобнее.
Как было: документация как часть релиза, а не как сервис
Help&Manual представляет собой нишевый, но довольно популярный среди технических писателей инструмент для авторинга: WYSIWYG-редактор, единый проект, экспорт в разные форматы. Мы использовали его много лет и на выходе получали документацию в виде набора PDF-файлов, по одному или нескольким файлам на продукт.
Формат, который десять лет назад выглядел разумным выбором, со временем начал создавать всё больше проблем:
Поиск внутри PDF неудобен. Стандартный поиск по Ctrl+F работает только внутри одного файла и плохо справляется даже с этим, особенно когда документ разрастается до сотен страниц.
PDF оставался невидимым для поисковых систем и нейросетей. Пользователь, искавший решение проблемы с нашим продуктом, не находил наших же документов: поисковая выдача показывала форумы и сторонние обсуждения. По той же причине ни один AI-ассистент не располагал сведениями о наших продуктах, поскольку содержимое статичных PDF-файлов поисковые системы индексируют неохотно.
Документация обновлялась только вместе с релизом продукта. Процесс был жёстко связан: до релиза изменения в документацию не вносились, после релиза она оставалась неизменной до следующего выпуска. Если ошибку в статье обнаруживали через неделю после выхода версии, исправление откладывалось на месяцы.
Стало очевидно, что проблема не в Help&Manual как редакторе: писатели к нему привыкли, и переучивать их не было необходимости. Проблема состояла в том, каким образом контент превращался в готовую документацию и доходил до пользователя.
Шаг первый: автоматический выход из Help&Manual
Главным условием было сохранить процесс работы технических писателей в неизменном виде. Help&Manual хранит каждую статью в виде XML-файла, а значит, контент можно трансформировать программно, без участия человека.
Так появился конвертер, представляющий собой консольное приложение на .NET Core, которое мы написали с использованием XSLT-трансформаций. На входе он принимает XML-файлы Help&Manual, на выходе формирует Markdown. Перенос всех статей оказался полностью автоматическим: ни одна из более чем двух тысяч статей не переписывалась вручную.
Сейчас портал насчитывает около двух с половиной тысяч статей, и все они продолжают создаваться и обновляться в прежнем редакторе.
Для сборки сайта из получившегося Markdown был выбран MkDocs, на тот момент наиболее очевидный и проверенный вариант для документации в формате docs-as-code. Некоторое время эта схема работала исправно.
Как устроен конвертер изнутри
Рассмотрим техническую сторону подробнее.
Запуск XSLT из C#
XSLT-файл загружается из ресурсов сборки, после чего трансформация выполняется через XslCompiledTransform, который компилирует XSLT в байт-код при загрузке, что обеспечивает высокую скорость при обработке большого количества файлов. Каждый XML-файл из Help&Manual читается через XmlReader и записывается через XmlWriter в кодировке UTF-8 без BOM. Последнее существенно: MkDocs и Zensical чувствительны к наличию BOM в файлах.
XslCompiledTransform transformer = new XslCompiledTransform();
transformer.Load(xsltFile);
var utf8NoBom = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false);
XmlWriterSettings settings = new XmlWriterSettings
{
ConformanceLevel = ConformanceLevel.Fragment,
OmitXmlDeclaration = true,
Encoding = utf8NoBom
};
using (XmlReader xmlReader = XmlReader.Create(stringReader))
using (XmlWriter writer = XmlWriter.Create(targetFilePath, settings))
{
transformer.Transform(xmlReader, CreateArguments(...), writer);
}
Extension-объекты: связь между C# и XSLT
Чистый XSLT 1.0 не хранит состояние между вызовами шаблонов, а нам требовалась сквозная нумерация рисунков и таблиц по всей статье. Решением стали extension-объекты, то есть обычные C#-классы, которые регистрируются в XsltArgumentList и становятся доступны из XSLT по пространству имён.
private static XsltArgumentList CreateArguments(...)
{
XsltArgumentList args = new XsltArgumentList();
// Счётчик номеров рисунков
MutableCounter imageCounter = new MutableCounter();
args.AddExtensionObject("urn:xslt-extensions", imageCounter);
// Счётчик номеров таблиц
MutableCounter tablesCounter = new MutableCounter();
args.AddExtensionObject("urn:xslt-extensions-tables", tablesCounter);
// Транслитератор имён файлов рисунков
RussianToEnglishConverter rus = new RussianToEnglishConverter();
rus.Prefix = imagesPrefix; // префикс продукта, например "index"
args.AddExtensionObject("urn:xslt-extensions-images", rus);
// Конвертер имён топиков и резолвер ссылок
TopicNameconverter topic = new TopicNameconverter(sourceDir, dirs);
args.AddExtensionObject("urn:xslt-extensions-files", topic);
return args;
}
В XSLT эти объекты объявляются через xmlns и используются как обычные функции:
<xsl:stylesheet version="1.0"
xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
xmlns:imagecounter="urn:xslt-extensions"
xmlns:tablecounter="urn:xslt-extensions-tables"
xmlns:images="urn:xslt-extensions-images"
xmlns:files="urn:xslt-extensions-files"
exclude-result-prefixes="imagecounter tablecounter images files">
Нумерация рисунков
Это наиболее показательная часть с точки зрения взаимодействия C# и XSLT. Help&Manual использует собственный плейсхолдер <%HMFIGURECOUNTER%> в подписях к рисункам. При конвертации его необходимо заменить на порядковый номер.
Класс MutableCounter намеренно сделан простым: он хранит счётчик и предоставляет два метода.
public class MutableCounter
{
public int Value { get; set; } = 1;
public int Increment()
{
Value++;
return Value;
}
public int GetValue() => Value;
}
В XSLT шаблон обработки рисунка выглядит следующим образом:
<xsl:template match="image">
<!-- Сначала получаем текущее значение счётчика без инкремента,
чтобы определить, есть ли у рисунка подпись -->
<xsl:variable name="currentimagetitle">
<xsl:value-of select="files:ReplaceCounters(caption, imagecounter:GetValue())"/>
</xsl:variable>
<xsl:variable name="imagetarget">
<xsl:value-of select="concat($imagesPath, images:Convert(@src))"/>
</xsl:variable>
<xsl:choose>
<!-- SVG-рисунки обрабатываются отдельно, вставляются как HTML-тег -->
<xsl:when test="contains($imagetarget, 'svg')">
<image src="{$imagetarget}"/>
</xsl:when>
<!-- Рисунок с подписью: инкрементируем счётчик и формируем figure -->
<xsl:when test="$currentimagetitle != ''">
<xsl:variable name="imagetitle">
<xsl:value-of select="files:ReplaceCounters(caption, imagecounter:Increment())"/>
</xsl:variable>
<xsl:text disable-output-escaping="yes"><figure></xsl:text>
<img src="{$imagetarget}" alt="{$imagetitle}"/>
<xsl:text disable-output-escaping="yes"><figcaption></xsl:text>
<xsl:value-of select="$imagetitle"/>
<xsl:text disable-output-escaping="yes"></figcaption></figure></xsl:text>
</xsl:when>
<!-- Рисунок без подписи: простой Markdown-синтаксис -->
<xsl:otherwise>
<xsl:value-of select="concat('')"/>
</xsl:otherwise>
</xsl:choose>
</xsl:template>
Логика построена на двух методах счётчика. Сначала вызывается imagecounter:GetValue(), чтобы проверить наличие подписи, не изменяя счётчик. Если подпись есть, вызывается imagecounter:Increment(), который увеличивает значение и возвращает новое. Благодаря этому счётчик растёт только для пронумерованных рисунков.
Транслитерация имён файлов рисунков
Help&Manual допускает имена файлов рисунков на русском языке, что недопустимо в URL. Поэтому все имена файлов при копировании проходят через транслитератор:
// Кириллица в латиницу, пробелы в подчёркивания
// "Окно настроек.png" в "indexokno_nastroek.png"
var targetFileName = RussianToEnglishConverter.ConvertWithPrefix(
Path.GetFileName(imageFile),
databaseName.Name // префикс продукта, например "index" для Delta Design
);
Префикс продукта добавляется намеренно. У разных продуктов могут встречаться одноимённые файлы рисунков, и без префикса они перезаписывали бы друг друга.
Отдельные шаблоны
Заголовок статьи преобразуется в Markdown-frontmatter с YAML и заголовок уровня #:
<xsl:template match="header">
<xsl:text>--- </xsl:text>
<xsl:text>search: </xsl:text>
<xsl:text> exclude: true </xsl:text>
<xsl:text>--- </xsl:text>
<xsl:value-of select="concat('# ', normalize-space(para/text))"/>
<xsl:text> </xsl:text>
</xsl:template>
Ссылки между статьями преобразуются в реальные пути к Markdown-файлам. Help&Manual хранит ссылки как пути к XML-файлам, а конвертер транслирует их в пути с расширением .md через метод TopicNameconverter.Convert(), который читает элемент <title> из XML-файла и транслитерирует его в имя файла:
<xsl:template match="link">
<xsl:variable name="linkTarget">
<xsl:when test="@href[string-length() > 0]">
<xsl:value-of select="files:Convert(@href)"/>
<xsl:if test="not(contains(@href, '.'))">
<xsl:text>.md</xsl:text>
</xsl:if>
</xsl:when>
<!-- якорные ссылки внутри статьи -->
<xsl:when test="@anchor[string-length() > 0]">
<xsl:text>#</xsl:text>
<xsl:value-of select="@anchor"/>
</xsl:when>
</xsl:variable>
<xsl:value-of select="concat('[', $linkTitle, '](', $linkTarget, ')')"/>
</xsl:template>
Подсказки (tips, warnings, notes) обрабатываются одним из самых содержательных шаблонов. В Help&Manual они оформляются как таблица из двух столбцов, где в первом столбце расположена иконка (warning.png, info.png, idea.png). Конвертер распознаёт этот паттерн по структуре таблицы и преобразует его в синтаксис admonition-блоков Zensical:
<xsl:template match="table[
@colcount='2' and @rowcount='1'
and not(thead)
and tr/td[1]/para/image/@src != ''
]">
<xsl:variable name="hinttype"
select="substring-before(tr/td[1]/para/image/@src, '.')"/>
<!-- "warning.png" в "warning", далее в "!!! warning" -->
<xsl:value-of select="concat('!!! ', $hinttype, ' ', files:Localize($hinttype))"/>
<xsl:text>

 </xsl:text>
<xsl:apply-templates select="tr/td[2]"/>
</xsl:template>
Фильтрация статей по статусу
Конвертер обрабатывает только статьи со статусом «Завершен» в атрибуте корневого XML-элемента. Это позволяет техническим писателям держать черновики в том же репозитории и в том же проекте Help&Manual: на сайт они не попадут до тех пор, пока статус не будет выставлен.
private static bool AllowProcessXML(string file, string xmlContent)
{
var status = TopicNameconverter.ExtractStatus(xmlContent);
return status == "Завершен";
}
Шаг второй: пределы производительности MkDocs
Документация Eremex охватывает не один продукт, а целую линейку: Delta Design, SimPCB Lite, Enterprise Server, Simtera IC, DeltaCAM. В сумме это около двух с половиной тысяч статей. На таком объёме MkDocs начал заметно замедляться: полная сборка сайта занимала более пяти минут.
Для CI, где сборка запускается на каждый коммит, пять минут составляют существенную задержку. Каждое изменение одной строки в одной статье означало пятиминутное ожидание перед тем, как результат становился доступен на внутреннем сайте.
В качестве альтернативы был выбран Zensical, более новый статический генератор документации. Поскольку промежуточным форматом уже служил Markdown, переход с одного генератора на другой не потребовал повторной миграции контента, ограничившись перенастройкой сборки. Результат оказался следующим:
сборка сайта сократилась с приблизительно пяти минут до приблизительно одной минуты;
полнотекстовый поиск на сайте стал быстрее и точнее, чем у MkDocs.
Как это работает сейчас
Итоговая схема получилась гибридной, и в этом её основное достоинство. Технические писатели по-прежнему работают в привычном Help&Manual, менять редактор им не пришлось. Изменилось то, что происходит с контентом после сохранения.
Единственное, чему потребовалось обучить писателей, это работа с Git. Они фиксируют изменения в репозитории, и далее процесс выполняется автоматически:
По коммиту запускается CI.
Конвертер на .NET Core и XSLT преобразует XML-файлы Help&Manual в Markdown.
Zensical собирает из Markdown статический сайт.
CI собирает Docker-образ со статическим сайтом и публикует его в GitLab Container Registry.
Watchtower на внутреннем сервере обнаруживает новый образ и автоматически перезапускает контейнер.
Обновлённая версия становится доступна на внутреннем сайте документации.
На внутреннем сайте команда проверяет новые и изменённые статьи, убеждаясь, что контент отображается корректно, конвертация прошла без ошибок, а форматирование соответствует задуманному.
Публичная версия docs.eremex.ru обновляется вручную, синхронно с релизом продукта. Это осознанное решение: выпуск продукта и выпуск документации к нему остаются согласованным событием, однако теперь публикация представляет собой полностью контролируемый шаг, а не сжатую по срокам пересборку PDF.
Технический писатель
│ пишет в Help&Manual
▼
XML-файлы (Help&Manual)
│ git commit
▼
CI (GitLab)
├─ XSLT-конвертер: XML в Markdown
├─ Zensical: Markdown в статический сайт (~1 мин)
└─ Docker build, push в GitLab Container Registry
▼
GitLab Container Registry
│ Watchtower опрашивает раз в минуту
▼
Внутренний сервер (Docker, Watchtower)
│ автоматический pull и restart контейнера
▼
Внутренний сайт документации
│ проверка командой
▼
Публикация вручную, синхронно с релизом продукта
▼
docs.eremex.ru
Развёртывание через Docker и Watchtower
Для автоматического обновления внутреннего сайта не потребовалось писать deployment-скрипты и настраивать SSH-доступ с CI на сервер. Вместо этого применяется Watchtower, служба, которая отслеживает обновления Docker-образов в реестре и перезапускает контейнеры при появлении новой версии.
Вся инфраструктура сервера описана в одном файле docker-compose.yml:
version: '3.7'
services:
docs.app:
image: registry.gitlab.eremex.ru/eremex/docs.app
container_name: docs.app
restart: always
watchtower:
image: containrrr/watchtower
container_name: watchtower
volumes:
- /var/run/docker.sock:/var/run/docker.sock
environment:
- WATCHTOWER_POLL_INTERVAL=60 # проверять обновления раз в минуту
command: docs.app --label-enable
restart: unless-stopped
Конфигурация состоит из двух сервисов, каждый из которых выполняет одну задачу.
docs.app содержит статический сайт документации. CI собирает этот контейнер при каждом коммите и публикует его в GitLab Container Registry.
watchtower обеспечивает автоматическое обновление. Он монтирует Docker-сокет (/var/run/docker.sock), что позволяет ему управлять другими контейнерами от имени хоста. Раз в минуту, согласно параметру WATCHTOWER_POLL_INTERVAL, Watchtower проверяет реестр. Если образ docs.app обновился, служба загружает новую версию и перезапускает контейнер. Такой подход не требует ни вебхуков, ни SSH, ни deployment-скрипта на сервере.
В результате вся цепочка от коммита в репозиторий до обновления внутреннего сайта не требует ручного вмешательства. CI собирает образ и публикует его в реестре, Watchtower загружает образ, контейнер перезапускается, и команда получает доступ к актуальной версии документации.
Что изменилось
Измеримых показателей у нас немного, однако изменения ощутимы и для команды, и для пользователей.
Скорость сборки. Пять минут на MkDocs против одной минуты на Zensical составляют пятикратное ускорение, и при объёме около двух с половиной тысяч статей эта разница проявляется на каждом коммите.
Документация стала доступна мгновенно, с любого устройства и из любой точки мира. Прежде требовалось найти нужный PDF, загрузить его и открыть на компьютере. Теперь пользователь открывает ссылку с телефона, ноутбука или любого браузера и сразу получает актуальную статью, без загрузки файлов. При этом сам формат PDF остался доступен для тех, кто к нему привык.
Появились полноценные перекрёстные ссылки. Статьи документации перестали существовать как изолированные файлы. Из одной статьи можно сослаться на другую и провести читателя по связанным темам.
Документация вошла в поисковую выдачу и в ответы нейросетей. Контент, прежде недоступный для Google и AI-ассистентов, теперь индексируется как обычный веб-сайт. На практике это означает, что AI-ассистенты дают более содержательные и точные ответы по нашим продуктам, поскольку получили доступ к первоисточнику, а не только к фрагментам обсуждений на сторонних форумах.
Появилась аналитика. Прежде у нас не было способа оценить, какие статьи документации читают пользователи. Теперь мы видим востребованность конкретных статей и можем направлять усилия команды туда, где они принесут наибольшую пользу, вместо равномерного распределения по всем разделам.
Что дальше: контекстная справка в Delta Design
Новая система документации открыла возможность, недоступную при работе с PDF, а именно контекстную справку непосредственно внутри Delta Design.
Замысел состоит в следующем. Каждая форма, диалог и панель в приложении получает уникальный идентификатор, который связывается со статьёй в документации. Пользователь нажимает F1 в любой части интерфейса и получает не оглавление всей справки, а именно ту страницу, которая описывает текущий элемент на экране.
С набором PDF-файлов такое решение было неосуществимо: у отдельных тем не было ни постоянных URL, ни механизма точечной навигации внутри файла. Сайт на основе Markdown, где каждой статье соответствует отдельная страница, предоставляет иные возможности: у каждой темы есть постоянный адрес, и привязать к нему идентификатор формы технически несложно.
Вторая часть замысла предполагает навигацию в обратном направлении, из документации в приложение. Планируется поддержка специальной схемы ссылок:
deltadesign://options
deltadesign://project/settings
Такая ссылка в статье документации при нажатии будет открывать соответствующее окно или диалог в запущенном экземпляре Delta Design. Пользователь, читающий статью о настройке экспорта, сможет открыть нужное окно нажатием на ссылку в тексте, не разыскивая соответствующий пункт меню самостоятельно.
Подобное решение преобразует документацию из обособленного справочника в двунаправленный интерфейс между пользователем и продуктом: приложение направляет пользователя к нужному разделу документации, а документация возвращает его к нужной части приложения.
Пока это планы. Реализуемыми их делает то обстоятельство, что документация теперь существует как полноценный веб-сайт с постоянными URL и открытой структурой.
Итоги
Основной вывод из нашего опыта состоит в том, что для модернизации публикации документации не обязательно менять инструмент, в котором она создаётся. Технические писатели остались в привычном для них редакторе Help&Manual, тогда как преобразования произошли на уровне инфраструктуры: автоматический конвертер вместо ручного экспорта, Git вместо пересылки файлов, CI вместо ручной сборки, Docker с Watchtower вместо развёртывания по SSH, современный статический генератор вместо устаревшего механизма выпуска PDF.
Второй вывод заключается в том, что выбор инструмента для самой трансформации также не является окончательным. Мы перешли с Help&Manual на MkDocs, считая этот этап завершающим, однако на объёме более двух тысяч статей MkDocs достиг своих пределов. Переход на Zensical потребовался не вследствие ошибки в начале пути, а потому, что требования возросли вместе с объёмом документации. Решение, оптимальное для двухсот статей, не обязано оставаться таковым для двух с половиной тысяч.























