Read in:
Русский

Валидаторы заметок

trip2g хранит каждую заметку в базе и работает с ней как с данными. Шаблоны, списки, поиск и API берут поля из frontmatter и разделы из текста. Карточке нужна картинка, списку — дата, сниппету в поиске — описание. Если поля нет или раздел назван иначе, страница ломается без ошибки: пустая карточка, список не в том порядке, обрезанный сниппет.

Нейросети хорошо пишут markdown, но каждый раз по-разному. В одном запуске раздел называется ## Steps, в другом — ## How to. В одном tags — список, в другом — строка.

Валидатор — это заметка с правилами на Jsonnet. Вы подключаете её к другим заметкам, и при каждой синхронизации trip2g проверяет их по этим правилам. Синхронизация проходит как обычно. Заметка, которая нарушила правило, получает предупреждение, и видно, что именно исправить.

Быстрый старт

1. Создайте заметку validators/required_frontmatter.md. Правила пишутся в блоке кода jsonnet:

Every article needs a title and a description, both strings.

```jsonnet
local required = ['title', 'description'];

[
  {level: 'error', msg: 'missing frontmatter field: ' + f}
  for f in required
  if !std.objectHas(meta, f)
] + [
  {level: 'error', msg: f + ' must be a string'}
  for f in required
  if std.objectHas(meta, f) && !std.isString(meta[f])
] + (
  if std.objectHas(meta, 'tags') && !std.isArray(meta.tags)
  then [{level: 'error', msg: 'tags must be a list'}]
  else []
)
```

2. Подключите её к заметке через поле validate:

---
title: Publishing a vault
validate: "[[required_frontmatter]]"
---

3. Синхронизируйте хранилище. Если у заметки нет description, в выводе синхронизации появится:

notes/article.md
  [CRITICAL] validate validators/required_frontmatter.md: missing frontmatter field: description

Заметка всё равно сохранится и опубликуется. Предупреждение висит на ней, пока вы её не исправите.

Где виден результат

  • CLI синхронизации. Сообщения выводятся под каждой заметкой. Если есть хотя бы один error, CLI завершается с кодом 1, и скрипт или ИИ-агент понимает, что нужно что-то исправить. Сообщения приходят с синхронизацией, которая отправляет заметки: если ничего не изменилось, CLI ничего не отправляет и ничего не показывает. См. CLI для синхронизации.
  • Админка. Dashboard → «Предупреждения в заметках» всегда показывает все заметки с их сообщениями.
  • Приложение, которое сохраняет заметки. updateNotes возвращает сообщения каждой сохранённой заметки в updated[].warnings. Заметка сохраняется даже с error. См. Приложение поверх trip2g.
  • Локально, без сервера. trip2g lint <папка> выводит те же сообщения. Он строже синхронизации: завершается с кодом 1 и на error, и на warning. Поставьте его в CI, чтобы невалидные заметки не попадали на сайт. См. Линтер для хранилища.

Как подключать валидаторы

В validate можно указать один валидатор или список:

validate:
  - "[[required_frontmatter]]"
  - "[[seo]]"
  - validators/session_has_image.md

Валидатор можно указать вики-ссылкой или путём, с .md или без. Поиск работает так же, как для вики-ссылок в тексте; часть #заголовок игнорируется. Заметка проходит проверку, только если ни один её валидатор не вернул error.

Один валидатор на много заметок

Пишите каждое правило один раз и переиспользуйте. Держите валидаторы в одной папке, например validators/, по одному правилу на заметку: «обязательные поля», «SEO», «у каждой сессии есть картинка». Каждому типу заметок — свой набор. Воркшопу — session_has_image и seo. Статье — required_frontmatter, seo и sections_order.

Чтобы проверять целую папку, не правьте каждую заметку, а добавьте валидаторы frontmatter-патчем:

---
type: frontmatter-patch
include: ["blog/**"]
---

Посты блога тоже проверяются на обязательные поля и SEO.

```jsonnet
local own = std.get(meta, 'validate', []);
local list = if std.isString(own) then [own] else own;
{ validate: list + ['[[required_frontmatter]]', '[[seo]]'] }
```

Патч берёт собственный validate заметки (пустой список, если его нет), превращает одиночную строку в список из одного элемента и дописывает правила папки. Заметка без validate получит правила папки. Заметка с validate: "[[sections_order]]" получит все три.

Такие патчи складываются. Каждый патч видит frontmatter, который оставили патчи до него, поэтому патч на blog/** и патч на **/*.md вместе дают посту блога оба набора правил и его собственные.

Патч, который возвращает просто список, например { validate: ['[[seo]]'] }, заменяет собственный validate заметки и всё, что добавили предыдущие патчи. Используйте эту форму, только если правила папки должны перекрыть правила заметки.

Пустой validate означает, что валидаторов нет: validate: без значения, validate: "" и null выключают проверку. Патч может выключить её для одной заметки, на которую распространяется патч папки: { validate: null }. Патчи применяются от меньшего priority к большему, поэтому дайте такому патчу priority больше, чем у патчей, которые добавляют валидаторы.

Валидаторы видят frontmatter уже после патчей, поэтому поле, которое добавил патч, считается заполненным.

Как написать валидатор

Валидаторы пишутся на Jsonnet, нужный синтаксис — на странице Jsonnet в trip2g. В каждом блоке jsonnet валидатора доступны:

Имя Что в нём
meta frontmatter заметки после патчей
path путь заметки, например blog/post.md
nodes текст заметки списком элементов, сверху вниз
std.native('regexMatch')(pattern, text) true, если текст подходит под регулярное выражение

У каждого элемента в nodes есть type, line (строка в файле, считая frontmatter) и depth (глубина вложенности):

type Дополнительные поля
heading level (1–6), text
paragraph text (текст пункта плотного списка тоже приходит как paragraph)
image src, alt (и ![alt](src), и ![[src|alt]])
link url, text
wikilink target, embed
code_block lang, пустой, если язык не указан
list ordered
list_item, blockquote, table —

Блок возвращает список сообщений. Пустой список значит, что с заметкой всё в порядке:

[
  {level: 'error', msg: 'what is wrong', line: 12},
  {level: 'warning', msg: 'worth a look'},
]
  • error помечает заметку как невалидную: CRITICAL в CLI, CLI завершается с кодом 1.
  • warning — совет: WARNING, код выхода CLI синхронизации остаётся 0. trip2g lint на нём всё равно падает.
  • line необязателен. Если он указан, сообщение его покажет.

Сломанный валидатор никогда не проходит молча. Опечатка в Jsonnet, результат не в виде списка, неизвестный level или ненайденный валидатор дают заметке сообщение CRITICAL.

В валидаторе может быть несколько блоков jsonnet. Выполняется каждый, их сообщения складываются.

Примеры

Эти валидаторы взяты из демо-хранилища, которое тесты trip2g прогоняют при каждой сборке.

Обязательные поля

local required = ['title', 'description'];

[
  {level: 'error', msg: 'missing frontmatter field: ' + f}
  for f in required
  if !std.objectHas(meta, f)
] + [
  {level: 'error', msg: f + ' must be a string'}
  for f in required
  if std.objectHas(meta, f) && !std.isString(meta[f])
] + (
  if std.objectHas(meta, 'tags') && !std.isArray(meta.tags)
  then [{level: 'error', msg: 'tags must be a list'}]
  else []
)

Разделы в заданном порядке

В статье ровно эти разделы второго уровня и в этом порядке:

local want = ['Intro', 'Steps', 'FAQ'];
local got = [n.text for n in nodes if n.type == 'heading' && n.level == 2];

if got == want then []
else [{
  level: 'error',
  msg: 'sections must be %s, got %s' % [
    std.join(' → ', want),
    std.join(' → ', got),
  ],
}]

У каждой сессии есть картинка

Заметка делится на разделы по каждому ##, и в каждом разделе считаются картинки. std.foldl проходит по списку элементов и несёт разделы с собой:

local last(xs) = xs[std.length(xs) - 1];
local init(xs) = xs[:std.length(xs) - 1];

local sections = std.foldl(
  function(acc, n)
    if n.type == 'heading' && n.level == 2 then
      acc + [{title: n.text, line: n.line, images: 0}]
    else if n.type == 'image' && std.length(acc) > 0 then
      init(acc) + [last(acc) + {images: super.images + 1}]
    else acc,
  nodes,
  [],
);

[
  {level: 'error', line: s.line, msg: '"%s" has no image' % s.title}
  for s in sections
  if std.startsWith(s.title, 'Session') && s.images == 0
]

Картинки и код

Нет alt-текста — предупреждение. Блок кода без языка — ошибка:

[
  {level: 'warning', line: n.line, msg: 'image %s has no alt text' % n.src}
  for n in nodes
  if n.type == 'image' && n.alt == ''
] + [
  {level: 'error', line: n.line, msg: 'code block without a language'}
  for n in nodes
  if n.type == 'code_block' && n.lang == ''
]

Длина описания и формат даты

local re = std.native('regexMatch');
local desc = std.get(meta, 'description', '');
local date = std.toString(std.get(meta, 'date', ''));

(
  if std.length(desc) > 160
  then [{
    level: 'warning',
    msg: 'description is %d chars, keep it under 160' % std.length(desc),
  }]
  else []
) + (
  if !re('^\\d{4}-\\d{2}-\\d{2}$', date)
  then [{level: 'error', msg: 'date must be YYYY-MM-DD, got "%s"' % date}]
  else []
)

Формат имени файла

Некоторые команды называют заметки так, чтобы человек или агент понимал, что в файле, не открывая его: {project} {type} title – YYYY-MM-DD – tool.md. Например, {blog} {article} Weekly summary – 2026-10-18 – Cursor.md. Дата всегда стоит ближе к концу, а последняя часть называет инструмент, который написал заметку.

Валидатор берёт имя файла из path, делит его по – и проверяет каждую часть. Если во frontmatter есть date, она должна совпадать с датой в имени. Инструмент, написанный правильно, но которого нет в списке, например Codex, даёт только предупреждение, чтобы новый инструмент не ломал синхронизацию. Неправильно написанный, например claude-code, — ошибку:

local re = std.native('regexMatch');
local parts = std.split(path, '/');
local file = parts[std.length(parts) - 1];
local name = std.substr(file, 0, std.length(file) - 3);
local chunks = std.split(name, ' – ');
local tools = [
  'Claude Code', 'Cursor', 'ChatGPT', 'Gemini', 'Windsurf', 'Manual',
];
local err(msg) = {level: 'error', msg: msg};
local warn(msg) = {level: 'warning', msg: msg};

local checkTool(tool) =
  if std.member(tools, tool) then []
  else if re('^[A-Z][A-Za-z0-9 .]*$', tool) then [
    warn('new tool "%s"? add it to the list in this validator' % tool),
  ] else [
    err('tool "%s" must be a name like %s' % [tool, std.join(', ', tools)]),
  ];

if std.length(chunks) != 3 then [
  err('name must be "{project} {type} title – YYYY-MM-DD – tool"'),
] else
  local head = chunks[0], date = chunks[1], tool = chunks[2];
  (
    if re('^\\{[a-z0-9]+\\} \\{[a-z]+\\} .+$', head) then []
    else [err('name must start with {project} {type} and a title')]
  ) + (
    if re('^\\d{4}-\\d{2}-\\d{2}$', date) then []
    else [err('date "%s" must be YYYY-MM-DD' % date)]
  ) + checkTool(tool) + (
    local fmDate = std.toString(std.get(meta, 'date', date));
    if fmDate == date then []
    else [err('frontmatter date %s differs from %s in the name'
              % [fmDate, date])]
  )

{blog} {2026-10-18} {article} Weekly summary.md не проходит: дата стоит посередине, и имя не делится на три части.

Такое правило подключайте к папке frontmatter-патчем, чтобы проверялись и новые файлы. Иначе файл, созданный без validate, не проверяется.

Если нужно больше

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

  • Change webhook отправляет каждую изменённую заметку в ваш сервис. Сервис может проверить что угодно: другие заметки, внешние системы, текущую дату. См. Вебхуки.
  • Cron webhook запускает проверку по расписанию — для правил, которые зависят от времени, например «перепроверить заметку после даты stale_after».

API, который добавляет заметке предупреждение, в trip2g нет. Сервис возвращает результат, записывая заметки: в ответе вебхука (changes[]) или через API-токен. Два способа, которые работают уже сейчас:

  • Заметка-отчёт. Сервис пишет заметку, например reports/validation.md, со списком проблем. Она видна на сайте и в поиске, как любая другая.

  • Поле и валидатор. Сервис записывает проблемы в поле frontmatter проверенной заметки, например external_errors: ["no backlinks"], а маленький валидатор превращает поле в сообщения:

    [
      {level: 'error', msg: e}
      for e in std.get(meta, 'external_errors', [])
    ]
    

    Сообщения появятся в выводе синхронизации и в админке, как любые другие. Это правит заметку автора на сервере, и она перестаёт совпадать с его локальной копией.

Планы

Правила по всему хранилищу запланированы, но как их сделать, пока не решено:

  • у каждой заметки есть хотя бы одна связь, входящая или исходящая;
  • в каждой папке есть заметка-индекс;
  • цель supersedes помечена как deprecated;
  • дата stale_after прошла.

Для них нужен другой вид валидатора: он видит все заметки и текущую дату и работает без кеша по заметкам. Обсуждение — в issue #436. Пока всегда можно сделать свою проверку рядом с trip2g: change или cron вебхук отправит заметки в ваш сервис, как описано выше.

Что ещё можно проверять

  • Договор с шаблоном. Layout читает cover, description и раздел ## Summary. Валидатор следит, чтобы они были у каждой заметки с этим layout.
  • Заметки, которые пишут агенты. Агент пишет заметку, запускает синхронизацию, получает код 1 со списком сообщений и исправляет заметку. Такой цикл ловит то, что модель сделала не так в конкретном запуске.
  • Данные в заметках. События, товары, люди: price — число, status — одно из draft, review, published, дата в формате ISO. Списки, графики и API получают чистые данные.
  • SEO и доступность. Один заголовок #, без пропуска уровней (##, а сразу за ним ####), alt у картинок, описание короче 160 символов.
  • Редакционный стиль. Запрещённые слова, ссылки с текстом «нажмите здесь», слишком длинные предложения. Текст абзаца лежит в поле text у элементов paragraph. Текста из ячеек таблиц в nodes нет.
  • Структура курса. В каждом уроке есть видео и раздел ## Quiz.
  • Посты в Telegram. Текст укладывается в лимит, картинок не больше одной.

Ограничения

  • Валидатор видит одну заметку. Он не проверит, что slug уникален во всём хранилище или что на заметку ссылается другая.
  • Валидаторы не блокируют синхронизацию. Невалидная заметка сохраняется и публикуется с предупреждением. Чтобы заблокировать её, запускайте trip2g lint в CI до синхронизации.
  • Заметка-валидатор публикуется, как любая другая.
  • Результаты запоминаются между синхронизациями. Заметка проверяется заново, только если изменилась она сама, изменился один из её валидаторов или стал указывать на другую заметку, или изменился любой frontmatter-патч. Запуск сервера, сохранение патча в админке или синхронизация с заметкой-патчем проверяют все заметки с validate ещё раз. С семью валидаторами на ~850 заметок такой проход занимает около 4 секунд.

Как это устроено внутри и сколько стоит: валидаторы заметок (dev).