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;
|
- поиск по всему контенту, светлая/тёмная тема, breadcrumbs, Previous/Next;
|
||||||
- изображения из `assets_dir`, единожды закодированные в base64 внутри manifest;
|
- изображения из `assets_dir`, единожды закодированные в base64 внутри manifest;
|
||||||
- полностью автономный `index.html`: начальный manifest вместе с CSS и JavaScript встроен;
|
- полностью автономный `index.html`: начальный manifest вместе с CSS и JavaScript встроен;
|
||||||
- хранение manifest в IndexedDB и одна проверка обновления при загрузке;
|
- хранение manifest в IndexedDB и лёгкая проверка обновления через файл `version`;
|
||||||
- `index.zip`, содержащий только `index.html`;
|
- `index.zip`, содержащий только `index.html`;
|
||||||
- live-preview с перекомпиляцией изменившегося Python-модуля.
|
- 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/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.html`
|
||||||
- `GET http://127.0.0.1:1234/index.zip`
|
- `GET http://127.0.0.1:1234/index.zip`
|
||||||
- `GET http://127.0.0.1:1234/live` (только при `live_preview=True`)
|
- `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` — полностью автономный клиент;
|
- `index.html` — полностью автономный клиент;
|
||||||
- `manifest.json` — manifest для публикации отдельным файлом;
|
- `manifest.json` — manifest для публикации отдельным файлом;
|
||||||
|
- `version` — короткий текстовый файл, содержащий `revision` manifest;
|
||||||
- `index.zip` — архив, содержащий только `index.html`.
|
- `index.zip` — архив, содержащий только `index.html`.
|
||||||
|
|
||||||
Функция возвращает `BuildResult` с теми же данными в памяти. Серверные параметры `host`, `port`, `live_preview`, `cors_origins` и `log_level` ей не требуются.
|
Функция возвращает `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.
|
При открытии минимальный 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`:
|
`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
|
from .ui import HeaderButton
|
||||||
|
|
||||||
__all__ = ["Component", "HeaderButton", "Page", "build", "create_app", "run"]
|
__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")
|
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:
|
class Compiler:
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
|
@ -226,6 +231,7 @@ class Compiler:
|
||||||
template = base_path.read_text(encoding="utf-8")
|
template = base_path.read_text(encoding="utf-8")
|
||||||
config = {
|
config = {
|
||||||
"manifestUrl": self.manifest_url,
|
"manifestUrl": self.manifest_url,
|
||||||
|
"versionUrl": _sibling_resource_url(self.manifest_url, "version"),
|
||||||
"livePreview": self.live_preview,
|
"livePreview": self.live_preview,
|
||||||
"liveUrl": "./live",
|
"liveUrl": "./live",
|
||||||
"cacheKey": hashlib.sha256(self.manifest_url.encode("utf-8")).hexdigest()[:16],
|
"cacheKey": hashlib.sha256(self.manifest_url.encode("utf-8")).hexdigest()[:16],
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,13 @@ from .registry import registry
|
||||||
from .ui import HeaderButton
|
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:
|
def _manifest_url(manifest_path: str | None, manifest_parh: str | None) -> str:
|
||||||
if manifest_path and manifest_parh:
|
if manifest_path and manifest_parh:
|
||||||
raise ValueError("Use either manifest_path or the legacy typo manifest_parh, not both")
|
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)
|
@app.get("/manifest.json", response_class=JSONResponse)
|
||||||
def manifest() -> 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")
|
@app.get("/index.zip")
|
||||||
def index_zip() -> Response:
|
def index_zip() -> Response:
|
||||||
|
|
@ -219,6 +234,7 @@ def build(
|
||||||
json.dumps(result.manifest, ensure_ascii=False, separators=(",", ":")),
|
json.dumps(result.manifest, ensure_ascii=False, separators=(",", ":")),
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
|
(output / "version").write_text(str(result.manifest["revision"]), encoding="utf-8")
|
||||||
(output / "index.zip").write_bytes(result.zip_archive)
|
(output / "index.zip").write_bytes(result.zip_archive)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,27 @@
|
||||||
elements.refresh.classList.toggle("loading", value);
|
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() {
|
function routeNumber() {
|
||||||
const match = location.hash.match(/^#\/page\/(.+)$/);
|
const match = location.hash.match(/^#\/page\/(.+)$/);
|
||||||
return match ? decodeURIComponent(match[1]) : null;
|
return match ? decodeURIComponent(match[1]) : null;
|
||||||
|
|
@ -478,10 +499,21 @@
|
||||||
await announceManifestUpdate(embedded, "embedded");
|
await announceManifestUpdate(embedded, "embedded");
|
||||||
changed = true;
|
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));
|
if (!response.ok) throw new Error(String(response.status));
|
||||||
const remote = await response.json();
|
const remote = await response.json();
|
||||||
if (!isManifest(remote) || remote.schema_version !== embedded.schema_version) throw new Error("manifest");
|
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;
|
const remoteChanged = remote.revision !== lastSyncedRevision;
|
||||||
if (remoteChanged) {
|
if (remoteChanged) {
|
||||||
await announceManifestUpdate(remote, "remote");
|
await announceManifestUpdate(remote, "remote");
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "shared-docs-lib"
|
name = "shared-docs-lib"
|
||||||
version = "0.3.5"
|
version = "0.3.6"
|
||||||
description = "Build self-contained, Obsidian-like documentation sites from Python functions."
|
description = "Build self-contained, Obsidian-like documentation sites from Python functions."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10"
|
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": "Source", "href": "https://example.com", "target": "_blank"},
|
||||||
{"label": "Download", "href": "/index.zip"},
|
{"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_response = client.get("/index.html")
|
||||||
html = html_response.text
|
html = html_response.text
|
||||||
assert html_response.status_code == 200
|
assert html_response.status_code == 200
|
||||||
assert "http://127.0.0.1:9000/manifest.json" in html
|
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 ".content { --custom-test: yes; }" in html
|
||||||
assert r'document.body.dataset.customTest = \"yes\";' in html
|
assert r'document.body.dataset.customTest = \"yes\";' in html
|
||||||
assert 'id="docslib-loading"' 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 "previousRevision" in system_js
|
||||||
assert "cached," in system_js
|
assert "cached," in system_js
|
||||||
assert "applied: false" 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 "docslib-runtime-style" in html
|
||||||
assert "indexedDB.open" in html
|
assert "indexedDB.open" in html
|
||||||
assert "link.dataset.page = button.page" 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):
|
def test_build_writes_static_bundle(tmp_path):
|
||||||
assert __version__ == "0.3.5"
|
assert __version__ == "0.3.6"
|
||||||
|
|
||||||
@Page("1", "Static page")
|
@Page("1", "Static page")
|
||||||
def static_page():
|
def static_page():
|
||||||
|
|
@ -192,10 +211,13 @@ def test_build_writes_static_bundle(tmp_path):
|
||||||
"index.html",
|
"index.html",
|
||||||
"index.zip",
|
"index.zip",
|
||||||
"manifest.json",
|
"manifest.json",
|
||||||
|
"version",
|
||||||
]
|
]
|
||||||
assert output.joinpath("index.html").read_bytes() == result.html
|
assert output.joinpath("index.html").read_bytes() == result.html
|
||||||
assert json.loads(output.joinpath("manifest.json").read_text(encoding="utf-8")) == result.manifest
|
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/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:
|
with zipfile.ZipFile(output / "index.zip") as archive:
|
||||||
assert archive.namelist() == ["index.html"]
|
assert archive.namelist() == ["index.html"]
|
||||||
assert archive.read("index.html") == result.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["changed"] is True
|
||||||
assert live["error"] is None
|
assert live["error"] is None
|
||||||
assert live["revision"] != initial["revision"]
|
assert live["revision"] != initial["revision"]
|
||||||
|
assert client.get("/version").text == live["revision"]
|
||||||
assert "two" in client.get("/manifest.json").json()["pages"][0]["content"]
|
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]]
|
[[package]]
|
||||||
name = "shared-docs-lib"
|
name = "shared-docs-lib"
|
||||||
version = "0.3.5"
|
version = "0.3.6"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "fastapi" },
|
{ name = "fastapi" },
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue