# SharedDocsLib
SharedDocsLib — небольшой Python-фреймворк для документации. Страницы и HTML-компоненты описываются функциями с декораторами, а библиотека собирает Obsidian-подобный автономный клиент и отдаёт его через FastAPI.
## Возможности
- страницы с номерами `1`, `1.1`, `1.1.2` и автоматическим деревом;
- собственные `@Component` и набор безопасных базовых компонентов;
- поиск по всему контенту, светлая/тёмная тема, breadcrumbs, Previous/Next;
- изображения из `assets_dir`, единожды закодированные в base64 внутри manifest;
- полностью автономный `index.html`: начальный manifest вместе с CSS и JavaScript встроен;
- хранение manifest в IndexedDB и лёгкая проверка обновления через файл `version`;
- `index.zip`, содержащий только `index.html`;
- live-preview с перекомпиляцией изменившегося Python-модуля.
UI не содержит языко-зависимых служебных подписей. Базовый шрифт — `system-ui 16px`, а служебные UTF-8-пиктограммы используют Arial. Навигационные стрелки отрисовываются CSS-границами без текстовых символов. По умолчанию включена светлая тема, выбор светлой или AMOLED-темы сохраняется локально; тема устройства не используется.
Accessibility-кнопка создаётся системным JavaScript, поэтому появляется и в ранее скомпилированных HTML после обновления manifest. На десктопе она закреплена в левом нижнем углу ниже loading-overlay, на мобильном располагается рядом с переключателем темы внутри меню. Псевдостраница настроек позволяет менять размер текста от 12 до 28 px, сбрасывать параметры и выбирать только локальные браузерные семейства `system-ui`, `sans-serif`, `serif` и `monospace`; сетевые шрифты не используются. Размер применяется только к sidebar, документации и кнопкам Previous/Next, а семейство — ко всему интерфейсу, кроме UTF-8 glyph-кнопок. Параметры сохраняются в `localStorage`.
На десктопе компактный header содержит toggle боковой панели, полное название документации, поиск и переключатель темы. Sidebar по умолчанию открыт, его состояние не сохраняется между загрузками, а при сворачивании внутренняя раскладка остаётся неподвижной и обрезается границей панели без переноса текста. Поисковый placeholder медленно печатает первые слова случайного названия, добавляет троеточие только в конце и сохраняет готовый текст 5 секунд; при вводе поле раскрывает ранжированные результаты вниз. Проверка manifest выполняется автоматически в фоне без отдельной кнопки обновления.
Обновлённый runtime сохраняет совместимость со старыми скомпилированными HTML-оболочками: отсутствие desktop-toggle, мобильного переключателя темы или общего контейнера page-transition не останавливает запуск клиента.
На мобильных устройствах используется viewport `100dvh` и нижний dock. Кнопка `☰` заменяет содержимое страницы копией desktop-sidebar, которая выезжает с левой границы экрана; между header-кнопками и деревом доступен круглый переключатель темы. Выбор страницы в открытом мобильном меню закрывает его и показывает новый контент моментально, без page-transition. Ввод в нижний поиск заменяет содержимое результатами, расположенными снизу вверх по релевантности. При остальных переходах контент вместе с кнопками предыдущей и следующей страницы плавно смещается на 30 px по направлению навигации и меняет прозрачность.
## Установка из Git
```bash
uv venv
source .venv/bin/activate
uv pip install "git+https://git.snw.su/snw/SharedDocsLib.git@main"
```
Для разработки и запуска примера из клонированного репозитория:
```bash
uv sync --extra dev
uv run python examples/basic.py
```
После запуска доступны:
- `GET http://127.0.0.1:1234/manifest.json`
- `GET http://127.0.0.1:1234/version`
- `GET http://127.0.0.1:1234/index.html`
- `GET http://127.0.0.1:1234/index.zip`
- `GET http://127.0.0.1:1234/live` (только при `live_preview=True`)
## Минимальный пример
```python
from docslib import HeaderButton, run
from docslib.hooks import Component, Page
from docslib.components import H1, Image, LocalLink, P
@Component
def Lead(text):
return f'
{text}
'
@Page("1.", "Page title")
def page1():
return [
H1("Hello"),
P("Documentation text"),
Image("photo.png", "Description"),
LocalLink("Open nested page", subpage1),
]
@Page("1.1", "Sub-page")
def subpage1():
return [H1("Nested page")]
if __name__ == "__main__":
run(
port=1234,
host="0.0.0.0",
title="My docs",
assets_dir="./assets",
custom_css="./assets/inject.css",
custom_js="./assets/inject.js",
manifest_path="http://127.0.0.1:1234/",
live_preview=True,
header_buttons=[
HeaderButton("Знакомство", page1),
HeaderButton("API reference", subpage1),
HeaderButton.external("Project", "https://example.com")
],
)
```
`manifest_path` принимает полный путь к JSON либо базовый URL, к которому будет добавлен `manifest.json`. URL файла `version` автоматически вычисляется рядом с manifest. Для совместимости также принят вариант `manifest_parh` из первоначального API, но в новом коде лучше использовать правильное имя.
## Статическая сборка
`build` принимает параметры компиляции из `run`, не связанные с сервером, и создаёт готовый статический комплект. По умолчанию используется каталог `./build`; отсутствующие родительские каталоги создаются автоматически.
```python
from docslib import HeaderButton, build
build(
build_directory="./dist/docs",
title="My docs",
assets_dir="./assets",
custom_css="./assets/inject.css",
custom_js="./assets/inject.js",
manifest_path="./manifest.json",
header_buttons=[
HeaderButton("Знакомство", page1),
HeaderButton("API reference", subpage1),
],
)
```
В `build_directory` записываются ровно четыре файла:
- `index.html` — полностью автономный клиент;
- `manifest.json` — manifest для публикации отдельным файлом;
- `version` — короткий текстовый файл, содержащий `revision` manifest;
- `index.zip` — архив, содержащий только `index.html`.
Функция возвращает `BuildResult` с теми же данными в памяти. Серверные параметры `host`, `port`, `live_preview`, `cors_origins` и `log_level` ей не требуются.
## Варианты компонентов и генераторы (1.0)
Все компоненты оформляются через `@Component`. Функция компонента возвращает непустой список или кортеж строк: каждый элемент — альтернативное представление одного блока. Форматы и порядок вариантов определяет разработчик; количество вариантов не ограничено. Старый компонент, возвращающий строку, продолжает работать: декоратор превращает её в `(output,)`.
```python
from html import escape
from docslib import Component, Page, build, DefaultGenerator, FilesGenerator
@Component
def Heading(text):
return (
f"{escape(text)}
", # вариант 1: HTML
f"# {text}", # вариант 2: Markdown
rf"\section{{{text}}}", # вариант 3: LaTeX
)
@Page("1", "Introduction")
def introduction():
return [Heading("Hello"), Heading("World")]
# По умолчанию: веб-обёртка и прежние четыре артефакта.
build(build_directory="./dist/web", title="My docs")
# Эквивалентно generator=DefaultGenerator(component_variant=1).
# Один файл My docs.md с сырым содержимым второго варианта.
build(
build_directory="./dist/markdown",
title="My docs",
generator=FilesGenerator(component_variant=2, content_extension="md"),
)
# Отдельный файл 1. Introduction.tex для каждой страницы.
build(
build_directory="./dist/latex",
generator=FilesGenerator(
component_variant=3, content_extension="tex", as_single_file=False,
),
)
```
`component_variant` — положительное целое число или непустой список/кортеж таких чисел в порядке приоритета. Нумерация **с 1**: значение `1` выбирает элемент с индексом `0`. Для веб-превью этот параметр можно передать в `run(component_variant=1)` или `create_app(component_variant=1)`, для сборки — в генератор. `DefaultGenerator` всегда создаёт веб-обёртку, поэтому выбранные для него строки должны подходить для вставки в HTML.
Начиная с 1.1, `component_variant=[3, 2]` выбирает третий вариант, а если его нет — второй. Выбор выполняется **для каждого компонента отдельно**: например, специальный компонент может содержать MCP-пример для LLM в варианте 3, а остальные компоненты используют общее описание REST API для людей из варианта 2. Можно задать более длинную цепочку, например `[3, 2, 1]`. Пустая строка считается явно заданным вариантом и не вызывает fallback.
```python
build(
title="LLM instructions",
generator=FilesGenerator(component_variant=[3, 2], content_extension="md"),
)
# Та же цепочка доступна в DefaultGenerator, run и create_app.
```
`FilesGenerator` требует `component_variant` и непустой `content_extension` (например, `md`, `.md`, `tex` или `txt`). Он возвращает `FilesBuildResult.files`: словарь `{имя_файла: содержимое}`. При `as_single_file=True` (по умолчанию) создаётся `{title}.{content_extension}`, страницы сортируются так же, как в веб-версии, и разделяются пустой строкой:
```text
--- 1. Introduction ---
# Hello
# World
--- 2. Next page ---
содержимое следующей страницы
```
При `as_single_file=False` создаются файлы `{number}. {page_name}.{content_extension}`, без заголовков-разделителей. Недопустимые символы в именах заменяются на `_`; пути не могут выйти из `build_directory`. Файлы записываются в UTF-8. Генератор не удаляет старые файлы из каталога — для разных форматов используйте разные каталоги.
Содержимое вариантов вставляется без экранирования и конвертации; между блоками страницы добавляется `\n`. Веб-шаблоны, CSS, JS и assets при файловом экспорте не загружаются и не копируются: ссылки на изображения и другие ресурсы определяет сам компонент. У каждого компонента должен быть хотя бы один вариант из заданной цепочки; иначе сборка завершается ошибкой с именем страницы и компонента. Варианты вне цепочки автоматически не подставляются.
Встроенные компоненты по-прежнему предоставляют один HTML-вариант. Для других форматов определите собственные компоненты, как в примере выше. Вызов компонента теперь возвращает кортеж вариантов, а не строку: если вручную собираете составной компонент, используйте нужные элементы явно, например `H1(text)[0]`. Результат `@Component` нельзя превращать в обычный `list` перед возвратом со страницы: обычные списки/кортежи трактуются как последовательность блоков, а результат декоратора — как альтернативы одного блока.
## Компоненты
Доступны `H1`, `H2`, `H3`, `P`, `MarkdownText`, `Image`, `Link`, `LocalLink` (`LocLink`), `Code`, `InlineCode`, `Quote`, `Callout`, `List`, `Table`, `Divider`, `Badge`, `Details`, `Json` и `RawHTML`.
Обычные компоненты экранируют пользовательский текст. `RawHTML` и HTML из собственных `@Component` считаются доверенными и вставляются как есть.
Функция страницы может вернуть результат `@Component`, строку, список/генератор блоков, вложенные списки или `None`. Обычные строки на странице остаются общими для всех вариантов вывода.
### Ссылки между страницами
`LocalLink` создаёт обычную браузерную ссылку с hash-route. Поэтому ссылку можно копировать, открывать в новой вкладке и активировать с клавиатуры. Клиент перехватывает только обычный клик и показывает нужную страницу без перезагрузки.
```python
# По объекту функции страницы — предпочтительный вариант:
LocalLink("Установка", install_page)
# По точному номеру:
LocalLink("Установка", "1.2")
# По уникальному названию:
LocalLink("Установка", "Install")
# По названию внутри раздела, если названия повторяются:
LocalLink("Установка для сервера", "Install", section="2")
```
Цель по функции разрешается во время компиляции, когда все `@Page` уже зарегистрированы, поэтому можно ссылаться и на функцию, объявленную ниже по файлу. Если название неоднозначно или страница отсутствует, компиляция завершится понятной ошибкой. `LocLink` является коротким псевдонимом `LocalLink`.
### Ключевые кнопки в header
Для навигации верхнего уровня используйте `HeaderButton`. Локальная цель поддерживает те же варианты, что и `LocalLink`: объект функции страницы, номер, уникальный заголовок или заголовок внутри указанного раздела.
```python
run(
header_buttons=[
# Короткая форма по функции страницы:
HeaderButton("Знакомство", introduction_page),
# Явная форма по номеру:
HeaderButton.local("API reference", "3"),
# По названию в определённом разделе:
HeaderButton.local("Установка", "Install", section="2"),
# Внешняя ссылка:
HeaderButton.external("GitHub", "https://github.com/example/project"),
]
)
```
Локальная header-кнопка компилируется в обычный `href="#/page/…"`, открывает страницу без перезагрузки и подсвечивается на всех вложенных страницах своего раздела. Старые словари остаются совместимыми:
```python
header_buttons=[
{"label": "Знакомство", "page": introduction_page},
{"label": "API", "page": "API reference", "section": "3"},
{"label": "GitHub", "href": "https://github.com/", "target": "_blank"},
]
```
## Manifest
Manifest имеет версию схемы, заголовок, `revision`, `updated_at`, страницы, настройки UI, словарь ресурсов и полный клиентский runtime. Системные файлы шаблона и пользовательские дополнения хранятся раздельно, а при загрузке объединяются в указанном порядке:
```json
{
"schema_version": 2,
"title": "My docs",
"revision": "…",
"updated_at": "…",
"pages": [{"number": "1", "title": "Home", "content": "…
"}],
"assets": {"photo.png": {"mime": "image/png", "data": "base64…"}},
"ui": {"header_buttons": []},
"client": {
"css": {"system": "…", "custom": "…"},
"js": {"system": "…", "custom": "…"}
}
}
```
При открытии минимальный HTML-loader показывает тематический fixed-overlay размером `100vw × 100dvh` с индикатором загрузки. Он немедленно читает manifest из IndexedDB; если записи ещё нет, сначала сохраняет встроенную копию. Затем loader применяет CSS, запускает системный JavaScript, рендерит документацию и удаляет overlay.
Только после первого рендера клиент запрашивает маленький файл `version`: пользователь уже может читать документацию и работать с интерфейсом. Если его хеш совпадает с активной ревизией, `manifest.json` вообще не скачивается. При несовпадении клиент загружает manifest, сохраняет обновление в IndexedDB без замены текущей страницы и применяет его при следующем открытии. К обоим URL добавляется уникальный `?no_cache=…`, запрос выполняется с `cache: "no-store"`, а сервер отвечает заголовками `no-store`, `no-cache`, `Pragma` и `Expires`. При недоступном сервере продолжает работать уже отрендеренная кешированная или встроенная версия.
`custom_css` и `custom_js` принимают пути к UTF-8-файлам. Пользовательский CSS добавляется после системного, поэтому может переопределять тему. Пользовательский JavaScript загружается после системного runtime и до события `docslib:rendered`:
```javascript
document.addEventListener("docslib:rendered", (event) => {
console.log(event.detail.manifest.revision);
}, { once: true });
```
При обнаружении новой ревизии клиент отправляет всплывающее DOM-событие `on_manifest_update`. Обработчик из `custom_js` регистрируется до начала фоновой проверки:
```javascript
document.addEventListener("on_manifest_update", (event) => {
const {
manifest,
currentManifest,
revision,
previousRevision,
source,
cached,
applied,
} = event.detail;
console.log(`Manifest ${previousRevision} → ${revision}`, {
manifest,
currentManifest,
source, // "remote" или "embedded"
cached, // удалось ли записать обновление в IndexedDB
applied, // false: оно применится при следующем открытии
});
});
```
Сразу после несовпадения хеша из `version`, но ещё до запроса большого `manifest.json`, отправляется `on_update_available`:
```javascript
document.addEventListener("on_update_available", (event) => {
const { revision, currentRevision, versionUrl, manifestUrl } = event.detail;
console.log(`Доступно обновление ${currentRevision} → ${revision}`, {
versionUrl,
manifestUrl,
});
});
```
Непосредственно перед заменой содержимого при переходе между страницами отправляется всплывающее событие `on_page_change`. В `page` находится полная запись целевой страницы из manifest, а в `previousPage` — предыдущая страница или `null` при первом рендере:
```javascript
document.addEventListener("on_page_change", (event) => {
const { page, previousPage, number, title, index } = event.detail;
console.log(`${previousPage?.number ?? "—"} → ${number}: ${title}`, {
page,
index,
});
});
```
Код из `custom_js` считается доверенным и выполняется в контексте страницы. Изменение системного или пользовательского CSS/JS меняет `revision`, поэтому обновлённый runtime сохраняется в IndexedDB и применяется после перезагрузки клиента.
Версия библиотеки, которой был скомпилирован физический `index.html`, доступна пользовательскому JavaScript в глобальной строковой переменной:
```javascript
console.log(globalThis.COMPILED_FROM_VERSION); // например, "0.3.7"
```
Присваивание добавляется в bootstrap-скрипт самой HTML-ки. Автообновление manifest не меняет `COMPILED_FROM_VERSION`: значение обновится только после новой компиляции и замены `index.html`.
Для печати всей документации из `custom_js` или консоли доступна глобальная функция:
```javascript
PrintFullDocumentation();
```
Она сразу закрывает интерфейс тематическим loading-overlay, формирует единый документ из всех страниц в порядке manifest, подставляет и декодирует все изображения, ожидает готовности шрифтов и только затем открывает системный диалог печати. Каждая страница документации начинается с нового печатного листа. Печатная тема всегда использует белый фон, чёрный текст и компактные заголовки независимо от выбранной экранной темы.
## Live-preview
В live-режиме браузер опрашивает `/live`. Сервер следит за файлами страниц, импортированными модулями с функциями `@Reactive`, а также за системными и пользовательскими CSS/JS. После успешной пересборки браузер загружает новый HTML и восстанавливает позицию прокрутки открытой страницы в пикселях без анимации. В запускаемом файле вызов `run()` обязательно помещать под `if __name__ == "__main__"`, чтобы watcher не запускал второй сервер.
Используйте `@Reactive` для обычных функций уровня модуля, например для локализации или общих значений. Декоратор сохраняет аргументы и возвращаемое значение функции. При редактировании её файла live preview заново выполняет рендер всех страниц.
```python
from docslib import Reactive
@Reactive
def product_name() -> str:
return "Мой продукт"
```
HTTP-загрузка и исполнение произвольных `.py` файлов намеренно отсутствуют: такая ручка была бы удалённым выполнением кода. Live-preview работает с локальными исходниками проекта.
## Свой клиент
Встроенные исходники находятся в `docslib/templates/`: `index.html`, `loader.js`, `client.css`, `client.js`. `loader.js` является небольшим bootstrap-кодом HTML, а `client.css` и `client.js` попадают в manifest. Скопируйте папку, измените файлы и передайте `template_dir="./my-template"`. Компилятор проверяет обязательные placeholders и всё равно выдаёт один автономный HTML.
Для тестов:
```bash
pytest
```