Русский
Запросы к заметкам
Сайту часто нужна выборка заметок: посты с тегом, пять последних статей, счётчики задач по статусам. Запрос к заметкам — это логика такой выборки, написанная один раз небольшой программой на Jsonnet в заметке. Шаблон вызывает его через noteQuery() при рендере страницы, а страница на JavaScript — через API. Оба получают ровно тот JSON, который собрала программа.
Данные берутся из frontmatter заметок, но сырой frontmatter с сервера не уходит: код лежит в вашем хранилище, и вы решаете, что вернуть. Посетитель передаёт только параметры, например тег.
Быстрый старт
1. Создайте заметку queries/by_tag.md с type: note-query и одним блоком jsonnet:
---
type: note-query
free: true
---
Посты блога с тегом, сначала новые.
```jsonnet
local tagged = [
n for n in notes
if std.startsWith(n.path, 'blog/')
&& std.member(std.get(n.meta, 'tags', []), params.tag)
];
local byDate = std.sort(tagged, function(n) std.get(n.meta, 'date', ''));
[
{ title: n.title, url: n.permalink, date: n.meta.date }
for n in std.reverse(byDate)
]
```
2. Синхронизируйте хранилище и вызовите запрос со страницы. makeRequest — маленький помощник для fetch со страницы Приложение поверх trip2g:
const noteQuery = makeRequest(`query ($input: NoteQueryInput!) {
noteQuery(input: $input) {
__typename
... on NoteQueryPayload { result }
... on ErrorPayload { message }
}
}`)
const data = await noteQuery({
input: { query: '[[by_tag]]', params: { tag: 'go' } },
})
const posts = data.noteQuery.result
// [{ title: 'Go on the web', url: '/blog/go_on_the_web', date: '2026-10-10' }, …]
query называет заметку-запрос: путём от корня хранилища, например queries/by_tag.md, или вики-ссылкой, например [[by_tag]]. Голое имя ищется по всему хранилищу; если имя повторяется, побеждает заметка с самым коротким путём. params — любое JSON-значение, до 16 КБ в закодированном виде; без него запрос получает {}. Результат приходит в поле result готовым JSON-значением, разбирать ничего не нужно.
Что видит запрос
| Имя | Что в нём |
|---|---|
notes |
список заметок, отсортированный по пути: { path, path_id, version_id, title, permalink, meta } |
params |
разобранные params, {}, если их не передали |
currentUser |
кто спрашивает: { id, role, isAdmin, signedIn }; у гостя id и role равны null |
meta — frontmatter заметки после frontmatter-патчей. path_id — номер заметки, он не меняется при правках; version_id — номер её текущей версии. С all_notes: true у каждой заметки есть ещё readable. Служебные заметки (пути, начинающиеся с _) и файлы, которые не являются markdown-заметками (canvas, base, excalidraw), в список не попадают.
Заметка, скрытая из поиска через search: false, в notes есть: флаг прячет заметку от поиска, а не от страницы, которая её показывает. У неё meta.search равно false, так что запрос, которому такие заметки не нужны, отсеивает их сам: if std.get(n.meta, 'search', true).
Программа может вернуть любой JSON: список заметок, объект со счётчиками, одно число. Запрос не ограничен списками заметок, он может их агрегировать.
Кто может запустить и что он увидит
Два правила не дают запросу ничего раскрыть:
- Посетитель может запустить запрос, только если может прочитать заметку-запрос. Доступ к ней задаётся как к любой заметке:
free: true— для всех, подграф — для подписчиков. Запрос, который посетитель прочитать не может, отвечаетquery not found, так же как несуществующий. - Программа видит только заметки, которые может прочитать посетитель. Гость, запустивший публичный запрос, не получит в
notesплатных заметок, так что запрос не покажет их заголовки или поля по ошибке. Админ, запустивший тот же запрос, видит всё.
На примерах ниже гость, запросивший by_tag с тегом go, получит два поста, а админ — ещё и третий, только для подписчиков.
Заметка-запрос публикуется как любая другая, поэтому читатели, которые могут запустить запрос, могут прочитать и его код.
Все заметки: all_notes: true
Иногда странице нужно показать больше, чем посетитель может открыть: «3 открытых поста, 1 для подписчиков». Добавьте во frontmatter заметки-запроса all_notes: true. Тогда в notes попадают все заметки, и у каждой есть readable: true, если посетитель может её открыть; это считает сервер по тем же правилам, что и сайт. Теперь программа сама решает, что получит каждый посетитель, поэтому фильтруйте по readable всё, чего посетителю видеть не положено. Без флага в notes только доступные заметки.
local blog = [n for n in notes if std.startsWith(n.path, 'blog/')];
{
open: [n.title for n in blog if n.readable],
locked: std.length([n for n in blog if !n.readable]),
signedIn: currentUser.signedIn,
}
Гость получит заголовки открытых постов и только число закрытых:
{
"open": ["Go on the web", "Hello, Go", "Web basics"],
"locked": 1,
"signedIn": false
}
Примеры
Эти запросы взяты из демо-хранилища, которое тесты trip2g прогоняют при каждой сборке.
Посты с тегом, сначала новые
Параметры: {"tag": "go"}.
local tagged = [
n for n in notes
if std.startsWith(n.path, 'blog/')
&& std.member(std.get(n.meta, 'tags', []), params.tag)
];
local byDate = std.sort(tagged, function(n) std.get(n.meta, 'date', ''));
[
{ title: n.title, url: n.permalink, date: n.meta.date }
for n in std.reverse(byDate)
]
Последние посты
Параметры: {"limit": 2}; без них — три поста.
local limit = std.get(params, 'limit', 3);
local posts = [n for n in notes if std.startsWith(n.path, 'blog/')];
local byDate = std.sort(posts, function(n) std.get(n.meta, 'date', ''));
[n.title for n in std.reverse(byDate)][:limit]
Срез list[:limit] оставляет первые элементы.
Счётчики для дашборда
Сколько задач в каждом статусе. Результат — объект, например {"done": 1, "todo": 2}:
local tasks = [n for n in notes if std.startsWith(n.path, 'tasks/')];
local status(n) = std.get(n.meta, 'status', 'none');
{
[s]: std.length([n for n in tasks if status(n) == s])
for s in std.set([status(n) for n in tasks])
}
{ [key]: value for … } строит объект с вычисляемыми ключами, а std.set убирает повторы и сортирует.
Заметки по папкам
Сколько заметок, доступных посетителю, лежит в каждой папке верхнего уровня. Гость получит {"blog": 3, "queries": 6, "tasks": 3}, а админ, который видит и пост для подписчиков, — {"blog": 4, "queries": 6, "tasks": 3}:
local folder(n) =
local parts = std.split(n.path, '/');
if std.length(parts) > 1 then parts[0] else '(root)';
{
[f]: std.length([n for n in notes if folder(n) == f])
for f in std.set([folder(n) for n in notes])
}
Счётчики зависят от доступа посетителя, поэтому один и тот же запрос показывает каждому читателю те цифры, которые ему можно видеть.
Другие агрегации
Тот же приём — ключ на группу и значение, посчитанное по её заметкам, — подходит для:
- заметок по тегам для облака тегов: группировка по каждому элементу
meta.tags; - архива по месяцам: группировка по первым семи символам
meta.date; - самой новой заметки в каждой папке: сортировка внутри группы и первая заметка;
- суммы числового поля, например оценки часов по задачам, через
std.foldl; - отчёта о полноте: пути заметок, у которых нет поля, например
description; - одного вызова на весь дашборд: объекта с несколькими такими разделами.
Строки: заметки за результатом
Часто запрос выбирает заметки, а страница их показывает: заголовки, ссылки, текст. Программе не нужно копировать эти поля. Она может вернуть только номер каждой заметки, а сами заметки отдаст сервер.
Если результат — список, в rows по строке на каждый элемент. data строки — элемент в том виде, в каком его вернула программа. note — заметка, которую элемент называет:
version_id— эта версия заметки;- иначе
path_id— последняя версия заметки; - иначе
path— заметка по этому пути в хранилище.
note равно null, если элемент не называет заметку, называет несуществующую или ту, которую посетитель прочитать не может. Сервер проверяет доступ здесь ещё раз, отдельно от программы, поэтому запрос с all_notes: true не отдаст закрытую заметку, даже если программа вернула её номер. Служебные заметки и не-markdown-файлы не возвращаются никогда, как и в notes. Если результат не список, rows равно null.
Запрос newest из демо-хранилища возвращает три самых новых поста номерами:
local posts = [n for n in notes if std.startsWith(n.path, 'blog/')];
local byDate = std.sort(posts, function(n) std.get(n.meta, 'date', ''));
[{ version_id: n.version_id } for n in std.reverse(byDate)][:3]
Страница на JavaScript берёт всё остальное из note:
const newest = makeRequest(`query {
noteQuery(input: { query: "[[newest]]" }) {
... on NoteQueryPayload {
rows { note { title url html } }
}
... on ErrorPayload { message }
}
}`)
const data = await newest()
const posts = data.noteQuery.rows.map((row) => row.note)
// [{ title: 'Go on the web', url: '…/blog/go_on_the_web', html: '…' }, …]
note — тот же тип, что возвращает запрос note. Сервер ищет заметку строки, только если запрос просит note, поэтому запрос, который читает лишь result или rows { data }, ничего лишнего не стоит.
Что выбрать:
- Счётчики, суммы, дашборд. Программа считает числа, страница читает
result. Номера заметок не нужны. - Выборка заметок. Верните
[{ version_id: n.version_id }](илиpath_id), а заголовок, ссылку и HTML берите изrows { note { … } }в GraphQL или из.Note()в шаблоне. Программа остаётся короткой, а страница получает поля, которые программа собрать не может, например готовый HTML. - И то и другое. Положите рядом с номером поля, нужные странице:
{ version_id: n.version_id, date: n.meta.date }; страница прочитает их изdata.
Ошибки
Неудачный вызов возвращает ErrorPayload с сообщением:
| Сообщение | Почему |
|---|---|
query not found |
такой заметки нет, или посетитель не может её прочитать |
<path> is not a note-query |
у заметки нет type: note-query |
<path>: a note-query needs exactly one jsonnet block |
блоков ноль или несколько |
params must be JSON, params is larger than 16 KB |
неправильные params |
<path>:3:5-9 Unknown variable: … |
код не компилируется: опечатка, неизвестное имя, незакрытая скобка; место — строка и колонки в блоке, колонки первой строки считаются от скрытого заголовка |
<path>: RUNTIME ERROR: … |
программа упала; первая строка ошибки Jsonnet |
В шаблоне
Свой шаблон вызывает тот же запрос через noteQuery(query, params). Запрос выполняется с правами того, кто смотрит страницу, и возвращает результат списками и картами, поэтому по нему можно пройти циклом:
{{ range _, p := noteQuery("[[by_tag]]", map("tag", "go")) }}
<a href="{{ p.url }}">{{ p.title }}</a>
{{ end }}
params необязательны; соберите их через map(…). Перебирайте с двумя переменными, как везде в Jet: с одной range отдаёт индекс. Упавший запрос останавливает рендер, админ видит его сообщение. Превью шаблона в админке тоже выполняет запросы, как и проверка шаблона при синхронизации: она рендерит как гость, поэтому шаблон, который вызывает запрос только для подписчиков у всех посетителей, получит предупреждение.
noteQueryRows(query, params) выполняет тот же запрос и отдаёт его строки. У строки есть .Data() — элемент в том виде, в каком его вернула программа, и .Note() — заметка, которую он называет, или nil, по тем же правилам, что rows выше:
{{ range _, row := noteQueryRows("[[newest]]") }}
{{ if post := row.Note(); post }}
<a href="{{ post.Permalink() }}">{{ post.Title() }}</a>
{{ end }}
{{ end }}
.Note() — тот же note, что и везде в шаблоне: Title(), HTMLString(), M() и остальное. Если результат не список, noteQueryRows останавливает рендер.
Гость и подписчик на одной и той же странице могут получить разные списки. Страницы, которые видит гость, кешируются только для гостей, поэтому подписчик никогда не получит список гостя.
Запрос или шаблон
Список заметок шаблон умеет строить и без запроса: nvs.ByGlob("blog/*.md") и SortByMeta собирают его на сервере при рендере страницы, а json() передаёт странице. Такой список фиксируется в момент рендера. noteQuery() в шаблоне пригодится для агрегаций, которые на Jet писать неудобно, или когда страница на JavaScript и шаблон должны использовать один запрос. Из JavaScript запрос вызывают, когда ответ зависит от ввода посетителя, например от тега, выбранного в меню.
Ограничения
- Каждый вызов заново выполняет программу. Кеша пока нет; на хранилище примерно из 850 заметок вызов занимает около 12 мс. Кеш — в issue #441. Один запрос к API выполняет не больше двух запросов к заметкам.
- Запрос работает, пока не закончит. Остановить Jsonnet на полпути нельзя, поэтому не пишите циклы, размер которых зависит от
params, напримерstd.range(1, params.n): любой посетитель сможет сделать их огромными. - Как следить за ценой. В метриках инстанса есть
trip2g_note_query_duration_secondsпо каждой заметке-запросу, а вызов дольше секунды пишется в лог какslow note query.