Frontmatter-патчи

У вас 200 заметок в папке blog/, и каждой нужно прописать free: true. Или вы хотите, чтобы все заметки в docs/ открывались на поддомене docs.mysite.com. Руками это делать бессмысленно, а через патчи вы пишете одно правило и оно применяется ко всей папке.

Frontmatter-патч работает так: вы указываете паттерн путей и выражение, которое добавляет или меняет свойства заметок. Система применяет патч при загрузке, до рендеринга страницы. Заметки остаются чистыми, в их frontmatter ничего не меняется.

Как настроена эта документация

Сайт с документацией trip2g, который вы сейчас читаете, использует frontmatter-патчи для управления мультиязычной структурой. Вот реальные правила, которые работают на этом сайте:

Паттерн Выражение Что делает
**/*.md {lang: "en"} Задаёт английский язык по умолчанию для всех заметок (приоритет −1, применяется первым)
ru/**/*.md {lang: "ru"} Переопределяет язык на русский для раздела ru/ (приоритет 0, применяется после)
**/*.md {free: true} Делает все страницы документации публичными
ru/user/**/*.md {left_sidebar: "ru/user/_sidebar.md"} Прикрепляет русский сайдбар ко всем страницам в RU-разделе
ru/**/*.md {header: "[[ru/_header]]", footer: "[[ru/_footer]]"} Переключает шапку и подвал на русскоязычные версии
ru/thoughts/**/*.md {left_sidebar: "[[ru/thoughts/_sidebar]]"} Прикрепляет сайдбар философского раздела
en/thoughts/**/*.md {left_sidebar: "[[en/thoughts/_sidebar]]"} Прикрепляет сайдбар английского философского раздела

Обратите внимание на каскад языков: первое правило ставит lang: "en" с приоритетом −1 для всего сайта. Второе правило с приоритетом 0 перекрывает только нужное — ru/**. Ни одной заметке не нужно прописывать lang вручную.

Дефолтный шаблон читает свойства lang, left_sidebar, header и footer из заметки и решает, что рендерить. Патчи подставляют эти свойства при загрузке, не трогая исходные файлы.

Кому подходит

  • У вас раздел с бесплатным контентом и раздел с платным, а каждый раз прописывать free вручную надоело
  • Вы хотите назначить layout целой папке, а не каждой заметке отдельно
  • Вам нужно привязать поддомен к разделу сайта через маршруты

Как создать патч

Патчи создаются в админке, в разделе Заметки и контент. Каждый патч состоит из трёх частей:

  1. Паттерны путей — к каким заметкам применять. Поддерживаются * (один уровень вложенности) и ** (рекурсивно)
  2. Выражение — что добавить или изменить в свойствах
  3. Приоритет — порядок применения, если патчей несколько

Пример с сайта trip2g.com:

Примеры

Сделать все заметки в blog/ бесплатными:

паттерн: blog/*
выражение: { free: true }

Каждая заметка в папке blog/ получит free: true, даже если во frontmatter этого нет.

Назначить layout для раздела:

паттерн: blog/*
выражение: { layout: "blog_layout" }

Все заметки в blog/ будут использовать шаблон blog_layout, а остальные возьмут тот, что указан у них во frontmatter или в настройках сайта.

Привязать папку к поддомену:

паттерн: docs/**
выражение: { route: "docs.mysite.com" }

Все заметки в docs/ и вложенных папках автоматически окажутся на docs.mysite.com. Подробнее о маршрутах читайте в мультидоменах.

Добавить суффикс к заголовкам:

паттерн: *
выражение: meta + { title: meta.title + " — My Site" }

Здесь используется переменная meta, которая содержит текущие свойства заметки. Выражение берёт существующий заголовок и добавляет к нему название сайта.

Условный патч — поставить layout, только если он не задан:

паттерн: *
выражение: if std.objectHas(meta, "layout") then {} else { layout: "default" }

Если в заметке уже прописан layout, патч ничего не трогает. Если нет, ставит default.

Приоритеты и цепочки

Патчи применяются по порядку: сначала с меньшим приоритетом, потом с большим. Каждый следующий патч видит результат предыдущего.

Допустим, у вас два правила:

Приоритет Паттерн Выражение
0 * { layout: "default" }
10 blog/* { layout: "blog_layout" }

Для заметки blog/post.md сначала применится правило с приоритетом 0 и поставит layout: "default". Потом правило с приоритетом 10 перезапишет его на blog_layout. Итого заметка получит blog_layout, а все остальные заметки останутся с default.

Паттерны

Паттерны определяют, к каким заметкам применится патч. Синтаксис похож на .gitignore, но с некоторыми особенностями.

Спецсимволы:

Символ Что делает
* Любая последовательность символов, кроме /. Не матчит скрытые файлы (начинающиеся с .)
** Любое количество вложенных папок, включая ноль
? Ровно один символ, кроме /
[abc] Один символ из набора: a, b или c
[a-z] Один символ из диапазона
[^abc] или [!abc] Любой символ, кроме указанных
{foo,bar} Один из вариантов: foo или bar

Примеры:

Паттерн Матчит Не матчит
blog/* blog/post.md, blog/draft.md blog/2024/post.md (вложенная папка)
blog/** blog/post.md, blog/2024/jan/post.md docs/post.md
blog/**/draft* blog/draft1.md, blog/2024/drafts/draft-new.md blog/final.md
* index.md, about.md .hidden.md, blog/post.md
*.md about.md, index.md .secret.md
.* .hidden.md, .config.md about.md
blog/post-?.md blog/post-1.md, blog/post-a.md blog/post-12.md
{blog,docs}/* blog/post.md, docs/api.md notes/post.md
blog/[0-9]* blog/2024-review.md blog/my-post.md

Нюансы, которые стоит знать:

** работает только между разделителями пути. Паттерн blog/**/*.md поймает все .md-файлы на любой глубине внутри blog/. А вот blog/**.md сработает так же, как blog/*.md, то есть только на первом уровне. Если нужна рекурсия, пишите blog/**/*.md.

Скрытые файлы (начинающиеся с точки) не попадают под * и ?. Чтобы поймать их, используйте явный паттерн .* или конкретное имя .config.

Пустые альтернативы в фигурных скобках работают как опциональные части: some{thing,} матчит и something, и some.

Include и exclude:

У каждого патча есть include-паттерны (к чему применять) и exclude-паттерны (что исключить). Заметка попадает под патч, если подходит хотя бы под один include и не подходит ни под один exclude.

include: ["blog/**"]
exclude: ["blog/drafts/*"]

Этот патч применится ко всем заметкам в blog/, кроме тех, что лежат в blog/drafts/.

Выражения на Jsonnet

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

Доступные переменные:

Переменная Тип Что содержит
meta объект Текущие свойства заметки (после предыдущих патчей в цепочке)
path строка Путь к файлу, например "blog/my-post.md"

Выражение должно вернуть объект {}. Его содержимое добавляется к свойствам заметки. Если ключ уже существует, он перезаписывается.

Простой объект — самый частый случай. Выглядит как JSON:

{ free: true, layout: "blog" }

Чтение текущих свойств через переменную meta:

meta + { title: meta.title + " — My Site" }

Оператор + для объектов работает как merge: берёт все поля из meta и перезаписывает title новым значением.

Условия через if / then / else:

if std.objectHas(meta, "layout") then {} else { layout: "default" }

Здесь std.objectHas проверяет, есть ли у заметки поле layout. Если есть, патч возвращает пустой объект (ничего не меняет). Если нет, ставит default. Без проверки через std.objectHas обращение к meta.layout упадёт на заметке, где этого поля нет.

Логика на основе пути:

if std.startsWith(path, "premium/")
then { free: false }
else {}

Хотя это можно решить паттернами ["premium/*"], бывают случаи, когда условие удобнее выразить через путь прямо в выражении.

Конкатенация строк через +:

{ description: meta.title + " — опубликовано на " + meta.site_name }

Форматирование строк через % (как в Python):

{ og_title: "%s | %s" % [meta.title, "My Site"] }

Полезные функции стандартной библиотеки:

Функция Что делает Пример
std.objectHas(obj, key) Проверяет наличие поля std.objectHas(meta, "layout")
std.length(x) Длина строки или массива std.length(meta.title) > 50
std.startsWith(str, prefix) Начинается ли строка с std.startsWith(path, "blog/")
std.endsWith(str, suffix) Заканчивается ли строка на std.endsWith(path, ".draft.md")
std.split(str, delim) Разбивает строку std.split(path, "/")
std.join(delim, arr) Склеивает массив std.join(", ", meta.tags)

Составной пример — premium-раздел с дефолтной сложностью:

meta + {
  free: false,
  reading_complexity:
    if std.objectHas(meta, "reading_complexity")
    then meta.reading_complexity
    else "advanced"
}

Патч закрывает доступ к заметкам (free: false) и ставит reading_complexity: "advanced", но только если автор не указал сложность вручную.

Пустой объект {} — возвращайте его, когда патч не должен ничего менять. Это полезно в ветке then или else условия.

Патчи из хранилища

Вместо создания патчей в админке можно описывать правила прямо в хранилище — как обычные markdown-файлы рядом с контентом. Такие файлы хранятся в Obsidian, попадают под контроль версий, редактируются как заметки.

Формат файла

Создайте markdown-файл в любом месте хранилища. Укажите во frontmatter type: frontmatter-patch, паттерны и приоритет. Jsonnet-выражение пишется в теле файла в блоке кода с языком jsonnet:

---
type: frontmatter-patch
include:
  - blog/*
  - articles/**
exclude:
  - blog/premium/*
priority: 10
---

Делает все blog-посты бесплатными, кроме premium-раздела.

```jsonnet
{ free: true }

**Поля frontmatter:**

| Поле | Обязательно | По умолчанию | Описание |
|------|-------------|--------------|----------|
| `type` | да | — | Должно быть `"frontmatter-patch"` |
| `include` | да | — | Glob-паттерны для заметок |
| `exclude` | нет | `[]` | Glob-паттерны для исключения |
| `priority` | нет | `0` | Меньше = применяется раньше (внутри vault-патчей) |

Поля `enabled` нет. Чтобы отключить патч, удалите файл или переименуйте его.

#### Видимость

Файлы с префиксом `_` — например `_rules.md` или `_publish-rules.md` — скрыты от посетителей сайта, но продолжают работать как патчи. Файлы без префикса отображаются как обычные заметки и одновременно работают как патчи. Это позволяет писать описание патча в свободной форме, ссылаться на него из других заметок и хранить как документацию.

#### Несколько патчей в одном файле

Каждый блок ` ```jsonnet ` в теле файла становится отдельным патчем. Все блоки в одном файле наследуют `include`, `exclude` и `priority` из frontmatter:

```markdown
---
type: frontmatter-patch
include:
  - docs/**
priority: 5
---

Два правила для раздела с документацией:

```jsonnet
{ layout: "doc" }
{ free: true }

Получится два патча, оба с `priority: 5` и `include: ["docs/**"]`.

#### Порядок применения

Сначала применяются патчи из админки, затем — vault-патчи. Внутри каждой группы порядок определяется приоритетом. Vault-патчи применяются после и могут перекрывать правила из админки.

#### Полный пример

`docs/_publish-rules.md`:

```markdown
---
type: frontmatter-patch
include:
  - docs/**
  - guides/**
exclude:
  - docs/draft/*
priority: 20
---

Правила публикации для документации и гайдов.

Все доки становятся бесплатными. Гайды по умолчанию получают layout «guide».
Draft-заметки исключены.

```jsonnet
{ free: true }
if std.startsWith(path, "guides/")
then { layout: "guide" }
else {}

#### Ошибки

Если в файле патча есть проблемы, сайт продолжает работать. Заметка отображается с предупреждением — вы видите проблему и можете исправить её.

| Ситуация | Что происходит |
|----------|----------------|
| Нет jsonnet-блока в теле | Предупреждение на заметке; файл рендерится как обычная заметка |
| Отсутствует `include` | Предупреждение на заметке |
| Невалидный glob-паттерн | Предупреждение на заметке |
| Невалидный Jsonnet-синтаксис | Предупреждение на заметке |

Предупреждение видно при открытии заметки на сайте. Остальные патчи продолжают работать.

### Что может пойти не так

**Патч не применяется.** Проверьте паттерн: `blog/*` не поймает `blog/drafts/post.md`, для вложенных папок нужен `blog/**`.

**Ошибка в выражении.** Если выражение падает на конкретной заметке (например, обращается к несуществующему полю), патч пропускается только для этой заметки. Остальные заметки обработаются нормально, сайт не сломается.

**Два патча конфликтуют.** Побеждает тот, у кого выше приоритет (большее число). При одинаковом приоритете побеждает созданный позже.

**Забыли про порядок.** Патч с приоритетом 10 видит `meta` после всех патчей с приоритетом 0-9. Если вы добавляете суффикс к `title` в патче с высоким приоритетом, он возьмёт уже изменённый заголовок.

**Обращение к несуществующему полю.** `meta.tags` упадёт, если у заметки нет поля `tags`. Оборачивайте в проверку: `if std.objectHas(meta, "tags") then meta.tags else []`.