Русский
Валидаторы заметок
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 (и , и ![[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).