295 lines
21 KiB
Markdown
295 lines
21 KiB
Markdown
# 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
|
||
```
|