diff --git a/README.md b/README.md
index a107b24..43eb124 100644
--- a/README.md
+++ b/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": "
…
"}],
"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.
Для тестов:
diff --git a/docslib/__init__.py b/docslib/__init__.py
index 05215f1..be4636b 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.2.2"
+__version__ = "0.3.0"
diff --git a/docslib/compiler.py b/docslib/compiler.py
index ef03409..81d1b06 100644
--- a/docslib/compiler.py
+++ b/docslib/compiler.py
@@ -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:
diff --git a/docslib/server.py b/docslib/server.py
index 9a07be6..59ebfb0 100644
--- a/docslib/server.py
+++ b/docslib/server.py
@@ -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__", ""} 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,
diff --git a/docslib/templates/client.js b/docslib/templates/client.js
index 7be72f4..f3d141a 100644
--- a/docslib/templates/client.js
+++ b/docslib/templates/client.js
@@ -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");
diff --git a/docslib/templates/index.html b/docslib/templates/index.html
index 199b269..5677f25 100644
--- a/docslib/templates/index.html
+++ b/docslib/templates/index.html
@@ -5,12 +5,13 @@
{{DOCSLIB_TITLE}}
-
+