Render cached manifest before background sync

This commit is contained in:
Server 2026-08-10 18:09:29 +00:00
parent 95a450597a
commit 9ca8d8312a
8 changed files with 65 additions and 40 deletions

View file

@ -15,7 +15,7 @@ SharedDocsLib — небольшой Python-фреймворк для докум
UI не содержит языко-зависимых служебных подписей. Базовый шрифт — `system-ui 16px`; служебные UTF-8-пиктограммы используют Arial, а навигационные стрелки остаются в `system-ui`. AMOLED-тема включена по умолчанию и сохраняется локально после переключения.
На десктопе компактный header содержит полное название документации и search/theme/refresh, а слева постоянно видны header-кнопки и дерево страниц. Поисковый placeholder медленно печатает первые слова случайного названия, добавляет троеточие только в конце и сохраняет готовый текст 5 секунд; при вводе поле раскрывает ранжированные результаты вниз. Кнопка `⟳` вращается во время проверки manifest; при наведении она заменяет поиск и тему, показывая ISO-дату и короткий `revision`, а при нажатии синхронизирует manifest и перезагружает страницу.
На десктопе компактный header содержит полное название документации и search/theme/refresh, а слева постоянно видны header-кнопки и дерево страниц. Поисковый placeholder медленно печатает первые слова случайного названия, добавляет троеточие только в конце и сохраняет готовый текст 5 секунд; при вводе поле раскрывает ранжированные результаты вниз. Кнопка `⟳` вращается во время проверки manifest; при наведении она заменяет поиск и тему, показывая ISO-дату и короткий `revision`, а при нажатии запускает повторную фоновую проверку обновления.
На мобильных устройствах используется viewport `100dvh` и нижний dock. Кнопка `☰` заменяет содержимое страницы копией desktop-sidebar. Ввод в нижний поиск заменяет содержимое результатами, расположенными снизу вверх по релевантности. Тема и обновление на мобильном скрыты.
@ -194,7 +194,9 @@ Manifest имеет версию схемы, заголовок, `revision`, `up
}
```
При открытии минимальный HTML-loader показывает серый fixed-overlay размером `100vw × 100dvh`, проверяет IndexedDB и один раз запрашивает `manifest_path`. Затем он применяет CSS, запускает системный JavaScript и удаляет overlay только после события первого успешного рендера. При недоступном сервере используется кеш или встроенная копия manifest.
При открытии минимальный HTML-loader показывает тематический fixed-overlay размером `100vw × 100dvh` с индикатором загрузки. Он немедленно читает manifest из IndexedDB; если записи ещё нет, сначала сохраняет встроенную копию. Затем loader применяет CSS, запускает системный JavaScript, рендерит документацию и удаляет overlay.
Только после первого рендера запускается фоновый запрос к `manifest_path`: пользователь уже может читать документацию и работать с интерфейсом. Найденное обновление сохраняется в IndexedDB без замены текущей страницы и применяется при следующем открытии. При недоступном сервере продолжает работать уже отрендеренная кешированная или встроенная версия.
`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.1"
__version__ = "0.3.2"

View file

@ -43,6 +43,7 @@
let placeholderPhase = "typing";
let lastPlaceholderPage = -1;
let customScriptLoaded = false;
let lastSyncedRevision = String(manifest.revision || "");
const dbPromise = new Promise((resolve, reject) => {
if (!window.indexedDB) return reject(new Error("IndexedDB"));
@ -446,16 +447,24 @@
setSyncing(true);
let changed = false;
try {
if (
isManifest(embedded) &&
embedded.schema_version === manifest.schema_version &&
String(embedded.updated_at || "") > String(manifest.updated_at || "")
) {
await cachePut(embedded).catch(() => {});
lastSyncedRevision = String(embedded.revision || lastSyncedRevision);
changed = embedded.revision !== manifest.revision;
}
const response = await fetch(config.manifestUrl, { cache: "no-store" });
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");
changed = remote.revision !== manifest.revision || remote.updated_at !== manifest.updated_at;
if (changed) {
const remoteChanged = remote.revision !== lastSyncedRevision;
if (remoteChanged) {
await cachePut(remote).catch(() => {});
manifest = remote;
location.reload();
return true;
lastSyncedRevision = String(remote.revision || lastSyncedRevision);
changed = true;
}
elements.refresh.classList.remove("error");
updateIdentity();
@ -474,7 +483,10 @@
const response = await fetch(config.liveUrl, { cache: "no-store" });
if (!response.ok) return;
const live = await response.json();
if (live.revision && live.revision !== manifest.revision) await syncManifest();
if (live.revision && live.revision !== lastSyncedRevision) {
const changed = await syncManifest();
if (changed) location.reload();
}
} catch (_) { /* local preview may stop */ }
}
@ -482,6 +494,7 @@
applyTheme(localStorage.getItem("docslib-theme") || "dark");
renderAll();
announceRendered();
syncManifest();
if (config.livePreview) setInterval(pollLive, 1200);
}
@ -503,8 +516,7 @@
elements.refresh.addEventListener("blur", () => elements.toolbar.classList.remove("refresh-expanded"));
elements.refresh.addEventListener("click", async () => {
if (syncing) return;
const changed = await syncManifest();
if (!changed) location.reload();
await syncManifest();
});
document.addEventListener("pointerdown", (event) => {
if (!elements.desktopSearchShell.contains(event.target)) elements.desktopSearchShell.classList.remove("open");

View file

@ -5,13 +5,21 @@
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<meta name="color-scheme" content="dark light">
<title>{{DOCSLIB_TITLE}}</title>
<script>
try { document.documentElement.dataset.theme = localStorage.getItem("docslib-theme") || "dark"; }
catch (_) { document.documentElement.dataset.theme = "dark"; }
</script>
<style id="docslib-loader-style">
html, body { width: 100%; height: 100dvh; min-height: 100dvh; margin: 0; overflow: hidden; background: #808080; }
#docslib-loading { position: fixed; inset: 0; z-index: 2147483647; width: 100vw; height: 100dvh; background: #808080; }
:root { --docslib-loader-bg: #000000; --docslib-loader-text: #f4f4f4; color-scheme: dark; }
:root[data-theme="light"] { --docslib-loader-bg: #ffffff; --docslib-loader-text: #111111; color-scheme: light; }
html, body { width: 100%; height: 100dvh; min-height: 100dvh; margin: 0; overflow: hidden; background: var(--docslib-loader-bg); }
#docslib-loading { position: fixed; inset: 0; z-index: 2147483647; display: grid; width: 100vw; height: 100dvh; place-items: center; color: var(--docslib-loader-text); background: var(--docslib-loader-bg); }
.docslib-loading-spinner { width: 48px; height: 48px; box-sizing: border-box; border: solid 4px currentColor; border-top: solid 4px transparent; border-radius: 9999px; animation: docslib-loader-spin .8s linear infinite; }
@keyframes docslib-loader-spin { to { transform: rotate(360deg); } }
</style>
</head>
<body>
<div id="docslib-loading" aria-hidden="true"></div>
<div id="docslib-loading" aria-hidden="true"><div class="docslib-loading-spinner"></div></div>
<div id="app" class="app-shell">
<header class="desktop-header">
<h2 id="desktop-site-title" class="desktop-title">{{DOCSLIB_TITLE}}</h2>

View file

@ -51,31 +51,14 @@
}
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");
if (isManifest(cached) && cached.schema_version === embedded.schema_version) {
return cached;
}
selected = remote;
await cachePut(remote).catch(() => {});
} catch (_) { /* cached or embedded manifest remains available */ }
finally { clearTimeout(timeout); }
return selected;
await cachePut(embedded);
} catch (_) { /* IndexedDB is unavailable; use the embedded manifest */ }
return embedded;
}
function applyRuntime(manifest) {

View file

@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "shared-docs-lib"
version = "0.3.1"
version = "0.3.2"
description = "Build self-contained, Obsidian-like documentation sites from Python functions."
readme = "README.md"
requires-python = ">=3.10"

View file

@ -4,15 +4,30 @@ import json
import os
import runpy
import zipfile
from pathlib import Path
from fastapi.testclient import TestClient
import docslib
from docslib import HeaderButton, __version__, build
from docslib.components import H1, Image, P
from docslib.hooks import Page
from docslib.server import create_app
def test_loader_renders_indexeddb_before_background_sync():
templates = Path(docslib.__file__).with_name("templates")
loader = (templates / "loader.js").read_text(encoding="utf-8")
client = (templates / "client.js").read_text(encoding="utf-8")
assert "const cached = await cacheGet();" in loader
assert "return cached;" in loader
assert "await cachePut(embedded);" in loader
assert "fetch(" not in loader
assert "renderAll();\n announceRendered();\n syncManifest();" in client
assert "manifest = remote" not in client
def test_all_endpoints_and_self_contained_build(tmp_path):
assets = tmp_path / "assets"
assets.mkdir()
@ -72,7 +87,12 @@ def test_all_endpoints_and_self_contained_build(tmp_path):
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 "--docslib-loader-bg: #000000; --docslib-loader-text: #f4f4f4" in html
assert "--docslib-loader-bg: #ffffff; --docslib-loader-text: #111111" in html
assert "position: fixed; inset: 0; z-index: 2147483647; display: grid; width: 100vw; height: 100dvh" in html
assert "border: solid 4px currentColor; border-top: solid 4px transparent; border-radius: 9999px" in html
assert "animation: docslib-loader-spin .8s linear infinite" in html
assert "return cached" in html
assert 'id="docslib-loader-style"' in html
assert "docslib:rendered" in html
assert "docslib-runtime-style" in html
@ -144,7 +164,7 @@ def test_all_endpoints_and_self_contained_build(tmp_path):
def test_build_writes_static_bundle(tmp_path):
assert __version__ == "0.3.1"
assert __version__ == "0.3.2"
@Page("1", "Static page")
def static_page():

2
uv.lock generated
View file

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