SharedDocsLib/README.md

295 lines
21 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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'<p class="lead">{text}</p>'
@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` ей не требуются.
## Компоненты
Доступны `H1`, `H2`, `H3`, `P`, `MarkdownText`, `Image`, `Link`, `LocalLink` (`LocLink`), `Code`, `InlineCode`, `Quote`, `Callout`, `List`, `Table`, `Divider`, `Badge`, `Details`, `Json` и `RawHTML`.
Обычные компоненты экранируют пользовательский текст. `RawHTML` и HTML из собственных `@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": "<h1>…</h1>"}],
"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`. Сервер следит за файлами, в которых объявлены страницы, а также за системными и пользовательскими CSS/JS. При изменении он пересобирает manifest; клиент сохраняет его и перезагружается с новым runtime. В запускаемом файле вызов `run()` обязательно помещать под `if __name__ == "__main__"`, чтобы watcher не запускал второй сервер.
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
```