Render cached manifest before background sync
This commit is contained in:
parent
95a450597a
commit
9ca8d8312a
8 changed files with 65 additions and 40 deletions
|
|
@ -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`:
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
2
uv.lock
generated
|
|
@ -452,7 +452,7 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "shared-docs-lib"
|
||||
version = "0.3.1"
|
||||
version = "0.3.2"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "fastapi" },
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue