From 529a9f1c47ab8b62136ee670683f0ddd13b8fcb2 Mon Sep 17 00:00:00 2001
From: Server <10.9.8.250@reg.snw.su>
Date: Sun, 9 Aug 2026 17:59:49 +0000
Subject: [PATCH] Initial SharedDocsLib implementation
---
.gitignore | 8 +
LICENSE | 22 +
README.md | 169 ++++++
docslib/__init__.py | 8 +
docslib/compiler.py | 221 +++++++
docslib/components.py | 169 ++++++
docslib/hooks.py | 65 ++
docslib/registry.py | 105 ++++
docslib/server.py | 211 +++++++
docslib/templates/client.css | 141 +++++
docslib/templates/client.js | 361 +++++++++++
docslib/templates/index.html | 60 ++
docslib/ui.py | 43 ++
examples/assets/architecture.svg | 33 ++
examples/assets/inject.css | 6 +
examples/basic.py | 70 +++
pyproject.toml | 30 +
tests/conftest.py | 11 +
tests/test_components.py | 64 ++
tests/test_server.py | 117 ++++
tests/test_ui.py | 19 +
uv.lock | 986 +++++++++++++++++++++++++++++++
22 files changed, 2919 insertions(+)
create mode 100644 .gitignore
create mode 100644 LICENSE
create mode 100644 README.md
create mode 100644 docslib/__init__.py
create mode 100644 docslib/compiler.py
create mode 100644 docslib/components.py
create mode 100644 docslib/hooks.py
create mode 100644 docslib/registry.py
create mode 100644 docslib/server.py
create mode 100644 docslib/templates/client.css
create mode 100644 docslib/templates/client.js
create mode 100644 docslib/templates/index.html
create mode 100644 docslib/ui.py
create mode 100644 examples/assets/architecture.svg
create mode 100644 examples/assets/inject.css
create mode 100644 examples/basic.py
create mode 100644 pyproject.toml
create mode 100644 tests/conftest.py
create mode 100644 tests/test_components.py
create mode 100644 tests/test_server.py
create mode 100644 tests/test_ui.py
create mode 100644 uv.lock
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..1458748
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,8 @@
+.venv/
+__pycache__/
+*.py[cod]
+.pytest_cache/
+*.egg-info/
+dist/
+build/
+
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..a6e4d64
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,22 @@
+MIT License
+
+Copyright (c) 2026 SharedDocsLib contributors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..119bf6d
--- /dev/null
+++ b/README.md
@@ -0,0 +1,169 @@
+# SharedDocsLib
+
+SharedDocsLib — небольшой Python-фреймворк для документации. Страницы и HTML-компоненты описываются функциями с декораторами, а библиотека собирает Obsidian-подобный автономный клиент и отдаёт его через FastAPI.
+
+## Возможности
+
+- страницы с номерами `1`, `1.1`, `1.1.2` и автоматическим деревом;
+- собственные `@Component` и набор безопасных базовых компонентов;
+- поиск по всему контенту, светлая/тёмная тема, breadcrumbs, Previous/Next;
+- изображения из `assets_dir`, единожды закодированные в base64 внутри manifest;
+- полностью автономный `index.html`: CSS, JavaScript и начальный manifest встроены;
+- хранение manifest в IndexedDB и одна проверка обновления при загрузке;
+- `index.zip`, содержащий только `index.html`;
+- live-preview с перекомпиляцией изменившегося Python-модуля.
+
+UI не содержит языко-зависимых служебных подписей: поиск, тема и мобильные меню обозначены пиктограммами, а в статусе показываются ISO-дата manifest и короткий `revision`. Светлая палитра нейтральная, тёмная использует настоящий AMOLED-чёрный. На мобильных устройствах header-кнопки открываются в отдельном правом drawer высотой `100dvh`.
+
+## Установка и запуск примера
+
+```bash
+python3 -m venv .venv
+source .venv/bin/activate
+pip install -e '.[dev]'
+python examples/basic.py
+```
+
+После запуска доступны:
+
+- `GET http://127.0.0.1:1234/manifest.json`
+- `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",
+ 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`. Для совместимости также принят вариант `manifest_parh` из первоначального API, но в новом коде лучше использовать правильное имя.
+
+## Компоненты
+
+Доступны `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 и словарь ресурсов:
+
+```json
+{
+ "schema_version": 1,
+ "title": "My docs",
+ "revision": "…",
+ "updated_at": "…",
+ "pages": [{"number": "1", "title": "Home", "content": "
…
"}],
+ "assets": {"photo.png": {"mime": "image/png", "data": "base64…"}},
+ "ui": {"header_buttons": []}
+}
+```
+
+При первом открытии встроенный manifest сохраняется в IndexedDB. Затем клиент один раз запрашивает `manifest_path`. Если `revision` или `updated_at` отличаются, новый manifest заменяет старый и страница перезагружается. При недоступном сервере продолжает работать кеш или встроенная копия.
+
+## Live-preview
+
+В live-режиме браузер опрашивает только `/live`. Сервер следит за файлами, в которых объявлены страницы, и при изменении повторно выполняет их, после чего клиент получает новый manifest. В запускаемом файле вызов `run()` обязательно помещать под `if __name__ == "__main__"`, чтобы watcher не запускал второй сервер.
+
+HTTP-загрузка и исполнение произвольных `.py` файлов намеренно отсутствуют: такая ручка была бы удалённым выполнением кода. Live-preview работает с локальными исходниками проекта.
+
+## Свой клиент
+
+Встроенные исходники находятся в `docslib/templates/`: `index.html`, `client.css`, `client.js`. Скопируйте папку, измените файлы и передайте `template_dir="./my-template"`. Компилятор проверяет обязательные placeholders и всё равно выдаёт один автономный HTML.
+
+Для тестов:
+
+```bash
+pytest
+```
diff --git a/docslib/__init__.py b/docslib/__init__.py
new file mode 100644
index 0000000..99ed2a6
--- /dev/null
+++ b/docslib/__init__.py
@@ -0,0 +1,8 @@
+"""Public API for SharedDocsLib."""
+
+from .hooks import Component, Page
+from .server import create_app, run
+from .ui import HeaderButton
+
+__all__ = ["Component", "HeaderButton", "Page", "create_app", "run"]
+__version__ = "0.1.0"
diff --git a/docslib/compiler.py b/docslib/compiler.py
new file mode 100644
index 0000000..ef03409
--- /dev/null
+++ b/docslib/compiler.py
@@ -0,0 +1,221 @@
+from __future__ import annotations
+
+import base64
+import hashlib
+import io
+import json
+import mimetypes
+import re
+import zipfile
+from dataclasses import dataclass
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Iterable, Mapping, Sequence
+
+from .registry import PageDefinition, registry
+from .ui import HeaderButton
+
+ASSET_PATTERN = re.compile(r"data-docslib-asset\s*=\s*(['\"])(?P.*?)\1", re.IGNORECASE)
+
+
+@dataclass(frozen=True, slots=True)
+class BuildResult:
+ manifest: dict[str, object]
+ html: bytes
+ zip_archive: bytes
+
+
+def _natural_part(value: str) -> tuple[tuple[int, object], ...]:
+ return tuple(
+ (0, int(part)) if part.isdigit() else (1, part.casefold())
+ for part in re.split(r"(\d+)", value)
+ if part
+ )
+
+
+def page_sort_key(page: PageDefinition) -> tuple[tuple[tuple[int, object], ...], ...]:
+ return tuple(_natural_part(part) for part in page.number.split("."))
+
+
+def _flatten_content(value: object) -> Iterable[str]:
+ if value is None:
+ return
+ if isinstance(value, str):
+ yield value
+ return
+ if isinstance(value, (bytes, bytearray, Mapping)):
+ raise TypeError("Page content must be HTML strings or an iterable of HTML strings")
+ try:
+ iterator = iter(value) # type: ignore[arg-type]
+ except TypeError as error:
+ raise TypeError(
+ f"Page content must be HTML strings or an iterable, got {type(value).__name__}"
+ ) from error
+ for item in iterator:
+ yield from _flatten_content(item)
+
+
+def _safe_json_for_script(value: object) -> str:
+ return (
+ json.dumps(value, ensure_ascii=False, separators=(",", ":"))
+ .replace("<", "\\u003c")
+ .replace(">", "\\u003e")
+ .replace("&", "\\u0026")
+ .replace("\u2028", "\\u2028")
+ .replace("\u2029", "\\u2029")
+ )
+
+
+def _read_optional_file(path: str | Path | None) -> str:
+ if path is None:
+ return ""
+ file_path = Path(path).expanduser().resolve()
+ if not file_path.is_file():
+ raise FileNotFoundError(f"CSS file does not exist: {file_path}")
+ return file_path.read_text(encoding="utf-8")
+
+
+class Compiler:
+ def __init__(
+ self,
+ *,
+ title: str = "Documentation",
+ assets_dir: str | Path = "./assets",
+ custom_css: str | Path | None = None,
+ template_dir: str | Path | None = None,
+ manifest_url: str = "./manifest.json",
+ live_preview: bool = False,
+ header_buttons: Sequence[HeaderButton | Mapping[str, object]] | None = None,
+ ) -> None:
+ self.title = str(title)
+ self.assets_dir = Path(assets_dir).expanduser().resolve()
+ self.custom_css = custom_css
+ self.template_dir = (
+ Path(template_dir).expanduser().resolve()
+ if template_dir
+ else Path(__file__).with_name("templates")
+ )
+ self.manifest_url = manifest_url
+ self.live_preview = bool(live_preview)
+ self.header_buttons = list(header_buttons or [])
+
+ def build(self) -> BuildResult:
+ pages = self._render_pages(registry.pages())
+ assets = self._load_assets(pages)
+ stable_content: dict[str, object] = {
+ "schema_version": 1,
+ "title": self.title,
+ "pages": pages,
+ "assets": assets,
+ "ui": {"header_buttons": self._compile_header_buttons()},
+ }
+ canonical = json.dumps(stable_content, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
+ revision = hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:20]
+ manifest = {
+ **stable_content,
+ "revision": revision,
+ "updated_at": datetime.now(timezone.utc).isoformat(timespec="milliseconds"),
+ }
+ html = self._build_html(manifest).encode("utf-8")
+ archive_buffer = io.BytesIO()
+ with zipfile.ZipFile(archive_buffer, "w", compression=zipfile.ZIP_DEFLATED) as archive:
+ archive.writestr("index.html", html)
+ return BuildResult(manifest=manifest, html=html, zip_archive=archive_buffer.getvalue())
+
+ def _render_pages(self, definitions: list[PageDefinition]) -> list[dict[str, str]]:
+ rendered: list[dict[str, str]] = []
+ for page in sorted(definitions, key=page_sort_key):
+ try:
+ content = "\n".join(_flatten_content(page.render()))
+ except Exception as error:
+ raise RuntimeError(f"Failed to render page {page.number!r} ({page.title})") from error
+ rendered.append({"number": page.number, "title": page.title, "content": content})
+ return rendered
+
+ def _load_assets(self, pages: list[dict[str, str]]) -> dict[str, dict[str, str]]:
+ names = {
+ match.group("name")
+ for page in pages
+ for match in ASSET_PATTERN.finditer(page["content"])
+ }
+ assets: dict[str, dict[str, str]] = {}
+ for name in sorted(names):
+ candidate = (self.assets_dir / name).resolve()
+ try:
+ candidate.relative_to(self.assets_dir)
+ except ValueError as error:
+ raise ValueError(f"Asset escapes assets_dir: {name}") from error
+ if not candidate.is_file():
+ raise FileNotFoundError(f"Referenced asset does not exist: {candidate}")
+ mime = mimetypes.guess_type(candidate.name)[0] or "application/octet-stream"
+ assets[name] = {
+ "mime": mime,
+ "data": base64.b64encode(candidate.read_bytes()).decode("ascii"),
+ }
+ return assets
+
+ def _compile_header_buttons(self) -> list[dict[str, str]]:
+ compiled: list[dict[str, str]] = []
+ for definition in self.header_buttons:
+ if isinstance(definition, HeaderButton):
+ label = definition.label
+ page_target = definition.page
+ href = definition.href
+ section = definition.section
+ new_tab = definition.new_tab
+ elif isinstance(definition, Mapping):
+ label = str(definition.get("label", "")).strip()
+ page_target = definition.get("page")
+ href_value = definition.get("href")
+ href = str(href_value) if href_value is not None else None
+ section = definition.get("section")
+ new_tab = definition.get("target") == "_blank" or bool(definition.get("new_tab", False))
+ if not label:
+ raise ValueError("Header button mapping requires a non-empty label")
+ if (page_target is None) == (href is None):
+ raise ValueError(
+ f"Header button {label!r} requires exactly one of 'page' or 'href'"
+ )
+ else:
+ raise TypeError("header_buttons entries must be HeaderButton objects or mappings")
+
+ if page_target is not None:
+ page = registry.resolve_page(page_target, section=section) # type: ignore[arg-type]
+ compiled.append({"label": label, "page": page.number})
+ else:
+ button = {"label": label, "href": str(href)}
+ if new_tab:
+ button["target"] = "_blank"
+ compiled.append(button)
+ return compiled
+
+ def _build_html(self, manifest: dict[str, object]) -> str:
+ base_path = self.template_dir / "index.html"
+ css_path = self.template_dir / "client.css"
+ script_path = self.template_dir / "client.js"
+ for path in (base_path, css_path, script_path):
+ if not path.is_file():
+ raise FileNotFoundError(f"Template file does not exist: {path}")
+ template = base_path.read_text(encoding="utf-8")
+ config = {
+ "manifestUrl": self.manifest_url,
+ "livePreview": self.live_preview,
+ "liveUrl": "./live",
+ "cacheKey": hashlib.sha256(self.manifest_url.encode("utf-8")).hexdigest()[:16],
+ }
+ replacements = {
+ "{{DOCSLIB_TITLE}}": self.title.replace("&", "&").replace("<", "<").replace(">", ">"),
+ "{{DOCSLIB_CSS}}": css_path.read_text(encoding="utf-8"),
+ "{{DOCSLIB_CUSTOM_CSS}}": _read_optional_file(self.custom_css),
+ "{{DOCSLIB_MANIFEST}}": _safe_json_for_script(manifest),
+ "{{DOCSLIB_CONFIG}}": _safe_json_for_script(config),
+ "{{DOCSLIB_JS}}": script_path.read_text(encoding="utf-8"),
+ }
+ for marker, value in replacements.items():
+ if marker not in template:
+ raise ValueError(f"Template is missing required marker {marker}")
+ template = template.replace(marker, value)
+ return template
+
+
+__all__ = ["BuildResult", "Compiler", "page_sort_key"]
diff --git a/docslib/components.py b/docslib/components.py
new file mode 100644
index 0000000..60e55a1
--- /dev/null
+++ b/docslib/components.py
@@ -0,0 +1,169 @@
+from __future__ import annotations
+
+import json
+from html import escape
+from pathlib import PurePosixPath
+from typing import Iterable, Mapping, Sequence
+from urllib.parse import quote
+
+from .hooks import Component
+from .registry import registry
+
+
+def _class_attr(class_name: str | None) -> str:
+ return f' class="{escape(class_name, quote=True)}"' if class_name else ""
+
+
+@Component
+def H1(text: object, *, id: str | None = None) -> str:
+ anchor = f' id="{escape(id, quote=True)}"' if id else ""
+ return f"
{escape(str(text))}
"
+
+
+@Component
+def H2(text: object, *, id: str | None = None) -> str:
+ anchor = f' id="{escape(id, quote=True)}"' if id else ""
+ return f"
{escape(str(text))}
"
+
+
+@Component
+def H3(text: object, *, id: str | None = None) -> str:
+ anchor = f' id="{escape(id, quote=True)}"' if id else ""
+ return f"