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-тема включена по умолчанию и сохраняется локально после переключения. 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. Ввод в нижний поиск заменяет содержимое результатами, расположенными снизу вверх по релевантности. Тема и обновление на мобильном скрыты. На мобильных устройствах используется 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`: `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 from .ui import HeaderButton
__all__ = ["Component", "HeaderButton", "Page", "build", "create_app", "run"] __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 placeholderPhase = "typing";
let lastPlaceholderPage = -1; let lastPlaceholderPage = -1;
let customScriptLoaded = false; let customScriptLoaded = false;
let lastSyncedRevision = String(manifest.revision || "");
const dbPromise = new Promise((resolve, reject) => { const dbPromise = new Promise((resolve, reject) => {
if (!window.indexedDB) return reject(new Error("IndexedDB")); if (!window.indexedDB) return reject(new Error("IndexedDB"));
@ -446,16 +447,24 @@
setSyncing(true); setSyncing(true);
let changed = false; let changed = false;
try { 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" }); const response = await fetch(config.manifestUrl, { cache: "no-store" });
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");
changed = remote.revision !== manifest.revision || remote.updated_at !== manifest.updated_at; const remoteChanged = remote.revision !== lastSyncedRevision;
if (changed) { if (remoteChanged) {
await cachePut(remote).catch(() => {}); await cachePut(remote).catch(() => {});
manifest = remote; lastSyncedRevision = String(remote.revision || lastSyncedRevision);
location.reload(); changed = true;
return true;
} }
elements.refresh.classList.remove("error"); elements.refresh.classList.remove("error");
updateIdentity(); updateIdentity();
@ -474,7 +483,10 @@
const response = await fetch(config.liveUrl, { cache: "no-store" }); const response = await fetch(config.liveUrl, { cache: "no-store" });
if (!response.ok) return; if (!response.ok) return;
const live = await response.json(); 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 */ } } catch (_) { /* local preview may stop */ }
} }
@ -482,6 +494,7 @@
applyTheme(localStorage.getItem("docslib-theme") || "dark"); applyTheme(localStorage.getItem("docslib-theme") || "dark");
renderAll(); renderAll();
announceRendered(); announceRendered();
syncManifest();
if (config.livePreview) setInterval(pollLive, 1200); if (config.livePreview) setInterval(pollLive, 1200);
} }
@ -503,8 +516,7 @@
elements.refresh.addEventListener("blur", () => elements.toolbar.classList.remove("refresh-expanded")); elements.refresh.addEventListener("blur", () => elements.toolbar.classList.remove("refresh-expanded"));
elements.refresh.addEventListener("click", async () => { elements.refresh.addEventListener("click", async () => {
if (syncing) return; if (syncing) return;
const changed = await syncManifest(); await syncManifest();
if (!changed) location.reload();
}); });
document.addEventListener("pointerdown", (event) => { document.addEventListener("pointerdown", (event) => {
if (!elements.desktopSearchShell.contains(event.target)) elements.desktopSearchShell.classList.remove("open"); 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="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<meta name="color-scheme" content="dark light"> <meta name="color-scheme" content="dark light">
<title>{{DOCSLIB_TITLE}}</title> <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"> <style id="docslib-loader-style">
html, body { width: 100%; height: 100dvh; min-height: 100dvh; margin: 0; overflow: hidden; background: #808080; } :root { --docslib-loader-bg: #000000; --docslib-loader-text: #f4f4f4; color-scheme: dark; }
#docslib-loading { position: fixed; inset: 0; z-index: 2147483647; width: 100vw; height: 100dvh; background: #808080; } :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> </style>
</head> </head>
<body> <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"> <div id="app" class="app-shell">
<header class="desktop-header"> <header class="desktop-header">
<h2 id="desktop-site-title" class="desktop-title">{{DOCSLIB_TITLE}}</h2> <h2 id="desktop-site-title" class="desktop-title">{{DOCSLIB_TITLE}}</h2>

View file

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

View file

@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "shared-docs-lib" name = "shared-docs-lib"
version = "0.3.1" version = "0.3.2"
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"

View file

@ -4,15 +4,30 @@ import json
import os import os
import runpy import runpy
import zipfile import zipfile
from pathlib import Path
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
import docslib
from docslib import HeaderButton, __version__, build from docslib import HeaderButton, __version__, build
from docslib.components import H1, Image, P from docslib.components import H1, Image, P
from docslib.hooks import Page from docslib.hooks import Page
from docslib.server import create_app 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): def test_all_endpoints_and_self_contained_build(tmp_path):
assets = tmp_path / "assets" assets = tmp_path / "assets"
assets.mkdir() assets.mkdir()
@ -72,7 +87,12 @@ def test_all_endpoints_and_self_contained_build(tmp_path):
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
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 'id="docslib-loader-style"' in html
assert "docslib:rendered" in html assert "docslib:rendered" in html
assert "docslib-runtime-style" 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): def test_build_writes_static_bundle(tmp_path):
assert __version__ == "0.3.1" assert __version__ == "0.3.2"
@Page("1", "Static page") @Page("1", "Static page")
def static_page(): def static_page():

2
uv.lock generated
View file

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