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-режиме браузер опрашивает только `/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 работает с локальными исходниками проекта.

View file

@ -2,11 +2,11 @@
from ._version import __version__
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 .ui import HeaderButton
__all__ = [
"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
from functools import update_wrapper
from pathlib import Path
from typing import Callable, Generic, ParamSpec
from typing import Callable, Generic, ParamSpec, TypeVar, cast
from .registry import PageDefinition, registry
P = ParamSpec("P")
R = TypeVar("R")
VariantSelection = int | list[int] | tuple[int, ...]
@ -53,6 +54,30 @@ def _normalise_page_number(number: str | int | float) -> str:
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]):
"""Normalise a component's string or list/tuple of strings into variants."""
@ -102,5 +127,5 @@ class Page:
return function
__all__ = ["Component", "Page"]
__all__ = ["Component", "Page", "Reactive"]

View file

@ -5,6 +5,8 @@ from pathlib import Path
from threading import RLock
from typing import Callable
ReactiveFunctions = dict[tuple[str, str], Callable[..., object]]
@dataclass(frozen=True, slots=True)
class PageDefinition:
@ -18,6 +20,7 @@ class PageDefinition:
class Registry:
def __init__(self) -> None:
self._pages: dict[str, PageDefinition] = {}
self._reactive: ReactiveFunctions = {}
self._lock = RLock()
def register(self, page: PageDefinition) -> None:
@ -31,6 +34,22 @@ class Registry:
with self._lock:
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(
self,
target: str | int | float | Callable[..., object],
@ -79,13 +98,13 @@ class Registry:
)
return candidates[0]
def snapshot(self) -> dict[str, PageDefinition]:
def snapshot(self) -> tuple[dict[str, PageDefinition], ReactiveFunctions]:
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:
self._pages = dict(snapshot)
self._pages, self._reactive = snapshot
def remove_source(self, source_file: str | Path) -> None:
target = str(Path(source_file).resolve())
@ -95,11 +114,15 @@ class Registry:
for key, page in self._pages.items()
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:
"""Clear all registered pages. Primarily useful in test suites."""
with self._lock:
self._pages.clear()
self._reactive.clear()
registry = Registry()

View file

@ -1,10 +1,10 @@
from __future__ import annotations
import importlib
import runpy
import sys
from pathlib import Path
from threading import RLock
from types import ModuleType
from typing import Mapping, Sequence
import uvicorn
@ -57,20 +57,18 @@ class DocumentationSite:
return self._last_error
def _discover_sources(self) -> None:
for page in registry.pages():
if not page.source_file:
continue
path = Path(page.source_file).resolve()
self._source_modules.setdefault(path, set()).add(page.module)
self._source_modules.clear()
for source_file, module in registry.sources():
path = Path(source_file)
self._source_modules.setdefault(path, set()).add(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:
self._source_mtimes[path] = path.stat().st_mtime_ns
except OSError:
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:
if not self.live_preview:
@ -88,11 +86,15 @@ class DocumentationSite:
return False
snapshot = registry.snapshot()
module_snapshots: dict[ModuleType, dict[str, object]] = {}
try:
for path, _ in changed:
self._reload_source(path)
self._reload_source(path, module_snapshots)
new_result = self.compiler.build()
except Exception as error:
for module, namespace in module_snapshots.items():
module.__dict__.clear()
module.__dict__.update(namespace)
registry.restore(snapshot)
self._last_error = f"{type(error).__name__}: {error}"
for path, mtime in changed:
@ -103,19 +105,22 @@ class DocumentationSite:
self._last_error = None
for path, mtime in changed:
self._source_mtimes[path] = mtime
self._source_modules.clear()
self._discover_sources()
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())
if not modules:
return
registry.remove_source(path)
reloadable = [name for name in modules if name not in {"__main__", "<run_path>"} and name in sys.modules]
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:
importlib.reload(sys.modules[name])
module = sys.modules[name]
snapshots.setdefault(module, module.__dict__.copy())
exec(code, module.__dict__)
else:
# 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))}")
@ -162,7 +167,7 @@ def create_app(
@app.get("/", include_in_schema=False)
@app.get("/index.html", response_class=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)
def manifest() -> JSONResponse:

View file

@ -57,6 +57,7 @@
let lastPlaceholderPage = -1;
let customScriptLoaded = false;
let lastSyncedRevision = String(manifest.revision || "");
let liveReloading = false;
let pageTransitionId = 0;
let pageAnimation = null;
let skipNextPageAnimation = false;
@ -838,21 +839,54 @@
}
async function pollLive() {
if (liveReloading) return;
try {
const response = await fetch(config.liveUrl, { cache: "no-store" });
if (!response.ok) return;
const live = await response.json();
if (live.revision && live.revision !== lastSyncedRevision) {
const changed = await syncManifest();
if (changed) location.reload();
if (liveReloading || !live.revision) return;
const revisionChanged = live.revision !== manifest.revision;
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() {
applyTheme(localStorage.getItem("docslib-theme") || "light");
loadAccessibilityPreferences();
renderAll();
if (config.livePreview) await restoreLiveScroll();
announceRendered();
syncManifest();
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"));
const request = indexedDB.open("docslib-cache", 1);
request.onupgradeneeded = () => {
@ -51,6 +51,7 @@
}
async function selectManifest() {
if (config.livePreview) return embedded;
try {
const cached = await cacheGet();
if (isManifest(cached) && cached.schema_version === embedded.schema_version) {

View file

@ -1,8 +1,10 @@
import base64
import importlib.util
import io
import json
import os
import runpy
import sys
import zipfile
from pathlib import Path
@ -24,7 +26,9 @@ def test_loader_renders_indexeddb_before_background_sync():
assert "return cached;" in loader
assert "await cachePut(embedded);" 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
@ -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"]
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):
custom_js = tmp_path / "inject.js"
custom_js.write_text("window.customVersion = 1;", encoding="utf-8")