Check lightweight version before manifest
This commit is contained in:
parent
1a2d817cf9
commit
214f134843
8 changed files with 90 additions and 11 deletions
10
README.md
10
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`:
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
2
uv.lock
generated
|
|
@ -452,7 +452,7 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "shared-docs-lib"
|
||||
version = "0.3.5"
|
||||
version = "0.3.6"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "fastapi" },
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue