Compare commits

...

2 commits

Author SHA1 Message Date
snw
6afc48778e Restore scroll position after live preview reload 2026-11-07 18:53:18 +03:00
snw
b36009bfc0 Support Reactive helpers in live preview 2026-11-07 18:53:18 +03:00
8 changed files with 186 additions and 32 deletions

View file

@ -349,7 +349,17 @@ PrintFullDocumentation();
## Live-preview ## Live-preview
В live-режиме браузер опрашивает только `/live`. Сервер следит за файлами, в которых объявлены страницы, а также за системными и пользовательскими CSS/JS. При изменении он пересобирает manifest; клиент сохраняет его и перезагружается с новым runtime. В запускаемом файле вызов `run()` обязательно помещать под `if __name__ == "__main__"`, чтобы watcher не запускал второй сервер. В live-режиме браузер опрашивает `/live`. Сервер следит за файлами страниц, импортированными модулями с функциями `@Reactive`, а также за системными и пользовательскими CSS/JS. После успешной пересборки браузер загружает новый HTML и восстанавливает позицию прокрутки открытой страницы в пикселях без анимации. В запускаемом файле вызов `run()` обязательно помещать под `if __name__ == "__main__"`, чтобы watcher не запускал второй сервер.
Используйте `@Reactive` для обычных функций уровня модуля, например для локализации или общих значений. Декоратор сохраняет аргументы и возвращаемое значение функции. При редактировании её файла live preview заново выполняет рендер всех страниц.
```python
from docslib import Reactive
@Reactive
def product_name() -> str:
return "Мой продукт"
```
HTTP-загрузка и исполнение произвольных `.py` файлов намеренно отсутствуют: такая ручка была бы удалённым выполнением кода. Live-preview работает с локальными исходниками проекта. HTTP-загрузка и исполнение произвольных `.py` файлов намеренно отсутствуют: такая ручка была бы удалённым выполнением кода. Live-preview работает с локальными исходниками проекта.

View file

@ -2,11 +2,11 @@
from ._version import __version__ from ._version import __version__
from .generators import DefaultGenerator, FilesBuildResult, FilesGenerator from .generators import DefaultGenerator, FilesBuildResult, FilesGenerator
from .hooks import Component, Page from .hooks import Component, Page, Reactive
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", "DefaultGenerator", "FilesBuildResult", "FilesGenerator", "Component", "DefaultGenerator", "FilesBuildResult", "FilesGenerator",
"HeaderButton", "Page", "build", "create_app", "run", "HeaderButton", "Page", "Reactive", "build", "create_app", "run",
] ]

View file

@ -3,11 +3,12 @@ from __future__ import annotations
import inspect import inspect
from functools import update_wrapper from functools import update_wrapper
from pathlib import Path from pathlib import Path
from typing import Callable, Generic, ParamSpec from typing import Callable, Generic, ParamSpec, TypeVar, cast
from .registry import PageDefinition, registry from .registry import PageDefinition, registry
P = ParamSpec("P") P = ParamSpec("P")
R = TypeVar("R")
VariantSelection = int | list[int] | tuple[int, ...] VariantSelection = int | list[int] | tuple[int, ...]
@ -53,6 +54,30 @@ def _normalise_page_number(number: str | int | float) -> str:
return ".".join(parts) return ".".join(parts)
class Reactive(Generic[P, R]):
"""Watch a helper's source file in live preview and preserve its return value."""
def __init__(self, function: Callable[P, R]) -> None:
if not callable(function):
raise TypeError("@Reactive must decorate a callable")
update_wrapper(self, function)
self.function = function
self._source_key: tuple[str, str] | None = None
if inspect.isfunction(function) and "<locals>" not in function.__qualname__:
source = inspect.getsourcefile(function)
if source:
self._source_key = str(Path(source).resolve()), function.__qualname__
registry.register_reactive(self._source_key, function)
def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R:
function = self.function
if self._source_key is not None:
latest = registry.reactive_function(self._source_key)
if latest is not None:
function = cast(Callable[P, R], latest)
return function(*args, **kwargs)
class Component(Generic[P]): class Component(Generic[P]):
"""Normalise a component's string or list/tuple of strings into variants.""" """Normalise a component's string or list/tuple of strings into variants."""
@ -102,5 +127,5 @@ class Page:
return function return function
__all__ = ["Component", "Page"] __all__ = ["Component", "Page", "Reactive"]

View file

@ -5,6 +5,8 @@ from pathlib import Path
from threading import RLock from threading import RLock
from typing import Callable from typing import Callable
ReactiveFunctions = dict[tuple[str, str], Callable[..., object]]
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class PageDefinition: class PageDefinition:
@ -18,6 +20,7 @@ class PageDefinition:
class Registry: class Registry:
def __init__(self) -> None: def __init__(self) -> None:
self._pages: dict[str, PageDefinition] = {} self._pages: dict[str, PageDefinition] = {}
self._reactive: ReactiveFunctions = {}
self._lock = RLock() self._lock = RLock()
def register(self, page: PageDefinition) -> None: def register(self, page: PageDefinition) -> None:
@ -31,6 +34,22 @@ class Registry:
with self._lock: with self._lock:
return list(self._pages.values()) return list(self._pages.values())
def register_reactive(self, key: tuple[str, str], function: Callable[..., object]) -> None:
with self._lock:
self._reactive[key] = function
def reactive_function(self, key: tuple[str, str]) -> Callable[..., object] | None:
with self._lock:
return self._reactive.get(key)
def sources(self) -> set[tuple[str, str]]:
with self._lock:
return {
(page.source_file, page.module) for page in self._pages.values() if page.source_file
} | {
(path, function.__module__) for (path, _), function in self._reactive.items()
}
def resolve_page( def resolve_page(
self, self,
target: str | int | float | Callable[..., object], target: str | int | float | Callable[..., object],
@ -79,13 +98,13 @@ class Registry:
) )
return candidates[0] return candidates[0]
def snapshot(self) -> dict[str, PageDefinition]: def snapshot(self) -> tuple[dict[str, PageDefinition], ReactiveFunctions]:
with self._lock: with self._lock:
return dict(self._pages) return self._pages.copy(), self._reactive.copy()
def restore(self, snapshot: dict[str, PageDefinition]) -> None: def restore(self, snapshot: tuple[dict[str, PageDefinition], ReactiveFunctions]) -> None:
with self._lock: with self._lock:
self._pages = dict(snapshot) self._pages, self._reactive = snapshot
def remove_source(self, source_file: str | Path) -> None: def remove_source(self, source_file: str | Path) -> None:
target = str(Path(source_file).resolve()) target = str(Path(source_file).resolve())
@ -95,11 +114,15 @@ class Registry:
for key, page in self._pages.items() for key, page in self._pages.items()
if not page.source_file or str(Path(page.source_file).resolve()) != target if not page.source_file or str(Path(page.source_file).resolve()) != target
} }
self._reactive = {
key: function for key, function in self._reactive.items() if key[0] != target
}
def clear(self) -> None: def clear(self) -> None:
"""Clear all registered pages. Primarily useful in test suites.""" """Clear all registered pages. Primarily useful in test suites."""
with self._lock: with self._lock:
self._pages.clear() self._pages.clear()
self._reactive.clear()
registry = Registry() registry = Registry()

View file

@ -1,10 +1,10 @@
from __future__ import annotations from __future__ import annotations
import importlib
import runpy import runpy
import sys import sys
from pathlib import Path from pathlib import Path
from threading import RLock from threading import RLock
from types import ModuleType
from typing import Mapping, Sequence from typing import Mapping, Sequence
import uvicorn import uvicorn
@ -57,20 +57,18 @@ class DocumentationSite:
return self._last_error return self._last_error
def _discover_sources(self) -> None: def _discover_sources(self) -> None:
for page in registry.pages(): self._source_modules.clear()
if not page.source_file: for source_file, module in registry.sources():
continue path = Path(source_file)
path = Path(page.source_file).resolve() self._source_modules.setdefault(path, set()).add(module)
self._source_modules.setdefault(path, set()).add(page.module) watched = self._source_modules.keys() | set(self.compiler.watched_files())
for path in self._source_mtimes.keys() - watched:
del self._source_mtimes[path]
for path in watched - self._source_mtimes.keys():
try: try:
self._source_mtimes[path] = path.stat().st_mtime_ns self._source_mtimes[path] = path.stat().st_mtime_ns
except OSError: except OSError:
continue continue
for path in self.compiler.watched_files():
try:
self._source_mtimes.setdefault(path, path.stat().st_mtime_ns)
except OSError:
continue
def refresh_changed_sources(self) -> bool: def refresh_changed_sources(self) -> bool:
if not self.live_preview: if not self.live_preview:
@ -88,11 +86,15 @@ class DocumentationSite:
return False return False
snapshot = registry.snapshot() snapshot = registry.snapshot()
module_snapshots: dict[ModuleType, dict[str, object]] = {}
try: try:
for path, _ in changed: for path, _ in changed:
self._reload_source(path) self._reload_source(path, module_snapshots)
new_result = self.compiler.build() new_result = self.compiler.build()
except Exception as error: except Exception as error:
for module, namespace in module_snapshots.items():
module.__dict__.clear()
module.__dict__.update(namespace)
registry.restore(snapshot) registry.restore(snapshot)
self._last_error = f"{type(error).__name__}: {error}" self._last_error = f"{type(error).__name__}: {error}"
for path, mtime in changed: for path, mtime in changed:
@ -103,19 +105,22 @@ class DocumentationSite:
self._last_error = None self._last_error = None
for path, mtime in changed: for path, mtime in changed:
self._source_mtimes[path] = mtime self._source_mtimes[path] = mtime
self._source_modules.clear()
self._discover_sources() self._discover_sources()
return True return True
def _reload_source(self, path: Path) -> None: def _reload_source(self, path: Path, snapshots: dict[ModuleType, dict[str, object]]) -> None:
modules = self._source_modules.get(path, set()) modules = self._source_modules.get(path, set())
if not modules: if not modules:
return return
registry.remove_source(path) registry.remove_source(path)
reloadable = [name for name in modules if name not in {"__main__", "<run_path>"} and name in sys.modules] reloadable = [name for name in modules if name not in {"__main__", "<run_path>"} and name in sys.modules]
if reloadable: if reloadable:
# Read the source directly so a same-size edit within one second bypasses stale bytecode.
code = compile(path.read_bytes(), str(path), "exec", dont_inherit=True)
for name in reloadable: for name in reloadable:
importlib.reload(sys.modules[name]) module = sys.modules[name]
snapshots.setdefault(module, module.__dict__.copy())
exec(code, module.__dict__)
else: else:
# run_name is deliberately not __main__, so a guarded run() call is not executed again. # run_name is deliberately not __main__, so a guarded run() call is not executed again.
runpy.run_path(str(path), run_name=f"_docslib_live_{abs(hash(path))}") runpy.run_path(str(path), run_name=f"_docslib_live_{abs(hash(path))}")
@ -162,7 +167,7 @@ def create_app(
@app.get("/", include_in_schema=False) @app.get("/", include_in_schema=False)
@app.get("/index.html", response_class=HTMLResponse) @app.get("/index.html", response_class=HTMLResponse)
def index() -> HTMLResponse: def index() -> HTMLResponse:
return HTMLResponse(site.result.html) return HTMLResponse(site.result.html, headers=NO_CACHE_HEADERS if live_preview else None)
@app.get("/manifest.json", response_class=JSONResponse) @app.get("/manifest.json", response_class=JSONResponse)
def manifest() -> JSONResponse: def manifest() -> JSONResponse:

View file

@ -57,6 +57,7 @@
let lastPlaceholderPage = -1; let lastPlaceholderPage = -1;
let customScriptLoaded = false; let customScriptLoaded = false;
let lastSyncedRevision = String(manifest.revision || ""); let lastSyncedRevision = String(manifest.revision || "");
let liveReloading = false;
let pageTransitionId = 0; let pageTransitionId = 0;
let pageAnimation = null; let pageAnimation = null;
let skipNextPageAnimation = false; let skipNextPageAnimation = false;
@ -838,21 +839,54 @@
} }
async function pollLive() { async function pollLive() {
if (liveReloading) return;
try { try {
const response = await fetch(config.liveUrl, { cache: "no-store" }); const response = await fetch(config.liveUrl, { cache: "no-store" });
if (!response.ok) return; if (!response.ok) return;
const live = await response.json(); const live = await response.json();
if (live.revision && live.revision !== lastSyncedRevision) { if (liveReloading || !live.revision) return;
const changed = await syncManifest(); const revisionChanged = live.revision !== manifest.revision;
if (changed) location.reload(); if (!revisionChanged && live.updated_at === manifest.updated_at) return;
liveReloading = true;
if (revisionChanged) await syncManifest();
const docslibLiveScroll = { page: currentNumber, top: elements.pageSurface.scrollTop };
try {
history.replaceState({ ...history.state, docslibLiveScroll }, "");
} catch (_) { /* reload even when history state is unavailable */ }
location.reload();
} catch (_) {
liveReloading = false;
} }
} catch (_) { /* local preview may stop */ } }
async function restoreLiveScroll() {
history.scrollRestoration = "manual";
if (!history.state?.docslibLiveScroll) return;
const { docslibLiveScroll, ...state } = history.state;
history.replaceState(state, "");
const { page, top } = docslibLiveScroll;
if (page !== currentNumber || !Number.isFinite(top) || top <= 0) return;
const surface = elements.pageSurface;
surface.scrollTo({ top, behavior: "instant" });
if (surface.scrollTop >= top - 1) return;
// Images can contribute to the scroll height only after their dimensions are known.
const pendingImages = [...elements.content.querySelectorAll("img")].filter((image) => !image.complete);
if (!pendingImages.length) return;
pendingImages.forEach((image) => { image.loading = "eager"; });
await Promise.race([
waitForPrintAssets(elements.content),
new Promise((resolve) => setTimeout(resolve, 300)),
]);
if (page === currentNumber) surface.scrollTo({ top, behavior: "instant" });
} }
async function start() { async function start() {
applyTheme(localStorage.getItem("docslib-theme") || "light"); applyTheme(localStorage.getItem("docslib-theme") || "light");
loadAccessibilityPreferences(); loadAccessibilityPreferences();
renderAll(); renderAll();
if (config.livePreview) await restoreLiveScroll();
announceRendered(); announceRendered();
syncManifest(); syncManifest();
if (config.livePreview) setInterval(pollLive, 1200); if (config.livePreview) setInterval(pollLive, 1200);

View file

@ -19,7 +19,7 @@
); );
} }
const dbPromise = new Promise((resolve, reject) => { const dbPromise = config.livePreview ? null : new Promise((resolve, reject) => {
if (!window.indexedDB) return reject(new Error("IndexedDB")); if (!window.indexedDB) return reject(new Error("IndexedDB"));
const request = indexedDB.open("docslib-cache", 1); const request = indexedDB.open("docslib-cache", 1);
request.onupgradeneeded = () => { request.onupgradeneeded = () => {
@ -51,6 +51,7 @@
} }
async function selectManifest() { async function selectManifest() {
if (config.livePreview) return embedded;
try { try {
const cached = await cacheGet(); const cached = await cacheGet();
if (isManifest(cached) && cached.schema_version === embedded.schema_version) { if (isManifest(cached) && cached.schema_version === embedded.schema_version) {

View file

@ -1,8 +1,10 @@
import base64 import base64
import importlib.util
import io import io
import json import json
import os import os
import runpy import runpy
import sys
import zipfile import zipfile
from pathlib import Path from pathlib import Path
@ -24,7 +26,9 @@ def test_loader_renders_indexeddb_before_background_sync():
assert "return cached;" in loader assert "return cached;" in loader
assert "await cachePut(embedded);" in loader assert "await cachePut(embedded);" in loader
assert "fetch(" not in loader assert "fetch(" not in loader
assert "renderAll();\n announceRendered();\n syncManifest();" in client assert 'if (config.livePreview) return embedded;' in loader
assert client.index(" renderAll();") < client.index(" announceRendered();")
assert " announceRendered();\n syncManifest();" in client
assert "manifest = remote" not in client assert "manifest = remote" not in client
@ -343,6 +347,58 @@ def test_live_preview_reloads_a_changed_main_source(tmp_path):
assert "two" in client.get("/manifest.json").json()["pages"][0]["content"] assert "two" in client.get("/manifest.json").json()["pages"][0]["content"]
def test_live_preview_reloads_imported_reactive_helpers(tmp_path, monkeypatch):
source = tmp_path / "reactive_preview_helper.py"
initial_source = (
"from docslib import Reactive\n"
"TITLE = 'one'\n"
"@Reactive\n"
"def product_name(prefix=''): return prefix + TITLE\n"
)
source.write_text(initial_source, encoding="utf-8")
os.utime(source, ns=(1_800_000_000_000_000_000,) * 2)
spec = importlib.util.spec_from_file_location(source.stem, source)
helper = importlib.util.module_from_spec(spec)
monkeypatch.setitem(sys.modules, source.stem, helper)
spec.loader.exec_module(helper)
product_name = helper.product_name
@Page("1", "First")
def first():
return product_name("First ")
@Page("2", "Second")
def second():
return product_name(prefix="Second ")
client = TestClient(create_app(live_preview=True))
initial = client.get("/manifest.json").json()
assert [page["content"] for page in initial["pages"]] == ["First one", "Second one"]
# Keep both file size and whole-second mtime to exercise rapid saves.
updated_source = initial_source.replace("'one'", "'two'")
source.write_text(updated_source, encoding="utf-8")
os.utime(source, ns=(1_800_000_000_100_000_000,) * 2)
live = client.get("/live").json()
assert live["changed"] is True
assert live["revision"] != initial["revision"]
updated = client.get("/manifest.json").json()
assert [page["content"] for page in updated["pages"]] == ["First two", "Second two"]
source.write_text(updated_source.replace("'two'", "None"), encoding="utf-8")
live = client.get("/live").json()
assert live["changed"] is False
assert live["error"].startswith("RuntimeError:")
assert client.get("/manifest.json").json() == updated
assert product_name() == "two"
source.write_text(updated_source.replace("'two'", "'three'"), encoding="utf-8")
live = client.get("/live").json()
assert live["changed"] is True
assert live["error"] is None
assert product_name() == "three"
def test_live_preview_rebuilds_changed_custom_javascript(tmp_path): def test_live_preview_rebuilds_changed_custom_javascript(tmp_path):
custom_js = tmp_path / "inject.js" custom_js = tmp_path / "inject.js"
custom_js.write_text("window.customVersion = 1;", encoding="utf-8") custom_js.write_text("window.customVersion = 1;", encoding="utf-8")