From 214f13484386fb06ba33746dd849a0360b7a195d Mon Sep 17 00:00:00 2001 From: Server <10.9.8.250@reg.snw.su> Date: Mon, 10 Aug 2026 22:50:43 +0000 Subject: [PATCH] Check lightweight version before manifest --- README.md | 10 ++++++---- docslib/__init__.py | 2 +- docslib/compiler.py | 6 ++++++ docslib/server.py | 18 +++++++++++++++++- docslib/templates/client.js | 34 +++++++++++++++++++++++++++++++++- pyproject.toml | 2 +- tests/test_server.py | 27 +++++++++++++++++++++++++-- uv.lock | 2 +- 8 files changed, 90 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index d83a72c..d5cb591 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ SharedDocsLib — небольшой Python-фреймворк для докум - поиск по всему контенту, светлая/тёмная тема, breadcrumbs, Previous/Next; - изображения из `assets_dir`, единожды закодированные в base64 внутри manifest; - полностью автономный `index.html`: начальный manifest вместе с CSS и JavaScript встроен; -- хранение manifest в IndexedDB и одна проверка обновления при загрузке; +- хранение manifest в IndexedDB и лёгкая проверка обновления через файл `version`; - `index.zip`, содержащий только `index.html`; - live-preview с перекомпиляцией изменившегося Python-модуля. @@ -37,6 +37,7 @@ uv run python examples/basic.py После запуска доступны: - `GET http://127.0.0.1:1234/manifest.json` +- `GET http://127.0.0.1:1234/version` - `GET http://127.0.0.1:1234/index.html` - `GET http://127.0.0.1:1234/index.zip` - `GET http://127.0.0.1:1234/live` (только при `live_preview=True`) @@ -83,7 +84,7 @@ if __name__ == "__main__": ) ``` -`manifest_path` принимает полный путь к JSON либо базовый URL, к которому будет добавлен `manifest.json`. Для совместимости также принят вариант `manifest_parh` из первоначального API, но в новом коде лучше использовать правильное имя. +`manifest_path` принимает полный путь к JSON либо базовый URL, к которому будет добавлен `manifest.json`. URL файла `version` автоматически вычисляется рядом с manifest. Для совместимости также принят вариант `manifest_parh` из первоначального API, но в новом коде лучше использовать правильное имя. ## Статическая сборка @@ -106,10 +107,11 @@ build( ) ``` -В `build_directory` записываются ровно три файла: +В `build_directory` записываются ровно четыре файла: - `index.html` — полностью автономный клиент; - `manifest.json` — manifest для публикации отдельным файлом; +- `version` — короткий текстовый файл, содержащий `revision` manifest; - `index.zip` — архив, содержащий только `index.html`. Функция возвращает `BuildResult` с теми же данными в памяти. Серверные параметры `host`, `port`, `live_preview`, `cors_origins` и `log_level` ей не требуются. @@ -196,7 +198,7 @@ Manifest имеет версию схемы, заголовок, `revision`, `up При открытии минимальный HTML-loader показывает тематический fixed-overlay размером `100vw × 100dvh` с индикатором загрузки. Он немедленно читает manifest из IndexedDB; если записи ещё нет, сначала сохраняет встроенную копию. Затем loader применяет CSS, запускает системный JavaScript, рендерит документацию и удаляет overlay. -Только после первого рендера запускается фоновый запрос к `manifest_path`: пользователь уже может читать документацию и работать с интерфейсом. Найденное обновление сохраняется в IndexedDB без замены текущей страницы и применяется при следующем открытии. При недоступном сервере продолжает работать уже отрендеренная кешированная или встроенная версия. +Только после первого рендера клиент запрашивает маленький файл `version`: пользователь уже может читать документацию и работать с интерфейсом. Если его хеш совпадает с активной ревизией, `manifest.json` вообще не скачивается. При несовпадении клиент загружает manifest, сохраняет обновление в IndexedDB без замены текущей страницы и применяет его при следующем открытии. К обоим URL добавляется уникальный `?no_cache=…`, запрос выполняется с `cache: "no-store"`, а сервер отвечает заголовками `no-store`, `no-cache`, `Pragma` и `Expires`. При недоступном сервере продолжает работать уже отрендеренная кешированная или встроенная версия. `custom_css` и `custom_js` принимают пути к UTF-8-файлам. Пользовательский CSS добавляется после системного, поэтому может переопределять тему. Пользовательский JavaScript загружается после системного runtime и до события `docslib:rendered`: diff --git a/docslib/__init__.py b/docslib/__init__.py index 523f1d3..30eb553 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.3.5" +__version__ = "0.3.6" diff --git a/docslib/compiler.py b/docslib/compiler.py index 81d1b06..d4bef68 100644 --- a/docslib/compiler.py +++ b/docslib/compiler.py @@ -75,6 +75,11 @@ def _read_optional_file(path: str | Path | None, *, label: str) -> str: return file_path.read_text(encoding="utf-8") +def _sibling_resource_url(url: str, name: str) -> str: + parent, separator, _ = url.rpartition("/") + return f"{parent}/{name}" if separator else name + + class Compiler: def __init__( self, @@ -226,6 +231,7 @@ class Compiler: template = base_path.read_text(encoding="utf-8") config = { "manifestUrl": self.manifest_url, + "versionUrl": _sibling_resource_url(self.manifest_url, "version"), "livePreview": self.live_preview, "liveUrl": "./live", "cacheKey": hashlib.sha256(self.manifest_url.encode("utf-8")).hexdigest()[:16], diff --git a/docslib/server.py b/docslib/server.py index 59ebfb0..581859c 100644 --- a/docslib/server.py +++ b/docslib/server.py @@ -18,6 +18,13 @@ from .registry import registry from .ui import HeaderButton +NO_CACHE_HEADERS = { + "Cache-Control": "no-store, no-cache, must-revalidate, max-age=0", + "Pragma": "no-cache", + "Expires": "0", +} + + def _manifest_url(manifest_path: str | None, manifest_parh: str | None) -> str: if manifest_path and manifest_parh: raise ValueError("Use either manifest_path or the legacy typo manifest_parh, not both") @@ -156,7 +163,15 @@ def create_app( @app.get("/manifest.json", response_class=JSONResponse) def manifest() -> JSONResponse: - return JSONResponse(site.result.manifest, headers={"Cache-Control": "no-store"}) + return JSONResponse(site.result.manifest, headers=NO_CACHE_HEADERS) + + @app.get("/version") + def version() -> Response: + return Response( + str(site.result.manifest["revision"]), + media_type="text/plain", + headers=NO_CACHE_HEADERS, + ) @app.get("/index.zip") def index_zip() -> Response: @@ -219,6 +234,7 @@ def build( 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 diff --git a/docslib/templates/client.js b/docslib/templates/client.js index 65980f2..8654865 100644 --- a/docslib/templates/client.js +++ b/docslib/templates/client.js @@ -99,6 +99,27 @@ elements.refresh.classList.toggle("loading", value); } + function siblingResourceUrl(url, name) { + const target = new URL(url, document.baseURI); + target.pathname = target.pathname.replace(/[^/]*$/, name); + target.search = ""; + target.hash = ""; + return target.href; + } + + function noCacheUrl(url) { + const target = new URL(url, document.baseURI); + target.searchParams.set( + "no_cache", + `${Date.now()}_${Math.random().toString(36).slice(2)}`, + ); + return target.href; + } + + function fetchNoCache(url) { + return fetch(noCacheUrl(url), { cache: "no-store" }); + } + function routeNumber() { const match = location.hash.match(/^#\/page\/(.+)$/); return match ? decodeURIComponent(match[1]) : null; @@ -478,10 +499,21 @@ await announceManifestUpdate(embedded, "embedded"); changed = true; } - const response = await fetch(config.manifestUrl, { cache: "no-store" }); + const versionUrl = config.versionUrl || siblingResourceUrl(config.manifestUrl, "version"); + const versionResponse = await fetchNoCache(versionUrl); + if (!versionResponse.ok) throw new Error(`version:${versionResponse.status}`); + const remoteRevision = (await versionResponse.text()).trim(); + if (!remoteRevision) throw new Error("version:empty"); + if (remoteRevision === lastSyncedRevision) { + elements.refresh.classList.remove("error"); + updateIdentity(); + return changed; + } + const response = await fetchNoCache(config.manifestUrl); 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"); + if (String(remote.revision || "") !== remoteRevision) throw new Error("version:mismatch"); const remoteChanged = remote.revision !== lastSyncedRevision; if (remoteChanged) { await announceManifestUpdate(remote, "remote"); diff --git a/pyproject.toml b/pyproject.toml index 14f3e5a..92f5cfc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "shared-docs-lib" -version = "0.3.5" +version = "0.3.6" 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 ad639cc..308706e 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -78,12 +78,24 @@ def test_all_endpoints_and_self_contained_build(tmp_path): {"label": "Source", "href": "https://example.com", "target": "_blank"}, {"label": "Download", "href": "/index.zip"}, ] - assert manifest_response.headers["cache-control"] == "no-store" + assert "no-store" in manifest_response.headers["cache-control"] + assert "no-cache" in manifest_response.headers["cache-control"] + assert manifest_response.headers["pragma"] == "no-cache" + assert manifest_response.headers["expires"] == "0" + + version_response = client.get("/version?no_cache=test") + assert version_response.status_code == 200 + assert version_response.text == manifest["revision"] + assert "no-store" in version_response.headers["cache-control"] + assert "no-cache" in version_response.headers["cache-control"] + assert version_response.headers["pragma"] == "no-cache" + assert version_response.headers["expires"] == "0" html_response = client.get("/index.html") html = html_response.text assert html_response.status_code == 200 assert "http://127.0.0.1:9000/manifest.json" in html + assert "http://127.0.0.1:9000/version" in html assert ".content { --custom-test: yes; }" in html assert r'document.body.dataset.customTest = \"yes\";' in html assert 'id="docslib-loading"' in html @@ -101,6 +113,13 @@ def test_all_endpoints_and_self_contained_build(tmp_path): assert "previousRevision" in system_js assert "cached," in system_js assert "applied: false" in system_js + assert 'config.versionUrl || siblingResourceUrl(config.manifestUrl, "version")' in system_js + assert 'target.searchParams.set(' in system_js + assert '"no_cache"' in system_js + assert 'fetch(noCacheUrl(url), { cache: "no-store" })' in system_js + assert system_js.index("await fetchNoCache(versionUrl)") < system_js.index( + "await fetchNoCache(config.manifestUrl)" + ) assert "docslib-runtime-style" in html assert "indexedDB.open" in html assert "link.dataset.page = button.page" in html @@ -174,7 +193,7 @@ def test_all_endpoints_and_self_contained_build(tmp_path): def test_build_writes_static_bundle(tmp_path): - assert __version__ == "0.3.5" + assert __version__ == "0.3.6" @Page("1", "Static page") def static_page(): @@ -192,10 +211,13 @@ def test_build_writes_static_bundle(tmp_path): "index.html", "index.zip", "manifest.json", + "version", ] assert output.joinpath("index.html").read_bytes() == result.html assert json.loads(output.joinpath("manifest.json").read_text(encoding="utf-8")) == result.manifest + assert output.joinpath("version").read_text(encoding="utf-8") == result.manifest["revision"] assert "https://docs.example/manifest.json" in result.html.decode("utf-8") + assert "https://docs.example/version" in result.html.decode("utf-8") with zipfile.ZipFile(output / "index.zip") as archive: assert archive.namelist() == ["index.html"] assert archive.read("index.html") == result.html @@ -223,6 +245,7 @@ def test_live_preview_reloads_a_changed_main_source(tmp_path): assert live["changed"] is True assert live["error"] is None assert live["revision"] != initial["revision"] + assert client.get("/version").text == live["revision"] assert "two" in client.get("/manifest.json").json()["pages"][0]["content"] diff --git a/uv.lock b/uv.lock index 39688fb..bd48562 100644 --- a/uv.lock +++ b/uv.lock @@ -452,7 +452,7 @@ wheels = [ [[package]] name = "shared-docs-lib" -version = "0.3.5" +version = "0.3.6" source = { editable = "." } dependencies = [ { name = "fastapi" },