Mochi0622's picture
initial deploy
e66cfb4
Raw
History Blame Contribute Delete
5.37 kB
"""
layout/page/loader.py
---------------------
JSON 資料載入與結構解析。
無任何 Dash / DMC 依賴,可獨立單元測試。
公開 API:
load_json(source) → {"blocks", "sections", "toc"}
norm_id_or_none(raw) → str | None
build_toc_titles(blocks, toc) → [{"id", "label", "level"}, ...]
"""
from __future__ import annotations
import os
import json
from typing import Any
# ---------------------------------------------------------------------------
# 檔案快取(process 層級,同一 JSON 只讀一次)
# ---------------------------------------------------------------------------
_cache: dict[str, dict] = {}
def _load_file_cached(path: str) -> dict:
"""讀取並快取 JSON 檔案,檔案未更動時直接回傳快取。"""
try:
mtime = os.path.getmtime(path)
except OSError:
return {}
cached = _cache.get(path)
if cached and cached.get("_mtime") == mtime:
return cached["_data"]
try:
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
except Exception:
data = {}
_cache[path] = {"_mtime": mtime, "_data": data}
return data
# ---------------------------------------------------------------------------
# 錯誤佔位文件
# ---------------------------------------------------------------------------
def _err_doc(msg: str) -> dict[str, Any]:
return {"blocks": [{"type": "paragraph", "content": f"⚠ {msg}"}], "toc": []}
# ---------------------------------------------------------------------------
# 主要載入函式
# ---------------------------------------------------------------------------
def load_json(source: Any) -> dict[str, Any]:
"""
接受多種來源,統一回傳 {"blocks": [...], "sections": [...], "toc": [...]}。
source 可以是:
- list / dict:直接使用
- str / Path:當作檔案路徑讀取
"""
if isinstance(source, (list, dict)):
obj = source
else:
p = str(source or "").strip()
if not p:
return _err_doc("未提供 JSON 路徑")
if not os.path.exists(p):
return _err_doc(f"找不到 JSON:{p}")
try:
obj = _load_file_cached(p)
if not obj:
return _err_doc(f"讀取 JSON 失敗或為空:{p}")
except Exception as e:
return _err_doc(f"讀取 JSON 失敗:{e}")
if isinstance(obj, list):
return {"blocks": obj, "sections": [], "toc": []}
if isinstance(obj, dict):
blocks = obj.get("blocks") or obj.get("doc") or obj.get("content") or []
sections = obj.get("sections") or []
toc = obj.get("toc") or obj.get("TOC") or []
if sections and isinstance(sections, list):
return {"blocks": blocks, "sections": sections, "toc": toc}
return {"blocks": blocks, "sections": [], "toc": toc}
return _err_doc(f"不支援的 JSON 型別:{type(obj).__name__}")
# ---------------------------------------------------------------------------
# ID 正規化
# ---------------------------------------------------------------------------
def norm_id_or_none(raw: str | None) -> str | None:
"""
把 URL fragment 或帶前綴的 id 字串還原成純 id。
範例:
'頁面#h-CH4-P2_id2' → 'CH4-P2_id2'
'#h-foo' → 'foo'
'h-bar' → 'bar'
'' / None → None
"""
if not raw:
return None
s = str(raw).strip()
if not s:
return None
if "#" in s:
s = s.split("#", 1)[1].strip()
if s.startswith("h-"):
s = s[2:]
return s or None
# ---------------------------------------------------------------------------
# TOC 標題清單建立
# ---------------------------------------------------------------------------
def build_toc_titles(
blocks: list[dict[str, Any]],
raw_toc: list[Any],
) -> list[dict[str, Any]]:
"""
回傳給 make_toc() 使用的 titles 清單:
[{"id": str, "label": str, "level": int}, ...]
優先使用 JSON 內嵌的 toc;若無,則從 blocks 的 heading 自動產生。
"""
titles: list[dict[str, Any]] = []
# 優先:JSON 內嵌 toc
if isinstance(raw_toc, list) and raw_toc:
for it in raw_toc:
if not isinstance(it, dict):
continue
label = (
it.get("title") or it.get("text") or it.get("label") or ""
).strip()
nid = norm_id_or_none(
it.get("id") or it.get("href") or it.get("slug")
)
level = int(it.get("level") or 1)
if not label or not nid:
continue
titles.append({"id": nid, "label": label, "level": max(1, min(level, 6))})
return titles
# 退回:從 blocks heading 產生
for b in blocks or []:
if (b.get("type") or "").lower() != "heading":
continue
nid = norm_id_or_none(b.get("id"))
if not nid:
continue
label = (b.get("content") or "").strip()
level = int(b.get("level") or 3)
titles.append({
"id": nid,
"label": label or "(空白標題)",
"level": max(1, min(level, 6)),
})
return titles