commit 529a9f1c47ab8b62136ee670683f0ddd13b8fcb2 Author: Server <10.9.8.250@reg.snw.su> Date: Sun Aug 9 17:59:49 2026 +0000 Initial SharedDocsLib implementation 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"{escape(str(text))}" + + +@Component +def P(text: object, *, class_name: str | None = None) -> str: + return f"{escape(str(text))}

" + + +@Component +def MarkdownText(text: object) -> str: + """Render preformatted plain text. This component intentionally does not parse Markdown.""" + return f'
{escape(str(text)).replace(chr(10), "
")}
' + + +@Component +def RawHTML(html: str) -> str: + """Insert trusted HTML without escaping.""" + return str(html) + + +@Component +def Image( + name: str, + alt: str = "", + *, + caption: str | None = None, + width: int | str | None = None, +) -> str: + normalised = PurePosixPath(str(name).replace("\\", "/")) + if normalised.is_absolute() or ".." in normalised.parts: + raise ValueError("Image name must be a relative path inside assets_dir") + asset_name = str(normalised) + width_attr = f' width="{escape(str(width), quote=True)}"' if width is not None else "" + image = ( + f'' + ) + if caption is None: + return image + return f"
{image}
{escape(caption)}
" + + +@Component +def Link(text: object, href: str, *, external: bool = False) -> str: + extras = ' target="_blank" rel="noopener noreferrer"' if external else "" + return f'{escape(str(text))}' + + +@Component +def LocalLink( + text: object, + target: object, + *, + section: str | int | float | None = None, +) -> str: + """Link to a page by number, title, or the decorated page function itself.""" + page = registry.resolve_page(target, section=section) # type: ignore[arg-type] + href = f"#/page/{quote(page.number, safe='.')}" + return ( + f'' + f"{escape(str(text))}" + ) + + +# Short alias matching the original API idea. +LocLink = LocalLink + + +@Component +def Code(code: object, language: str = "") -> str: + language_class = f' class="language-{escape(language, quote=True)}"' if language else "" + return f"
{escape(str(code))}
" + + +@Component +def InlineCode(code: object) -> str: + return f"{escape(str(code))}" + + +@Component +def Quote(text: object, *, source: str | None = None) -> str: + footer = f"
— {escape(source)}
" if source else "" + return f"

{escape(str(text))}

{footer}
" + + +@Component +def Callout(text: object, *, title: str = "Note", kind: str = "note") -> str: + safe_kind = "".join(char for char in kind.lower() if char.isalnum() or char in "_-") or "note" + return ( + f'" + ) + + +@Component +def List(items: Iterable[object], *, ordered: bool = False) -> str: + tag = "ol" if ordered else "ul" + values = "".join(f"
  • {escape(str(item))}
  • " for item in items) + return f"<{tag}>{values}" + + +@Component +def Table(headers: Sequence[object], rows: Iterable[Sequence[object]]) -> str: + head = "".join(f"{escape(str(value))}" for value in headers) + body = "".join( + "" + "".join(f"{escape(str(value))}" for value in row) + "" + for row in rows + ) + return f"
    {head}{body}
    " + + +@Component +def Divider() -> str: + return "
    " + + +@Component +def Badge(text: object, *, color: str = "accent") -> str: + safe_color = "".join(char for char in color.lower() if char.isalnum() or char in "_-") or "accent" + return f'{escape(str(text))}' + + +@Component +def Details(summary: object, content: object, *, open: bool = False) -> str: + open_attr = " open" if open else "" + return f"{escape(str(summary))}

    {escape(str(content))}

    " + + +@Component +def Json(data: Mapping[str, object] | Sequence[object]) -> str: + return Code(json.dumps(data, ensure_ascii=False, indent=2), "json") + + +__all__ = [ + "Badge", "Callout", "Code", "Details", "Divider", "H1", "H2", "H3", "Image", + "InlineCode", "Json", "Link", "List", "LocalLink", "LocLink", "MarkdownText", "P", "Quote", + "RawHTML", "Table", +] diff --git a/docslib/hooks.py b/docslib/hooks.py new file mode 100644 index 0000000..e6b61c7 --- /dev/null +++ b/docslib/hooks.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +import inspect +from functools import update_wrapper +from pathlib import Path +from typing import Callable, Generic, ParamSpec + +from .registry import PageDefinition, registry + +P = ParamSpec("P") + + +def _normalise_page_number(number: str | int | float) -> str: + value = str(number).strip().strip(".") + parts = [part.strip() for part in value.split(".")] + if not value or any(not part for part in parts): + raise ValueError("Page number must contain non-empty dot-separated parts") + return ".".join(parts) + + +class Component(Generic[P]): + """Turn a function returning HTML into a reusable documentation component.""" + + def __init__(self, function: Callable[P, str]) -> None: + if not callable(function): + raise TypeError("@Component must decorate a callable") + self.function = function + update_wrapper(self, function) + + def __call__(self, *args: P.args, **kwargs: P.kwargs) -> str: + result = self.function(*args, **kwargs) + if not isinstance(result, str): + raise TypeError(f"Component {self.__name__} must return str, got {type(result).__name__}") + return result + + +class Page: + """Register a zero-argument function as a numbered documentation page.""" + + def __init__(self, number: str | int | float, title: str) -> None: + self.number = _normalise_page_number(number) + self.title = str(title).strip() + if not self.title: + raise ValueError("Page title cannot be empty") + + def __call__(self, function: Callable[[], object]) -> Callable[[], object]: + signature = inspect.signature(function) + if signature.parameters: + raise TypeError(f"Page function {function.__name__} must not accept arguments") + source = inspect.getsourcefile(function) + source_file = str(Path(source).resolve()) if source else None + registry.register( + PageDefinition( + number=self.number, + title=self.title, + render=function, + module=function.__module__, + source_file=source_file, + ) + ) + return function + + +__all__ = ["Component", "Page"] + diff --git a/docslib/registry.py b/docslib/registry.py new file mode 100644 index 0000000..495b74b --- /dev/null +++ b/docslib/registry.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from threading import RLock +from typing import Callable + + +@dataclass(frozen=True, slots=True) +class PageDefinition: + number: str + title: str + render: Callable[[], object] + module: str + source_file: str | None + + +class Registry: + def __init__(self) -> None: + self._pages: dict[str, PageDefinition] = {} + self._lock = RLock() + + def register(self, page: PageDefinition) -> None: + with self._lock: + previous = self._pages.get(page.number) + if previous is not None and previous.render is not page.render: + raise ValueError(f"Page number {page.number!r} is already registered") + self._pages[page.number] = page + + def pages(self) -> list[PageDefinition]: + with self._lock: + return list(self._pages.values()) + + def resolve_page( + self, + target: str | int | float | Callable[..., object], + *, + section: str | int | float | None = None, + ) -> PageDefinition: + """Resolve a page by function object, number, or title within an optional section.""" + with self._lock: + pages = list(self._pages.values()) + + if callable(target): + matches = [page for page in pages if page.render is target] + if not matches: + name = getattr(target, "__name__", repr(target)) + raise ValueError(f"No @Page is registered for function {name}") + return matches[0] + + value = str(target).strip() + if not value: + raise ValueError("Local link target cannot be empty") + normalised_number = value.strip(".") + by_number = [page for page in pages if page.number == normalised_number] + if by_number: + return by_number[0] + + section_number = str(section).strip().strip(".") if section is not None else None + if section_number == "": + raise ValueError("Local link section cannot be empty") + candidates = [ + page + for page in pages + if page.title.casefold() == value.casefold() + and ( + section_number is None + or page.number == section_number + or page.number.startswith(section_number + ".") + ) + ] + if not candidates: + location = f" inside section {section_number!r}" if section_number else "" + raise ValueError(f"No page titled {value!r}{location}") + if len(candidates) > 1: + numbers = ", ".join(page.number for page in candidates) + raise ValueError( + f"Page title {value!r} is ambiguous ({numbers}); provide section= or a page number" + ) + return candidates[0] + + def snapshot(self) -> dict[str, PageDefinition]: + with self._lock: + return dict(self._pages) + + def restore(self, snapshot: dict[str, PageDefinition]) -> None: + with self._lock: + self._pages = dict(snapshot) + + def remove_source(self, source_file: str | Path) -> None: + target = str(Path(source_file).resolve()) + with self._lock: + self._pages = { + key: page + for key, page in self._pages.items() + if not page.source_file or str(Path(page.source_file).resolve()) != target + } + + def clear(self) -> None: + """Clear all registered pages. Primarily useful in test suites.""" + with self._lock: + self._pages.clear() + + +registry = Registry() diff --git a/docslib/server.py b/docslib/server.py new file mode 100644 index 0000000..7fc3201 --- /dev/null +++ b/docslib/server.py @@ -0,0 +1,211 @@ +from __future__ import annotations + +import importlib +import runpy +import sys +from pathlib import Path +from threading import RLock +from typing import Mapping, Sequence + +import uvicorn +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import HTMLResponse, JSONResponse, Response + +from .compiler import BuildResult, Compiler +from .registry import registry +from .ui import HeaderButton + + +def _manifest_url(manifest_path: str | None, manifest_parh: str | None) -> str: + if manifest_path and manifest_parh: + raise ValueError("Use either manifest_path or the legacy typo manifest_parh, not both") + value = manifest_path or manifest_parh or "./manifest.json" + if value.endswith(".json"): + return value + return value.rstrip("/") + "/manifest.json" + + +class DocumentationSite: + def __init__(self, compiler: Compiler, *, live_preview: bool) -> None: + self.compiler = compiler + self.live_preview = live_preview + self._lock = RLock() + self._result = compiler.build() + self._last_error: str | None = None + self._source_modules: dict[Path, set[str]] = {} + self._source_mtimes: dict[Path, int] = {} + self._discover_sources() + + @property + def result(self) -> BuildResult: + with self._lock: + return self._result + + @property + def last_error(self) -> str | None: + with self._lock: + return self._last_error + + def _discover_sources(self) -> None: + for page in registry.pages(): + if not page.source_file: + continue + path = Path(page.source_file).resolve() + self._source_modules.setdefault(path, set()).add(page.module) + try: + self._source_mtimes[path] = path.stat().st_mtime_ns + except OSError: + continue + + def refresh_changed_sources(self) -> bool: + if not self.live_preview: + return False + with self._lock: + changed = [] + for path, previous in self._source_mtimes.items(): + try: + current = path.stat().st_mtime_ns + except OSError: + continue + if current != previous: + changed.append((path, current)) + if not changed: + return False + + snapshot = registry.snapshot() + try: + for path, _ in changed: + self._reload_source(path) + new_result = self.compiler.build() + except Exception as error: + registry.restore(snapshot) + self._last_error = f"{type(error).__name__}: {error}" + for path, mtime in changed: + self._source_mtimes[path] = mtime + return False + + self._result = new_result + self._last_error = None + for path, mtime in changed: + self._source_mtimes[path] = mtime + self._source_modules.clear() + self._discover_sources() + return True + + def _reload_source(self, path: Path) -> None: + modules = self._source_modules.get(path, set()) + registry.remove_source(path) + reloadable = [name for name in modules if name not in {"__main__", ""} and name in sys.modules] + if reloadable: + for name in reloadable: + importlib.reload(sys.modules[name]) + else: + # run_name is deliberately not __main__, so a guarded run() call is not executed again. + runpy.run_path(str(path), run_name=f"_docslib_live_{abs(hash(path))}") + + +def create_app( + *, + title: str = "Documentation", + assets_dir: str | Path = "./assets", + custom_css: str | Path | None = None, + template_dir: str | Path | None = None, + manifest_path: str | None = None, + manifest_parh: str | None = None, + live_preview: bool = False, + header_buttons: Sequence[HeaderButton | Mapping[str, object]] | None = None, + cors_origins: Sequence[str] = ("*",), +) -> FastAPI: + """Compile registered pages and return a ready-to-serve FastAPI application.""" + manifest_url = _manifest_url(manifest_path, manifest_parh) + compiler = Compiler( + title=title, + assets_dir=assets_dir, + custom_css=custom_css, + template_dir=template_dir, + manifest_url=manifest_url, + live_preview=live_preview, + header_buttons=header_buttons, + ) + site = DocumentationSite(compiler, live_preview=live_preview) + app = FastAPI(title=f"{title} · SharedDocsLib", docs_url=None, redoc_url=None) + app.state.docslib = site + app.add_middleware( + CORSMiddleware, + allow_origins=list(cors_origins), + allow_credentials=False, + allow_methods=["GET"], + allow_headers=["*"], + ) + + @app.get("/", include_in_schema=False) + @app.get("/index.html", response_class=HTMLResponse) + def index() -> HTMLResponse: + return HTMLResponse(site.result.html) + + @app.get("/manifest.json", response_class=JSONResponse) + def manifest() -> JSONResponse: + return JSONResponse(site.result.manifest, headers={"Cache-Control": "no-store"}) + + @app.get("/index.zip") + def index_zip() -> Response: + return Response( + site.result.zip_archive, + media_type="application/zip", + headers={ + "Content-Disposition": 'attachment; filename="index.zip"', + "Cache-Control": "no-store", + }, + ) + + if live_preview: + @app.get("/live") + def live() -> JSONResponse: + changed = site.refresh_changed_sources() + result = site.result + return JSONResponse( + { + "enabled": True, + "changed": changed, + "revision": result.manifest["revision"], + "updated_at": result.manifest["updated_at"], + "error": site.last_error, + }, + headers={"Cache-Control": "no-store"}, + ) + + return app + + +def run( + *, + port: int = 8000, + host: str = "127.0.0.1", + title: str = "Documentation", + assets_dir: str | Path = "./assets", + custom_css: str | Path | None = None, + template_dir: str | Path | None = None, + manifest_path: str | None = None, + manifest_parh: str | None = None, + live_preview: bool = False, + header_buttons: Sequence[HeaderButton | Mapping[str, object]] | None = None, + cors_origins: Sequence[str] = ("*",), + log_level: str = "info", +) -> None: + """Compile the documentation and run it with Uvicorn.""" + app = create_app( + title=title, + assets_dir=assets_dir, + custom_css=custom_css, + template_dir=template_dir, + manifest_path=manifest_path, + manifest_parh=manifest_parh, + live_preview=live_preview, + header_buttons=header_buttons, + cors_origins=cors_origins, + ) + uvicorn.run(app, host=host, port=port, log_level=log_level) + + +__all__ = ["DocumentationSite", "create_app", "run"] diff --git a/docslib/templates/client.css b/docslib/templates/client.css new file mode 100644 index 0000000..7a42610 --- /dev/null +++ b/docslib/templates/client.css @@ -0,0 +1,141 @@ +:root { + --bg: #f5f5f5; + --surface: #ffffff; + --sidebar: #ededed; + --text: #171717; + --muted: #6f6f6f; + --faint: #a3a3a3; + --border: #d9d9d9; + --hover: #e3e3e3; + --accent: #171717; + --accent-soft: rgba(0, 0, 0, .08); + --code: #ebebeb; + --shadow: 0 18px 60px rgba(0, 0, 0, .16); + color-scheme: light; +} + +:root[data-theme="dark"] { + --bg: #000000; + --surface: #070707; + --sidebar: #000000; + --text: #f5f5f5; + --muted: #a3a3a3; + --faint: #666666; + --border: #242424; + --hover: #121212; + --accent: #ffffff; + --accent-soft: rgba(255, 255, 255, .10); + --code: #090909; + --shadow: 0 22px 70px rgba(0, 0, 0, .8); + color-scheme: dark; +} + +* { box-sizing: border-box; } +[hidden] { display: none !important; } +html, body { width: 100%; height: 100dvh; min-height: 100dvh; max-height: 100dvh; overflow: hidden; } +html { background: var(--bg); } +body { margin: 0; color: var(--text); background: var(--bg); font: 15px/1.65 Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } +button, input { font: inherit; } +button, a { -webkit-tap-highlight-color: transparent; } +.app-shell { position: relative; width: 100%; height: 100%; overflow: hidden; } +.sidebar { position: fixed; inset: 0 auto 0 0; z-index: 20; display: flex; width: 288px; height: 100%; flex-direction: column; border-right: 1px solid var(--border); background: var(--sidebar); } +.brand-row { display: flex; align-items: center; min-height: 64px; padding: 13px 16px; } +.brand-copy { display: flex; min-width: 0; flex: 1; flex-direction: column; line-height: 1.2; } +.brand-copy strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14px; } +.sidebar-actions { display: flex; gap: 7px; padding: 5px 12px 13px; border-bottom: 1px solid var(--border); } +.action-button, .icon-button, .key-button { border: 0; color: var(--muted); background: transparent; cursor: pointer; } +.action-button { display: grid; width: 36px; height: 36px; flex: 0 0 auto; place-items: center; padding: 0; border-radius: 7px; } +.action-button:hover, .icon-button:hover, .key-button:hover { color: var(--text); background: var(--hover); } +.icon-button { display: grid; width: 36px; height: 36px; flex: 0 0 auto; place-items: center; border-radius: 7px; } +.page-tree { flex: 1; overflow: auto; padding: 12px 9px 24px; scrollbar-width: thin; } +.tree-group { margin: 1px 0; } +.tree-children { margin-left: 12px; padding-left: 8px; border-left: 1px solid var(--border); } +.tree-link { display: flex; width: 100%; min-height: 33px; align-items: center; gap: 7px; padding: 5px 9px; border: 0; border-radius: 6px; color: var(--muted); background: transparent; cursor: pointer; text-align: left; } +.tree-link:hover { color: var(--text); background: var(--hover); } +.tree-link.active { color: var(--text); background: var(--accent-soft); font-weight: 600; } +.tree-number { min-width: 26px; color: var(--faint); font: 10px ui-monospace, monospace; } +.tree-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.update-status { display: flex; min-height: 48px; align-items: center; gap: 9px; padding: 0 17px; border-top: 1px solid var(--border); color: var(--muted); font-size: 11px; } +.update-status i { width: 7px; height: 7px; border-radius: 50%; background: #55a96b; box-shadow: 0 0 0 3px rgba(85, 169, 107, .11); } +.update-status.syncing i { background: #d8a441; animation: pulse 1s infinite; } +.update-status.error i { background: #d75b5b; } +@keyframes pulse { 50% { opacity: .35; } } +.workspace { height: 100%; min-height: 0; margin-left: 288px; overflow-x: hidden; overflow-y: auto; overscroll-behavior-y: contain; scroll-behavior: smooth; -webkit-overflow-scrolling: touch; } +.topbar { position: sticky; top: 0; z-index: 10; display: flex; min-height: 58px; align-items: center; gap: 10px; padding: 0 30px; border-bottom: 1px solid color-mix(in srgb, var(--border), transparent 18%); background: color-mix(in srgb, var(--bg), transparent 7%); backdrop-filter: blur(16px); } +.breadcrumbs { min-width: 0; flex: 1; overflow: hidden; color: var(--muted); text-overflow: ellipsis; white-space: nowrap; font-size: 12px; } +.breadcrumbs span + span::before { margin: 0 8px; color: var(--faint); content: "/"; } +.header-actions { display: flex; max-width: 55%; align-items: center; } +.header-buttons { display: flex; align-items: center; gap: 8px; overflow-x: auto; scrollbar-width: none; } +.header-buttons::-webkit-scrollbar { display: none; } +.header-link { padding: 5px 10px; border: 1px solid var(--border); border-radius: 7px; color: var(--muted); text-decoration: none; font-size: 12px; } +.header-link:hover { color: var(--text); border-color: var(--faint); background: var(--surface); } +.header-link.active { color: var(--accent); border-color: color-mix(in srgb, var(--accent), transparent 45%); background: var(--accent-soft); } +.content { width: min(820px, calc(100% - 64px)); min-height: calc(100dvh - 180px); margin: 0 auto; padding: 62px 0 70px; outline: none; } +.content > :first-child { margin-top: 0; } +.content h1 { margin: 0 0 1.4rem; font-size: clamp(2rem, 5vw, 3.1rem); line-height: 1.1; letter-spacing: -.04em; } +.content h2 { margin: 2.4rem 0 .75rem; padding-bottom: .35rem; border-bottom: 1px solid var(--border); font-size: 1.55rem; letter-spacing: -.02em; } +.content h3 { margin: 1.8rem 0 .5rem; font-size: 1.15rem; } +.content p { margin: .7rem 0 1rem; } +.content a { color: var(--accent); text-underline-offset: 3px; } +.content img { display: block; max-width: 100%; height: auto; margin: 1.35rem auto; border-radius: 8px; box-shadow: 0 7px 25px rgba(0, 0, 0, .12); } +.content figure { margin: 1.5rem 0; } +.content figure img { margin-bottom: .55rem; } +.content figcaption { color: var(--muted); text-align: center; font-size: 12px; } +.content pre { overflow: auto; margin: 1.2rem 0; padding: 17px 19px; border: 1px solid var(--border); border-radius: 9px; background: var(--code); line-height: 1.55; } +.content code { padding: .12em .32em; border-radius: 4px; background: var(--code); font: .88em/1.5 "SFMono-Regular", Consolas, monospace; } +.content pre code { padding: 0; background: transparent; } +.content blockquote { margin: 1.3rem 0; padding: .3rem 1.2rem; border-left: 3px solid var(--accent); color: var(--muted); } +.content blockquote footer { font-size: 12px; } +.content hr { margin: 2.5rem 0; border: 0; border-top: 1px solid var(--border); } +.content details { margin: 1rem 0; padding: .8rem 1rem; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); } +.content summary { cursor: pointer; font-weight: 650; } +.callout { margin: 1.25rem 0; padding: 13px 16px; border: 1px solid color-mix(in srgb, var(--accent), transparent 68%); border-left: 4px solid var(--accent); border-radius: 7px; background: var(--accent-soft); } +.callout strong { display: block; margin-bottom: 3px; } +.badge { display: inline-block; padding: 1px 7px; border-radius: 999px; color: var(--accent); background: var(--accent-soft); font-size: .8em; font-weight: 700; } +.table-wrap { overflow-x: auto; margin: 1.2rem 0; } +.content table { width: 100%; border-collapse: collapse; } +.content th, .content td { padding: 9px 12px; border: 1px solid var(--border); text-align: left; } +.content th { background: var(--sidebar); font-size: 12px; } +.page-navigation { display: grid; width: min(820px, calc(100% - 64px)); grid-template-columns: 1fr 1fr; gap: 14px; margin: 0 auto; padding: 0 0 58px; } +.nav-card { min-height: 66px; padding: 11px 15px; border: 1px solid var(--border); border-radius: 9px; color: var(--muted); background: var(--surface); cursor: pointer; text-align: left; } +.nav-card:last-child { text-align: right; } +.nav-card:hover:not(:disabled) { color: var(--text); border-color: var(--accent); transform: translateY(-1px); } +.nav-card:disabled { visibility: hidden; } +.nav-card small { display: block; margin-bottom: 2px; color: var(--faint); font-size: 10px; text-transform: uppercase; letter-spacing: .08em; } +.search-dialog { width: min(650px, calc(100% - 28px)); max-height: min(650px, calc(100dvh - 40px)); margin-top: 9dvh; padding: 0; border: 1px solid var(--border); border-radius: 12px; color: var(--text); background: var(--surface); box-shadow: var(--shadow); } +.search-dialog::backdrop { background: rgba(0, 0, 0, .38); backdrop-filter: blur(2px); } +.search-box { display: flex; align-items: center; gap: 9px; padding: 11px; border-bottom: 1px solid var(--border); } +.search-box label { display: flex; flex: 1; align-items: center; gap: 9px; } +.search-box input { width: 100%; border: 0; outline: 0; color: var(--text); background: transparent; } +.key-button { padding: 4px 7px; border: 1px solid var(--border); border-radius: 5px; font-size: 10px; } +.search-results { max-height: 480px; overflow: auto; padding: 7px; } +.search-result { display: block; width: 100%; padding: 10px 12px; border: 0; border-radius: 7px; color: var(--text); background: transparent; cursor: pointer; text-align: left; } +.search-result:hover, .search-result:focus { outline: 0; background: var(--hover); } +.search-result strong { display: block; } +.search-result span { display: block; overflow: hidden; margin-top: 3px; color: var(--muted); text-overflow: ellipsis; white-space: nowrap; font-size: 12px; } +.empty-state { padding: 48px 20px; color: var(--muted); text-align: center; } +.sidebar-overlay, .actions-overlay, .mobile-only { display: none; } + +@media (max-width: 800px) { + .mobile-only { display: grid; } + .sidebar { transform: translateX(-102%); transition: transform .22s ease; box-shadow: var(--shadow); } + body.sidebar-open .sidebar { transform: none; } + .sidebar-overlay, .actions-overlay { position: fixed; inset: 0; z-index: 15; background: rgba(0, 0, 0, .58); } + body.sidebar-open .sidebar-overlay { display: block; } + .header-actions { position: fixed; inset: 0 0 0 auto; z-index: 30; display: flex; width: min(88vw, 420px); max-width: none; height: 100dvh; flex-direction: column; align-items: stretch; padding: max(12px, env(safe-area-inset-top)) max(12px, env(safe-area-inset-right)) max(12px, env(safe-area-inset-bottom)) 12px; border-left: 1px solid var(--border); background: var(--sidebar); box-shadow: var(--shadow); transform: translateX(102%); transition: transform .22s ease; } + body.actions-open .header-actions { transform: none; } + body.actions-open .actions-overlay { display: block; } + .actions-close { align-self: flex-end; margin-bottom: 8px; } + .header-buttons { flex: 1; flex-direction: column; align-items: stretch; gap: 6px; overflow-y: auto; } + .header-link { width: 100%; padding: 11px 13px; border-color: transparent; font-size: 14px; } + .workspace { margin-left: 0; } + .topbar { padding: 0 14px; } + .content, .page-navigation { width: min(100% - 34px, 820px); } + .content { padding-top: 42px; } +} + +@media print { + .sidebar, .topbar, .page-navigation { display: none; } + .workspace { margin: 0; } + .content { width: 100%; padding: 0; } +} diff --git a/docslib/templates/client.js b/docslib/templates/client.js new file mode 100644 index 0000000..346810d --- /dev/null +++ b/docslib/templates/client.js @@ -0,0 +1,361 @@ +(() => { + "use strict"; + + const embedded = JSON.parse(document.getElementById("docslib-manifest").textContent); + const config = JSON.parse(document.getElementById("docslib-config").textContent); + const elements = { + title: document.getElementById("site-title"), + tree: document.getElementById("page-tree"), + content: document.getElementById("content"), + breadcrumbs: document.getElementById("breadcrumbs"), + previous: document.getElementById("previous-page"), + next: document.getElementById("next-page"), + status: document.getElementById("update-status"), + searchDialog: document.getElementById("search-dialog"), + searchInput: document.getElementById("search-input"), + searchResults: document.getElementById("search-results"), + themeIcon: document.getElementById("theme-icon"), + headerButtons: document.getElementById("header-buttons"), + headerActions: document.getElementById("header-actions"), + headerActionsAnchor: document.getElementById("header-actions-anchor"), + openActions: document.getElementById("open-actions"), + workspace: document.querySelector(".workspace"), + }; + let manifest = embedded; + let currentNumber = null; + const mobileLayout = matchMedia("(max-width: 800px)"); + + const dbPromise = new Promise((resolve, reject) => { + if (!window.indexedDB) return reject(new Error("IndexedDB unavailable")); + const request = indexedDB.open("docslib-cache", 1); + request.onupgradeneeded = () => request.result.createObjectStore("manifests"); + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + + async function cacheGet() { + const db = await dbPromise; + return new Promise((resolve, reject) => { + const request = db.transaction("manifests", "readonly").objectStore("manifests").get(config.cacheKey); + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + } + + async function cachePut(value) { + const db = await dbPromise; + return new Promise((resolve, reject) => { + const transaction = db.transaction("manifests", "readwrite"); + transaction.objectStore("manifests").put(value, config.cacheKey); + transaction.oncomplete = resolve; + transaction.onerror = () => reject(transaction.error); + }); + } + + function manifestIdentity(value = manifest) { + const updated = String(value.updated_at || "") + .replace("T", " ") + .replace(/\.\d+(?=Z|[+-])/, "") + .replace("+00:00", "Z"); + const revision = String(value.revision || "").slice(0, 8); + return [updated, revision].filter(Boolean).join(" · ") || "—"; + } + + function setStatus(state = "", detail = "") { + elements.status.className = `update-status ${state}`.trim(); + elements.status.querySelector("span").textContent = manifestIdentity(); + elements.status.title = detail || String(manifest.revision || ""); + } + + function routeNumber() { + const match = location.hash.match(/^#\/page\/(.+)$/); + return match ? decodeURIComponent(match[1]) : null; + } + + function navigate(number, replace = false) { + const hash = `#/page/${encodeURIComponent(number)}`; + if (replace) history.replaceState(null, "", hash); + else if (location.hash !== hash) location.hash = hash; + renderPage(number); + document.body.classList.remove("sidebar-open"); + document.body.classList.remove("actions-open"); + } + + function placeHeaderActions() { + if (mobileLayout.matches) { + if (elements.headerActions.parentElement !== document.body) document.body.append(elements.headerActions); + } else { + if (elements.headerActions.previousElementSibling !== elements.headerActionsAnchor) { + elements.headerActionsAnchor.after(elements.headerActions); + } + document.body.classList.remove("actions-open"); + } + } + + function makeTree() { + elements.tree.replaceChildren(); + const root = { children: new Map(), page: null }; + manifest.pages.forEach((page) => { + let node = root; + page.number.split(".").forEach((part) => { + if (!node.children.has(part)) node.children.set(part, { children: new Map(), page: null }); + node = node.children.get(part); + }); + node.page = page; + }); + const appendNodes = (parent, node) => { + node.children.forEach((child) => { + const group = document.createElement("div"); + group.className = "tree-group"; + if (child.page) { + const button = document.createElement("button"); + button.className = "tree-link"; + button.dataset.page = child.page.number; + const number = document.createElement("span"); + number.className = "tree-number"; + number.textContent = child.page.number; + const title = document.createElement("span"); + title.className = "tree-title"; + title.textContent = child.page.title; + button.append(number, title); + button.addEventListener("click", () => navigate(child.page.number)); + group.append(button); + } + if (child.children.size) { + const nested = document.createElement("div"); + nested.className = "tree-children"; + appendNodes(nested, child); + group.append(nested); + } + parent.append(group); + }); + }; + appendNodes(elements.tree, root); + } + + function assetDataUri(name) { + const asset = manifest.assets && manifest.assets[name]; + return asset ? `data:${asset.mime};base64,${asset.data}` : ""; + } + + function hydrateAssets() { + elements.content.querySelectorAll("[data-docslib-asset]").forEach((element) => { + const name = element.dataset.docslibAsset; + const uri = assetDataUri(name); + if (uri && element.tagName === "IMG") element.src = uri; + else if (!uri) { + element.setAttribute("title", name); + element.classList.add("missing-asset"); + } + }); + } + + function renderPage(number) { + if (!manifest.pages.length) { + elements.content.replaceChildren(); + elements.previous.disabled = elements.next.disabled = true; + return; + } + let index = manifest.pages.findIndex((page) => page.number === number); + if (index < 0) index = 0; + const page = manifest.pages[index]; + currentNumber = page.number; + if (routeNumber() !== page.number) history.replaceState(null, "", `#/page/${encodeURIComponent(page.number)}`); + document.title = `${page.title} · ${manifest.title}`; + elements.content.innerHTML = page.content || ""; + hydrateAssets(); + elements.content.querySelectorAll("a[href^='#/page/']").forEach((link) => { + link.addEventListener("click", (event) => { + if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; + event.preventDefault(); + navigate(decodeURIComponent(link.hash.slice(7))); + }); + }); + elements.tree.querySelectorAll(".tree-link").forEach((button) => button.classList.toggle("active", button.dataset.page === page.number)); + elements.headerButtons.querySelectorAll("[data-page]").forEach((button) => { + const target = button.dataset.page; + button.classList.toggle("active", page.number === target || page.number.startsWith(`${target}.`)); + }); + elements.breadcrumbs.replaceChildren(); + const numberParts = page.number.split("."); + numberParts.forEach((_, partIndex) => { + const candidate = manifest.pages.find((item) => item.number === numberParts.slice(0, partIndex + 1).join(".")); + if (candidate) { + const crumb = document.createElement("span"); + crumb.textContent = candidate.title; + elements.breadcrumbs.append(crumb); + } + }); + setupNavButton(elements.previous, manifest.pages[index - 1], "←"); + setupNavButton(elements.next, manifest.pages[index + 1], "→"); + elements.content.focus({ preventScroll: true }); + elements.workspace.scrollTo({ top: 0, behavior: "instant" }); + } + + function setupNavButton(button, page, direction) { + button.disabled = !page; + button.replaceChildren(); + if (!page) return; + const small = document.createElement("small"); + small.textContent = direction; + const title = document.createElement("span"); + title.textContent = page.title; + button.append(small, title); + button.onclick = () => navigate(page.number); + } + + function renderHeaderButtons() { + elements.headerButtons.replaceChildren(); + const buttons = (manifest.ui && manifest.ui.header_buttons) || []; + buttons.forEach((button) => { + if (!button.label || (!button.href && !button.page)) return; + const link = document.createElement("a"); + link.className = "header-link"; + link.textContent = button.label; + if (button.page) { + link.href = `#/page/${encodeURIComponent(button.page)}`; + link.dataset.page = button.page; + link.addEventListener("click", (event) => { + if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; + event.preventDefault(); + navigate(button.page); + }); + } else { + link.href = button.href; + } + if (!button.page && button.target === "_blank") { + link.target = "_blank"; + link.rel = "noopener noreferrer"; + } + link.addEventListener("click", () => document.body.classList.remove("actions-open")); + elements.headerButtons.append(link); + }); + elements.openActions.hidden = buttons.length === 0; + } + + function renderAll() { + document.documentElement.lang = "und"; + elements.title.textContent = manifest.title; + makeTree(); + renderHeaderButtons(); + renderPage(routeNumber() || currentNumber || (manifest.pages[0] && manifest.pages[0].number)); + setStatus(); + } + + function plainText(html) { + const node = document.createElement("div"); + node.innerHTML = html; + return (node.textContent || "").replace(/\s+/g, " ").trim(); + } + + function search(query) { + const needle = query.trim().toLocaleLowerCase(); + const matches = !needle ? manifest.pages : manifest.pages.filter((page) => + `${page.number} ${page.title} ${plainText(page.content)}`.toLocaleLowerCase().includes(needle) + ); + elements.searchResults.replaceChildren(); + matches.slice(0, 30).forEach((page) => { + const button = document.createElement("button"); + button.className = "search-result"; + const title = document.createElement("strong"); + title.textContent = `${page.number} ${page.title}`; + const excerpt = document.createElement("span"); + excerpt.textContent = plainText(page.content).slice(0, 150) || "—"; + button.append(title, excerpt); + button.addEventListener("click", () => { + elements.searchDialog.close(); + navigate(page.number); + }); + elements.searchResults.append(button); + }); + if (!matches.length) elements.searchResults.innerHTML = '
    '; + } + + function openSearch() { + if (!elements.searchDialog.open) elements.searchDialog.showModal(); + elements.searchInput.value = ""; + search(""); + setTimeout(() => elements.searchInput.focus(), 0); + } + + function applyTheme(theme) { + document.documentElement.dataset.theme = theme; + elements.themeIcon.textContent = theme === "dark" ? "☀" : "◐"; + localStorage.setItem("docslib-theme", theme); + } + + async function syncManifest({ reload = true } = {}) { + setStatus("syncing"); + try { + const response = await fetch(config.manifestUrl, { cache: "no-store" }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const remote = await response.json(); + if (!remote.schema_version || !Array.isArray(remote.pages)) throw new Error("Invalid manifest"); + if (remote.revision !== manifest.revision || remote.updated_at !== manifest.updated_at) { + await cachePut(remote).catch(() => {}); + manifest = remote; + if (reload) { + sessionStorage.setItem("docslib-updated", remote.revision || remote.updated_at); + location.reload(); + return; + } + renderAll(); + setStatus(); + } else { + setStatus(); + } + } catch (error) { + setStatus("error", String(error)); + console.warn("SharedDocsLib update check failed:", error); + } + } + + async function pollLive() { + try { + const response = await fetch(config.liveUrl, { cache: "no-store" }); + if (!response.ok) return; + const live = await response.json(); + if (live.error) setStatus("error", live.error); + else if (live.revision && live.revision !== manifest.revision) await syncManifest({ reload: false }); + } catch (_) { /* The normal manifest remains usable when the dev server stops. */ } + } + + async function start() { + applyTheme(localStorage.getItem("docslib-theme") || (matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light")); + try { + const cached = await cacheGet(); + if ( + cached && + cached.schema_version === embedded.schema_version && + String(cached.updated_at || "") >= String(embedded.updated_at || "") + ) manifest = cached; + else await cachePut(embedded); + } catch (error) { + console.warn("SharedDocsLib cache unavailable:", error); + } + renderAll(); + if (sessionStorage.getItem("docslib-updated")) sessionStorage.removeItem("docslib-updated"); + syncManifest(); + if (config.livePreview) setInterval(pollLive, 1200); + } + + document.getElementById("search-button").addEventListener("click", openSearch); + document.getElementById("close-search").addEventListener("click", () => elements.searchDialog.close()); + elements.searchInput.addEventListener("input", () => search(elements.searchInput.value)); + document.getElementById("theme-button").addEventListener("click", () => applyTheme(document.documentElement.dataset.theme === "dark" ? "light" : "dark")); + document.getElementById("open-sidebar").addEventListener("click", () => document.body.classList.add("sidebar-open")); + document.getElementById("close-sidebar").addEventListener("click", () => document.body.classList.remove("sidebar-open")); + document.getElementById("sidebar-overlay").addEventListener("click", () => document.body.classList.remove("sidebar-open")); + document.getElementById("open-actions").addEventListener("click", () => document.body.classList.add("actions-open")); + document.getElementById("close-actions").addEventListener("click", () => document.body.classList.remove("actions-open")); + document.getElementById("actions-overlay").addEventListener("click", () => document.body.classList.remove("actions-open")); + if (mobileLayout.addEventListener) mobileLayout.addEventListener("change", placeHeaderActions); + else mobileLayout.addListener(placeHeaderActions); + window.addEventListener("hashchange", () => renderPage(routeNumber())); + window.addEventListener("keydown", (event) => { + if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k") { event.preventDefault(); openSearch(); } + if (event.key === "Escape" && elements.searchDialog.open) elements.searchDialog.close(); + }); + placeHeaderActions(); + start(); +})(); diff --git a/docslib/templates/index.html b/docslib/templates/index.html new file mode 100644 index 0000000..caadf7b --- /dev/null +++ b/docslib/templates/index.html @@ -0,0 +1,60 @@ + + + + + + + {{DOCSLIB_TITLE}} + + + +
    + + +
    +
    +
    + + + + + +
    +
    +
    + + +
    +
    +
    + + +
    +
    + + + + + diff --git a/docslib/ui.py b/docslib/ui.py new file mode 100644 index 0000000..fd33eee --- /dev/null +++ b/docslib/ui.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Callable + + +@dataclass(frozen=True, slots=True) +class HeaderButton: + """A prominent header link pointing to a local page or an external URL.""" + + label: str + page: str | int | float | Callable[..., object] | None = None + href: str | None = None + section: str | int | float | None = None + new_tab: bool = False + + def __post_init__(self) -> None: + label = str(self.label).strip() + if not label: + raise ValueError("Header button label cannot be empty") + object.__setattr__(self, "label", label) + if (self.page is None) == (self.href is None): + raise ValueError("HeaderButton requires exactly one of page= or href=") + if self.href is not None and not str(self.href).strip(): + raise ValueError("Header button href cannot be empty") + + @classmethod + def local( + cls, + label: str, + page: str | int | float | Callable[..., object], + *, + section: str | int | float | None = None, + ) -> HeaderButton: + return cls(label=label, page=page, section=section) + + @classmethod + def external(cls, label: str, href: str, *, new_tab: bool = True) -> HeaderButton: + return cls(label=label, href=href, new_tab=new_tab) + + +__all__ = ["HeaderButton"] + diff --git a/examples/assets/architecture.svg b/examples/assets/architecture.svg new file mode 100644 index 0000000..fa27b21 --- /dev/null +++ b/examples/assets/architecture.svg @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + Python + @Page · Components + + + + + + Compiler + manifest · assets + + + + + + HTML + offline + + + + diff --git a/examples/assets/inject.css b/examples/assets/inject.css new file mode 100644 index 0000000..5d7b8b4 --- /dev/null +++ b/examples/assets/inject.css @@ -0,0 +1,6 @@ +.content .lead { + max-width: 680px; + color: var(--muted); + font-size: 1.25rem; +} + diff --git a/examples/basic.py b/examples/basic.py new file mode 100644 index 0000000..4456c26 --- /dev/null +++ b/examples/basic.py @@ -0,0 +1,70 @@ +from docslib import HeaderButton, run +from docslib.components import Callout, Code, H1, H2, Image, List, LocalLink, P, Table +from docslib.hooks import Component, Page + + +@Component +def Lead(text: str) -> str: + return f'

    {text}

    ' + + +@Page("1.", "Welcome") +def welcome(): + return [ + H1("SharedDocsLib"), + Lead("Self-contained documentation generated directly from Python."), + Image("architecture.svg", "Documentation architecture", caption="One HTML file, online or offline."), + Callout("Press Ctrl+K to search all pages.", title="Quick tip"), + P("Continue with the next guide:"), + LocalLink("Open the quick start →", quick_start), + ] + + +@Page("1.1", "Quick start") +def quick_start(): + return [ + H1("Quick start"), + P("Define numbered pages and return a list of components."), + Code( + '''@Page("2", "Example") +def example(): + return [H1("Hello"), P("World")]''', + "python", + ), + H2("What gets generated"), + List(["manifest.json", "index.html", "index.zip"]), + ] + + +@Page("2", "Components") +def components(): + return [ + H1("Components"), + P("Built-ins cover the small set needed for straightforward technical documentation."), + Table( + ["Component", "Purpose"], + [ + ["H1 / H2 / H3", "Headings"], + ["P / List / Quote", "Structured text"], + ["Image", "Embedded base64 assets"], + ["Code / Table", "Technical content"], + ], + ), + ] + + +if __name__ == "__main__": + run( + port=1234, + host="0.0.0.0", + title="SharedDocsLib Example", + assets_dir="./examples/assets", + custom_css="./examples/assets/inject.css", + live_preview=True, + header_buttons=[ + HeaderButton("Welcome", welcome), + HeaderButton("API reference", components), + HeaderButton.external("Download", "/index.zip", new_tab=False), + HeaderButton.external("GitHub", "https://github.com/"), + ], + ) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..4ca43d4 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,30 @@ +[build-system] +requires = ["hatchling>=1.25"] +build-backend = "hatchling.build" + +[project] +name = "shared-docs-lib" +version = "0.1.0" +description = "Build self-contained, Obsidian-like documentation sites from Python functions." +readme = "README.md" +requires-python = ">=3.10" +license = { text = "MIT" } +authors = [{ name = "SharedDocsLib contributors" }] +dependencies = [ + "fastapi>=0.110,<1", + "uvicorn[standard]>=0.27,<1", +] + +[project.optional-dependencies] +dev = [ + "httpx>=0.27,<1", + "pytest>=8,<9", +] + +[tool.hatch.build.targets.wheel] +packages = ["docslib"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-q" + diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..e51192d --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,11 @@ +import pytest + +from docslib.registry import registry + + +@pytest.fixture(autouse=True) +def clean_registry(): + registry.clear() + yield + registry.clear() + diff --git a/tests/test_components.py b/tests/test_components.py new file mode 100644 index 0000000..9d29897 --- /dev/null +++ b/tests/test_components.py @@ -0,0 +1,64 @@ +import pytest + +from docslib.components import H1, Image, List, LocalLink, LocLink, P, RawHTML +from docslib.hooks import Component, Page +from docslib.registry import registry + + +def test_components_escape_text_and_raw_html_is_explicit(): + assert H1("") == "

    <hello>

    " + assert P('a & b') == "

    a & b

    " + assert List([""]) == "
    • <one>
    " + assert RawHTML("trusted") == "trusted" + + +def test_custom_component_must_return_string(): + @Component + def Broken(): + return 123 + + with pytest.raises(TypeError, match="must return str"): + Broken() + + +def test_page_registration_normalises_number_and_rejects_arguments(): + @Page("1.", "Home") + def home(): + return H1("Home") + + assert registry.pages()[0].number == "1" + + with pytest.raises(TypeError, match="must not accept arguments"): + @Page("2", "Broken") + def broken(value): + return value + + +def test_image_rejects_parent_traversal(): + with pytest.raises(ValueError, match="relative path"): + Image("../secret.png") + + +def test_local_link_resolves_number_title_section_and_function(): + @Page("1", "Introduction") + def introduction(): + return "" + + @Page("1.1", "Install") + def install_one(): + return "" + + @Page("2.1", "Install") + def install_two(): + return "" + + assert 'href="#/page/1"' in LocalLink("Intro", "1.") + assert 'href="#/page/1.1"' in LocalLink("Install", "Install", section="1") + assert 'href="#/page/2.1"' in LocalLink("Other install", install_two) + assert LocLink is LocalLink + + with pytest.raises(ValueError, match="ambiguous"): + LocalLink("Install", "Install") + + with pytest.raises(ValueError, match="No @Page"): + LocalLink("Unknown", lambda: None) diff --git a/tests/test_server.py b/tests/test_server.py new file mode 100644 index 0000000..7011689 --- /dev/null +++ b/tests/test_server.py @@ -0,0 +1,117 @@ +import base64 +import io +import json +import os +import runpy +import zipfile + +from fastapi.testclient import TestClient + +from docslib import HeaderButton +from docslib.components import H1, Image, P +from docslib.hooks import Page +from docslib.server import create_app + + +def test_all_endpoints_and_self_contained_build(tmp_path): + assets = tmp_path / "assets" + assets.mkdir() + image_bytes = b"not-a-real-png-but-good-enough-for-encoding" + (assets / "hero.png").write_bytes(image_bytes) + custom_css = tmp_path / "inject.css" + custom_css.write_text(".content { --custom-test: yes; }", encoding="utf-8") + + @Page("1.1", "Child") + def child(): + return P("Second page") + + @Page("1.", "Home") + def home(): + return [H1("Welcome"), Image("hero.png", "Hero")] + + app = create_app( + title="Test docs", + assets_dir=assets, + custom_css=custom_css, + manifest_path="http://127.0.0.1:9000/", + header_buttons=[ + HeaderButton("Home", home), + HeaderButton.local("Child", "Child", section="1"), + HeaderButton.external("Source", "https://example.com"), + {"label": "Download", "href": "/index.zip"}, + ], + ) + client = TestClient(app) + + manifest_response = client.get("/manifest.json") + assert manifest_response.status_code == 200 + manifest = manifest_response.json() + assert [page["number"] for page in manifest["pages"]] == ["1", "1.1"] + assert manifest["assets"]["hero.png"]["data"] == base64.b64encode(image_bytes).decode() + assert len(manifest["revision"]) == 20 + assert manifest["ui"]["header_buttons"] == [ + {"label": "Home", "page": "1"}, + {"label": "Child", "page": "1.1"}, + {"label": "Source", "href": "https://example.com", "target": "_blank"}, + {"label": "Download", "href": "/index.zip"}, + ] + assert manifest_response.headers["cache-control"] == "no-store" + + html_response = client.get("/index.html") + html = html_response.text + assert html_response.status_code == 200 + assert "http://127.0.0.1:9000/manifest.json" in html + assert ".content { --custom-test: yes; }" in html + assert "indexedDB.open" in html + assert "link.dataset.page = button.page" in html + assert 'class="brand-mark"' not in html + assert ">Knowledge base<" not in html + assert ">Search<" not in html + assert ">Ctrl K<" not in html + assert ">Up to date<" not in html + assert "manifestIdentity" in html + assert "100dvh" in html + assert "100vh" not in html + assert "html, body { width: 100%; height: 100dvh" in html + assert "overflow-y: auto" in html + assert "document.body.append(elements.headerActions)" in html + assert "elements.workspace.scrollTo" in html + assert "--bg: #000000" in html + assert "--accent: #ffffff" in html + assert 'id="header-actions"' in html + assert 'id="actions-overlay"' in html + assert "