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([""]) == ("
  • <one>
",) + 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\n

Nested

" + ) + 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():