diff --git a/README.md b/README.md index 43eb124..381a0fd 100644 --- a/README.md +++ b/README.md @@ -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`: diff --git a/docslib/__init__.py b/docslib/__init__.py index f423191..d91e53a 100644 --- a/docslib/__init__.py +++ b/docslib/__init__.py @@ -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" diff --git a/docslib/templates/client.js b/docslib/templates/client.js index f3d141a..69facd1 100644 --- a/docslib/templates/client.js +++ b/docslib/templates/client.js @@ -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"); diff --git a/docslib/templates/index.html b/docslib/templates/index.html index 5677f25..777d62e 100644 --- a/docslib/templates/index.html +++ b/docslib/templates/index.html @@ -5,13 +5,21 @@ {{DOCSLIB_TITLE}} + - +

{{DOCSLIB_TITLE}}

diff --git a/docslib/templates/loader.js b/docslib/templates/loader.js index 8760f6b..55ba7b7 100644 --- a/docslib/templates/loader.js +++ b/docslib/templates/loader.js @@ -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) { diff --git a/pyproject.toml b/pyproject.toml index 30b6233..9bd94ec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/tests/test_server.py b/tests/test_server.py index b959f3e..c78ea5b 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -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(): diff --git a/uv.lock b/uv.lock index 52c8563..acc49dd 100644 --- a/uv.lock +++ b/uv.lock @@ -452,7 +452,7 @@ wheels = [ [[package]] name = "shared-docs-lib" -version = "0.3.1" +version = "0.3.2" source = { editable = "." } dependencies = [ { name = "fastapi" },