diff --git a/README.md b/README.md
index a536211..d9129b9 100644
--- a/README.md
+++ b/README.md
@@ -13,7 +13,7 @@ SharedDocsLib — небольшой Python-фреймворк для докум
- `index.zip`, содержащий только `index.html`;
- 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`.
@@ -120,13 +120,72 @@ build(
Функция возвращает `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"
{escape(text)}
", # вариант 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`.
Обычные компоненты экранируют пользовательский текст. `RawHTML` и HTML из собственных `@Component` считаются доверенными и вставляются как есть.
-Функция страницы может вернуть строку, список/генератор строк, вложенные списки или `None`.
+Функция страницы может вернуть результат `@Component`, строку, список/генератор блоков, вложенные списки или `None`. Обычные строки на странице остаются общими для всех вариантов вывода.
### Ссылки между страницами
diff --git a/docslib/__init__.py b/docslib/__init__.py
index 5a90b99..dc1777a 100644
--- a/docslib/__init__.py
+++ b/docslib/__init__.py
@@ -1,8 +1,12 @@
"""Public API for SharedDocsLib."""
from ._version import __version__
+from .generators import DefaultGenerator, FilesBuildResult, FilesGenerator
from .hooks import Component, Page
from .server import build, create_app, run
from .ui import HeaderButton
-__all__ = ["Component", "HeaderButton", "Page", "build", "create_app", "run"]
+__all__ = [
+ "Component", "DefaultGenerator", "FilesBuildResult", "FilesGenerator",
+ "HeaderButton", "Page", "build", "create_app", "run",
+]
diff --git a/docslib/_version.py b/docslib/_version.py
index 828bf68..d0d6ad0 100644
--- a/docslib/_version.py
+++ b/docslib/_version.py
@@ -1,3 +1,3 @@
"""Single source of truth for the SharedDocsLib package version."""
-__version__ = "0.5.3"
+__version__ = "1.0.0"
diff --git a/docslib/compiler.py b/docslib/compiler.py
index 168f4bb..850ad45 100644
--- a/docslib/compiler.py
+++ b/docslib/compiler.py
@@ -13,6 +13,7 @@ from pathlib import Path
from typing import Iterable, Mapping, Sequence
from ._version import __version__
+from .hooks import ComponentOutput
from .registry import PageDefinition, registry
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("."))
-def _flatten_content(value: object) -> Iterable[str]:
+def _flatten_content(value: object, component_variant: int = 1) -> Iterable[str]:
if value is None:
return
+ if isinstance(value, ComponentOutput):
+ yield value.select(component_variant)
+ 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")
+ raise TypeError("Page content must be strings, component outputs or an iterable of blocks")
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__}"
+ f"Page content must be strings, component outputs or an iterable, got {type(value).__name__}"
) from error
for item in iterator:
- yield from _flatten_content(item)
+ yield from _flatten_content(item, component_variant)
def _safe_json_for_script(value: object) -> str:
@@ -93,6 +97,7 @@ class Compiler:
manifest_url: str = "./manifest.json",
live_preview: bool = False,
header_buttons: Sequence[HeaderButton | Mapping[str, object]] | None = None,
+ component_variant: int = 1,
) -> None:
self.title = str(title)
self.assets_dir = Path(assets_dir).expanduser().resolve()
@@ -106,9 +111,12 @@ class Compiler:
self.manifest_url = manifest_url
self.live_preview = bool(live_preview)
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:
- pages = self._render_pages(registry.pages())
+ pages = self.render_pages()
assets = self._load_assets(pages)
stable_content: dict[str, object] = {
"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]] = []
- for page in sorted(definitions, key=page_sort_key):
+ for page in sorted(registry.pages(), key=page_sort_key):
try:
- content = "\n".join(_flatten_content(page.render()))
+ content = "\n".join(_flatten_content(page.render(), self.component_variant))
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})
return rendered
diff --git a/docslib/components.py b/docslib/components.py
index 60e55a1..48fe475 100644
--- a/docslib/components.py
+++ b/docslib/components.py
@@ -158,7 +158,7 @@ def Details(summary: object, content: object, *, open: bool = False) -> str:
@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")
diff --git a/docslib/generators.py b/docslib/generators.py
new file mode 100644
index 0000000..41121d4
--- /dev/null
+++ b/docslib/generators.py
@@ -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"]
diff --git a/docslib/hooks.py b/docslib/hooks.py
index e6b61c7..9ab2144 100644
--- a/docslib/hooks.py
+++ b/docslib/hooks.py
@@ -10,6 +10,27 @@ from .registry import PageDefinition, registry
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:
value = str(number).strip().strip(".")
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]):
- """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):
raise TypeError("@Component must decorate a callable")
self.function = 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)
- if not isinstance(result, str):
- raise TypeError(f"Component {self.__name__} must return str, got {type(result).__name__}")
- return result
+ if isinstance(result, str):
+ result = (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:
diff --git a/docslib/server.py b/docslib/server.py
index 581859c..856e52f 100644
--- a/docslib/server.py
+++ b/docslib/server.py
@@ -1,7 +1,6 @@
from __future__ import annotations
import importlib
-import json
import runpy
import sys
from pathlib import Path
@@ -14,6 +13,7 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse, JSONResponse, Response
from .compiler import BuildResult, Compiler
+from .generators import DefaultGenerator, FilesBuildResult, FilesGenerator
from .registry import registry
from .ui import HeaderButton
@@ -131,6 +131,7 @@ def create_app(
manifest_parh: str | None = None,
live_preview: bool = False,
header_buttons: Sequence[HeaderButton | Mapping[str, object]] | None = None,
+ component_variant: int = 1,
cors_origins: Sequence[str] = ("*",),
) -> FastAPI:
"""Compile registered pages and return a ready-to-serve FastAPI application."""
@@ -143,6 +144,7 @@ def create_app(
template_dir=template_dir,
manifest_url=manifest_url,
live_preview=live_preview,
+ component_variant=component_variant,
header_buttons=header_buttons,
)
site = DocumentationSite(compiler, live_preview=live_preview)
@@ -214,8 +216,11 @@ def build(
manifest_path: str | None = None,
manifest_parh: str | None = None,
header_buttons: Sequence[HeaderButton | Mapping[str, object]] | None = None,
-) -> BuildResult:
- """Compile registered pages and write a static documentation bundle."""
+ generator: DefaultGenerator | FilesGenerator = DefaultGenerator(),
+) -> 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(
title=title,
assets_dir=assets_dir,
@@ -224,19 +229,10 @@ def build(
template_dir=template_dir,
manifest_url=_manifest_url(manifest_path, manifest_parh),
live_preview=False,
+ component_variant=generator.component_variant,
header_buttons=header_buttons,
)
- 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
+ return generator.generate(compiler, Path(build_directory).expanduser().resolve())
def run(
@@ -252,6 +248,7 @@ def run(
manifest_parh: str | None = None,
live_preview: bool = False,
header_buttons: Sequence[HeaderButton | Mapping[str, object]] | None = None,
+ component_variant: int = 1,
cors_origins: Sequence[str] = ("*",),
log_level: str = "info",
) -> None:
@@ -265,6 +262,7 @@ def run(
manifest_path=manifest_path,
manifest_parh=manifest_parh,
live_preview=live_preview,
+ component_variant=component_variant,
header_buttons=header_buttons,
cors_origins=cors_origins,
)
diff --git a/tests/test_components.py b/tests/test_components.py
index 9d29897..4f471ee 100644
--- a/tests/test_components.py
+++ b/tests/test_components.py
@@ -6,10 +6,10 @@ 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([""]) == ""
- assert RawHTML("trusted") == "trusted"
+ assert H1("") == ("<hello>
",)
+ assert P('a & b') == ("a & b
",)
+ assert List([""]) == ("",)
+ assert RawHTML("trusted") == ("trusted",)
def test_custom_component_must_return_string():
@@ -52,9 +52,9 @@ def test_local_link_resolves_number_title_section_and_function():
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 'href="#/page/1"' in LocalLink("Intro", "1.")[0]
+ assert 'href="#/page/1.1"' in LocalLink("Install", "Install", section="1")[0]
+ assert 'href="#/page/2.1"' in LocalLink("Other install", install_two)[0]
assert LocLink is LocalLink
with pytest.raises(ValueError, match="ambiguous"):
diff --git a/tests/test_generators.py b/tests/test_generators.py
new file mode 100644
index 0000000..eb879a3
--- /dev/null
+++ b/tests/test_generators.py
@@ -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"{text}
", 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") == ("Test
", "# 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"] == (
+ "First
\nraw <&>\r\n\nNested
"
+ )
+ 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 "HTML only
"
+
+ @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"
diff --git a/tests/test_server.py b/tests/test_server.py
index 1c5639a..602acec 100644
--- a/tests/test_server.py
+++ b/tests/test_server.py
@@ -287,7 +287,7 @@ def test_all_endpoints_and_self_contained_build(tmp_path):
def test_build_writes_static_bundle(tmp_path):
- assert __version__ == "0.5.3"
+ assert __version__ == "1.0.0"
@Page("1", "Static page")
def static_page():