Deliver client runtime through manifest

This commit is contained in:
Server 2026-08-10 17:28:30 +00:00
parent 3de82a7ef0
commit f1b10c2b12
12 changed files with 277 additions and 56 deletions

View file

@ -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.
Для тестов:

View file

@ -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"

View file

@ -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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;"),
"{{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:

View file

@ -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,

View file

@ -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");

View file

@ -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
View 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));
})();

View file

@ -0,0 +1,3 @@
document.addEventListener("docslib:rendered", (event) => {
document.documentElement.dataset.exampleRevision = event.detail.manifest.revision;
}, { once: true });

View file

@ -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),

View file

@ -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"

View file

@ -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
View file

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