Read in:
Русский

Запросы к заметкам

Сайту часто нужна выборка заметок: посты с тегом, пять последних статей, счётчики задач по статусам. Запрос к заметкам — это логика такой выборки, написанная один раз небольшой программой на 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: список заметок, объект со счётчиками, одно число. Запрос не ограничен списками заметок, он может их агрегировать.

Кто может запустить и что он увидит

Два правила не дают запросу ничего раскрыть:

  1. Посетитель может запустить запрос, только если может прочитать заметку-запрос. Доступ к ней задаётся как к любой заметке: free: true — для всех, подграф — для подписчиков. Запрос, который посетитель прочитать не может, отвечает query not found, так же как несуществующий.
  2. Программа видит только заметки, которые может прочитать посетитель. Гость, запустивший публичный запрос, не получит в 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.