Skip to content

Встраивание файлов из внешних источников ({% include-code %}) #2207

Description

@vgvoleg

Проблема

Почти любой код в документации откуда-то скопирован: примеры, конфигурационные файлы, манифесты,
схемы, скрипты. Это не редкий случай, а норма — у фрагмента почти всегда есть оригинал, который
живёт своей жизнью и меняется независимо.

Копия расходится с оригиналом, и это расхождение ничем не обнаруживается: правка оригинала не
ломает сборку документации и не видна в её ревью. Устаревший фрагмент выглядит ровно так же, как
актуальный, — до тех пор, пока читатель не попробует его применить.

Расстояние до оригинала на суть не влияет. Он может лежать в соседнем каталоге того же репозитория
или в чужом репозитории — копия устаревает одинаково, разница только в том, насколько трудно это
заметить.

Измеренный пример: YDB и восемь SDK

Самый наглядный случай, на котором задача и возникла. У YDB восемь официальных SDK, каждый в
отдельном репозитории:

Язык Репозиторий
Go ydb-platform/ydb-go-sdk
Java ydb-platform/ydb-java-sdk
Python ydb-platform/ydb-python-sdk
C# ydb-platform/ydb-dotnet-sdk
JavaScript ydb-platform/ydb-js-sdk
Rust ydb-platform/ydb-rs-sdk
C++ ydb-platform/ydb-cpp-sdk
PHP ydb-platform/ydb-php-sdk

Документация — в девятом репозитории (ydb-platform/ydb, каталог ydb/docs), и примеры для всех
восьми SDK скопированы в неё руками. Масштаб на ветке main:

  • 2350 файлов документации, из них 575 содержат код, всего 3178 код-блоков;
  • 185 код-блоков на языках SDK в 59 файлах русской версии и ещё 179 в английской — около 360
    скопированных вручную фрагментов;
  • одна страница обычно показывает сценарий сразу во всех SDK: в рецепте аутентификации девять вкладок
    (Native SDK, Native SDK (Asyncio), database/sql, JDBC, SQLAlchemy, userver и другие).

Чтобы держать эти 360 фрагментов актуальными, авторам восьми SDK нужно помнить про девятый
репозиторий при каждом изменении примера. Дисциплиной это не решается.

Другие сценарии того же класса

Задача не про SDK как таковые — они лишь понятный всем частный случай. Тот же механизм закрывает:

  • конфигурации и манифестыdocker-compose.yml, k8s-манифесты, terraform, настройки CI,
    которые показывают в разделах «как развернуть»;
  • сгенерированные артефакты — OpenAPI-спеки, protobuf- и JSON-схемы, публикуемые сборкой;
  • тесты как документация — интеграционный тест часто и есть самый честный пример использования,
    и он уже поддерживается в рабочем состоянии;
  • миграции и SQL-скрипты, которые применяются в проде и заодно показываются в документации;
  • код рядом с документацией — файлы соседнего каталога или пакета в том же репозитории;
    скачивать нечего, но копия устаревает точно так же;
  • общие фрагменты между наборами документации — один и тот же файл переиспользуется доками
    нескольких продуктов.

Общее у всех: у фрагмента есть оригинал, который поддерживают отдельно от документации.

Предложение

Дать документации ссылаться на источник истины, а не на копию. Фрагмент вставляется директивой, а
физически берётся из внешнего источника на этапе сборки:

{% include-code [Подключение к БД](python-sdk:static-credentials/example.py#auth-static) %}

Источники объявляются один раз в .yfm:

sources:
  python-sdk:
    type: git
    repo: ydb-platform/ydb-python-sdk
    ref: main
    path: examples

  infra:
    type: local
    dir: ../deploy

  schemas:
    type: http
    url: https://storage.example.com/schemas

Фрагмент внутри файла размечается комментарием-маркером:

# #region auth-static
driver_config = ydb.DriverConfig(
    endpoint=endpoint,
    credentials=ydb.StaticCredentials.from_user_password(user, password),
)
# #endregion auth-static

Результат в собранной документации — обычный код-блок: та же подсветка, та же кнопка копирования, а
под ним ссылка на исходный файл.

Что это даёт

Содержимое становится проверяемым. Это главное. Сейчас код и конфиги в документации — текст,
который никто не компилирует и не валидирует. После перехода в документацию попадает фрагмент
реального файла, который живёт в своём репозитории под своим CI: пример компилируется и покрыт
тестами, манифест применяется, схема валидируется. Ответственность за работоспособность переезжает с
документатора на обычный пайплайн владельца файла.

Расхождение становится заметным. Если владелец источника удалит или переименует размеченный
фрагмент, сборка документации упадёт с явной ошибкой. Тихое протухание превращается в обычную поломку
сборки, которую видно сразу.

Разделение ответственности. Владельцы файлов поддерживают их у себя. Документаторы пишут контекст
и объяснения, не следя за содержимым фрагментов.

Массовое переключение версий. Версия источника указана в одном месте конфига, а не в сотнях
фрагментов: обновить все примеры — правка одной строки. Параметризация через переменные сборки
позволяет собирать одну документацию против разных версий источника.

Ссылка на источник. Под фрагментом — ссылка на файл, привязанная к конкретному коммиту (не к
подвижной ветке), с точными номерами строк. Читатель может перейти и посмотреть в полном контексте.

Совместимость с офлайн-форматами. Содержимое подставляется на этапе сборки, поэтому физически
попадает и в HTML, и в PDF. Никаких ссылок, которые невозможно разрешить офлайн.

Дизайн

Источники объявляются в конфиге, а не в директиве

Ключевое решение. Полный URL внутри каждой директивы ({% include-code "https://github.com/org/repo/blob/main/file.py" %}) выглядит проще, но ломается ровно на том, ради
чего фича и нужна: обновление версии превращается в правку сотен вхождений, а ссылка на main
означает, что документация версии 23.x однажды начнёт показывать примеры из 26.x.

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

  • git — файлы из репозитория на хостинге (repo, host, ref, path). Основной случай для
    примеров и конфигураций из соседних репозиториев.
  • http — обычные файлы по HTTP: объектное хранилище, артефакты сборки, публикуемые схемы
    (url, path).
  • local — каталог на диске: соседний пакет в монорепозитории, сгенерированный на этапе сборки
    код, чекаут, который CI и так делает (dir, path).

Тип меняется без правки документов: в тексте стоит только логическое имя источника.

Репозиторий не клонируется

Для git-источника ref резолвится в коммит по обычному HTTP (git smart HTTP, несколько килобайт),
после чего скачиваются только те файлы, на которые реально ссылаются документы.

Это принципиально для монорепозиториев: вытащить один файл из ydb-platform/ydb стоит килобайты
вместо десятков мегабайт, которых потребовал бы клон — даже поверхностный, blobless и sparse.

Из окружения нужен только доступ в сеть: git в PATH не требуется, ssh-ключи не нужны.

Фрагменты адресуются именованными регионами, а не номерами строк

Номера строк протухают при первом же рефакторинге, причём молча. Маркеры регионов переживают правки и
являются явным контрактом между источником и документацией.

Маркер ищется в любом месте строки и не зависит от синтаксиса комментариев, поэтому одинаково работает
в Go, Python, Java, C#, JS, Rust, C++, PHP, а также в YAML, SQL и XML — то есть в конфигах и схемах,
а не только в коде. Поддерживаются две конвенции: #region name / #endregion и [START name] /
[END name] (принята в примерах Google). Строки-маркеры вырезаются из вывода, регионы можно
вкладывать, общий отступ снимается — фрагмент из середины файла выглядит самостоятельным.

Диапазоны строк (#L10-L25) поддерживаются для источников, владельцы которых не готовы добавлять
маркеры, но сборка предупреждает о них.

Отсутствие региона — ошибка сборки

Сознательное решение: именно оно превращает тихое расхождение в громкий отказ в тот момент, когда
меняется исходник.

Технические детали

  • Директива раскрывается в обычный fenced-блок до остальной обработки markdown, поэтому HTML, PDF,
    single-page, md2md, поиск и llms.txt работают без изменений.
  • Ref резолвится один раз на сборку в главном потоке; файлы скачиваются по мере надобности, каждый
    один раз, включая параллельную сборку (-j N).
  • Скачанное складывается в каталог загрузки (--sources-download-dir) под именем коммита.
  • Внешние файлы читаются через существующую песочницу путей, отдельным зарегистрированным scope, а не
    в обход неё.

Что потребуется от владельцев источников

Только одно: расставить маркеры регионов в тех файлах, на которые будет ссылаться документация.
Правка — две строки комментариев на фрагмент, файл при этом остаётся валидным и работающим.

Ссылка на файл целиком не требует вообще никаких изменений на стороне источника, поэтому переход
можно делать постепенно: сначала подключить источники и включать файлы целиком, а регионы просить
точечно, по мере надобности.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions