От набора PDF-файлов до портала технической документации на 2,5 тысячи статей

от автора

У инженеров, которые проектируют электронные устройства, документация в виде 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">&lt;figure&gt;</xsl:text>            <img src="{$imagetarget}" alt="{$imagetitle}"/>            <xsl:text disable-output-escaping="yes">&lt;figcaption&gt;</xsl:text>            <xsl:value-of select="$imagetitle"/>            <xsl:text disable-output-escaping="yes">&lt;/figcaption&gt;&lt;/figure&gt;</xsl:text>        </xsl:when>        <!-- Рисунок без подписи: простой Markdown-синтаксис -->        <xsl:otherwise>            <xsl:value-of select="concat('![](', $imagetarget, ')')"/>        </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>---&#10;</xsl:text>    <xsl:text>search:&#10;</xsl:text>    <xsl:text>  exclude: true&#10;</xsl:text>    <xsl:text>---&#10;&#10;</xsl:text>    <xsl:value-of select="concat('# ', normalize-space(para/text))"/>    <xsl:text>&#10;</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>&#xa;&#xa;    </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. Они фиксируют изменения в репозитории, и далее процесс выполняется автоматически:

  1. По коммиту запускается CI.

  2. Конвертер на .NET Core и XSLT преобразует XML-файлы Help&Manual в Markdown.

  3. Zensical собирает из Markdown статический сайт.

  4. CI собирает Docker-образ со статическим сайтом и публикует его в GitLab Container Registry.

  5. Watchtower на внутреннем сервере обнаруживает новый образ и автоматически перезапускает контейнер.

  6. Обновлённая версия становится доступна на внутреннем сайте документации.

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

Публичная версия 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/