From f1b10c2b121cc730723fd77cc2fe702652332f0f Mon Sep 17 00:00:00 2001 From: Server <10.9.8.250@reg.snw.su> Date: Mon, 10 Aug 2026 17:28:30 +0000 Subject: [PATCH] Deliver client runtime through manifest --- README.md | 30 +++++++--- docslib/__init__.py | 2 +- docslib/compiler.py | 43 +++++++++++--- docslib/server.py | 13 +++++ docslib/templates/client.js | 81 ++++++++++++++++---------- docslib/templates/index.html | 9 +-- docslib/templates/loader.js | 106 +++++++++++++++++++++++++++++++++++ examples/assets/inject.js | 3 + examples/basic.py | 1 + pyproject.toml | 2 +- tests/test_server.py | 41 +++++++++++++- uv.lock | 2 +- 12 files changed, 277 insertions(+), 56 deletions(-) create mode 100644 docslib/templates/loader.js create mode 100644 examples/assets/inject.js diff --git a/README.md b/README.md index a107b24..43eb124 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ SharedDocsLib — небольшой Python-фреймворк для докум - собственные `@Component` и набор безопасных базовых компонентов; - поиск по всему контенту, светлая/тёмная тема, breadcrumbs, Previous/Next; - изображения из `assets_dir`, единожды закодированные в base64 внутри manifest; -- полностью автономный `index.html`: CSS, JavaScript и начальный manifest встроены; +- полностью автономный `index.html`: начальный manifest вместе с CSS и JavaScript встроен; - хранение manifest в IndexedDB и одна проверка обновления при загрузке; - `index.zip`, содержащий только `index.html`; - live-preview с перекомпиляцией изменившегося Python-модуля. @@ -72,6 +72,7 @@ if __name__ == "__main__": title="My docs", assets_dir="./assets", custom_css="./assets/inject.css", + custom_js="./assets/inject.js", manifest_path="http://127.0.0.1:1234/", live_preview=True, header_buttons=[ @@ -96,6 +97,7 @@ build( title="My docs", assets_dir="./assets", custom_css="./assets/inject.css", + custom_js="./assets/inject.js", manifest_path="./manifest.json", header_buttons=[ HeaderButton("Знакомство", page1), @@ -174,31 +176,45 @@ header_buttons=[ ## Manifest -Manifest имеет версию схемы, заголовок, `revision`, `updated_at`, страницы, настройки UI и словарь ресурсов: +Manifest имеет версию схемы, заголовок, `revision`, `updated_at`, страницы, настройки UI, словарь ресурсов и полный клиентский runtime. Системные файлы шаблона и пользовательские дополнения хранятся раздельно, а при загрузке объединяются в указанном порядке: ```json { - "schema_version": 1, + "schema_version": 2, "title": "My docs", "revision": "…", "updated_at": "…", "pages": [{"number": "1", "title": "Home", "content": "

"}], "assets": {"photo.png": {"mime": "image/png", "data": "base64…"}}, - "ui": {"header_buttons": []} + "ui": {"header_buttons": []}, + "client": { + "css": {"system": "…", "custom": "…"}, + "js": {"system": "…", "custom": "…"} + } } ``` -При первом открытии встроенный manifest сохраняется в IndexedDB. Затем клиент один раз запрашивает `manifest_path`. Если `revision` или `updated_at` отличаются, новый manifest заменяет старый и страница перезагружается. При недоступном сервере продолжает работать кеш или встроенная копия. +При открытии минимальный HTML-loader показывает серый fixed-overlay размером `100vw × 100dvh`, проверяет IndexedDB и один раз запрашивает `manifest_path`. Затем он применяет CSS, запускает системный JavaScript и удаляет overlay только после события первого успешного рендера. При недоступном сервере используется кеш или встроенная копия manifest. + +`custom_css` и `custom_js` принимают пути к UTF-8-файлам. Пользовательский CSS добавляется после системного, поэтому может переопределять тему. Пользовательский JavaScript загружается после системного runtime и до события `docslib:rendered`: + +```javascript +document.addEventListener("docslib:rendered", (event) => { + console.log(event.detail.manifest.revision); +}, { once: true }); +``` + +Код из `custom_js` считается доверенным и выполняется в контексте страницы. Изменение системного или пользовательского CSS/JS меняет `revision`, поэтому обновлённый runtime сохраняется в IndexedDB и применяется после перезагрузки клиента. ## Live-preview -В live-режиме браузер опрашивает только `/live`. Сервер следит за файлами, в которых объявлены страницы, и при изменении повторно выполняет их, после чего клиент получает новый manifest. В запускаемом файле вызов `run()` обязательно помещать под `if __name__ == "__main__"`, чтобы watcher не запускал второй сервер. +В live-режиме браузер опрашивает только `/live`. Сервер следит за файлами, в которых объявлены страницы, а также за системными и пользовательскими CSS/JS. При изменении он пересобирает manifest; клиент сохраняет его и перезагружается с новым runtime. В запускаемом файле вызов `run()` обязательно помещать под `if __name__ == "__main__"`, чтобы watcher не запускал второй сервер. HTTP-загрузка и исполнение произвольных `.py` файлов намеренно отсутствуют: такая ручка была бы удалённым выполнением кода. Live-preview работает с локальными исходниками проекта. ## Свой клиент -Встроенные исходники находятся в `docslib/templates/`: `index.html`, `client.css`, `client.js`. Скопируйте папку, измените файлы и передайте `template_dir="./my-template"`. Компилятор проверяет обязательные placeholders и всё равно выдаёт один автономный HTML. +Встроенные исходники находятся в `docslib/templates/`: `index.html`, `loader.js`, `client.css`, `client.js`. `loader.js` является небольшим bootstrap-кодом HTML, а `client.css` и `client.js` попадают в manifest. Скопируйте папку, измените файлы и передайте `template_dir="./my-template"`. Компилятор проверяет обязательные placeholders и всё равно выдаёт один автономный HTML. Для тестов: diff --git a/docslib/__init__.py b/docslib/__init__.py index 05215f1..be4636b 100644 --- a/docslib/__init__.py +++ b/docslib/__init__.py @@ -5,4 +5,4 @@ from .server import build, create_app, run from .ui import HeaderButton __all__ = ["Component", "HeaderButton", "Page", "build", "create_app", "run"] -__version__ = "0.2.2" +__version__ = "0.3.0" diff --git a/docslib/compiler.py b/docslib/compiler.py index ef03409..81d1b06 100644 --- a/docslib/compiler.py +++ b/docslib/compiler.py @@ -66,12 +66,12 @@ def _safe_json_for_script(value: object) -> str: ) -def _read_optional_file(path: str | Path | None) -> str: +def _read_optional_file(path: str | Path | None, *, label: str) -> str: if path is None: return "" file_path = Path(path).expanduser().resolve() if not file_path.is_file(): - raise FileNotFoundError(f"CSS file does not exist: {file_path}") + raise FileNotFoundError(f"{label} file does not exist: {file_path}") return file_path.read_text(encoding="utf-8") @@ -82,6 +82,7 @@ class Compiler: title: str = "Documentation", assets_dir: str | Path = "./assets", custom_css: str | Path | None = None, + custom_js: str | Path | None = None, template_dir: str | Path | None = None, manifest_url: str = "./manifest.json", live_preview: bool = False, @@ -90,6 +91,7 @@ class Compiler: self.title = str(title) self.assets_dir = Path(assets_dir).expanduser().resolve() self.custom_css = custom_css + self.custom_js = custom_js self.template_dir = ( Path(template_dir).expanduser().resolve() if template_dir @@ -103,11 +105,12 @@ class Compiler: pages = self._render_pages(registry.pages()) assets = self._load_assets(pages) stable_content: dict[str, object] = { - "schema_version": 1, + "schema_version": 2, "title": self.title, "pages": pages, "assets": assets, "ui": {"header_buttons": self._compile_header_buttons()}, + "client": self._load_client(), } canonical = json.dumps(stable_content, ensure_ascii=False, sort_keys=True, separators=(",", ":")) revision = hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:20] @@ -122,6 +125,31 @@ class Compiler: archive.writestr("index.html", html) return BuildResult(manifest=manifest, html=html, zip_archive=archive_buffer.getvalue()) + def watched_files(self) -> tuple[Path, ...]: + """Return CSS and JavaScript sources that affect the manifest revision.""" + paths = [self.template_dir / "client.css", self.template_dir / "client.js"] + for optional in (self.custom_css, self.custom_js): + if optional is not None: + paths.append(Path(optional).expanduser().resolve()) + return tuple(paths) + + def _load_client(self) -> dict[str, dict[str, str]]: + css_path = self.template_dir / "client.css" + script_path = self.template_dir / "client.js" + for path in (css_path, script_path): + if not path.is_file(): + raise FileNotFoundError(f"Template file does not exist: {path}") + return { + "css": { + "system": css_path.read_text(encoding="utf-8"), + "custom": _read_optional_file(self.custom_css, label="CSS"), + }, + "js": { + "system": script_path.read_text(encoding="utf-8"), + "custom": _read_optional_file(self.custom_js, label="JavaScript"), + }, + } + def _render_pages(self, definitions: list[PageDefinition]) -> list[dict[str, str]]: rendered: list[dict[str, str]] = [] for page in sorted(definitions, key=page_sort_key): @@ -191,9 +219,8 @@ class Compiler: def _build_html(self, manifest: dict[str, object]) -> str: base_path = self.template_dir / "index.html" - css_path = self.template_dir / "client.css" - script_path = self.template_dir / "client.js" - for path in (base_path, css_path, script_path): + loader_path = self.template_dir / "loader.js" + for path in (base_path, loader_path): if not path.is_file(): raise FileNotFoundError(f"Template file does not exist: {path}") template = base_path.read_text(encoding="utf-8") @@ -205,11 +232,9 @@ class Compiler: } replacements = { "{{DOCSLIB_TITLE}}": self.title.replace("&", "&").replace("<", "<").replace(">", ">"), - "{{DOCSLIB_CSS}}": css_path.read_text(encoding="utf-8"), - "{{DOCSLIB_CUSTOM_CSS}}": _read_optional_file(self.custom_css), "{{DOCSLIB_MANIFEST}}": _safe_json_for_script(manifest), "{{DOCSLIB_CONFIG}}": _safe_json_for_script(config), - "{{DOCSLIB_JS}}": script_path.read_text(encoding="utf-8"), + "{{DOCSLIB_LOADER}}": loader_path.read_text(encoding="utf-8"), } for marker, value in replacements.items(): if marker not in template: diff --git a/docslib/server.py b/docslib/server.py index 9a07be6..59ebfb0 100644 --- a/docslib/server.py +++ b/docslib/server.py @@ -58,6 +58,11 @@ class DocumentationSite: 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: @@ -96,6 +101,8 @@ class DocumentationSite: def _reload_source(self, path: Path) -> 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__", ""} and name in sys.modules] if reloadable: @@ -111,6 +118,7 @@ def create_app( title: str = "Documentation", assets_dir: str | Path = "./assets", custom_css: str | Path | None = None, + custom_js: str | Path | None = None, template_dir: str | Path | None = None, manifest_path: str | None = None, manifest_parh: str | None = None, @@ -124,6 +132,7 @@ def create_app( title=title, assets_dir=assets_dir, custom_css=custom_css, + custom_js=custom_js, template_dir=template_dir, manifest_url=manifest_url, live_preview=live_preview, @@ -185,6 +194,7 @@ def build( title: str = "Documentation", assets_dir: str | Path = "./assets", custom_css: str | Path | None = None, + custom_js: str | Path | None = None, template_dir: str | Path | None = None, manifest_path: str | None = None, manifest_parh: str | None = None, @@ -195,6 +205,7 @@ def build( title=title, assets_dir=assets_dir, custom_css=custom_css, + custom_js=custom_js, template_dir=template_dir, manifest_url=_manifest_url(manifest_path, manifest_parh), live_preview=False, @@ -219,6 +230,7 @@ def run( title: str = "Documentation", assets_dir: str | Path = "./assets", custom_css: str | Path | None = None, + custom_js: str | Path | None = None, template_dir: str | Path | None = None, manifest_path: str | None = None, manifest_parh: str | None = None, @@ -232,6 +244,7 @@ def run( title=title, assets_dir=assets_dir, custom_css=custom_css, + custom_js=custom_js, template_dir=template_dir, manifest_path=manifest_path, manifest_parh=manifest_parh, diff --git a/docslib/templates/client.js b/docslib/templates/client.js index 7be72f4..f3d141a 100644 --- a/docslib/templates/client.js +++ b/docslib/templates/client.js @@ -2,7 +2,7 @@ "use strict"; const embedded = JSON.parse(document.getElementById("docslib-manifest").textContent); - const config = JSON.parse(document.getElementById("docslib-config").textContent); + const config = window.__DOCSLIB_CONFIG__ || JSON.parse(document.getElementById("docslib-config").textContent); const mobileLayout = matchMedia("(max-width: 820px)"); const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)"); const elements = { @@ -30,7 +30,7 @@ mobileMenuButton: document.getElementById("mobile-menu-button"), }; - let manifest = embedded; + let manifest = window.__DOCSLIB_MANIFEST__ || embedded; let currentNumber = null; let syncing = false; let mobileMode = "content"; @@ -42,24 +42,20 @@ let placeholderDots = 0; let placeholderPhase = "typing"; let lastPlaceholderPage = -1; + let customScriptLoaded = false; const dbPromise = new Promise((resolve, reject) => { if (!window.indexedDB) return reject(new Error("IndexedDB")); const request = indexedDB.open("docslib-cache", 1); - request.onupgradeneeded = () => request.result.createObjectStore("manifests"); + request.onupgradeneeded = () => { + if (!request.result.objectStoreNames.contains("manifests")) { + request.result.createObjectStore("manifests"); + } + }; request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); - async function cacheGet() { - const db = await dbPromise; - return new Promise((resolve, reject) => { - const request = db.transaction("manifests", "readonly").objectStore("manifests").get(config.cacheKey); - request.onsuccess = () => resolve(request.result); - request.onerror = () => reject(request.error); - }); - } - async function cachePut(value) { const db = await dbPromise; return new Promise((resolve, reject) => { @@ -79,6 +75,19 @@ return [updated, revision].filter(Boolean).join(" · ") || "—"; } + function isManifest(value) { + return Boolean( + value && + value.schema_version >= 2 && + Array.isArray(value.pages) && + value.client && + value.client.css && + typeof value.client.css.system === "string" && + value.client.js && + typeof value.client.js.system === "string" + ); + } + function updateIdentity() { elements.identity.textContent = manifestIdentity(); elements.refresh.title = String(manifest.revision || ""); @@ -398,6 +407,7 @@ } function renderAll() { + window.__DOCSLIB_MANIFEST__ = manifest; document.documentElement.lang = "und"; elements.desktopTitle.textContent = manifest.title; elements.mobileTitle.textContent = manifest.title; @@ -410,7 +420,28 @@ startPlaceholder(); } - async function syncManifest({ reloadOnChange = false } = {}) { + function loadCustomScript() { + if (customScriptLoaded) return; + customScriptLoaded = true; + const source = manifest.client && manifest.client.js && manifest.client.js.custom; + if (!source || !source.trim()) return; + const script = document.createElement("script"); + script.id = "docslib-custom-script"; + script.textContent = `${source}\n//# sourceURL=docslib-custom.js`; + document.body.append(script); + } + + function announceRendered() { + try { + loadCustomScript(); + } catch (error) { + console.error("SharedDocsLib custom_js", error); + } finally { + document.dispatchEvent(new CustomEvent("docslib:rendered", { detail: { manifest } })); + } + } + + async function syncManifest() { if (syncing) return false; setSyncing(true); let changed = false; @@ -418,16 +449,13 @@ const response = await fetch(config.manifestUrl, { cache: "no-store" }); if (!response.ok) throw new Error(String(response.status)); const remote = await response.json(); - if (!remote.schema_version || !Array.isArray(remote.pages)) throw new Error("manifest"); + if (!isManifest(remote) || remote.schema_version !== embedded.schema_version) throw new Error("manifest"); changed = remote.revision !== manifest.revision || remote.updated_at !== manifest.updated_at; if (changed) { await cachePut(remote).catch(() => {}); manifest = remote; - if (reloadOnChange) { - location.reload(); - return true; - } - renderAll(); + location.reload(); + return true; } elements.refresh.classList.remove("error"); updateIdentity(); @@ -452,17 +480,8 @@ async function start() { applyTheme(localStorage.getItem("docslib-theme") || "dark"); - try { - const cached = await cacheGet(); - if ( - cached && - cached.schema_version === embedded.schema_version && - String(cached.updated_at || "") >= String(embedded.updated_at || "") - ) manifest = cached; - else await cachePut(embedded); - } catch (_) { /* embedded manifest remains available */ } renderAll(); - await syncManifest({ reloadOnChange: true }); + announceRendered(); if (config.livePreview) setInterval(pollLive, 1200); } @@ -484,8 +503,8 @@ elements.refresh.addEventListener("blur", () => elements.toolbar.classList.remove("refresh-expanded")); elements.refresh.addEventListener("click", async () => { if (syncing) return; - await syncManifest(); - location.reload(); + const changed = await syncManifest(); + if (!changed) location.reload(); }); document.addEventListener("pointerdown", (event) => { if (!elements.desktopSearchShell.contains(event.target)) elements.desktopSearchShell.classList.remove("open"); diff --git a/docslib/templates/index.html b/docslib/templates/index.html index 199b269..5677f25 100644 --- a/docslib/templates/index.html +++ b/docslib/templates/index.html @@ -5,12 +5,13 @@ {{DOCSLIB_TITLE}} - +

{{DOCSLIB_TITLE}}

@@ -70,7 +71,7 @@ diff --git a/docslib/templates/loader.js b/docslib/templates/loader.js new file mode 100644 index 0000000..8760f6b --- /dev/null +++ b/docslib/templates/loader.js @@ -0,0 +1,106 @@ +(() => { + "use strict"; + + const embedded = JSON.parse(document.getElementById("docslib-manifest").textContent); + const config = JSON.parse(document.getElementById("docslib-config").textContent); + const loading = document.getElementById("docslib-loading"); + const loaderStyle = document.getElementById("docslib-loader-style"); + + function isManifest(value) { + return Boolean( + value && + value.schema_version >= 2 && + Array.isArray(value.pages) && + value.client && + value.client.css && + typeof value.client.css.system === "string" && + value.client.js && + typeof value.client.js.system === "string" + ); + } + + const dbPromise = new Promise((resolve, reject) => { + if (!window.indexedDB) return reject(new Error("IndexedDB")); + const request = indexedDB.open("docslib-cache", 1); + request.onupgradeneeded = () => { + if (!request.result.objectStoreNames.contains("manifests")) { + request.result.createObjectStore("manifests"); + } + }; + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + + async function cacheGet() { + const db = await dbPromise; + return new Promise((resolve, reject) => { + const request = db.transaction("manifests", "readonly").objectStore("manifests").get(config.cacheKey); + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + } + + async function cachePut(value) { + const db = await dbPromise; + return new Promise((resolve, reject) => { + const transaction = db.transaction("manifests", "readwrite"); + transaction.objectStore("manifests").put(value, config.cacheKey); + transaction.oncomplete = resolve; + transaction.onerror = () => reject(transaction.error); + }); + } + + async function selectManifest() { + let selected = embedded; + try { + const cached = await cacheGet(); + if ( + isManifest(cached) && + cached.schema_version === embedded.schema_version && + String(cached.updated_at || "") >= String(embedded.updated_at || "") + ) selected = cached; + else await cachePut(embedded); + } catch (_) { /* embedded manifest remains available */ } + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 4000); + try { + const response = await fetch(config.manifestUrl, { cache: "no-store", signal: controller.signal }); + if (!response.ok) throw new Error(String(response.status)); + const remote = await response.json(); + if (!isManifest(remote) || remote.schema_version !== embedded.schema_version) { + throw new Error("manifest"); + } + selected = remote; + await cachePut(remote).catch(() => {}); + } catch (_) { /* cached or embedded manifest remains available */ } + finally { clearTimeout(timeout); } + return selected; + } + + function applyRuntime(manifest) { + const style = document.createElement("style"); + style.id = "docslib-runtime-style"; + style.textContent = [manifest.client.css.system, manifest.client.css.custom || ""].join("\n"); + document.head.append(style); + + window.__DOCSLIB_MANIFEST__ = manifest; + window.__DOCSLIB_CONFIG__ = config; + const script = document.createElement("script"); + script.id = "docslib-runtime-script"; + script.textContent = `${manifest.client.js.system}\n//# sourceURL=docslib-client.js`; + document.body.append(script); + } + + document.addEventListener("docslib:rendered", () => { + loading?.remove(); + loaderStyle?.remove(); + }, { once: true }); + + selectManifest() + .then((manifest) => { + if (!isManifest(manifest)) throw new Error("manifest"); + applyRuntime(manifest); + }) + .catch((error) => console.error("SharedDocsLib", error)); +})(); diff --git a/examples/assets/inject.js b/examples/assets/inject.js new file mode 100644 index 0000000..7e011ff --- /dev/null +++ b/examples/assets/inject.js @@ -0,0 +1,3 @@ +document.addEventListener("docslib:rendered", (event) => { + document.documentElement.dataset.exampleRevision = event.detail.manifest.revision; +}, { once: true }); diff --git a/examples/basic.py b/examples/basic.py index 4456c26..6039abd 100644 --- a/examples/basic.py +++ b/examples/basic.py @@ -60,6 +60,7 @@ if __name__ == "__main__": title="SharedDocsLib Example", assets_dir="./examples/assets", custom_css="./examples/assets/inject.css", + custom_js="./examples/assets/inject.js", live_preview=True, header_buttons=[ HeaderButton("Welcome", welcome), diff --git a/pyproject.toml b/pyproject.toml index ce2b372..ca68f66 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "shared-docs-lib" -version = "0.2.2" +version = "0.3.0" description = "Build self-contained, Obsidian-like documentation sites from Python functions." readme = "README.md" requires-python = ">=3.10" diff --git a/tests/test_server.py b/tests/test_server.py index 85f68f4..f865157 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -20,6 +20,8 @@ def test_all_endpoints_and_self_contained_build(tmp_path): (assets / "hero.png").write_bytes(image_bytes) custom_css = tmp_path / "inject.css" custom_css.write_text(".content { --custom-test: yes; }", encoding="utf-8") + custom_js = tmp_path / "inject.js" + custom_js.write_text('document.body.dataset.customTest = "yes";', encoding="utf-8") @Page("1.1", "Child") def child(): @@ -33,6 +35,7 @@ def test_all_endpoints_and_self_contained_build(tmp_path): title="Test docs", assets_dir=assets, custom_css=custom_css, + custom_js=custom_js, manifest_path="http://127.0.0.1:9000/", header_buttons=[ HeaderButton("Home", home), @@ -48,6 +51,11 @@ def test_all_endpoints_and_self_contained_build(tmp_path): manifest = manifest_response.json() assert [page["number"] for page in manifest["pages"]] == ["1", "1.1"] assert manifest["assets"]["hero.png"]["data"] == base64.b64encode(image_bytes).decode() + assert manifest["schema_version"] == 2 + assert "html, body { width: 100%; height: 100dvh" in manifest["client"]["css"]["system"] + assert manifest["client"]["css"]["custom"] == ".content { --custom-test: yes; }" + assert "placeholderTick" in manifest["client"]["js"]["system"] + assert manifest["client"]["js"]["custom"] == 'document.body.dataset.customTest = "yes";' assert len(manifest["revision"]) == 20 assert manifest["ui"]["header_buttons"] == [ {"label": "Home", "page": "1"}, @@ -62,6 +70,12 @@ def test_all_endpoints_and_self_contained_build(tmp_path): assert html_response.status_code == 200 assert "http://127.0.0.1:9000/manifest.json" in html assert ".content { --custom-test: yes; }" in html + assert r'document.body.dataset.customTest = \"yes\";' in html + assert 'id="docslib-loading"' in html + assert "position: fixed; inset: 0; z-index: 2147483647; width: 100vw; height: 100dvh; background: #808080" in html + assert 'id="docslib-loader-style"' in html + assert "docslib:rendered" in html + assert "docslib-runtime-style" in html assert "indexedDB.open" in html assert "link.dataset.page = button.page" in html assert 'class="brand-mark"' not in html @@ -96,7 +110,7 @@ def test_all_endpoints_and_self_contained_build(tmp_path): assert 'id="mobile-menu-view"' in html assert 'id="mobile-search-view"' in html assert "placeholderTick" in html - assert '".".repeat(dots)' in html + assert '".".repeat(dots)' in manifest["client"]["js"]["system"] assert "setTimeout(placeholderTick, 5000)" in html assert '.sidebar-header-buttons .header-link:not(.active):first-child' not in html assert "border: 1px solid transparent" not in html @@ -128,7 +142,7 @@ def test_all_endpoints_and_self_contained_build(tmp_path): def test_build_writes_static_bundle(tmp_path): - assert __version__ == "0.2.2" + assert __version__ == "0.3.0" @Page("1", "Static page") def static_page(): @@ -178,3 +192,26 @@ def test_live_preview_reloads_a_changed_main_source(tmp_path): assert live["error"] is None assert live["revision"] != initial["revision"] assert "two" in client.get("/manifest.json").json()["pages"][0]["content"] + + +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") + + @Page("1", "Runtime") + def runtime_page(): + return "

runtime

" + + client = TestClient(create_app(custom_js=custom_js, live_preview=True)) + initial = client.get("/manifest.json").json() + assert initial["client"]["js"]["custom"] == "window.customVersion = 1;" + + custom_js.write_text("window.customVersion = 2;", encoding="utf-8") + stat = custom_js.stat() + os.utime(custom_js, ns=(stat.st_atime_ns, stat.st_mtime_ns + 1_000_000_000)) + + live = client.get("/live").json() + updated = client.get("/manifest.json").json() + assert live["changed"] is True + assert live["revision"] != initial["revision"] + assert updated["client"]["js"]["custom"] == "window.customVersion = 2;" diff --git a/uv.lock b/uv.lock index 03498a3..78dfd43 100644 --- a/uv.lock +++ b/uv.lock @@ -452,7 +452,7 @@ wheels = [ [[package]] name = "shared-docs-lib" -version = "0.2.2" +version = "0.3.0" source = { editable = "." } dependencies = [ { name = "fastapi" },