Compare commits

..

No commits in common. "13398fa9610044deaaaeef78b387c5ce3522b530" and "0e1660970e0d51046ea5caab7cc99be563a031e5" have entirely different histories.

13 changed files with 48 additions and 402 deletions

View file

@ -13,7 +13,7 @@ SharedDocsLib — небольшой Python-фреймворк для докум
- `index.zip`, содержащий только `index.html`; - `index.zip`, содержащий только `index.html`;
- live-preview с перекомпиляцией изменившегося Python-модуля. - live-preview с перекомпиляцией изменившегося Python-модуля.
UI не содержит языко-зависимых служебных подписей. Базовый шрифт — `system-ui 16px`, а служебные UTF-8-пиктограммы используют Arial. Навигационные стрелки отрисовываются CSS-границами без текстовых символов. По умолчанию включена светлая тема, выбор светлой или AMOLED-темы сохраняется локально; тема устройства не используется. 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`. Accessibility-кнопка создаётся системным JavaScript, поэтому появляется и в ранее скомпилированных HTML после обновления manifest. На десктопе она закреплена в левом нижнем углу ниже loading-overlay, на мобильном располагается рядом с переключателем темы внутри меню. Псевдостраница настроек позволяет менять размер текста от 12 до 28 px, сбрасывать параметры и выбирать только локальные браузерные семейства `system-ui`, `sans-serif`, `serif` и `monospace`; сетевые шрифты не используются. Размер применяется только к sidebar, документации и кнопкам Previous/Next, а семейство — ко всему интерфейсу, кроме UTF-8 glyph-кнопок. Параметры сохраняются в `localStorage`.
@ -120,72 +120,13 @@ build(
Функция возвращает `BuildResult` с теми же данными в памяти. Серверные параметры `host`, `port`, `live_preview`, `cors_origins` и `log_level` ей не требуются. Функция возвращает `BuildResult` с теми же данными в памяти. Серверные параметры `host`, `port`, `live_preview`, `cors_origins` и `log_level` ей не требуются.
## Варианты компонентов и генераторы (1.0)
Все компоненты оформляются через `@Component`. Функция компонента возвращает непустой список или кортеж строк: каждый элемент — альтернативное представление одного блока. Форматы и порядок вариантов определяет разработчик; количество вариантов не ограничено. Старый компонент, возвращающий строку, продолжает работать: декоратор превращает её в `(output,)`.
```python
from html import escape
from docslib import Component, Page, build, DefaultGenerator, FilesGenerator
@Component
def Heading(text):
return (
f"<h1>{escape(text)}</h1>", # вариант 1: HTML
f"# {text}", # вариант 2: Markdown
rf"\section{{{text}}}", # вариант 3: LaTeX
)
@Page("1", "Introduction")
def introduction():
return [Heading("Hello"), Heading("World")]
# По умолчанию: веб-обёртка и прежние четыре артефакта.
build(build_directory="./dist/web", title="My docs")
# Эквивалентно generator=DefaultGenerator(component_variant=1).
# Один файл My docs.md с сырым содержимым второго варианта.
build(
build_directory="./dist/markdown",
title="My docs",
generator=FilesGenerator(component_variant=2, content_extension="md"),
)
# Отдельный файл 1. Introduction.tex для каждой страницы.
build(
build_directory="./dist/latex",
generator=FilesGenerator(
component_variant=3, content_extension="tex", as_single_file=False,
),
)
```
`component_variant` — положительное целое число, нумерация **с 1**: значение `1` выбирает элемент с индексом `0`. Для веб-превью этот параметр можно передать в `run(component_variant=1)` или `create_app(component_variant=1)`, для сборки — в генератор. `DefaultGenerator` всегда создаёт веб-обёртку, поэтому выбранные для него строки должны подходить для вставки в HTML.
`FilesGenerator` требует `component_variant` и непустой `content_extension` (например, `md`, `.md`, `tex` или `txt`). Он возвращает `FilesBuildResult.files`: словарь `{имя_файла: содержимое}`. При `as_single_file=True` (по умолчанию) создаётся `{title}.{content_extension}`, страницы сортируются так же, как в веб-версии, и разделяются пустой строкой:
```text
--- 1. Introduction ---
# Hello
# World
--- 2. Next page ---
содержимое следующей страницы
```
При `as_single_file=False` создаются файлы `{number}. {page_name}.{content_extension}`, без заголовков-разделителей. Недопустимые символы в именах заменяются на `_`; пути не могут выйти из `build_directory`. Файлы записываются в UTF-8. Генератор не удаляет старые файлы из каталога — для разных форматов используйте разные каталоги.
Содержимое вариантов вставляется без экранирования и конвертации; между блоками страницы добавляется `\n`. Веб-шаблоны, CSS, JS и assets при файловом экспорте не загружаются и не копируются: ссылки на изображения и другие ресурсы определяет сам компонент. У всех компонентов на странице должен быть выбранный вариант; иначе сборка завершается ошибкой с именем страницы и компонента, без подстановки HTML в другой формат.
Встроенные компоненты по-прежнему предоставляют один HTML-вариант. Для других форматов определите собственные компоненты, как в примере выше. Вызов компонента теперь возвращает кортеж вариантов, а не строку: если вручную собираете составной компонент, используйте нужные элементы явно, например `H1(text)[0]`. Результат `@Component` нельзя превращать в обычный `list` перед возвратом со страницы: обычные списки/кортежи трактуются как последовательность блоков, а результат декоратора — как альтернативы одного блока.
## Компоненты ## Компоненты
Доступны `H1`, `H2`, `H3`, `P`, `MarkdownText`, `Image`, `Link`, `LocalLink` (`LocLink`), `Code`, `InlineCode`, `Quote`, `Callout`, `List`, `Table`, `Divider`, `Badge`, `Details`, `Json` и `RawHTML`. Доступны `H1`, `H2`, `H3`, `P`, `MarkdownText`, `Image`, `Link`, `LocalLink` (`LocLink`), `Code`, `InlineCode`, `Quote`, `Callout`, `List`, `Table`, `Divider`, `Badge`, `Details`, `Json` и `RawHTML`.
Обычные компоненты экранируют пользовательский текст. `RawHTML` и HTML из собственных `@Component` считаются доверенными и вставляются как есть. Обычные компоненты экранируют пользовательский текст. `RawHTML` и HTML из собственных `@Component` считаются доверенными и вставляются как есть.
Функция страницы может вернуть результат `@Component`, строку, список/генератор блоков, вложенные списки или `None`. Обычные строки на странице остаются общими для всех вариантов вывода. Функция страницы может вернуть строку, список/генератор строк, вложенные списки или `None`.
### Ссылки между страницами ### Ссылки между страницами

View file

@ -1,12 +1,8 @@
"""Public API for SharedDocsLib.""" """Public API for SharedDocsLib."""
from ._version import __version__ from ._version import __version__
from .generators import DefaultGenerator, FilesBuildResult, FilesGenerator
from .hooks import Component, Page from .hooks import Component, Page
from .server import build, create_app, run from .server import build, create_app, run
from .ui import HeaderButton from .ui import HeaderButton
__all__ = [ __all__ = ["Component", "HeaderButton", "Page", "build", "create_app", "run"]
"Component", "DefaultGenerator", "FilesBuildResult", "FilesGenerator",
"HeaderButton", "Page", "build", "create_app", "run",
]

View file

@ -1,3 +1,3 @@
"""Single source of truth for the SharedDocsLib package version.""" """Single source of truth for the SharedDocsLib package version."""
__version__ = "1.0.0" __version__ = "0.5.2"

View file

@ -13,7 +13,6 @@ from pathlib import Path
from typing import Iterable, Mapping, Sequence from typing import Iterable, Mapping, Sequence
from ._version import __version__ from ._version import __version__
from .hooks import ComponentOutput
from .registry import PageDefinition, registry from .registry import PageDefinition, registry
from .ui import HeaderButton from .ui import HeaderButton
@ -39,25 +38,22 @@ def page_sort_key(page: PageDefinition) -> tuple[tuple[tuple[int, object], ...],
return tuple(_natural_part(part) for part in page.number.split(".")) return tuple(_natural_part(part) for part in page.number.split("."))
def _flatten_content(value: object, component_variant: int = 1) -> Iterable[str]: def _flatten_content(value: object) -> Iterable[str]:
if value is None: if value is None:
return return
if isinstance(value, ComponentOutput):
yield value.select(component_variant)
return
if isinstance(value, str): if isinstance(value, str):
yield value yield value
return return
if isinstance(value, (bytes, bytearray, Mapping)): if isinstance(value, (bytes, bytearray, Mapping)):
raise TypeError("Page content must be strings, component outputs or an iterable of blocks") raise TypeError("Page content must be HTML strings or an iterable of HTML strings")
try: try:
iterator = iter(value) # type: ignore[arg-type] iterator = iter(value) # type: ignore[arg-type]
except TypeError as error: except TypeError as error:
raise TypeError( raise TypeError(
f"Page content must be strings, component outputs or an iterable, got {type(value).__name__}" f"Page content must be HTML strings or an iterable, got {type(value).__name__}"
) from error ) from error
for item in iterator: for item in iterator:
yield from _flatten_content(item, component_variant) yield from _flatten_content(item)
def _safe_json_for_script(value: object) -> str: def _safe_json_for_script(value: object) -> str:
@ -97,7 +93,6 @@ class Compiler:
manifest_url: str = "./manifest.json", manifest_url: str = "./manifest.json",
live_preview: bool = False, live_preview: bool = False,
header_buttons: Sequence[HeaderButton | Mapping[str, object]] | None = None, header_buttons: Sequence[HeaderButton | Mapping[str, object]] | None = None,
component_variant: int = 1,
) -> None: ) -> None:
self.title = str(title) self.title = str(title)
self.assets_dir = Path(assets_dir).expanduser().resolve() self.assets_dir = Path(assets_dir).expanduser().resolve()
@ -111,12 +106,9 @@ class Compiler:
self.manifest_url = manifest_url self.manifest_url = manifest_url
self.live_preview = bool(live_preview) self.live_preview = bool(live_preview)
self.header_buttons = list(header_buttons or []) self.header_buttons = list(header_buttons or [])
if type(component_variant) is not int or component_variant < 1:
raise ValueError("component_variant must be a positive integer (1-based)")
self.component_variant = component_variant
def build(self) -> BuildResult: def build(self) -> BuildResult:
pages = self.render_pages() pages = self._render_pages(registry.pages())
assets = self._load_assets(pages) assets = self._load_assets(pages)
stable_content: dict[str, object] = { stable_content: dict[str, object] = {
"schema_version": 2, "schema_version": 2,
@ -164,14 +156,13 @@ class Compiler:
}, },
} }
def render_pages(self) -> list[dict[str, str]]: def _render_pages(self, definitions: list[PageDefinition]) -> list[dict[str, str]]:
"""Render selected component variants without loading HTML assets or templates."""
rendered: list[dict[str, str]] = [] rendered: list[dict[str, str]] = []
for page in sorted(registry.pages(), key=page_sort_key): for page in sorted(definitions, key=page_sort_key):
try: try:
content = "\n".join(_flatten_content(page.render(), self.component_variant)) content = "\n".join(_flatten_content(page.render()))
except Exception as error: except Exception as error:
raise RuntimeError(f"Failed to render page {page.number!r} ({page.title}): {error}") from 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}) rendered.append({"number": page.number, "title": page.title, "content": content})
return rendered return rendered

View file

@ -158,7 +158,7 @@ def Details(summary: object, content: object, *, open: bool = False) -> str:
@Component @Component
def Json(data: Mapping[str, object] | Sequence[object]) -> tuple[str, ...]: def Json(data: Mapping[str, object] | Sequence[object]) -> str:
return Code(json.dumps(data, ensure_ascii=False, indent=2), "json") return Code(json.dumps(data, ensure_ascii=False, indent=2), "json")

View file

@ -1,100 +0,0 @@
from __future__ import annotations
import json
import re
from dataclasses import dataclass
from pathlib import Path
from .compiler import BuildResult, Compiler
def _validate_variant(value: int) -> None:
if type(value) is not int or value < 1:
raise ValueError("component_variant must be a positive integer (1-based)")
def _filename(value: str) -> str:
name = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", value).strip().rstrip(". ")
if not name:
name = "Documentation"
if name.split(".")[0].upper() in {
"CON", "PRN", "AUX", "NUL",
*(f"COM{i}" for i in range(1, 10)),
*(f"LPT{i}" for i in range(1, 10)),
}:
name = "_" + name
return name
@dataclass(frozen=True, slots=True)
class FilesBuildResult:
"""UTF-8 content keyed by generated filename, relative to build_directory."""
files: dict[str, str]
@dataclass(frozen=True, slots=True)
class DefaultGenerator:
"""Generate the standard four-artifact HTML bundle."""
component_variant: int = 1
def __post_init__(self) -> None:
_validate_variant(self.component_variant)
def generate(self, compiler: Compiler, output: Path) -> BuildResult:
result = compiler.build()
output.mkdir(parents=True, exist_ok=True)
(output / "index.html").write_bytes(result.html)
(output / "manifest.json").write_text(
json.dumps(result.manifest, ensure_ascii=False, separators=(",", ":")),
encoding="utf-8",
)
(output / "version").write_text(str(result.manifest["revision"]), encoding="utf-8")
(output / "index.zip").write_bytes(result.zip_archive)
return result
@dataclass(frozen=True, slots=True)
class FilesGenerator:
"""Export a selected component variant as raw content, without an HTML shell."""
component_variant: int
content_extension: str
as_single_file: bool = True
def __post_init__(self) -> None:
_validate_variant(self.component_variant)
if not isinstance(self.content_extension, str):
raise TypeError("content_extension must be a string")
extension = self.content_extension.removeprefix(".")
if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9._-]*", extension) or extension.endswith("."):
raise ValueError("content_extension must be a non-empty file extension without path separators")
object.__setattr__(self, "content_extension", extension)
if type(self.as_single_file) is not bool:
raise TypeError("as_single_file must be bool")
def generate(self, compiler: Compiler, output: Path) -> FilesBuildResult:
pages = compiler.render_pages()
if self.as_single_file:
files = {
f"{_filename(compiler.title)}.{self.content_extension}": "\n\n".join(
f"--- {page['number']}. {page['title']} ---\n{page['content']}"
for page in pages
)
}
else:
files = {}
for page in pages:
filename = f"{_filename(page['number'] + '. ' + page['title'])}.{self.content_extension}"
if filename.casefold() in {name.casefold() for name in files}:
raise ValueError(f"Generated filename collision: {filename}")
files[filename] = page["content"]
output.mkdir(parents=True, exist_ok=True)
for filename, content in files.items():
with (output / filename).open("w", encoding="utf-8", newline="") as stream:
stream.write(content)
return FilesBuildResult(files=files)
__all__ = ["DefaultGenerator", "FilesBuildResult", "FilesGenerator"]

View file

@ -10,27 +10,6 @@ from .registry import PageDefinition, registry
P = ParamSpec("P") P = ParamSpec("P")
class ComponentOutput(tuple[str, ...]):
"""A component's alternatives, distinct from a page's sequence of blocks."""
component_name: str
def __new__(cls, variants: tuple[str, ...], name: str):
output = super().__new__(cls, variants)
output.component_name = name
return output
def select(self, variant: int) -> str:
if type(variant) is not int or variant < 1:
raise ValueError("component_variant must be a positive integer (1-based)")
if variant > len(self):
raise ValueError(
f"Component {self.component_name} has {len(self)} variant(s); "
f"requested variant {variant}"
)
return self[variant - 1]
def _normalise_page_number(number: str | int | float) -> str: def _normalise_page_number(number: str | int | float) -> str:
value = str(number).strip().strip(".") value = str(number).strip().strip(".")
parts = [part.strip() for part in value.split(".")] parts = [part.strip() for part in value.split(".")]
@ -40,25 +19,19 @@ def _normalise_page_number(number: str | int | float) -> str:
class Component(Generic[P]): class Component(Generic[P]):
"""Normalise a component's string or list/tuple of strings into variants.""" """Turn a function returning HTML into a reusable documentation component."""
def __init__(self, function: Callable[P, str | list[str] | tuple[str, ...]]) -> None: def __init__(self, function: Callable[P, str]) -> None:
if not callable(function): if not callable(function):
raise TypeError("@Component must decorate a callable") raise TypeError("@Component must decorate a callable")
self.function = function self.function = function
update_wrapper(self, function) update_wrapper(self, function)
def __call__(self, *args: P.args, **kwargs: P.kwargs) -> ComponentOutput: def __call__(self, *args: P.args, **kwargs: P.kwargs) -> str:
result = self.function(*args, **kwargs) result = self.function(*args, **kwargs)
if isinstance(result, str): if not isinstance(result, str):
result = (result,) raise TypeError(f"Component {self.__name__} must return str, got {type(result).__name__}")
if not isinstance(result, (list, tuple)) or not result or any( return result
not isinstance(item, str) for item in result
):
raise TypeError(
f"Component {self.__name__} must return str or a non-empty list/tuple of strings"
)
return ComponentOutput(tuple(result), self.__name__)
class Page: class Page:

View file

@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import importlib import importlib
import json
import runpy import runpy
import sys import sys
from pathlib import Path from pathlib import Path
@ -13,7 +14,6 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse, JSONResponse, Response from fastapi.responses import HTMLResponse, JSONResponse, Response
from .compiler import BuildResult, Compiler from .compiler import BuildResult, Compiler
from .generators import DefaultGenerator, FilesBuildResult, FilesGenerator
from .registry import registry from .registry import registry
from .ui import HeaderButton from .ui import HeaderButton
@ -131,7 +131,6 @@ def create_app(
manifest_parh: str | None = None, manifest_parh: str | None = None,
live_preview: bool = False, live_preview: bool = False,
header_buttons: Sequence[HeaderButton | Mapping[str, object]] | None = None, header_buttons: Sequence[HeaderButton | Mapping[str, object]] | None = None,
component_variant: int = 1,
cors_origins: Sequence[str] = ("*",), cors_origins: Sequence[str] = ("*",),
) -> FastAPI: ) -> FastAPI:
"""Compile registered pages and return a ready-to-serve FastAPI application.""" """Compile registered pages and return a ready-to-serve FastAPI application."""
@ -144,7 +143,6 @@ def create_app(
template_dir=template_dir, template_dir=template_dir,
manifest_url=manifest_url, manifest_url=manifest_url,
live_preview=live_preview, live_preview=live_preview,
component_variant=component_variant,
header_buttons=header_buttons, header_buttons=header_buttons,
) )
site = DocumentationSite(compiler, live_preview=live_preview) site = DocumentationSite(compiler, live_preview=live_preview)
@ -216,11 +214,8 @@ def build(
manifest_path: str | None = None, manifest_path: str | None = None,
manifest_parh: str | None = None, manifest_parh: str | None = None,
header_buttons: Sequence[HeaderButton | Mapping[str, object]] | None = None, header_buttons: Sequence[HeaderButton | Mapping[str, object]] | None = None,
generator: DefaultGenerator | FilesGenerator = DefaultGenerator(), ) -> BuildResult:
) -> BuildResult | FilesBuildResult: """Compile registered pages and write a static documentation bundle."""
"""Generate a static HTML bundle or raw content files from registered pages."""
if not isinstance(generator, (DefaultGenerator, FilesGenerator)):
raise TypeError("generator must be DefaultGenerator or FilesGenerator")
compiler = Compiler( compiler = Compiler(
title=title, title=title,
assets_dir=assets_dir, assets_dir=assets_dir,
@ -229,10 +224,19 @@ def build(
template_dir=template_dir, template_dir=template_dir,
manifest_url=_manifest_url(manifest_path, manifest_parh), manifest_url=_manifest_url(manifest_path, manifest_parh),
live_preview=False, live_preview=False,
component_variant=generator.component_variant,
header_buttons=header_buttons, header_buttons=header_buttons,
) )
return generator.generate(compiler, Path(build_directory).expanduser().resolve()) result = compiler.build()
output = Path(build_directory).expanduser().resolve()
output.mkdir(parents=True, exist_ok=True)
(output / "index.html").write_bytes(result.html)
(output / "manifest.json").write_text(
json.dumps(result.manifest, ensure_ascii=False, separators=(",", ":")),
encoding="utf-8",
)
(output / "version").write_text(str(result.manifest["revision"]), encoding="utf-8")
(output / "index.zip").write_bytes(result.zip_archive)
return result
def run( def run(
@ -248,7 +252,6 @@ def run(
manifest_parh: str | None = None, manifest_parh: str | None = None,
live_preview: bool = False, live_preview: bool = False,
header_buttons: Sequence[HeaderButton | Mapping[str, object]] | None = None, header_buttons: Sequence[HeaderButton | Mapping[str, object]] | None = None,
component_variant: int = 1,
cors_origins: Sequence[str] = ("*",), cors_origins: Sequence[str] = ("*",),
log_level: str = "info", log_level: str = "info",
) -> None: ) -> None:
@ -262,7 +265,6 @@ def run(
manifest_path=manifest_path, manifest_path=manifest_path,
manifest_parh=manifest_parh, manifest_parh=manifest_parh,
live_preview=live_preview, live_preview=live_preview,
component_variant=component_variant,
header_buttons=header_buttons, header_buttons=header_buttons,
cors_origins=cors_origins, cors_origins=cors_origins,
) )

View file

@ -850,7 +850,7 @@
} }
async function start() { async function start() {
applyTheme(localStorage.getItem("docslib-theme") || "light"); applyTheme(localStorage.getItem("docslib-theme") || "dark");
loadAccessibilityPreferences(); loadAccessibilityPreferences();
renderAll(); renderAll();
announceRendered(); announceRendered();

View file

@ -3,15 +3,15 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"> <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<meta name="color-scheme" content="light"> <meta name="color-scheme" content="dark light">
<title>{{DOCSLIB_TITLE}}</title> <title>{{DOCSLIB_TITLE}}</title>
<script> <script>
try { document.documentElement.dataset.theme = localStorage.getItem("docslib-theme") || "light"; } try { document.documentElement.dataset.theme = localStorage.getItem("docslib-theme") || "dark"; }
catch (_) { document.documentElement.dataset.theme = "light"; } catch (_) { document.documentElement.dataset.theme = "dark"; }
</script> </script>
<style id="docslib-loader-style"> <style id="docslib-loader-style">
:root { --docslib-loader-bg: #ffffff; --docslib-loader-text: #111111; color-scheme: light; } :root { --docslib-loader-bg: #000000; --docslib-loader-text: #f4f4f4; color-scheme: dark; }
:root[data-theme="dark"] { --docslib-loader-bg: #000000; --docslib-loader-text: #f4f4f4; color-scheme: dark; } :root[data-theme="light"] { --docslib-loader-bg: #ffffff; --docslib-loader-text: #111111; color-scheme: light; }
html, body { width: 100%; height: 100dvh; min-height: 100dvh; margin: 0; overflow: hidden; background: var(--docslib-loader-bg); } html, body { width: 100%; height: 100dvh; min-height: 100dvh; margin: 0; overflow: hidden; background: var(--docslib-loader-bg); }
#docslib-loading { position: fixed; inset: 0; z-index: 2147483647; display: grid; width: 100vw; height: 100dvh; place-items: center; color: var(--docslib-loader-text); background: var(--docslib-loader-bg); } #docslib-loading { position: fixed; inset: 0; z-index: 2147483647; display: grid; width: 100vw; height: 100dvh; place-items: center; color: var(--docslib-loader-text); background: var(--docslib-loader-bg); }
.docslib-loading-spinner { width: 48px; height: 48px; box-sizing: border-box; border: solid 4px currentColor; border-top: solid 4px transparent; border-radius: 9999px; animation: docslib-loader-spin .8s linear infinite; } .docslib-loading-spinner { width: 48px; height: 48px; box-sizing: border-box; border: solid 4px currentColor; border-top: solid 4px transparent; border-radius: 9999px; animation: docslib-loader-spin .8s linear infinite; }

View file

@ -6,10 +6,10 @@ from docslib.registry import registry
def test_components_escape_text_and_raw_html_is_explicit(): def test_components_escape_text_and_raw_html_is_explicit():
assert H1("<hello>") == ("<h1>&lt;hello&gt;</h1>",) assert H1("<hello>") == "<h1>&lt;hello&gt;</h1>"
assert P('a & b') == ("<p>a &amp; b</p>",) assert P('a & b') == "<p>a &amp; b</p>"
assert List(["<one>"]) == ("<ul><li>&lt;one&gt;</li></ul>",) assert List(["<one>"]) == "<ul><li>&lt;one&gt;</li></ul>"
assert RawHTML("<b>trusted</b>") == ("<b>trusted</b>",) assert RawHTML("<b>trusted</b>") == "<b>trusted</b>"
def test_custom_component_must_return_string(): def test_custom_component_must_return_string():
@ -52,9 +52,9 @@ def test_local_link_resolves_number_title_section_and_function():
def install_two(): def install_two():
return "" return ""
assert 'href="#/page/1"' in LocalLink("Intro", "1.")[0] assert 'href="#/page/1"' in LocalLink("Intro", "1.")
assert 'href="#/page/1.1"' in LocalLink("Install", "Install", section="1")[0] assert 'href="#/page/1.1"' in LocalLink("Install", "Install", section="1")
assert 'href="#/page/2.1"' in LocalLink("Other install", install_two)[0] assert 'href="#/page/2.1"' in LocalLink("Other install", install_two)
assert LocLink is LocalLink assert LocLink is LocalLink
with pytest.raises(ValueError, match="ambiguous"): with pytest.raises(ValueError, match="ambiguous"):

View file

@ -1,157 +0,0 @@
import pytest
from fastapi.testclient import TestClient
from docslib import Component, DefaultGenerator, FilesGenerator, Page, build, create_app
from docslib.components import Json
@Component
def Heading(text):
return (f"<h1>{text}</h1>", f"# {text}", rf"\section{{{text}}}")
def register_pages():
@Page("10", "Last")
def last():
return Heading("Last")
@Page("2", "First")
def first():
return [Heading("First"), ["raw <&>\r\n", None, Heading("Nested")]]
def test_component_normalises_strings_lists_and_tuples():
@Component
def legacy():
return "legacy"
@Component
def alternatives():
return ["html", "md", "tex", "other"]
assert legacy() == ("legacy",)
assert alternatives() == ("html", "md", "tex", "other")
assert Heading("Test") == ("<h1>Test</h1>", "# Test", r"\section{Test}")
assert "language-json" in Json({"a": 1})[0]
@pytest.mark.parametrize("result", [[], (), ["ok", 1], None, {"html": "x"}, iter(["x"])])
def test_invalid_component_outputs(result):
@Component
def invalid():
return result
with pytest.raises(TypeError, match="non-empty list/tuple of strings"):
invalid()
def test_default_build_selects_first_variant_and_preserves_page_blocks(tmp_path):
register_pages()
result = build(build_directory=tmp_path)
assert result.manifest["pages"][0]["content"] == (
"<h1>First</h1>\nraw <&>\r\n\n<h1>Nested</h1>"
)
assert len(list(tmp_path.iterdir())) == 4
second = build(build_directory=tmp_path, generator=DefaultGenerator(component_variant=2))
assert second.manifest["pages"][1]["content"] == "# Last"
def test_single_file_has_sorted_pages_and_exact_raw_content(tmp_path):
register_pages()
result = build(
build_directory=tmp_path, title="My docs",
generator=FilesGenerator(2, ".md"),
# Raw export must not try to read HTML templates, custom JS or assets.
template_dir=tmp_path / "absent", custom_js=tmp_path / "absent.js",
)
expected = "--- 2. First ---\n# First\nraw <&>\r\n\n# Nested\n\n--- 10. Last ---\n# Last"
assert result.files == {"My docs.md": expected}
assert [p.name for p in tmp_path.iterdir()] == ["My docs.md"]
assert (tmp_path / "My docs.md").read_bytes() == expected.encode("utf-8")
def test_separate_files_and_unlimited_variant_formats(tmp_path):
register_pages()
result = build(build_directory=tmp_path, generator=FilesGenerator(3, "tex", False))
assert set(result.files) == {"2. First.tex", "10. Last.tex"}
assert (tmp_path / "10. Last.tex").read_text() == r"\section{Last}"
def test_preview_selects_variant():
register_pages()
client = TestClient(create_app(component_variant=3, live_preview=True))
assert client.get("/manifest.json").json()["pages"][1]["content"] == r"\section{Last}"
assert client.get("/live").status_code == 200
def test_missing_variant_names_component_and_page_before_writing(tmp_path):
register_pages()
with pytest.raises(RuntimeError, match=r"2.*First.*Heading.*requested variant 4"):
build(build_directory=tmp_path, generator=FilesGenerator(4, "txt"))
assert not list(tmp_path.iterdir())
@pytest.mark.parametrize("variant", [0, -1, True, 1.5, "2"])
def test_variant_validation(variant):
with pytest.raises(ValueError, match="positive integer"):
FilesGenerator(variant, "md")
with pytest.raises(ValueError, match="positive integer"):
DefaultGenerator(variant)
with pytest.raises(ValueError, match="positive integer"):
create_app(component_variant=variant)
@pytest.mark.parametrize("extension", ["", ".", "../md", "a/b", "a\\b", "md.", "md\n"])
def test_extension_validation(extension):
with pytest.raises(ValueError, match="content_extension"):
FilesGenerator(1, extension)
def test_export_sanitises_names_and_keeps_files_in_output(tmp_path):
@Page("1", "../Bad: title?")
def page():
return "untouched"
result = build(build_directory=tmp_path, generator=FilesGenerator(1, "txt", False))
assert result.files == {"1. .._Bad_ title_.txt": "untouched"}
result = build(build_directory=tmp_path, title="CON", generator=FilesGenerator(1, "txt"))
assert "_CON.txt" in result.files
def test_single_variant_components_do_not_silently_fall_back_to_html(tmp_path):
@Component
def legacy():
return "<p>HTML only</p>"
@Page("1", "Legacy")
def page():
return legacy()
with pytest.raises(RuntimeError, match="legacy has 1 variant"):
build(build_directory=tmp_path, generator=FilesGenerator(2, "md"))
assert not list(tmp_path.iterdir())
def test_filename_collisions_are_reported_before_writing(tmp_path):
@Page("1/1", "Same")
def first():
return "one"
@Page("1:1", "Same")
def second():
return "two"
with pytest.raises(ValueError, match="filename collision"):
build(build_directory=tmp_path, generator=FilesGenerator(1, "txt", False))
assert not list(tmp_path.iterdir())
def test_run_passes_variant_to_preview(monkeypatch):
import docslib.server as server
register_pages()
served = []
monkeypatch.setattr(server.uvicorn, "run", lambda app, **kwargs: served.append(app))
server.run(component_variant=2)
client = TestClient(served[0])
assert client.get("/manifest.json").json()["pages"][1]["content"] == "# Last"

View file

@ -287,7 +287,7 @@ def test_all_endpoints_and_self_contained_build(tmp_path):
def test_build_writes_static_bundle(tmp_path): def test_build_writes_static_bundle(tmp_path):
assert __version__ == "1.0.0" assert __version__ == "0.5.2"
@Page("1", "Static page") @Page("1", "Static page")
def static_page(): def static_page():