Check lightweight version before manifest

This commit is contained in:
Server 2026-08-10 22:50:43 +00:00
parent 1a2d817cf9
commit 214f134843
8 changed files with 90 additions and 11 deletions

View file

@ -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`:

View file

@ -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"

View file

@ -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],

View file

@ -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

View file

@ -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");

View file

@ -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"

View file

@ -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"]

2
uv.lock generated
View file

@ -452,7 +452,7 @@ wheels = [
[[package]]
name = "shared-docs-lib"
version = "0.3.5"
version = "0.3.6"
source = { editable = "." }
dependencies = [
{ name = "fastapi" },