zhenxun_bot/zhenxun/services/renderer/theme.py

268 lines
9.7 KiB
Python
Raw Normal View History

from collections.abc import Callable
import inspect
from pathlib import Path
from typing import Any
import aiofiles
from jinja2 import (
ChoiceLoader,
Environment,
FileSystemLoader,
PrefixLoader,
TemplateNotFound,
select_autoescape,
)
import markdown
from pydantic import BaseModel
import ujson as json
from zhenxun.configs.path_config import THEMES_PATH
from zhenxun.services.log import logger
from zhenxun.services.renderer.models import TemplateManifest
from zhenxun.services.renderer.protocols import Renderable
from zhenxun.utils.exception import RenderingError
from zhenxun.utils.pydantic_compat import model_dump
class Theme(BaseModel):
name: str
palette: dict[str, Any]
style_css: str = ""
assets_dir: Path
default_assets_dir: Path
class ThemeManager:
def __init__(
self,
plugin_template_paths: dict[str, Path],
custom_filters: dict[str, Callable],
custom_globals: dict[str, Callable],
markdown_styles: dict[str, Path],
):
prefix_loader = PrefixLoader(
{
namespace: FileSystemLoader(str(path.absolute()))
for namespace, path in plugin_template_paths.items()
}
)
theme_loader = FileSystemLoader(
[
str(THEMES_PATH / "current_theme_placeholder" / "templates"),
str(THEMES_PATH / "default" / "templates"),
]
)
final_loader = ChoiceLoader([prefix_loader, theme_loader])
self.jinja_env = Environment(
loader=final_loader,
enable_async=True,
autoescape=select_autoescape(["html", "xml"]),
)
self.current_theme: Theme | None = None
self._custom_filters = custom_filters
self._custom_globals = custom_globals
self._markdown_styles = markdown_styles
self.jinja_env.globals["resolve_template"] = self._resolve_component_template
self.jinja_env.filters["md"] = self._markdown_filter
@staticmethod
def _markdown_filter(text: str) -> str:
"""一个将 Markdown 文本转换为 HTML 的 Jinja2 过滤器。"""
if not isinstance(text, str):
return ""
return markdown.markdown(
text,
extensions=[
"pymdownx.tasklist",
"tables",
"fenced_code",
"codehilite",
"mdx_math",
"pymdownx.tilde",
],
extension_configs={"mdx_math": {"enable_dollar_delimiter": True}},
)
async def load_theme(self, theme_name: str = "default"):
theme_dir = THEMES_PATH / theme_name
if not theme_dir.is_dir():
logger.error(f"主题 '{theme_name}' 不存在,将回退到默认主题。")
if theme_name == "default":
raise FileNotFoundError("默认主题 'default' 未找到!")
theme_name = "default"
theme_dir = THEMES_PATH / "default"
if self.jinja_env.loader and isinstance(self.jinja_env.loader, ChoiceLoader):
current_loaders = list(self.jinja_env.loader.loaders)
if len(current_loaders) > 1:
current_loaders[1] = FileSystemLoader(
[
str(theme_dir / "templates"),
str(THEMES_PATH / "default" / "templates"),
]
)
self.jinja_env.loader = ChoiceLoader(current_loaders)
else:
logger.error("Jinja2 loader 不是 ChoiceLoader 或未设置,无法更新主题路径。")
palette_path = theme_dir / "palette.json"
palette = (
json.loads(palette_path.read_text("utf-8")) if palette_path.exists() else {}
)
self.current_theme = Theme(
name=theme_name,
palette=palette,
assets_dir=theme_dir / "assets",
default_assets_dir=THEMES_PATH / "default" / "assets",
)
theme_context_dict = {
"name": theme_name,
"palette": palette,
"assets_dir": theme_dir / "assets",
"default_assets_dir": THEMES_PATH / "default" / "assets",
}
self.jinja_env.globals["theme"] = theme_context_dict
logger.info(f"主题管理器已加载主题: {theme_name}")
async def _resolve_component_template(self, component_path: str) -> str:
"""
智能解析组件路径
如果路径是目录则查找 manifest.json 以获取入口点
"""
if Path(component_path).suffix:
return component_path
manifest_path_str = f"{component_path}/manifest.json"
if not self.jinja_env.loader:
raise TemplateNotFound(
f"Jinja2 loader 未配置。无法查找 '{manifest_path_str}'"
)
try:
_, full_path, _ = self.jinja_env.loader.get_source(
self.jinja_env, manifest_path_str
)
if full_path and Path(full_path).exists():
async with aiofiles.open(full_path, encoding="utf-8") as f:
manifest_data = json.loads(await f.read())
entrypoint = manifest_data.get("entrypoint")
if not entrypoint:
raise RenderingError(
f"组件 '{component_path}' 的 manifest.json 中缺少 "
f"'entrypoint' 键。"
)
return f"{component_path}/{entrypoint}"
except TemplateNotFound:
logger.debug(
f"未找到 '{manifest_path_str}',将回退到默认的 'main.html' 入口点。"
)
return f"{component_path}/main.html"
raise TemplateNotFound(f"无法为组件 '{component_path}' 找到模板入口点。")
async def get_template_manifest(
self, component_path: str
) -> TemplateManifest | None:
"""
查找并解析组件的 manifest.json 文件
"""
manifest_path_str = f"{component_path}/manifest.json"
if not self.jinja_env.loader:
return None
try:
_, full_path, _ = self.jinja_env.loader.get_source(
self.jinja_env, manifest_path_str
)
if full_path and Path(full_path).exists():
async with aiofiles.open(full_path, encoding="utf-8") as f:
manifest_data = json.loads(await f.read())
return TemplateManifest(**manifest_data)
except TemplateNotFound:
return None
return None
def _resolve_markdown_style_path(self, style_name: str) -> Path | None:
"""
按照 注册 -> 主题约定 -> 默认约定 的顺序解析 Markdown 样式路径
"""
if style_name in self._markdown_styles:
logger.debug(f"找到已注册的 Markdown 样式: '{style_name}'")
return self._markdown_styles[style_name]
logger.warning(f"样式 '{style_name}' 在注册表中未找到。")
return None
async def _render_component_to_html(
self,
component: Renderable,
required_scripts: list[str] | None = None,
required_styles: list[str] | None = None,
**kwargs,
) -> str:
"""将 Renderable 组件渲染成 HTML 字符串,并处理异步数据。"""
if not self.current_theme:
await self.load_theme()
assert self.current_theme is not None, "主题加载失败"
data_dict = component.get_render_data()
custom_style_css = ""
if hasattr(component, "get_extra_css"):
css_result = component.get_extra_css(self)
if inspect.isawaitable(css_result):
custom_style_css = await css_result
else:
custom_style_css = css_result
def asset_loader(asset_path: str) -> str:
"""[新增] 用于在Jinja2模板中解析静态资源的辅助函数。"""
assert self.current_theme is not None
current_theme_asset = self.current_theme.assets_dir / asset_path
if current_theme_asset.exists():
return current_theme_asset.relative_to(THEMES_PATH.parent).as_posix()
default_theme_asset = self.current_theme.default_assets_dir / asset_path
if default_theme_asset.exists():
return default_theme_asset.relative_to(THEMES_PATH.parent).as_posix()
logger.warning(
f"资源文件在主题 '{self.current_theme.name}''default' 中均未找到: "
f"{asset_path}"
)
return ""
theme_context_dict = model_dump(self.current_theme)
theme_context_dict["asset"] = asset_loader
resolved_template_name = await self._resolve_component_template(
str(component.template_name)
)
logger.debug(
f"正在渲染组件 '{component.template_name}' "
f"(主题: {self.current_theme.name}),解析模板: '{resolved_template_name}'",
"RendererService",
)
if self._custom_filters:
self.jinja_env.filters.update(self._custom_filters)
if self._custom_globals:
self.jinja_env.globals.update(self._custom_globals)
template = self.jinja_env.get_template(resolved_template_name)
template_context = {
"data": data_dict,
"theme": theme_context_dict,
"theme_css": "",
"custom_style_css": custom_style_css,
"required_scripts": required_scripts or [],
"required_styles": required_styles or [],
}
template_context.update(kwargs)
return await template.render_async(**template_context)