ТЗ размещение кастомного Trip2G layout и assets
ТЗ: размещение кастомного Trip2G layout и assets
Связанные материалы: демо-страница meditation, журнал действий, карта продукта.
Короткий вывод
Для кастомной визуальной страницы в Trip2G рабочая схема такая:
vault/
├─ meditation.md
└─ _layouts/
└─ meditation/
├─ index.html
├─ final.css
└─ final.js
В заметке указываем layout:
---
free: true
layout: meditation/index
title: Ночная медитация
description: Интерактивная главная с глазом, мандалой, орбитами медитаций и агентским логом.
---
В HTML layout подключаем CSS/JS не как обычные файлы, а через Jet helper asset(...):
<link rel="stylesheet" href="{{ asset("final.css") }}" />
<script src="{{ asset("final.js") }}"></script>
Источник правды для HTML/CSS/JS
Агент не должен угадывать, какой HTML/CSS/JS нужен для страницы. Источник правды должен быть явным.
Для текущей демо-страницы meditation источник правды такой:
Скачать исходный архив: files.zip
Архив-исходник в vault:
Исследование trip2g/files.zip
Файлы внутри архива:
final.html -> основа для _layouts/meditation/index.html
final.css -> _layouts/meditation/final.css
final.js -> _layouts/meditation/final.js
Важно: архив должен лежать прямо в vault и быть вставлен в ТЗ как Obsidian embed ![[files.zip]]. Sync должен сам утянуть этот .zip в S3/attachments. Если ![[files.zip]] не публикуется или не попадает в storage, это баг системы синхронизации/публикации, а не ошибка ТЗ.
Правило для агента:
- Если в vault уже есть
_layouts/meditation/index.html,_layouts/meditation/final.css,_layouts/meditation/final.js— дорабатывать именно эти файлы как актуальную версию. - Если layout-файлов ещё нет — взять их из приложенного архива
![[files.zip]]:final.html,final.css,final.js. - При переносе
final.htmlв_layouts/meditation/index.htmlзаменить обычные подключения CSS/JS на Trip2G asset helper:
<link rel="stylesheet" href="{{ asset("final.css") }}" />
<script src="{{ asset("final.js") }}"></script>
- Не брать HTML/CSS/JS из
meditation.md: markdown-страница содержит только frontmatter и ссылку на layout. - Если ни архива, ни готовых layout-файлов нет — это блокер ТЗ; агент должен явно остановиться и запросить исходники, а не фантазировать интерфейс.
Что проверено на практике
Проверено на странице:
- meditation
- публичный URL:
https://dobireports.2pub.me/meditation
Фактический результат:
_layouts/meditation/index.htmlсинхронизируется как layout-документ;final.cssиfinal.jsне пушатся как самостоятельные документы;- после обнаружения
{{ asset("final.css") }}и{{ asset("final.js") }}sync CLI загружает их как assets к note/layout; - в публичном HTML вместо
asset(...)появляются signed storage URLs; - интерактивная страница открывается с кастомным CSS/JS.
Почему это не просто glob
В sync CLI обнаружена логика не вида «залей все файлы по glob», а более узкая схема:
- Layout определяется по пути:
path.startsWith("_layouts/") && (path.endsWith(".html") || path.endsWith(".html.json"))
-
Для publish-field фильтра layout пропускается особым образом:
_layouts/.../*.htmlможет синхронизироваться даже без обычногоfree: true/ publish frontmatter. -
Asset path резолвится относительно layout/note:
./file.css→ относительно директории текущего файла;/path/file.css→ от корня vault;dir/file.css→ как явный путь от корня;file.css→ сначала как файл от корня, потомassets/file.css, потом рядом с текущим layout/note.
- Сервер после push возвращает список
notes.assets, а CLI отдельно вызываетuploadNoteAssetдля каждого найденного asset.
То есть CSS/JS попадают на сервер не потому, что sync разрешает любые .css/.js, а потому что они объявлены в HTML через asset(...) и становятся note assets.
Ограничение
Raw upload .css и .js как самостоятельных документов не поддержан. Наблюдаемая ошибка при прямой загрузке:
Unsupported document type '.js'
Unsupported document type '.css'
Поддержанные типы документов, которые были видны в ошибках/ответах:
.cfg, .csv, .docx, .ini, .json, .log, .md, .pdf, .pptx, .toml, .txt, .xlsx, .xml, .yaml, .yml, .zip
Поэтому правило:
.md— самостоятельные страницы/заметки;_layouts/**/*.html— кастомные layout-документы;.css/.js/ изображения / прочее — только как assets, если они объявлены черезasset(...)из layout или другой поддержанной точки.
Требования к расположению файлов
1. Markdown-страница
Пример:
meditation.md
Минимальный frontmatter:
---
free: true
layout: meditation/index
title: Ночная медитация
description: Короткое описание страницы.
---
Рекомендованные properties:
free: true— публичность/публикация страницы;layout: meditation/index— путь layout без_layouts/и без.html;title— нормальный<title>и заголовок для индексации;description— сниппет/описание;tags— если страница часть исследовательской/проектной базы.
Важно: YAML-строки с двоеточиями нужно брать в кавычки. Например description: "Личный ИИ-помощник в Telegram: контент, задачи...". Если не закавычить значение с :, frontmatter может не распарситься, и Trip2G покажет сырой markdown/frontmatter вместо кастомного layout.
2. Layout HTML
Пример:
_layouts/meditation/index.html
Рекомендации:
- держать layout в отдельной папке, если у него есть CSS/JS;
- называть entrypoint
index.html, тогда в note писатьlayout: meditation/index; - в
<title>использовать данные note, например{{ note.Title() }}; - все локальные CSS/JS подключать через
{{ asset("...") }}.
Минимальный skeleton:
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<title>{{ note.Title() }}</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="stylesheet" href="{{ asset("style.css") }}" />
</head>
<body>
<!-- custom page -->
<script src="{{ asset("script.js") }}"></script>
</body>
</html>
3. Assets рядом с layout
Пример:
_layouts/meditation/final.css
_layouts/meditation/final.js
Если asset лежит рядом с index.html, достаточно:
{{ asset("final.css") }}
{{ asset("final.js") }}
Если asset лежит в подпапке:
_layouts/meditation/assets/final.css
то лучше явно:
{{ asset("assets/final.css") }}
Данные интерактива из отдельных markdown-файлов
Итоговое решение лучше делать без общего массива meditations в meditation.md: каждая медитация — отдельная обычная markdown-заметка в стандартной папке.
Рекомендуемая структура:
vault/
├─ meditation.md # главная интерактивная страница
├─ meditations/
│ ├─ meditation_0.md
│ ├─ meditation_1.md
│ ├─ meditation_2.md
│ └─ ...
└─ _layouts/
└─ meditation/
├─ index.html
├─ final.css
└─ final.js
Главная meditation.md остаётся почти пустой и только выбирает layout:
---
free: true
layout: meditation/index
title: Ночная медитация
description: Интерактивная главная с глазом, мандалой, орбитами медитаций и агентским логом.
---
Каждый файл медитации сам несёт свои properties:
---
free: true
title: Длинная ночная пауза
meditation_n: 4
order: 4
meditation_date: "2026-05-15 02:21"
mood: тишина
latest: true
agent_log: "02:21:04 agent tick: пробуждение — выбранное время наступило..."
---
# Длинная ночная пауза
Текст конкретной медитации.
Layout собирает список автоматически через Trip2G template API:
{{ range i, doc := nvs.ByGlob("meditations/*.md").SortByMeta("order").All() }}
<li data-value="{{ doc.M().GetInt("meditation_n", i) }}|{{ doc.Title() }}|{{ doc.M().GetString("meditation_date", "") }}|{{ doc.M().GetString("mood", "покой") }}|{{ doc.M().GetBool("latest", false) }}|{{ doc.Permalink() }}"></li>
{{ end }}
Формат строки, которую layout отдаёт JS:
n|title|date|mood|isLatest|url
Почему так лучше:
- каждая медитация пишется как обычная заметка;
- её можно открывать как отдельную страницу;
- главная страница не требует ручного общего массива
meditations; - добавление новой медитации = создать новый файл
meditations/meditation_N.mdсorderи properties; nvs.ByGlob("meditations/*.md")сам соберёт файлы для интерактива;- ссылка на узле орбиты берётся из
doc.Permalink(), то есть не нужно угадывать slug.
Минимальные required properties для каждой медитации:
free: true— если медитация должна быть публичной;title— название узла и страницы;order— порядок сортировки в орбите;meditation_n— номер для отображенияmeditation_N;meditation_date— строка даты для UI;mood— ключ палитры: напримерпокой,тишина,наблюдение,пробуждение,интенсивность;latest: true— только у текущей/последней медитации;agent_log— строка для правого лога, если нужна.
Если нужен отдельный общий лог, его можно не хранить в meditation.md; лучше либо брать agent_log из каждой медитации, либо сделать отдельную папку/заметки meditation-logs/*.md и аналогично собрать через nvs.ByGlob(...).
Acceptance criteria
Страница считается корректно собранной, если:
trip2g-sync.mjsзавершился без ошибок;- в sync output есть
Assets uploadedили assets уже skipped/up-to-date; - публичный URL открывается;
- в HTML публичной страницы нет сырых
{{ asset(...) }}; - вместо них видны storage/signed URLs;
- CSS и JS реально применились на странице.
Команда синхронизации
Использовать штатный sync, API key брать из Obsidian plugin config, не печатать ключ в лог:
python3 - <<'PY'
from pathlib import Path
import json, subprocess
vault = Path('/opt/data/dobireports.2pub.me')
config = json.loads((vault/'.obsidian/plugins/trip2g/data.json').read_text())
sync = config['syncDirs'][0]
api_key = sync['apiKey']
api_url = sync['apiUrl'].rstrip('/') + '/graphql'
cmd = [
'node', '/opt/data/work/bin/trip2g-sync.mjs', str(vault),
'--api-url', api_url,
'--api-key', api_key,
'--two-way',
'--conflict-resolution', 'local',
'--verbose',
]
proc = subprocess.run(cmd, text=True, capture_output=True, timeout=180)
print(proc.stdout.replace(api_key, api_key[:6] + '...REDACTED'))
if proc.returncode:
print(proc.stderr.replace(api_key, api_key[:6] + '...REDACTED'))
raise SystemExit(proc.returncode)
PY