У инженеров, которые проектируют электронные устройства, документация в виде 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://optionsdeltadesign://project/settings
Такая ссылка в статье документации при нажатии будет открывать соответствующее окно или диалог в запущенном экземпляре Delta Design. Пользователь, читающий статью о настройке экспорта, сможет открыть нужное окно нажатием на ссылку в тексте, не разыскивая соответствующий пункт меню самостоятельно.
Подобное решение преобразует документацию из обособленного справочника в двунаправленный интерфейс между пользователем и продуктом: приложение направляет пользователя к нужному разделу документации, а документация возвращает его к нужной части приложения.
Пока это планы. Реализуемыми их делает то обстоятельство, что документация теперь существует как полноценный веб-сайт с постоянными URL и открытой структурой.
Итоги
Основной вывод из нашего опыта состоит в том, что для модернизации публикации документации не обязательно менять инструмент, в котором она создаётся. Технические писатели остались в привычном для них редакторе Help&Manual, тогда как преобразования произошли на уровне инфраструктуры: автоматический конвертер вместо ручного экспорта, Git вместо пересылки файлов, CI вместо ручной сборки, Docker с Watchtower вместо развёртывания по SSH, современный статический генератор вместо устаревшего механизма выпуска PDF.
Второй вывод заключается в том, что выбор инструмента для самой трансформации также не является окончательным. Мы перешли с Help&Manual на MkDocs, считая этот этап завершающим, однако на объёме более двух тысяч статей MkDocs достиг своих пределов. Переход на Zensical потребовался не вследствие ошибки в начале пути, а потому, что требования возросли вместе с объёмом документации. Решение, оптимальное для двухсот статей, не обязано оставаться таковым для двух с половиной тысяч.
ссылка на оригинал статьи https://habr.com/ru/articles/1051740/