"""共用文章输入及 Markdown 主题渲染;此模块不登录、不上传,也不处理平台限制。""" from __future__ import annotations import json import os import shutil import subprocess from html import escape from pathlib import Path from utils.article_content import ArticleDocument ARTICLE_THEMES = { "plain": "原生:仅转换正文结构,不添加主题样式", "default": "经典:居中标题与强调色", "grace": "优雅:圆角引用与柔和装饰", "simple": "简洁:清晰留白与轻量装饰", "modern": "现代:暖色、圆角与宽松行距", } ARTICLE_MARKDOWN_EXTENSIONS = {".md", ".markdown"} ARTICLE_HTML_EXTENSIONS = {".html", ".htm"} ARTICLE_RENDERER_DIR = Path(__file__).with_name("article_renderer") def load_article(content_file: str | Path, theme: str | None = None) -> ArticleDocument: """从原稿所在目录解析资源;长正文通过标准输入传递,不进入命令行参数。""" path = Path(content_file).expanduser().resolve() source = path.read_text(encoding="utf-8-sig") suffix = path.suffix.lower() metadata = {} if suffix in ARTICLE_MARKDOWN_EXTENSIONS: theme = theme or "default" if theme not in ARTICLE_THEMES: raise ValueError(f"主题必须为: {', '.join(ARTICLE_THEMES)}") node = shutil.which("node") if not node or not (ARTICLE_RENDERER_DIR / "node_modules/baoyu-md").is_dir(): raise RuntimeError("Markdown 渲染需要 Node.js 22+ 及依赖;请执行 sau article setup") completed = subprocess.run( [node, str(ARTICLE_RENDERER_DIR / "render.mjs")], input=json.dumps({"content": source, "theme": theme}, ensure_ascii=False), capture_output=True, text=True, encoding="utf-8", timeout=60, ) if completed.returncode: raise RuntimeError(f"Markdown 渲染失败: {completed.stderr.strip()}") payload = json.loads(completed.stdout) source, metadata = payload["html"], payload["metadata"] elif suffix in ARTICLE_HTML_EXTENSIONS | {".txt"}: if theme is not None: raise ValueError("--theme 仅适用于 Markdown;HTML 和 TXT 不重新套用主题") if suffix == ".txt": source = "".join(f"

{escape(line) if line else '
'}

" for line in source.splitlines()) else: raise ValueError("正文文件支持 .html/.htm、.md/.markdown,以及兼容的 .txt") document = ArticleDocument(source, path.parent) document.source_format = "markdown" if suffix in ARTICLE_MARKDOWN_EXTENSIONS else ("text" if suffix == ".txt" else "html") document.theme = theme document.metadata = metadata return document def render_article_file(content_file: str | Path, output: str | Path, theme: str | None = None, overwrite: bool = False) -> dict: """保存可独立预览的 HTML;输出路径改变时仍能找到原稿中的本地图片。""" source = Path(content_file).expanduser().resolve() target = Path(output).expanduser().resolve() if target == source: raise ValueError("输出文件不能覆盖输入原稿") if target.suffix.lower() not in ARTICLE_HTML_EXTENSIONS: raise ValueError("--output 必须为 .html 或 .htm 文件") if target.exists() and not overwrite: raise FileExistsError(f"输出文件已存在;使用 --overwrite 明确覆盖: {target}") document = load_article(source, theme) title = escape(document.metadata.get("title", source.stem)) html = ('' '' f'{title}{document.content_html}') target.parent.mkdir(parents=True, exist_ok=True) target.write_text(html, encoding="utf-8") return {"output": str(target), "format": document.source_format, "theme": document.theme, "image_count": len(document.images)} def setup_article_renderer() -> None: """按锁文件安装本地渲染依赖;发表和渲染过程中不下载程序。""" node = shutil.which("node") npm = shutil.which("npm.cmd" if os.name == "nt" else "npm") if not node or not npm: raise RuntimeError("请先安装 Node.js 22+(含 npm),再执行 sau article setup") version = subprocess.check_output([node, "--version"], text=True).strip() if int(version.lstrip("v").split(".")[0]) < 22: raise RuntimeError(f"文章渲染需要 Node.js 22+,当前为 {version}") subprocess.run([npm, "ci", "--ignore-scripts", "--no-audit", "--no-fund"], cwd=ARTICLE_RENDERER_DIR, check=True, timeout=180)