ТЗ размещение кастомного 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, это баг системы синхронизации/публикации, а не ошибка ТЗ.

Правило для агента:

  1. Если в vault уже есть _layouts/meditation/index.html, _layouts/meditation/final.css, _layouts/meditation/final.js — дорабатывать именно эти файлы как актуальную версию.
  2. Если layout-файлов ещё нет — взять их из приложенного архива ![[files.zip]]: final.html, final.css, final.js.
  3. При переносе final.html в _layouts/meditation/index.html заменить обычные подключения CSS/JS на Trip2G asset helper:
<link rel="stylesheet" href="{{ asset("final.css") }}" />
<script src="{{ asset("final.js") }}"></script>
  1. Не брать HTML/CSS/JS из meditation.md: markdown-страница содержит только frontmatter и ссылку на layout.
  2. Если ни архива, ни готовых 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», а более узкая схема:

  1. Layout определяется по пути:
path.startsWith("_layouts/") && (path.endsWith(".html") || path.endsWith(".html.json"))
  1. Для publish-field фильтра layout пропускается особым образом: _layouts/.../*.html может синхронизироваться даже без обычного free: true / publish frontmatter.

  2. Asset path резолвится относительно layout/note:

  • ./file.css → относительно директории текущего файла;
  • /path/file.css → от корня vault;
  • dir/file.css → как явный путь от корня;
  • file.css → сначала как файл от корня, потом assets/file.css, потом рядом с текущим layout/note.
  1. Сервер после 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