Compare commits

..

24 Commits

Author SHA1 Message Date
Jim Liu 宝玉 4be6f3682a chore: sync vendored baoyu-chrome-cdp across all skills 2026-03-16 12:54:36 -05:00
Jim Liu 宝玉 b89ef02221 chore(baoyu-post-to-wechat): sync vendored baoyu-chrome-cdp 2026-03-16 12:53:51 -05:00
Jim Liu 宝玉 55356c8820 chore: release v1.69.0 2026-03-16 12:53:25 -05:00
Jim Liu 宝玉 f23d4eebc3 docs(baoyu-cover-image): clarify people reference image workflow and interactive confirmation 2026-03-16 12:52:16 -05:00
Jim Liu 宝玉 3b795b6ef3 Merge pull request #92 from bviews/main
feat(baoyu-chrome-cdp): support Chrome 146 auto-connect
2026-03-16 12:40:22 -05:00
Jim Liu 宝玉 c62d9d5a1b Merge pull request #94 from cfh-7598/fix/wechat-article-reliability
fix(baoyu-post-to-wechat): improve browser publishing reliability
2026-03-16 12:32:49 -05:00
cfh 84c56b0da3 fix(baoyu-post-to-wechat): improve browser publishing reliability
- Add retry logic (5 attempts with progressive backoff) to clickMenuByText
  to handle slow-loading home page menus
- Increase post-login wait from 2s to 5s and menu timeout from 20s to 40s
- Replace fixed 3s sleep after editor tab opens with waitForElement polling
  for #title (30s) and .ProseMirror (15s) to reliably wait for full load
- Improve title/author filling with focus() and change event dispatch
  for more reliable value setting

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 00:43:39 +08:00
Alec Jiang 462d080a0e fix(gemini-web): also respect BAOYU_CHROME_PROFILE_DIR in auto-discovery skip
Both GEMINI_WEB_CHROME_PROFILE_DIR and BAOYU_CHROME_PROFILE_DIR are
valid profile overrides (see resolveGeminiWebChromeProfileDir). Skip
existing Chrome auto-discovery when either is set.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 19:02:58 +08:00
Alec Jiang bd01c370c9 fix(gemini-web): respect explicit profile config over auto-discovery
Skip existing Chrome auto-discovery when GEMINI_WEB_CHROME_PROFILE_DIR
is explicitly set, to avoid binding to the wrong browser profile/account.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 18:39:53 +08:00
Alec Jiang b7bcc8c094 fix(baoyu-chrome-cdp): keep HTTP validation in findExistingChromeDebugPort
findExistingChromeDebugPort callers (fetch_google_cookies_via_cdp) depend
on /json/version being available. TCP-only check could misclassify
non-CDP listeners as reusable, causing waitForChromeDebugPort to timeout
instead of falling back to launching a new Chrome. Restore isDebugPortReady
(HTTP) as the validator for this function; TCP-only check remains in
discoverRunningChromeDebugPort for Chrome 146 approval mode.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 18:11:37 +08:00
Alec Jiang d9f9da639d fix(baoyu-chrome-cdp): support Chrome 146 native remote debugging (approval mode)
Chrome 146+ blocks all HTTP endpoints (/json/version) in approval mode.
Read DevToolsActivePort ws path directly and use TCP port check instead
of HTTP discovery. Add WebSocket connect retry loop for approval dialog.
Unify findExistingChromeDebugPort to use the same mechanism.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 18:00:58 +08:00
bviews 126b3040e6 fix(gemini-web): reuse openPageSession and fix orphaned tab leak
- Replace manual CDP target orchestration with openPageSession, keeping
  behavior consistent with fetch_google_cookies_via_cdp
- Move created-tab cleanup into finally block so tabs are always closed
  even when Target.attachToTarget or Network.enable throws

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 16:44:53 +08:00
bviews 682888cc95 feat(baoyu-chrome-cdp): support connecting to existing Chrome session
Add ability to discover and connect to an already-running Chrome browser
(v144+) for cookie extraction, avoiding the need to launch a new window
and re-login. Uses Chrome's DevToolsActivePort from default user data
directories and process scanning as discovery mechanisms.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 15:51:57 +08:00
Jim Liu 宝玉 e31294415d chore: release v1.68.0 2026-03-14 23:08:00 -05:00
Jim Liu 宝玉 926f74e9c9 feat(baoyu-cover-image): add character preservation from reference images 2026-03-14 23:07:56 -05:00
Jim Liu 宝玉 339990e87e feat(baoyu-article-illustrator): add configurable output directory support 2026-03-14 23:07:56 -05:00
Jim Liu 宝玉 3c5c3e006d Merge pull request #89 from shixy96/fix/markdown-to-html-inline-code-in-emphasis
fix(markdown-to-html): preserve inline code inside bold/emphasis
2026-03-14 22:27:14 -05:00
shixy 2aa9790789 fix: preserve inline code in cjk emphasis 2026-03-14 17:12:03 +08:00
shixy 38fc733b99 fix(markdown-to-html): preserve inline code inside bold/emphasis
The `extractText()` helper in `preprocessCjkEmphasis()` only handled
`text` nodes and nodes with `children`. `inlineCode` AST nodes (which
have a `value` but no `children`) fell through to the default empty-
string return, silently dropping their content.

For example `**算出 \`logits\`**` rendered as `<strong>算出 </strong>`
with the code span completely lost.

Add an `inlineCode` branch that wraps the node value in backticks so
the downstream `marked` pass can turn it into a proper `<code>` element.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 16:13:02 +08:00
Jim Liu 宝玉 4d2b95d1d1 chore: release v1.67.0 2026-03-13 19:12:47 -05:00
JianJang2017 ac2ce0b8b6 Add qwen-image-2.0-pro support for baoyu-image-gen 2026-03-13 19:09:54 -05:00
Jim Liu 宝玉 de7dc85361 chore: sync shared skill package vendor test files 2026-03-13 17:56:53 -05:00
Jim Liu 宝玉 0c02b81885 chore: release v1.66.1 2026-03-13 17:56:30 -05:00
Jim Liu 宝玉 774ad784d8 test: migrate tests from centralized mjs to colocated TypeScript
Move test files from tests/ directory to colocate with source code,
convert from .mjs to .ts using tsx runner, add workspaces and npm
cache to CI workflow.
2026-03-13 16:36:06 -05:00
69 changed files with 6737 additions and 257 deletions
+1 -1
View File
@@ -6,7 +6,7 @@
},
"metadata": {
"description": "Skills shared by Baoyu for improving daily work efficiency",
"version": "1.66.0"
"version": "1.69.0"
},
"plugins": [
{
+4
View File
@@ -16,6 +16,10 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
+34
View File
@@ -2,6 +2,40 @@
English | [中文](./CHANGELOG.zh.md)
## 1.69.0 - 2026-03-16
### Features
- `baoyu-chrome-cdp`: support connecting to existing Chrome session (by @bviews)
### Fixes
- `baoyu-chrome-cdp`: support Chrome 146 native remote debugging in approval mode (by @bviews)
- `baoyu-chrome-cdp`: keep HTTP validation in findExistingChromeDebugPort (by @bviews)
- `baoyu-danger-gemini-web`: reuse openPageSession and fix orphaned tab leak (by @bviews)
- `baoyu-danger-gemini-web`: respect explicit profile config over auto-discovery (by @bviews)
- `baoyu-danger-gemini-web`: respect BAOYU_CHROME_PROFILE_DIR in auto-discovery skip (by @bviews)
- `baoyu-post-to-wechat`: improve browser publishing reliability (by @cfh-7598)
### Documentation
- `baoyu-cover-image`: clarify people reference image workflow and interactive confirmation
## 1.68.0 - 2026-03-14
### Features
- `baoyu-article-illustrator`: add configurable output directory (`default_output_dir`) with 4 options — `imgs-subdir`, `same-dir`, `illustrations-subdir`, `independent`
- `baoyu-cover-image`: add character preservation from reference images — use `usage: direct` to pass people references to model for stylized likeness
## 1.67.0 - 2026-03-13
### Features
- `baoyu-image-gen`: add qwen-image-2.0-pro model support for DashScope provider with free-form sizes and text rendering (by @JianJang2017)
## 1.66.1 - 2026-03-13
### Tests
- Migrate test files from centralized `tests/` directory to colocate with source code
- Convert tests from `.mjs` to TypeScript (`.test.ts`) with `tsx` runner
- Add npm workspaces configuration and npm cache to CI workflow
## 1.66.0 - 2026-03-13
### Features
+34
View File
@@ -2,6 +2,40 @@
[English](./CHANGELOG.md) | 中文
## 1.69.0 - 2026-03-16
### 新功能
- `baoyu-chrome-cdp`:支持连接到已有的 Chrome 会话 (by @bviews)
### 修复
- `baoyu-chrome-cdp`:支持 Chrome 146 原生远程调试(审批模式)(by @bviews)
- `baoyu-chrome-cdp`:保留 findExistingChromeDebugPort 中的 HTTP 验证 (by @bviews)
- `baoyu-danger-gemini-web`:复用 openPageSession 并修复孤立标签页泄漏 (by @bviews)
- `baoyu-danger-gemini-web`:显式配置优先于自动发现 (by @bviews)
- `baoyu-danger-gemini-web`:自动发现跳过时也遵循 BAOYU_CHROME_PROFILE_DIR (by @bviews)
- `baoyu-post-to-wechat`:提升浏览器发布可靠性 (by @cfh-7598)
### 文档
- `baoyu-cover-image`:完善人物参考图片工作流和交互式确认说明
## 1.68.0 - 2026-03-14
### 新功能
- `baoyu-article-illustrator`:新增可配置输出目录(`default_output_dir`),支持 4 种选项——`imgs-subdir``same-dir``illustrations-subdir``independent`
- `baoyu-cover-image`:新增参考图片人物保留功能——当参考图包含人物时使用 `usage: direct` 传递给模型,风格化保留人物特征
## 1.67.0 - 2026-03-13
### 新功能
- `baoyu-image-gen`:新增 DashScope qwen-image-2.0-pro 模型支持,支持自由尺寸和文字渲染 (by @JianJang2017)
## 1.66.1 - 2026-03-13
### 测试
- 将测试文件从集中式 `tests/` 目录迁移至与源码同级
- 将测试从 `.mjs` 转换为 TypeScript`.test.ts`),使用 `tsx` 运行器
- 新增 npm workspaces 配置,CI 工作流添加 npm 缓存
## 1.66.0 - 2026-03-13
### 新功能
+1 -1
View File
@@ -1,6 +1,6 @@
# CLAUDE.md
Claude Code marketplace plugin providing AI-powered content generation skills. Version: **1.66.0**.
Claude Code marketplace plugin providing AI-powered content generation skills. Version: **1.69.0**.
## Architecture
+2 -2
View File
@@ -726,7 +726,7 @@ AI SDK-based image generation using OpenAI, Google, OpenRouter, DashScope (Aliyu
| `OPENAI_IMAGE_MODEL` | OpenAI model | `gpt-image-1.5` |
| `OPENROUTER_IMAGE_MODEL` | OpenRouter model | `google/gemini-3.1-flash-image-preview` |
| `GOOGLE_IMAGE_MODEL` | Google model | `gemini-3-pro-image-preview` |
| `DASHSCOPE_IMAGE_MODEL` | DashScope model | `z-image-turbo` |
| `DASHSCOPE_IMAGE_MODEL` | DashScope model | `qwen-image-2.0-pro` |
| `REPLICATE_IMAGE_MODEL` | Replicate model | `google/nano-banana-pro` |
| `JIMENG_IMAGE_MODEL` | Jimeng model | `jimeng_t2i_v40` |
| `SEEDREAM_IMAGE_MODEL` | Seedream model | `doubao-seedream-5-0-260128` |
@@ -996,7 +996,7 @@ GOOGLE_IMAGE_MODEL=gemini-3-pro-image-preview
# DashScope (Aliyun Tongyi Wanxiang)
DASHSCOPE_API_KEY=sk-xxx
DASHSCOPE_IMAGE_MODEL=z-image-turbo
DASHSCOPE_IMAGE_MODEL=qwen-image-2.0-pro
# DASHSCOPE_BASE_URL=https://dashscope.aliyuncs.com/api/v1
# Replicate
+2 -2
View File
@@ -726,7 +726,7 @@ AI 驱动的生成后端。
| `OPENAI_IMAGE_MODEL` | OpenAI 模型 | `gpt-image-1.5` |
| `OPENROUTER_IMAGE_MODEL` | OpenRouter 模型 | `google/gemini-3.1-flash-image-preview` |
| `GOOGLE_IMAGE_MODEL` | Google 模型 | `gemini-3-pro-image-preview` |
| `DASHSCOPE_IMAGE_MODEL` | DashScope 模型 | `z-image-turbo` |
| `DASHSCOPE_IMAGE_MODEL` | DashScope 模型 | `qwen-image-2.0-pro` |
| `REPLICATE_IMAGE_MODEL` | Replicate 模型 | `google/nano-banana-pro` |
| `JIMENG_IMAGE_MODEL` | 即梦模型 | `jimeng_t2i_v40` |
| `SEEDREAM_IMAGE_MODEL` | 豆包模型 | `doubao-seedream-5-0-260128` |
@@ -996,7 +996,7 @@ GOOGLE_IMAGE_MODEL=gemini-3-pro-image-preview
# DashScope(阿里通义万相)
DASHSCOPE_API_KEY=sk-xxx
DASHSCOPE_IMAGE_MODEL=z-image-turbo
DASHSCOPE_IMAGE_MODEL=qwen-image-2.0-pro
# DASHSCOPE_BASE_URL=https://dashscope.aliyuncs.com/api/v1
# Replicate
+1958
View File
File diff suppressed because it is too large Load Diff
+8 -2
View File
@@ -2,8 +2,14 @@
"name": "baoyu-skills",
"private": true,
"type": "module",
"workspaces": [
"packages/*"
],
"scripts": {
"test": "node --test",
"test:coverage": "node --experimental-test-coverage --test"
"test": "node --import tsx --test",
"test:coverage": "node --import tsx --experimental-test-coverage --test"
},
"devDependencies": {
"tsx": "^4.20.5"
}
}
+171
View File
@@ -0,0 +1,171 @@
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import http from "node:http";
import os from "node:os";
import path from "node:path";
import process from "node:process";
import test, { type TestContext } from "node:test";
import {
findChromeExecutable,
findExistingChromeDebugPort,
getFreePort,
resolveSharedChromeProfileDir,
waitForChromeDebugPort,
} from "./index.ts";
function useEnv(
t: TestContext,
values: Record<string, string | null>,
): void {
const previous = new Map<string, string | undefined>();
for (const [key, value] of Object.entries(values)) {
previous.set(key, process.env[key]);
if (value == null) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
t.after(() => {
for (const [key, value] of previous.entries()) {
if (value == null) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
});
}
async function makeTempDir(prefix: string): Promise<string> {
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
}
async function startDebugServer(port: number): Promise<http.Server> {
const server = http.createServer((req, res) => {
if (req.url === "/json/version") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({
webSocketDebuggerUrl: `ws://127.0.0.1:${port}/devtools/browser/demo`,
}));
return;
}
res.writeHead(404);
res.end();
});
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(port, "127.0.0.1", () => resolve());
});
return server;
}
async function closeServer(server: http.Server): Promise<void> {
await new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) reject(error);
else resolve();
});
});
}
test("getFreePort honors a fixed environment override and otherwise allocates a TCP port", async (t) => {
useEnv(t, { TEST_FIXED_PORT: "45678" });
assert.equal(await getFreePort("TEST_FIXED_PORT"), 45678);
const dynamicPort = await getFreePort();
assert.ok(Number.isInteger(dynamicPort));
assert.ok(dynamicPort > 0);
});
test("findChromeExecutable prefers env overrides and falls back to candidate paths", async (t) => {
const root = await makeTempDir("baoyu-chrome-bin-");
t.after(() => fs.rm(root, { recursive: true, force: true }));
const envChrome = path.join(root, "env-chrome");
const fallbackChrome = path.join(root, "fallback-chrome");
await fs.writeFile(envChrome, "");
await fs.writeFile(fallbackChrome, "");
useEnv(t, { BAOYU_CHROME_PATH: envChrome });
assert.equal(
findChromeExecutable({
envNames: ["BAOYU_CHROME_PATH"],
candidates: { default: [fallbackChrome] },
}),
envChrome,
);
useEnv(t, { BAOYU_CHROME_PATH: null });
assert.equal(
findChromeExecutable({
envNames: ["BAOYU_CHROME_PATH"],
candidates: { default: [fallbackChrome] },
}),
fallbackChrome,
);
});
test("resolveSharedChromeProfileDir supports env overrides, WSL paths, and default suffixes", (t) => {
useEnv(t, { BAOYU_SHARED_PROFILE: "/tmp/custom-profile" });
assert.equal(
resolveSharedChromeProfileDir({
envNames: ["BAOYU_SHARED_PROFILE"],
appDataDirName: "demo-app",
profileDirName: "demo-profile",
}),
path.resolve("/tmp/custom-profile"),
);
useEnv(t, { BAOYU_SHARED_PROFILE: null });
assert.equal(
resolveSharedChromeProfileDir({
wslWindowsHome: "/mnt/c/Users/demo",
appDataDirName: "demo-app",
profileDirName: "demo-profile",
}),
path.join("/mnt/c/Users/demo", ".local", "share", "demo-app", "demo-profile"),
);
const fallback = resolveSharedChromeProfileDir({
appDataDirName: "demo-app",
profileDirName: "demo-profile",
});
assert.match(fallback, /demo-app[\\/]demo-profile$/);
});
test("findExistingChromeDebugPort reads DevToolsActivePort and validates it against a live endpoint", async (t) => {
const root = await makeTempDir("baoyu-cdp-profile-");
t.after(() => fs.rm(root, { recursive: true, force: true }));
const port = await getFreePort();
const server = await startDebugServer(port);
t.after(() => closeServer(server));
await fs.writeFile(path.join(root, "DevToolsActivePort"), `${port}\n/devtools/browser/demo\n`);
const found = await findExistingChromeDebugPort({ profileDir: root, timeoutMs: 1000 });
assert.equal(found, port);
});
test("waitForChromeDebugPort retries until the debug endpoint becomes available", async (t) => {
const port = await getFreePort();
const serverPromise = (async () => {
await new Promise((resolve) => setTimeout(resolve, 200));
const server = await startDebugServer(port);
t.after(() => closeServer(server));
})();
const websocketUrl = await waitForChromeDebugPort(port, 4000, {
includeLastError: true,
});
await serverPromise;
assert.equal(websocketUrl, `ws://127.0.0.1:${port}/devtools/browser/demo`);
});
+110 -7
View File
@@ -43,6 +43,18 @@ type FindExistingChromeDebugPortOptions = {
timeoutMs?: number;
};
export type ChromeChannel = "stable" | "beta" | "canary" | "dev";
export type DiscoveredChrome = {
port: number;
wsUrl: string;
};
type DiscoverRunningChromeOptions = {
channels?: ChromeChannel[];
timeoutMs?: number;
};
type LaunchChromeOptions = {
chromePath: string;
profileDir: string;
@@ -173,16 +185,32 @@ async function isDebugPortReady(port: number, timeoutMs = 3_000): Promise<boolea
}
}
function isPortListening(port: number, timeoutMs = 3_000): Promise<boolean> {
return new Promise((resolve) => {
const socket = new net.Socket();
const timer = setTimeout(() => { socket.destroy(); resolve(false); }, timeoutMs);
socket.once("connect", () => { clearTimeout(timer); socket.destroy(); resolve(true); });
socket.once("error", () => { clearTimeout(timer); resolve(false); });
socket.connect(port, "127.0.0.1");
});
}
function parseDevToolsActivePort(filePath: string): { port: number; wsPath: string } | null {
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;
}
export async function findExistingChromeDebugPort(options: FindExistingChromeDebugPortOptions): Promise<number | null> {
const timeoutMs = options.timeoutMs ?? 3_000;
const portFile = path.join(options.profileDir, "DevToolsActivePort");
const parsed = parseDevToolsActivePort(path.join(options.profileDir, "DevToolsActivePort"));
try {
const content = fs.readFileSync(portFile, "utf-8");
const [portLine] = content.split(/\r?\n/);
const port = Number.parseInt(portLine?.trim() ?? "", 10);
if (port > 0 && await isDebugPortReady(port, timeoutMs)) return port;
} catch {}
if (parsed && parsed.port > 0 && await isDebugPortReady(parsed.port, timeoutMs)) return parsed.port;
if (process.platform === "win32") return null;
@@ -204,6 +232,81 @@ export async function findExistingChromeDebugPort(options: FindExistingChromeDeb
return null;
}
export function getDefaultChromeUserDataDirs(channels: ChromeChannel[] = ["stable"]): string[] {
const home = os.homedir();
const dirs: string[] = [];
const channelDirs: Record<string, { darwin: string; linux: string; win32: string }> = {
stable: {
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome"),
linux: path.join(home, ".config", "google-chrome"),
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome", "User Data"),
},
beta: {
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome Beta"),
linux: path.join(home, ".config", "google-chrome-beta"),
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome Beta", "User Data"),
},
canary: {
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome Canary"),
linux: path.join(home, ".config", "google-chrome-canary"),
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome SxS", "User Data"),
},
dev: {
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome Dev"),
linux: path.join(home, ".config", "google-chrome-dev"),
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome Dev", "User Data"),
},
};
const platform = process.platform === "darwin" ? "darwin" : process.platform === "win32" ? "win32" : "linux";
for (const ch of channels) {
const entry = channelDirs[ch];
if (entry) dirs.push(entry[platform]);
}
return dirs;
}
export async function discoverRunningChromeDebugPort(options: DiscoverRunningChromeOptions = {}): Promise<DiscoveredChrome | null> {
const channels = options.channels ?? ["stable", "beta", "canary", "dev"];
const timeoutMs = options.timeoutMs ?? 3_000;
const userDataDirs = getDefaultChromeUserDataDirs(channels);
for (const dir of userDataDirs) {
const parsed = parseDevToolsActivePort(path.join(dir, "DevToolsActivePort"));
if (!parsed) continue;
if (await isPortListening(parsed.port, timeoutMs)) {
return { port: parsed.port, wsUrl: `ws://127.0.0.1:${parsed.port}${parsed.wsPath}` };
}
}
if (process.platform !== "win32") {
try {
const result = spawnSync("ps", ["aux"], { encoding: "utf-8", timeout: 5_000 });
if (result.status === 0 && result.stdout) {
const lines = result.stdout
.split("\n")
.filter((line) => line.includes("--remote-debugging-port=") && /chrome|chromium/i.test(line));
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)) {
try {
const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(`http://127.0.0.1:${port}/json/version`, { timeoutMs });
if (version.webSocketDebuggerUrl) return { port, wsUrl: version.webSocketDebuggerUrl };
} catch {}
}
}
}
} catch {}
}
return null;
}
export async function waitForChromeDebugPort(
port: number,
timeoutMs: number,
+93
View File
@@ -0,0 +1,93 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
extractSummaryFromBody,
extractTitleFromMarkdown,
parseFrontmatter,
pickFirstString,
serializeFrontmatter,
stripWrappingQuotes,
toFrontmatterString,
} from "./content.ts";
test("parseFrontmatter extracts YAML fields and strips wrapping quotes", () => {
const input = `---
title: "Hello World"
author: Baoyu
summary: plain text
---
# Heading
Body`;
const result = parseFrontmatter(input);
assert.deepEqual(result.frontmatter, {
title: "Hello World",
author: "Baoyu",
summary: "plain text",
});
assert.match(result.body, /^# Heading/);
});
test("parseFrontmatter returns original content when no frontmatter exists", () => {
const input = "# No frontmatter";
assert.deepEqual(parseFrontmatter(input), {
frontmatter: {},
body: input,
});
});
test("serializeFrontmatter renders YAML only when fields exist", () => {
assert.equal(serializeFrontmatter({}), "");
assert.equal(
serializeFrontmatter({ title: "Hello", author: "Baoyu" }),
"---\ntitle: Hello\nauthor: Baoyu\n---\n",
);
});
test("quote and frontmatter string helpers normalize mixed scalar values", () => {
assert.equal(stripWrappingQuotes(`" quoted "`), "quoted");
assert.equal(stripWrappingQuotes("“ 中文标题 ”"), "中文标题");
assert.equal(stripWrappingQuotes("plain"), "plain");
assert.equal(toFrontmatterString("'hello'"), "hello");
assert.equal(toFrontmatterString(42), "42");
assert.equal(toFrontmatterString(false), "false");
assert.equal(toFrontmatterString({}), undefined);
assert.equal(
pickFirstString({ summary: 123, title: "" }, ["title", "summary"]),
"123",
);
});
test("markdown title and summary extraction skip non-body content and clean formatting", () => {
const markdown = `
![cover](cover.png)
## “My Title”
Body paragraph
`;
assert.equal(extractTitleFromMarkdown(markdown), "My Title");
const summary = extractSummaryFromBody(
`
# Heading
> quote
- list
1. ordered
\`\`\`
code
\`\`\`
This is **the first paragraph** with [a link](https://example.com) and \`inline code\` that should be summarized cleanly.
`,
70,
);
assert.equal(
summary,
"This is the first paragraph with a link and inline code that should...",
);
});
+140
View File
@@ -0,0 +1,140 @@
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import process from "node:process";
import test, { type TestContext } from "node:test";
import { COLOR_PRESETS, FONT_FAMILY_MAP } from "./constants.ts";
import {
buildMarkdownDocumentMeta,
formatTimestamp,
resolveColorToken,
resolveFontFamilyToken,
resolveMarkdownStyle,
resolveRenderOptions,
} from "./document.ts";
function useCwd(t: TestContext, cwd: string): void {
const previous = process.cwd();
process.chdir(cwd);
t.after(() => {
process.chdir(previous);
});
}
async function makeTempDir(prefix: string): Promise<string> {
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
}
test("document token resolvers map known presets and allow passthrough values", () => {
assert.equal(resolveColorToken("green"), COLOR_PRESETS.green);
assert.equal(resolveColorToken("#123456"), "#123456");
assert.equal(resolveColorToken(), undefined);
assert.equal(resolveFontFamilyToken("mono"), FONT_FAMILY_MAP.mono);
assert.equal(resolveFontFamilyToken("Custom Font"), "Custom Font");
assert.equal(resolveFontFamilyToken(), undefined);
});
test("formatTimestamp uses compact sortable datetime output", () => {
const date = new Date("2026-03-13T21:04:05.000Z");
const pad = (value: number) => String(value).padStart(2, "0");
const expected = `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(
date.getDate(),
)}${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`;
assert.equal(formatTimestamp(date), expected);
});
test("buildMarkdownDocumentMeta prefers frontmatter and falls back to markdown title and summary", () => {
const metaFromYaml = buildMarkdownDocumentMeta(
"# Markdown Title\n\nBody summary paragraph that should be ignored.",
{
title: `" YAML Title "`,
author: "'Baoyu'",
summary: `" YAML Summary "`,
},
"fallback",
);
assert.deepEqual(metaFromYaml, {
title: "YAML Title",
author: "Baoyu",
description: "YAML Summary",
});
const metaFromMarkdown = buildMarkdownDocumentMeta(
`## “Markdown Title”\n\nThis is the first body paragraph that should become the summary because it is long enough.`,
{},
"fallback",
);
assert.equal(metaFromMarkdown.title, "Markdown Title");
assert.match(metaFromMarkdown.description ?? "", /^This is the first body paragraph/);
});
test("resolveMarkdownStyle merges theme defaults with explicit overrides", () => {
const style = resolveMarkdownStyle({
theme: "modern",
primaryColor: "#112233",
fontFamily: "Custom Sans",
});
assert.equal(style.primaryColor, "#112233");
assert.equal(style.fontFamily, "Custom Sans");
assert.equal(style.fontSize, "15px");
assert.equal(style.containerBg, "rgba(250, 249, 245, 1)");
});
test("resolveRenderOptions loads workspace EXTEND settings and lets explicit options win", async (t) => {
const root = await makeTempDir("baoyu-md-render-options-");
useCwd(t, root);
const extendPath = path.join(
root,
".baoyu-skills",
"baoyu-markdown-to-html",
"EXTEND.md",
);
await fs.mkdir(path.dirname(extendPath), { recursive: true });
await fs.writeFile(
extendPath,
`---
default_theme: modern
default_color: green
default_font_family: mono
default_font_size: 17
default_code_theme: nord
mac_code_block: false
show_line_number: true
cite: true
count: true
legend: title-alt
keep_title: true
---
`,
);
const fromExtend = resolveRenderOptions();
assert.equal(fromExtend.theme, "modern");
assert.equal(fromExtend.primaryColor, COLOR_PRESETS.green);
assert.equal(fromExtend.fontFamily, FONT_FAMILY_MAP.mono);
assert.equal(fromExtend.fontSize, "17px");
assert.equal(fromExtend.codeTheme, "nord");
assert.equal(fromExtend.isMacCodeBlock, false);
assert.equal(fromExtend.isShowLineNumber, true);
assert.equal(fromExtend.citeStatus, true);
assert.equal(fromExtend.countStatus, true);
assert.equal(fromExtend.legend, "title-alt");
assert.equal(fromExtend.keepTitle, true);
const explicit = resolveRenderOptions({
theme: "simple",
fontSize: "18px",
keepTitle: false,
});
assert.equal(explicit.theme, "simple");
assert.equal(explicit.fontSize, "18px");
assert.equal(explicit.keepTitle, false);
});
@@ -0,0 +1,71 @@
import assert from "node:assert/strict";
import test from "node:test";
import { DEFAULT_STYLE } from "./constants.ts";
import {
buildCss,
buildHtmlDocument,
modifyHtmlStructure,
normalizeCssText,
normalizeInlineCss,
removeFirstHeading,
} from "./html-builder.ts";
test("buildCss injects style variables and concatenates base and theme CSS", () => {
const css = buildCss("body { color: red; }", ".theme { color: blue; }");
assert.match(css, /--md-primary-color: #0F4C81;/);
assert.match(css, /body \{ color: red; \}/);
assert.match(css, /\.theme \{ color: blue; \}/);
});
test("buildHtmlDocument includes optional meta tags and code theme CSS", () => {
const html = buildHtmlDocument(
{
title: "Doc",
author: "Baoyu",
description: "Summary",
},
"body { color: red; }",
"<article>Hello</article>",
".hljs { color: blue; }",
);
assert.match(html, /<title>Doc<\/title>/);
assert.match(html, /meta name="author" content="Baoyu"/);
assert.match(html, /meta name="description" content="Summary"/);
assert.match(html, /<style>body \{ color: red; \}<\/style>/);
assert.match(html, /<style>\.hljs \{ color: blue; \}<\/style>/);
assert.match(html, /<article>Hello<\/article>/);
});
test("normalizeCssText and normalizeInlineCss replace variables and strip declarations", () => {
const rawCss = `
:root { --md-primary-color: #000; --md-font-size: 12px; --foreground: 0 0% 5%; }
.box { color: var(--md-primary-color); font-size: var(--md-font-size); background: hsl(var(--foreground)); }
`;
const normalizedCss = normalizeCssText(rawCss, DEFAULT_STYLE);
assert.match(normalizedCss, /color: #0F4C81/);
assert.match(normalizedCss, /font-size: 16px/);
assert.match(normalizedCss, /background: #3f3f3f/);
assert.doesNotMatch(normalizedCss, /--md-primary-color/);
const normalizedHtml = normalizeInlineCss(
`<style>${rawCss}</style><div style="color: var(--md-primary-color)"></div>`,
DEFAULT_STYLE,
);
assert.match(normalizedHtml, /color: #0F4C81/);
assert.doesNotMatch(normalizedHtml, /var\(--md-primary-color\)/);
});
test("HTML structure helpers hoist nested lists and remove the first heading", () => {
const nestedList = `<ul><li>Parent<ul><li>Child</li></ul></li></ul>`;
assert.equal(
modifyHtmlStructure(nestedList),
`<ul><li>Parent</li><ul><li>Child</li></ul></ul>`,
);
const html = `<h1>Title</h1><p>Intro</p><h2>Sub</h2>`;
assert.equal(removeFirstHeading(html), `<p>Intro</p><h2>Sub</h2>`);
});
+79
View File
@@ -0,0 +1,79 @@
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import {
getImageExtension,
replaceMarkdownImagesWithPlaceholders,
resolveContentImages,
resolveImagePath,
} from "./images.ts";
async function makeTempDir(prefix: string): Promise<string> {
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
}
test("replaceMarkdownImagesWithPlaceholders rewrites markdown and tracks image metadata", () => {
const result = replaceMarkdownImagesWithPlaceholders(
`![cover](images/cover.png)\n\nText\n\n![diagram](images/diagram.webp)`,
"IMG_",
);
assert.equal(result.markdown, `IMG_1\n\nText\n\nIMG_2`);
assert.deepEqual(result.images, [
{ alt: "cover", originalPath: "images/cover.png", placeholder: "IMG_1" },
{ alt: "diagram", originalPath: "images/diagram.webp", placeholder: "IMG_2" },
]);
});
test("image extension and local fallback resolution handle common path variants", async (t) => {
assert.equal(getImageExtension("https://example.com/a.jpeg?x=1"), "jpeg");
assert.equal(getImageExtension("/tmp/figure"), "png");
const root = await makeTempDir("baoyu-md-images-");
t.after(() => fs.rm(root, { recursive: true, force: true }));
const baseDir = path.join(root, "article");
const tempDir = path.join(root, "tmp");
await fs.mkdir(baseDir, { recursive: true });
await fs.mkdir(tempDir, { recursive: true });
await fs.writeFile(path.join(baseDir, "figure.webp"), "webp");
const resolved = await resolveImagePath("figure.png", baseDir, tempDir, "test");
assert.equal(resolved, path.join(baseDir, "figure.webp"));
});
test("resolveContentImages resolves image placeholders against the content directory", async (t) => {
const root = await makeTempDir("baoyu-md-content-images-");
t.after(() => fs.rm(root, { recursive: true, force: true }));
const baseDir = path.join(root, "article");
const tempDir = path.join(root, "tmp");
await fs.mkdir(baseDir, { recursive: true });
await fs.mkdir(tempDir, { recursive: true });
await fs.writeFile(path.join(baseDir, "cover.png"), "png");
const resolved = await resolveContentImages(
[
{
alt: "cover",
originalPath: "cover.png",
placeholder: "IMG_1",
},
],
baseDir,
tempDir,
"test",
);
assert.deepEqual(resolved, [
{
alt: "cover",
originalPath: "cover.png",
placeholder: "IMG_1",
localPath: path.join(baseDir, "cover.png"),
},
]);
});
+64
View File
@@ -0,0 +1,64 @@
import assert from "node:assert/strict";
import test from "node:test";
import { initRenderer, renderMarkdown } from "./renderer.ts";
const render = (md: string) => {
const r = initRenderer();
return renderMarkdown(md, r).html;
};
test("bold with inline code (no underscore)", () => {
const html = render("**算出 `logits`,算出 `loss`。**");
assert.match(html, /<code[^>]*>logits<\/code>/);
assert.match(html, /<code[^>]*>loss<\/code>/);
});
test("bold with inline code (contains underscore)", () => {
const html = render("**变成 `input_ids`。**");
assert.match(html, /<code[^>]*>input_ids<\/code>/);
});
test("emphasis with inline code", () => {
const html = render("*查看 `hidden_states`*");
assert.match(html, /<code[^>]*>hidden_states<\/code>/);
});
test("plain inline code (regression)", () => {
const html = render("`lm_head`");
assert.match(html, /<code[^>]*>lm_head<\/code>/);
});
test("bold without code (regression)", () => {
const html = render("**纯粗体文本**");
assert.match(html, /<strong[^>]*>纯粗体文本<\/strong>/);
assert.doesNotMatch(html, /<code/);
});
test("bold with inline code containing backticks", () => {
const html = render("**``a`b``**");
assert.match(html, /<code[^>]*>a&#96;b<\/code>/);
});
test("emphasis with inline code containing backticks", () => {
const html = render("*``a`b``*");
assert.match(html, /<em[^>]*><code[^>]*>a&#96;b<\/code><\/em>/);
});
test("bold with inline code containing consecutive backticks", () => {
const html = render("**```a``b```**");
assert.match(html, /<code[^>]*>a&#96;&#96;b<\/code>/);
});
test("bold with inline code containing only backticks", () => {
const html = render("**```` `` ````**");
assert.match(html, /<code[^>]*>&#96;&#96;<\/code>/);
});
test("bold with inline code containing only spaces", () => {
const oneSpace = render("**`` ``**");
assert.match(oneSpace, /<code[^>]*> <\/code>/);
const twoSpaces = render("**`` ``**");
assert.match(twoSpaces, /<code[^>]*> <\/code>/);
});
+8
View File
@@ -109,6 +109,13 @@ function parseFrontMatterAndContent(markdownText: string): ParseResult {
}
}
function wrapInlineCode(value: string): string {
const runs = value.match(/`+/g);
const fence = "`".repeat(Math.max(...(runs?.map((run) => run.length) ?? [0])) + 1);
const padding = /^ *$/.test(value) ? "" : " ";
return `${fence}${padding}${value}${padding}${fence}`;
}
export function initRenderer(opts: IOpts = {}): RendererAPI {
const footnotes: [number, string, string][] = [];
let footnoteIndex = 0;
@@ -369,6 +376,7 @@ function preprocessCjkEmphasis(markdown: string): string {
const tree = processor.parse(markdown);
const extractText = (node: any): string => {
if (node.type === "text") return node.value;
if (node.type === "inlineCode") return wrapInlineCode(node.value);
if (node.children) return node.children.map(extractText).join("");
return "";
};
@@ -7,18 +7,18 @@ import test from "node:test";
import {
listReleaseFiles,
validateSelfContainedRelease,
} from "../scripts/lib/release-files.mjs";
} from "./release-files.mjs";
async function makeTempDir(prefix) {
async function makeTempDir(prefix: string): Promise<string> {
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
}
async function writeFile(filePath, contents = "") {
async function writeFile(filePath: string, contents = ""): Promise<void> {
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, contents);
}
async function writeJson(filePath, value) {
async function writeJson(filePath: string, value: unknown): Promise<void> {
await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`);
}
@@ -4,18 +4,18 @@ import os from "node:os";
import path from "node:path";
import test from "node:test";
import { syncSharedSkillPackages } from "../scripts/lib/shared-skill-packages.mjs";
import { syncSharedSkillPackages } from "./shared-skill-packages.mjs";
async function makeTempDir(prefix) {
async function makeTempDir(prefix: string): Promise<string> {
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
}
async function writeFile(filePath, contents = "") {
async function writeFile(filePath: string, contents = ""): Promise<void> {
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, contents);
}
async function writeJson(filePath, value) {
async function writeJson(filePath: string, value: unknown): Promise<void> {
await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`);
}
@@ -53,13 +53,13 @@ test("syncSharedSkillPackages vendors workspace packages into skill scripts", as
const updatedPackageJson = JSON.parse(
await fs.readFile(path.join(consumerDir, "package.json"), "utf8"),
);
) as { dependencies: Record<string, string> };
assert.equal(updatedPackageJson.dependencies["baoyu-md"], "file:./vendor/baoyu-md");
assert.equal(updatedPackageJson.dependencies.kleur, "^4.1.5");
const vendoredPackageJson = JSON.parse(
await fs.readFile(path.join(consumerDir, "vendor", "baoyu-md", "package.json"), "utf8"),
);
) as { name: string };
assert.equal(vendoredPackageJson.name, "baoyu-md");
const vendoredFile = await fs.readFile(
+16 -4
View File
@@ -133,7 +133,7 @@ Full procedures: [references/workflow.md](references/workflow.md#step-5-generate
### Step 6: Finalize
Insert `![description](path/NN-{type}-{slug}.png)` after paragraphs.
Insert `![description]({relative-path}/NN-{type}-{slug}.png)` after paragraphs. Path computed relative to article file based on output directory setting.
```
Article Illustration Complete!
@@ -143,15 +143,27 @@ Images: X/N generated
## Output Directory
Output directory is determined by `default_output_dir` in EXTEND.md (set during first-time setup):
| `default_output_dir` | Output Path | Markdown Insert Path |
|----------------------|-------------|----------------------|
| `imgs-subdir` (default) | `{article-dir}/imgs/` | `imgs/NN-{type}-{slug}.png` |
| `same-dir` | `{article-dir}/` | `NN-{type}-{slug}.png` |
| `illustrations-subdir` | `{article-dir}/illustrations/` | `illustrations/NN-{type}-{slug}.png` |
| `independent` | `illustrations/{topic-slug}/` | `illustrations/{topic-slug}/NN-{type}-{slug}.png` (relative to cwd) |
All auxiliary files (outline, prompts) are saved inside the output directory:
```
illustrations/{topic-slug}/
├── source-{slug}.{ext}
├── references/ # if provided
{output-dir}/
├── outline.md
├── prompts/
│ └── NN-{type}-{slug}.md
└── NN-{type}-{slug}.png
```
When input is **pasted content** (no file path), always uses `illustrations/{topic-slug}/` with `source-{slug}.{ext}` saved alongside.
**Slug**: 2-4 words, kebab-case. **Conflict**: append `-YYYYMMDD-HHMMSS`.
## Modification
@@ -69,7 +69,23 @@ options:
description: "Friendly, approachable, personal"
```
### Question 3: Save Location
### Question 3: Output Directory
```
header: "Output Directory"
question: "Where to save generated illustrations when illustrating a file?"
options:
- label: "imgs-subdir (Recommended)"
description: "{article-dir}/imgs/ — images in a subdirectory next to the article"
- label: "same-dir"
description: "{article-dir}/ — images alongside the article file"
- label: "illustrations-subdir"
description: "{article-dir}/illustrations/ — separate illustrations subdirectory"
- label: "independent"
description: "illustrations/{topic-slug}/ — standalone directory in cwd"
```
### Question 4: Save Location
```
header: "Save"
@@ -108,6 +124,7 @@ watermark:
preferred_style:
name: [selected style or null]
description: ""
default_output_dir: imgs-subdir # same-dir | imgs-subdir | illustrations-subdir | independent
language: null
custom_styles: []
---
@@ -55,7 +55,7 @@ Reference Style Extracted (no file):
| Input | Output Directory | Next |
|-------|------------------|------|
| File path | Ask user (1.2) | → 1.2 |
| File path | EXTEND.md `default_output_dir` (default: `imgs-subdir`). If not configured, confirm in 1.2. | → 1.2 |
| Pasted content | `illustrations/{topic-slug}/` | → 1.4 |
**Backup rule for pasted content**: If `source.md` exists in target directory, rename to `source-backup-YYYYMMDD-HHMMSS.md` before saving.
@@ -68,7 +68,7 @@ Check preferences and existing state, then ask ALL needed questions in ONE AskUs
| Question | When to Ask | Options |
|----------|-------------|---------|
| Output directory | No `default_output_dir` in EXTEND.md | `{article-dir}/`, `{article-dir}/imgs/` (Recommended), `{article-dir}/illustrations/`, `illustrations/{topic-slug}/` |
| Output directory | No `default_output_dir` in EXTEND.md | `{article-dir}/imgs/` (Recommended), `{article-dir}/`, `{article-dir}/illustrations/`, `illustrations/{topic-slug}/` |
| Existing images | Target dir has `.png/.jpg/.webp` files | `supplement`, `overwrite`, `regenerate` |
| Article update | Always (file path input) | `update`, `copy` |
@@ -237,7 +237,7 @@ Reference Images:
## Step 4: Generate Outline
Save as `outline.md`:
Save as `{output-dir}/outline.md` (all paths below are relative to the output directory determined in Step 1.1/1.2):
```yaml
---
@@ -285,7 +285,7 @@ references: # Only if references provided
For each illustration in the outline:
1. **Create prompt file**: `prompts/NN-{type}-{slug}.md`
1. **Create prompt file**: `{output-dir}/prompts/NN-{type}-{slug}.md`
2. **Include YAML frontmatter**:
```yaml
---
@@ -381,10 +381,14 @@ Add: `Include a subtle watermark "[content]" at [position].`
### 6.1 Update Article
Insert after corresponding paragraph:
```markdown
![description](illustrations/{slug}/NN-{type}-{slug}.png)
```
Insert after corresponding paragraph, using path relative to article file:
| `default_output_dir` | Insert Path |
|----------------------|-------------|
| `imgs-subdir` | `![description](imgs/NN-{type}-{slug}.png)` |
| `same-dir` | `![description](NN-{type}-{slug}.png)` |
| `illustrations-subdir` | `![description](illustrations/NN-{type}-{slug}.png)` |
| `independent` | `![description](illustrations/{topic-slug}/NN-{type}-{slug}.png)` (relative to cwd) |
Alt text: concise description in article's language.
+13 -1
View File
@@ -162,9 +162,21 @@ if (Test-Path "$HOME/.baoyu-skills/baoyu-cover-image/EXTEND.md") { "user" }
5. **Detect language**: Compare source, user input, EXTEND.md preference
6. **Determine output directory**: Per File Structure rules
**⚠️ People in Reference Images — MUST follow all 3 rules:**
If reference images contain **people** who should appear in the cover:
1. **`usage: direct`** — MUST set in refs description file. NEVER use `style` or `palette` when people need to appear
2. **Per-character description** — MUST describe each person's distinctive features (hair, glasses, skin tone, clothing) in `refs/ref-NN-{slug}.md`. Vague descriptions like "a man" will fail
3. **`--ref` flag** — MUST pass reference image via `--ref` in Step 4 so the model sees actual faces
See [reference-images.md § Character Analysis](references/workflow/reference-images.md) for description format.
### Step 2: Confirm Options ⚠️
Full confirmation flow: [references/workflow/confirm-options.md](references/workflow/confirm-options.md)
**MUST use `AskUserQuestion` tool** to present options as interactive selection — NOT plain text tables. Present up to 4 questions in a single `AskUserQuestion` call (Type, Palette, Rendering, Font + Settings). Each question shows the recommended option first with reason, followed by alternatives.
Full confirmation flow and question format: [references/workflow/confirm-options.md](references/workflow/confirm-options.md)
| Condition | Skipped | Still Asked |
|-----------|---------|-------------|
@@ -83,12 +83,17 @@ Full library: [references/visual-elements.md](visual-elements.md)
### Character Handling
When people are needed:
**Default (no reference with people)**:
- Use simplified silhouettes or abstract stick figures
- Symbolic representations (head + shoulders outline)
- NO realistic faces, detailed anatomy, or photographic representations
- Cartoon/icon style consistent with rendering choice
**When reference images contain people**:
- Reference image is passed to model (`usage: direct`) — model must visually reference it to preserve character likeness
- Stylize to match chosen rendering (cartoon/vector), preserving distinctive features (hair, clothing, pose)
- NEVER photorealistic
## Mood Application
Apply mood adjustments to the base palette:
@@ -201,6 +201,12 @@ CRITICAL: The generated cover MUST visually reference the provided images. The c
- [Typography]: [Specific treatment, e.g., "Uppercase text with wide letter-spacing"]
- [Layout element]: [Specific spatial element, e.g., "Bottom banner strip in dark color"]
## From Ref 1 ([filename]) — Characters (if people present):
- **Character 1**: [Appearance, e.g., "Woman, long wavy blonde hair"] → MUST stylize: [e.g., "flat-vector, simplified face, keep blonde hair, label: 'Nicole Forsgren'"]
- **Character 2**: [Appearance, e.g., "Man, short dark hair, stubble"] → MUST stylize: [e.g., "flat-vector, simplified face, keep dark hair, label: 'Gergely Orosz'"]
- **Placement**: [e.g., "Right third, side by side, facing left toward main visual"]
- **Style**: Match rendering style, NOT photorealistic
## From Ref 2 ([filename]) — REQUIRED elements:
[Same detailed breakdown]
@@ -31,8 +31,8 @@ usage: direct | style | palette
| Usage | When to Use |
|-------|-------------|
| `direct` | Reference matches desired output closely |
| `style` | Extract visual style characteristics only |
| `direct` | Model sees reference image directly; required if people must appear in output |
| `style` | Extract visual style only (not for people who must appear) |
| `palette` | Extract color scheme only |
## Verbal Extraction (No File)
@@ -59,6 +59,19 @@ References are high-priority inputs. Extract **specific, concrete, reproducible*
**Output format**: List each element as bullet that can be copy-pasted into prompt as mandatory instruction.
### Character Analysis ⚠️ If Reference Contains People
Use `usage: direct` so model sees the reference image. Additionally describe per character: **appearance**, **pose**, **clothing** → with **transformation rules** (stylize to match rendering).
| Extract | Good | Bad |
|---------|------|-----|
| Appearance | "Woman: long wavy blonde hair, friendly smile" | "A woman" |
| Pose | "Standing, facing camera, confident posture" | "Standing" |
| Clothing | "Dark T-shirt, business casual" | "Formal" |
| Transform | "Flat-vector cartoon, keep hair color & clothing" | "Make cartoon" |
Use `usage: direct`. Output each character as MUST/REQUIRED prompt instruction.
## Verification Output
**For saved files**:
@@ -2,6 +2,7 @@ import process from 'node:process';
import {
CdpConnection,
discoverRunningChromeDebugPort,
findChromeExecutable as findChromeExecutableBase,
findExistingChromeDebugPort,
getFreePort,
@@ -97,6 +98,92 @@ async function is_gemini_session_ready(cookies: CookieMap, verbose: boolean): Pr
}
}
async function fetch_cookies_from_existing_chrome(
timeoutMs: number,
verbose: boolean,
): Promise<CookieMap | null> {
const discovered = await discoverRunningChromeDebugPort();
if (discovered === null) return null;
if (verbose) logger.info(`Found existing Chrome on port ${discovered.port}. Connecting via WebSocket...`);
let cdp: CdpConnection | null = null;
let createdTab = false;
let targetId: string | null = null;
try {
const connectStart = Date.now();
const connectTimeout = 30_000;
let lastConnErr: unknown = null;
while (Date.now() - connectStart < connectTimeout) {
try {
cdp = await CdpConnection.connect(discovered.wsUrl, 5_000);
break;
} catch (e) {
lastConnErr = e;
if (verbose) logger.debug(`WebSocket connect attempt failed: ${e instanceof Error ? e.message : String(e)}, retrying...`);
await sleep(1000);
}
}
if (!cdp) {
if (verbose) logger.debug(`Could not connect to Chrome after ${connectTimeout / 1000}s: ${lastConnErr instanceof Error ? lastConnErr.message : String(lastConnErr)}`);
return null;
}
const targets = await cdp.send<{ targetInfos: Array<{ targetId: string; url: string; type: string }> }>('Target.getTargets');
const hasGeminiTab = targets.targetInfos.some(
(t) => t.type === 'page' && t.url.includes('gemini.google.com'),
);
createdTab = !hasGeminiTab;
if (verbose) logger.debug(hasGeminiTab ? 'Found existing Gemini tab, attaching...' : 'No Gemini tab found, creating new tab...');
const page = await openPageSession({
cdp,
reusing: false,
url: GEMINI_APP_URL,
matchTarget: (target) => target.type === 'page' && target.url.includes('gemini.google.com'),
enableNetwork: true,
activateTarget: false,
});
const { sessionId } = page;
targetId = page.targetId;
const start = Date.now();
let last: CookieMap = {};
while (Date.now() - start < timeoutMs) {
const { cookies } = await cdp.send<{ cookies: Array<{ name: string; value: string }> }>(
'Network.getCookies',
{ urls: ['https://gemini.google.com/', 'https://accounts.google.com/', 'https://www.google.com/'] },
{ sessionId, timeoutMs: 10_000 },
);
const cookieMap: CookieMap = {};
for (const cookie of cookies) {
if (cookie?.name && typeof cookie.value === 'string') cookieMap[cookie.name] = cookie.value;
}
last = cookieMap;
if (await is_gemini_session_ready(cookieMap, verbose)) return cookieMap;
await sleep(1000);
}
if (verbose) logger.debug(`Existing Chrome did not yield valid cookies. Last keys: ${Object.keys(last).join(', ')}`);
return null;
} catch (e) {
if (verbose) logger.debug(`Failed to connect to existing Chrome: ${e instanceof Error ? e.message : String(e)}`);
return null;
} finally {
if (cdp) {
if (createdTab && targetId) {
try { await cdp.send('Target.closeTarget', { targetId }, { timeoutMs: 5_000 }); } catch {}
}
cdp.close();
}
}
}
async function fetch_google_cookies_via_cdp(
profileDir: string,
timeoutMs: number,
@@ -178,6 +265,19 @@ export async function load_browser_cookies(domain_name: string = '', verbose: bo
if (cached) return { chrome: cached };
}
const hasExplicitProfile = !!(process.env.GEMINI_WEB_CHROME_PROFILE_DIR?.trim() || process.env.BAOYU_CHROME_PROFILE_DIR?.trim());
const existingCookies = hasExplicitProfile ? null : await fetch_cookies_from_existing_chrome(30_000, verbose);
if (existingCookies) {
const filtered: CookieMap = {};
for (const [key, value] of Object.entries(existingCookies)) {
if (typeof value === 'string' && value.length > 0) filtered[key] = value;
}
await write_cookie_file(filtered, resolveGeminiWebCookiePath(), 'cdp-existing');
void domain_name;
return { chrome: filtered };
}
const profileDir = process.env.GEMINI_WEB_CHROME_PROFILE_DIR?.trim() || resolveGeminiWebChromeProfileDir();
const cookies = await fetch_google_cookies_via_cdp(profileDir, 120_000, verbose);
@@ -0,0 +1,171 @@
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import http from "node:http";
import os from "node:os";
import path from "node:path";
import process from "node:process";
import test, { type TestContext } from "node:test";
import {
findChromeExecutable,
findExistingChromeDebugPort,
getFreePort,
resolveSharedChromeProfileDir,
waitForChromeDebugPort,
} from "./index.ts";
function useEnv(
t: TestContext,
values: Record<string, string | null>,
): void {
const previous = new Map<string, string | undefined>();
for (const [key, value] of Object.entries(values)) {
previous.set(key, process.env[key]);
if (value == null) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
t.after(() => {
for (const [key, value] of previous.entries()) {
if (value == null) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
});
}
async function makeTempDir(prefix: string): Promise<string> {
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
}
async function startDebugServer(port: number): Promise<http.Server> {
const server = http.createServer((req, res) => {
if (req.url === "/json/version") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({
webSocketDebuggerUrl: `ws://127.0.0.1:${port}/devtools/browser/demo`,
}));
return;
}
res.writeHead(404);
res.end();
});
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(port, "127.0.0.1", () => resolve());
});
return server;
}
async function closeServer(server: http.Server): Promise<void> {
await new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) reject(error);
else resolve();
});
});
}
test("getFreePort honors a fixed environment override and otherwise allocates a TCP port", async (t) => {
useEnv(t, { TEST_FIXED_PORT: "45678" });
assert.equal(await getFreePort("TEST_FIXED_PORT"), 45678);
const dynamicPort = await getFreePort();
assert.ok(Number.isInteger(dynamicPort));
assert.ok(dynamicPort > 0);
});
test("findChromeExecutable prefers env overrides and falls back to candidate paths", async (t) => {
const root = await makeTempDir("baoyu-chrome-bin-");
t.after(() => fs.rm(root, { recursive: true, force: true }));
const envChrome = path.join(root, "env-chrome");
const fallbackChrome = path.join(root, "fallback-chrome");
await fs.writeFile(envChrome, "");
await fs.writeFile(fallbackChrome, "");
useEnv(t, { BAOYU_CHROME_PATH: envChrome });
assert.equal(
findChromeExecutable({
envNames: ["BAOYU_CHROME_PATH"],
candidates: { default: [fallbackChrome] },
}),
envChrome,
);
useEnv(t, { BAOYU_CHROME_PATH: null });
assert.equal(
findChromeExecutable({
envNames: ["BAOYU_CHROME_PATH"],
candidates: { default: [fallbackChrome] },
}),
fallbackChrome,
);
});
test("resolveSharedChromeProfileDir supports env overrides, WSL paths, and default suffixes", (t) => {
useEnv(t, { BAOYU_SHARED_PROFILE: "/tmp/custom-profile" });
assert.equal(
resolveSharedChromeProfileDir({
envNames: ["BAOYU_SHARED_PROFILE"],
appDataDirName: "demo-app",
profileDirName: "demo-profile",
}),
path.resolve("/tmp/custom-profile"),
);
useEnv(t, { BAOYU_SHARED_PROFILE: null });
assert.equal(
resolveSharedChromeProfileDir({
wslWindowsHome: "/mnt/c/Users/demo",
appDataDirName: "demo-app",
profileDirName: "demo-profile",
}),
path.join("/mnt/c/Users/demo", ".local", "share", "demo-app", "demo-profile"),
);
const fallback = resolveSharedChromeProfileDir({
appDataDirName: "demo-app",
profileDirName: "demo-profile",
});
assert.match(fallback, /demo-app[\\/]demo-profile$/);
});
test("findExistingChromeDebugPort reads DevToolsActivePort and validates it against a live endpoint", async (t) => {
const root = await makeTempDir("baoyu-cdp-profile-");
t.after(() => fs.rm(root, { recursive: true, force: true }));
const port = await getFreePort();
const server = await startDebugServer(port);
t.after(() => closeServer(server));
await fs.writeFile(path.join(root, "DevToolsActivePort"), `${port}\n/devtools/browser/demo\n`);
const found = await findExistingChromeDebugPort({ profileDir: root, timeoutMs: 1000 });
assert.equal(found, port);
});
test("waitForChromeDebugPort retries until the debug endpoint becomes available", async (t) => {
const port = await getFreePort();
const serverPromise = (async () => {
await new Promise((resolve) => setTimeout(resolve, 200));
const server = await startDebugServer(port);
t.after(() => closeServer(server));
})();
const websocketUrl = await waitForChromeDebugPort(port, 4000, {
includeLastError: true,
});
await serverPromise;
assert.equal(websocketUrl, `ws://127.0.0.1:${port}/devtools/browser/demo`);
});
@@ -43,6 +43,18 @@ type FindExistingChromeDebugPortOptions = {
timeoutMs?: number;
};
export type ChromeChannel = "stable" | "beta" | "canary" | "dev";
export type DiscoveredChrome = {
port: number;
wsUrl: string;
};
type DiscoverRunningChromeOptions = {
channels?: ChromeChannel[];
timeoutMs?: number;
};
type LaunchChromeOptions = {
chromePath: string;
profileDir: string;
@@ -173,16 +185,32 @@ async function isDebugPortReady(port: number, timeoutMs = 3_000): Promise<boolea
}
}
function isPortListening(port: number, timeoutMs = 3_000): Promise<boolean> {
return new Promise((resolve) => {
const socket = new net.Socket();
const timer = setTimeout(() => { socket.destroy(); resolve(false); }, timeoutMs);
socket.once("connect", () => { clearTimeout(timer); socket.destroy(); resolve(true); });
socket.once("error", () => { clearTimeout(timer); resolve(false); });
socket.connect(port, "127.0.0.1");
});
}
function parseDevToolsActivePort(filePath: string): { port: number; wsPath: string } | null {
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;
}
export async function findExistingChromeDebugPort(options: FindExistingChromeDebugPortOptions): Promise<number | null> {
const timeoutMs = options.timeoutMs ?? 3_000;
const portFile = path.join(options.profileDir, "DevToolsActivePort");
const parsed = parseDevToolsActivePort(path.join(options.profileDir, "DevToolsActivePort"));
try {
const content = fs.readFileSync(portFile, "utf-8");
const [portLine] = content.split(/\r?\n/);
const port = Number.parseInt(portLine?.trim() ?? "", 10);
if (port > 0 && await isDebugPortReady(port, timeoutMs)) return port;
} catch {}
if (parsed && parsed.port > 0 && await isDebugPortReady(parsed.port, timeoutMs)) return parsed.port;
if (process.platform === "win32") return null;
@@ -204,6 +232,81 @@ export async function findExistingChromeDebugPort(options: FindExistingChromeDeb
return null;
}
export function getDefaultChromeUserDataDirs(channels: ChromeChannel[] = ["stable"]): string[] {
const home = os.homedir();
const dirs: string[] = [];
const channelDirs: Record<string, { darwin: string; linux: string; win32: string }> = {
stable: {
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome"),
linux: path.join(home, ".config", "google-chrome"),
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome", "User Data"),
},
beta: {
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome Beta"),
linux: path.join(home, ".config", "google-chrome-beta"),
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome Beta", "User Data"),
},
canary: {
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome Canary"),
linux: path.join(home, ".config", "google-chrome-canary"),
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome SxS", "User Data"),
},
dev: {
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome Dev"),
linux: path.join(home, ".config", "google-chrome-dev"),
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome Dev", "User Data"),
},
};
const platform = process.platform === "darwin" ? "darwin" : process.platform === "win32" ? "win32" : "linux";
for (const ch of channels) {
const entry = channelDirs[ch];
if (entry) dirs.push(entry[platform]);
}
return dirs;
}
export async function discoverRunningChromeDebugPort(options: DiscoverRunningChromeOptions = {}): Promise<DiscoveredChrome | null> {
const channels = options.channels ?? ["stable", "beta", "canary", "dev"];
const timeoutMs = options.timeoutMs ?? 3_000;
const userDataDirs = getDefaultChromeUserDataDirs(channels);
for (const dir of userDataDirs) {
const parsed = parseDevToolsActivePort(path.join(dir, "DevToolsActivePort"));
if (!parsed) continue;
if (await isPortListening(parsed.port, timeoutMs)) {
return { port: parsed.port, wsUrl: `ws://127.0.0.1:${parsed.port}${parsed.wsPath}` };
}
}
if (process.platform !== "win32") {
try {
const result = spawnSync("ps", ["aux"], { encoding: "utf-8", timeout: 5_000 });
if (result.status === 0 && result.stdout) {
const lines = result.stdout
.split("\n")
.filter((line) => line.includes("--remote-debugging-port=") && /chrome|chromium/i.test(line));
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)) {
try {
const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(`http://127.0.0.1:${port}/json/version`, { timeoutMs });
if (version.webSocketDebuggerUrl) return { port, wsUrl: version.webSocketDebuggerUrl };
} catch {}
}
}
}
} catch {}
}
return null;
}
export async function waitForChromeDebugPort(
port: number,
timeoutMs: number,
@@ -0,0 +1,171 @@
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import http from "node:http";
import os from "node:os";
import path from "node:path";
import process from "node:process";
import test, { type TestContext } from "node:test";
import {
findChromeExecutable,
findExistingChromeDebugPort,
getFreePort,
resolveSharedChromeProfileDir,
waitForChromeDebugPort,
} from "./index.ts";
function useEnv(
t: TestContext,
values: Record<string, string | null>,
): void {
const previous = new Map<string, string | undefined>();
for (const [key, value] of Object.entries(values)) {
previous.set(key, process.env[key]);
if (value == null) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
t.after(() => {
for (const [key, value] of previous.entries()) {
if (value == null) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
});
}
async function makeTempDir(prefix: string): Promise<string> {
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
}
async function startDebugServer(port: number): Promise<http.Server> {
const server = http.createServer((req, res) => {
if (req.url === "/json/version") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({
webSocketDebuggerUrl: `ws://127.0.0.1:${port}/devtools/browser/demo`,
}));
return;
}
res.writeHead(404);
res.end();
});
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(port, "127.0.0.1", () => resolve());
});
return server;
}
async function closeServer(server: http.Server): Promise<void> {
await new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) reject(error);
else resolve();
});
});
}
test("getFreePort honors a fixed environment override and otherwise allocates a TCP port", async (t) => {
useEnv(t, { TEST_FIXED_PORT: "45678" });
assert.equal(await getFreePort("TEST_FIXED_PORT"), 45678);
const dynamicPort = await getFreePort();
assert.ok(Number.isInteger(dynamicPort));
assert.ok(dynamicPort > 0);
});
test("findChromeExecutable prefers env overrides and falls back to candidate paths", async (t) => {
const root = await makeTempDir("baoyu-chrome-bin-");
t.after(() => fs.rm(root, { recursive: true, force: true }));
const envChrome = path.join(root, "env-chrome");
const fallbackChrome = path.join(root, "fallback-chrome");
await fs.writeFile(envChrome, "");
await fs.writeFile(fallbackChrome, "");
useEnv(t, { BAOYU_CHROME_PATH: envChrome });
assert.equal(
findChromeExecutable({
envNames: ["BAOYU_CHROME_PATH"],
candidates: { default: [fallbackChrome] },
}),
envChrome,
);
useEnv(t, { BAOYU_CHROME_PATH: null });
assert.equal(
findChromeExecutable({
envNames: ["BAOYU_CHROME_PATH"],
candidates: { default: [fallbackChrome] },
}),
fallbackChrome,
);
});
test("resolveSharedChromeProfileDir supports env overrides, WSL paths, and default suffixes", (t) => {
useEnv(t, { BAOYU_SHARED_PROFILE: "/tmp/custom-profile" });
assert.equal(
resolveSharedChromeProfileDir({
envNames: ["BAOYU_SHARED_PROFILE"],
appDataDirName: "demo-app",
profileDirName: "demo-profile",
}),
path.resolve("/tmp/custom-profile"),
);
useEnv(t, { BAOYU_SHARED_PROFILE: null });
assert.equal(
resolveSharedChromeProfileDir({
wslWindowsHome: "/mnt/c/Users/demo",
appDataDirName: "demo-app",
profileDirName: "demo-profile",
}),
path.join("/mnt/c/Users/demo", ".local", "share", "demo-app", "demo-profile"),
);
const fallback = resolveSharedChromeProfileDir({
appDataDirName: "demo-app",
profileDirName: "demo-profile",
});
assert.match(fallback, /demo-app[\\/]demo-profile$/);
});
test("findExistingChromeDebugPort reads DevToolsActivePort and validates it against a live endpoint", async (t) => {
const root = await makeTempDir("baoyu-cdp-profile-");
t.after(() => fs.rm(root, { recursive: true, force: true }));
const port = await getFreePort();
const server = await startDebugServer(port);
t.after(() => closeServer(server));
await fs.writeFile(path.join(root, "DevToolsActivePort"), `${port}\n/devtools/browser/demo\n`);
const found = await findExistingChromeDebugPort({ profileDir: root, timeoutMs: 1000 });
assert.equal(found, port);
});
test("waitForChromeDebugPort retries until the debug endpoint becomes available", async (t) => {
const port = await getFreePort();
const serverPromise = (async () => {
await new Promise((resolve) => setTimeout(resolve, 200));
const server = await startDebugServer(port);
t.after(() => closeServer(server));
})();
const websocketUrl = await waitForChromeDebugPort(port, 4000, {
includeLastError: true,
});
await serverPromise;
assert.equal(websocketUrl, `ws://127.0.0.1:${port}/devtools/browser/demo`);
});
@@ -43,6 +43,18 @@ type FindExistingChromeDebugPortOptions = {
timeoutMs?: number;
};
export type ChromeChannel = "stable" | "beta" | "canary" | "dev";
export type DiscoveredChrome = {
port: number;
wsUrl: string;
};
type DiscoverRunningChromeOptions = {
channels?: ChromeChannel[];
timeoutMs?: number;
};
type LaunchChromeOptions = {
chromePath: string;
profileDir: string;
@@ -173,16 +185,32 @@ async function isDebugPortReady(port: number, timeoutMs = 3_000): Promise<boolea
}
}
function isPortListening(port: number, timeoutMs = 3_000): Promise<boolean> {
return new Promise((resolve) => {
const socket = new net.Socket();
const timer = setTimeout(() => { socket.destroy(); resolve(false); }, timeoutMs);
socket.once("connect", () => { clearTimeout(timer); socket.destroy(); resolve(true); });
socket.once("error", () => { clearTimeout(timer); resolve(false); });
socket.connect(port, "127.0.0.1");
});
}
function parseDevToolsActivePort(filePath: string): { port: number; wsPath: string } | null {
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;
}
export async function findExistingChromeDebugPort(options: FindExistingChromeDebugPortOptions): Promise<number | null> {
const timeoutMs = options.timeoutMs ?? 3_000;
const portFile = path.join(options.profileDir, "DevToolsActivePort");
const parsed = parseDevToolsActivePort(path.join(options.profileDir, "DevToolsActivePort"));
try {
const content = fs.readFileSync(portFile, "utf-8");
const [portLine] = content.split(/\r?\n/);
const port = Number.parseInt(portLine?.trim() ?? "", 10);
if (port > 0 && await isDebugPortReady(port, timeoutMs)) return port;
} catch {}
if (parsed && parsed.port > 0 && await isDebugPortReady(parsed.port, timeoutMs)) return parsed.port;
if (process.platform === "win32") return null;
@@ -204,6 +232,81 @@ export async function findExistingChromeDebugPort(options: FindExistingChromeDeb
return null;
}
export function getDefaultChromeUserDataDirs(channels: ChromeChannel[] = ["stable"]): string[] {
const home = os.homedir();
const dirs: string[] = [];
const channelDirs: Record<string, { darwin: string; linux: string; win32: string }> = {
stable: {
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome"),
linux: path.join(home, ".config", "google-chrome"),
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome", "User Data"),
},
beta: {
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome Beta"),
linux: path.join(home, ".config", "google-chrome-beta"),
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome Beta", "User Data"),
},
canary: {
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome Canary"),
linux: path.join(home, ".config", "google-chrome-canary"),
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome SxS", "User Data"),
},
dev: {
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome Dev"),
linux: path.join(home, ".config", "google-chrome-dev"),
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome Dev", "User Data"),
},
};
const platform = process.platform === "darwin" ? "darwin" : process.platform === "win32" ? "win32" : "linux";
for (const ch of channels) {
const entry = channelDirs[ch];
if (entry) dirs.push(entry[platform]);
}
return dirs;
}
export async function discoverRunningChromeDebugPort(options: DiscoverRunningChromeOptions = {}): Promise<DiscoveredChrome | null> {
const channels = options.channels ?? ["stable", "beta", "canary", "dev"];
const timeoutMs = options.timeoutMs ?? 3_000;
const userDataDirs = getDefaultChromeUserDataDirs(channels);
for (const dir of userDataDirs) {
const parsed = parseDevToolsActivePort(path.join(dir, "DevToolsActivePort"));
if (!parsed) continue;
if (await isPortListening(parsed.port, timeoutMs)) {
return { port: parsed.port, wsUrl: `ws://127.0.0.1:${parsed.port}${parsed.wsPath}` };
}
}
if (process.platform !== "win32") {
try {
const result = spawnSync("ps", ["aux"], { encoding: "utf-8", timeout: 5_000 });
if (result.status === 0 && result.stdout) {
const lines = result.stdout
.split("\n")
.filter((line) => line.includes("--remote-debugging-port=") && /chrome|chromium/i.test(line));
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)) {
try {
const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(`http://127.0.0.1:${port}/json/version`, { timeoutMs });
if (version.webSocketDebuggerUrl) return { port, wsUrl: version.webSocketDebuggerUrl };
} catch {}
}
}
}
} catch {}
}
return null;
}
export async function waitForChromeDebugPort(
port: number,
timeoutMs: number,
+54 -2
View File
@@ -92,6 +92,12 @@ ${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider o
# DashScope (阿里通义万象)
${BUN_X} {baseDir}/scripts/main.ts --prompt "一只可爱的猫" --image out.png --provider dashscope
# DashScope Qwen-Image 2.0 Pro (recommended for custom sizes and text rendering)
${BUN_X} {baseDir}/scripts/main.ts --prompt "为咖啡品牌设计一张 21:9 横幅海报,包含清晰中文标题" --image out.png --provider dashscope --model qwen-image-2.0-pro --size 2048x872
# DashScope legacy Qwen fixed-size model
${BUN_X} {baseDir}/scripts/main.ts --prompt "一张电影感海报" --image out.png --provider dashscope --model qwen-image-max --size 1664x928
# Replicate (google/nano-banana-pro)
${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider replicate
@@ -142,7 +148,7 @@ Paths in `promptFiles`, `image`, and `ref` are resolved relative to the batch fi
| `--batchfile <path>` | JSON batch file for multi-image generation |
| `--jobs <count>` | Worker count for batch mode (default: auto, max from config, built-in default 10) |
| `--provider google\|openai\|openrouter\|dashscope\|jimeng\|seedream\|replicate` | Force provider (default: auto-detect) |
| `--model <id>`, `-m` | Model ID (Google: `gemini-3-pro-image-preview`; OpenAI: `gpt-image-1.5`; OpenRouter: `google/gemini-3.1-flash-image-preview`) |
| `--model <id>`, `-m` | Model ID (Google: `gemini-3-pro-image-preview`; OpenAI: `gpt-image-1.5`; OpenRouter: `google/gemini-3.1-flash-image-preview`; DashScope: `qwen-image-2.0-pro`) |
| `--ar <ratio>` | Aspect ratio (e.g., `16:9`, `1:1`, `4:3`) |
| `--size <WxH>` | Size (e.g., `1024x1024`) |
| `--quality normal\|2k` | Quality preset (default: `2k`) |
@@ -166,7 +172,7 @@ Paths in `promptFiles`, `image`, and `ref` are resolved relative to the batch fi
| `OPENAI_IMAGE_MODEL` | OpenAI model override |
| `OPENROUTER_IMAGE_MODEL` | OpenRouter model override (default: `google/gemini-3.1-flash-image-preview`) |
| `GOOGLE_IMAGE_MODEL` | Google model override |
| `DASHSCOPE_IMAGE_MODEL` | DashScope model override (default: z-image-turbo) |
| `DASHSCOPE_IMAGE_MODEL` | DashScope model override (default: `qwen-image-2.0-pro`) |
| `REPLICATE_IMAGE_MODEL` | Replicate model override (default: google/nano-banana-pro) |
| `JIMENG_IMAGE_MODEL` | Jimeng model override (default: jimeng_t2i_v40) |
| `SEEDREAM_IMAGE_MODEL` | Seedream model override (default: doubao-seedream-5-0-260128) |
@@ -201,6 +207,52 @@ Model priority (highest → lowest), applies to all providers:
- Show: `Using [provider] / [model]`
- Show switch hint: `Switch model: --model <id> | EXTEND.md default_model.[provider] | env <PROVIDER>_IMAGE_MODEL`
### DashScope Models
Use `--model qwen-image-2.0-pro` or set `default_model.dashscope` / `DASHSCOPE_IMAGE_MODEL` when the user wants official Qwen-Image behavior.
Official DashScope model families:
- `qwen-image-2.0-pro`, `qwen-image-2.0-pro-2026-03-03`, `qwen-image-2.0`, `qwen-image-2.0-2026-03-03`
- Free-form `size` in `宽*高` format
- Total pixels must stay between `512*512` and `2048*2048`
- Default size is approximately `1024*1024`
- Best choice for custom ratios such as `21:9` and text-heavy Chinese/English layouts
- `qwen-image-max`, `qwen-image-max-2025-12-30`, `qwen-image-plus`, `qwen-image-plus-2026-01-09`, `qwen-image`
- Fixed sizes only: `1664*928`, `1472*1104`, `1328*1328`, `1104*1472`, `928*1664`
- Default size is `1664*928`
- `qwen-image` currently has the same capability as `qwen-image-plus`
- Legacy DashScope models such as `z-image-turbo`, `z-image-ultra`, `wanx-v1`
- Keep using them only when the user explicitly asks for legacy behavior or compatibility
When translating CLI args into DashScope behavior:
- `--size` wins over `--ar`
- For `qwen-image-2.0*`, prefer explicit `--size`; otherwise infer from `--ar` and use the official recommended resolutions below
- For `qwen-image-max/plus/image`, only use the five official fixed sizes; if the requested ratio is not covered, switch to `qwen-image-2.0-pro`
- `--quality` is a baoyu-image-gen compatibility preset, not a native DashScope API field. Mapping `normal` / `2k` onto the `qwen-image-2.0*` table below is an implementation inference, not an official API guarantee
Recommended `qwen-image-2.0*` sizes for common aspect ratios:
| Ratio | `normal` | `2k` |
|-------|----------|------|
| `1:1` | `1024*1024` | `1536*1536` |
| `2:3` | `768*1152` | `1024*1536` |
| `3:2` | `1152*768` | `1536*1024` |
| `3:4` | `960*1280` | `1080*1440` |
| `4:3` | `1280*960` | `1440*1080` |
| `9:16` | `720*1280` | `1080*1920` |
| `16:9` | `1280*720` | `1920*1080` |
| `21:9` | `1344*576` | `2048*872` |
DashScope official APIs also expose `negative_prompt`, `prompt_extend`, and `watermark`, but `baoyu-image-gen` does not expose them as dedicated CLI flags today.
Official references:
- [Qwen-Image API](https://help.aliyun.com/zh/model-studio/qwen-image-api)
- [Text-to-image guide](https://help.aliyun.com/zh/model-studio/text-to-image)
- [Qwen-Image Edit API](https://help.aliyun.com/zh/model-studio/qwen-image-edit-api)
### OpenRouter Models
Use full OpenRouter model IDs, e.g.:
@@ -50,7 +50,7 @@ options:
- label: "OpenRouter"
description: "Router for Gemini/FLUX/OpenAI-compatible image models"
- label: "DashScope"
description: "Alibaba Cloud - z-image-turbo, good for Chinese content"
description: "Alibaba Cloud - Qwen-Image, strong Chinese/English text rendering"
- label: "Replicate"
description: "Community models - nano-banana-pro, flexible model selection"
```
@@ -186,12 +186,26 @@ options:
header: "DashScope Model"
question: "Choose a default DashScope image generation model?"
options:
- label: "z-image-turbo (Recommended)"
description: "Fast generation, good quality"
- label: "qwen-image-2.0-pro (Recommended)"
description: "Best DashScope model for text rendering and custom sizes"
- label: "qwen-image-2.0"
description: "Faster 2.0 variant with flexible output size"
- label: "qwen-image-max"
description: "Legacy Qwen model with five fixed output sizes"
- label: "qwen-image-plus"
description: "Legacy Qwen model, same current capability as qwen-image"
- label: "z-image-turbo"
description: "Legacy DashScope model for compatibility"
- label: "z-image-ultra"
description: "Higher quality, slower generation"
description: "Legacy DashScope model, higher quality but slower"
```
Notes for DashScope setup:
- Prefer `qwen-image-2.0-pro` when the user needs custom `--size`, uncommon ratios like `21:9`, or strong Chinese/English text rendering.
- `qwen-image-max` / `qwen-image-plus` / `qwen-image` only support five fixed sizes: `1664*928`, `1472*1104`, `1328*1328`, `1104*1472`, `928*1664`.
- In `baoyu-image-gen`, `quality` is a compatibility preset. It is not a native DashScope parameter.
### Replicate Model Selection
```yaml
@@ -23,7 +23,7 @@ default_model:
google: null # e.g., "gemini-3-pro-image-preview", "gemini-3.1-flash-image-preview"
openai: null # e.g., "gpt-image-1.5", "gpt-image-1"
openrouter: null # e.g., "google/gemini-3.1-flash-image-preview"
dashscope: null # e.g., "z-image-turbo"
dashscope: null # e.g., "qwen-image-2.0-pro"
replicate: null # e.g., "google/nano-banana-pro"
batch:
@@ -88,7 +88,7 @@ default_model:
google: "gemini-3-pro-image-preview"
openai: "gpt-image-1.5"
openrouter: "google/gemini-3.1-flash-image-preview"
dashscope: "z-image-turbo"
dashscope: "qwen-image-2.0-pro"
replicate: "google/nano-banana-pro"
batch:
max_workers: 10
@@ -2,8 +2,9 @@ import assert from "node:assert/strict";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import test, { type TestContext } from "node:test";
import type { CliArgs, ExtendConfig } from "./types.ts";
import {
createTaskArgs,
detectProvider,
@@ -16,9 +17,9 @@ import {
normalizeOutputImagePath,
parseArgs,
parseSimpleYaml,
} from "../../../skills/baoyu-image-gen/scripts/main.ts";
} from "./main.ts";
function makeArgs(overrides = {}) {
function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
return {
prompt: null,
promptFiles: [],
@@ -39,8 +40,11 @@ function makeArgs(overrides = {}) {
};
}
function useEnv(t, values) {
const previous = new Map();
function useEnv(
t: TestContext,
values: Record<string, string | null>,
): void {
const previous = new Map<string, string | undefined>();
for (const [key, value] of Object.entries(values)) {
previous.set(key, process.env[key]);
if (value == null) {
@@ -61,7 +65,7 @@ function useEnv(t, values) {
});
}
async function makeTempDir(prefix) {
async function makeTempDir(prefix: string): Promise<string> {
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
}
@@ -161,7 +165,7 @@ test("mergeConfig only fills values missing from CLI args", () => {
default_quality: "2k",
default_aspect_ratio: "3:2",
default_image_size: "2K",
},
} satisfies Partial<ExtendConfig>,
);
assert.equal(merged.provider, "openai");
@@ -219,7 +223,7 @@ test("batch worker and provider-rate-limit configuration prefer env over EXTEND
BAOYU_IMAGE_GEN_GOOGLE_START_INTERVAL_MS: "450",
});
const extendConfig = {
const extendConfig: Partial<ExtendConfig> = {
batch: {
max_workers: 7,
provider_limits: {
@@ -263,7 +267,7 @@ test("loadBatchTasks and createTaskArgs resolve batch-relative paths", async (t)
const loaded = await loadBatchTasks(batchFile);
assert.equal(loaded.jobs, 2);
assert.equal(loaded.batchDir, path.dirname(batchFile));
assert.equal(loaded.tasks[0].id, "hero");
assert.equal(loaded.tasks[0]?.id, "hero");
const taskArgs = createTaskArgs(
makeArgs({
@@ -271,7 +275,7 @@ test("loadBatchTasks and createTaskArgs resolve batch-relative paths", async (t)
quality: "2k",
json: true,
}),
loaded.tasks[0],
loaded.tasks[0]!,
loaded.batchDir,
);
+1 -1
View File
@@ -116,7 +116,7 @@ Environment variables:
OPENAI_IMAGE_MODEL Default OpenAI model (gpt-image-1.5)
OPENROUTER_IMAGE_MODEL Default OpenRouter model (google/gemini-3.1-flash-image-preview)
GOOGLE_IMAGE_MODEL Default Google model (gemini-3-pro-image-preview)
DASHSCOPE_IMAGE_MODEL Default DashScope model (z-image-turbo)
DASHSCOPE_IMAGE_MODEL Default DashScope model (qwen-image-2.0-pro)
REPLICATE_IMAGE_MODEL Default Replicate model (google/nano-banana-pro)
JIMENG_IMAGE_MODEL Default Jimeng model (jimeng_t2i_v40)
SEEDREAM_IMAGE_MODEL Default Seedream model (doubao-seedream-5-0-260128)
@@ -0,0 +1,148 @@
import assert from "node:assert/strict";
import test, { type TestContext } from "node:test";
import {
getDefaultModel,
getModelFamily,
getQwen2SizeFromAspectRatio,
getSizeFromAspectRatio,
normalizeSize,
parseAspectRatio,
parseSize,
resolveSizeForModel,
} from "./dashscope.ts";
function useEnv(
t: TestContext,
values: Record<string, string | null>,
): void {
const previous = new Map<string, string | undefined>();
for (const [key, value] of Object.entries(values)) {
previous.set(key, process.env[key]);
if (value == null) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
t.after(() => {
for (const [key, value] of previous.entries()) {
if (value == null) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
});
}
test("DashScope default model prefers env override and otherwise uses qwen-image-2.0-pro", (t) => {
useEnv(t, { DASHSCOPE_IMAGE_MODEL: null });
assert.equal(getDefaultModel(), "qwen-image-2.0-pro");
process.env.DASHSCOPE_IMAGE_MODEL = "qwen-image-max";
assert.equal(getDefaultModel(), "qwen-image-max");
});
test("DashScope aspect-ratio parsing accepts numeric ratios only", () => {
assert.deepEqual(parseAspectRatio("3:2"), { width: 3, height: 2 });
assert.equal(parseAspectRatio("square"), null);
assert.equal(parseAspectRatio("-1:2"), null);
});
test("DashScope model family routing distinguishes qwen-2.0, fixed-size qwen, and legacy models", () => {
assert.equal(getModelFamily("qwen-image-2.0-pro"), "qwen2");
assert.equal(getModelFamily("qwen-image"), "qwenFixed");
assert.equal(getModelFamily("z-image-turbo"), "legacy");
assert.equal(getModelFamily("wanx-v1"), "legacy");
});
test("Legacy DashScope size selection keeps the previous quality-based heuristic", () => {
assert.equal(getSizeFromAspectRatio(null, "normal"), "1024*1024");
assert.equal(getSizeFromAspectRatio("16:9", "normal"), "1280*720");
assert.equal(getSizeFromAspectRatio("16:9", "2k"), "2048*1152");
assert.equal(getSizeFromAspectRatio("invalid", "2k"), "1536*1536");
});
test("Qwen 2.0 recommended sizes follow the official common-ratio table", () => {
assert.equal(getQwen2SizeFromAspectRatio(null, "normal"), "1024*1024");
assert.equal(getQwen2SizeFromAspectRatio(null, "2k"), "1536*1536");
assert.equal(getQwen2SizeFromAspectRatio("16:9", "normal"), "1280*720");
assert.equal(getQwen2SizeFromAspectRatio("21:9", "2k"), "2048*872");
});
test("Qwen 2.0 derives free-form sizes within pixel budget for uncommon ratios", () => {
const size = getQwen2SizeFromAspectRatio("5:2", "normal");
const parsed = parseSize(size);
assert.ok(parsed);
assert.ok(parsed.width * parsed.height >= 512 * 512);
assert.ok(parsed.width * parsed.height <= 2048 * 2048);
assert.ok(Math.abs(parsed.width / parsed.height - 2.5) < 0.08);
});
test("resolveSizeForModel validates explicit qwen-image-2.0 sizes by total pixels", () => {
assert.equal(
resolveSizeForModel("qwen-image-2.0-pro", {
size: "2048x872",
aspectRatio: null,
quality: "2k",
}),
"2048*872",
);
assert.throws(
() =>
resolveSizeForModel("qwen-image-2.0-pro", {
size: "4096x4096",
aspectRatio: null,
quality: "2k",
}),
/total pixels between/,
);
});
test("resolveSizeForModel enforces fixed sizes for qwen-image-max/plus/image", () => {
assert.equal(
resolveSizeForModel("qwen-image-max", {
size: null,
aspectRatio: "1:1",
quality: "2k",
}),
"1328*1328",
);
assert.equal(
resolveSizeForModel("qwen-image", {
size: "1664x928",
aspectRatio: "9:16",
quality: "normal",
}),
"1664*928",
);
assert.throws(
() =>
resolveSizeForModel("qwen-image-max", {
size: null,
aspectRatio: "21:9",
quality: "2k",
}),
/supports only fixed ratios/,
);
assert.throws(
() =>
resolveSizeForModel("qwen-image-plus", {
size: "1024x1024",
aspectRatio: null,
quality: "2k",
}),
/support only these sizes/,
);
});
test("DashScope size normalization converts WxH into provider format", () => {
assert.equal(normalizeSize("1024x1024"), "1024*1024");
assert.equal(normalizeSize("2048*1152"), "2048*1152");
});
@@ -1,28 +1,46 @@
import type { CliArgs } from "../types";
import type { CliArgs, Quality } from "../types";
export function getDefaultModel(): string {
return process.env.DASHSCOPE_IMAGE_MODEL || "z-image-turbo";
}
type DashScopeModelFamily = "qwen2" | "qwenFixed" | "legacy";
function getApiKey(): string | null {
return process.env.DASHSCOPE_API_KEY || null;
}
type DashScopeModelSpec = {
family: DashScopeModelFamily;
defaultSize: string;
};
function getBaseUrl(): string {
const base = process.env.DASHSCOPE_BASE_URL || "https://dashscope.aliyuncs.com";
return base.replace(/\/+$/g, "");
}
const DEFAULT_MODEL = "qwen-image-2.0-pro";
const MIN_QWEN_2_TOTAL_PIXELS = 512 * 512;
const MAX_QWEN_2_TOTAL_PIXELS = 2048 * 2048;
const SIZE_STEP = 16;
const QWEN_NEGATIVE_PROMPT =
"低分辨率,低画质,肢体畸形,手指畸形,画面过饱和,蜡像感,人脸无细节,过度光滑,画面具有AI感,构图混乱,文字模糊,扭曲";
export function parseAspectRatio(ar: string): { width: number; height: number } | null {
const match = ar.match(/^(\d+(?:\.\d+)?):(\d+(?:\.\d+)?)$/);
if (!match) return null;
const w = parseFloat(match[1]!);
const h = parseFloat(match[2]!);
if (w <= 0 || h <= 0) return null;
return { width: w, height: h };
}
const QWEN_2_TARGET_PIXELS: Record<Quality, number> = {
normal: 1024 * 1024,
"2k": 1536 * 1536,
};
const STANDARD_SIZES: [number, number][] = [
const QWEN_2_RECOMMENDED: Record<string, Record<Quality, string>> = {
"1:1": { normal: "1024*1024", "2k": "1536*1536" },
"2:3": { normal: "768*1152", "2k": "1024*1536" },
"3:2": { normal: "1152*768", "2k": "1536*1024" },
"3:4": { normal: "960*1280", "2k": "1080*1440" },
"4:3": { normal: "1280*960", "2k": "1440*1080" },
"9:16": { normal: "720*1280", "2k": "1080*1920" },
"16:9": { normal: "1280*720", "2k": "1920*1080" },
"21:9": { normal: "1344*576", "2k": "2048*872" },
};
const QWEN_FIXED_SIZES_BY_RATIO: Record<string, string> = {
"16:9": "1664*928",
"4:3": "1472*1104",
"1:1": "1328*1328",
"3:4": "1104*1472",
"9:16": "928*1664",
};
const QWEN_FIXED_SIZES = Object.values(QWEN_FIXED_SIZES_BY_RATIO);
const LEGACY_STANDARD_SIZES: [number, number][] = [
[1024, 1024],
[1280, 720],
[720, 1280],
@@ -34,7 +52,7 @@ const STANDARD_SIZES: [number, number][] = [
[864, 1536],
];
const STANDARD_SIZES_2K: [number, number][] = [
const LEGACY_STANDARD_SIZES_2K: [number, number][] = [
[1536, 1536],
[2048, 1152],
[1152, 2048],
@@ -45,9 +63,167 @@ const STANDARD_SIZES_2K: [number, number][] = [
[2048, 2048],
];
const QWEN_2_SPEC: DashScopeModelSpec = {
family: "qwen2",
defaultSize: "1024*1024",
};
const QWEN_FIXED_SPEC: DashScopeModelSpec = {
family: "qwenFixed",
defaultSize: QWEN_FIXED_SIZES_BY_RATIO["16:9"],
};
const LEGACY_SPEC: DashScopeModelSpec = {
family: "legacy",
defaultSize: "1536*1536",
};
const MODEL_SPEC_ALIASES: Record<string, DashScopeModelSpec> = {
"qwen-image-2.0-pro": QWEN_2_SPEC,
"qwen-image-2.0-pro-2026-03-03": QWEN_2_SPEC,
"qwen-image-2.0": QWEN_2_SPEC,
"qwen-image-2.0-2026-03-03": QWEN_2_SPEC,
"qwen-image-max": QWEN_FIXED_SPEC,
"qwen-image-max-2025-12-30": QWEN_FIXED_SPEC,
"qwen-image-plus": QWEN_FIXED_SPEC,
"qwen-image-plus-2026-01-09": QWEN_FIXED_SPEC,
"qwen-image": QWEN_FIXED_SPEC,
};
export function getDefaultModel(): string {
return process.env.DASHSCOPE_IMAGE_MODEL || DEFAULT_MODEL;
}
function getApiKey(): string | null {
return process.env.DASHSCOPE_API_KEY || null;
}
function getBaseUrl(): string {
const base = process.env.DASHSCOPE_BASE_URL || "https://dashscope.aliyuncs.com";
return base.replace(/\/+$/g, "");
}
function getModelSpec(model: string): DashScopeModelSpec {
return MODEL_SPEC_ALIASES[model.trim().toLowerCase()] || LEGACY_SPEC;
}
export function getModelFamily(model: string): DashScopeModelFamily {
return getModelSpec(model).family;
}
function normalizeQuality(quality: CliArgs["quality"]): Quality {
return quality === "normal" ? "normal" : "2k";
}
export function parseAspectRatio(ar: string): { width: number; height: number } | null {
const match = ar.match(/^(\d+(?:\.\d+)?):(\d+(?:\.\d+)?)$/);
if (!match) return null;
const w = parseFloat(match[1]!);
const h = parseFloat(match[2]!);
if (w <= 0 || h <= 0) return null;
return { width: w, height: h };
}
export function normalizeSize(size: string): string {
return size.replace("x", "*");
}
export function parseSize(size: string): { width: number; height: number } | null {
const match = normalizeSize(size).match(/^(\d+)\*(\d+)$/);
if (!match) return null;
const width = Number(match[1]);
const height = Number(match[2]);
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
return null;
}
return { width, height };
}
function formatSize(width: number, height: number): string {
return `${width}*${height}`;
}
function getRatioValue(ar: string): number | null {
const parsed = parseAspectRatio(ar);
if (!parsed) return null;
return parsed.width / parsed.height;
}
function findKnownRatioKey(ar: string, candidates: string[], tolerance = 0.02): string | null {
const targetRatio = getRatioValue(ar);
if (targetRatio == null) return null;
let bestKey: string | null = null;
let bestDiff = Infinity;
for (const candidate of candidates) {
const candidateRatio = getRatioValue(candidate);
if (candidateRatio == null) continue;
const diff = Math.abs(candidateRatio - targetRatio);
if (diff < bestDiff) {
bestDiff = diff;
bestKey = candidate;
}
}
return bestDiff <= tolerance ? bestKey : null;
}
function roundToStep(value: number): number {
return Math.max(SIZE_STEP, Math.round(value / SIZE_STEP) * SIZE_STEP);
}
function fitToPixelBudget(
width: number,
height: number,
minPixels: number,
maxPixels: number,
): { width: number; height: number } {
let nextWidth = width;
let nextHeight = height;
let pixels = nextWidth * nextHeight;
if (pixels > maxPixels) {
const scale = Math.sqrt(maxPixels / pixels);
nextWidth *= scale;
nextHeight *= scale;
} else if (pixels < minPixels) {
const scale = Math.sqrt(minPixels / pixels);
nextWidth *= scale;
nextHeight *= scale;
}
let roundedWidth = roundToStep(nextWidth);
let roundedHeight = roundToStep(nextHeight);
pixels = roundedWidth * roundedHeight;
while (pixels > maxPixels && (roundedWidth > SIZE_STEP || roundedHeight > SIZE_STEP)) {
if (roundedWidth >= roundedHeight && roundedWidth > SIZE_STEP) {
roundedWidth -= SIZE_STEP;
} else if (roundedHeight > SIZE_STEP) {
roundedHeight -= SIZE_STEP;
} else {
break;
}
pixels = roundedWidth * roundedHeight;
}
while (pixels < minPixels) {
if (roundedWidth <= roundedHeight) {
roundedWidth += SIZE_STEP;
} else {
roundedHeight += SIZE_STEP;
}
pixels = roundedWidth * roundedHeight;
}
return { width: roundedWidth, height: roundedHeight };
}
export function getSizeFromAspectRatio(ar: string | null, quality: CliArgs["quality"]): string {
const is2k = quality === "2k";
const defaultSize = is2k ? "1536*1536" : "1024*1024";
const normalizedQuality = normalizeQuality(quality);
const sizes = normalizedQuality === "2k" ? LEGACY_STANDARD_SIZES_2K : LEGACY_STANDARD_SIZES;
const defaultSize = normalizedQuality === "2k" ? "1536*1536" : "1024*1024";
if (!ar) return defaultSize;
@@ -55,86 +231,157 @@ export function getSizeFromAspectRatio(ar: string | null, quality: CliArgs["qual
if (!parsed) return defaultSize;
const targetRatio = parsed.width / parsed.height;
const sizes = is2k ? STANDARD_SIZES_2K : STANDARD_SIZES;
let best = defaultSize;
let bestDiff = Infinity;
for (const [w, h] of sizes) {
const diff = Math.abs(w / h - targetRatio);
for (const [width, height] of sizes) {
const diff = Math.abs(width / height - targetRatio);
if (diff < bestDiff) {
bestDiff = diff;
best = `${w}*${h}`;
best = formatSize(width, height);
}
}
return best;
}
export function normalizeSize(size: string): string {
return size.replace("x", "*");
export function getQwen2SizeFromAspectRatio(ar: string | null, quality: CliArgs["quality"]): string {
const normalizedQuality = normalizeQuality(quality);
if (!ar) {
return QWEN_2_RECOMMENDED["1:1"][normalizedQuality];
}
const recommendedRatio = findKnownRatioKey(ar, Object.keys(QWEN_2_RECOMMENDED));
if (recommendedRatio) {
return QWEN_2_RECOMMENDED[recommendedRatio][normalizedQuality];
}
const parsed = parseAspectRatio(ar);
if (!parsed) {
return QWEN_2_RECOMMENDED["1:1"][normalizedQuality];
}
const targetRatio = parsed.width / parsed.height;
const targetPixels = QWEN_2_TARGET_PIXELS[normalizedQuality];
const rawWidth = Math.sqrt(targetPixels * targetRatio);
const rawHeight = Math.sqrt(targetPixels / targetRatio);
const fitted = fitToPixelBudget(
rawWidth,
rawHeight,
MIN_QWEN_2_TOTAL_PIXELS,
MAX_QWEN_2_TOTAL_PIXELS,
);
return formatSize(fitted.width, fitted.height);
}
export async function generateImage(
prompt: string,
model: string,
args: CliArgs
): Promise<Uint8Array> {
const apiKey = getApiKey();
if (!apiKey) throw new Error("DASHSCOPE_API_KEY is required");
if (args.referenceImages.length > 0) {
throw new Error(
"Reference images are not supported with DashScope provider in baoyu-image-gen. Use --provider google with a Gemini multimodal model."
function getQwenFixedSizeFromAspectRatio(ar: string | null, quality: CliArgs["quality"]): string {
if (quality === "normal") {
console.warn(
"DashScope qwen-image-max/plus/image models use fixed output sizes; --quality normal does not change the generated resolution."
);
}
const size = args.size ? normalizeSize(args.size) : getSizeFromAspectRatio(args.aspectRatio, args.quality);
const url = `${getBaseUrl()}/api/v1/services/aigc/multimodal-generation/generation`;
if (!ar) return QWEN_FIXED_SPEC.defaultSize;
const body = {
model,
input: {
messages: [
{
role: "user",
content: [{ text: prompt }],
},
],
},
parameters: {
prompt_extend: false,
size,
},
};
console.log(`Generating image with DashScope (${model})...`, { size });
const res = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify(body),
});
if (!res.ok) {
const err = await res.text();
throw new Error(`DashScope API error (${res.status}): ${err}`);
const ratioKey = findKnownRatioKey(ar, Object.keys(QWEN_FIXED_SIZES_BY_RATIO));
if (!ratioKey) {
throw new Error(
`DashScope model supports only fixed ratios ${Object.keys(QWEN_FIXED_SIZES_BY_RATIO).join(", ")}. ` +
`For custom ratios like "${ar}", use --model qwen-image-2.0-pro.`
);
}
const result = await res.json() as {
output?: {
result_image?: string;
choices?: Array<{
message?: {
content?: Array<{ image?: string }>;
};
}>;
};
return QWEN_FIXED_SIZES_BY_RATIO[ratioKey]!;
}
function validateSizeFormat(size: string): { width: number; height: number } {
const parsed = parseSize(size);
if (!parsed) {
throw new Error(`Invalid DashScope size "${size}". Expected <width>x<height> or <width>*<height>.`);
}
return parsed;
}
function validateQwen2Size(size: string): string {
const normalized = normalizeSize(size);
const parsed = validateSizeFormat(normalized);
const totalPixels = parsed.width * parsed.height;
if (totalPixels < MIN_QWEN_2_TOTAL_PIXELS || totalPixels > MAX_QWEN_2_TOTAL_PIXELS) {
throw new Error(
`DashScope qwen-image-2.0* models require total pixels between ${MIN_QWEN_2_TOTAL_PIXELS} ` +
`and ${MAX_QWEN_2_TOTAL_PIXELS}. Received ${normalized} (${totalPixels} pixels).`
);
}
return normalized;
}
function validateQwenFixedSize(size: string): string {
const normalized = normalizeSize(size);
validateSizeFormat(normalized);
if (!QWEN_FIXED_SIZES.includes(normalized)) {
throw new Error(
`DashScope qwen-image-max/plus/image models support only these sizes: ${QWEN_FIXED_SIZES.join(", ")}. ` +
`Received ${normalized}.`
);
}
return normalized;
}
export function resolveSizeForModel(
model: string,
args: Pick<CliArgs, "size" | "aspectRatio" | "quality">,
): string {
const spec = getModelSpec(model);
if (args.size) {
if (spec.family === "qwen2") return validateQwen2Size(args.size);
if (spec.family === "qwenFixed") return validateQwenFixedSize(args.size);
validateSizeFormat(args.size);
return normalizeSize(args.size);
}
if (spec.family === "qwen2") {
return getQwen2SizeFromAspectRatio(args.aspectRatio, args.quality);
}
if (spec.family === "qwenFixed") {
return getQwenFixedSizeFromAspectRatio(args.aspectRatio, args.quality);
}
return getSizeFromAspectRatio(args.aspectRatio, args.quality);
}
function buildParameters(
family: DashScopeModelFamily,
size: string,
): Record<string, unknown> {
const parameters: Record<string, unknown> = {
prompt_extend: false,
size,
};
if (family === "qwen2" || family === "qwenFixed") {
parameters.watermark = false;
parameters.negative_prompt = QWEN_NEGATIVE_PROMPT;
}
return parameters;
}
type DashScopeResponse = {
output?: {
result_image?: string;
choices?: Array<{
message?: {
content?: Array<{ image?: string }>;
};
}>;
};
};
async function extractImageFromResponse(result: DashScopeResponse): Promise<Uint8Array> {
let imageData: string | null = null;
if (result.output?.result_image) {
@@ -163,3 +410,54 @@ export async function generateImage(
return Uint8Array.from(Buffer.from(imageData, "base64"));
}
export async function generateImage(
prompt: string,
model: string,
args: CliArgs
): Promise<Uint8Array> {
const apiKey = getApiKey();
if (!apiKey) throw new Error("DASHSCOPE_API_KEY is required");
if (args.referenceImages.length > 0) {
throw new Error(
"Reference images are not supported with DashScope provider in baoyu-image-gen. Use --provider google with a Gemini multimodal model."
);
}
const spec = getModelSpec(model);
const size = resolveSizeForModel(model, args);
const url = `${getBaseUrl()}/api/v1/services/aigc/multimodal-generation/generation`;
const body = {
model,
input: {
messages: [
{
role: "user",
content: [{ text: prompt }],
},
],
},
parameters: buildParameters(spec.family, size),
};
console.log(`Generating image with DashScope (${model})...`, { family: spec.family, size });
const res = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify(body),
});
if (!res.ok) {
const err = await res.text();
throw new Error(`DashScope API error (${res.status}): ${err}`);
}
const result = await res.json() as DashScopeResponse;
return extractImageFromResponse(result);
}
@@ -1,6 +1,7 @@
import assert from "node:assert/strict";
import test from "node:test";
import test, { type TestContext } from "node:test";
import type { CliArgs } from "../types.ts";
import {
addAspectRatioToPrompt,
buildGoogleUrl,
@@ -11,10 +12,13 @@ import {
isGoogleImagen,
isGoogleMultimodal,
normalizeGoogleModelId,
} from "../../../skills/baoyu-image-gen/scripts/providers/google.ts";
} from "./google.ts";
function useEnv(t, values) {
const previous = new Map();
function useEnv(
t: TestContext,
values: Record<string, string | null>,
): void {
const previous = new Map<string, string | undefined>();
for (const [key, value] of Object.entries(values)) {
previous.set(key, process.env[key]);
if (value == null) {
@@ -35,6 +39,27 @@ function useEnv(t, values) {
});
}
function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
return {
prompt: null,
promptFiles: [],
imagePath: null,
provider: null,
model: null,
aspectRatio: null,
size: null,
quality: null,
imageSize: null,
referenceImages: [],
n: 1,
batchFile: null,
jobs: null,
json: false,
help: false,
...overrides,
};
}
test("Google provider helpers normalize model IDs and select image size defaults", () => {
assert.equal(
normalizeGoogleModelId("models/gemini-3.1-flash-image-preview"),
@@ -42,14 +67,8 @@ test("Google provider helpers normalize model IDs and select image size defaults
);
assert.equal(isGoogleMultimodal("models/gemini-3-pro-image-preview"), true);
assert.equal(isGoogleImagen("imagen-3.0-generate-002"), true);
assert.equal(
getGoogleImageSize({ imageSize: null, quality: "2k" }),
"2K",
);
assert.equal(
getGoogleImageSize({ imageSize: "4K", quality: "normal" }),
"4K",
);
assert.equal(getGoogleImageSize(makeArgs({ imageSize: null, quality: "2k" })), "2K");
assert.equal(getGoogleImageSize(makeArgs({ imageSize: "4K", quality: "normal" })), "4K");
});
test("Google URL builder appends v1beta when the base URL does not already include it", (t) => {
@@ -6,7 +6,7 @@ import {
getMimeType,
getOpenAISize,
parseAspectRatio,
} from "../../../skills/baoyu-image-gen/scripts/providers/openai.ts";
} from "./openai.ts";
test("OpenAI aspect-ratio parsing and size selection match model families", () => {
assert.deepEqual(parseAspectRatio("16:9"), { width: 16, height: 9 });
@@ -1,17 +1,30 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { CliArgs } from "../types.ts";
import {
buildInput,
extractOutputUrl,
parseModelId,
} from "../../../skills/baoyu-image-gen/scripts/providers/replicate.ts";
} from "./replicate.ts";
function makeArgs(overrides = {}) {
function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
return {
prompt: null,
promptFiles: [],
imagePath: null,
provider: null,
model: null,
aspectRatio: null,
size: null,
quality: null,
imageSize: null,
referenceImages: [],
n: 1,
batchFile: null,
jobs: null,
json: false,
help: false,
...overrides,
};
}
@@ -69,20 +82,20 @@ test("Replicate input builder maps aspect ratio, image count, quality, and refs"
test("Replicate output extraction supports string, array, and object URLs", () => {
assert.equal(
extractOutputUrl({ output: "https://example.com/a.png" }),
extractOutputUrl({ output: "https://example.com/a.png" } as never),
"https://example.com/a.png",
);
assert.equal(
extractOutputUrl({ output: ["https://example.com/b.png"] }),
extractOutputUrl({ output: ["https://example.com/b.png"] } as never),
"https://example.com/b.png",
);
assert.equal(
extractOutputUrl({ output: { url: "https://example.com/c.png" } }),
extractOutputUrl({ output: { url: "https://example.com/c.png" } } as never),
"https://example.com/c.png",
);
assert.throws(
() => extractOutputUrl({ output: { invalid: true } }),
() => extractOutputUrl({ output: { invalid: true } } as never),
/Unexpected Replicate output format/,
);
});
@@ -0,0 +1,93 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
extractSummaryFromBody,
extractTitleFromMarkdown,
parseFrontmatter,
pickFirstString,
serializeFrontmatter,
stripWrappingQuotes,
toFrontmatterString,
} from "./content.ts";
test("parseFrontmatter extracts YAML fields and strips wrapping quotes", () => {
const input = `---
title: "Hello World"
author: Baoyu
summary: plain text
---
# Heading
Body`;
const result = parseFrontmatter(input);
assert.deepEqual(result.frontmatter, {
title: "Hello World",
author: "Baoyu",
summary: "plain text",
});
assert.match(result.body, /^# Heading/);
});
test("parseFrontmatter returns original content when no frontmatter exists", () => {
const input = "# No frontmatter";
assert.deepEqual(parseFrontmatter(input), {
frontmatter: {},
body: input,
});
});
test("serializeFrontmatter renders YAML only when fields exist", () => {
assert.equal(serializeFrontmatter({}), "");
assert.equal(
serializeFrontmatter({ title: "Hello", author: "Baoyu" }),
"---\ntitle: Hello\nauthor: Baoyu\n---\n",
);
});
test("quote and frontmatter string helpers normalize mixed scalar values", () => {
assert.equal(stripWrappingQuotes(`" quoted "`), "quoted");
assert.equal(stripWrappingQuotes("“ 中文标题 ”"), "中文标题");
assert.equal(stripWrappingQuotes("plain"), "plain");
assert.equal(toFrontmatterString("'hello'"), "hello");
assert.equal(toFrontmatterString(42), "42");
assert.equal(toFrontmatterString(false), "false");
assert.equal(toFrontmatterString({}), undefined);
assert.equal(
pickFirstString({ summary: 123, title: "" }, ["title", "summary"]),
"123",
);
});
test("markdown title and summary extraction skip non-body content and clean formatting", () => {
const markdown = `
![cover](cover.png)
## My Title
Body paragraph
`;
assert.equal(extractTitleFromMarkdown(markdown), "My Title");
const summary = extractSummaryFromBody(
`
# Heading
> quote
- list
1. ordered
\`\`\`
code
\`\`\`
This is **the first paragraph** with [a link](https://example.com) and \`inline code\` that should be summarized cleanly.
`,
70,
);
assert.equal(
summary,
"This is the first paragraph with a link and inline code that should...",
);
});
@@ -0,0 +1,140 @@
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import process from "node:process";
import test, { type TestContext } from "node:test";
import { COLOR_PRESETS, FONT_FAMILY_MAP } from "./constants.ts";
import {
buildMarkdownDocumentMeta,
formatTimestamp,
resolveColorToken,
resolveFontFamilyToken,
resolveMarkdownStyle,
resolveRenderOptions,
} from "./document.ts";
function useCwd(t: TestContext, cwd: string): void {
const previous = process.cwd();
process.chdir(cwd);
t.after(() => {
process.chdir(previous);
});
}
async function makeTempDir(prefix: string): Promise<string> {
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
}
test("document token resolvers map known presets and allow passthrough values", () => {
assert.equal(resolveColorToken("green"), COLOR_PRESETS.green);
assert.equal(resolveColorToken("#123456"), "#123456");
assert.equal(resolveColorToken(), undefined);
assert.equal(resolveFontFamilyToken("mono"), FONT_FAMILY_MAP.mono);
assert.equal(resolveFontFamilyToken("Custom Font"), "Custom Font");
assert.equal(resolveFontFamilyToken(), undefined);
});
test("formatTimestamp uses compact sortable datetime output", () => {
const date = new Date("2026-03-13T21:04:05.000Z");
const pad = (value: number) => String(value).padStart(2, "0");
const expected = `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(
date.getDate(),
)}${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`;
assert.equal(formatTimestamp(date), expected);
});
test("buildMarkdownDocumentMeta prefers frontmatter and falls back to markdown title and summary", () => {
const metaFromYaml = buildMarkdownDocumentMeta(
"# Markdown Title\n\nBody summary paragraph that should be ignored.",
{
title: `" YAML Title "`,
author: "'Baoyu'",
summary: `" YAML Summary "`,
},
"fallback",
);
assert.deepEqual(metaFromYaml, {
title: "YAML Title",
author: "Baoyu",
description: "YAML Summary",
});
const metaFromMarkdown = buildMarkdownDocumentMeta(
`## “Markdown Title”\n\nThis is the first body paragraph that should become the summary because it is long enough.`,
{},
"fallback",
);
assert.equal(metaFromMarkdown.title, "Markdown Title");
assert.match(metaFromMarkdown.description ?? "", /^This is the first body paragraph/);
});
test("resolveMarkdownStyle merges theme defaults with explicit overrides", () => {
const style = resolveMarkdownStyle({
theme: "modern",
primaryColor: "#112233",
fontFamily: "Custom Sans",
});
assert.equal(style.primaryColor, "#112233");
assert.equal(style.fontFamily, "Custom Sans");
assert.equal(style.fontSize, "15px");
assert.equal(style.containerBg, "rgba(250, 249, 245, 1)");
});
test("resolveRenderOptions loads workspace EXTEND settings and lets explicit options win", async (t) => {
const root = await makeTempDir("baoyu-md-render-options-");
useCwd(t, root);
const extendPath = path.join(
root,
".baoyu-skills",
"baoyu-markdown-to-html",
"EXTEND.md",
);
await fs.mkdir(path.dirname(extendPath), { recursive: true });
await fs.writeFile(
extendPath,
`---
default_theme: modern
default_color: green
default_font_family: mono
default_font_size: 17
default_code_theme: nord
mac_code_block: false
show_line_number: true
cite: true
count: true
legend: title-alt
keep_title: true
---
`,
);
const fromExtend = resolveRenderOptions();
assert.equal(fromExtend.theme, "modern");
assert.equal(fromExtend.primaryColor, COLOR_PRESETS.green);
assert.equal(fromExtend.fontFamily, FONT_FAMILY_MAP.mono);
assert.equal(fromExtend.fontSize, "17px");
assert.equal(fromExtend.codeTheme, "nord");
assert.equal(fromExtend.isMacCodeBlock, false);
assert.equal(fromExtend.isShowLineNumber, true);
assert.equal(fromExtend.citeStatus, true);
assert.equal(fromExtend.countStatus, true);
assert.equal(fromExtend.legend, "title-alt");
assert.equal(fromExtend.keepTitle, true);
const explicit = resolveRenderOptions({
theme: "simple",
fontSize: "18px",
keepTitle: false,
});
assert.equal(explicit.theme, "simple");
assert.equal(explicit.fontSize, "18px");
assert.equal(explicit.keepTitle, false);
});
@@ -0,0 +1,71 @@
import assert from "node:assert/strict";
import test from "node:test";
import { DEFAULT_STYLE } from "./constants.ts";
import {
buildCss,
buildHtmlDocument,
modifyHtmlStructure,
normalizeCssText,
normalizeInlineCss,
removeFirstHeading,
} from "./html-builder.ts";
test("buildCss injects style variables and concatenates base and theme CSS", () => {
const css = buildCss("body { color: red; }", ".theme { color: blue; }");
assert.match(css, /--md-primary-color: #0F4C81;/);
assert.match(css, /body \{ color: red; \}/);
assert.match(css, /\.theme \{ color: blue; \}/);
});
test("buildHtmlDocument includes optional meta tags and code theme CSS", () => {
const html = buildHtmlDocument(
{
title: "Doc",
author: "Baoyu",
description: "Summary",
},
"body { color: red; }",
"<article>Hello</article>",
".hljs { color: blue; }",
);
assert.match(html, /<title>Doc<\/title>/);
assert.match(html, /meta name="author" content="Baoyu"/);
assert.match(html, /meta name="description" content="Summary"/);
assert.match(html, /<style>body \{ color: red; \}<\/style>/);
assert.match(html, /<style>\.hljs \{ color: blue; \}<\/style>/);
assert.match(html, /<article>Hello<\/article>/);
});
test("normalizeCssText and normalizeInlineCss replace variables and strip declarations", () => {
const rawCss = `
:root { --md-primary-color: #000; --md-font-size: 12px; --foreground: 0 0% 5%; }
.box { color: var(--md-primary-color); font-size: var(--md-font-size); background: hsl(var(--foreground)); }
`;
const normalizedCss = normalizeCssText(rawCss, DEFAULT_STYLE);
assert.match(normalizedCss, /color: #0F4C81/);
assert.match(normalizedCss, /font-size: 16px/);
assert.match(normalizedCss, /background: #3f3f3f/);
assert.doesNotMatch(normalizedCss, /--md-primary-color/);
const normalizedHtml = normalizeInlineCss(
`<style>${rawCss}</style><div style="color: var(--md-primary-color)"></div>`,
DEFAULT_STYLE,
);
assert.match(normalizedHtml, /color: #0F4C81/);
assert.doesNotMatch(normalizedHtml, /var\(--md-primary-color\)/);
});
test("HTML structure helpers hoist nested lists and remove the first heading", () => {
const nestedList = `<ul><li>Parent<ul><li>Child</li></ul></li></ul>`;
assert.equal(
modifyHtmlStructure(nestedList),
`<ul><li>Parent</li><ul><li>Child</li></ul></ul>`,
);
const html = `<h1>Title</h1><p>Intro</p><h2>Sub</h2>`;
assert.equal(removeFirstHeading(html), `<p>Intro</p><h2>Sub</h2>`);
});
@@ -0,0 +1,79 @@
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import {
getImageExtension,
replaceMarkdownImagesWithPlaceholders,
resolveContentImages,
resolveImagePath,
} from "./images.ts";
async function makeTempDir(prefix: string): Promise<string> {
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
}
test("replaceMarkdownImagesWithPlaceholders rewrites markdown and tracks image metadata", () => {
const result = replaceMarkdownImagesWithPlaceholders(
`![cover](images/cover.png)\n\nText\n\n![diagram](images/diagram.webp)`,
"IMG_",
);
assert.equal(result.markdown, `IMG_1\n\nText\n\nIMG_2`);
assert.deepEqual(result.images, [
{ alt: "cover", originalPath: "images/cover.png", placeholder: "IMG_1" },
{ alt: "diagram", originalPath: "images/diagram.webp", placeholder: "IMG_2" },
]);
});
test("image extension and local fallback resolution handle common path variants", async (t) => {
assert.equal(getImageExtension("https://example.com/a.jpeg?x=1"), "jpeg");
assert.equal(getImageExtension("/tmp/figure"), "png");
const root = await makeTempDir("baoyu-md-images-");
t.after(() => fs.rm(root, { recursive: true, force: true }));
const baseDir = path.join(root, "article");
const tempDir = path.join(root, "tmp");
await fs.mkdir(baseDir, { recursive: true });
await fs.mkdir(tempDir, { recursive: true });
await fs.writeFile(path.join(baseDir, "figure.webp"), "webp");
const resolved = await resolveImagePath("figure.png", baseDir, tempDir, "test");
assert.equal(resolved, path.join(baseDir, "figure.webp"));
});
test("resolveContentImages resolves image placeholders against the content directory", async (t) => {
const root = await makeTempDir("baoyu-md-content-images-");
t.after(() => fs.rm(root, { recursive: true, force: true }));
const baseDir = path.join(root, "article");
const tempDir = path.join(root, "tmp");
await fs.mkdir(baseDir, { recursive: true });
await fs.mkdir(tempDir, { recursive: true });
await fs.writeFile(path.join(baseDir, "cover.png"), "png");
const resolved = await resolveContentImages(
[
{
alt: "cover",
originalPath: "cover.png",
placeholder: "IMG_1",
},
],
baseDir,
tempDir,
"test",
);
assert.deepEqual(resolved, [
{
alt: "cover",
originalPath: "cover.png",
placeholder: "IMG_1",
localPath: path.join(baseDir, "cover.png"),
},
]);
});
@@ -0,0 +1,64 @@
import assert from "node:assert/strict";
import test from "node:test";
import { initRenderer, renderMarkdown } from "./renderer.ts";
const render = (md: string) => {
const r = initRenderer();
return renderMarkdown(md, r).html;
};
test("bold with inline code (no underscore)", () => {
const html = render("**算出 `logits`,算出 `loss`。**");
assert.match(html, /<code[^>]*>logits<\/code>/);
assert.match(html, /<code[^>]*>loss<\/code>/);
});
test("bold with inline code (contains underscore)", () => {
const html = render("**变成 `input_ids`。**");
assert.match(html, /<code[^>]*>input_ids<\/code>/);
});
test("emphasis with inline code", () => {
const html = render("*查看 `hidden_states`*");
assert.match(html, /<code[^>]*>hidden_states<\/code>/);
});
test("plain inline code (regression)", () => {
const html = render("`lm_head`");
assert.match(html, /<code[^>]*>lm_head<\/code>/);
});
test("bold without code (regression)", () => {
const html = render("**纯粗体文本**");
assert.match(html, /<strong[^>]*>纯粗体文本<\/strong>/);
assert.doesNotMatch(html, /<code/);
});
test("bold with inline code containing backticks", () => {
const html = render("**``a`b``**");
assert.match(html, /<code[^>]*>a&#96;b<\/code>/);
});
test("emphasis with inline code containing backticks", () => {
const html = render("*``a`b``*");
assert.match(html, /<em[^>]*><code[^>]*>a&#96;b<\/code><\/em>/);
});
test("bold with inline code containing consecutive backticks", () => {
const html = render("**```a``b```**");
assert.match(html, /<code[^>]*>a&#96;&#96;b<\/code>/);
});
test("bold with inline code containing only backticks", () => {
const html = render("**```` `` ````**");
assert.match(html, /<code[^>]*>&#96;&#96;<\/code>/);
});
test("bold with inline code containing only spaces", () => {
const oneSpace = render("**`` ``**");
assert.match(oneSpace, /<code[^>]*> <\/code>/);
const twoSpaces = render("**`` ``**");
assert.match(twoSpaces, /<code[^>]*> <\/code>/);
});
@@ -109,6 +109,13 @@ function parseFrontMatterAndContent(markdownText: string): ParseResult {
}
}
function wrapInlineCode(value: string): string {
const runs = value.match(/`+/g);
const fence = "`".repeat(Math.max(...(runs?.map((run) => run.length) ?? [0])) + 1);
const padding = /^ *$/.test(value) ? "" : " ";
return `${fence}${padding}${value}${padding}${fence}`;
}
export function initRenderer(opts: IOpts = {}): RendererAPI {
const footnotes: [number, string, string][] = [];
let footnoteIndex = 0;
@@ -369,6 +376,7 @@ function preprocessCjkEmphasis(markdown: string): string {
const tree = processor.parse(markdown);
const extractText = (node: any): string => {
if (node.type === "text") return node.value;
if (node.type === "inlineCode") return wrapInlineCode(node.value);
if (node.children) return node.children.map(extractText).join("");
return "";
};
@@ -0,0 +1,171 @@
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import http from "node:http";
import os from "node:os";
import path from "node:path";
import process from "node:process";
import test, { type TestContext } from "node:test";
import {
findChromeExecutable,
findExistingChromeDebugPort,
getFreePort,
resolveSharedChromeProfileDir,
waitForChromeDebugPort,
} from "./index.ts";
function useEnv(
t: TestContext,
values: Record<string, string | null>,
): void {
const previous = new Map<string, string | undefined>();
for (const [key, value] of Object.entries(values)) {
previous.set(key, process.env[key]);
if (value == null) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
t.after(() => {
for (const [key, value] of previous.entries()) {
if (value == null) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
});
}
async function makeTempDir(prefix: string): Promise<string> {
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
}
async function startDebugServer(port: number): Promise<http.Server> {
const server = http.createServer((req, res) => {
if (req.url === "/json/version") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({
webSocketDebuggerUrl: `ws://127.0.0.1:${port}/devtools/browser/demo`,
}));
return;
}
res.writeHead(404);
res.end();
});
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(port, "127.0.0.1", () => resolve());
});
return server;
}
async function closeServer(server: http.Server): Promise<void> {
await new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) reject(error);
else resolve();
});
});
}
test("getFreePort honors a fixed environment override and otherwise allocates a TCP port", async (t) => {
useEnv(t, { TEST_FIXED_PORT: "45678" });
assert.equal(await getFreePort("TEST_FIXED_PORT"), 45678);
const dynamicPort = await getFreePort();
assert.ok(Number.isInteger(dynamicPort));
assert.ok(dynamicPort > 0);
});
test("findChromeExecutable prefers env overrides and falls back to candidate paths", async (t) => {
const root = await makeTempDir("baoyu-chrome-bin-");
t.after(() => fs.rm(root, { recursive: true, force: true }));
const envChrome = path.join(root, "env-chrome");
const fallbackChrome = path.join(root, "fallback-chrome");
await fs.writeFile(envChrome, "");
await fs.writeFile(fallbackChrome, "");
useEnv(t, { BAOYU_CHROME_PATH: envChrome });
assert.equal(
findChromeExecutable({
envNames: ["BAOYU_CHROME_PATH"],
candidates: { default: [fallbackChrome] },
}),
envChrome,
);
useEnv(t, { BAOYU_CHROME_PATH: null });
assert.equal(
findChromeExecutable({
envNames: ["BAOYU_CHROME_PATH"],
candidates: { default: [fallbackChrome] },
}),
fallbackChrome,
);
});
test("resolveSharedChromeProfileDir supports env overrides, WSL paths, and default suffixes", (t) => {
useEnv(t, { BAOYU_SHARED_PROFILE: "/tmp/custom-profile" });
assert.equal(
resolveSharedChromeProfileDir({
envNames: ["BAOYU_SHARED_PROFILE"],
appDataDirName: "demo-app",
profileDirName: "demo-profile",
}),
path.resolve("/tmp/custom-profile"),
);
useEnv(t, { BAOYU_SHARED_PROFILE: null });
assert.equal(
resolveSharedChromeProfileDir({
wslWindowsHome: "/mnt/c/Users/demo",
appDataDirName: "demo-app",
profileDirName: "demo-profile",
}),
path.join("/mnt/c/Users/demo", ".local", "share", "demo-app", "demo-profile"),
);
const fallback = resolveSharedChromeProfileDir({
appDataDirName: "demo-app",
profileDirName: "demo-profile",
});
assert.match(fallback, /demo-app[\\/]demo-profile$/);
});
test("findExistingChromeDebugPort reads DevToolsActivePort and validates it against a live endpoint", async (t) => {
const root = await makeTempDir("baoyu-cdp-profile-");
t.after(() => fs.rm(root, { recursive: true, force: true }));
const port = await getFreePort();
const server = await startDebugServer(port);
t.after(() => closeServer(server));
await fs.writeFile(path.join(root, "DevToolsActivePort"), `${port}\n/devtools/browser/demo\n`);
const found = await findExistingChromeDebugPort({ profileDir: root, timeoutMs: 1000 });
assert.equal(found, port);
});
test("waitForChromeDebugPort retries until the debug endpoint becomes available", async (t) => {
const port = await getFreePort();
const serverPromise = (async () => {
await new Promise((resolve) => setTimeout(resolve, 200));
const server = await startDebugServer(port);
t.after(() => closeServer(server));
})();
const websocketUrl = await waitForChromeDebugPort(port, 4000, {
includeLastError: true,
});
await serverPromise;
assert.equal(websocketUrl, `ws://127.0.0.1:${port}/devtools/browser/demo`);
});
@@ -43,6 +43,18 @@ type FindExistingChromeDebugPortOptions = {
timeoutMs?: number;
};
export type ChromeChannel = "stable" | "beta" | "canary" | "dev";
export type DiscoveredChrome = {
port: number;
wsUrl: string;
};
type DiscoverRunningChromeOptions = {
channels?: ChromeChannel[];
timeoutMs?: number;
};
type LaunchChromeOptions = {
chromePath: string;
profileDir: string;
@@ -173,16 +185,32 @@ async function isDebugPortReady(port: number, timeoutMs = 3_000): Promise<boolea
}
}
function isPortListening(port: number, timeoutMs = 3_000): Promise<boolean> {
return new Promise((resolve) => {
const socket = new net.Socket();
const timer = setTimeout(() => { socket.destroy(); resolve(false); }, timeoutMs);
socket.once("connect", () => { clearTimeout(timer); socket.destroy(); resolve(true); });
socket.once("error", () => { clearTimeout(timer); resolve(false); });
socket.connect(port, "127.0.0.1");
});
}
function parseDevToolsActivePort(filePath: string): { port: number; wsPath: string } | null {
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;
}
export async function findExistingChromeDebugPort(options: FindExistingChromeDebugPortOptions): Promise<number | null> {
const timeoutMs = options.timeoutMs ?? 3_000;
const portFile = path.join(options.profileDir, "DevToolsActivePort");
const parsed = parseDevToolsActivePort(path.join(options.profileDir, "DevToolsActivePort"));
try {
const content = fs.readFileSync(portFile, "utf-8");
const [portLine] = content.split(/\r?\n/);
const port = Number.parseInt(portLine?.trim() ?? "", 10);
if (port > 0 && await isDebugPortReady(port, timeoutMs)) return port;
} catch {}
if (parsed && parsed.port > 0 && await isDebugPortReady(parsed.port, timeoutMs)) return parsed.port;
if (process.platform === "win32") return null;
@@ -204,6 +232,81 @@ export async function findExistingChromeDebugPort(options: FindExistingChromeDeb
return null;
}
export function getDefaultChromeUserDataDirs(channels: ChromeChannel[] = ["stable"]): string[] {
const home = os.homedir();
const dirs: string[] = [];
const channelDirs: Record<string, { darwin: string; linux: string; win32: string }> = {
stable: {
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome"),
linux: path.join(home, ".config", "google-chrome"),
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome", "User Data"),
},
beta: {
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome Beta"),
linux: path.join(home, ".config", "google-chrome-beta"),
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome Beta", "User Data"),
},
canary: {
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome Canary"),
linux: path.join(home, ".config", "google-chrome-canary"),
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome SxS", "User Data"),
},
dev: {
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome Dev"),
linux: path.join(home, ".config", "google-chrome-dev"),
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome Dev", "User Data"),
},
};
const platform = process.platform === "darwin" ? "darwin" : process.platform === "win32" ? "win32" : "linux";
for (const ch of channels) {
const entry = channelDirs[ch];
if (entry) dirs.push(entry[platform]);
}
return dirs;
}
export async function discoverRunningChromeDebugPort(options: DiscoverRunningChromeOptions = {}): Promise<DiscoveredChrome | null> {
const channels = options.channels ?? ["stable", "beta", "canary", "dev"];
const timeoutMs = options.timeoutMs ?? 3_000;
const userDataDirs = getDefaultChromeUserDataDirs(channels);
for (const dir of userDataDirs) {
const parsed = parseDevToolsActivePort(path.join(dir, "DevToolsActivePort"));
if (!parsed) continue;
if (await isPortListening(parsed.port, timeoutMs)) {
return { port: parsed.port, wsUrl: `ws://127.0.0.1:${parsed.port}${parsed.wsPath}` };
}
}
if (process.platform !== "win32") {
try {
const result = spawnSync("ps", ["aux"], { encoding: "utf-8", timeout: 5_000 });
if (result.status === 0 && result.stdout) {
const lines = result.stdout
.split("\n")
.filter((line) => line.includes("--remote-debugging-port=") && /chrome|chromium/i.test(line));
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)) {
try {
const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(`http://127.0.0.1:${port}/json/version`, { timeoutMs });
if (version.webSocketDebuggerUrl) return { port, wsUrl: version.webSocketDebuggerUrl };
} catch {}
}
}
}
} catch {}
}
return null;
}
export async function waitForChromeDebugPort(
port: number,
timeoutMs: number,
@@ -0,0 +1,93 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
extractSummaryFromBody,
extractTitleFromMarkdown,
parseFrontmatter,
pickFirstString,
serializeFrontmatter,
stripWrappingQuotes,
toFrontmatterString,
} from "./content.ts";
test("parseFrontmatter extracts YAML fields and strips wrapping quotes", () => {
const input = `---
title: "Hello World"
author: Baoyu
summary: plain text
---
# Heading
Body`;
const result = parseFrontmatter(input);
assert.deepEqual(result.frontmatter, {
title: "Hello World",
author: "Baoyu",
summary: "plain text",
});
assert.match(result.body, /^# Heading/);
});
test("parseFrontmatter returns original content when no frontmatter exists", () => {
const input = "# No frontmatter";
assert.deepEqual(parseFrontmatter(input), {
frontmatter: {},
body: input,
});
});
test("serializeFrontmatter renders YAML only when fields exist", () => {
assert.equal(serializeFrontmatter({}), "");
assert.equal(
serializeFrontmatter({ title: "Hello", author: "Baoyu" }),
"---\ntitle: Hello\nauthor: Baoyu\n---\n",
);
});
test("quote and frontmatter string helpers normalize mixed scalar values", () => {
assert.equal(stripWrappingQuotes(`" quoted "`), "quoted");
assert.equal(stripWrappingQuotes("“ 中文标题 ”"), "中文标题");
assert.equal(stripWrappingQuotes("plain"), "plain");
assert.equal(toFrontmatterString("'hello'"), "hello");
assert.equal(toFrontmatterString(42), "42");
assert.equal(toFrontmatterString(false), "false");
assert.equal(toFrontmatterString({}), undefined);
assert.equal(
pickFirstString({ summary: 123, title: "" }, ["title", "summary"]),
"123",
);
});
test("markdown title and summary extraction skip non-body content and clean formatting", () => {
const markdown = `
![cover](cover.png)
## My Title
Body paragraph
`;
assert.equal(extractTitleFromMarkdown(markdown), "My Title");
const summary = extractSummaryFromBody(
`
# Heading
> quote
- list
1. ordered
\`\`\`
code
\`\`\`
This is **the first paragraph** with [a link](https://example.com) and \`inline code\` that should be summarized cleanly.
`,
70,
);
assert.equal(
summary,
"This is the first paragraph with a link and inline code that should...",
);
});
@@ -0,0 +1,140 @@
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import process from "node:process";
import test, { type TestContext } from "node:test";
import { COLOR_PRESETS, FONT_FAMILY_MAP } from "./constants.ts";
import {
buildMarkdownDocumentMeta,
formatTimestamp,
resolveColorToken,
resolveFontFamilyToken,
resolveMarkdownStyle,
resolveRenderOptions,
} from "./document.ts";
function useCwd(t: TestContext, cwd: string): void {
const previous = process.cwd();
process.chdir(cwd);
t.after(() => {
process.chdir(previous);
});
}
async function makeTempDir(prefix: string): Promise<string> {
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
}
test("document token resolvers map known presets and allow passthrough values", () => {
assert.equal(resolveColorToken("green"), COLOR_PRESETS.green);
assert.equal(resolveColorToken("#123456"), "#123456");
assert.equal(resolveColorToken(), undefined);
assert.equal(resolveFontFamilyToken("mono"), FONT_FAMILY_MAP.mono);
assert.equal(resolveFontFamilyToken("Custom Font"), "Custom Font");
assert.equal(resolveFontFamilyToken(), undefined);
});
test("formatTimestamp uses compact sortable datetime output", () => {
const date = new Date("2026-03-13T21:04:05.000Z");
const pad = (value: number) => String(value).padStart(2, "0");
const expected = `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(
date.getDate(),
)}${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`;
assert.equal(formatTimestamp(date), expected);
});
test("buildMarkdownDocumentMeta prefers frontmatter and falls back to markdown title and summary", () => {
const metaFromYaml = buildMarkdownDocumentMeta(
"# Markdown Title\n\nBody summary paragraph that should be ignored.",
{
title: `" YAML Title "`,
author: "'Baoyu'",
summary: `" YAML Summary "`,
},
"fallback",
);
assert.deepEqual(metaFromYaml, {
title: "YAML Title",
author: "Baoyu",
description: "YAML Summary",
});
const metaFromMarkdown = buildMarkdownDocumentMeta(
`## “Markdown Title”\n\nThis is the first body paragraph that should become the summary because it is long enough.`,
{},
"fallback",
);
assert.equal(metaFromMarkdown.title, "Markdown Title");
assert.match(metaFromMarkdown.description ?? "", /^This is the first body paragraph/);
});
test("resolveMarkdownStyle merges theme defaults with explicit overrides", () => {
const style = resolveMarkdownStyle({
theme: "modern",
primaryColor: "#112233",
fontFamily: "Custom Sans",
});
assert.equal(style.primaryColor, "#112233");
assert.equal(style.fontFamily, "Custom Sans");
assert.equal(style.fontSize, "15px");
assert.equal(style.containerBg, "rgba(250, 249, 245, 1)");
});
test("resolveRenderOptions loads workspace EXTEND settings and lets explicit options win", async (t) => {
const root = await makeTempDir("baoyu-md-render-options-");
useCwd(t, root);
const extendPath = path.join(
root,
".baoyu-skills",
"baoyu-markdown-to-html",
"EXTEND.md",
);
await fs.mkdir(path.dirname(extendPath), { recursive: true });
await fs.writeFile(
extendPath,
`---
default_theme: modern
default_color: green
default_font_family: mono
default_font_size: 17
default_code_theme: nord
mac_code_block: false
show_line_number: true
cite: true
count: true
legend: title-alt
keep_title: true
---
`,
);
const fromExtend = resolveRenderOptions();
assert.equal(fromExtend.theme, "modern");
assert.equal(fromExtend.primaryColor, COLOR_PRESETS.green);
assert.equal(fromExtend.fontFamily, FONT_FAMILY_MAP.mono);
assert.equal(fromExtend.fontSize, "17px");
assert.equal(fromExtend.codeTheme, "nord");
assert.equal(fromExtend.isMacCodeBlock, false);
assert.equal(fromExtend.isShowLineNumber, true);
assert.equal(fromExtend.citeStatus, true);
assert.equal(fromExtend.countStatus, true);
assert.equal(fromExtend.legend, "title-alt");
assert.equal(fromExtend.keepTitle, true);
const explicit = resolveRenderOptions({
theme: "simple",
fontSize: "18px",
keepTitle: false,
});
assert.equal(explicit.theme, "simple");
assert.equal(explicit.fontSize, "18px");
assert.equal(explicit.keepTitle, false);
});
@@ -0,0 +1,71 @@
import assert from "node:assert/strict";
import test from "node:test";
import { DEFAULT_STYLE } from "./constants.ts";
import {
buildCss,
buildHtmlDocument,
modifyHtmlStructure,
normalizeCssText,
normalizeInlineCss,
removeFirstHeading,
} from "./html-builder.ts";
test("buildCss injects style variables and concatenates base and theme CSS", () => {
const css = buildCss("body { color: red; }", ".theme { color: blue; }");
assert.match(css, /--md-primary-color: #0F4C81;/);
assert.match(css, /body \{ color: red; \}/);
assert.match(css, /\.theme \{ color: blue; \}/);
});
test("buildHtmlDocument includes optional meta tags and code theme CSS", () => {
const html = buildHtmlDocument(
{
title: "Doc",
author: "Baoyu",
description: "Summary",
},
"body { color: red; }",
"<article>Hello</article>",
".hljs { color: blue; }",
);
assert.match(html, /<title>Doc<\/title>/);
assert.match(html, /meta name="author" content="Baoyu"/);
assert.match(html, /meta name="description" content="Summary"/);
assert.match(html, /<style>body \{ color: red; \}<\/style>/);
assert.match(html, /<style>\.hljs \{ color: blue; \}<\/style>/);
assert.match(html, /<article>Hello<\/article>/);
});
test("normalizeCssText and normalizeInlineCss replace variables and strip declarations", () => {
const rawCss = `
:root { --md-primary-color: #000; --md-font-size: 12px; --foreground: 0 0% 5%; }
.box { color: var(--md-primary-color); font-size: var(--md-font-size); background: hsl(var(--foreground)); }
`;
const normalizedCss = normalizeCssText(rawCss, DEFAULT_STYLE);
assert.match(normalizedCss, /color: #0F4C81/);
assert.match(normalizedCss, /font-size: 16px/);
assert.match(normalizedCss, /background: #3f3f3f/);
assert.doesNotMatch(normalizedCss, /--md-primary-color/);
const normalizedHtml = normalizeInlineCss(
`<style>${rawCss}</style><div style="color: var(--md-primary-color)"></div>`,
DEFAULT_STYLE,
);
assert.match(normalizedHtml, /color: #0F4C81/);
assert.doesNotMatch(normalizedHtml, /var\(--md-primary-color\)/);
});
test("HTML structure helpers hoist nested lists and remove the first heading", () => {
const nestedList = `<ul><li>Parent<ul><li>Child</li></ul></li></ul>`;
assert.equal(
modifyHtmlStructure(nestedList),
`<ul><li>Parent</li><ul><li>Child</li></ul></ul>`,
);
const html = `<h1>Title</h1><p>Intro</p><h2>Sub</h2>`;
assert.equal(removeFirstHeading(html), `<p>Intro</p><h2>Sub</h2>`);
});
@@ -0,0 +1,79 @@
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import {
getImageExtension,
replaceMarkdownImagesWithPlaceholders,
resolveContentImages,
resolveImagePath,
} from "./images.ts";
async function makeTempDir(prefix: string): Promise<string> {
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
}
test("replaceMarkdownImagesWithPlaceholders rewrites markdown and tracks image metadata", () => {
const result = replaceMarkdownImagesWithPlaceholders(
`![cover](images/cover.png)\n\nText\n\n![diagram](images/diagram.webp)`,
"IMG_",
);
assert.equal(result.markdown, `IMG_1\n\nText\n\nIMG_2`);
assert.deepEqual(result.images, [
{ alt: "cover", originalPath: "images/cover.png", placeholder: "IMG_1" },
{ alt: "diagram", originalPath: "images/diagram.webp", placeholder: "IMG_2" },
]);
});
test("image extension and local fallback resolution handle common path variants", async (t) => {
assert.equal(getImageExtension("https://example.com/a.jpeg?x=1"), "jpeg");
assert.equal(getImageExtension("/tmp/figure"), "png");
const root = await makeTempDir("baoyu-md-images-");
t.after(() => fs.rm(root, { recursive: true, force: true }));
const baseDir = path.join(root, "article");
const tempDir = path.join(root, "tmp");
await fs.mkdir(baseDir, { recursive: true });
await fs.mkdir(tempDir, { recursive: true });
await fs.writeFile(path.join(baseDir, "figure.webp"), "webp");
const resolved = await resolveImagePath("figure.png", baseDir, tempDir, "test");
assert.equal(resolved, path.join(baseDir, "figure.webp"));
});
test("resolveContentImages resolves image placeholders against the content directory", async (t) => {
const root = await makeTempDir("baoyu-md-content-images-");
t.after(() => fs.rm(root, { recursive: true, force: true }));
const baseDir = path.join(root, "article");
const tempDir = path.join(root, "tmp");
await fs.mkdir(baseDir, { recursive: true });
await fs.mkdir(tempDir, { recursive: true });
await fs.writeFile(path.join(baseDir, "cover.png"), "png");
const resolved = await resolveContentImages(
[
{
alt: "cover",
originalPath: "cover.png",
placeholder: "IMG_1",
},
],
baseDir,
tempDir,
"test",
);
assert.deepEqual(resolved, [
{
alt: "cover",
originalPath: "cover.png",
placeholder: "IMG_1",
localPath: path.join(baseDir, "cover.png"),
},
]);
});
@@ -0,0 +1,64 @@
import assert from "node:assert/strict";
import test from "node:test";
import { initRenderer, renderMarkdown } from "./renderer.ts";
const render = (md: string) => {
const r = initRenderer();
return renderMarkdown(md, r).html;
};
test("bold with inline code (no underscore)", () => {
const html = render("**算出 `logits`,算出 `loss`。**");
assert.match(html, /<code[^>]*>logits<\/code>/);
assert.match(html, /<code[^>]*>loss<\/code>/);
});
test("bold with inline code (contains underscore)", () => {
const html = render("**变成 `input_ids`。**");
assert.match(html, /<code[^>]*>input_ids<\/code>/);
});
test("emphasis with inline code", () => {
const html = render("*查看 `hidden_states`*");
assert.match(html, /<code[^>]*>hidden_states<\/code>/);
});
test("plain inline code (regression)", () => {
const html = render("`lm_head`");
assert.match(html, /<code[^>]*>lm_head<\/code>/);
});
test("bold without code (regression)", () => {
const html = render("**纯粗体文本**");
assert.match(html, /<strong[^>]*>纯粗体文本<\/strong>/);
assert.doesNotMatch(html, /<code/);
});
test("bold with inline code containing backticks", () => {
const html = render("**``a`b``**");
assert.match(html, /<code[^>]*>a&#96;b<\/code>/);
});
test("emphasis with inline code containing backticks", () => {
const html = render("*``a`b``*");
assert.match(html, /<em[^>]*><code[^>]*>a&#96;b<\/code><\/em>/);
});
test("bold with inline code containing consecutive backticks", () => {
const html = render("**```a``b```**");
assert.match(html, /<code[^>]*>a&#96;&#96;b<\/code>/);
});
test("bold with inline code containing only backticks", () => {
const html = render("**```` `` ````**");
assert.match(html, /<code[^>]*>&#96;&#96;<\/code>/);
});
test("bold with inline code containing only spaces", () => {
const oneSpace = render("**`` ``**");
assert.match(oneSpace, /<code[^>]*> <\/code>/);
const twoSpaces = render("**`` ``**");
assert.match(twoSpaces, /<code[^>]*> <\/code>/);
});
@@ -109,6 +109,13 @@ function parseFrontMatterAndContent(markdownText: string): ParseResult {
}
}
function wrapInlineCode(value: string): string {
const runs = value.match(/`+/g);
const fence = "`".repeat(Math.max(...(runs?.map((run) => run.length) ?? [0])) + 1);
const padding = /^ *$/.test(value) ? "" : " ";
return `${fence}${padding}${value}${padding}${fence}`;
}
export function initRenderer(opts: IOpts = {}): RendererAPI {
const footnotes: [number, string, string][] = [];
let footnoteIndex = 0;
@@ -369,6 +376,7 @@ function preprocessCjkEmphasis(markdown: string): string {
const tree = processor.parse(markdown);
const extractText = (node: any): string => {
if (node.type === "text") return node.value;
if (node.type === "inlineCode") return wrapInlineCode(node.value);
if (node.children) return node.children.map(extractText).join("");
return "";
};
@@ -51,32 +51,42 @@ async function waitForElement(session: ChromeSession, selector: string, timeoutM
return false;
}
async function clickMenuByText(session: ChromeSession, text: string): Promise<void> {
async function clickMenuByText(session: ChromeSession, text: string, maxRetries = 5): Promise<void> {
console.log(`[wechat] Clicking "${text}" menu...`);
const posResult = await session.cdp.send<{ result: { value: string } }>('Runtime.evaluate', {
expression: `
(function() {
const items = document.querySelectorAll('.new-creation__menu .new-creation__menu-item');
for (const item of items) {
const title = item.querySelector('.new-creation__menu-title');
if (title && title.textContent?.trim() === '${text}') {
item.scrollIntoView({ block: 'center' });
const rect = item.getBoundingClientRect();
return JSON.stringify({ x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 });
for (let attempt = 1; attempt <= maxRetries; attempt++) {
const posResult = await session.cdp.send<{ result: { value: string } }>('Runtime.evaluate', {
expression: `
(function() {
const items = document.querySelectorAll('.new-creation__menu .new-creation__menu-item');
for (const item of items) {
const title = item.querySelector('.new-creation__menu-title');
if (title && title.textContent?.trim() === '${text}') {
item.scrollIntoView({ block: 'center' });
const rect = item.getBoundingClientRect();
return JSON.stringify({ x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 });
}
}
}
return 'null';
})()
`,
returnByValue: true,
}, { sessionId: session.sessionId });
return 'null';
})()
`,
returnByValue: true,
}, { sessionId: session.sessionId });
if (posResult.result.value === 'null') throw new Error(`Menu "${text}" not found`);
const pos = JSON.parse(posResult.result.value);
if (posResult.result.value !== 'null') {
const pos = JSON.parse(posResult.result.value);
await session.cdp.send('Input.dispatchMouseEvent', { type: 'mousePressed', x: pos.x, y: pos.y, button: 'left', clickCount: 1 }, { sessionId: session.sessionId });
await sleep(100);
await session.cdp.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: pos.x, y: pos.y, button: 'left', clickCount: 1 }, { sessionId: session.sessionId });
return;
}
await session.cdp.send('Input.dispatchMouseEvent', { type: 'mousePressed', x: pos.x, y: pos.y, button: 'left', clickCount: 1 }, { sessionId: session.sessionId });
await sleep(100);
await session.cdp.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: pos.x, y: pos.y, button: 'left', clickCount: 1 }, { sessionId: session.sessionId });
if (attempt < maxRetries) {
const delay = Math.min(1000 * attempt, 3000);
console.log(`[wechat] Menu "${text}" not found, retrying in ${delay}ms (${attempt}/${maxRetries})...`);
await sleep(delay);
}
}
throw new Error(`Menu "${text}" not found after ${maxRetries} attempts`);
}
async function copyImageToClipboard(imagePath: string): Promise<void> {
@@ -495,10 +505,10 @@ export async function postArticle(options: ArticleOptions): Promise<void> {
if (!loggedIn) throw new Error('Login timeout');
}
console.log('[wechat] Logged in.');
await sleep(2000);
await sleep(5000);
// Wait for menu to be ready
const menuReady = await waitForElement(session, '.new-creation__menu', 20_000);
const menuReady = await waitForElement(session, '.new-creation__menu', 40_000);
if (!menuReady) throw new Error('Home page menu did not load');
const targets = await cdp.send<{ targetInfos: Array<{ targetId: string; url: string; type: string }> }>('Target.getTargets');
@@ -517,16 +527,21 @@ export async function postArticle(options: ArticleOptions): Promise<void> {
await cdp.send('Runtime.enable', {}, { sessionId });
await cdp.send('DOM.enable', {}, { sessionId });
await sleep(3000);
// Wait for editor elements to fully load
console.log('[wechat] Waiting for editor to load...');
const editorLoaded = await waitForElement(session, '#title', 30_000);
if (!editorLoaded) throw new Error('Editor did not load (#title not found)');
await waitForElement(session, '.ProseMirror', 15_000);
await sleep(2000);
if (effectiveTitle) {
console.log('[wechat] Filling title...');
await evaluate(session, `document.querySelector('#title').value = ${JSON.stringify(effectiveTitle)}; document.querySelector('#title').dispatchEvent(new Event('input', { bubbles: true }));`);
await evaluate(session, `(function() { const el = document.querySelector('#title'); el.focus(); el.value = ${JSON.stringify(effectiveTitle)}; el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); })()`);
}
if (effectiveAuthor) {
console.log('[wechat] Filling author...');
await evaluate(session, `document.querySelector('#author').value = ${JSON.stringify(effectiveAuthor)}; document.querySelector('#author').dispatchEvent(new Event('input', { bubbles: true }));`);
await evaluate(session, `(function() { const el = document.querySelector('#author'); el.focus(); el.value = ${JSON.stringify(effectiveAuthor)}; el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); })()`);
}
await sleep(500);
@@ -0,0 +1,171 @@
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import http from "node:http";
import os from "node:os";
import path from "node:path";
import process from "node:process";
import test, { type TestContext } from "node:test";
import {
findChromeExecutable,
findExistingChromeDebugPort,
getFreePort,
resolveSharedChromeProfileDir,
waitForChromeDebugPort,
} from "./index.ts";
function useEnv(
t: TestContext,
values: Record<string, string | null>,
): void {
const previous = new Map<string, string | undefined>();
for (const [key, value] of Object.entries(values)) {
previous.set(key, process.env[key]);
if (value == null) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
t.after(() => {
for (const [key, value] of previous.entries()) {
if (value == null) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
});
}
async function makeTempDir(prefix: string): Promise<string> {
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
}
async function startDebugServer(port: number): Promise<http.Server> {
const server = http.createServer((req, res) => {
if (req.url === "/json/version") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({
webSocketDebuggerUrl: `ws://127.0.0.1:${port}/devtools/browser/demo`,
}));
return;
}
res.writeHead(404);
res.end();
});
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(port, "127.0.0.1", () => resolve());
});
return server;
}
async function closeServer(server: http.Server): Promise<void> {
await new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) reject(error);
else resolve();
});
});
}
test("getFreePort honors a fixed environment override and otherwise allocates a TCP port", async (t) => {
useEnv(t, { TEST_FIXED_PORT: "45678" });
assert.equal(await getFreePort("TEST_FIXED_PORT"), 45678);
const dynamicPort = await getFreePort();
assert.ok(Number.isInteger(dynamicPort));
assert.ok(dynamicPort > 0);
});
test("findChromeExecutable prefers env overrides and falls back to candidate paths", async (t) => {
const root = await makeTempDir("baoyu-chrome-bin-");
t.after(() => fs.rm(root, { recursive: true, force: true }));
const envChrome = path.join(root, "env-chrome");
const fallbackChrome = path.join(root, "fallback-chrome");
await fs.writeFile(envChrome, "");
await fs.writeFile(fallbackChrome, "");
useEnv(t, { BAOYU_CHROME_PATH: envChrome });
assert.equal(
findChromeExecutable({
envNames: ["BAOYU_CHROME_PATH"],
candidates: { default: [fallbackChrome] },
}),
envChrome,
);
useEnv(t, { BAOYU_CHROME_PATH: null });
assert.equal(
findChromeExecutable({
envNames: ["BAOYU_CHROME_PATH"],
candidates: { default: [fallbackChrome] },
}),
fallbackChrome,
);
});
test("resolveSharedChromeProfileDir supports env overrides, WSL paths, and default suffixes", (t) => {
useEnv(t, { BAOYU_SHARED_PROFILE: "/tmp/custom-profile" });
assert.equal(
resolveSharedChromeProfileDir({
envNames: ["BAOYU_SHARED_PROFILE"],
appDataDirName: "demo-app",
profileDirName: "demo-profile",
}),
path.resolve("/tmp/custom-profile"),
);
useEnv(t, { BAOYU_SHARED_PROFILE: null });
assert.equal(
resolveSharedChromeProfileDir({
wslWindowsHome: "/mnt/c/Users/demo",
appDataDirName: "demo-app",
profileDirName: "demo-profile",
}),
path.join("/mnt/c/Users/demo", ".local", "share", "demo-app", "demo-profile"),
);
const fallback = resolveSharedChromeProfileDir({
appDataDirName: "demo-app",
profileDirName: "demo-profile",
});
assert.match(fallback, /demo-app[\\/]demo-profile$/);
});
test("findExistingChromeDebugPort reads DevToolsActivePort and validates it against a live endpoint", async (t) => {
const root = await makeTempDir("baoyu-cdp-profile-");
t.after(() => fs.rm(root, { recursive: true, force: true }));
const port = await getFreePort();
const server = await startDebugServer(port);
t.after(() => closeServer(server));
await fs.writeFile(path.join(root, "DevToolsActivePort"), `${port}\n/devtools/browser/demo\n`);
const found = await findExistingChromeDebugPort({ profileDir: root, timeoutMs: 1000 });
assert.equal(found, port);
});
test("waitForChromeDebugPort retries until the debug endpoint becomes available", async (t) => {
const port = await getFreePort();
const serverPromise = (async () => {
await new Promise((resolve) => setTimeout(resolve, 200));
const server = await startDebugServer(port);
t.after(() => closeServer(server));
})();
const websocketUrl = await waitForChromeDebugPort(port, 4000, {
includeLastError: true,
});
await serverPromise;
assert.equal(websocketUrl, `ws://127.0.0.1:${port}/devtools/browser/demo`);
});
@@ -43,6 +43,18 @@ type FindExistingChromeDebugPortOptions = {
timeoutMs?: number;
};
export type ChromeChannel = "stable" | "beta" | "canary" | "dev";
export type DiscoveredChrome = {
port: number;
wsUrl: string;
};
type DiscoverRunningChromeOptions = {
channels?: ChromeChannel[];
timeoutMs?: number;
};
type LaunchChromeOptions = {
chromePath: string;
profileDir: string;
@@ -173,16 +185,32 @@ async function isDebugPortReady(port: number, timeoutMs = 3_000): Promise<boolea
}
}
function isPortListening(port: number, timeoutMs = 3_000): Promise<boolean> {
return new Promise((resolve) => {
const socket = new net.Socket();
const timer = setTimeout(() => { socket.destroy(); resolve(false); }, timeoutMs);
socket.once("connect", () => { clearTimeout(timer); socket.destroy(); resolve(true); });
socket.once("error", () => { clearTimeout(timer); resolve(false); });
socket.connect(port, "127.0.0.1");
});
}
function parseDevToolsActivePort(filePath: string): { port: number; wsPath: string } | null {
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;
}
export async function findExistingChromeDebugPort(options: FindExistingChromeDebugPortOptions): Promise<number | null> {
const timeoutMs = options.timeoutMs ?? 3_000;
const portFile = path.join(options.profileDir, "DevToolsActivePort");
const parsed = parseDevToolsActivePort(path.join(options.profileDir, "DevToolsActivePort"));
try {
const content = fs.readFileSync(portFile, "utf-8");
const [portLine] = content.split(/\r?\n/);
const port = Number.parseInt(portLine?.trim() ?? "", 10);
if (port > 0 && await isDebugPortReady(port, timeoutMs)) return port;
} catch {}
if (parsed && parsed.port > 0 && await isDebugPortReady(parsed.port, timeoutMs)) return parsed.port;
if (process.platform === "win32") return null;
@@ -204,6 +232,81 @@ export async function findExistingChromeDebugPort(options: FindExistingChromeDeb
return null;
}
export function getDefaultChromeUserDataDirs(channels: ChromeChannel[] = ["stable"]): string[] {
const home = os.homedir();
const dirs: string[] = [];
const channelDirs: Record<string, { darwin: string; linux: string; win32: string }> = {
stable: {
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome"),
linux: path.join(home, ".config", "google-chrome"),
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome", "User Data"),
},
beta: {
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome Beta"),
linux: path.join(home, ".config", "google-chrome-beta"),
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome Beta", "User Data"),
},
canary: {
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome Canary"),
linux: path.join(home, ".config", "google-chrome-canary"),
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome SxS", "User Data"),
},
dev: {
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome Dev"),
linux: path.join(home, ".config", "google-chrome-dev"),
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome Dev", "User Data"),
},
};
const platform = process.platform === "darwin" ? "darwin" : process.platform === "win32" ? "win32" : "linux";
for (const ch of channels) {
const entry = channelDirs[ch];
if (entry) dirs.push(entry[platform]);
}
return dirs;
}
export async function discoverRunningChromeDebugPort(options: DiscoverRunningChromeOptions = {}): Promise<DiscoveredChrome | null> {
const channels = options.channels ?? ["stable", "beta", "canary", "dev"];
const timeoutMs = options.timeoutMs ?? 3_000;
const userDataDirs = getDefaultChromeUserDataDirs(channels);
for (const dir of userDataDirs) {
const parsed = parseDevToolsActivePort(path.join(dir, "DevToolsActivePort"));
if (!parsed) continue;
if (await isPortListening(parsed.port, timeoutMs)) {
return { port: parsed.port, wsUrl: `ws://127.0.0.1:${parsed.port}${parsed.wsPath}` };
}
}
if (process.platform !== "win32") {
try {
const result = spawnSync("ps", ["aux"], { encoding: "utf-8", timeout: 5_000 });
if (result.status === 0 && result.stdout) {
const lines = result.stdout
.split("\n")
.filter((line) => line.includes("--remote-debugging-port=") && /chrome|chromium/i.test(line));
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)) {
try {
const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(`http://127.0.0.1:${port}/json/version`, { timeoutMs });
if (version.webSocketDebuggerUrl) return { port, wsUrl: version.webSocketDebuggerUrl };
} catch {}
}
}
}
} catch {}
}
return null;
}
export async function waitForChromeDebugPort(
port: number,
timeoutMs: number,
@@ -0,0 +1,93 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
extractSummaryFromBody,
extractTitleFromMarkdown,
parseFrontmatter,
pickFirstString,
serializeFrontmatter,
stripWrappingQuotes,
toFrontmatterString,
} from "./content.ts";
test("parseFrontmatter extracts YAML fields and strips wrapping quotes", () => {
const input = `---
title: "Hello World"
author: Baoyu
summary: plain text
---
# Heading
Body`;
const result = parseFrontmatter(input);
assert.deepEqual(result.frontmatter, {
title: "Hello World",
author: "Baoyu",
summary: "plain text",
});
assert.match(result.body, /^# Heading/);
});
test("parseFrontmatter returns original content when no frontmatter exists", () => {
const input = "# No frontmatter";
assert.deepEqual(parseFrontmatter(input), {
frontmatter: {},
body: input,
});
});
test("serializeFrontmatter renders YAML only when fields exist", () => {
assert.equal(serializeFrontmatter({}), "");
assert.equal(
serializeFrontmatter({ title: "Hello", author: "Baoyu" }),
"---\ntitle: Hello\nauthor: Baoyu\n---\n",
);
});
test("quote and frontmatter string helpers normalize mixed scalar values", () => {
assert.equal(stripWrappingQuotes(`" quoted "`), "quoted");
assert.equal(stripWrappingQuotes("“ 中文标题 ”"), "中文标题");
assert.equal(stripWrappingQuotes("plain"), "plain");
assert.equal(toFrontmatterString("'hello'"), "hello");
assert.equal(toFrontmatterString(42), "42");
assert.equal(toFrontmatterString(false), "false");
assert.equal(toFrontmatterString({}), undefined);
assert.equal(
pickFirstString({ summary: 123, title: "" }, ["title", "summary"]),
"123",
);
});
test("markdown title and summary extraction skip non-body content and clean formatting", () => {
const markdown = `
![cover](cover.png)
## My Title
Body paragraph
`;
assert.equal(extractTitleFromMarkdown(markdown), "My Title");
const summary = extractSummaryFromBody(
`
# Heading
> quote
- list
1. ordered
\`\`\`
code
\`\`\`
This is **the first paragraph** with [a link](https://example.com) and \`inline code\` that should be summarized cleanly.
`,
70,
);
assert.equal(
summary,
"This is the first paragraph with a link and inline code that should...",
);
});
@@ -0,0 +1,140 @@
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import process from "node:process";
import test, { type TestContext } from "node:test";
import { COLOR_PRESETS, FONT_FAMILY_MAP } from "./constants.ts";
import {
buildMarkdownDocumentMeta,
formatTimestamp,
resolveColorToken,
resolveFontFamilyToken,
resolveMarkdownStyle,
resolveRenderOptions,
} from "./document.ts";
function useCwd(t: TestContext, cwd: string): void {
const previous = process.cwd();
process.chdir(cwd);
t.after(() => {
process.chdir(previous);
});
}
async function makeTempDir(prefix: string): Promise<string> {
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
}
test("document token resolvers map known presets and allow passthrough values", () => {
assert.equal(resolveColorToken("green"), COLOR_PRESETS.green);
assert.equal(resolveColorToken("#123456"), "#123456");
assert.equal(resolveColorToken(), undefined);
assert.equal(resolveFontFamilyToken("mono"), FONT_FAMILY_MAP.mono);
assert.equal(resolveFontFamilyToken("Custom Font"), "Custom Font");
assert.equal(resolveFontFamilyToken(), undefined);
});
test("formatTimestamp uses compact sortable datetime output", () => {
const date = new Date("2026-03-13T21:04:05.000Z");
const pad = (value: number) => String(value).padStart(2, "0");
const expected = `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(
date.getDate(),
)}${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`;
assert.equal(formatTimestamp(date), expected);
});
test("buildMarkdownDocumentMeta prefers frontmatter and falls back to markdown title and summary", () => {
const metaFromYaml = buildMarkdownDocumentMeta(
"# Markdown Title\n\nBody summary paragraph that should be ignored.",
{
title: `" YAML Title "`,
author: "'Baoyu'",
summary: `" YAML Summary "`,
},
"fallback",
);
assert.deepEqual(metaFromYaml, {
title: "YAML Title",
author: "Baoyu",
description: "YAML Summary",
});
const metaFromMarkdown = buildMarkdownDocumentMeta(
`## “Markdown Title”\n\nThis is the first body paragraph that should become the summary because it is long enough.`,
{},
"fallback",
);
assert.equal(metaFromMarkdown.title, "Markdown Title");
assert.match(metaFromMarkdown.description ?? "", /^This is the first body paragraph/);
});
test("resolveMarkdownStyle merges theme defaults with explicit overrides", () => {
const style = resolveMarkdownStyle({
theme: "modern",
primaryColor: "#112233",
fontFamily: "Custom Sans",
});
assert.equal(style.primaryColor, "#112233");
assert.equal(style.fontFamily, "Custom Sans");
assert.equal(style.fontSize, "15px");
assert.equal(style.containerBg, "rgba(250, 249, 245, 1)");
});
test("resolveRenderOptions loads workspace EXTEND settings and lets explicit options win", async (t) => {
const root = await makeTempDir("baoyu-md-render-options-");
useCwd(t, root);
const extendPath = path.join(
root,
".baoyu-skills",
"baoyu-markdown-to-html",
"EXTEND.md",
);
await fs.mkdir(path.dirname(extendPath), { recursive: true });
await fs.writeFile(
extendPath,
`---
default_theme: modern
default_color: green
default_font_family: mono
default_font_size: 17
default_code_theme: nord
mac_code_block: false
show_line_number: true
cite: true
count: true
legend: title-alt
keep_title: true
---
`,
);
const fromExtend = resolveRenderOptions();
assert.equal(fromExtend.theme, "modern");
assert.equal(fromExtend.primaryColor, COLOR_PRESETS.green);
assert.equal(fromExtend.fontFamily, FONT_FAMILY_MAP.mono);
assert.equal(fromExtend.fontSize, "17px");
assert.equal(fromExtend.codeTheme, "nord");
assert.equal(fromExtend.isMacCodeBlock, false);
assert.equal(fromExtend.isShowLineNumber, true);
assert.equal(fromExtend.citeStatus, true);
assert.equal(fromExtend.countStatus, true);
assert.equal(fromExtend.legend, "title-alt");
assert.equal(fromExtend.keepTitle, true);
const explicit = resolveRenderOptions({
theme: "simple",
fontSize: "18px",
keepTitle: false,
});
assert.equal(explicit.theme, "simple");
assert.equal(explicit.fontSize, "18px");
assert.equal(explicit.keepTitle, false);
});
@@ -0,0 +1,71 @@
import assert from "node:assert/strict";
import test from "node:test";
import { DEFAULT_STYLE } from "./constants.ts";
import {
buildCss,
buildHtmlDocument,
modifyHtmlStructure,
normalizeCssText,
normalizeInlineCss,
removeFirstHeading,
} from "./html-builder.ts";
test("buildCss injects style variables and concatenates base and theme CSS", () => {
const css = buildCss("body { color: red; }", ".theme { color: blue; }");
assert.match(css, /--md-primary-color: #0F4C81;/);
assert.match(css, /body \{ color: red; \}/);
assert.match(css, /\.theme \{ color: blue; \}/);
});
test("buildHtmlDocument includes optional meta tags and code theme CSS", () => {
const html = buildHtmlDocument(
{
title: "Doc",
author: "Baoyu",
description: "Summary",
},
"body { color: red; }",
"<article>Hello</article>",
".hljs { color: blue; }",
);
assert.match(html, /<title>Doc<\/title>/);
assert.match(html, /meta name="author" content="Baoyu"/);
assert.match(html, /meta name="description" content="Summary"/);
assert.match(html, /<style>body \{ color: red; \}<\/style>/);
assert.match(html, /<style>\.hljs \{ color: blue; \}<\/style>/);
assert.match(html, /<article>Hello<\/article>/);
});
test("normalizeCssText and normalizeInlineCss replace variables and strip declarations", () => {
const rawCss = `
:root { --md-primary-color: #000; --md-font-size: 12px; --foreground: 0 0% 5%; }
.box { color: var(--md-primary-color); font-size: var(--md-font-size); background: hsl(var(--foreground)); }
`;
const normalizedCss = normalizeCssText(rawCss, DEFAULT_STYLE);
assert.match(normalizedCss, /color: #0F4C81/);
assert.match(normalizedCss, /font-size: 16px/);
assert.match(normalizedCss, /background: #3f3f3f/);
assert.doesNotMatch(normalizedCss, /--md-primary-color/);
const normalizedHtml = normalizeInlineCss(
`<style>${rawCss}</style><div style="color: var(--md-primary-color)"></div>`,
DEFAULT_STYLE,
);
assert.match(normalizedHtml, /color: #0F4C81/);
assert.doesNotMatch(normalizedHtml, /var\(--md-primary-color\)/);
});
test("HTML structure helpers hoist nested lists and remove the first heading", () => {
const nestedList = `<ul><li>Parent<ul><li>Child</li></ul></li></ul>`;
assert.equal(
modifyHtmlStructure(nestedList),
`<ul><li>Parent</li><ul><li>Child</li></ul></ul>`,
);
const html = `<h1>Title</h1><p>Intro</p><h2>Sub</h2>`;
assert.equal(removeFirstHeading(html), `<p>Intro</p><h2>Sub</h2>`);
});
@@ -0,0 +1,79 @@
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import {
getImageExtension,
replaceMarkdownImagesWithPlaceholders,
resolveContentImages,
resolveImagePath,
} from "./images.ts";
async function makeTempDir(prefix: string): Promise<string> {
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
}
test("replaceMarkdownImagesWithPlaceholders rewrites markdown and tracks image metadata", () => {
const result = replaceMarkdownImagesWithPlaceholders(
`![cover](images/cover.png)\n\nText\n\n![diagram](images/diagram.webp)`,
"IMG_",
);
assert.equal(result.markdown, `IMG_1\n\nText\n\nIMG_2`);
assert.deepEqual(result.images, [
{ alt: "cover", originalPath: "images/cover.png", placeholder: "IMG_1" },
{ alt: "diagram", originalPath: "images/diagram.webp", placeholder: "IMG_2" },
]);
});
test("image extension and local fallback resolution handle common path variants", async (t) => {
assert.equal(getImageExtension("https://example.com/a.jpeg?x=1"), "jpeg");
assert.equal(getImageExtension("/tmp/figure"), "png");
const root = await makeTempDir("baoyu-md-images-");
t.after(() => fs.rm(root, { recursive: true, force: true }));
const baseDir = path.join(root, "article");
const tempDir = path.join(root, "tmp");
await fs.mkdir(baseDir, { recursive: true });
await fs.mkdir(tempDir, { recursive: true });
await fs.writeFile(path.join(baseDir, "figure.webp"), "webp");
const resolved = await resolveImagePath("figure.png", baseDir, tempDir, "test");
assert.equal(resolved, path.join(baseDir, "figure.webp"));
});
test("resolveContentImages resolves image placeholders against the content directory", async (t) => {
const root = await makeTempDir("baoyu-md-content-images-");
t.after(() => fs.rm(root, { recursive: true, force: true }));
const baseDir = path.join(root, "article");
const tempDir = path.join(root, "tmp");
await fs.mkdir(baseDir, { recursive: true });
await fs.mkdir(tempDir, { recursive: true });
await fs.writeFile(path.join(baseDir, "cover.png"), "png");
const resolved = await resolveContentImages(
[
{
alt: "cover",
originalPath: "cover.png",
placeholder: "IMG_1",
},
],
baseDir,
tempDir,
"test",
);
assert.deepEqual(resolved, [
{
alt: "cover",
originalPath: "cover.png",
placeholder: "IMG_1",
localPath: path.join(baseDir, "cover.png"),
},
]);
});
@@ -0,0 +1,64 @@
import assert from "node:assert/strict";
import test from "node:test";
import { initRenderer, renderMarkdown } from "./renderer.ts";
const render = (md: string) => {
const r = initRenderer();
return renderMarkdown(md, r).html;
};
test("bold with inline code (no underscore)", () => {
const html = render("**算出 `logits`,算出 `loss`。**");
assert.match(html, /<code[^>]*>logits<\/code>/);
assert.match(html, /<code[^>]*>loss<\/code>/);
});
test("bold with inline code (contains underscore)", () => {
const html = render("**变成 `input_ids`。**");
assert.match(html, /<code[^>]*>input_ids<\/code>/);
});
test("emphasis with inline code", () => {
const html = render("*查看 `hidden_states`*");
assert.match(html, /<code[^>]*>hidden_states<\/code>/);
});
test("plain inline code (regression)", () => {
const html = render("`lm_head`");
assert.match(html, /<code[^>]*>lm_head<\/code>/);
});
test("bold without code (regression)", () => {
const html = render("**纯粗体文本**");
assert.match(html, /<strong[^>]*>纯粗体文本<\/strong>/);
assert.doesNotMatch(html, /<code/);
});
test("bold with inline code containing backticks", () => {
const html = render("**``a`b``**");
assert.match(html, /<code[^>]*>a&#96;b<\/code>/);
});
test("emphasis with inline code containing backticks", () => {
const html = render("*``a`b``*");
assert.match(html, /<em[^>]*><code[^>]*>a&#96;b<\/code><\/em>/);
});
test("bold with inline code containing consecutive backticks", () => {
const html = render("**```a``b```**");
assert.match(html, /<code[^>]*>a&#96;&#96;b<\/code>/);
});
test("bold with inline code containing only backticks", () => {
const html = render("**```` `` ````**");
assert.match(html, /<code[^>]*>&#96;&#96;<\/code>/);
});
test("bold with inline code containing only spaces", () => {
const oneSpace = render("**`` ``**");
assert.match(oneSpace, /<code[^>]*> <\/code>/);
const twoSpaces = render("**`` ``**");
assert.match(twoSpaces, /<code[^>]*> <\/code>/);
});
@@ -109,6 +109,13 @@ function parseFrontMatterAndContent(markdownText: string): ParseResult {
}
}
function wrapInlineCode(value: string): string {
const runs = value.match(/`+/g);
const fence = "`".repeat(Math.max(...(runs?.map((run) => run.length) ?? [0])) + 1);
const padding = /^ *$/.test(value) ? "" : " ";
return `${fence}${padding}${value}${padding}${fence}`;
}
export function initRenderer(opts: IOpts = {}): RendererAPI {
const footnotes: [number, string, string][] = [];
let footnoteIndex = 0;
@@ -369,6 +376,7 @@ function preprocessCjkEmphasis(markdown: string): string {
const tree = processor.parse(markdown);
const extractText = (node: any): string => {
if (node.type === "text") return node.value;
if (node.type === "inlineCode") return wrapInlineCode(node.value);
if (node.children) return node.children.map(extractText).join("");
return "";
};
@@ -0,0 +1,171 @@
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import http from "node:http";
import os from "node:os";
import path from "node:path";
import process from "node:process";
import test, { type TestContext } from "node:test";
import {
findChromeExecutable,
findExistingChromeDebugPort,
getFreePort,
resolveSharedChromeProfileDir,
waitForChromeDebugPort,
} from "./index.ts";
function useEnv(
t: TestContext,
values: Record<string, string | null>,
): void {
const previous = new Map<string, string | undefined>();
for (const [key, value] of Object.entries(values)) {
previous.set(key, process.env[key]);
if (value == null) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
t.after(() => {
for (const [key, value] of previous.entries()) {
if (value == null) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
});
}
async function makeTempDir(prefix: string): Promise<string> {
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
}
async function startDebugServer(port: number): Promise<http.Server> {
const server = http.createServer((req, res) => {
if (req.url === "/json/version") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({
webSocketDebuggerUrl: `ws://127.0.0.1:${port}/devtools/browser/demo`,
}));
return;
}
res.writeHead(404);
res.end();
});
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(port, "127.0.0.1", () => resolve());
});
return server;
}
async function closeServer(server: http.Server): Promise<void> {
await new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) reject(error);
else resolve();
});
});
}
test("getFreePort honors a fixed environment override and otherwise allocates a TCP port", async (t) => {
useEnv(t, { TEST_FIXED_PORT: "45678" });
assert.equal(await getFreePort("TEST_FIXED_PORT"), 45678);
const dynamicPort = await getFreePort();
assert.ok(Number.isInteger(dynamicPort));
assert.ok(dynamicPort > 0);
});
test("findChromeExecutable prefers env overrides and falls back to candidate paths", async (t) => {
const root = await makeTempDir("baoyu-chrome-bin-");
t.after(() => fs.rm(root, { recursive: true, force: true }));
const envChrome = path.join(root, "env-chrome");
const fallbackChrome = path.join(root, "fallback-chrome");
await fs.writeFile(envChrome, "");
await fs.writeFile(fallbackChrome, "");
useEnv(t, { BAOYU_CHROME_PATH: envChrome });
assert.equal(
findChromeExecutable({
envNames: ["BAOYU_CHROME_PATH"],
candidates: { default: [fallbackChrome] },
}),
envChrome,
);
useEnv(t, { BAOYU_CHROME_PATH: null });
assert.equal(
findChromeExecutable({
envNames: ["BAOYU_CHROME_PATH"],
candidates: { default: [fallbackChrome] },
}),
fallbackChrome,
);
});
test("resolveSharedChromeProfileDir supports env overrides, WSL paths, and default suffixes", (t) => {
useEnv(t, { BAOYU_SHARED_PROFILE: "/tmp/custom-profile" });
assert.equal(
resolveSharedChromeProfileDir({
envNames: ["BAOYU_SHARED_PROFILE"],
appDataDirName: "demo-app",
profileDirName: "demo-profile",
}),
path.resolve("/tmp/custom-profile"),
);
useEnv(t, { BAOYU_SHARED_PROFILE: null });
assert.equal(
resolveSharedChromeProfileDir({
wslWindowsHome: "/mnt/c/Users/demo",
appDataDirName: "demo-app",
profileDirName: "demo-profile",
}),
path.join("/mnt/c/Users/demo", ".local", "share", "demo-app", "demo-profile"),
);
const fallback = resolveSharedChromeProfileDir({
appDataDirName: "demo-app",
profileDirName: "demo-profile",
});
assert.match(fallback, /demo-app[\\/]demo-profile$/);
});
test("findExistingChromeDebugPort reads DevToolsActivePort and validates it against a live endpoint", async (t) => {
const root = await makeTempDir("baoyu-cdp-profile-");
t.after(() => fs.rm(root, { recursive: true, force: true }));
const port = await getFreePort();
const server = await startDebugServer(port);
t.after(() => closeServer(server));
await fs.writeFile(path.join(root, "DevToolsActivePort"), `${port}\n/devtools/browser/demo\n`);
const found = await findExistingChromeDebugPort({ profileDir: root, timeoutMs: 1000 });
assert.equal(found, port);
});
test("waitForChromeDebugPort retries until the debug endpoint becomes available", async (t) => {
const port = await getFreePort();
const serverPromise = (async () => {
await new Promise((resolve) => setTimeout(resolve, 200));
const server = await startDebugServer(port);
t.after(() => closeServer(server));
})();
const websocketUrl = await waitForChromeDebugPort(port, 4000, {
includeLastError: true,
});
await serverPromise;
assert.equal(websocketUrl, `ws://127.0.0.1:${port}/devtools/browser/demo`);
});
@@ -43,6 +43,18 @@ type FindExistingChromeDebugPortOptions = {
timeoutMs?: number;
};
export type ChromeChannel = "stable" | "beta" | "canary" | "dev";
export type DiscoveredChrome = {
port: number;
wsUrl: string;
};
type DiscoverRunningChromeOptions = {
channels?: ChromeChannel[];
timeoutMs?: number;
};
type LaunchChromeOptions = {
chromePath: string;
profileDir: string;
@@ -173,16 +185,32 @@ async function isDebugPortReady(port: number, timeoutMs = 3_000): Promise<boolea
}
}
function isPortListening(port: number, timeoutMs = 3_000): Promise<boolean> {
return new Promise((resolve) => {
const socket = new net.Socket();
const timer = setTimeout(() => { socket.destroy(); resolve(false); }, timeoutMs);
socket.once("connect", () => { clearTimeout(timer); socket.destroy(); resolve(true); });
socket.once("error", () => { clearTimeout(timer); resolve(false); });
socket.connect(port, "127.0.0.1");
});
}
function parseDevToolsActivePort(filePath: string): { port: number; wsPath: string } | null {
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;
}
export async function findExistingChromeDebugPort(options: FindExistingChromeDebugPortOptions): Promise<number | null> {
const timeoutMs = options.timeoutMs ?? 3_000;
const portFile = path.join(options.profileDir, "DevToolsActivePort");
const parsed = parseDevToolsActivePort(path.join(options.profileDir, "DevToolsActivePort"));
try {
const content = fs.readFileSync(portFile, "utf-8");
const [portLine] = content.split(/\r?\n/);
const port = Number.parseInt(portLine?.trim() ?? "", 10);
if (port > 0 && await isDebugPortReady(port, timeoutMs)) return port;
} catch {}
if (parsed && parsed.port > 0 && await isDebugPortReady(parsed.port, timeoutMs)) return parsed.port;
if (process.platform === "win32") return null;
@@ -204,6 +232,81 @@ export async function findExistingChromeDebugPort(options: FindExistingChromeDeb
return null;
}
export function getDefaultChromeUserDataDirs(channels: ChromeChannel[] = ["stable"]): string[] {
const home = os.homedir();
const dirs: string[] = [];
const channelDirs: Record<string, { darwin: string; linux: string; win32: string }> = {
stable: {
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome"),
linux: path.join(home, ".config", "google-chrome"),
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome", "User Data"),
},
beta: {
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome Beta"),
linux: path.join(home, ".config", "google-chrome-beta"),
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome Beta", "User Data"),
},
canary: {
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome Canary"),
linux: path.join(home, ".config", "google-chrome-canary"),
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome SxS", "User Data"),
},
dev: {
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome Dev"),
linux: path.join(home, ".config", "google-chrome-dev"),
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome Dev", "User Data"),
},
};
const platform = process.platform === "darwin" ? "darwin" : process.platform === "win32" ? "win32" : "linux";
for (const ch of channels) {
const entry = channelDirs[ch];
if (entry) dirs.push(entry[platform]);
}
return dirs;
}
export async function discoverRunningChromeDebugPort(options: DiscoverRunningChromeOptions = {}): Promise<DiscoveredChrome | null> {
const channels = options.channels ?? ["stable", "beta", "canary", "dev"];
const timeoutMs = options.timeoutMs ?? 3_000;
const userDataDirs = getDefaultChromeUserDataDirs(channels);
for (const dir of userDataDirs) {
const parsed = parseDevToolsActivePort(path.join(dir, "DevToolsActivePort"));
if (!parsed) continue;
if (await isPortListening(parsed.port, timeoutMs)) {
return { port: parsed.port, wsUrl: `ws://127.0.0.1:${parsed.port}${parsed.wsPath}` };
}
}
if (process.platform !== "win32") {
try {
const result = spawnSync("ps", ["aux"], { encoding: "utf-8", timeout: 5_000 });
if (result.status === 0 && result.stdout) {
const lines = result.stdout
.split("\n")
.filter((line) => line.includes("--remote-debugging-port=") && /chrome|chromium/i.test(line));
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)) {
try {
const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(`http://127.0.0.1:${port}/json/version`, { timeoutMs });
if (version.webSocketDebuggerUrl) return { port, wsUrl: version.webSocketDebuggerUrl };
} catch {}
}
}
}
} catch {}
}
return null;
}
export async function waitForChromeDebugPort(
port: number,
timeoutMs: number,
@@ -0,0 +1,171 @@
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import http from "node:http";
import os from "node:os";
import path from "node:path";
import process from "node:process";
import test, { type TestContext } from "node:test";
import {
findChromeExecutable,
findExistingChromeDebugPort,
getFreePort,
resolveSharedChromeProfileDir,
waitForChromeDebugPort,
} from "./index.ts";
function useEnv(
t: TestContext,
values: Record<string, string | null>,
): void {
const previous = new Map<string, string | undefined>();
for (const [key, value] of Object.entries(values)) {
previous.set(key, process.env[key]);
if (value == null) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
t.after(() => {
for (const [key, value] of previous.entries()) {
if (value == null) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
});
}
async function makeTempDir(prefix: string): Promise<string> {
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
}
async function startDebugServer(port: number): Promise<http.Server> {
const server = http.createServer((req, res) => {
if (req.url === "/json/version") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({
webSocketDebuggerUrl: `ws://127.0.0.1:${port}/devtools/browser/demo`,
}));
return;
}
res.writeHead(404);
res.end();
});
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(port, "127.0.0.1", () => resolve());
});
return server;
}
async function closeServer(server: http.Server): Promise<void> {
await new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) reject(error);
else resolve();
});
});
}
test("getFreePort honors a fixed environment override and otherwise allocates a TCP port", async (t) => {
useEnv(t, { TEST_FIXED_PORT: "45678" });
assert.equal(await getFreePort("TEST_FIXED_PORT"), 45678);
const dynamicPort = await getFreePort();
assert.ok(Number.isInteger(dynamicPort));
assert.ok(dynamicPort > 0);
});
test("findChromeExecutable prefers env overrides and falls back to candidate paths", async (t) => {
const root = await makeTempDir("baoyu-chrome-bin-");
t.after(() => fs.rm(root, { recursive: true, force: true }));
const envChrome = path.join(root, "env-chrome");
const fallbackChrome = path.join(root, "fallback-chrome");
await fs.writeFile(envChrome, "");
await fs.writeFile(fallbackChrome, "");
useEnv(t, { BAOYU_CHROME_PATH: envChrome });
assert.equal(
findChromeExecutable({
envNames: ["BAOYU_CHROME_PATH"],
candidates: { default: [fallbackChrome] },
}),
envChrome,
);
useEnv(t, { BAOYU_CHROME_PATH: null });
assert.equal(
findChromeExecutable({
envNames: ["BAOYU_CHROME_PATH"],
candidates: { default: [fallbackChrome] },
}),
fallbackChrome,
);
});
test("resolveSharedChromeProfileDir supports env overrides, WSL paths, and default suffixes", (t) => {
useEnv(t, { BAOYU_SHARED_PROFILE: "/tmp/custom-profile" });
assert.equal(
resolveSharedChromeProfileDir({
envNames: ["BAOYU_SHARED_PROFILE"],
appDataDirName: "demo-app",
profileDirName: "demo-profile",
}),
path.resolve("/tmp/custom-profile"),
);
useEnv(t, { BAOYU_SHARED_PROFILE: null });
assert.equal(
resolveSharedChromeProfileDir({
wslWindowsHome: "/mnt/c/Users/demo",
appDataDirName: "demo-app",
profileDirName: "demo-profile",
}),
path.join("/mnt/c/Users/demo", ".local", "share", "demo-app", "demo-profile"),
);
const fallback = resolveSharedChromeProfileDir({
appDataDirName: "demo-app",
profileDirName: "demo-profile",
});
assert.match(fallback, /demo-app[\\/]demo-profile$/);
});
test("findExistingChromeDebugPort reads DevToolsActivePort and validates it against a live endpoint", async (t) => {
const root = await makeTempDir("baoyu-cdp-profile-");
t.after(() => fs.rm(root, { recursive: true, force: true }));
const port = await getFreePort();
const server = await startDebugServer(port);
t.after(() => closeServer(server));
await fs.writeFile(path.join(root, "DevToolsActivePort"), `${port}\n/devtools/browser/demo\n`);
const found = await findExistingChromeDebugPort({ profileDir: root, timeoutMs: 1000 });
assert.equal(found, port);
});
test("waitForChromeDebugPort retries until the debug endpoint becomes available", async (t) => {
const port = await getFreePort();
const serverPromise = (async () => {
await new Promise((resolve) => setTimeout(resolve, 200));
const server = await startDebugServer(port);
t.after(() => closeServer(server));
})();
const websocketUrl = await waitForChromeDebugPort(port, 4000, {
includeLastError: true,
});
await serverPromise;
assert.equal(websocketUrl, `ws://127.0.0.1:${port}/devtools/browser/demo`);
});
@@ -43,6 +43,18 @@ type FindExistingChromeDebugPortOptions = {
timeoutMs?: number;
};
export type ChromeChannel = "stable" | "beta" | "canary" | "dev";
export type DiscoveredChrome = {
port: number;
wsUrl: string;
};
type DiscoverRunningChromeOptions = {
channels?: ChromeChannel[];
timeoutMs?: number;
};
type LaunchChromeOptions = {
chromePath: string;
profileDir: string;
@@ -173,16 +185,32 @@ async function isDebugPortReady(port: number, timeoutMs = 3_000): Promise<boolea
}
}
function isPortListening(port: number, timeoutMs = 3_000): Promise<boolean> {
return new Promise((resolve) => {
const socket = new net.Socket();
const timer = setTimeout(() => { socket.destroy(); resolve(false); }, timeoutMs);
socket.once("connect", () => { clearTimeout(timer); socket.destroy(); resolve(true); });
socket.once("error", () => { clearTimeout(timer); resolve(false); });
socket.connect(port, "127.0.0.1");
});
}
function parseDevToolsActivePort(filePath: string): { port: number; wsPath: string } | null {
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;
}
export async function findExistingChromeDebugPort(options: FindExistingChromeDebugPortOptions): Promise<number | null> {
const timeoutMs = options.timeoutMs ?? 3_000;
const portFile = path.join(options.profileDir, "DevToolsActivePort");
const parsed = parseDevToolsActivePort(path.join(options.profileDir, "DevToolsActivePort"));
try {
const content = fs.readFileSync(portFile, "utf-8");
const [portLine] = content.split(/\r?\n/);
const port = Number.parseInt(portLine?.trim() ?? "", 10);
if (port > 0 && await isDebugPortReady(port, timeoutMs)) return port;
} catch {}
if (parsed && parsed.port > 0 && await isDebugPortReady(parsed.port, timeoutMs)) return parsed.port;
if (process.platform === "win32") return null;
@@ -204,6 +232,81 @@ export async function findExistingChromeDebugPort(options: FindExistingChromeDeb
return null;
}
export function getDefaultChromeUserDataDirs(channels: ChromeChannel[] = ["stable"]): string[] {
const home = os.homedir();
const dirs: string[] = [];
const channelDirs: Record<string, { darwin: string; linux: string; win32: string }> = {
stable: {
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome"),
linux: path.join(home, ".config", "google-chrome"),
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome", "User Data"),
},
beta: {
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome Beta"),
linux: path.join(home, ".config", "google-chrome-beta"),
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome Beta", "User Data"),
},
canary: {
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome Canary"),
linux: path.join(home, ".config", "google-chrome-canary"),
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome SxS", "User Data"),
},
dev: {
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome Dev"),
linux: path.join(home, ".config", "google-chrome-dev"),
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome Dev", "User Data"),
},
};
const platform = process.platform === "darwin" ? "darwin" : process.platform === "win32" ? "win32" : "linux";
for (const ch of channels) {
const entry = channelDirs[ch];
if (entry) dirs.push(entry[platform]);
}
return dirs;
}
export async function discoverRunningChromeDebugPort(options: DiscoverRunningChromeOptions = {}): Promise<DiscoveredChrome | null> {
const channels = options.channels ?? ["stable", "beta", "canary", "dev"];
const timeoutMs = options.timeoutMs ?? 3_000;
const userDataDirs = getDefaultChromeUserDataDirs(channels);
for (const dir of userDataDirs) {
const parsed = parseDevToolsActivePort(path.join(dir, "DevToolsActivePort"));
if (!parsed) continue;
if (await isPortListening(parsed.port, timeoutMs)) {
return { port: parsed.port, wsUrl: `ws://127.0.0.1:${parsed.port}${parsed.wsPath}` };
}
}
if (process.platform !== "win32") {
try {
const result = spawnSync("ps", ["aux"], { encoding: "utf-8", timeout: 5_000 });
if (result.status === 0 && result.stdout) {
const lines = result.stdout
.split("\n")
.filter((line) => line.includes("--remote-debugging-port=") && /chrome|chromium/i.test(line));
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)) {
try {
const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(`http://127.0.0.1:${port}/json/version`, { timeoutMs });
if (version.webSocketDebuggerUrl) return { port, wsUrl: version.webSocketDebuggerUrl };
} catch {}
}
}
}
} catch {}
}
return null;
}
export async function waitForChromeDebugPort(
port: number,
timeoutMs: number,
@@ -1,26 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
getSizeFromAspectRatio,
normalizeSize,
parseAspectRatio,
} from "../../../skills/baoyu-image-gen/scripts/providers/dashscope.ts";
test("DashScope aspect-ratio parsing accepts numeric ratios only", () => {
assert.deepEqual(parseAspectRatio("3:2"), { width: 3, height: 2 });
assert.equal(parseAspectRatio("square"), null);
assert.equal(parseAspectRatio("-1:2"), null);
});
test("DashScope size selection picks the closest supported size per quality preset", () => {
assert.equal(getSizeFromAspectRatio(null, "normal"), "1024*1024");
assert.equal(getSizeFromAspectRatio("16:9", "normal"), "1280*720");
assert.equal(getSizeFromAspectRatio("16:9", "2k"), "2048*1152");
assert.equal(getSizeFromAspectRatio("invalid", "2k"), "1536*1536");
});
test("DashScope size normalization converts WxH into provider format", () => {
assert.equal(normalizeSize("1024x1024"), "1024*1024");
assert.equal(normalizeSize("2048*1152"), "2048*1152");
});