Compare commits

..

10 Commits

Author SHA1 Message Date
Jim Liu 宝玉 66160abb9f chore: release v2.1.0 2026-05-24 21:27:57 -05:00
Jim Liu 宝玉 b34e19daad feat(baoyu-post-to-wechat,baoyu-post-to-weibo,baoyu-post-to-x): cascade Mermaid PNG preprocessing into publishing pipelines 2026-05-24 21:27:53 -05:00
Jim Liu 宝玉 cda76e20fd feat(baoyu-markdown-to-html): render Mermaid code blocks to PNG via headless Chrome 2026-05-24 21:27:43 -05:00
Jim Liu 宝玉 8d2fd7aae0 feat(baoyu-md): add Mermaid preprocessing and utility modules 2026-05-24 21:27:38 -05:00
Jim Liu 宝玉 f60702cc8e feat(baoyu-chrome-cdp): add Mermaid-to-PNG rendering via CDP 2026-05-24 21:27:33 -05:00
Jim Liu 宝玉 613bee6bb8 feat(build): support multi-entry output in shared package build script 2026-05-24 21:27:28 -05:00
Jim Liu 宝玉 e63e21786d chore: release v2.0.1 2026-05-24 18:59:16 -05:00
Jim Liu 宝玉 b3a3b632c8 fix(baoyu-post-to-wechat): repair htmlToPlainText syntax error and entity decoding
Follow-up to the previous newspic conversion commit:

- `“` / `”` mapped to `"""` (three straight double-quotes),
  producing an unterminated string literal that prevented wechat-api.ts
  from parsing. Replaced with the intended curly Unicode characters.
- `‘` / `’` similarly mapped to straight `'`; switched to the
  curly singles to match the entity meaning.
- Numeric entities used `String.fromCharCode`, which mangles code points
  above 0xFFFF (e.g. emoji like `😀` → empty string). Switched to
  `String.fromCodePoint`.
- Added hexadecimal numeric entity support (`😀` etc.); the
  previous regex only matched decimal forms.
- Anchored the entity sub-patterns with `^...$` so the callback cannot
  accidentally re-match a substring of the captured entity.
2026-05-24 18:57:01 -05:00
Go1dFinger 3aafe60463 fix(baoyu-post-to-wechat): improve newspic plain text conversion for WeChat API
The previous fix used a simple regex to strip HTML tags for newspic
content, but it had several issues:

1. HTML entities ( , <, >, &, etc.) were not decoded
2. Block-level tags (</p>, </div>, </h1> etc.) did not produce line breaks
3. Multiple consecutive whitespace characters were not collapsed

This commit introduces a dedicated htmlToPlainText() function that:
- Converts <br> and block-level closing tags to line breaks
- Strips all remaining HTML tags
- Decodes common HTML entities (including numeric entities)
- Collapses consecutive whitespace into single spaces
- Trims whitespace from each line

This ensures the content field for newspic articles is properly
formatted plain text that complies with WeChat API requirements,
preventing error 45166 (invalid content) especially when publishing
with multiple images.

Closes #163
Merges #164
2026-05-24 18:55:59 -05:00
Jim Liu 宝玉 246e90bf28 chore: align baoyu-{article-illustrator,comic,infographic,slide-deck} SKILL.md versions with ClawHub 2026-05-24 18:36:45 -05:00
33 changed files with 7209 additions and 10309 deletions
+1 -1
View File
@@ -6,7 +6,7 @@
},
"metadata": {
"description": "Skills shared by Baoyu for improving daily work efficiency",
"version": "2.0.0"
"version": "2.1.0"
},
"plugins": [
{
+16
View File
@@ -2,6 +2,22 @@
English | [中文](./CHANGELOG.zh.md)
## 2.1.0 - 2026-05-24
### Features
- `baoyu-markdown-to-html`: render fenced ` ```mermaid ` code blocks to local PNG via shared Chrome (CDP) before the standard image-placeholder pipeline runs. New CLI flags: `--mermaid-theme <default|forest|dark|neutral|base>`, `--mermaid-scale <N>` (default `2` for @2x resolution), `--mermaid-bg <white|transparent|#hex>`, and `--no-mermaid` to disable rendering. Generated images land in `imgs/.mermaid-cache/mermaid-<hash>.png` and are deduplicated/reused across runs by a 12-char SHA-256 over `(code, theme, scale, background, mermaid version)`. The browser-side `<pre class="mermaid">` path is retained as a graceful fallback when Chrome is unavailable or a single block fails to render
- `baoyu-post-to-wechat`, `baoyu-post-to-weibo`, `baoyu-post-to-x`: cascade the same Mermaid → PNG preprocessing into the WeChat / Weibo / X publishing pipelines so diagrams appear as real images in the published posts (previously they fell through as unrendered `<pre>` blocks). Existing `WECHATIMGPH_*` / `WBIMGPH_*` / `XIMGPH_*` placeholder pipelines pick up the generated PNGs unchanged
- `baoyu-md` package: new exports `preprocessMermaidInMarkdown`, `extractMermaidBlocks`, `replaceMermaidBlocks`, `hashMermaidCode`, and `MERMAID_VERSION` (skills inject the render function so the package stays Chrome-free)
- `baoyu-chrome-cdp` package: new `./mermaid` subexport providing `renderMermaidToPng(code, outputPath, options)` backed by a process-singleton CDP connection that reuses the shared Chrome profile and ships the vendored Mermaid 10.9.1 UMD bundle as an asset
## 2.0.1 - 2026-05-24
### Fixes
- `baoyu-post-to-wechat`: convert `newspic` (image-text) `content` from HTML to plain text before sending to the WeChat draft API. The previous behavior passed raw HTML, triggering error 45166 (invalid content) when multiple images pushed the body over WeChat's length limit. The new `htmlToPlainText` helper turns `<br>` and block-closing tags into line breaks, strips remaining tags, decodes named/numeric (decimal and hex) entities, and collapses whitespace (by @Go1dFinger, [#164](https://github.com/JimLiu/baoyu-skills/pull/164), closes [#163](https://github.com/JimLiu/baoyu-skills/issues/163))
### Credits
- `baoyu-post-to-wechat` newspic plain-text fix contributed by @Go1dFinger ([#164](https://github.com/JimLiu/baoyu-skills/pull/164))
## 2.0.0 - 2026-05-24
### Breaking
+16
View File
@@ -2,6 +2,22 @@
[English](./CHANGELOG.md) | 中文
## 2.1.0 - 2026-05-24
### 新功能
- `baoyu-markdown-to-html`:在标准图片占位符流水线之前,通过共享 Chrome(CDP)把 ` ```mermaid ` 围栏代码块渲染为本地 PNG。新增 CLI 参数 `--mermaid-theme <default|forest|dark|neutral|base>``--mermaid-scale <N>`(默认 `2`,即 @2x 分辨率)、`--mermaid-bg <white|transparent|#hex>`,以及用于关闭渲染的 `--no-mermaid`。生成的图片落在 `imgs/.mermaid-cache/mermaid-<hash>.png`,跨次运行通过对 `(code, theme, scale, background, mermaid 版本)` 取 SHA-256 前 12 位进行去重/复用。Chrome 不可用或单块渲染失败时,保留浏览器侧 `<pre class="mermaid">` 兜底
- `baoyu-post-to-wechat``baoyu-post-to-weibo``baoyu-post-to-x`:在微信 / 微博 / X 发布流水线中同步接入上述 Mermaid → PNG 预处理,Mermaid 图能以真图形式出现在已发布的稿件中(此前会落到未渲染的 `<pre>` 块)。现有的 `WECHATIMGPH_*` / `WBIMGPH_*` / `XIMGPH_*` 占位符流水线无需改动,会直接拾取生成的 PNG
- `baoyu-md` 包:新增导出 `preprocessMermaidInMarkdown``extractMermaidBlocks``replaceMermaidBlocks``hashMermaidCode``MERMAID_VERSION`(渲染函数由 skill 注入,本包不再反向依赖 Chrome)
- `baoyu-chrome-cdp` 包:新增 `./mermaid` 子导出,提供 `renderMermaidToPng(code, outputPath, options)`,底层为进程级单例 CDP 连接,复用共享 Chrome profile,并把 Mermaid 10.9.1 UMD 资产打入包内
## 2.0.1 - 2026-05-24
### 修复
- `baoyu-post-to-wechat`:发布 `newspic`(图文)时,将 `content` 字段从 HTML 转换为纯文本后再提交微信草稿接口。此前直接传 HTML,多图场景下内容长度超出限制会触发错误 45166(invalid content)。新增 `htmlToPlainText` 工具:把 `<br>` 和块级闭合标签转成换行、剥离其余标签、解码命名实体与十进制/十六进制数字实体、合并多余空白 (by @Go1dFinger, [#164](https://github.com/JimLiu/baoyu-skills/pull/164), 关联 [#163](https://github.com/JimLiu/baoyu-skills/issues/163))
### 致谢
- `baoyu-post-to-wechat` 的 newspic 纯文本修复由 @Go1dFinger 贡献([#164](https://github.com/JimLiu/baoyu-skills/pull/164)
## 2.0.0 - 2026-05-24
### 破坏性变更
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+729
View File
@@ -0,0 +1,729 @@
var __create = Object.create;
var __getProtoOf = Object.getPrototypeOf;
var __defProp = Object.defineProperty;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __hasOwnProp = Object.prototype.hasOwnProperty;
function __accessProp(key) {
return this[key];
}
var __toESMCache_node;
var __toESMCache_esm;
var __toESM = (mod, isNodeMode, target) => {
var canCache = mod != null && typeof mod === "object";
if (canCache) {
var cache = isNodeMode ? __toESMCache_node ??= new WeakMap : __toESMCache_esm ??= new WeakMap;
var cached = cache.get(mod);
if (cached)
return cached;
}
target = mod != null ? __create(__getProtoOf(mod)) : {};
const to = isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target;
for (let key of __getOwnPropNames(mod))
if (!__hasOwnProp.call(to, key))
__defProp(to, key, {
get: __accessProp.bind(mod, key),
enumerable: true
});
if (canCache)
cache.set(mod, to);
return to;
};
var __toCommonJS = (from) => {
var entry = (__moduleCache ??= new WeakMap).get(from), desc;
if (entry)
return entry;
entry = __defProp({}, "__esModule", { value: true });
if (from && typeof from === "object" || typeof from === "function") {
for (var key of __getOwnPropNames(from))
if (!__hasOwnProp.call(entry, key))
__defProp(entry, key, {
get: __accessProp.bind(from, key),
enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
});
}
__moduleCache.set(from, entry);
return entry;
};
var __moduleCache;
var __returnValue = (v) => v;
function __exportSetter(name, newValue) {
this[name] = __returnValue.bind(null, newValue);
}
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, {
get: all[name],
enumerable: true,
configurable: true,
set: __exportSetter.bind(all, name)
});
};
// src/mermaid.ts
var exports_mermaid = {};
__export(exports_mermaid, {
renderMermaidToPng: () => renderMermaidToPng,
closeRenderer: () => closeRenderer,
MermaidRenderError: () => MermaidRenderError
});
module.exports = __toCommonJS(exports_mermaid);
var import_node_child_process2 = require("node:child_process");
var import_node_fs2 = __toESM(require("node:fs"));
var import_node_path2 = __toESM(require("node:path"));
var import_node_process2 = __toESM(require("node:process"));
var import_node_url = require("node:url");
// src/index.ts
var import_node_child_process = require("node:child_process");
var import_node_fs = __toESM(require("node:fs"));
var import_node_net = __toESM(require("node:net"));
var import_node_os = __toESM(require("node:os"));
var import_node_path = __toESM(require("node:path"));
var import_node_process = __toESM(require("node:process"));
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function getFreePort(fixedEnvName) {
const fixed = fixedEnvName ? Number.parseInt(import_node_process.default.env[fixedEnvName] ?? "", 10) : NaN;
if (Number.isInteger(fixed) && fixed > 0)
return fixed;
return await new Promise((resolve, reject) => {
const server = import_node_net.default.createServer();
server.unref();
server.on("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (!address || typeof address === "string") {
server.close(() => reject(new Error("Unable to allocate a free TCP port.")));
return;
}
const port = address.port;
server.close((err) => {
if (err)
reject(err);
else
resolve(port);
});
});
});
}
function findChromeExecutable(options) {
for (const envName of options.envNames ?? []) {
const override = import_node_process.default.env[envName]?.trim();
if (override && import_node_fs.default.existsSync(override))
return override;
}
const candidates = import_node_process.default.platform === "darwin" ? options.candidates.darwin ?? options.candidates.default : import_node_process.default.platform === "win32" ? options.candidates.win32 ?? options.candidates.default : options.candidates.default;
for (const candidate of candidates) {
if (import_node_fs.default.existsSync(candidate))
return candidate;
}
return;
}
function resolveSharedChromeProfileDir(options = {}) {
for (const envName of options.envNames ?? []) {
const override = import_node_process.default.env[envName]?.trim();
if (override)
return import_node_path.default.resolve(override);
}
const appDataDirName = options.appDataDirName ?? "baoyu-skills";
const profileDirName = options.profileDirName ?? "chrome-profile";
if (options.wslWindowsHome) {
return import_node_path.default.join(options.wslWindowsHome, ".local", "share", appDataDirName, profileDirName);
}
const base = import_node_process.default.platform === "darwin" ? import_node_path.default.join(import_node_os.default.homedir(), "Library", "Application Support") : import_node_process.default.platform === "win32" ? import_node_process.default.env.APPDATA ?? import_node_path.default.join(import_node_os.default.homedir(), "AppData", "Roaming") : import_node_process.default.env.XDG_DATA_HOME ?? import_node_path.default.join(import_node_os.default.homedir(), ".local", "share");
return import_node_path.default.join(base, appDataDirName, profileDirName);
}
async function fetchWithTimeout(url, timeoutMs) {
if (!timeoutMs || timeoutMs <= 0)
return await fetch(url, { redirect: "follow" });
const ctl = new AbortController;
const timer = setTimeout(() => ctl.abort(), timeoutMs);
try {
return await fetch(url, { redirect: "follow", signal: ctl.signal });
} finally {
clearTimeout(timer);
}
}
async function fetchJson(url, options = {}) {
const response = await fetchWithTimeout(url, options.timeoutMs);
if (!response.ok) {
throw new Error(`Request failed: ${response.status} ${response.statusText}`);
}
return await response.json();
}
async function isDebugPortReady(port, timeoutMs = 3000) {
try {
const version = await fetchJson(`http://127.0.0.1:${port}/json/version`, { timeoutMs });
return !!version.webSocketDebuggerUrl;
} catch {
return false;
}
}
function parseDevToolsActivePort(filePath) {
try {
const content = import_node_fs.default.readFileSync(filePath, "utf-8");
const lines = content.split(/\r?\n/);
const port = Number.parseInt(lines[0]?.trim() ?? "", 10);
const wsPath = lines[1]?.trim();
if (port > 0 && wsPath)
return { port, wsPath };
} catch {}
return null;
}
async function findExistingChromeDebugPort(options) {
const timeoutMs = options.timeoutMs ?? 3000;
const parsed = parseDevToolsActivePort(import_node_path.default.join(options.profileDir, "DevToolsActivePort"));
if (parsed && parsed.port > 0 && await isDebugPortReady(parsed.port, timeoutMs))
return parsed.port;
if (import_node_process.default.platform === "win32")
return null;
try {
const result = import_node_child_process.spawnSync("ps", ["aux"], { encoding: "utf-8", timeout: 5000 });
if (result.status !== 0 || !result.stdout)
return null;
const lines = result.stdout.split(`
`).filter((line) => line.includes(options.profileDir) && line.includes("--remote-debugging-port="));
for (const line of lines) {
const portMatch = line.match(/--remote-debugging-port=(\d+)/);
const port = Number.parseInt(portMatch?.[1] ?? "", 10);
if (port > 0 && await isDebugPortReady(port, timeoutMs))
return port;
}
} catch {}
return null;
}
async function waitForChromeDebugPort(port, timeoutMs, options) {
const start = Date.now();
let lastError = null;
while (Date.now() - start < timeoutMs) {
try {
const version = await fetchJson(`http://127.0.0.1:${port}/json/version`, { timeoutMs: 5000 });
if (version.webSocketDebuggerUrl)
return version.webSocketDebuggerUrl;
lastError = new Error("Missing webSocketDebuggerUrl");
} catch (error) {
lastError = error;
}
await sleep(200);
}
if (options?.includeLastError && lastError) {
throw new Error(`Chrome debug port not ready: ${lastError instanceof Error ? lastError.message : String(lastError)}`);
}
throw new Error("Chrome debug port not ready");
}
class CdpConnection {
ws;
nextId = 0;
pending = new Map;
eventHandlers = new Map;
defaultTimeoutMs;
constructor(ws, defaultTimeoutMs = 15000) {
this.ws = ws;
this.defaultTimeoutMs = defaultTimeoutMs;
this.ws.addEventListener("message", (event) => {
try {
const data = typeof event.data === "string" ? event.data : new TextDecoder().decode(event.data);
const msg = JSON.parse(data);
if (msg.method) {
const handlers = this.eventHandlers.get(msg.method);
if (handlers) {
handlers.forEach((handler) => handler(msg.params));
}
}
if (msg.id) {
const pending = this.pending.get(msg.id);
if (pending) {
this.pending.delete(msg.id);
if (pending.timer)
clearTimeout(pending.timer);
if (msg.error?.message)
pending.reject(new Error(msg.error.message));
else
pending.resolve(msg.result);
}
}
} catch {}
});
this.ws.addEventListener("close", () => {
for (const [id, pending] of this.pending.entries()) {
this.pending.delete(id);
if (pending.timer)
clearTimeout(pending.timer);
pending.reject(new Error("CDP connection closed."));
}
});
}
static async connect(url, timeoutMs, options) {
const ws = new WebSocket(url);
await new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error("CDP connection timeout.")), timeoutMs);
ws.addEventListener("open", () => {
clearTimeout(timer);
resolve();
});
ws.addEventListener("error", () => {
clearTimeout(timer);
reject(new Error("CDP connection failed."));
});
});
return new CdpConnection(ws, options?.defaultTimeoutMs ?? 15000);
}
on(method, handler) {
if (!this.eventHandlers.has(method)) {
this.eventHandlers.set(method, new Set);
}
this.eventHandlers.get(method)?.add(handler);
}
off(method, handler) {
this.eventHandlers.get(method)?.delete(handler);
}
async send(method, params, options) {
const id = ++this.nextId;
const message = { id, method };
if (params)
message.params = params;
if (options?.sessionId)
message.sessionId = options.sessionId;
const timeoutMs = options?.timeoutMs ?? this.defaultTimeoutMs;
const result = await new Promise((resolve, reject) => {
const timer = timeoutMs > 0 ? setTimeout(() => {
this.pending.delete(id);
reject(new Error(`CDP timeout: ${method}`));
}, timeoutMs) : null;
this.pending.set(id, { resolve, reject, timer });
this.ws.send(JSON.stringify(message));
});
return result;
}
close() {
try {
this.ws.close();
} catch {}
}
}
async function launchChrome(options) {
await import_node_fs.default.promises.mkdir(options.profileDir, { recursive: true });
const args = [
`--remote-debugging-port=${options.port}`,
`--user-data-dir=${options.profileDir}`,
"--no-first-run",
"--no-default-browser-check",
...options.extraArgs ?? []
];
if (options.headless)
args.push("--headless=new");
if (options.url)
args.push(options.url);
return import_node_child_process.spawn(options.chromePath, args, { stdio: "ignore" });
}
async function openPageSession(options) {
let targetId;
let createdTarget = false;
if (options.reusing) {
const created = await options.cdp.send("Target.createTarget", { url: options.url });
targetId = created.targetId;
createdTarget = true;
} else {
const targets = await options.cdp.send("Target.getTargets");
const existing = targets.targetInfos.find(options.matchTarget);
if (existing) {
targetId = existing.targetId;
} else {
const created = await options.cdp.send("Target.createTarget", { url: options.url });
targetId = created.targetId;
createdTarget = true;
}
}
const { sessionId } = await options.cdp.send("Target.attachToTarget", { targetId, flatten: true });
if (options.activateTarget ?? true) {
await options.cdp.send("Target.activateTarget", { targetId });
}
if (options.enablePage)
await options.cdp.send("Page.enable", {}, { sessionId });
if (options.enableRuntime)
await options.cdp.send("Runtime.enable", {}, { sessionId });
if (options.enableDom)
await options.cdp.send("DOM.enable", {}, { sessionId });
if (options.enableNetwork)
await options.cdp.send("Network.enable", {}, { sessionId });
return { sessionId, targetId, createdTarget };
}
// src/mermaid.ts
class MermaidRenderError extends Error {
constructor(message, options) {
super(message);
this.name = "MermaidRenderError";
if (options?.cause !== undefined) {
this.cause = options.cause;
}
}
}
function resolveRenderScale(scale) {
const resolved = scale ?? 2;
if (!Number.isFinite(resolved) || resolved <= 0) {
throw new MermaidRenderError(`Invalid Mermaid render scale: ${scale}`);
}
return resolved;
}
function resolveMinWidth(minWidth) {
if (minWidth === undefined)
return;
if (!Number.isFinite(minWidth) || minWidth <= 0) {
throw new MermaidRenderError(`Invalid Mermaid render minWidth: ${minWidth}`);
}
return minWidth;
}
var CHROME_CANDIDATES = {
darwin: [
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
"/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta",
"/Applications/Chromium.app/Contents/MacOS/Chromium",
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"
],
win32: [
"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
"C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
"C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe",
"C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe"
],
default: [
"/usr/bin/google-chrome",
"/usr/bin/google-chrome-stable",
"/usr/bin/chromium",
"/usr/bin/chromium-browser",
"/snap/bin/chromium",
"/usr/bin/microsoft-edge"
]
};
var wslHome;
function getWslWindowsHome() {
if (wslHome !== undefined)
return wslHome;
if (!import_node_process2.default.env.WSL_DISTRO_NAME) {
wslHome = null;
return null;
}
try {
const raw = import_node_child_process2.execSync('cmd.exe /C "echo %USERPROFILE%"', {
encoding: "utf-8",
timeout: 5000
}).trim().replace(/\r/g, "");
wslHome = import_node_child_process2.execSync(`wslpath -u "${raw}"`, {
encoding: "utf-8",
timeout: 5000
}).trim() || null;
} catch {
wslHome = null;
}
return wslHome;
}
function getProfileDir() {
return resolveSharedChromeProfileDir({
envNames: ["BAOYU_CHROME_PROFILE_DIR", "MERMAID_RENDER_PROFILE_DIR"],
wslWindowsHome: getWslWindowsHome()
});
}
function resolveAssetsDir() {
const here = import_node_url.fileURLToPath(undefined);
const dir = import_node_path2.default.dirname(here);
const candidates = [
import_node_path2.default.resolve(dir, "..", "assets"),
import_node_path2.default.resolve(dir, "assets")
];
for (const candidate of candidates) {
if (import_node_fs2.default.existsSync(import_node_path2.default.join(candidate, "mermaid.min.js")))
return candidate;
}
throw new MermaidRenderError(`Cannot locate mermaid.min.js. Looked in: ${candidates.join(", ")}`);
}
var cachedMermaidScript = null;
function loadMermaidScript() {
if (cachedMermaidScript)
return cachedMermaidScript;
const assetsDir = resolveAssetsDir();
cachedMermaidScript = import_node_fs2.default.readFileSync(import_node_path2.default.join(assetsDir, "mermaid.min.js"), "utf-8");
return cachedMermaidScript;
}
var rendererState = null;
var connectingPromise = null;
var exitHookInstalled = false;
function installExitHook() {
if (exitHookInstalled)
return;
exitHookInstalled = true;
const cleanup = () => {
const state = rendererState;
rendererState = null;
if (!state)
return;
try {
state.cdp.close();
} catch {}
if (state.ownsChrome && state.chrome) {
try {
state.chrome.kill("SIGTERM");
} catch {}
}
};
import_node_process2.default.on("exit", cleanup);
import_node_process2.default.on("beforeExit", cleanup);
}
async function tryConnectExisting(port) {
try {
const wsUrl = await waitForChromeDebugPort(port, 5000, { includeLastError: true });
return await CdpConnection.connect(wsUrl, 5000);
} catch {
return null;
}
}
async function ensureRenderer() {
if (rendererState)
return rendererState;
if (connectingPromise)
return await connectingPromise;
connectingPromise = (async () => {
const profileDir = getProfileDir();
const existingPort = await findExistingChromeDebugPort({ profileDir });
if (existingPort) {
const cdp2 = await tryConnectExisting(existingPort);
if (cdp2) {
const state2 = {
cdp: cdp2,
chrome: null,
port: existingPort,
ownsChrome: false
};
rendererState = state2;
installExitHook();
return state2;
}
}
const chromePath = findChromeExecutable({
candidates: CHROME_CANDIDATES,
envNames: ["BAOYU_CHROME_PATH", "MERMAID_RENDER_CHROME_PATH"]
});
if (!chromePath) {
throw new MermaidRenderError("Chrome not found. Install Google Chrome / Chromium / Edge, or set BAOYU_CHROME_PATH.");
}
const port = await getFreePort("MERMAID_RENDER_DEBUG_PORT");
const chrome = await launchChrome({
chromePath,
profileDir,
port,
headless: true,
extraArgs: [
"--disable-blink-features=AutomationControlled",
"--disable-gpu",
"--hide-scrollbars"
]
});
const wsUrl = await waitForChromeDebugPort(port, 30000, { includeLastError: true });
const cdp = await CdpConnection.connect(wsUrl, 30000);
const state = { cdp, chrome, port, ownsChrome: true };
rendererState = state;
installExitHook();
return state;
})();
try {
return await connectingPromise;
} finally {
connectingPromise = null;
}
}
function buildHostHtml(code, theme, background) {
const script = loadMermaidScript();
const safeCode = code.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
const safeTheme = JSON.stringify(theme);
const cssBackground = background === "transparent" ? "transparent" : background;
return `<!doctype html>
<html>
<head>
<meta charset="utf-8">
<style>
html, body {
margin: 0;
padding: 0;
background: ${cssBackground};
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", "Hiragino Sans GB", "Noto Sans CJK SC", sans-serif;
}
#host {
display: inline-block;
padding: 16px;
}
#host svg {
max-width: none !important;
}
</style>
</head>
<body>
<div id="host"><div class="mermaid">${safeCode}</div></div>
<script>${script}</script>
<script>
window.__mermaidReady = false;
window.__mermaidError = null;
try {
mermaid.initialize({ startOnLoad: false, theme: ${safeTheme}, securityLevel: "loose" });
mermaid.run({ querySelector: ".mermaid" })
.then(function () { window.__mermaidReady = true; })
.catch(function (err) { window.__mermaidError = String(err && err.message || err); });
} catch (err) {
window.__mermaidError = String(err && err.message || err);
}
</script>
</body>
</html>`;
}
async function evaluate(cdp, sessionId, expression) {
const result = await cdp.send("Runtime.evaluate", {
expression,
returnByValue: true,
awaitPromise: false
}, { sessionId });
const exception = result.exceptionDetails;
if (exception) {
return {
value: undefined,
exceptionText: exception.exception?.description ?? exception.text ?? "evaluation failed"
};
}
return { value: result.result.value };
}
async function waitForMermaidSvg(cdp, sessionId, timeoutMs) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const status = await evaluate(cdp, sessionId, `(function () {
if (window.__mermaidError) {
return { ready: false, error: window.__mermaidError, rect: null };
}
var svg = document.querySelector(".mermaid svg");
if (!svg) return { ready: false, error: null, rect: null };
var bbox = svg.getBoundingClientRect();
return {
ready: window.__mermaidReady === true,
error: null,
rect: {
x: bbox.x,
y: bbox.y,
width: bbox.width,
height: bbox.height,
},
};
})()`);
if (status.exceptionText) {
throw new MermaidRenderError(`Mermaid evaluation failed: ${status.exceptionText}`);
}
const value = status.value;
if (value?.error) {
throw new MermaidRenderError(`Mermaid render failed: ${value.error}`);
}
if (value?.ready && value.rect && value.rect.width > 0 && value.rect.height > 0) {
const host = await evaluate(cdp, sessionId, `(function () {
var host = document.getElementById("host");
if (!host) return null;
var rect = host.getBoundingClientRect();
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
})()`);
if (host.value)
return host.value;
return value.rect;
}
await sleep(80);
}
throw new MermaidRenderError(`Mermaid render timed out after ${timeoutMs}ms`);
}
async function withPageSession(state, fn) {
let session = null;
try {
session = await openPageSession({
cdp: state.cdp,
reusing: !state.ownsChrome,
url: "about:blank",
matchTarget: () => false,
enablePage: true,
enableRuntime: true
});
return await fn(session.sessionId, session.targetId);
} finally {
if (session?.createdTarget) {
try {
await state.cdp.send("Target.closeTarget", { targetId: session.targetId });
} catch {}
}
}
}
async function renderMermaidToPng(code, outputPath, options = {}) {
const theme = options.theme ?? "default";
const scale = resolveRenderScale(options.scale);
const minWidth = resolveMinWidth(options.minWidth);
const background = options.background ?? "white";
const timeoutMs = options.timeoutMs ?? 15000;
if (!code.trim()) {
throw new MermaidRenderError("Mermaid code is empty");
}
await import_node_fs2.default.promises.mkdir(import_node_path2.default.dirname(outputPath), { recursive: true });
const state = await ensureRenderer();
const html = buildHostHtml(code, theme, background);
return await withPageSession(state, async (sessionId) => {
await state.cdp.send("Emulation.setDeviceMetricsOverride", {
width: 1280,
height: 800,
deviceScaleFactor: 1,
mobile: false
}, { sessionId });
await state.cdp.send("Page.setDocumentContent", { frameId: await getFrameId(state.cdp, sessionId), html }, { sessionId });
const rect = await waitForMermaidSvg(state.cdp, sessionId, timeoutMs);
const cssWidth = Math.max(1, Math.ceil(rect.width));
const cssHeight = Math.max(1, Math.ceil(rect.height));
const targetCssWidth = Math.max(cssWidth, Math.ceil(minWidth ?? cssWidth));
const captureScale = scale * (targetCssWidth / cssWidth);
const bitmapWidth = Math.max(1, Math.ceil(cssWidth * captureScale));
const bitmapHeight = Math.max(1, Math.ceil(cssHeight * captureScale));
await state.cdp.send("Emulation.setDeviceMetricsOverride", {
width: cssWidth,
height: cssHeight,
deviceScaleFactor: 1,
mobile: false
}, { sessionId });
const shot = await state.cdp.send("Page.captureScreenshot", {
format: "png",
clip: {
x: rect.x,
y: rect.y,
width: cssWidth,
height: cssHeight,
scale: captureScale
},
captureBeyondViewport: true
}, { sessionId });
const buffer = Buffer.from(shot.data, "base64");
await import_node_fs2.default.promises.writeFile(outputPath, buffer);
return {
width: bitmapWidth,
height: bitmapHeight,
bytes: buffer.length
};
});
}
async function getFrameId(cdp, sessionId) {
const result = await cdp.send("Page.getFrameTree", {}, { sessionId });
return result.frameTree.frame.id;
}
async function closeRenderer() {
const state = rendererState;
rendererState = null;
if (!state)
return;
try {
state.cdp.close();
} catch {}
if (state.ownsChrome && state.chrome) {
try {
state.chrome.kill("SIGTERM");
} catch {}
}
}
+665
View File
@@ -0,0 +1,665 @@
// src/mermaid.ts
import { execSync } from "node:child_process";
import fs2 from "node:fs";
import path2 from "node:path";
import process2 from "node:process";
import { fileURLToPath } from "node:url";
// src/index.ts
import { spawn, spawnSync } from "node:child_process";
import fs from "node:fs";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import process from "node:process";
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function getFreePort(fixedEnvName) {
const fixed = fixedEnvName ? Number.parseInt(process.env[fixedEnvName] ?? "", 10) : NaN;
if (Number.isInteger(fixed) && fixed > 0)
return fixed;
return await new Promise((resolve, reject) => {
const server = net.createServer();
server.unref();
server.on("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (!address || typeof address === "string") {
server.close(() => reject(new Error("Unable to allocate a free TCP port.")));
return;
}
const port = address.port;
server.close((err) => {
if (err)
reject(err);
else
resolve(port);
});
});
});
}
function findChromeExecutable(options) {
for (const envName of options.envNames ?? []) {
const override = process.env[envName]?.trim();
if (override && fs.existsSync(override))
return override;
}
const candidates = process.platform === "darwin" ? options.candidates.darwin ?? options.candidates.default : process.platform === "win32" ? options.candidates.win32 ?? options.candidates.default : options.candidates.default;
for (const candidate of candidates) {
if (fs.existsSync(candidate))
return candidate;
}
return;
}
function resolveSharedChromeProfileDir(options = {}) {
for (const envName of options.envNames ?? []) {
const override = process.env[envName]?.trim();
if (override)
return path.resolve(override);
}
const appDataDirName = options.appDataDirName ?? "baoyu-skills";
const profileDirName = options.profileDirName ?? "chrome-profile";
if (options.wslWindowsHome) {
return path.join(options.wslWindowsHome, ".local", "share", appDataDirName, profileDirName);
}
const base = process.platform === "darwin" ? path.join(os.homedir(), "Library", "Application Support") : process.platform === "win32" ? process.env.APPDATA ?? path.join(os.homedir(), "AppData", "Roaming") : process.env.XDG_DATA_HOME ?? path.join(os.homedir(), ".local", "share");
return path.join(base, appDataDirName, profileDirName);
}
async function fetchWithTimeout(url, timeoutMs) {
if (!timeoutMs || timeoutMs <= 0)
return await fetch(url, { redirect: "follow" });
const ctl = new AbortController;
const timer = setTimeout(() => ctl.abort(), timeoutMs);
try {
return await fetch(url, { redirect: "follow", signal: ctl.signal });
} finally {
clearTimeout(timer);
}
}
async function fetchJson(url, options = {}) {
const response = await fetchWithTimeout(url, options.timeoutMs);
if (!response.ok) {
throw new Error(`Request failed: ${response.status} ${response.statusText}`);
}
return await response.json();
}
async function isDebugPortReady(port, timeoutMs = 3000) {
try {
const version = await fetchJson(`http://127.0.0.1:${port}/json/version`, { timeoutMs });
return !!version.webSocketDebuggerUrl;
} catch {
return false;
}
}
function parseDevToolsActivePort(filePath) {
try {
const content = fs.readFileSync(filePath, "utf-8");
const lines = content.split(/\r?\n/);
const port = Number.parseInt(lines[0]?.trim() ?? "", 10);
const wsPath = lines[1]?.trim();
if (port > 0 && wsPath)
return { port, wsPath };
} catch {}
return null;
}
async function findExistingChromeDebugPort(options) {
const timeoutMs = options.timeoutMs ?? 3000;
const parsed = parseDevToolsActivePort(path.join(options.profileDir, "DevToolsActivePort"));
if (parsed && parsed.port > 0 && await isDebugPortReady(parsed.port, timeoutMs))
return parsed.port;
if (process.platform === "win32")
return null;
try {
const result = spawnSync("ps", ["aux"], { encoding: "utf-8", timeout: 5000 });
if (result.status !== 0 || !result.stdout)
return null;
const lines = result.stdout.split(`
`).filter((line) => line.includes(options.profileDir) && line.includes("--remote-debugging-port="));
for (const line of lines) {
const portMatch = line.match(/--remote-debugging-port=(\d+)/);
const port = Number.parseInt(portMatch?.[1] ?? "", 10);
if (port > 0 && await isDebugPortReady(port, timeoutMs))
return port;
}
} catch {}
return null;
}
async function waitForChromeDebugPort(port, timeoutMs, options) {
const start = Date.now();
let lastError = null;
while (Date.now() - start < timeoutMs) {
try {
const version = await fetchJson(`http://127.0.0.1:${port}/json/version`, { timeoutMs: 5000 });
if (version.webSocketDebuggerUrl)
return version.webSocketDebuggerUrl;
lastError = new Error("Missing webSocketDebuggerUrl");
} catch (error) {
lastError = error;
}
await sleep(200);
}
if (options?.includeLastError && lastError) {
throw new Error(`Chrome debug port not ready: ${lastError instanceof Error ? lastError.message : String(lastError)}`);
}
throw new Error("Chrome debug port not ready");
}
class CdpConnection {
ws;
nextId = 0;
pending = new Map;
eventHandlers = new Map;
defaultTimeoutMs;
constructor(ws, defaultTimeoutMs = 15000) {
this.ws = ws;
this.defaultTimeoutMs = defaultTimeoutMs;
this.ws.addEventListener("message", (event) => {
try {
const data = typeof event.data === "string" ? event.data : new TextDecoder().decode(event.data);
const msg = JSON.parse(data);
if (msg.method) {
const handlers = this.eventHandlers.get(msg.method);
if (handlers) {
handlers.forEach((handler) => handler(msg.params));
}
}
if (msg.id) {
const pending = this.pending.get(msg.id);
if (pending) {
this.pending.delete(msg.id);
if (pending.timer)
clearTimeout(pending.timer);
if (msg.error?.message)
pending.reject(new Error(msg.error.message));
else
pending.resolve(msg.result);
}
}
} catch {}
});
this.ws.addEventListener("close", () => {
for (const [id, pending] of this.pending.entries()) {
this.pending.delete(id);
if (pending.timer)
clearTimeout(pending.timer);
pending.reject(new Error("CDP connection closed."));
}
});
}
static async connect(url, timeoutMs, options) {
const ws = new WebSocket(url);
await new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error("CDP connection timeout.")), timeoutMs);
ws.addEventListener("open", () => {
clearTimeout(timer);
resolve();
});
ws.addEventListener("error", () => {
clearTimeout(timer);
reject(new Error("CDP connection failed."));
});
});
return new CdpConnection(ws, options?.defaultTimeoutMs ?? 15000);
}
on(method, handler) {
if (!this.eventHandlers.has(method)) {
this.eventHandlers.set(method, new Set);
}
this.eventHandlers.get(method)?.add(handler);
}
off(method, handler) {
this.eventHandlers.get(method)?.delete(handler);
}
async send(method, params, options) {
const id = ++this.nextId;
const message = { id, method };
if (params)
message.params = params;
if (options?.sessionId)
message.sessionId = options.sessionId;
const timeoutMs = options?.timeoutMs ?? this.defaultTimeoutMs;
const result = await new Promise((resolve, reject) => {
const timer = timeoutMs > 0 ? setTimeout(() => {
this.pending.delete(id);
reject(new Error(`CDP timeout: ${method}`));
}, timeoutMs) : null;
this.pending.set(id, { resolve, reject, timer });
this.ws.send(JSON.stringify(message));
});
return result;
}
close() {
try {
this.ws.close();
} catch {}
}
}
async function launchChrome(options) {
await fs.promises.mkdir(options.profileDir, { recursive: true });
const args = [
`--remote-debugging-port=${options.port}`,
`--user-data-dir=${options.profileDir}`,
"--no-first-run",
"--no-default-browser-check",
...options.extraArgs ?? []
];
if (options.headless)
args.push("--headless=new");
if (options.url)
args.push(options.url);
return spawn(options.chromePath, args, { stdio: "ignore" });
}
async function openPageSession(options) {
let targetId;
let createdTarget = false;
if (options.reusing) {
const created = await options.cdp.send("Target.createTarget", { url: options.url });
targetId = created.targetId;
createdTarget = true;
} else {
const targets = await options.cdp.send("Target.getTargets");
const existing = targets.targetInfos.find(options.matchTarget);
if (existing) {
targetId = existing.targetId;
} else {
const created = await options.cdp.send("Target.createTarget", { url: options.url });
targetId = created.targetId;
createdTarget = true;
}
}
const { sessionId } = await options.cdp.send("Target.attachToTarget", { targetId, flatten: true });
if (options.activateTarget ?? true) {
await options.cdp.send("Target.activateTarget", { targetId });
}
if (options.enablePage)
await options.cdp.send("Page.enable", {}, { sessionId });
if (options.enableRuntime)
await options.cdp.send("Runtime.enable", {}, { sessionId });
if (options.enableDom)
await options.cdp.send("DOM.enable", {}, { sessionId });
if (options.enableNetwork)
await options.cdp.send("Network.enable", {}, { sessionId });
return { sessionId, targetId, createdTarget };
}
// src/mermaid.ts
class MermaidRenderError extends Error {
constructor(message, options) {
super(message);
this.name = "MermaidRenderError";
if (options?.cause !== undefined) {
this.cause = options.cause;
}
}
}
function resolveRenderScale(scale) {
const resolved = scale ?? 2;
if (!Number.isFinite(resolved) || resolved <= 0) {
throw new MermaidRenderError(`Invalid Mermaid render scale: ${scale}`);
}
return resolved;
}
function resolveMinWidth(minWidth) {
if (minWidth === undefined)
return;
if (!Number.isFinite(minWidth) || minWidth <= 0) {
throw new MermaidRenderError(`Invalid Mermaid render minWidth: ${minWidth}`);
}
return minWidth;
}
var CHROME_CANDIDATES = {
darwin: [
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
"/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta",
"/Applications/Chromium.app/Contents/MacOS/Chromium",
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"
],
win32: [
"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
"C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
"C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe",
"C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe"
],
default: [
"/usr/bin/google-chrome",
"/usr/bin/google-chrome-stable",
"/usr/bin/chromium",
"/usr/bin/chromium-browser",
"/snap/bin/chromium",
"/usr/bin/microsoft-edge"
]
};
var wslHome;
function getWslWindowsHome() {
if (wslHome !== undefined)
return wslHome;
if (!process2.env.WSL_DISTRO_NAME) {
wslHome = null;
return null;
}
try {
const raw = execSync('cmd.exe /C "echo %USERPROFILE%"', {
encoding: "utf-8",
timeout: 5000
}).trim().replace(/\r/g, "");
wslHome = execSync(`wslpath -u "${raw}"`, {
encoding: "utf-8",
timeout: 5000
}).trim() || null;
} catch {
wslHome = null;
}
return wslHome;
}
function getProfileDir() {
return resolveSharedChromeProfileDir({
envNames: ["BAOYU_CHROME_PROFILE_DIR", "MERMAID_RENDER_PROFILE_DIR"],
wslWindowsHome: getWslWindowsHome()
});
}
function resolveAssetsDir() {
const here = fileURLToPath(import.meta.url);
const dir = path2.dirname(here);
const candidates = [
path2.resolve(dir, "..", "assets"),
path2.resolve(dir, "assets")
];
for (const candidate of candidates) {
if (fs2.existsSync(path2.join(candidate, "mermaid.min.js")))
return candidate;
}
throw new MermaidRenderError(`Cannot locate mermaid.min.js. Looked in: ${candidates.join(", ")}`);
}
var cachedMermaidScript = null;
function loadMermaidScript() {
if (cachedMermaidScript)
return cachedMermaidScript;
const assetsDir = resolveAssetsDir();
cachedMermaidScript = fs2.readFileSync(path2.join(assetsDir, "mermaid.min.js"), "utf-8");
return cachedMermaidScript;
}
var rendererState = null;
var connectingPromise = null;
var exitHookInstalled = false;
function installExitHook() {
if (exitHookInstalled)
return;
exitHookInstalled = true;
const cleanup = () => {
const state = rendererState;
rendererState = null;
if (!state)
return;
try {
state.cdp.close();
} catch {}
if (state.ownsChrome && state.chrome) {
try {
state.chrome.kill("SIGTERM");
} catch {}
}
};
process2.on("exit", cleanup);
process2.on("beforeExit", cleanup);
}
async function tryConnectExisting(port) {
try {
const wsUrl = await waitForChromeDebugPort(port, 5000, { includeLastError: true });
return await CdpConnection.connect(wsUrl, 5000);
} catch {
return null;
}
}
async function ensureRenderer() {
if (rendererState)
return rendererState;
if (connectingPromise)
return await connectingPromise;
connectingPromise = (async () => {
const profileDir = getProfileDir();
const existingPort = await findExistingChromeDebugPort({ profileDir });
if (existingPort) {
const cdp2 = await tryConnectExisting(existingPort);
if (cdp2) {
const state2 = {
cdp: cdp2,
chrome: null,
port: existingPort,
ownsChrome: false
};
rendererState = state2;
installExitHook();
return state2;
}
}
const chromePath = findChromeExecutable({
candidates: CHROME_CANDIDATES,
envNames: ["BAOYU_CHROME_PATH", "MERMAID_RENDER_CHROME_PATH"]
});
if (!chromePath) {
throw new MermaidRenderError("Chrome not found. Install Google Chrome / Chromium / Edge, or set BAOYU_CHROME_PATH.");
}
const port = await getFreePort("MERMAID_RENDER_DEBUG_PORT");
const chrome = await launchChrome({
chromePath,
profileDir,
port,
headless: true,
extraArgs: [
"--disable-blink-features=AutomationControlled",
"--disable-gpu",
"--hide-scrollbars"
]
});
const wsUrl = await waitForChromeDebugPort(port, 30000, { includeLastError: true });
const cdp = await CdpConnection.connect(wsUrl, 30000);
const state = { cdp, chrome, port, ownsChrome: true };
rendererState = state;
installExitHook();
return state;
})();
try {
return await connectingPromise;
} finally {
connectingPromise = null;
}
}
function buildHostHtml(code, theme, background) {
const script = loadMermaidScript();
const safeCode = code.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
const safeTheme = JSON.stringify(theme);
const cssBackground = background === "transparent" ? "transparent" : background;
return `<!doctype html>
<html>
<head>
<meta charset="utf-8">
<style>
html, body {
margin: 0;
padding: 0;
background: ${cssBackground};
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", "Hiragino Sans GB", "Noto Sans CJK SC", sans-serif;
}
#host {
display: inline-block;
padding: 16px;
}
#host svg {
max-width: none !important;
}
</style>
</head>
<body>
<div id="host"><div class="mermaid">${safeCode}</div></div>
<script>${script}</script>
<script>
window.__mermaidReady = false;
window.__mermaidError = null;
try {
mermaid.initialize({ startOnLoad: false, theme: ${safeTheme}, securityLevel: "loose" });
mermaid.run({ querySelector: ".mermaid" })
.then(function () { window.__mermaidReady = true; })
.catch(function (err) { window.__mermaidError = String(err && err.message || err); });
} catch (err) {
window.__mermaidError = String(err && err.message || err);
}
</script>
</body>
</html>`;
}
async function evaluate(cdp, sessionId, expression) {
const result = await cdp.send("Runtime.evaluate", {
expression,
returnByValue: true,
awaitPromise: false
}, { sessionId });
const exception = result.exceptionDetails;
if (exception) {
return {
value: undefined,
exceptionText: exception.exception?.description ?? exception.text ?? "evaluation failed"
};
}
return { value: result.result.value };
}
async function waitForMermaidSvg(cdp, sessionId, timeoutMs) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const status = await evaluate(cdp, sessionId, `(function () {
if (window.__mermaidError) {
return { ready: false, error: window.__mermaidError, rect: null };
}
var svg = document.querySelector(".mermaid svg");
if (!svg) return { ready: false, error: null, rect: null };
var bbox = svg.getBoundingClientRect();
return {
ready: window.__mermaidReady === true,
error: null,
rect: {
x: bbox.x,
y: bbox.y,
width: bbox.width,
height: bbox.height,
},
};
})()`);
if (status.exceptionText) {
throw new MermaidRenderError(`Mermaid evaluation failed: ${status.exceptionText}`);
}
const value = status.value;
if (value?.error) {
throw new MermaidRenderError(`Mermaid render failed: ${value.error}`);
}
if (value?.ready && value.rect && value.rect.width > 0 && value.rect.height > 0) {
const host = await evaluate(cdp, sessionId, `(function () {
var host = document.getElementById("host");
if (!host) return null;
var rect = host.getBoundingClientRect();
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
})()`);
if (host.value)
return host.value;
return value.rect;
}
await sleep(80);
}
throw new MermaidRenderError(`Mermaid render timed out after ${timeoutMs}ms`);
}
async function withPageSession(state, fn) {
let session = null;
try {
session = await openPageSession({
cdp: state.cdp,
reusing: !state.ownsChrome,
url: "about:blank",
matchTarget: () => false,
enablePage: true,
enableRuntime: true
});
return await fn(session.sessionId, session.targetId);
} finally {
if (session?.createdTarget) {
try {
await state.cdp.send("Target.closeTarget", { targetId: session.targetId });
} catch {}
}
}
}
async function renderMermaidToPng(code, outputPath, options = {}) {
const theme = options.theme ?? "default";
const scale = resolveRenderScale(options.scale);
const minWidth = resolveMinWidth(options.minWidth);
const background = options.background ?? "white";
const timeoutMs = options.timeoutMs ?? 15000;
if (!code.trim()) {
throw new MermaidRenderError("Mermaid code is empty");
}
await fs2.promises.mkdir(path2.dirname(outputPath), { recursive: true });
const state = await ensureRenderer();
const html = buildHostHtml(code, theme, background);
return await withPageSession(state, async (sessionId) => {
await state.cdp.send("Emulation.setDeviceMetricsOverride", {
width: 1280,
height: 800,
deviceScaleFactor: 1,
mobile: false
}, { sessionId });
await state.cdp.send("Page.setDocumentContent", { frameId: await getFrameId(state.cdp, sessionId), html }, { sessionId });
const rect = await waitForMermaidSvg(state.cdp, sessionId, timeoutMs);
const cssWidth = Math.max(1, Math.ceil(rect.width));
const cssHeight = Math.max(1, Math.ceil(rect.height));
const targetCssWidth = Math.max(cssWidth, Math.ceil(minWidth ?? cssWidth));
const captureScale = scale * (targetCssWidth / cssWidth);
const bitmapWidth = Math.max(1, Math.ceil(cssWidth * captureScale));
const bitmapHeight = Math.max(1, Math.ceil(cssHeight * captureScale));
await state.cdp.send("Emulation.setDeviceMetricsOverride", {
width: cssWidth,
height: cssHeight,
deviceScaleFactor: 1,
mobile: false
}, { sessionId });
const shot = await state.cdp.send("Page.captureScreenshot", {
format: "png",
clip: {
x: rect.x,
y: rect.y,
width: cssWidth,
height: cssHeight,
scale: captureScale
},
captureBeyondViewport: true
}, { sessionId });
const buffer = Buffer.from(shot.data, "base64");
await fs2.promises.writeFile(outputPath, buffer);
return {
width: bitmapWidth,
height: bitmapHeight,
bytes: buffer.length
};
});
}
async function getFrameId(cdp, sessionId) {
const result = await cdp.send("Page.getFrameTree", {}, { sessionId });
return result.frameTree.frame.id;
}
async function closeRenderer() {
const state = rendererState;
rendererState = null;
if (!state)
return;
try {
state.cdp.close();
} catch {}
if (state.ownsChrome && state.chrome) {
try {
state.chrome.kill("SIGTERM");
} catch {}
}
}
export {
renderMermaidToPng,
closeRenderer,
MermaidRenderError
};
+10 -2
View File
@@ -5,6 +5,7 @@
"files": [
"dist",
"src/**/*.ts",
"assets",
"!src/**/*.test.ts"
],
"exports": {
@@ -14,7 +15,14 @@
"require": "./dist/index.cjs",
"default": "./dist/index.js"
},
"./src/*": "./src/*"
"./mermaid": {
"types": "./src/mermaid.ts",
"import": "./dist/mermaid.js",
"require": "./dist/mermaid.cjs",
"default": "./dist/mermaid.js"
},
"./src/*": "./src/*",
"./assets/*": "./assets/*"
},
"description": "Shared Chrome DevTools Protocol utilities for baoyu skills.",
"main": "./dist/index.cjs",
@@ -33,7 +41,7 @@
"access": "public"
},
"scripts": {
"build": "node ../../scripts/build-shared-package.mjs",
"build": "node ../../scripts/build-shared-package.mjs --entry-out src/index.ts:index --entry-out src/mermaid.ts:mermaid --asset assets:assets",
"prepack": "bun run build"
},
"engines": {
+499
View File
@@ -0,0 +1,499 @@
import { execSync, type ChildProcess } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";
import {
CdpConnection,
findChromeExecutable,
findExistingChromeDebugPort,
getFreePort,
launchChrome,
openPageSession,
resolveSharedChromeProfileDir,
sleep,
waitForChromeDebugPort,
type PageSession,
type PlatformCandidates,
} from "./index.js";
export class MermaidRenderError extends Error {
constructor(message: string, options?: { cause?: unknown }) {
super(message);
this.name = "MermaidRenderError";
if (options?.cause !== undefined) {
(this as { cause?: unknown }).cause = options.cause;
}
}
}
export interface MermaidRenderOptions {
theme?: string;
scale?: number;
background?: string;
timeoutMs?: number;
minWidth?: number;
}
export interface MermaidRenderResult {
width: number;
height: number;
bytes: number;
}
function resolveRenderScale(scale: number | undefined): number {
const resolved = scale ?? 2;
if (!Number.isFinite(resolved) || resolved <= 0) {
throw new MermaidRenderError(`Invalid Mermaid render scale: ${scale}`);
}
return resolved;
}
function resolveMinWidth(minWidth: number | undefined): number | undefined {
if (minWidth === undefined) return undefined;
if (!Number.isFinite(minWidth) || minWidth <= 0) {
throw new MermaidRenderError(`Invalid Mermaid render minWidth: ${minWidth}`);
}
return minWidth;
}
const CHROME_CANDIDATES: PlatformCandidates = {
darwin: [
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
"/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta",
"/Applications/Chromium.app/Contents/MacOS/Chromium",
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
],
win32: [
"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
"C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
"C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe",
"C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe",
],
default: [
"/usr/bin/google-chrome",
"/usr/bin/google-chrome-stable",
"/usr/bin/chromium",
"/usr/bin/chromium-browser",
"/snap/bin/chromium",
"/usr/bin/microsoft-edge",
],
};
let wslHome: string | null | undefined;
function getWslWindowsHome(): string | null {
if (wslHome !== undefined) return wslHome;
if (!process.env.WSL_DISTRO_NAME) {
wslHome = null;
return null;
}
try {
const raw = execSync('cmd.exe /C "echo %USERPROFILE%"', {
encoding: "utf-8",
timeout: 5_000,
}).trim().replace(/\r/g, "");
wslHome = execSync(`wslpath -u "${raw}"`, {
encoding: "utf-8",
timeout: 5_000,
}).trim() || null;
} catch {
wslHome = null;
}
return wslHome;
}
function getProfileDir(): string {
return resolveSharedChromeProfileDir({
envNames: ["BAOYU_CHROME_PROFILE_DIR", "MERMAID_RENDER_PROFILE_DIR"],
wslWindowsHome: getWslWindowsHome(),
});
}
function resolveAssetsDir(): string {
const here = fileURLToPath(import.meta.url);
const dir = path.dirname(here);
const candidates = [
path.resolve(dir, "..", "assets"),
path.resolve(dir, "assets"),
];
for (const candidate of candidates) {
if (fs.existsSync(path.join(candidate, "mermaid.min.js"))) return candidate;
}
throw new MermaidRenderError(
`Cannot locate mermaid.min.js. Looked in: ${candidates.join(", ")}`,
);
}
let cachedMermaidScript: string | null = null;
function loadMermaidScript(): string {
if (cachedMermaidScript) return cachedMermaidScript;
const assetsDir = resolveAssetsDir();
cachedMermaidScript = fs.readFileSync(path.join(assetsDir, "mermaid.min.js"), "utf-8");
return cachedMermaidScript;
}
interface RendererState {
cdp: CdpConnection;
chrome: ChildProcess | null;
port: number | null;
ownsChrome: boolean;
}
let rendererState: RendererState | null = null;
let connectingPromise: Promise<RendererState> | null = null;
let exitHookInstalled = false;
function installExitHook(): void {
if (exitHookInstalled) return;
exitHookInstalled = true;
const cleanup = () => {
const state = rendererState;
rendererState = null;
if (!state) return;
try { state.cdp.close(); } catch {}
if (state.ownsChrome && state.chrome) {
try { state.chrome.kill("SIGTERM"); } catch {}
}
};
process.on("exit", cleanup);
process.on("beforeExit", cleanup);
}
async function tryConnectExisting(port: number): Promise<CdpConnection | null> {
try {
const wsUrl = await waitForChromeDebugPort(port, 5_000, { includeLastError: true });
return await CdpConnection.connect(wsUrl, 5_000);
} catch {
return null;
}
}
async function ensureRenderer(): Promise<RendererState> {
if (rendererState) return rendererState;
if (connectingPromise) return await connectingPromise;
connectingPromise = (async (): Promise<RendererState> => {
const profileDir = getProfileDir();
const existingPort = await findExistingChromeDebugPort({ profileDir });
if (existingPort) {
const cdp = await tryConnectExisting(existingPort);
if (cdp) {
const state: RendererState = {
cdp,
chrome: null,
port: existingPort,
ownsChrome: false,
};
rendererState = state;
installExitHook();
return state;
}
}
const chromePath = findChromeExecutable({
candidates: CHROME_CANDIDATES,
envNames: ["BAOYU_CHROME_PATH", "MERMAID_RENDER_CHROME_PATH"],
});
if (!chromePath) {
throw new MermaidRenderError(
"Chrome not found. Install Google Chrome / Chromium / Edge, or set BAOYU_CHROME_PATH.",
);
}
const port = await getFreePort("MERMAID_RENDER_DEBUG_PORT");
const chrome = await launchChrome({
chromePath,
profileDir,
port,
headless: true,
extraArgs: [
"--disable-blink-features=AutomationControlled",
"--disable-gpu",
"--hide-scrollbars",
],
});
const wsUrl = await waitForChromeDebugPort(port, 30_000, { includeLastError: true });
const cdp = await CdpConnection.connect(wsUrl, 30_000);
const state: RendererState = { cdp, chrome, port, ownsChrome: true };
rendererState = state;
installExitHook();
return state;
})();
try {
return await connectingPromise;
} finally {
connectingPromise = null;
}
}
function buildHostHtml(code: string, theme: string, background: string): string {
const script = loadMermaidScript();
const safeCode = code
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
const safeTheme = JSON.stringify(theme);
const cssBackground = background === "transparent" ? "transparent" : background;
return `<!doctype html>
<html>
<head>
<meta charset="utf-8">
<style>
html, body {
margin: 0;
padding: 0;
background: ${cssBackground};
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", "Hiragino Sans GB", "Noto Sans CJK SC", sans-serif;
}
#host {
display: inline-block;
padding: 16px;
}
#host svg {
max-width: none !important;
}
</style>
</head>
<body>
<div id="host"><div class="mermaid">${safeCode}</div></div>
<script>${script}</script>
<script>
window.__mermaidReady = false;
window.__mermaidError = null;
try {
mermaid.initialize({ startOnLoad: false, theme: ${safeTheme}, securityLevel: "loose" });
mermaid.run({ querySelector: ".mermaid" })
.then(function () { window.__mermaidReady = true; })
.catch(function (err) { window.__mermaidError = String(err && err.message || err); });
} catch (err) {
window.__mermaidError = String(err && err.message || err);
}
</script>
</body>
</html>`;
}
async function evaluate<T = unknown>(
cdp: CdpConnection,
sessionId: string,
expression: string,
): Promise<{ value: T | undefined; exceptionText?: string }> {
const result = await cdp.send<{
result: { value?: T };
exceptionDetails?: { text?: string; exception?: { description?: string } };
}>(
"Runtime.evaluate",
{
expression,
returnByValue: true,
awaitPromise: false,
},
{ sessionId },
);
const exception = result.exceptionDetails;
if (exception) {
return {
value: undefined,
exceptionText: exception.exception?.description ?? exception.text ?? "evaluation failed",
};
}
return { value: result.result.value };
}
interface BoundingRect {
x: number;
y: number;
width: number;
height: number;
}
async function waitForMermaidSvg(
cdp: CdpConnection,
sessionId: string,
timeoutMs: number,
): Promise<BoundingRect> {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const status = await evaluate<{
ready: boolean;
error: string | null;
rect: BoundingRect | null;
}>(
cdp,
sessionId,
`(function () {
if (window.__mermaidError) {
return { ready: false, error: window.__mermaidError, rect: null };
}
var svg = document.querySelector(".mermaid svg");
if (!svg) return { ready: false, error: null, rect: null };
var bbox = svg.getBoundingClientRect();
return {
ready: window.__mermaidReady === true,
error: null,
rect: {
x: bbox.x,
y: bbox.y,
width: bbox.width,
height: bbox.height,
},
};
})()`,
);
if (status.exceptionText) {
throw new MermaidRenderError(`Mermaid evaluation failed: ${status.exceptionText}`);
}
const value = status.value;
if (value?.error) {
throw new MermaidRenderError(`Mermaid render failed: ${value.error}`);
}
if (value?.ready && value.rect && value.rect.width > 0 && value.rect.height > 0) {
const host = await evaluate<BoundingRect | null>(
cdp,
sessionId,
`(function () {
var host = document.getElementById("host");
if (!host) return null;
var rect = host.getBoundingClientRect();
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
})()`,
);
if (host.value) return host.value;
return value.rect;
}
await sleep(80);
}
throw new MermaidRenderError(`Mermaid render timed out after ${timeoutMs}ms`);
}
async function withPageSession<T>(
state: RendererState,
fn: (sessionId: string, targetId: string) => Promise<T>,
): Promise<T> {
let session: PageSession | null = null;
try {
session = await openPageSession({
cdp: state.cdp,
reusing: !state.ownsChrome,
url: "about:blank",
matchTarget: () => false,
enablePage: true,
enableRuntime: true,
});
return await fn(session.sessionId, session.targetId);
} finally {
if (session?.createdTarget) {
try {
await state.cdp.send("Target.closeTarget", { targetId: session.targetId });
} catch {}
}
}
}
export async function renderMermaidToPng(
code: string,
outputPath: string,
options: MermaidRenderOptions = {},
): Promise<MermaidRenderResult> {
const theme = options.theme ?? "default";
const scale = resolveRenderScale(options.scale);
const minWidth = resolveMinWidth(options.minWidth);
const background = options.background ?? "white";
const timeoutMs = options.timeoutMs ?? 15_000;
if (!code.trim()) {
throw new MermaidRenderError("Mermaid code is empty");
}
await fs.promises.mkdir(path.dirname(outputPath), { recursive: true });
const state = await ensureRenderer();
const html = buildHostHtml(code, theme, background);
return await withPageSession(state, async (sessionId) => {
await state.cdp.send(
"Emulation.setDeviceMetricsOverride",
{
width: 1280,
height: 800,
deviceScaleFactor: 1,
mobile: false,
},
{ sessionId },
);
await state.cdp.send(
"Page.setDocumentContent",
{ frameId: await getFrameId(state.cdp, sessionId), html },
{ sessionId },
);
const rect = await waitForMermaidSvg(state.cdp, sessionId, timeoutMs);
const cssWidth = Math.max(1, Math.ceil(rect.width));
const cssHeight = Math.max(1, Math.ceil(rect.height));
const targetCssWidth = Math.max(cssWidth, Math.ceil(minWidth ?? cssWidth));
const captureScale = scale * (targetCssWidth / cssWidth);
const bitmapWidth = Math.max(1, Math.ceil(cssWidth * captureScale));
const bitmapHeight = Math.max(1, Math.ceil(cssHeight * captureScale));
await state.cdp.send(
"Emulation.setDeviceMetricsOverride",
{
width: cssWidth,
height: cssHeight,
deviceScaleFactor: 1,
mobile: false,
},
{ sessionId },
);
const shot = await state.cdp.send<{ data: string }>(
"Page.captureScreenshot",
{
format: "png",
clip: {
x: rect.x,
y: rect.y,
width: cssWidth,
height: cssHeight,
scale: captureScale,
},
captureBeyondViewport: true,
},
{ sessionId },
);
const buffer = Buffer.from(shot.data, "base64");
await fs.promises.writeFile(outputPath, buffer);
return {
width: bitmapWidth,
height: bitmapHeight,
bytes: buffer.length,
};
});
}
async function getFrameId(cdp: CdpConnection, sessionId: string): Promise<string> {
const result = await cdp.send<{ frameTree: { frame: { id: string } } }>(
"Page.getFrameTree",
{},
{ sessionId },
);
return result.frameTree.frame.id;
}
export async function closeRenderer(): Promise<void> {
const state = rendererState;
rendererState = null;
if (!state) return;
try { state.cdp.close(); } catch {}
if (state.ownsChrome && state.chrome) {
try { state.chrome.kill("SIGTERM"); } catch {}
}
}
+205 -5120
View File
File diff suppressed because one or more lines are too long
+205 -5120
View File
File diff suppressed because one or more lines are too long
+2
View File
@@ -5,6 +5,8 @@ export * from "./document.js";
export * from "./extend-config.js";
export * from "./html-builder.js";
export * from "./images.js";
export * from "./mermaid-preprocess.js";
export * from "./mermaid-utils.js";
export * from "./renderer.js";
export * from "./themes.js";
export * from "./types.js";
@@ -0,0 +1,134 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import {
preprocessMermaidInMarkdown,
type MermaidRenderFn,
} from "./mermaid-preprocess.ts";
function withTempDir<T>(fn: (dir: string) => Promise<T>): Promise<T> {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "mermaid-preprocess-test-"));
return fn(dir).finally(() => {
fs.rmSync(dir, { recursive: true, force: true });
});
}
const stubPngBytes = Buffer.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
]);
const stubRender: MermaidRenderFn = async (_code, outPath) => {
await fs.promises.mkdir(path.dirname(outPath), { recursive: true });
await fs.promises.writeFile(outPath, stubPngBytes);
};
test("preprocessMermaidInMarkdown skips when disabled", async () => {
await withTempDir(async (baseDir) => {
const markdown = "```mermaid\ngraph TD\nA-->B\n```";
const result = await preprocessMermaidInMarkdown(markdown, {
baseDir,
renderFn: stubRender,
enabled: false,
});
assert.equal(result.markdown, markdown);
assert.equal(result.images.length, 0);
});
});
test("preprocessMermaidInMarkdown skips when renderFn is missing", async () => {
await withTempDir(async (baseDir) => {
const markdown = "```mermaid\ngraph TD\nA-->B\n```";
const result = await preprocessMermaidInMarkdown(markdown, { baseDir });
assert.equal(result.markdown, markdown);
assert.equal(result.images.length, 0);
});
});
test("preprocessMermaidInMarkdown deduplicates identical blocks via hashed cache", async () => {
await withTempDir(async (baseDir) => {
const block = "```mermaid\ngraph TD\nA-->B\n```";
const markdown = `${block}\n\nsome text\n\n${block}\n\nother text\n\n\`\`\`mermaid\nflowchart LR\nX-->Y\n\`\`\``;
let renderCalls = 0;
const renderFn: MermaidRenderFn = async (code, outPath) => {
renderCalls += 1;
await stubRender(code, outPath, {});
};
const result = await preprocessMermaidInMarkdown(markdown, {
baseDir,
renderFn,
});
assert.equal(renderCalls, 2, "should render two distinct blocks");
assert.equal(result.images.length, 3, "all three blocks produce image entries");
const uniqueHashes = new Set(result.images.map((image) => image.hash));
assert.equal(uniqueHashes.size, 2);
const matches = result.markdown.match(/!\[Mermaid diagram\]/g) ?? [];
assert.equal(matches.length, 3);
assert.ok(!result.markdown.includes("```mermaid"));
});
});
test("preprocessMermaidInMarkdown reuses cached files (cached=true when file exists)", async () => {
await withTempDir(async (baseDir) => {
const markdown = "```mermaid\ngraph TD\nA-->B\n```";
let renderCalls = 0;
const renderFn: MermaidRenderFn = async (code, outPath) => {
renderCalls += 1;
await stubRender(code, outPath, {});
};
const first = await preprocessMermaidInMarkdown(markdown, { baseDir, renderFn });
assert.equal(renderCalls, 1);
assert.equal(first.images[0]!.cached, false);
const second = await preprocessMermaidInMarkdown(markdown, { baseDir, renderFn });
assert.equal(renderCalls, 1, "second pass hits cache");
assert.equal(second.images[0]!.cached, true);
});
});
test("preprocessMermaidInMarkdown survives renderFn errors and keeps raw block", async () => {
await withTempDir(async (baseDir) => {
const markdown = "```mermaid\ninvalid syntax!!\n```\n\nrest";
const errors: string[] = [];
const failingRender: MermaidRenderFn = async () => {
throw new Error("render boom");
};
const result = await preprocessMermaidInMarkdown(markdown, {
baseDir,
renderFn: failingRender,
onError: (error) => {
errors.push(error instanceof Error ? error.message : String(error));
},
});
assert.equal(errors.length, 1);
assert.equal(errors[0], "render boom");
assert.equal(result.images.length, 0);
assert.ok(result.markdown.includes("```mermaid"));
assert.ok(result.markdown.includes("rest"));
});
});
test("preprocessMermaidInMarkdown writes PNGs under imgs/.mermaid-cache/", async () => {
await withTempDir(async (baseDir) => {
const markdown = "```mermaid\ngraph TD\nA-->B\n```";
const result = await preprocessMermaidInMarkdown(markdown, {
baseDir,
renderFn: stubRender,
});
assert.equal(result.images.length, 1);
const image = result.images[0]!;
assert.ok(image.localPath.includes(path.join("imgs", ".mermaid-cache")));
assert.ok(image.mdRef.includes("imgs/.mermaid-cache/"));
assert.ok(fs.existsSync(image.localPath));
});
});
+131
View File
@@ -0,0 +1,131 @@
import fs from "node:fs";
import path from "node:path";
import {
MERMAID_VERSION,
extractMermaidBlocks,
hashMermaidCode,
replaceMermaidBlocks,
type MermaidBlock,
} from "./mermaid-utils.js";
export interface MermaidRenderOptions {
theme?: string;
scale?: number;
background?: string;
minWidth?: number;
}
export type MermaidRenderFn = (
code: string,
outputPath: string,
options: MermaidRenderOptions,
) => Promise<void>;
export interface MermaidPreprocessOptions extends MermaidRenderOptions {
baseDir: string;
imgSubdir?: string;
renderFn?: MermaidRenderFn;
enabled?: boolean;
alt?: string;
onError?: (error: unknown, block: MermaidBlock) => void;
}
export interface MermaidPreprocessedImage {
raw: string;
code: string;
hash: string;
localPath: string;
mdRef: string;
cached: boolean;
}
export interface MermaidPreprocessResult {
markdown: string;
images: MermaidPreprocessedImage[];
}
export async function preprocessMermaidInMarkdown(
markdown: string,
options: MermaidPreprocessOptions,
): Promise<MermaidPreprocessResult> {
const {
baseDir,
imgSubdir = "imgs/.mermaid-cache",
renderFn,
enabled = true,
theme,
scale,
background,
minWidth,
alt = "Mermaid diagram",
onError,
} = options;
if (!enabled || !renderFn) {
return { markdown, images: [] };
}
const blocks = extractMermaidBlocks(markdown);
if (blocks.length === 0) {
return { markdown, images: [] };
}
const cacheDir = path.resolve(baseDir, imgSubdir);
fs.mkdirSync(cacheDir, { recursive: true });
const replacements = new Map<string, string>();
const images: MermaidPreprocessedImage[] = [];
const renderedHashes = new Set<string>();
for (const block of blocks) {
const hash = hashMermaidCode({
code: block.code,
theme,
scale,
background,
minWidth,
version: MERMAID_VERSION,
});
const filename = `mermaid-${hash}.png`;
const localPath = path.join(cacheDir, filename);
const mdRef = `![${alt}](${path.posix.join(imgSubdir, filename)})`;
const cached = fs.existsSync(localPath);
if (!cached && !renderedHashes.has(hash)) {
try {
await renderFn(block.code, localPath, { theme, scale, background, minWidth });
renderedHashes.add(hash);
} catch (error) {
if (onError) {
onError(error, block);
} else {
console.error(
`[mermaid] render failed for block (hash ${hash}): ${
error instanceof Error ? error.message : String(error)
}`,
);
}
continue;
}
}
if (!fs.existsSync(localPath)) {
continue;
}
replacements.set(block.raw, mdRef);
images.push({
raw: block.raw,
code: block.code,
hash,
localPath,
mdRef,
cached,
});
}
const newMarkdown = replaceMermaidBlocks(markdown, replacements);
return { markdown: newMarkdown, images };
}
+115
View File
@@ -0,0 +1,115 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
MERMAID_VERSION,
extractMermaidBlocks,
hashMermaidCode,
replaceMermaidBlocks,
} from "./mermaid-utils.ts";
test("extractMermaidBlocks finds fenced mermaid blocks at the top level", () => {
const markdown = `Intro
\`\`\`mermaid
graph TD
A --> B
\`\`\`
Outro`;
const blocks = extractMermaidBlocks(markdown);
assert.equal(blocks.length, 1);
assert.equal(blocks[0]!.code.trim(), "graph TD\n A --> B");
assert.equal(blocks[0]!.infoString, "");
assert.ok(blocks[0]!.raw.includes("```mermaid"));
});
test("extractMermaidBlocks preserves info-string suffixes", () => {
const markdown = "```mermaid theme=dark\nflowchart LR\n A --> B\n```";
const blocks = extractMermaidBlocks(markdown);
assert.equal(blocks.length, 1);
assert.equal(blocks[0]!.infoString, "theme=dark");
});
test("extractMermaidBlocks finds blocks nested inside lists", () => {
const markdown = `Steps:
1. First, render the diagram:
\`\`\`mermaid
sequenceDiagram
Alice->>Bob: Hello
\`\`\`
2. Then do something else.`;
const blocks = extractMermaidBlocks(markdown);
assert.equal(blocks.length, 1);
assert.equal(blocks[0]!.code.includes("sequenceDiagram"), true);
});
test("extractMermaidBlocks ignores non-mermaid fences and empty blocks", () => {
const markdown = `\`\`\`ts
const x = 1;
\`\`\`
\`\`\`mermaid
\`\`\`
\`\`\`mermaidsomething
not a real lang
\`\`\``;
const blocks = extractMermaidBlocks(markdown);
assert.equal(blocks.length, 1);
assert.equal(blocks[0]!.infoString, "something");
assert.equal(blocks[0]!.code.trim(), "not a real lang");
});
test("replaceMermaidBlocks performs exact string replacement", () => {
const markdown = "before\n\n```mermaid\ngraph TD\nA-->B\n```\n\nafter";
const blocks = extractMermaidBlocks(markdown);
const map = new Map([[blocks[0]!.raw, "![diagram](img.png)"]]);
const replaced = replaceMermaidBlocks(markdown, map);
assert.equal(replaced, "before\n\n![diagram](img.png)\n\nafter");
});
test("replaceMermaidBlocks leaves markdown unchanged when no replacements match", () => {
const markdown = "hello\n\nworld";
const replaced = replaceMermaidBlocks(markdown, new Map([["nope", "x"]]));
assert.equal(replaced, markdown);
});
test("hashMermaidCode is stable for the same inputs", () => {
const a = hashMermaidCode({ code: "graph TD\nA-->B" });
const b = hashMermaidCode({ code: "graph TD\nA-->B" });
assert.equal(a, b);
assert.equal(a.length, 12);
});
test("hashMermaidCode defaults to 2x render scale", () => {
const implicit = hashMermaidCode({ code: "graph TD\nA-->B" });
const explicit = hashMermaidCode({ code: "graph TD\nA-->B", scale: 2 });
assert.equal(implicit, explicit);
});
test("hashMermaidCode ignores trailing whitespace", () => {
const a = hashMermaidCode({ code: "graph TD\nA-->B" });
const b = hashMermaidCode({ code: "graph TD\nA-->B \n\n" });
assert.equal(a, b);
});
test("hashMermaidCode reflects theme/scale/background/version changes", () => {
const base = hashMermaidCode({ code: "graph TD\nA-->B" });
assert.notEqual(base, hashMermaidCode({ code: "graph TD\nA-->B", theme: "dark" }));
assert.notEqual(base, hashMermaidCode({ code: "graph TD\nA-->B", scale: 3 }));
assert.notEqual(base, hashMermaidCode({ code: "graph TD\nA-->B", minWidth: 860 }));
assert.notEqual(base, hashMermaidCode({ code: "graph TD\nA-->B", background: "#000" }));
assert.notEqual(base, hashMermaidCode({ code: "graph TD\nA-->B", version: "x.y.z" }));
});
test("MERMAID_VERSION matches the vendored bundle (10.x)", () => {
assert.match(MERMAID_VERSION, /^10\./);
});
+74
View File
@@ -0,0 +1,74 @@
import { createHash } from "node:crypto";
import { Marked, type Tokens } from "marked";
export const MERMAID_VERSION = "10.9.1";
export interface MermaidBlock {
raw: string;
code: string;
infoString: string;
}
export interface HashMermaidInput {
code: string;
theme?: string;
scale?: number;
background?: string;
minWidth?: number;
version?: string;
}
export function extractMermaidBlocks(markdown: string): MermaidBlock[] {
const blocks: MermaidBlock[] = [];
const lexer = new Marked({ breaks: true });
const tokens = lexer.lexer(markdown);
walkTokens(tokens, (token) => {
if (token.type !== "code") return;
const codeToken = token as Tokens.Code;
const lang = (codeToken.lang ?? "").trim();
if (!lang.startsWith("mermaid")) return;
const infoString = lang.slice("mermaid".length).trim();
const code = codeToken.text ?? "";
if (code.trim() === "") return;
blocks.push({
raw: codeToken.raw,
code,
infoString,
});
});
return blocks;
}
export function replaceMermaidBlocks(
markdown: string,
replacements: Map<string, string>,
): string {
let result = markdown;
for (const [raw, replacement] of replacements) {
if (!raw || replacement === undefined) continue;
result = result.split(raw).join(replacement);
}
return result;
}
export function hashMermaidCode(input: HashMermaidInput): string {
const payload = JSON.stringify({
code: input.code.replace(/\s+$/g, ""),
theme: input.theme ?? "default",
scale: input.scale ?? 2,
minWidth: input.minWidth ?? null,
background: input.background ?? "white",
version: input.version ?? MERMAID_VERSION,
});
return createHash("sha256").update(payload).digest("hex").slice(0, 12);
}
type AnyToken = { type?: string; tokens?: AnyToken[]; items?: AnyToken[] };
function walkTokens(tokens: AnyToken[], visit: (token: AnyToken) => void): void {
for (const token of tokens) {
visit(token);
if (Array.isArray(token.tokens)) walkTokens(token.tokens, visit);
if (Array.isArray(token.items)) walkTokens(token.items, visit);
}
}
+1 -15
View File
@@ -119,7 +119,6 @@ function wrapInlineCode(value: string): string {
export function initRenderer(opts: IOpts = {}): RendererAPI {
const footnotes: [number, string, string][] = [];
let footnoteIndex = 0;
let codeIndex = 0;
const listOrderedStack: boolean[] = [];
const listCounters: number[] = [];
const isBrowser = typeof window !== "undefined";
@@ -208,20 +207,7 @@ export function initRenderer(opts: IOpts = {}): RendererAPI {
code({ text, lang = "" }: Tokens.Code): string {
if (lang.startsWith("mermaid")) {
if (isBrowser) {
clearTimeout(codeIndex as any);
codeIndex = setTimeout(async () => {
const windowRef = typeof window !== "undefined" ? (window as any) : undefined;
if (windowRef && windowRef.mermaid) {
const mermaid = windowRef.mermaid;
await mermaid.run();
} else {
const mermaid = await import("mermaid");
await mermaid.default.run();
}
}, 0) as any as number;
}
return `<pre class="mermaid">${text}</pre>`;
return `<pre class="mermaid">${escapeHtml(text)}</pre>`;
}
const langText = lang.split(" ")[0];
const isLanguageRegistered = hljs.getLanguage(langText);
+41 -21
View File
@@ -9,27 +9,33 @@ import { pathToFileURL } from "node:url";
async function main() {
const options = parseArgs(process.argv.slice(2));
const packageDir = path.resolve(options.packageDir);
const entryPath = path.resolve(packageDir, options.entry);
const outDir = path.resolve(packageDir, options.outDir);
await fs.rm(outDir, { recursive: true, force: true });
await fs.mkdir(outDir, { recursive: true });
const bun = resolveBunRuntime();
runBuild(bun, {
entryPath,
external: options.external,
format: "esm",
outfile: path.join(outDir, "index.js"),
});
const cjsOutfile = path.join(outDir, "index.cjs");
runBuild(bun, {
entryPath,
external: options.external,
format: "cjs",
outfile: cjsOutfile,
});
await scrubCommonJsSourceFileUrls(cjsOutfile, packageDir);
const entries = options.entries.length > 0
? options.entries
: [{ source: options.entry, name: "index" }];
for (const entry of entries) {
const entryPath = path.resolve(packageDir, entry.source);
runBuild(bun, {
entryPath,
external: options.external,
format: "esm",
outfile: path.join(outDir, `${entry.name}.js`),
});
const cjsOutfile = path.join(outDir, `${entry.name}.cjs`);
runBuild(bun, {
entryPath,
external: options.external,
format: "cjs",
outfile: cjsOutfile,
});
await scrubCommonJsSourceFileUrls(cjsOutfile, packageDir);
}
for (const asset of options.assets) {
const source = path.resolve(packageDir, asset.source);
@@ -45,6 +51,7 @@ function parseArgs(argv) {
outDir: "dist",
external: [],
assets: [],
entries: [],
};
for (let index = 0; index < argv.length; index += 1) {
@@ -57,6 +64,10 @@ function parseArgs(argv) {
options.entry = readArgValue(argv, ++index, arg);
continue;
}
if (arg === "--entry-out") {
options.entries.push(parseEntryOutArg(readArgValue(argv, ++index, arg)));
continue;
}
if (arg === "--out-dir") {
options.outDir = readArgValue(argv, ++index, arg);
continue;
@@ -79,6 +90,14 @@ function parseArgs(argv) {
return options;
}
function parseEntryOutArg(value) {
const [source, name] = value.split(":");
if (!source || !name) {
throw new Error(`Invalid --entry-out value: ${value} (expected <source>:<name>)`);
}
return { source, name };
}
function readArgValue(argv, index, flag) {
const value = argv[index];
if (!value) {
@@ -153,12 +172,13 @@ function printUsage() {
console.log(`Usage: build-shared-package.mjs [options]
Options:
--package-dir <dir> Package root (default: current directory)
--entry <file> Entry file relative to package root (default: src/index.ts)
--out-dir <dir> Output directory relative to package root (default: dist)
--external <name> Leave a module external (can be repeated)
--asset <src[:dst]> Copy an asset directory/file into out-dir (can be repeated)
-h, --help Show help`);
--package-dir <dir> Package root (default: current directory)
--entry <file> Entry file relative to package root (default: src/index.ts)
--entry-out <src:name> Add an entry that emits <name>.js / <name>.cjs (repeatable)
--out-dir <dir> Output directory relative to package root (default: dist)
--external <name> Leave a module external (can be repeated)
--asset <src[:dst]> Copy an asset directory/file into out-dir (can be repeated)
-h, --help Show help`);
}
main().catch((error) => {
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: baoyu-article-illustrator
description: Analyzes article structure, identifies positions requiring visual aids, generates illustrations with Type × Style × Palette three-dimension approach. Use when user asks to "illustrate article", "add images", "generate images for article", or "为文章配图".
version: 1.59.1
version: 1.117.3
metadata:
openclaw:
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-article-illustrator
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: baoyu-comic
description: Knowledge comic creator supporting multiple art styles and tones. Creates original educational comics with detailed panel layouts and batch-capable image generation. Use when user asks to create "知识漫画", "教育漫画", "biography comic", "tutorial comic", or "Logicomix-style comic".
version: 1.57.1
version: 1.117.3
metadata:
openclaw:
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-comic
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: baoyu-infographic
description: Generate professional infographics with 21 layout types and 22 visual styles. Analyzes content, recommends layout×style combinations, and generates publication-ready infographics. Use when user asks to create "infographic", "信息图", "visual summary", "可视化", or "高密度信息大图".
version: 1.58.1
version: 1.117.3
metadata:
openclaw:
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-infographic
+18 -4
View File
@@ -1,7 +1,7 @@
---
name: baoyu-markdown-to-html
description: Converts Markdown to styled HTML with WeChat-compatible themes. Supports code highlighting, math, PlantUML, footnotes, alerts, infographics, and optional bottom citations for external links. Use when user asks for "markdown to html", "convert md to html", "md 转 html", "微信外链转底部引用", or needs styled HTML output from markdown.
version: 1.56.1
description: Converts Markdown to styled HTML with WeChat-compatible themes. Supports code highlighting, math, Mermaid (rendered to PNG via headless Chrome), PlantUML, footnotes, alerts, infographics, and optional bottom citations for external links. Use when user asks for "markdown to html", "convert md to html", "md 转 html", "微信外链转底部引用", or needs styled HTML output from markdown.
version: 1.57.0
metadata:
openclaw:
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-markdown-to-html
@@ -45,7 +45,7 @@ Check EXTEND.md in priority order — the first one found wins:
If none found, use defaults.
**EXTEND.md supports**: default theme, custom CSS variables, code block style.
**EXTEND.md supports**: default theme, custom CSS variables, code block style, mermaid defaults (`mermaid_theme`, `mermaid_scale`, `mermaid_background`).
## Workflow
@@ -124,6 +124,11 @@ ${BUN_X} {baseDir}/scripts/main.ts <markdown_file> [options]
| `--title <title>` | Override title from frontmatter | |
| `--cite` | Convert external links to bottom citations, append `引用链接` section | false (off) |
| `--keep-title` | Keep the first heading in content | false (removed) |
| `--mermaid-theme <name>` | Mermaid theme: `default`, `forest`, `dark`, `neutral`, `base` | default |
| `--mermaid-scale <N>` | Mermaid render scale (positive number ≤ 4) | 2 |
| `--mermaid-width <N>` | Mermaid target display width in CSS px; PNG is rendered at `width × scale` pixels when the diagram is narrower than this | 860 |
| `--mermaid-bg <value>` | Mermaid background: `white`, `transparent`, or `#hex` | white |
| `--no-mermaid` | Skip Mermaid PNG rendering; emit `<pre class="mermaid">` fallback | false |
| `--help` | Show help | |
**Color Presets:**
@@ -190,10 +195,19 @@ ${BUN_X} {baseDir}/scripts/main.ts article.md --title "My Article"
"localPath": "/path/to/img.png",
"originalPath": "imgs/image.png"
}
],
"mermaidImages": [
{
"hash": "a1b2c3d4e5f6",
"localPath": "/path/to/imgs/.mermaid-cache/mermaid-a1b2c3d4e5f6.png",
"cached": false
}
]
}
```
**Mermaid rendering**: Code blocks fenced as ` ```mermaid ` are rendered to PNGs via headless Chrome (CDP) and cached at `imgs/.mermaid-cache/mermaid-<hash>.png`. The cache key includes the code, theme, scale, target width, background, and mermaid version. Add `imgs/.mermaid-cache/` to `.gitignore` if you do not want generated diagrams checked in. Requires Chrome/Chromium/Edge on the system; otherwise the block falls back to `<pre class="mermaid">…</pre>` and conversion still succeeds.
## Themes
| Theme | Description |
@@ -219,7 +233,7 @@ ${BUN_X} {baseDir}/scripts/main.ts article.md --title "My Article"
| Alerts | `> [!NOTE]`, `> [!WARNING]`, etc. |
| Footnotes | `[^1]` references |
| Ruby text | `{base|annotation}` |
| Mermaid | ` ```mermaid ` diagrams |
| Mermaid | ` ```mermaid ` blocks rendered to local PNG via headless Chrome (cached under `imgs/.mermaid-cache/`); falls back to `<pre class="mermaid">` if Chrome is unavailable or rendering fails |
| PlantUML | ` ```plantuml ` diagrams |
## Frontmatter
+129 -6
View File
@@ -13,6 +13,7 @@ import {
formatTimestamp,
parseArgs,
parseFrontmatter,
preprocessMermaidInMarkdown,
renderMarkdownDocument,
replaceMarkdownImagesWithPlaceholders,
resolveContentImages,
@@ -20,6 +21,7 @@ import {
stripWrappingQuotes,
} from "baoyu-md";
import type { CliOptions } from "baoyu-md";
import { closeRenderer, renderMermaidToPng } from "baoyu-chrome-cdp/mermaid";
interface ImageInfo {
placeholder: string;
@@ -27,6 +29,12 @@ interface ImageInfo {
originalPath: string;
}
interface MermaidImageInfo {
hash: string;
localPath: string;
cached: boolean;
}
interface ParsedResult {
title: string;
author: string;
@@ -34,10 +42,20 @@ interface ParsedResult {
htmlPath: string;
backupPath?: string;
contentImages: ImageInfo[];
mermaidImages: MermaidImageInfo[];
}
interface MermaidCliOptions {
enabled?: boolean;
theme?: string;
scale?: number;
background?: string;
minWidth?: number;
}
type ConvertMarkdownOptions = Partial<Omit<CliOptions, "inputPath">> & {
title?: string;
mermaid?: MermaidCliOptions;
};
export async function convertMarkdown(
@@ -70,8 +88,34 @@ export async function convertMarkdown(
? { ...frontmatter, title }
: frontmatter;
const mermaidEnabled = options?.mermaid?.enabled !== false;
const mermaidMinWidth = options?.mermaid?.minWidth ?? 860;
const { markdown: mermaidProcessedBody, images: mermaidImages } =
await preprocessMermaidInMarkdown(body, {
baseDir,
renderFn: renderMermaidToPng,
enabled: mermaidEnabled,
theme: options?.mermaid?.theme,
scale: options?.mermaid?.scale,
background: options?.mermaid?.background,
minWidth: mermaidMinWidth,
onError: (error, block) => {
const message = error instanceof Error ? error.message : String(error);
console.error(
`[markdown-to-html] mermaid render failed (${block.code.slice(0, 40).replace(/\s+/g, " ")}…): ${message}`,
);
},
});
if (mermaidImages.length > 0) {
const fresh = mermaidImages.filter((image) => !image.cached).length;
console.error(
`[markdown-to-html] mermaid: ${mermaidImages.length} block(s), ${fresh} rendered, ${mermaidImages.length - fresh} cached`,
);
}
const { images, markdown: rewrittenBody } = replaceMarkdownImagesWithPlaceholders(
body,
mermaidProcessedBody,
"MDTOHTMLIMGPH_",
);
const rewrittenMarkdown = `${serializeFrontmatter(effectiveFrontmatter)}${rewrittenBody}`;
@@ -130,6 +174,11 @@ export async function convertMarkdown(
htmlPath: finalHtmlPath,
backupPath,
contentImages,
mermaidImages: mermaidImages.map((image) => ({
hash: image.hash,
localPath: image.localPath,
cached: image.cached,
})),
};
}
@@ -156,6 +205,11 @@ Options:
--count Show reading time / word count
--legend <value> Image caption: title-alt, alt-title, title, alt, none
--keep-title Keep the first heading in content. Default: false (removed)
--mermaid-theme <name> Mermaid theme: default, forest, dark, neutral. Default: default
--mermaid-scale <N> Mermaid render scale: 1, 1.5, 2, 3. Default: 2
--mermaid-width <N> Mermaid target display width in CSS px. Default: 860
--mermaid-bg <value> Mermaid background: white, transparent, or #hex. Default: white
--no-mermaid Skip Mermaid rendering; emit <pre class="mermaid"> fallback
--help Show this help
Output:
@@ -215,13 +269,78 @@ function extractTitleArg(argv: string[]): { renderArgs: string[]; title?: string
return { renderArgs, title };
}
const VALID_MERMAID_THEMES = new Set(["default", "forest", "dark", "neutral", "base"]);
function extractMermaidArgs(argv: string[]): { renderArgs: string[]; mermaid: MermaidCliOptions } {
const mermaid: MermaidCliOptions = {};
const renderArgs: string[] = [];
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i]!;
if (arg === "--no-mermaid") {
mermaid.enabled = false;
continue;
}
if (arg === "--mermaid-theme" || arg.startsWith("--mermaid-theme=")) {
const value = parseArgValue(argv, i, "--mermaid-theme");
if (!value) {
console.error("Missing value for --mermaid-theme");
printUsage(1);
}
if (!VALID_MERMAID_THEMES.has(value)) {
console.error(`Invalid --mermaid-theme: ${value} (choose one of ${[...VALID_MERMAID_THEMES].join(", ")})`);
printUsage(1);
}
mermaid.theme = value;
if (!arg.includes("=")) i += 1;
continue;
}
if (arg === "--mermaid-scale" || arg.startsWith("--mermaid-scale=")) {
const value = parseArgValue(argv, i, "--mermaid-scale");
const parsed = Number.parseFloat(value ?? "");
if (!value || !Number.isFinite(parsed) || parsed <= 0 || parsed > 4) {
console.error(`Invalid --mermaid-scale: ${value} (expect a positive number ≤ 4)`);
printUsage(1);
}
mermaid.scale = parsed;
if (!arg.includes("=")) i += 1;
continue;
}
if (arg === "--mermaid-width" || arg.startsWith("--mermaid-width=")) {
const value = parseArgValue(argv, i, "--mermaid-width");
const parsed = Number.parseInt(value ?? "", 10);
if (!value || !Number.isFinite(parsed) || parsed <= 0) {
console.error(`Invalid --mermaid-width: ${value} (expect a positive integer)`);
printUsage(1);
}
mermaid.minWidth = parsed;
if (!arg.includes("=")) i += 1;
continue;
}
if (arg === "--mermaid-bg" || arg.startsWith("--mermaid-bg=")) {
const value = parseArgValue(argv, i, "--mermaid-bg");
if (!value) {
console.error("Missing value for --mermaid-bg");
printUsage(1);
}
mermaid.background = value;
if (!arg.includes("=")) i += 1;
continue;
}
renderArgs.push(arg);
}
return { renderArgs, mermaid };
}
async function main(): Promise<void> {
const args = process.argv.slice(2);
if (args.length === 0 || args.includes("--help") || args.includes("-h")) {
printUsage(0);
}
const { renderArgs, title } = extractTitleArg(args);
const { renderArgs: afterTitle, title } = extractTitleArg(args);
const { renderArgs, mermaid } = extractMermaidArgs(afterTitle);
const options = parseArgs(renderArgs);
if (!options) {
printUsage(1);
@@ -238,11 +357,15 @@ async function main(): Promise<void> {
process.exit(1);
}
const result = await convertMarkdown(markdownPath, { ...options, title });
const result = await convertMarkdown(markdownPath, { ...options, title, mermaid });
console.log(JSON.stringify(result, null, 2));
}
await main().catch((error) => {
try {
await main();
} catch (error) {
console.error(`Error: ${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
});
process.exitCode = 1;
} finally {
await closeRenderer();
}
@@ -3,6 +3,7 @@
"private": true,
"type": "module",
"dependencies": {
"baoyu-chrome-cdp": "^0.1.0",
"baoyu-md": "^0.1.0"
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: baoyu-post-to-wechat
description: Posts content to WeChat Official Account (微信公众号) via API or Chrome CDP. Supports article posting (文章) with HTML, markdown, or plain text input, and image-text posting (贴图, formerly 图文) with multiple images. Markdown article workflows default to converting ordinary external links into bottom citations for WeChat-friendly output. Use when user mentions "发布公众号", "post to wechat", "微信公众号", or "贴图/图文/文章".
version: 1.117.5
version: 1.118.0
metadata:
openclaw:
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-post-to-wechat
@@ -8,6 +8,7 @@ import {
extractSummaryFromBody,
extractTitleFromMarkdown,
parseFrontmatter,
preprocessMermaidInMarkdown,
renderMarkdownDocument,
replaceMarkdownImagesWithPlaceholders,
resolveColorToken,
@@ -15,6 +16,7 @@ import {
serializeFrontmatter,
stripWrappingQuotes,
} from "baoyu-md";
import { closeRenderer, renderMermaidToPng } from "baoyu-chrome-cdp/mermaid";
interface ImageInfo {
placeholder: string;
@@ -55,8 +57,27 @@ export async function convertMarkdown(
summary = extractSummaryFromBody(body, 120);
}
const { markdown: mermaidProcessedBody, images: mermaidImages } =
await preprocessMermaidInMarkdown(body, {
baseDir,
renderFn: renderMermaidToPng,
onError: (error, block) => {
const message = error instanceof Error ? error.message : String(error);
console.error(
`[md-to-wechat] mermaid render failed (${block.code.slice(0, 40).replace(/\s+/g, " ")}…): ${message}`,
);
},
});
if (mermaidImages.length > 0) {
const fresh = mermaidImages.filter((image) => !image.cached).length;
console.error(
`[md-to-wechat] mermaid: ${mermaidImages.length} block(s), ${fresh} rendered, ${mermaidImages.length - fresh} cached`,
);
}
const { images, markdown: rewrittenBody } = replaceMarkdownImagesWithPlaceholders(
body,
mermaidProcessedBody,
"WECHATIMGPH_",
);
const rewrittenMarkdown = `${serializeFrontmatter(frontmatter)}${rewrittenBody}`;
@@ -166,7 +187,11 @@ async function main(): Promise<void> {
console.log(JSON.stringify(result, null, 2));
}
await main().catch((error) => {
try {
await main();
} catch (error) {
console.error(`Error: ${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
});
process.exitCode = 1;
} finally {
await closeRenderer();
}
@@ -99,6 +99,63 @@ function toHttpsUrl(url: string | undefined): string {
return url.startsWith("http://") ? url.replace(/^http:\/\//i, "https://") : url;
}
function htmlToPlainText(html: string): string {
if (!html) return "";
let text = html;
// 1. 将 <br>, <br/>, <br /> 替换为换行符
text = text.replace(/<br\s*\/?>/gi, "\n");
// 2. 将 </p>, </div>, </h1>, </h2>, </h3>, </h4>, </h5>, </h6>, </li> 替换为换行符
text = text.replace(/<\/(?:p|div|h[1-6]|li|tr|td|th)>/gi, "\n");
// 3. 去掉所有剩余的 HTML 标签
text = text.replace(/<[^>]+>/g, "");
// 4. 解码 HTML 实体
const entityMap: Record<string, string> = {
"&nbsp;": " ",
"&lt;": "<",
"&gt;": ">",
"&amp;": "&",
"&quot;": '"',
"&#39;": "'",
"&apos;": "'",
"&mdash;": "—",
"&ndash;": "",
"&hellip;": "…",
"&ldquo;": "“",
"&rdquo;": "”",
"&lsquo;": "",
"&rsquo;": "",
};
text = text.replace(/&(?:[a-zA-Z]+|#x[0-9a-fA-F]+|#\d+);/g, (entity) => {
if (entityMap[entity]) return entityMap[entity];
const hexMatch = entity.match(/^&#x([0-9a-fA-F]+);$/);
if (hexMatch) {
return String.fromCodePoint(Number.parseInt(hexMatch[1]!, 16));
}
const numMatch = entity.match(/^&#(\d+);$/);
if (numMatch) {
return String.fromCodePoint(Number.parseInt(numMatch[1]!, 10));
}
return entity;
});
// 5. 合并多个连续空白字符(空格、制表符、换行)为一个空格
text = text.replace(/[ \t]+/g, " ");
// 6. 合并多个连续换行为一个换行
text = text.replace(/\n{3,}/g, "\n\n");
// 7. 去掉行首行尾空白
text = text.split("\n").map(line => line.trim()).join("\n");
// 8. 最终 trim
return text.trim();
}
async function uploadImage(
imagePath: string,
accessToken: string,
@@ -295,10 +352,15 @@ async function publishToDraft(
if (!options.imageMediaIds || options.imageMediaIds.length === 0) {
throw new Error("newspic requires at least one image");
}
// newspic 的 content 应该是纯文本,需要:
// 1. 去掉 HTML 标签
// 2. 解码 HTML 实体(&nbsp;、&lt; 等)
// 3. 合并多余空白字符
const plainContent = htmlToPlainText(options.content);
article = {
article_type: "newspic",
title: options.title,
content: options.content,
content: plainContent,
need_open_comment: noc,
only_fans_can_comment: ofcc,
image_info: {
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: baoyu-post-to-weibo
description: Posts content to Weibo (微博). Supports regular posts with text, images, and videos, and headline articles (头条文章) with Markdown input via Chrome CDP. Use when user asks to "post to Weibo", "发微博", "发布微博", "publish to Weibo", "share on Weibo", "写微博", or "微博头条文章".
version: 1.56.1
version: 1.57.0
metadata:
openclaw:
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-post-to-weibo
@@ -8,6 +8,7 @@ import {
extractTitleFromMarkdown,
parseFrontmatter,
pickFirstString,
preprocessMermaidInMarkdown,
renderMarkdownDocument,
replaceMarkdownImagesWithPlaceholders,
resolveColorToken,
@@ -16,6 +17,7 @@ import {
serializeFrontmatter,
stripWrappingQuotes,
} from "baoyu-md";
import { closeRenderer, renderMermaidToPng } from "baoyu-chrome-cdp/mermaid";
interface ImageInfo {
placeholder: string;
@@ -69,8 +71,27 @@ export async function parseMarkdown(
|| pickFirstString(frontmatter, ["featureImage", "cover_image", "coverImage", "cover", "image"])
|| null;
const { markdown: mermaidProcessedBody, images: mermaidImages } =
await preprocessMermaidInMarkdown(body, {
baseDir,
renderFn: renderMermaidToPng,
onError: (error, block) => {
const message = error instanceof Error ? error.message : String(error);
console.error(
`[md-to-html] mermaid render failed (${block.code.slice(0, 40).replace(/\s+/g, " ")}…): ${message}`,
);
},
});
if (mermaidImages.length > 0) {
const fresh = mermaidImages.filter((image) => !image.cached).length;
console.error(
`[md-to-html] mermaid: ${mermaidImages.length} block(s), ${fresh} rendered, ${mermaidImages.length - fresh} cached`,
);
}
const { images, markdown: rewrittenBody } = replaceMarkdownImagesWithPlaceholders(
body,
mermaidProcessedBody,
"WBIMGPH_",
);
const rewrittenMarkdown = `${serializeFrontmatter(frontmatter)}${rewrittenBody}`;
@@ -163,8 +184,12 @@ Options:
}
if (import.meta.main ?? (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(import.meta.filename ?? ""))) {
await main().catch((error) => {
try {
await main();
} catch (error) {
console.error(`Error: ${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
});
process.exitCode = 1;
} finally {
await closeRenderer();
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: baoyu-post-to-x
description: Posts content and articles to X (Twitter). Supports regular posts with images/videos and X Articles (long-form Markdown). In Codex, honor explicit requests for the Codex Chrome plugin/@chrome by using the Chrome Extension workflow; otherwise use Chrome Computer Use when available and fall back to real Chrome CDP scripts only when allowed. Use when user asks to "post to X", "tweet", "publish to Twitter", or "share on X".
version: 1.57.2
version: 1.58.0
metadata:
openclaw:
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-post-to-x
+30 -4
View File
@@ -15,6 +15,9 @@ import remarkCjkFriendly from 'remark-cjk-friendly';
import remarkParse from 'remark-parse';
import remarkStringify from 'remark-stringify';
import { preprocessMermaidInMarkdown } from 'baoyu-md';
import { closeRenderer, renderMermaidToPng } from 'baoyu-chrome-cdp/mermaid';
interface ImageInfo {
placeholder: string;
localPath: string;
@@ -318,10 +321,29 @@ export async function parseMarkdown(
coverImagePath = findCoverImageNearMarkdown(baseDir);
}
const { markdown: mermaidProcessedBody, images: mermaidImages } =
await preprocessMermaidInMarkdown(body, {
baseDir,
renderFn: renderMermaidToPng,
onError: (error, block) => {
const message = error instanceof Error ? error.message : String(error);
console.error(
`[md-to-html] mermaid render failed (${block.code.slice(0, 40).replace(/\s+/g, ' ')}…): ${message}`,
);
},
});
if (mermaidImages.length > 0) {
const fresh = mermaidImages.filter((image) => !image.cached).length;
console.error(
`[md-to-html] mermaid: ${mermaidImages.length} block(s), ${fresh} rendered, ${mermaidImages.length - fresh} cached`,
);
}
const images: Array<{ src: string; alt: string; blockIndex: number }> = [];
let imageCounter = 0;
const { html, totalBlocks } = convertMarkdownToHtml(body, (src, alt) => {
const { html, totalBlocks } = convertMarkdownToHtml(mermaidProcessedBody, (src, alt) => {
const placeholder = `XIMGPH_${++imageCounter}`;
images.push({ src, alt, blockIndex: -1 });
return placeholder;
@@ -460,8 +482,12 @@ async function main(): Promise<void> {
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
await main().catch((err) => {
try {
await main();
} catch (err) {
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
});
process.exitCode = 1;
} finally {
await closeRenderer();
}
}
@@ -4,6 +4,7 @@
"type": "module",
"dependencies": {
"baoyu-chrome-cdp": "^0.1.0",
"baoyu-md": "^0.1.0",
"front-matter": "^4.0.2",
"highlight.js": "^11.11.1",
"marked": "^15.0.6",
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: baoyu-slide-deck
description: Generates professional slide deck images from content. Creates outlines with style instructions, then generates individual slide images. Use when user asks to "create slides", "make a presentation", "generate deck", "slide deck", or "PPT".
version: 1.57.1
version: 1.117.3
metadata:
openclaw:
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-slide-deck