Compare commits
2 commits
0e1660970e
...
13398fa961
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13398fa961 | ||
|
|
ffd16f0fd5 |
13 changed files with 402 additions and 48 deletions
63
README.md
63
README.md
|
|
@ -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,13 +120,72 @@ 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` считаются доверенными и вставляются как есть.
|
||||||
|
|
||||||
Функция страницы может вернуть строку, список/генератор строк, вложенные списки или `None`.
|
Функция страницы может вернуть результат `@Component`, строку, список/генератор блоков, вложенные списки или `None`. Обычные строки на странице остаются общими для всех вариантов вывода.
|
||||||
|
|
||||||
### Ссылки между страницами
|
### Ссылки между страницами
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,12 @@
|
||||||
"""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__ = ["Component", "HeaderButton", "Page", "build", "create_app", "run"]
|
__all__ = [
|
||||||
|
"Component", "DefaultGenerator", "FilesBuildResult", "FilesGenerator",
|
||||||
|
"HeaderButton", "Page", "build", "create_app", "run",
|
||||||
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
"""Single source of truth for the SharedDocsLib package version."""
|
"""Single source of truth for the SharedDocsLib package version."""
|
||||||
|
|
||||||
__version__ = "0.5.2"
|
__version__ = "1.0.0"
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ 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
|
||||||
|
|
||||||
|
|
@ -38,22 +39,25 @@ 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) -> Iterable[str]:
|
def _flatten_content(value: object, component_variant: int = 1) -> 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 HTML strings or an iterable of HTML strings")
|
raise TypeError("Page content must be strings, component outputs or an iterable of blocks")
|
||||||
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 HTML strings or an iterable, got {type(value).__name__}"
|
f"Page content must be strings, component outputs or an iterable, got {type(value).__name__}"
|
||||||
) from error
|
) from error
|
||||||
for item in iterator:
|
for item in iterator:
|
||||||
yield from _flatten_content(item)
|
yield from _flatten_content(item, component_variant)
|
||||||
|
|
||||||
|
|
||||||
def _safe_json_for_script(value: object) -> str:
|
def _safe_json_for_script(value: object) -> str:
|
||||||
|
|
@ -93,6 +97,7 @@ 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()
|
||||||
|
|
@ -106,9 +111,12 @@ 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(registry.pages())
|
pages = self.render_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,
|
||||||
|
|
@ -156,13 +164,14 @@ class Compiler:
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
def _render_pages(self, definitions: list[PageDefinition]) -> list[dict[str, str]]:
|
def render_pages(self) -> 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(definitions, key=page_sort_key):
|
for page in sorted(registry.pages(), key=page_sort_key):
|
||||||
try:
|
try:
|
||||||
content = "\n".join(_flatten_content(page.render()))
|
content = "\n".join(_flatten_content(page.render(), self.component_variant))
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
raise RuntimeError(f"Failed to render page {page.number!r} ({page.title})") from error
|
raise RuntimeError(f"Failed to render page {page.number!r} ({page.title}): {error}") 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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -158,7 +158,7 @@ def Details(summary: object, content: object, *, open: bool = False) -> str:
|
||||||
|
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
def Json(data: Mapping[str, object] | Sequence[object]) -> str:
|
def Json(data: Mapping[str, object] | Sequence[object]) -> tuple[str, ...]:
|
||||||
return Code(json.dumps(data, ensure_ascii=False, indent=2), "json")
|
return Code(json.dumps(data, ensure_ascii=False, indent=2), "json")
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
100
docslib/generators.py
Normal file
100
docslib/generators.py
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
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"]
|
||||||
|
|
@ -10,6 +10,27 @@ 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(".")]
|
||||||
|
|
@ -19,19 +40,25 @@ def _normalise_page_number(number: str | int | float) -> str:
|
||||||
|
|
||||||
|
|
||||||
class Component(Generic[P]):
|
class Component(Generic[P]):
|
||||||
"""Turn a function returning HTML into a reusable documentation component."""
|
"""Normalise a component's string or list/tuple of strings into variants."""
|
||||||
|
|
||||||
def __init__(self, function: Callable[P, str]) -> None:
|
def __init__(self, function: Callable[P, str | list[str] | tuple[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) -> str:
|
def __call__(self, *args: P.args, **kwargs: P.kwargs) -> ComponentOutput:
|
||||||
result = self.function(*args, **kwargs)
|
result = self.function(*args, **kwargs)
|
||||||
if not isinstance(result, str):
|
if isinstance(result, str):
|
||||||
raise TypeError(f"Component {self.__name__} must return str, got {type(result).__name__}")
|
result = (result,)
|
||||||
return result
|
if not isinstance(result, (list, tuple)) or not result or any(
|
||||||
|
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:
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
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
|
||||||
|
|
@ -14,6 +13,7 @@ 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,6 +131,7 @@ 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."""
|
||||||
|
|
@ -143,6 +144,7 @@ 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)
|
||||||
|
|
@ -214,8 +216,11 @@ 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,
|
||||||
) -> BuildResult:
|
generator: DefaultGenerator | FilesGenerator = DefaultGenerator(),
|
||||||
"""Compile registered pages and write a static documentation bundle."""
|
) -> BuildResult | FilesBuildResult:
|
||||||
|
"""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,
|
||||||
|
|
@ -224,19 +229,10 @@ 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,
|
||||||
)
|
)
|
||||||
result = compiler.build()
|
return generator.generate(compiler, Path(build_directory).expanduser().resolve())
|
||||||
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(
|
||||||
|
|
@ -252,6 +248,7 @@ 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:
|
||||||
|
|
@ -265,6 +262,7 @@ 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,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -850,7 +850,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
async function start() {
|
async function start() {
|
||||||
applyTheme(localStorage.getItem("docslib-theme") || "dark");
|
applyTheme(localStorage.getItem("docslib-theme") || "light");
|
||||||
loadAccessibilityPreferences();
|
loadAccessibilityPreferences();
|
||||||
renderAll();
|
renderAll();
|
||||||
announceRendered();
|
announceRendered();
|
||||||
|
|
|
||||||
|
|
@ -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="dark light">
|
<meta name="color-scheme" content="light">
|
||||||
<title>{{DOCSLIB_TITLE}}</title>
|
<title>{{DOCSLIB_TITLE}}</title>
|
||||||
<script>
|
<script>
|
||||||
try { document.documentElement.dataset.theme = localStorage.getItem("docslib-theme") || "dark"; }
|
try { document.documentElement.dataset.theme = localStorage.getItem("docslib-theme") || "light"; }
|
||||||
catch (_) { document.documentElement.dataset.theme = "dark"; }
|
catch (_) { document.documentElement.dataset.theme = "light"; }
|
||||||
</script>
|
</script>
|
||||||
<style id="docslib-loader-style">
|
<style id="docslib-loader-style">
|
||||||
:root { --docslib-loader-bg: #000000; --docslib-loader-text: #f4f4f4; color-scheme: dark; }
|
:root { --docslib-loader-bg: #ffffff; --docslib-loader-text: #111111; color-scheme: light; }
|
||||||
:root[data-theme="light"] { --docslib-loader-bg: #ffffff; --docslib-loader-text: #111111; color-scheme: light; }
|
:root[data-theme="dark"] { --docslib-loader-bg: #000000; --docslib-loader-text: #f4f4f4; color-scheme: dark; }
|
||||||
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; }
|
||||||
|
|
|
||||||
|
|
@ -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><hello></h1>"
|
assert H1("<hello>") == ("<h1><hello></h1>",)
|
||||||
assert P('a & b') == "<p>a & b</p>"
|
assert P('a & b') == ("<p>a & b</p>",)
|
||||||
assert List(["<one>"]) == "<ul><li><one></li></ul>"
|
assert List(["<one>"]) == ("<ul><li><one></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.")
|
assert 'href="#/page/1"' in LocalLink("Intro", "1.")[0]
|
||||||
assert 'href="#/page/1.1"' in LocalLink("Install", "Install", section="1")
|
assert 'href="#/page/1.1"' in LocalLink("Install", "Install", section="1")[0]
|
||||||
assert 'href="#/page/2.1"' in LocalLink("Other install", install_two)
|
assert 'href="#/page/2.1"' in LocalLink("Other install", install_two)[0]
|
||||||
assert LocLink is LocalLink
|
assert LocLink is LocalLink
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="ambiguous"):
|
with pytest.raises(ValueError, match="ambiguous"):
|
||||||
|
|
|
||||||
157
tests/test_generators.py
Normal file
157
tests/test_generators.py
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
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"
|
||||||
|
|
@ -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__ == "0.5.2"
|
assert __version__ == "1.0.0"
|
||||||
|
|
||||||
@Page("1", "Static page")
|
@Page("1", "Static page")
|
||||||
def static_page():
|
def static_page():
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue