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-тема включена по умолчанию и сохраняется локально после переключения.
|
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`:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
2
uv.lock
generated
|
|
@ -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" },
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue