Deliver client runtime through manifest
This commit is contained in:
parent
3de82a7ef0
commit
f1b10c2b12
12 changed files with 277 additions and 56 deletions
30
README.md
30
README.md
|
|
@ -8,7 +8,7 @@ SharedDocsLib — небольшой Python-фреймворк для докум
|
|||
- собственные `@Component` и набор безопасных базовых компонентов;
|
||||
- поиск по всему контенту, светлая/тёмная тема, breadcrumbs, Previous/Next;
|
||||
- изображения из `assets_dir`, единожды закодированные в base64 внутри manifest;
|
||||
- полностью автономный `index.html`: CSS, JavaScript и начальный manifest встроены;
|
||||
- полностью автономный `index.html`: начальный manifest вместе с CSS и JavaScript встроен;
|
||||
- хранение manifest в IndexedDB и одна проверка обновления при загрузке;
|
||||
- `index.zip`, содержащий только `index.html`;
|
||||
- live-preview с перекомпиляцией изменившегося Python-модуля.
|
||||
|
|
@ -72,6 +72,7 @@ if __name__ == "__main__":
|
|||
title="My docs",
|
||||
assets_dir="./assets",
|
||||
custom_css="./assets/inject.css",
|
||||
custom_js="./assets/inject.js",
|
||||
manifest_path="http://127.0.0.1:1234/",
|
||||
live_preview=True,
|
||||
header_buttons=[
|
||||
|
|
@ -96,6 +97,7 @@ build(
|
|||
title="My docs",
|
||||
assets_dir="./assets",
|
||||
custom_css="./assets/inject.css",
|
||||
custom_js="./assets/inject.js",
|
||||
manifest_path="./manifest.json",
|
||||
header_buttons=[
|
||||
HeaderButton("Знакомство", page1),
|
||||
|
|
@ -174,31 +176,45 @@ header_buttons=[
|
|||
|
||||
## Manifest
|
||||
|
||||
Manifest имеет версию схемы, заголовок, `revision`, `updated_at`, страницы, настройки UI и словарь ресурсов:
|
||||
Manifest имеет версию схемы, заголовок, `revision`, `updated_at`, страницы, настройки UI, словарь ресурсов и полный клиентский runtime. Системные файлы шаблона и пользовательские дополнения хранятся раздельно, а при загрузке объединяются в указанном порядке:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": 1,
|
||||
"schema_version": 2,
|
||||
"title": "My docs",
|
||||
"revision": "…",
|
||||
"updated_at": "…",
|
||||
"pages": [{"number": "1", "title": "Home", "content": "<h1>…</h1>"}],
|
||||
"assets": {"photo.png": {"mime": "image/png", "data": "base64…"}},
|
||||
"ui": {"header_buttons": []}
|
||||
"ui": {"header_buttons": []},
|
||||
"client": {
|
||||
"css": {"system": "…", "custom": "…"},
|
||||
"js": {"system": "…", "custom": "…"}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
При первом открытии встроенный manifest сохраняется в IndexedDB. Затем клиент один раз запрашивает `manifest_path`. Если `revision` или `updated_at` отличаются, новый manifest заменяет старый и страница перезагружается. При недоступном сервере продолжает работать кеш или встроенная копия.
|
||||
При открытии минимальный HTML-loader показывает серый fixed-overlay размером `100vw × 100dvh`, проверяет IndexedDB и один раз запрашивает `manifest_path`. Затем он применяет CSS, запускает системный JavaScript и удаляет overlay только после события первого успешного рендера. При недоступном сервере используется кеш или встроенная копия manifest.
|
||||
|
||||
`custom_css` и `custom_js` принимают пути к UTF-8-файлам. Пользовательский CSS добавляется после системного, поэтому может переопределять тему. Пользовательский JavaScript загружается после системного runtime и до события `docslib:rendered`:
|
||||
|
||||
```javascript
|
||||
document.addEventListener("docslib:rendered", (event) => {
|
||||
console.log(event.detail.manifest.revision);
|
||||
}, { once: true });
|
||||
```
|
||||
|
||||
Код из `custom_js` считается доверенным и выполняется в контексте страницы. Изменение системного или пользовательского CSS/JS меняет `revision`, поэтому обновлённый runtime сохраняется в IndexedDB и применяется после перезагрузки клиента.
|
||||
|
||||
## Live-preview
|
||||
|
||||
В live-режиме браузер опрашивает только `/live`. Сервер следит за файлами, в которых объявлены страницы, и при изменении повторно выполняет их, после чего клиент получает новый manifest. В запускаемом файле вызов `run()` обязательно помещать под `if __name__ == "__main__"`, чтобы watcher не запускал второй сервер.
|
||||
В live-режиме браузер опрашивает только `/live`. Сервер следит за файлами, в которых объявлены страницы, а также за системными и пользовательскими CSS/JS. При изменении он пересобирает manifest; клиент сохраняет его и перезагружается с новым runtime. В запускаемом файле вызов `run()` обязательно помещать под `if __name__ == "__main__"`, чтобы watcher не запускал второй сервер.
|
||||
|
||||
HTTP-загрузка и исполнение произвольных `.py` файлов намеренно отсутствуют: такая ручка была бы удалённым выполнением кода. Live-preview работает с локальными исходниками проекта.
|
||||
|
||||
## Свой клиент
|
||||
|
||||
Встроенные исходники находятся в `docslib/templates/`: `index.html`, `client.css`, `client.js`. Скопируйте папку, измените файлы и передайте `template_dir="./my-template"`. Компилятор проверяет обязательные placeholders и всё равно выдаёт один автономный HTML.
|
||||
Встроенные исходники находятся в `docslib/templates/`: `index.html`, `loader.js`, `client.css`, `client.js`. `loader.js` является небольшим bootstrap-кодом HTML, а `client.css` и `client.js` попадают в manifest. Скопируйте папку, измените файлы и передайте `template_dir="./my-template"`. Компилятор проверяет обязательные placeholders и всё равно выдаёт один автономный HTML.
|
||||
|
||||
Для тестов:
|
||||
|
||||
|
|
|
|||
|
|
@ -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.2.2"
|
||||
__version__ = "0.3.0"
|
||||
|
|
|
|||
|
|
@ -66,12 +66,12 @@ def _safe_json_for_script(value: object) -> str:
|
|||
)
|
||||
|
||||
|
||||
def _read_optional_file(path: str | Path | None) -> str:
|
||||
def _read_optional_file(path: str | Path | None, *, label: str) -> str:
|
||||
if path is None:
|
||||
return ""
|
||||
file_path = Path(path).expanduser().resolve()
|
||||
if not file_path.is_file():
|
||||
raise FileNotFoundError(f"CSS file does not exist: {file_path}")
|
||||
raise FileNotFoundError(f"{label} file does not exist: {file_path}")
|
||||
return file_path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
|
|
@ -82,6 +82,7 @@ class Compiler:
|
|||
title: str = "Documentation",
|
||||
assets_dir: str | Path = "./assets",
|
||||
custom_css: str | Path | None = None,
|
||||
custom_js: str | Path | None = None,
|
||||
template_dir: str | Path | None = None,
|
||||
manifest_url: str = "./manifest.json",
|
||||
live_preview: bool = False,
|
||||
|
|
@ -90,6 +91,7 @@ class Compiler:
|
|||
self.title = str(title)
|
||||
self.assets_dir = Path(assets_dir).expanduser().resolve()
|
||||
self.custom_css = custom_css
|
||||
self.custom_js = custom_js
|
||||
self.template_dir = (
|
||||
Path(template_dir).expanduser().resolve()
|
||||
if template_dir
|
||||
|
|
@ -103,11 +105,12 @@ class Compiler:
|
|||
pages = self._render_pages(registry.pages())
|
||||
assets = self._load_assets(pages)
|
||||
stable_content: dict[str, object] = {
|
||||
"schema_version": 1,
|
||||
"schema_version": 2,
|
||||
"title": self.title,
|
||||
"pages": pages,
|
||||
"assets": assets,
|
||||
"ui": {"header_buttons": self._compile_header_buttons()},
|
||||
"client": self._load_client(),
|
||||
}
|
||||
canonical = json.dumps(stable_content, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
revision = hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:20]
|
||||
|
|
@ -122,6 +125,31 @@ class Compiler:
|
|||
archive.writestr("index.html", html)
|
||||
return BuildResult(manifest=manifest, html=html, zip_archive=archive_buffer.getvalue())
|
||||
|
||||
def watched_files(self) -> tuple[Path, ...]:
|
||||
"""Return CSS and JavaScript sources that affect the manifest revision."""
|
||||
paths = [self.template_dir / "client.css", self.template_dir / "client.js"]
|
||||
for optional in (self.custom_css, self.custom_js):
|
||||
if optional is not None:
|
||||
paths.append(Path(optional).expanduser().resolve())
|
||||
return tuple(paths)
|
||||
|
||||
def _load_client(self) -> dict[str, dict[str, str]]:
|
||||
css_path = self.template_dir / "client.css"
|
||||
script_path = self.template_dir / "client.js"
|
||||
for path in (css_path, script_path):
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError(f"Template file does not exist: {path}")
|
||||
return {
|
||||
"css": {
|
||||
"system": css_path.read_text(encoding="utf-8"),
|
||||
"custom": _read_optional_file(self.custom_css, label="CSS"),
|
||||
},
|
||||
"js": {
|
||||
"system": script_path.read_text(encoding="utf-8"),
|
||||
"custom": _read_optional_file(self.custom_js, label="JavaScript"),
|
||||
},
|
||||
}
|
||||
|
||||
def _render_pages(self, definitions: list[PageDefinition]) -> list[dict[str, str]]:
|
||||
rendered: list[dict[str, str]] = []
|
||||
for page in sorted(definitions, key=page_sort_key):
|
||||
|
|
@ -191,9 +219,8 @@ class Compiler:
|
|||
|
||||
def _build_html(self, manifest: dict[str, object]) -> str:
|
||||
base_path = self.template_dir / "index.html"
|
||||
css_path = self.template_dir / "client.css"
|
||||
script_path = self.template_dir / "client.js"
|
||||
for path in (base_path, css_path, script_path):
|
||||
loader_path = self.template_dir / "loader.js"
|
||||
for path in (base_path, loader_path):
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError(f"Template file does not exist: {path}")
|
||||
template = base_path.read_text(encoding="utf-8")
|
||||
|
|
@ -205,11 +232,9 @@ class Compiler:
|
|||
}
|
||||
replacements = {
|
||||
"{{DOCSLIB_TITLE}}": self.title.replace("&", "&").replace("<", "<").replace(">", ">"),
|
||||
"{{DOCSLIB_CSS}}": css_path.read_text(encoding="utf-8"),
|
||||
"{{DOCSLIB_CUSTOM_CSS}}": _read_optional_file(self.custom_css),
|
||||
"{{DOCSLIB_MANIFEST}}": _safe_json_for_script(manifest),
|
||||
"{{DOCSLIB_CONFIG}}": _safe_json_for_script(config),
|
||||
"{{DOCSLIB_JS}}": script_path.read_text(encoding="utf-8"),
|
||||
"{{DOCSLIB_LOADER}}": loader_path.read_text(encoding="utf-8"),
|
||||
}
|
||||
for marker, value in replacements.items():
|
||||
if marker not in template:
|
||||
|
|
|
|||
|
|
@ -58,6 +58,11 @@ class DocumentationSite:
|
|||
self._source_mtimes[path] = path.stat().st_mtime_ns
|
||||
except OSError:
|
||||
continue
|
||||
for path in self.compiler.watched_files():
|
||||
try:
|
||||
self._source_mtimes.setdefault(path, path.stat().st_mtime_ns)
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
def refresh_changed_sources(self) -> bool:
|
||||
if not self.live_preview:
|
||||
|
|
@ -96,6 +101,8 @@ class DocumentationSite:
|
|||
|
||||
def _reload_source(self, path: Path) -> None:
|
||||
modules = self._source_modules.get(path, set())
|
||||
if not modules:
|
||||
return
|
||||
registry.remove_source(path)
|
||||
reloadable = [name for name in modules if name not in {"__main__", "<run_path>"} and name in sys.modules]
|
||||
if reloadable:
|
||||
|
|
@ -111,6 +118,7 @@ def create_app(
|
|||
title: str = "Documentation",
|
||||
assets_dir: str | Path = "./assets",
|
||||
custom_css: str | Path | None = None,
|
||||
custom_js: str | Path | None = None,
|
||||
template_dir: str | Path | None = None,
|
||||
manifest_path: str | None = None,
|
||||
manifest_parh: str | None = None,
|
||||
|
|
@ -124,6 +132,7 @@ def create_app(
|
|||
title=title,
|
||||
assets_dir=assets_dir,
|
||||
custom_css=custom_css,
|
||||
custom_js=custom_js,
|
||||
template_dir=template_dir,
|
||||
manifest_url=manifest_url,
|
||||
live_preview=live_preview,
|
||||
|
|
@ -185,6 +194,7 @@ def build(
|
|||
title: str = "Documentation",
|
||||
assets_dir: str | Path = "./assets",
|
||||
custom_css: str | Path | None = None,
|
||||
custom_js: str | Path | None = None,
|
||||
template_dir: str | Path | None = None,
|
||||
manifest_path: str | None = None,
|
||||
manifest_parh: str | None = None,
|
||||
|
|
@ -195,6 +205,7 @@ def build(
|
|||
title=title,
|
||||
assets_dir=assets_dir,
|
||||
custom_css=custom_css,
|
||||
custom_js=custom_js,
|
||||
template_dir=template_dir,
|
||||
manifest_url=_manifest_url(manifest_path, manifest_parh),
|
||||
live_preview=False,
|
||||
|
|
@ -219,6 +230,7 @@ def run(
|
|||
title: str = "Documentation",
|
||||
assets_dir: str | Path = "./assets",
|
||||
custom_css: str | Path | None = None,
|
||||
custom_js: str | Path | None = None,
|
||||
template_dir: str | Path | None = None,
|
||||
manifest_path: str | None = None,
|
||||
manifest_parh: str | None = None,
|
||||
|
|
@ -232,6 +244,7 @@ def run(
|
|||
title=title,
|
||||
assets_dir=assets_dir,
|
||||
custom_css=custom_css,
|
||||
custom_js=custom_js,
|
||||
template_dir=template_dir,
|
||||
manifest_path=manifest_path,
|
||||
manifest_parh=manifest_parh,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"use strict";
|
||||
|
||||
const embedded = JSON.parse(document.getElementById("docslib-manifest").textContent);
|
||||
const config = JSON.parse(document.getElementById("docslib-config").textContent);
|
||||
const config = window.__DOCSLIB_CONFIG__ || JSON.parse(document.getElementById("docslib-config").textContent);
|
||||
const mobileLayout = matchMedia("(max-width: 820px)");
|
||||
const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)");
|
||||
const elements = {
|
||||
|
|
@ -30,7 +30,7 @@
|
|||
mobileMenuButton: document.getElementById("mobile-menu-button"),
|
||||
};
|
||||
|
||||
let manifest = embedded;
|
||||
let manifest = window.__DOCSLIB_MANIFEST__ || embedded;
|
||||
let currentNumber = null;
|
||||
let syncing = false;
|
||||
let mobileMode = "content";
|
||||
|
|
@ -42,24 +42,20 @@
|
|||
let placeholderDots = 0;
|
||||
let placeholderPhase = "typing";
|
||||
let lastPlaceholderPage = -1;
|
||||
let customScriptLoaded = false;
|
||||
|
||||
const dbPromise = new Promise((resolve, reject) => {
|
||||
if (!window.indexedDB) return reject(new Error("IndexedDB"));
|
||||
const request = indexedDB.open("docslib-cache", 1);
|
||||
request.onupgradeneeded = () => request.result.createObjectStore("manifests");
|
||||
request.onupgradeneeded = () => {
|
||||
if (!request.result.objectStoreNames.contains("manifests")) {
|
||||
request.result.createObjectStore("manifests");
|
||||
}
|
||||
};
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
|
||||
async function cacheGet() {
|
||||
const db = await dbPromise;
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = db.transaction("manifests", "readonly").objectStore("manifests").get(config.cacheKey);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
async function cachePut(value) {
|
||||
const db = await dbPromise;
|
||||
return new Promise((resolve, reject) => {
|
||||
|
|
@ -79,6 +75,19 @@
|
|||
return [updated, revision].filter(Boolean).join(" · ") || "—";
|
||||
}
|
||||
|
||||
function isManifest(value) {
|
||||
return Boolean(
|
||||
value &&
|
||||
value.schema_version >= 2 &&
|
||||
Array.isArray(value.pages) &&
|
||||
value.client &&
|
||||
value.client.css &&
|
||||
typeof value.client.css.system === "string" &&
|
||||
value.client.js &&
|
||||
typeof value.client.js.system === "string"
|
||||
);
|
||||
}
|
||||
|
||||
function updateIdentity() {
|
||||
elements.identity.textContent = manifestIdentity();
|
||||
elements.refresh.title = String(manifest.revision || "");
|
||||
|
|
@ -398,6 +407,7 @@
|
|||
}
|
||||
|
||||
function renderAll() {
|
||||
window.__DOCSLIB_MANIFEST__ = manifest;
|
||||
document.documentElement.lang = "und";
|
||||
elements.desktopTitle.textContent = manifest.title;
|
||||
elements.mobileTitle.textContent = manifest.title;
|
||||
|
|
@ -410,7 +420,28 @@
|
|||
startPlaceholder();
|
||||
}
|
||||
|
||||
async function syncManifest({ reloadOnChange = false } = {}) {
|
||||
function loadCustomScript() {
|
||||
if (customScriptLoaded) return;
|
||||
customScriptLoaded = true;
|
||||
const source = manifest.client && manifest.client.js && manifest.client.js.custom;
|
||||
if (!source || !source.trim()) return;
|
||||
const script = document.createElement("script");
|
||||
script.id = "docslib-custom-script";
|
||||
script.textContent = `${source}\n//# sourceURL=docslib-custom.js`;
|
||||
document.body.append(script);
|
||||
}
|
||||
|
||||
function announceRendered() {
|
||||
try {
|
||||
loadCustomScript();
|
||||
} catch (error) {
|
||||
console.error("SharedDocsLib custom_js", error);
|
||||
} finally {
|
||||
document.dispatchEvent(new CustomEvent("docslib:rendered", { detail: { manifest } }));
|
||||
}
|
||||
}
|
||||
|
||||
async function syncManifest() {
|
||||
if (syncing) return false;
|
||||
setSyncing(true);
|
||||
let changed = false;
|
||||
|
|
@ -418,16 +449,13 @@
|
|||
const response = await fetch(config.manifestUrl, { cache: "no-store" });
|
||||
if (!response.ok) throw new Error(String(response.status));
|
||||
const remote = await response.json();
|
||||
if (!remote.schema_version || !Array.isArray(remote.pages)) 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;
|
||||
if (changed) {
|
||||
await cachePut(remote).catch(() => {});
|
||||
manifest = remote;
|
||||
if (reloadOnChange) {
|
||||
location.reload();
|
||||
return true;
|
||||
}
|
||||
renderAll();
|
||||
location.reload();
|
||||
return true;
|
||||
}
|
||||
elements.refresh.classList.remove("error");
|
||||
updateIdentity();
|
||||
|
|
@ -452,17 +480,8 @@
|
|||
|
||||
async function start() {
|
||||
applyTheme(localStorage.getItem("docslib-theme") || "dark");
|
||||
try {
|
||||
const cached = await cacheGet();
|
||||
if (
|
||||
cached &&
|
||||
cached.schema_version === embedded.schema_version &&
|
||||
String(cached.updated_at || "") >= String(embedded.updated_at || "")
|
||||
) manifest = cached;
|
||||
else await cachePut(embedded);
|
||||
} catch (_) { /* embedded manifest remains available */ }
|
||||
renderAll();
|
||||
await syncManifest({ reloadOnChange: true });
|
||||
announceRendered();
|
||||
if (config.livePreview) setInterval(pollLive, 1200);
|
||||
}
|
||||
|
||||
|
|
@ -484,8 +503,8 @@
|
|||
elements.refresh.addEventListener("blur", () => elements.toolbar.classList.remove("refresh-expanded"));
|
||||
elements.refresh.addEventListener("click", async () => {
|
||||
if (syncing) return;
|
||||
await syncManifest();
|
||||
location.reload();
|
||||
const changed = await syncManifest();
|
||||
if (!changed) location.reload();
|
||||
});
|
||||
document.addEventListener("pointerdown", (event) => {
|
||||
if (!elements.desktopSearchShell.contains(event.target)) elements.desktopSearchShell.classList.remove("open");
|
||||
|
|
|
|||
|
|
@ -5,12 +5,13 @@
|
|||
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
||||
<meta name="color-scheme" content="dark light">
|
||||
<title>{{DOCSLIB_TITLE}}</title>
|
||||
<style>
|
||||
{{DOCSLIB_CSS}}
|
||||
{{DOCSLIB_CUSTOM_CSS}}
|
||||
<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; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="docslib-loading" aria-hidden="true"></div>
|
||||
<div id="app" class="app-shell">
|
||||
<header class="desktop-header">
|
||||
<h2 id="desktop-site-title" class="desktop-title">{{DOCSLIB_TITLE}}</h2>
|
||||
|
|
@ -70,7 +71,7 @@
|
|||
<script id="docslib-manifest" type="application/json">{{DOCSLIB_MANIFEST}}</script>
|
||||
<script id="docslib-config" type="application/json">{{DOCSLIB_CONFIG}}</script>
|
||||
<script>
|
||||
{{DOCSLIB_JS}}
|
||||
{{DOCSLIB_LOADER}}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
106
docslib/templates/loader.js
Normal file
106
docslib/templates/loader.js
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
(() => {
|
||||
"use strict";
|
||||
|
||||
const embedded = JSON.parse(document.getElementById("docslib-manifest").textContent);
|
||||
const config = JSON.parse(document.getElementById("docslib-config").textContent);
|
||||
const loading = document.getElementById("docslib-loading");
|
||||
const loaderStyle = document.getElementById("docslib-loader-style");
|
||||
|
||||
function isManifest(value) {
|
||||
return Boolean(
|
||||
value &&
|
||||
value.schema_version >= 2 &&
|
||||
Array.isArray(value.pages) &&
|
||||
value.client &&
|
||||
value.client.css &&
|
||||
typeof value.client.css.system === "string" &&
|
||||
value.client.js &&
|
||||
typeof value.client.js.system === "string"
|
||||
);
|
||||
}
|
||||
|
||||
const dbPromise = new Promise((resolve, reject) => {
|
||||
if (!window.indexedDB) return reject(new Error("IndexedDB"));
|
||||
const request = indexedDB.open("docslib-cache", 1);
|
||||
request.onupgradeneeded = () => {
|
||||
if (!request.result.objectStoreNames.contains("manifests")) {
|
||||
request.result.createObjectStore("manifests");
|
||||
}
|
||||
};
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
|
||||
async function cacheGet() {
|
||||
const db = await dbPromise;
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = db.transaction("manifests", "readonly").objectStore("manifests").get(config.cacheKey);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
async function cachePut(value) {
|
||||
const db = await dbPromise;
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction("manifests", "readwrite");
|
||||
transaction.objectStore("manifests").put(value, config.cacheKey);
|
||||
transaction.oncomplete = resolve;
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
});
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
selected = remote;
|
||||
await cachePut(remote).catch(() => {});
|
||||
} catch (_) { /* cached or embedded manifest remains available */ }
|
||||
finally { clearTimeout(timeout); }
|
||||
return selected;
|
||||
}
|
||||
|
||||
function applyRuntime(manifest) {
|
||||
const style = document.createElement("style");
|
||||
style.id = "docslib-runtime-style";
|
||||
style.textContent = [manifest.client.css.system, manifest.client.css.custom || ""].join("\n");
|
||||
document.head.append(style);
|
||||
|
||||
window.__DOCSLIB_MANIFEST__ = manifest;
|
||||
window.__DOCSLIB_CONFIG__ = config;
|
||||
const script = document.createElement("script");
|
||||
script.id = "docslib-runtime-script";
|
||||
script.textContent = `${manifest.client.js.system}\n//# sourceURL=docslib-client.js`;
|
||||
document.body.append(script);
|
||||
}
|
||||
|
||||
document.addEventListener("docslib:rendered", () => {
|
||||
loading?.remove();
|
||||
loaderStyle?.remove();
|
||||
}, { once: true });
|
||||
|
||||
selectManifest()
|
||||
.then((manifest) => {
|
||||
if (!isManifest(manifest)) throw new Error("manifest");
|
||||
applyRuntime(manifest);
|
||||
})
|
||||
.catch((error) => console.error("SharedDocsLib", error));
|
||||
})();
|
||||
3
examples/assets/inject.js
Normal file
3
examples/assets/inject.js
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
document.addEventListener("docslib:rendered", (event) => {
|
||||
document.documentElement.dataset.exampleRevision = event.detail.manifest.revision;
|
||||
}, { once: true });
|
||||
|
|
@ -60,6 +60,7 @@ if __name__ == "__main__":
|
|||
title="SharedDocsLib Example",
|
||||
assets_dir="./examples/assets",
|
||||
custom_css="./examples/assets/inject.css",
|
||||
custom_js="./examples/assets/inject.js",
|
||||
live_preview=True,
|
||||
header_buttons=[
|
||||
HeaderButton("Welcome", welcome),
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||
|
||||
[project]
|
||||
name = "shared-docs-lib"
|
||||
version = "0.2.2"
|
||||
version = "0.3.0"
|
||||
description = "Build self-contained, Obsidian-like documentation sites from Python functions."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ def test_all_endpoints_and_self_contained_build(tmp_path):
|
|||
(assets / "hero.png").write_bytes(image_bytes)
|
||||
custom_css = tmp_path / "inject.css"
|
||||
custom_css.write_text(".content { --custom-test: yes; }", encoding="utf-8")
|
||||
custom_js = tmp_path / "inject.js"
|
||||
custom_js.write_text('document.body.dataset.customTest = "yes";', encoding="utf-8")
|
||||
|
||||
@Page("1.1", "Child")
|
||||
def child():
|
||||
|
|
@ -33,6 +35,7 @@ def test_all_endpoints_and_self_contained_build(tmp_path):
|
|||
title="Test docs",
|
||||
assets_dir=assets,
|
||||
custom_css=custom_css,
|
||||
custom_js=custom_js,
|
||||
manifest_path="http://127.0.0.1:9000/",
|
||||
header_buttons=[
|
||||
HeaderButton("Home", home),
|
||||
|
|
@ -48,6 +51,11 @@ def test_all_endpoints_and_self_contained_build(tmp_path):
|
|||
manifest = manifest_response.json()
|
||||
assert [page["number"] for page in manifest["pages"]] == ["1", "1.1"]
|
||||
assert manifest["assets"]["hero.png"]["data"] == base64.b64encode(image_bytes).decode()
|
||||
assert manifest["schema_version"] == 2
|
||||
assert "html, body { width: 100%; height: 100dvh" in manifest["client"]["css"]["system"]
|
||||
assert manifest["client"]["css"]["custom"] == ".content { --custom-test: yes; }"
|
||||
assert "placeholderTick" in manifest["client"]["js"]["system"]
|
||||
assert manifest["client"]["js"]["custom"] == 'document.body.dataset.customTest = "yes";'
|
||||
assert len(manifest["revision"]) == 20
|
||||
assert manifest["ui"]["header_buttons"] == [
|
||||
{"label": "Home", "page": "1"},
|
||||
|
|
@ -62,6 +70,12 @@ def test_all_endpoints_and_self_contained_build(tmp_path):
|
|||
assert html_response.status_code == 200
|
||||
assert "http://127.0.0.1:9000/manifest.json" in html
|
||||
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 'id="docslib-loader-style"' in html
|
||||
assert "docslib:rendered" in html
|
||||
assert "docslib-runtime-style" in html
|
||||
assert "indexedDB.open" in html
|
||||
assert "link.dataset.page = button.page" in html
|
||||
assert 'class="brand-mark"' not in html
|
||||
|
|
@ -96,7 +110,7 @@ def test_all_endpoints_and_self_contained_build(tmp_path):
|
|||
assert 'id="mobile-menu-view"' in html
|
||||
assert 'id="mobile-search-view"' in html
|
||||
assert "placeholderTick" in html
|
||||
assert '".".repeat(dots)' in html
|
||||
assert '".".repeat(dots)' in manifest["client"]["js"]["system"]
|
||||
assert "setTimeout(placeholderTick, 5000)" in html
|
||||
assert '.sidebar-header-buttons .header-link:not(.active):first-child' not in html
|
||||
assert "border: 1px solid transparent" not in html
|
||||
|
|
@ -128,7 +142,7 @@ def test_all_endpoints_and_self_contained_build(tmp_path):
|
|||
|
||||
|
||||
def test_build_writes_static_bundle(tmp_path):
|
||||
assert __version__ == "0.2.2"
|
||||
assert __version__ == "0.3.0"
|
||||
|
||||
@Page("1", "Static page")
|
||||
def static_page():
|
||||
|
|
@ -178,3 +192,26 @@ def test_live_preview_reloads_a_changed_main_source(tmp_path):
|
|||
assert live["error"] is None
|
||||
assert live["revision"] != initial["revision"]
|
||||
assert "two" in client.get("/manifest.json").json()["pages"][0]["content"]
|
||||
|
||||
|
||||
def test_live_preview_rebuilds_changed_custom_javascript(tmp_path):
|
||||
custom_js = tmp_path / "inject.js"
|
||||
custom_js.write_text("window.customVersion = 1;", encoding="utf-8")
|
||||
|
||||
@Page("1", "Runtime")
|
||||
def runtime_page():
|
||||
return "<p>runtime</p>"
|
||||
|
||||
client = TestClient(create_app(custom_js=custom_js, live_preview=True))
|
||||
initial = client.get("/manifest.json").json()
|
||||
assert initial["client"]["js"]["custom"] == "window.customVersion = 1;"
|
||||
|
||||
custom_js.write_text("window.customVersion = 2;", encoding="utf-8")
|
||||
stat = custom_js.stat()
|
||||
os.utime(custom_js, ns=(stat.st_atime_ns, stat.st_mtime_ns + 1_000_000_000))
|
||||
|
||||
live = client.get("/live").json()
|
||||
updated = client.get("/manifest.json").json()
|
||||
assert live["changed"] is True
|
||||
assert live["revision"] != initial["revision"]
|
||||
assert updated["client"]["js"]["custom"] == "window.customVersion = 2;"
|
||||
|
|
|
|||
2
uv.lock
generated
2
uv.lock
generated
|
|
@ -452,7 +452,7 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "shared-docs-lib"
|
||||
version = "0.2.2"
|
||||
version = "0.3.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "fastapi" },
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue