mirror of
https://github.com/JimLiu/baoyu-skills.git
synced 2026-07-12 22:09:48 +08:00
Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 93e54a7b86 | |||
| c1f8a9ad07 | |||
| 8b8ecf61a6 | |||
| 4be6f3682a | |||
| b89ef02221 | |||
| 55356c8820 | |||
| f23d4eebc3 | |||
| 3b795b6ef3 | |||
| c62d9d5a1b | |||
| 84c56b0da3 | |||
| 462d080a0e | |||
| bd01c370c9 | |||
| b7bcc8c094 | |||
| d9f9da639d | |||
| 126b3040e6 | |||
| 682888cc95 | |||
| e31294415d | |||
| 926f74e9c9 | |||
| 339990e87e | |||
| 3c5c3e006d | |||
| 2aa9790789 | |||
| 38fc733b99 | |||
| 4d2b95d1d1 | |||
| ac2ce0b8b6 | |||
| de7dc85361 | |||
| 0c02b81885 | |||
| 774ad784d8 | |||
| 484b00109f | |||
| ac217d5402 | |||
| 70d9f63727 | |||
| 3398509d9e | |||
| a11613c11b | |||
| cb17cb9cca | |||
| 88d6e09472 | |||
| 004236682d | |||
| 7e07c1bb84 | |||
| c151f33775 | |||
| 32003da694 |
@@ -6,7 +6,7 @@
|
||||
},
|
||||
"metadata": {
|
||||
"description": "Skills shared by Baoyu for improving daily work efficiency",
|
||||
"version": "1.65.0"
|
||||
"version": "1.69.1"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
name: Test
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
node-tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run tests
|
||||
run: npm test
|
||||
@@ -2,6 +2,67 @@
|
||||
|
||||
English | [中文](./CHANGELOG.zh.md)
|
||||
|
||||
## 1.69.1 - 2026-03-16
|
||||
|
||||
### Fixes
|
||||
- `baoyu-chrome-cdp`: tighten chrome auto-connect logic to reduce false positives
|
||||
|
||||
## 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
|
||||
- `baoyu-image-gen`: add Jimeng (即梦) and Seedream (豆包) image generation providers (by @lindaifeng)
|
||||
|
||||
### Fixes
|
||||
- `baoyu-image-gen`: tighten Jimeng provider behavior
|
||||
|
||||
### Refactor
|
||||
- `baoyu-image-gen`: export functions for testability and add module entry guard
|
||||
|
||||
### Documentation
|
||||
- `baoyu-image-gen`: add Jimeng and Seedream provider documentation to SKILL.md and READMEs
|
||||
|
||||
### Tests
|
||||
- Add test infrastructure with CI workflow and image-gen unit tests
|
||||
|
||||
## 1.65.1 - 2026-03-13
|
||||
|
||||
### Refactor
|
||||
- `baoyu-translate`: replace remark/unified with markdown-it for chunk parsing, add main.ts CLI entry point
|
||||
|
||||
## 1.65.0 - 2026-03-13
|
||||
|
||||
### Features
|
||||
|
||||
@@ -2,6 +2,67 @@
|
||||
|
||||
[English](./CHANGELOG.md) | 中文
|
||||
|
||||
## 1.69.1 - 2026-03-16
|
||||
|
||||
### 修复
|
||||
- `baoyu-chrome-cdp`:收紧 Chrome 自动连接逻辑,减少误连接
|
||||
|
||||
## 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
|
||||
|
||||
### 新功能
|
||||
- `baoyu-image-gen`:新增即梦(Jimeng)和豆包(Seedream)图像生成服务商 (by @lindaifeng)
|
||||
|
||||
### 修复
|
||||
- `baoyu-image-gen`:收紧即梦服务商行为
|
||||
|
||||
### 重构
|
||||
- `baoyu-image-gen`:导出函数以支持测试,新增模块入口守卫
|
||||
|
||||
### 文档
|
||||
- `baoyu-image-gen`:在 SKILL.md 和 README 中添加即梦和豆包服务商文档
|
||||
|
||||
### 测试
|
||||
- 新增测试基础设施,包含 CI 工作流和 image-gen 单元测试
|
||||
|
||||
## 1.65.1 - 2026-03-13
|
||||
|
||||
### 重构
|
||||
- `baoyu-translate`:将 chunk 解析从 remark/unified 替换为 markdown-it,新增 main.ts CLI 入口
|
||||
|
||||
## 1.65.0 - 2026-03-13
|
||||
|
||||
### 新功能
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# CLAUDE.md
|
||||
|
||||
Claude Code marketplace plugin providing AI-powered content generation skills. Version: **1.65.0**.
|
||||
Claude Code marketplace plugin providing AI-powered content generation skills. Version: **1.69.1**.
|
||||
|
||||
## Architecture
|
||||
|
||||
|
||||
@@ -665,7 +665,7 @@ AI-powered generation backends.
|
||||
|
||||
#### baoyu-image-gen
|
||||
|
||||
AI SDK-based image generation using OpenAI, Google, OpenRouter, DashScope (Aliyun Tongyi Wanxiang), and Replicate APIs. Supports text-to-image, reference images, aspect ratios, and quality presets.
|
||||
AI SDK-based image generation using OpenAI, Google, OpenRouter, DashScope (Aliyun Tongyi Wanxiang), Jimeng (即梦), Seedream (豆包), and Replicate APIs. Supports text-to-image, reference images, aspect ratios, and quality presets.
|
||||
|
||||
```bash
|
||||
# Basic generation (auto-detect provider)
|
||||
@@ -689,6 +689,12 @@ AI SDK-based image generation using OpenAI, Google, OpenRouter, DashScope (Aliyu
|
||||
# Replicate
|
||||
/baoyu-image-gen --prompt "A cat" --image cat.png --provider replicate
|
||||
|
||||
# Jimeng (即梦)
|
||||
/baoyu-image-gen --prompt "一只可爱的猫" --image cat.png --provider jimeng
|
||||
|
||||
# Seedream (豆包)
|
||||
/baoyu-image-gen --prompt "一只可爱的猫" --image cat.png --provider seedream
|
||||
|
||||
# With reference images (Google, OpenAI, OpenRouter, or Replicate)
|
||||
/baoyu-image-gen --prompt "Make it blue" --image out.png --ref source.png
|
||||
```
|
||||
@@ -699,7 +705,7 @@ AI SDK-based image generation using OpenAI, Google, OpenRouter, DashScope (Aliyu
|
||||
| `--prompt`, `-p` | Prompt text |
|
||||
| `--promptfiles` | Read prompt from files (concatenated) |
|
||||
| `--image` | Output image path (required) |
|
||||
| `--provider` | `google`, `openai`, `openrouter`, `dashscope` or `replicate` (default: auto-detect; prefers google) |
|
||||
| `--provider` | `google`, `openai`, `openrouter`, `dashscope`, `jimeng`, `seedream` or `replicate` (default: auto-detect; prefers google) |
|
||||
| `--model`, `-m` | Model ID |
|
||||
| `--ar` | Aspect ratio (e.g., `16:9`, `1:1`, `4:3`) |
|
||||
| `--size` | Size (e.g., `1024x1024`) |
|
||||
@@ -714,16 +720,24 @@ AI SDK-based image generation using OpenAI, Google, OpenRouter, DashScope (Aliyu
|
||||
| `GOOGLE_API_KEY` | Google API key | - |
|
||||
| `DASHSCOPE_API_KEY` | DashScope API key (Aliyun) | - |
|
||||
| `REPLICATE_API_TOKEN` | Replicate API token | - |
|
||||
| `JIMENG_ACCESS_KEY_ID` | Jimeng Volcengine access key | - |
|
||||
| `JIMENG_SECRET_ACCESS_KEY` | Jimeng Volcengine secret key | - |
|
||||
| `ARK_API_KEY` | Seedream Volcengine ARK API key | - |
|
||||
| `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` |
|
||||
| `OPENAI_BASE_URL` | Custom OpenAI endpoint | - |
|
||||
| `OPENROUTER_BASE_URL` | Custom OpenRouter endpoint | `https://openrouter.ai/api/v1` |
|
||||
| `GOOGLE_BASE_URL` | Custom Google endpoint | - |
|
||||
| `DASHSCOPE_BASE_URL` | Custom DashScope endpoint | - |
|
||||
| `REPLICATE_BASE_URL` | Custom Replicate endpoint | - |
|
||||
| `JIMENG_BASE_URL` | Custom Jimeng endpoint | `https://visual.volcengineapi.com` |
|
||||
| `JIMENG_REGION` | Jimeng region | `cn-north-1` |
|
||||
| `SEEDREAM_BASE_URL` | Custom Seedream endpoint | `https://ark.cn-beijing.volces.com/api/v3` |
|
||||
|
||||
**Provider Auto-Selection**:
|
||||
1. If `--provider` specified → use it
|
||||
@@ -982,13 +996,25 @@ 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
|
||||
REPLICATE_API_TOKEN=r8_xxx
|
||||
REPLICATE_IMAGE_MODEL=google/nano-banana-pro
|
||||
# REPLICATE_BASE_URL=https://api.replicate.com
|
||||
|
||||
# Jimeng (即梦)
|
||||
JIMENG_ACCESS_KEY_ID=xxx
|
||||
JIMENG_SECRET_ACCESS_KEY=xxx
|
||||
JIMENG_IMAGE_MODEL=jimeng_t2i_v40
|
||||
# JIMENG_BASE_URL=https://visual.volcengineapi.com
|
||||
# JIMENG_REGION=cn-north-1
|
||||
|
||||
# Seedream (豆包)
|
||||
ARK_API_KEY=xxx
|
||||
SEEDREAM_IMAGE_MODEL=doubao-seedream-5-0-260128
|
||||
# SEEDREAM_BASE_URL=https://ark.cn-beijing.volces.com/api/v3
|
||||
EOF
|
||||
```
|
||||
|
||||
|
||||
+30
-4
@@ -665,7 +665,7 @@ AI 驱动的生成后端。
|
||||
|
||||
#### baoyu-image-gen
|
||||
|
||||
基于 AI SDK 的图像生成,支持 OpenAI、Google、OpenRouter、DashScope(阿里通义万相)和 Replicate API。支持文生图、参考图、宽高比和质量预设。
|
||||
基于 AI SDK 的图像生成,支持 OpenAI、Google、OpenRouter、DashScope(阿里通义万相)、即梦(Jimeng)、豆包(Seedream)和 Replicate API。支持文生图、参考图、宽高比和质量预设。
|
||||
|
||||
```bash
|
||||
# 基础生成(自动检测服务商)
|
||||
@@ -689,6 +689,12 @@ AI 驱动的生成后端。
|
||||
# Replicate
|
||||
/baoyu-image-gen --prompt "一只猫" --image cat.png --provider replicate
|
||||
|
||||
# 即梦(Jimeng)
|
||||
/baoyu-image-gen --prompt "一只可爱的猫" --image cat.png --provider jimeng
|
||||
|
||||
# 豆包(Seedream)
|
||||
/baoyu-image-gen --prompt "一只可爱的猫" --image cat.png --provider seedream
|
||||
|
||||
# 带参考图(Google、OpenAI、OpenRouter 或 Replicate)
|
||||
/baoyu-image-gen --prompt "把它变成蓝色" --image out.png --ref source.png
|
||||
```
|
||||
@@ -699,7 +705,7 @@ AI 驱动的生成后端。
|
||||
| `--prompt`, `-p` | 提示词文本 |
|
||||
| `--promptfiles` | 从文件读取提示词(多文件拼接) |
|
||||
| `--image` | 输出图片路径(必需) |
|
||||
| `--provider` | `google`、`openai`、`openrouter`、`dashscope` 或 `replicate`(默认:自动检测,优先 google) |
|
||||
| `--provider` | `google`、`openai`、`openrouter`、`dashscope`、`jimeng`、`seedream` 或 `replicate`(默认:自动检测,优先 google) |
|
||||
| `--model`, `-m` | 模型 ID |
|
||||
| `--ar` | 宽高比(如 `16:9`、`1:1`、`4:3`) |
|
||||
| `--size` | 尺寸(如 `1024x1024`) |
|
||||
@@ -714,16 +720,24 @@ AI 驱动的生成后端。
|
||||
| `GOOGLE_API_KEY` | Google API 密钥 | - |
|
||||
| `DASHSCOPE_API_KEY` | DashScope API 密钥(阿里云) | - |
|
||||
| `REPLICATE_API_TOKEN` | Replicate API Token | - |
|
||||
| `JIMENG_ACCESS_KEY_ID` | 即梦火山引擎 Access Key | - |
|
||||
| `JIMENG_SECRET_ACCESS_KEY` | 即梦火山引擎 Secret Key | - |
|
||||
| `ARK_API_KEY` | 豆包火山引擎 ARK API 密钥 | - |
|
||||
| `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` |
|
||||
| `OPENAI_BASE_URL` | 自定义 OpenAI 端点 | - |
|
||||
| `OPENROUTER_BASE_URL` | 自定义 OpenRouter 端点 | `https://openrouter.ai/api/v1` |
|
||||
| `GOOGLE_BASE_URL` | 自定义 Google 端点 | - |
|
||||
| `DASHSCOPE_BASE_URL` | 自定义 DashScope 端点 | - |
|
||||
| `REPLICATE_BASE_URL` | 自定义 Replicate 端点 | - |
|
||||
| `JIMENG_BASE_URL` | 自定义即梦端点 | `https://visual.volcengineapi.com` |
|
||||
| `JIMENG_REGION` | 即梦区域 | `cn-north-1` |
|
||||
| `SEEDREAM_BASE_URL` | 自定义豆包端点 | `https://ark.cn-beijing.volces.com/api/v3` |
|
||||
|
||||
**服务商自动选择**:
|
||||
1. 如果指定了 `--provider` → 使用指定的
|
||||
@@ -982,13 +996,25 @@ 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
|
||||
REPLICATE_API_TOKEN=r8_xxx
|
||||
REPLICATE_IMAGE_MODEL=google/nano-banana-pro
|
||||
# REPLICATE_BASE_URL=https://api.replicate.com
|
||||
|
||||
# 即梦(Jimeng)
|
||||
JIMENG_ACCESS_KEY_ID=xxx
|
||||
JIMENG_SECRET_ACCESS_KEY=xxx
|
||||
JIMENG_IMAGE_MODEL=jimeng_t2i_v40
|
||||
# JIMENG_BASE_URL=https://visual.volcengineapi.com
|
||||
# JIMENG_REGION=cn-north-1
|
||||
|
||||
# 豆包(Seedream)
|
||||
ARK_API_KEY=xxx
|
||||
SEEDREAM_IMAGE_MODEL=doubao-seedream-5-0-260128
|
||||
# SEEDREAM_BASE_URL=https://ark.cn-beijing.volces.com/api/v3
|
||||
EOF
|
||||
```
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
# Testing Strategy
|
||||
|
||||
This repository has many scripts, but they do not share a single runtime or dependency graph. The lowest-risk testing strategy is to start from stable Node-based library code, then expand outward to CLI and skill-specific smoke tests.
|
||||
|
||||
## Current Baseline
|
||||
|
||||
- Root test runner: `node:test`
|
||||
- Entry point: `npm test`
|
||||
- Coverage command: `npm run test:coverage`
|
||||
- CI trigger: GitHub Actions on `push`, `pull_request`, and manual dispatch
|
||||
|
||||
This avoids introducing Jest/Vitest across a repo that already mixes plain Node scripts, Bun-based skill packages, vendored code, and browser automation.
|
||||
|
||||
## Rollout Plan
|
||||
|
||||
### Phase 1: Stable library coverage
|
||||
|
||||
Focus on pure functions under `scripts/lib/` first.
|
||||
|
||||
- `scripts/lib/release-files.mjs`
|
||||
- `scripts/lib/shared-skill-packages.mjs`
|
||||
|
||||
Goals:
|
||||
|
||||
- Validate file filtering and release packaging rules
|
||||
- Catch regressions in package vendoring and dependency rewriting
|
||||
- Keep tests deterministic and free of network, Bun, or browser requirements
|
||||
|
||||
### Phase 2: Root CLI integration tests
|
||||
|
||||
Add temp-directory integration tests for root CLIs that already support dry-run or local-only flows.
|
||||
|
||||
- `scripts/sync-shared-skill-packages.mjs`
|
||||
- `scripts/publish-skill.mjs --dry-run`
|
||||
- `scripts/sync-clawhub.mjs` argument handling and local skill discovery
|
||||
|
||||
Goals:
|
||||
|
||||
- Assert exit codes and stdout for common flows
|
||||
- Cover CLI argument parsing without hitting external services
|
||||
|
||||
### Phase 3: Skill script smoke tests
|
||||
|
||||
Add opt-in smoke tests for selected `skills/*/scripts/` packages, starting with those that:
|
||||
|
||||
- accept local input files
|
||||
- have deterministic output
|
||||
- do not require authenticated browser sessions
|
||||
|
||||
Examples:
|
||||
|
||||
- markdown transforms
|
||||
- file conversion helpers
|
||||
- local content analyzers
|
||||
|
||||
Keep browser automation, login flows, and live API publishing scripts outside the default CI path unless they are explicitly mocked.
|
||||
|
||||
### Phase 4: Coverage gates
|
||||
|
||||
After the stable Node path has enough breadth, add coverage thresholds in CI for the tested root modules.
|
||||
|
||||
Recommended order:
|
||||
|
||||
1. Start with reporting only
|
||||
2. Add line/function thresholds for `scripts/lib/**`
|
||||
3. Expand include patterns once skill-level smoke tests are reliable
|
||||
|
||||
## Conventions For New Tests
|
||||
|
||||
- Prefer temp directories over committed fixtures unless the fixture is reused heavily
|
||||
- Test exported functions before testing CLI wrappers
|
||||
- Avoid network, browser, and credential dependencies in default CI
|
||||
- Keep tests isolated so they can run with plain `node --test`
|
||||
Generated
+1958
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "baoyu-skills",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
"scripts": {
|
||||
"test": "node --import tsx --test",
|
||||
"test:coverage": "node --import tsx --experimental-test-coverage --test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tsx": "^4.20.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
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 {
|
||||
discoverRunningChromeDebugPort,
|
||||
findChromeExecutable,
|
||||
findExistingChromeDebugPort,
|
||||
getFreePort,
|
||||
openPageSession,
|
||||
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();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function shellPathForPlatform(): string | null {
|
||||
if (process.platform === "win32") return null;
|
||||
return "/bin/bash";
|
||||
}
|
||||
|
||||
async function startFakeChromiumProcess(port: number): Promise<ChildProcess | null> {
|
||||
const shell = shellPathForPlatform();
|
||||
if (!shell) return null;
|
||||
|
||||
const child = spawn(
|
||||
shell,
|
||||
[
|
||||
"-lc",
|
||||
`exec -a chromium-mock ${JSON.stringify(process.execPath)} -e 'setInterval(() => {}, 1000)' -- --remote-debugging-port=${port}`,
|
||||
],
|
||||
{ stdio: "ignore" },
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
return child;
|
||||
}
|
||||
|
||||
async function stopProcess(child: ChildProcess | null): Promise<void> {
|
||||
if (!child) return;
|
||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
||||
|
||||
child.kill("SIGTERM");
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
|
||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
||||
await new Promise((resolve) => child.once("exit", 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("discoverRunningChromeDebugPort reads DevToolsActivePort from the provided user-data dir", async (t) => {
|
||||
const root = await makeTempDir("baoyu-cdp-user-data-");
|
||||
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 discoverRunningChromeDebugPort({
|
||||
userDataDirs: [root],
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
assert.deepEqual(found, {
|
||||
port,
|
||||
wsUrl: `ws://127.0.0.1:${port}/devtools/browser/demo`,
|
||||
});
|
||||
});
|
||||
|
||||
test("discoverRunningChromeDebugPort ignores unrelated debugging processes", async (t) => {
|
||||
if (process.platform === "win32") {
|
||||
t.skip("Process discovery fallback is not used on Windows.");
|
||||
return;
|
||||
}
|
||||
|
||||
const root = await makeTempDir("baoyu-cdp-user-data-");
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
|
||||
const port = await getFreePort();
|
||||
const server = await startDebugServer(port);
|
||||
t.after(() => closeServer(server));
|
||||
|
||||
const fakeChromium = await startFakeChromiumProcess(port);
|
||||
t.after(async () => { await stopProcess(fakeChromium); });
|
||||
|
||||
const found = await discoverRunningChromeDebugPort({
|
||||
userDataDirs: [root],
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
assert.equal(found, null);
|
||||
});
|
||||
|
||||
test("openPageSession reports whether it created a new target", async () => {
|
||||
const calls: string[] = [];
|
||||
const cdpExisting = {
|
||||
send: async <T>(method: string): Promise<T> => {
|
||||
calls.push(method);
|
||||
if (method === "Target.getTargets") {
|
||||
return {
|
||||
targetInfos: [{ targetId: "existing-target", type: "page", url: "https://gemini.google.com/app" }],
|
||||
} as T;
|
||||
}
|
||||
if (method === "Target.attachToTarget") return { sessionId: "session-existing" } as T;
|
||||
throw new Error(`Unexpected method: ${method}`);
|
||||
},
|
||||
};
|
||||
|
||||
const existing = await openPageSession({
|
||||
cdp: cdpExisting as never,
|
||||
reusing: false,
|
||||
url: "https://gemini.google.com/app",
|
||||
matchTarget: (target) => target.url.includes("gemini.google.com"),
|
||||
activateTarget: false,
|
||||
});
|
||||
|
||||
assert.deepEqual(existing, {
|
||||
sessionId: "session-existing",
|
||||
targetId: "existing-target",
|
||||
createdTarget: false,
|
||||
});
|
||||
assert.deepEqual(calls, ["Target.getTargets", "Target.attachToTarget"]);
|
||||
|
||||
const createCalls: string[] = [];
|
||||
const cdpCreated = {
|
||||
send: async <T>(method: string): Promise<T> => {
|
||||
createCalls.push(method);
|
||||
if (method === "Target.getTargets") return { targetInfos: [] } as T;
|
||||
if (method === "Target.createTarget") return { targetId: "created-target" } as T;
|
||||
if (method === "Target.attachToTarget") return { sessionId: "session-created" } as T;
|
||||
throw new Error(`Unexpected method: ${method}`);
|
||||
},
|
||||
};
|
||||
|
||||
const created = await openPageSession({
|
||||
cdp: cdpCreated as never,
|
||||
reusing: false,
|
||||
url: "https://gemini.google.com/app",
|
||||
matchTarget: (target) => target.url.includes("gemini.google.com"),
|
||||
activateTarget: false,
|
||||
});
|
||||
|
||||
assert.deepEqual(created, {
|
||||
sessionId: "session-created",
|
||||
targetId: "created-target",
|
||||
createdTarget: true,
|
||||
});
|
||||
assert.deepEqual(createCalls, ["Target.getTargets", "Target.createTarget", "Target.attachToTarget"]);
|
||||
});
|
||||
|
||||
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,19 @@ type FindExistingChromeDebugPortOptions = {
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
export type ChromeChannel = "stable" | "beta" | "canary" | "dev";
|
||||
|
||||
export type DiscoveredChrome = {
|
||||
port: number;
|
||||
wsUrl: string;
|
||||
};
|
||||
|
||||
type DiscoverRunningChromeOptions = {
|
||||
channels?: ChromeChannel[];
|
||||
userDataDirs?: string[];
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
type LaunchChromeOptions = {
|
||||
chromePath: string;
|
||||
profileDir: string;
|
||||
@@ -73,6 +86,7 @@ type OpenPageSessionOptions = {
|
||||
export type PageSession = {
|
||||
sessionId: string;
|
||||
targetId: string;
|
||||
createdTarget: boolean;
|
||||
};
|
||||
|
||||
export function sleep(ms: number): Promise<void> {
|
||||
@@ -173,16 +187,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 +234,85 @@ 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 = (options.userDataDirs ?? getDefaultChromeUserDataDirs(channels))
|
||||
.map((dir) => path.resolve(dir));
|
||||
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=") &&
|
||||
userDataDirs.some((dir) => line.includes(dir))
|
||||
);
|
||||
|
||||
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,
|
||||
@@ -376,10 +485,12 @@ export function killChrome(chrome: ChildProcess): void {
|
||||
|
||||
export async function openPageSession(options: OpenPageSessionOptions): Promise<PageSession> {
|
||||
let targetId: string;
|
||||
let createdTarget = false;
|
||||
|
||||
if (options.reusing) {
|
||||
const created = await options.cdp.send<{ targetId: string }>("Target.createTarget", { url: options.url });
|
||||
targetId = created.targetId;
|
||||
createdTarget = true;
|
||||
} else {
|
||||
const targets = await options.cdp.send<{ targetInfos: ChromeTargetInfo[] }>("Target.getTargets");
|
||||
const existing = targets.targetInfos.find(options.matchTarget);
|
||||
@@ -388,6 +499,7 @@ export async function openPageSession(options: OpenPageSessionOptions): Promise<
|
||||
} else {
|
||||
const created = await options.cdp.send<{ targetId: string }>("Target.createTarget", { url: options.url });
|
||||
targetId = created.targetId;
|
||||
createdTarget = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -404,5 +516,5 @@ export async function openPageSession(options: OpenPageSessionOptions): Promise<
|
||||
if (options.enableDom) await options.cdp.send("DOM.enable", {}, { sessionId });
|
||||
if (options.enableNetwork) await options.cdp.send("Network.enable", {}, { sessionId });
|
||||
|
||||
return { sessionId, targetId };
|
||||
return { sessionId, targetId, createdTarget };
|
||||
}
|
||||
|
||||
@@ -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 = `
|
||||

|
||||
## “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(
|
||||
`\n\nText\n\n`,
|
||||
"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`b<\/code>/);
|
||||
});
|
||||
|
||||
test("emphasis with inline code containing backticks", () => {
|
||||
const html = render("*``a`b``*");
|
||||
assert.match(html, /<em[^>]*><code[^>]*>a`b<\/code><\/em>/);
|
||||
});
|
||||
|
||||
test("bold with inline code containing consecutive backticks", () => {
|
||||
const html = render("**```a``b```**");
|
||||
assert.match(html, /<code[^>]*>a``b<\/code>/);
|
||||
});
|
||||
|
||||
test("bold with inline code containing only backticks", () => {
|
||||
const html = render("**```` `` ````**");
|
||||
assert.match(html, /<code[^>]*>``<\/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,110 @@
|
||||
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 {
|
||||
listReleaseFiles,
|
||||
validateSelfContainedRelease,
|
||||
} from "./release-files.mjs";
|
||||
|
||||
async function makeTempDir(prefix: string): Promise<string> {
|
||||
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
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: string, value: unknown): Promise<void> {
|
||||
await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`);
|
||||
}
|
||||
|
||||
test("listReleaseFiles skips generated paths and returns sorted relative paths", async (t) => {
|
||||
const root = await makeTempDir("baoyu-release-files-");
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
|
||||
await writeFile(path.join(root, "b.txt"), "b");
|
||||
await writeFile(path.join(root, "a.txt"), "a");
|
||||
await writeFile(path.join(root, "nested", "keep.txt"), "keep");
|
||||
await writeFile(path.join(root, "node_modules", "skip.js"), "skip");
|
||||
await writeFile(path.join(root, ".git", "config"), "skip");
|
||||
await writeFile(path.join(root, "dist", "artifact.txt"), "skip");
|
||||
await writeFile(path.join(root, "out", "artifact.txt"), "skip");
|
||||
await writeFile(path.join(root, "build", "artifact.txt"), "skip");
|
||||
await writeFile(path.join(root, ".DS_Store"), "skip");
|
||||
await writeFile(path.join(root, "bun.lockb"), "skip");
|
||||
|
||||
const files = await listReleaseFiles(root);
|
||||
|
||||
assert.deepEqual(
|
||||
files.map((file) => file.relPath),
|
||||
["a.txt", "b.txt", "nested/keep.txt"],
|
||||
);
|
||||
});
|
||||
|
||||
test("validateSelfContainedRelease accepts file dependencies that stay within the release root", async (t) => {
|
||||
const root = await makeTempDir("baoyu-release-ok-");
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
|
||||
await writeJson(path.join(root, "shared", "package.json"), {
|
||||
name: "shared-package",
|
||||
version: "1.0.0",
|
||||
});
|
||||
await writeFile(path.join(root, "shared", "index.js"), "export const shared = true;\n");
|
||||
await writeJson(path.join(root, "skill", "package.json"), {
|
||||
name: "test-skill",
|
||||
version: "1.0.0",
|
||||
dependencies: {
|
||||
"shared-package": "file:../shared",
|
||||
},
|
||||
});
|
||||
|
||||
await assert.doesNotReject(() => validateSelfContainedRelease(root));
|
||||
});
|
||||
|
||||
test("validateSelfContainedRelease rejects missing local file dependencies", async (t) => {
|
||||
const root = await makeTempDir("baoyu-release-missing-");
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
|
||||
await writeJson(path.join(root, "skill", "package.json"), {
|
||||
name: "test-skill",
|
||||
version: "1.0.0",
|
||||
dependencies: {
|
||||
"shared-package": "file:../shared",
|
||||
},
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => validateSelfContainedRelease(root),
|
||||
/Missing local dependency for release/,
|
||||
);
|
||||
});
|
||||
|
||||
test("validateSelfContainedRelease rejects file dependencies outside the release root", async (t) => {
|
||||
const root = await makeTempDir("baoyu-release-root-");
|
||||
const outside = await makeTempDir("baoyu-release-outside-");
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
t.after(() => fs.rm(outside, { recursive: true, force: true }));
|
||||
|
||||
const skillDir = path.join(root, "skill");
|
||||
const externalSpec = path
|
||||
.relative(skillDir, outside)
|
||||
.split(path.sep)
|
||||
.join("/");
|
||||
|
||||
await writeJson(path.join(skillDir, "package.json"), {
|
||||
name: "test-skill",
|
||||
version: "1.0.0",
|
||||
dependencies: {
|
||||
"outside-package": `file:${externalSpec}`,
|
||||
},
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => validateSelfContainedRelease(root),
|
||||
/Release target is not self-contained/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
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 { syncSharedSkillPackages } from "./shared-skill-packages.mjs";
|
||||
|
||||
async function makeTempDir(prefix: string): Promise<string> {
|
||||
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
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: string, value: unknown): Promise<void> {
|
||||
await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`);
|
||||
}
|
||||
|
||||
test("syncSharedSkillPackages vendors workspace packages into skill scripts", async (t) => {
|
||||
const root = await makeTempDir("baoyu-sync-shared-");
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
|
||||
await writeJson(path.join(root, "packages", "baoyu-md", "package.json"), {
|
||||
name: "baoyu-md",
|
||||
version: "1.0.0",
|
||||
});
|
||||
await writeFile(
|
||||
path.join(root, "packages", "baoyu-md", "src", "index.ts"),
|
||||
"export const markdown = true;\n",
|
||||
);
|
||||
|
||||
const consumerDir = path.join(root, "skills", "demo-skill", "scripts");
|
||||
await writeJson(path.join(consumerDir, "package.json"), {
|
||||
name: "demo-skill-scripts",
|
||||
version: "1.0.0",
|
||||
dependencies: {
|
||||
"baoyu-md": "^1.0.0",
|
||||
kleur: "^4.1.5",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await syncSharedSkillPackages(root, { install: false });
|
||||
|
||||
assert.deepEqual(result.packageDirs, [consumerDir]);
|
||||
assert.deepEqual(result.managedPaths, [
|
||||
"skills/demo-skill/scripts/bun.lock",
|
||||
"skills/demo-skill/scripts/package.json",
|
||||
"skills/demo-skill/scripts/vendor",
|
||||
]);
|
||||
|
||||
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(
|
||||
path.join(consumerDir, "vendor", "baoyu-md", "src", "index.ts"),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(vendoredFile, /markdown = true/);
|
||||
});
|
||||
@@ -133,7 +133,7 @@ Full procedures: [references/workflow.md](references/workflow.md#step-5-generate
|
||||
|
||||
### Step 6: Finalize
|
||||
|
||||
Insert `` after paragraphs.
|
||||
Insert `` 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
|
||||

|
||||
```
|
||||
Insert after corresponding paragraph, using path relative to article file:
|
||||
|
||||
| `default_output_dir` | Insert Path |
|
||||
|----------------------|-------------|
|
||||
| `imgs-subdir` | `` |
|
||||
| `same-dir` | `` |
|
||||
| `illustrations-subdir` | `` |
|
||||
| `independent` | `` (relative to cwd) |
|
||||
|
||||
Alt text: concise description in article's language.
|
||||
|
||||
|
||||
@@ -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,87 @@ 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 targetId: string | null = null;
|
||||
let createdTarget = false;
|
||||
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 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;
|
||||
createdTarget = page.createdTarget;
|
||||
|
||||
if (verbose) logger.debug(createdTarget ? 'No Gemini tab found, creating new tab...' : 'Found existing Gemini tab, attaching...');
|
||||
|
||||
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 (createdTarget && 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 +260,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);
|
||||
|
||||
|
||||
+307
@@ -0,0 +1,307 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
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 {
|
||||
discoverRunningChromeDebugPort,
|
||||
findChromeExecutable,
|
||||
findExistingChromeDebugPort,
|
||||
getFreePort,
|
||||
openPageSession,
|
||||
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();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function shellPathForPlatform(): string | null {
|
||||
if (process.platform === "win32") return null;
|
||||
return "/bin/bash";
|
||||
}
|
||||
|
||||
async function startFakeChromiumProcess(port: number): Promise<ChildProcess | null> {
|
||||
const shell = shellPathForPlatform();
|
||||
if (!shell) return null;
|
||||
|
||||
const child = spawn(
|
||||
shell,
|
||||
[
|
||||
"-lc",
|
||||
`exec -a chromium-mock ${JSON.stringify(process.execPath)} -e 'setInterval(() => {}, 1000)' -- --remote-debugging-port=${port}`,
|
||||
],
|
||||
{ stdio: "ignore" },
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
return child;
|
||||
}
|
||||
|
||||
async function stopProcess(child: ChildProcess | null): Promise<void> {
|
||||
if (!child) return;
|
||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
||||
|
||||
child.kill("SIGTERM");
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
|
||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
||||
await new Promise((resolve) => child.once("exit", 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("discoverRunningChromeDebugPort reads DevToolsActivePort from the provided user-data dir", async (t) => {
|
||||
const root = await makeTempDir("baoyu-cdp-user-data-");
|
||||
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 discoverRunningChromeDebugPort({
|
||||
userDataDirs: [root],
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
assert.deepEqual(found, {
|
||||
port,
|
||||
wsUrl: `ws://127.0.0.1:${port}/devtools/browser/demo`,
|
||||
});
|
||||
});
|
||||
|
||||
test("discoverRunningChromeDebugPort ignores unrelated debugging processes", async (t) => {
|
||||
if (process.platform === "win32") {
|
||||
t.skip("Process discovery fallback is not used on Windows.");
|
||||
return;
|
||||
}
|
||||
|
||||
const root = await makeTempDir("baoyu-cdp-user-data-");
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
|
||||
const port = await getFreePort();
|
||||
const server = await startDebugServer(port);
|
||||
t.after(() => closeServer(server));
|
||||
|
||||
const fakeChromium = await startFakeChromiumProcess(port);
|
||||
t.after(async () => { await stopProcess(fakeChromium); });
|
||||
|
||||
const found = await discoverRunningChromeDebugPort({
|
||||
userDataDirs: [root],
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
assert.equal(found, null);
|
||||
});
|
||||
|
||||
test("openPageSession reports whether it created a new target", async () => {
|
||||
const calls: string[] = [];
|
||||
const cdpExisting = {
|
||||
send: async <T>(method: string): Promise<T> => {
|
||||
calls.push(method);
|
||||
if (method === "Target.getTargets") {
|
||||
return {
|
||||
targetInfos: [{ targetId: "existing-target", type: "page", url: "https://gemini.google.com/app" }],
|
||||
} as T;
|
||||
}
|
||||
if (method === "Target.attachToTarget") return { sessionId: "session-existing" } as T;
|
||||
throw new Error(`Unexpected method: ${method}`);
|
||||
},
|
||||
};
|
||||
|
||||
const existing = await openPageSession({
|
||||
cdp: cdpExisting as never,
|
||||
reusing: false,
|
||||
url: "https://gemini.google.com/app",
|
||||
matchTarget: (target) => target.url.includes("gemini.google.com"),
|
||||
activateTarget: false,
|
||||
});
|
||||
|
||||
assert.deepEqual(existing, {
|
||||
sessionId: "session-existing",
|
||||
targetId: "existing-target",
|
||||
createdTarget: false,
|
||||
});
|
||||
assert.deepEqual(calls, ["Target.getTargets", "Target.attachToTarget"]);
|
||||
|
||||
const createCalls: string[] = [];
|
||||
const cdpCreated = {
|
||||
send: async <T>(method: string): Promise<T> => {
|
||||
createCalls.push(method);
|
||||
if (method === "Target.getTargets") return { targetInfos: [] } as T;
|
||||
if (method === "Target.createTarget") return { targetId: "created-target" } as T;
|
||||
if (method === "Target.attachToTarget") return { sessionId: "session-created" } as T;
|
||||
throw new Error(`Unexpected method: ${method}`);
|
||||
},
|
||||
};
|
||||
|
||||
const created = await openPageSession({
|
||||
cdp: cdpCreated as never,
|
||||
reusing: false,
|
||||
url: "https://gemini.google.com/app",
|
||||
matchTarget: (target) => target.url.includes("gemini.google.com"),
|
||||
activateTarget: false,
|
||||
});
|
||||
|
||||
assert.deepEqual(created, {
|
||||
sessionId: "session-created",
|
||||
targetId: "created-target",
|
||||
createdTarget: true,
|
||||
});
|
||||
assert.deepEqual(createCalls, ["Target.getTargets", "Target.createTarget", "Target.attachToTarget"]);
|
||||
});
|
||||
|
||||
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`);
|
||||
});
|
||||
+120
-8
@@ -43,6 +43,19 @@ type FindExistingChromeDebugPortOptions = {
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
export type ChromeChannel = "stable" | "beta" | "canary" | "dev";
|
||||
|
||||
export type DiscoveredChrome = {
|
||||
port: number;
|
||||
wsUrl: string;
|
||||
};
|
||||
|
||||
type DiscoverRunningChromeOptions = {
|
||||
channels?: ChromeChannel[];
|
||||
userDataDirs?: string[];
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
type LaunchChromeOptions = {
|
||||
chromePath: string;
|
||||
profileDir: string;
|
||||
@@ -73,6 +86,7 @@ type OpenPageSessionOptions = {
|
||||
export type PageSession = {
|
||||
sessionId: string;
|
||||
targetId: string;
|
||||
createdTarget: boolean;
|
||||
};
|
||||
|
||||
export function sleep(ms: number): Promise<void> {
|
||||
@@ -173,16 +187,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 +234,85 @@ 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 = (options.userDataDirs ?? getDefaultChromeUserDataDirs(channels))
|
||||
.map((dir) => path.resolve(dir));
|
||||
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=") &&
|
||||
userDataDirs.some((dir) => line.includes(dir))
|
||||
);
|
||||
|
||||
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,
|
||||
@@ -376,10 +485,12 @@ export function killChrome(chrome: ChildProcess): void {
|
||||
|
||||
export async function openPageSession(options: OpenPageSessionOptions): Promise<PageSession> {
|
||||
let targetId: string;
|
||||
let createdTarget = false;
|
||||
|
||||
if (options.reusing) {
|
||||
const created = await options.cdp.send<{ targetId: string }>("Target.createTarget", { url: options.url });
|
||||
targetId = created.targetId;
|
||||
createdTarget = true;
|
||||
} else {
|
||||
const targets = await options.cdp.send<{ targetInfos: ChromeTargetInfo[] }>("Target.getTargets");
|
||||
const existing = targets.targetInfos.find(options.matchTarget);
|
||||
@@ -388,6 +499,7 @@ export async function openPageSession(options: OpenPageSessionOptions): Promise<
|
||||
} else {
|
||||
const created = await options.cdp.send<{ targetId: string }>("Target.createTarget", { url: options.url });
|
||||
targetId = created.targetId;
|
||||
createdTarget = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -404,5 +516,5 @@ export async function openPageSession(options: OpenPageSessionOptions): Promise<
|
||||
if (options.enableDom) await options.cdp.send("DOM.enable", {}, { sessionId });
|
||||
if (options.enableNetwork) await options.cdp.send("Network.enable", {}, { sessionId });
|
||||
|
||||
return { sessionId, targetId };
|
||||
return { sessionId, targetId, createdTarget };
|
||||
}
|
||||
|
||||
+307
@@ -0,0 +1,307 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
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 {
|
||||
discoverRunningChromeDebugPort,
|
||||
findChromeExecutable,
|
||||
findExistingChromeDebugPort,
|
||||
getFreePort,
|
||||
openPageSession,
|
||||
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();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function shellPathForPlatform(): string | null {
|
||||
if (process.platform === "win32") return null;
|
||||
return "/bin/bash";
|
||||
}
|
||||
|
||||
async function startFakeChromiumProcess(port: number): Promise<ChildProcess | null> {
|
||||
const shell = shellPathForPlatform();
|
||||
if (!shell) return null;
|
||||
|
||||
const child = spawn(
|
||||
shell,
|
||||
[
|
||||
"-lc",
|
||||
`exec -a chromium-mock ${JSON.stringify(process.execPath)} -e 'setInterval(() => {}, 1000)' -- --remote-debugging-port=${port}`,
|
||||
],
|
||||
{ stdio: "ignore" },
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
return child;
|
||||
}
|
||||
|
||||
async function stopProcess(child: ChildProcess | null): Promise<void> {
|
||||
if (!child) return;
|
||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
||||
|
||||
child.kill("SIGTERM");
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
|
||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
||||
await new Promise((resolve) => child.once("exit", 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("discoverRunningChromeDebugPort reads DevToolsActivePort from the provided user-data dir", async (t) => {
|
||||
const root = await makeTempDir("baoyu-cdp-user-data-");
|
||||
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 discoverRunningChromeDebugPort({
|
||||
userDataDirs: [root],
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
assert.deepEqual(found, {
|
||||
port,
|
||||
wsUrl: `ws://127.0.0.1:${port}/devtools/browser/demo`,
|
||||
});
|
||||
});
|
||||
|
||||
test("discoverRunningChromeDebugPort ignores unrelated debugging processes", async (t) => {
|
||||
if (process.platform === "win32") {
|
||||
t.skip("Process discovery fallback is not used on Windows.");
|
||||
return;
|
||||
}
|
||||
|
||||
const root = await makeTempDir("baoyu-cdp-user-data-");
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
|
||||
const port = await getFreePort();
|
||||
const server = await startDebugServer(port);
|
||||
t.after(() => closeServer(server));
|
||||
|
||||
const fakeChromium = await startFakeChromiumProcess(port);
|
||||
t.after(async () => { await stopProcess(fakeChromium); });
|
||||
|
||||
const found = await discoverRunningChromeDebugPort({
|
||||
userDataDirs: [root],
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
assert.equal(found, null);
|
||||
});
|
||||
|
||||
test("openPageSession reports whether it created a new target", async () => {
|
||||
const calls: string[] = [];
|
||||
const cdpExisting = {
|
||||
send: async <T>(method: string): Promise<T> => {
|
||||
calls.push(method);
|
||||
if (method === "Target.getTargets") {
|
||||
return {
|
||||
targetInfos: [{ targetId: "existing-target", type: "page", url: "https://gemini.google.com/app" }],
|
||||
} as T;
|
||||
}
|
||||
if (method === "Target.attachToTarget") return { sessionId: "session-existing" } as T;
|
||||
throw new Error(`Unexpected method: ${method}`);
|
||||
},
|
||||
};
|
||||
|
||||
const existing = await openPageSession({
|
||||
cdp: cdpExisting as never,
|
||||
reusing: false,
|
||||
url: "https://gemini.google.com/app",
|
||||
matchTarget: (target) => target.url.includes("gemini.google.com"),
|
||||
activateTarget: false,
|
||||
});
|
||||
|
||||
assert.deepEqual(existing, {
|
||||
sessionId: "session-existing",
|
||||
targetId: "existing-target",
|
||||
createdTarget: false,
|
||||
});
|
||||
assert.deepEqual(calls, ["Target.getTargets", "Target.attachToTarget"]);
|
||||
|
||||
const createCalls: string[] = [];
|
||||
const cdpCreated = {
|
||||
send: async <T>(method: string): Promise<T> => {
|
||||
createCalls.push(method);
|
||||
if (method === "Target.getTargets") return { targetInfos: [] } as T;
|
||||
if (method === "Target.createTarget") return { targetId: "created-target" } as T;
|
||||
if (method === "Target.attachToTarget") return { sessionId: "session-created" } as T;
|
||||
throw new Error(`Unexpected method: ${method}`);
|
||||
},
|
||||
};
|
||||
|
||||
const created = await openPageSession({
|
||||
cdp: cdpCreated as never,
|
||||
reusing: false,
|
||||
url: "https://gemini.google.com/app",
|
||||
matchTarget: (target) => target.url.includes("gemini.google.com"),
|
||||
activateTarget: false,
|
||||
});
|
||||
|
||||
assert.deepEqual(created, {
|
||||
sessionId: "session-created",
|
||||
targetId: "created-target",
|
||||
createdTarget: true,
|
||||
});
|
||||
assert.deepEqual(createCalls, ["Target.getTargets", "Target.createTarget", "Target.attachToTarget"]);
|
||||
});
|
||||
|
||||
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`);
|
||||
});
|
||||
+120
-8
@@ -43,6 +43,19 @@ type FindExistingChromeDebugPortOptions = {
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
export type ChromeChannel = "stable" | "beta" | "canary" | "dev";
|
||||
|
||||
export type DiscoveredChrome = {
|
||||
port: number;
|
||||
wsUrl: string;
|
||||
};
|
||||
|
||||
type DiscoverRunningChromeOptions = {
|
||||
channels?: ChromeChannel[];
|
||||
userDataDirs?: string[];
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
type LaunchChromeOptions = {
|
||||
chromePath: string;
|
||||
profileDir: string;
|
||||
@@ -73,6 +86,7 @@ type OpenPageSessionOptions = {
|
||||
export type PageSession = {
|
||||
sessionId: string;
|
||||
targetId: string;
|
||||
createdTarget: boolean;
|
||||
};
|
||||
|
||||
export function sleep(ms: number): Promise<void> {
|
||||
@@ -173,16 +187,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 +234,85 @@ 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 = (options.userDataDirs ?? getDefaultChromeUserDataDirs(channels))
|
||||
.map((dir) => path.resolve(dir));
|
||||
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=") &&
|
||||
userDataDirs.some((dir) => line.includes(dir))
|
||||
);
|
||||
|
||||
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,
|
||||
@@ -376,10 +485,12 @@ export function killChrome(chrome: ChildProcess): void {
|
||||
|
||||
export async function openPageSession(options: OpenPageSessionOptions): Promise<PageSession> {
|
||||
let targetId: string;
|
||||
let createdTarget = false;
|
||||
|
||||
if (options.reusing) {
|
||||
const created = await options.cdp.send<{ targetId: string }>("Target.createTarget", { url: options.url });
|
||||
targetId = created.targetId;
|
||||
createdTarget = true;
|
||||
} else {
|
||||
const targets = await options.cdp.send<{ targetInfos: ChromeTargetInfo[] }>("Target.getTargets");
|
||||
const existing = targets.targetInfos.find(options.matchTarget);
|
||||
@@ -388,6 +499,7 @@ export async function openPageSession(options: OpenPageSessionOptions): Promise<
|
||||
} else {
|
||||
const created = await options.cdp.send<{ targetId: string }>("Target.createTarget", { url: options.url });
|
||||
targetId = created.targetId;
|
||||
createdTarget = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -404,5 +516,5 @@ export async function openPageSession(options: OpenPageSessionOptions): Promise<
|
||||
if (options.enableDom) await options.cdp.send("DOM.enable", {}, { sessionId });
|
||||
if (options.enableNetwork) await options.cdp.send("Network.enable", {}, { sessionId });
|
||||
|
||||
return { sessionId, targetId };
|
||||
return { sessionId, targetId, createdTarget };
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: baoyu-image-gen
|
||||
description: AI image generation with OpenAI, Google, OpenRouter, DashScope and Replicate APIs. Supports text-to-image, reference images, aspect ratios, and batch generation from saved prompt files. Sequential by default; use batch parallel generation when the user already has multiple prompts or wants stable multi-image throughput. Use when user asks to generate, create, or draw images.
|
||||
description: AI image generation with OpenAI, Google, OpenRouter, DashScope, Jimeng, Seedream and Replicate APIs. Supports text-to-image, reference images, aspect ratios, and batch generation from saved prompt files. Sequential by default; use batch parallel generation when the user already has multiple prompts or wants stable multi-image throughput. Use when user asks to generate, create, or draw images.
|
||||
version: 1.56.2
|
||||
metadata:
|
||||
openclaw:
|
||||
@@ -13,7 +13,7 @@ metadata:
|
||||
|
||||
# Image Generation (AI SDK)
|
||||
|
||||
Official API-based image generation. Supports OpenAI, Google, OpenRouter, DashScope (阿里通义万象) and Replicate providers.
|
||||
Official API-based image generation. Supports OpenAI, Google, OpenRouter, DashScope (阿里通义万象), Jimeng (即梦), Seedream (豆包) and Replicate providers.
|
||||
|
||||
## Script Directory
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -141,13 +147,13 @@ Paths in `promptFiles`, `image`, and `ref` are resolved relative to the batch fi
|
||||
| `--image <path>` | Output image path (required in single-image mode) |
|
||||
| `--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\|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`) |
|
||||
| `--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`; 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`) |
|
||||
| `--imageSize 1K\|2K\|4K` | Image size for Google/OpenRouter (default: from quality) |
|
||||
| `--ref <files...>` | Reference images. Supported by Google multimodal, OpenAI GPT Image edits, OpenRouter multimodal models, and Replicate |
|
||||
| `--ref <files...>` | Reference images. Supported by Google multimodal, OpenAI GPT Image edits, OpenRouter multimodal models, and Replicate. Not supported by Jimeng or Seedream |
|
||||
| `--n <count>` | Number of images |
|
||||
| `--json` | JSON output |
|
||||
|
||||
@@ -160,11 +166,16 @@ Paths in `promptFiles`, `image`, and `ref` are resolved relative to the batch fi
|
||||
| `GOOGLE_API_KEY` | Google API key |
|
||||
| `DASHSCOPE_API_KEY` | DashScope API key (阿里云) |
|
||||
| `REPLICATE_API_TOKEN` | Replicate API token |
|
||||
| `JIMENG_ACCESS_KEY_ID` | Jimeng (即梦) Volcengine access key |
|
||||
| `JIMENG_SECRET_ACCESS_KEY` | Jimeng (即梦) Volcengine secret key |
|
||||
| `ARK_API_KEY` | Seedream (豆包) Volcengine ARK API key |
|
||||
| `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) |
|
||||
| `OPENAI_BASE_URL` | Custom OpenAI endpoint |
|
||||
| `OPENROUTER_BASE_URL` | Custom OpenRouter endpoint (default: `https://openrouter.ai/api/v1`) |
|
||||
| `OPENROUTER_HTTP_REFERER` | Optional app/site URL for OpenRouter attribution |
|
||||
@@ -172,6 +183,9 @@ Paths in `promptFiles`, `image`, and `ref` are resolved relative to the batch fi
|
||||
| `GOOGLE_BASE_URL` | Custom Google endpoint |
|
||||
| `DASHSCOPE_BASE_URL` | Custom DashScope endpoint |
|
||||
| `REPLICATE_BASE_URL` | Custom Replicate endpoint |
|
||||
| `JIMENG_BASE_URL` | Custom Jimeng endpoint (default: `https://visual.volcengineapi.com`) |
|
||||
| `JIMENG_REGION` | Jimeng region (default: `cn-north-1`) |
|
||||
| `SEEDREAM_BASE_URL` | Custom Seedream endpoint (default: `https://ark.cn-beijing.volces.com/api/v3`) |
|
||||
| `BAOYU_IMAGE_GEN_MAX_WORKERS` | Override batch worker cap |
|
||||
| `BAOYU_IMAGE_GEN_<PROVIDER>_CONCURRENCY` | Override provider concurrency, e.g. `BAOYU_IMAGE_GEN_REPLICATE_CONCURRENCY` |
|
||||
| `BAOYU_IMAGE_GEN_<PROVIDER>_START_INTERVAL_MS` | Override provider start gap, e.g. `BAOYU_IMAGE_GEN_REPLICATE_START_INTERVAL_MS` |
|
||||
@@ -193,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.:
|
||||
@@ -227,7 +287,7 @@ ${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider r
|
||||
|
||||
## Provider Selection
|
||||
|
||||
1. `--ref` provided + no `--provider` → auto-select Google first, then OpenAI, then OpenRouter, then Replicate
|
||||
1. `--ref` provided + no `--provider` → auto-select Google first, then OpenAI, then OpenRouter, then Replicate (Jimeng and Seedream do not support reference images)
|
||||
2. `--provider` specified → use it (if `--ref`, must be `google`, `openai`, `openrouter`, or `replicate`)
|
||||
3. Only one API key available → use that provider
|
||||
4. Multiple available → default to Google
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test, { type TestContext } from "node:test";
|
||||
|
||||
import type { CliArgs, ExtendConfig } from "./types.ts";
|
||||
import {
|
||||
createTaskArgs,
|
||||
detectProvider,
|
||||
getConfiguredMaxWorkers,
|
||||
getConfiguredProviderRateLimits,
|
||||
getWorkerCount,
|
||||
isRetryableGenerationError,
|
||||
loadBatchTasks,
|
||||
mergeConfig,
|
||||
normalizeOutputImagePath,
|
||||
parseArgs,
|
||||
parseSimpleYaml,
|
||||
} from "./main.ts";
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
test("parseArgs parses the main image-gen CLI flags", () => {
|
||||
const args = parseArgs([
|
||||
"--promptfiles",
|
||||
"prompts/system.md",
|
||||
"prompts/content.md",
|
||||
"--image",
|
||||
"out/hero",
|
||||
"--provider",
|
||||
"openai",
|
||||
"--quality",
|
||||
"2k",
|
||||
"--imageSize",
|
||||
"4k",
|
||||
"--ref",
|
||||
"ref/one.png",
|
||||
"ref/two.jpg",
|
||||
"--n",
|
||||
"3",
|
||||
"--jobs",
|
||||
"5",
|
||||
"--json",
|
||||
]);
|
||||
|
||||
assert.deepEqual(args.promptFiles, ["prompts/system.md", "prompts/content.md"]);
|
||||
assert.equal(args.imagePath, "out/hero");
|
||||
assert.equal(args.provider, "openai");
|
||||
assert.equal(args.quality, "2k");
|
||||
assert.equal(args.imageSize, "4K");
|
||||
assert.deepEqual(args.referenceImages, ["ref/one.png", "ref/two.jpg"]);
|
||||
assert.equal(args.n, 3);
|
||||
assert.equal(args.jobs, 5);
|
||||
assert.equal(args.json, true);
|
||||
});
|
||||
|
||||
test("parseArgs falls back to positional prompt and rejects invalid provider", () => {
|
||||
const positional = parseArgs(["draw", "a", "cat"]);
|
||||
assert.equal(positional.prompt, "draw a cat");
|
||||
|
||||
assert.throws(
|
||||
() => parseArgs(["--provider", "stability"]),
|
||||
/Invalid provider/,
|
||||
);
|
||||
});
|
||||
|
||||
test("parseSimpleYaml parses nested defaults and provider limits", () => {
|
||||
const yaml = `
|
||||
version: 2
|
||||
default_provider: openrouter
|
||||
default_quality: normal
|
||||
default_aspect_ratio: '16:9'
|
||||
default_image_size: 2K
|
||||
default_model:
|
||||
google: gemini-3-pro-image-preview
|
||||
openai: gpt-image-1.5
|
||||
batch:
|
||||
max_workers: 8
|
||||
provider_limits:
|
||||
google:
|
||||
concurrency: 2
|
||||
start_interval_ms: 900
|
||||
openai:
|
||||
concurrency: 4
|
||||
`;
|
||||
|
||||
const config = parseSimpleYaml(yaml);
|
||||
|
||||
assert.equal(config.version, 2);
|
||||
assert.equal(config.default_provider, "openrouter");
|
||||
assert.equal(config.default_quality, "normal");
|
||||
assert.equal(config.default_aspect_ratio, "16:9");
|
||||
assert.equal(config.default_image_size, "2K");
|
||||
assert.equal(config.default_model?.google, "gemini-3-pro-image-preview");
|
||||
assert.equal(config.default_model?.openai, "gpt-image-1.5");
|
||||
assert.equal(config.batch?.max_workers, 8);
|
||||
assert.deepEqual(config.batch?.provider_limits?.google, {
|
||||
concurrency: 2,
|
||||
start_interval_ms: 900,
|
||||
});
|
||||
assert.deepEqual(config.batch?.provider_limits?.openai, {
|
||||
concurrency: 4,
|
||||
});
|
||||
});
|
||||
|
||||
test("mergeConfig only fills values missing from CLI args", () => {
|
||||
const merged = mergeConfig(
|
||||
makeArgs({
|
||||
provider: "openai",
|
||||
quality: null,
|
||||
aspectRatio: null,
|
||||
imageSize: "4K",
|
||||
}),
|
||||
{
|
||||
default_provider: "google",
|
||||
default_quality: "2k",
|
||||
default_aspect_ratio: "3:2",
|
||||
default_image_size: "2K",
|
||||
} satisfies Partial<ExtendConfig>,
|
||||
);
|
||||
|
||||
assert.equal(merged.provider, "openai");
|
||||
assert.equal(merged.quality, "2k");
|
||||
assert.equal(merged.aspectRatio, "3:2");
|
||||
assert.equal(merged.imageSize, "4K");
|
||||
});
|
||||
|
||||
test("detectProvider rejects non-ref-capable providers and prefers Google first when multiple keys exist", (t) => {
|
||||
assert.throws(
|
||||
() =>
|
||||
detectProvider(
|
||||
makeArgs({
|
||||
provider: "dashscope",
|
||||
referenceImages: ["ref.png"],
|
||||
}),
|
||||
),
|
||||
/Reference images require a ref-capable provider/,
|
||||
);
|
||||
|
||||
useEnv(t, {
|
||||
GOOGLE_API_KEY: "google-key",
|
||||
OPENAI_API_KEY: "openai-key",
|
||||
OPENROUTER_API_KEY: null,
|
||||
DASHSCOPE_API_KEY: null,
|
||||
REPLICATE_API_TOKEN: null,
|
||||
JIMENG_ACCESS_KEY_ID: null,
|
||||
JIMENG_SECRET_ACCESS_KEY: null,
|
||||
ARK_API_KEY: null,
|
||||
});
|
||||
assert.equal(detectProvider(makeArgs()), "google");
|
||||
});
|
||||
|
||||
test("detectProvider selects an available ref-capable provider for reference-image tasks", (t) => {
|
||||
useEnv(t, {
|
||||
GOOGLE_API_KEY: null,
|
||||
OPENAI_API_KEY: "openai-key",
|
||||
OPENROUTER_API_KEY: null,
|
||||
DASHSCOPE_API_KEY: null,
|
||||
REPLICATE_API_TOKEN: null,
|
||||
JIMENG_ACCESS_KEY_ID: null,
|
||||
JIMENG_SECRET_ACCESS_KEY: null,
|
||||
ARK_API_KEY: null,
|
||||
});
|
||||
assert.equal(
|
||||
detectProvider(makeArgs({ referenceImages: ["ref.png"] })),
|
||||
"openai",
|
||||
);
|
||||
});
|
||||
|
||||
test("batch worker and provider-rate-limit configuration prefer env over EXTEND config", (t) => {
|
||||
useEnv(t, {
|
||||
BAOYU_IMAGE_GEN_MAX_WORKERS: "12",
|
||||
BAOYU_IMAGE_GEN_GOOGLE_CONCURRENCY: "5",
|
||||
BAOYU_IMAGE_GEN_GOOGLE_START_INTERVAL_MS: "450",
|
||||
});
|
||||
|
||||
const extendConfig: Partial<ExtendConfig> = {
|
||||
batch: {
|
||||
max_workers: 7,
|
||||
provider_limits: {
|
||||
google: {
|
||||
concurrency: 2,
|
||||
start_interval_ms: 900,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
assert.equal(getConfiguredMaxWorkers(extendConfig), 12);
|
||||
assert.deepEqual(getConfiguredProviderRateLimits(extendConfig).google, {
|
||||
concurrency: 5,
|
||||
startIntervalMs: 450,
|
||||
});
|
||||
});
|
||||
|
||||
test("loadBatchTasks and createTaskArgs resolve batch-relative paths", async (t) => {
|
||||
const root = await makeTempDir("baoyu-image-gen-batch-");
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
|
||||
const batchFile = path.join(root, "jobs", "batch.json");
|
||||
await fs.mkdir(path.dirname(batchFile), { recursive: true });
|
||||
await fs.writeFile(
|
||||
batchFile,
|
||||
JSON.stringify({
|
||||
jobs: 2,
|
||||
tasks: [
|
||||
{
|
||||
id: "hero",
|
||||
promptFiles: ["prompts/hero.md"],
|
||||
image: "out/hero",
|
||||
ref: ["refs/hero.png"],
|
||||
ar: "16:9",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const loaded = await loadBatchTasks(batchFile);
|
||||
assert.equal(loaded.jobs, 2);
|
||||
assert.equal(loaded.batchDir, path.dirname(batchFile));
|
||||
assert.equal(loaded.tasks[0]?.id, "hero");
|
||||
|
||||
const taskArgs = createTaskArgs(
|
||||
makeArgs({
|
||||
provider: "replicate",
|
||||
quality: "2k",
|
||||
json: true,
|
||||
}),
|
||||
loaded.tasks[0]!,
|
||||
loaded.batchDir,
|
||||
);
|
||||
|
||||
assert.deepEqual(taskArgs.promptFiles, [
|
||||
path.join(loaded.batchDir, "prompts/hero.md"),
|
||||
]);
|
||||
assert.equal(taskArgs.imagePath, path.join(loaded.batchDir, "out/hero"));
|
||||
assert.deepEqual(taskArgs.referenceImages, [
|
||||
path.join(loaded.batchDir, "refs/hero.png"),
|
||||
]);
|
||||
assert.equal(taskArgs.provider, "replicate");
|
||||
assert.equal(taskArgs.aspectRatio, "16:9");
|
||||
assert.equal(taskArgs.quality, "2k");
|
||||
assert.equal(taskArgs.json, true);
|
||||
});
|
||||
|
||||
test("path normalization, worker count, and retry classification follow expected rules", () => {
|
||||
assert.match(normalizeOutputImagePath("out/sample"), /out[\\/]+sample\.png$/);
|
||||
assert.match(normalizeOutputImagePath("out/sample.webp"), /out[\\/]+sample\.webp$/);
|
||||
|
||||
assert.equal(getWorkerCount(8, null, 3), 3);
|
||||
assert.equal(getWorkerCount(2, 6, 5), 2);
|
||||
assert.equal(getWorkerCount(5, 0, 4), 1);
|
||||
|
||||
assert.equal(isRetryableGenerationError(new Error("API error (401): denied")), false);
|
||||
assert.equal(isRetryableGenerationError(new Error("socket hang up")), true);
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { homedir } from "node:os";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import type {
|
||||
BatchFile,
|
||||
@@ -55,6 +56,8 @@ const DEFAULT_PROVIDER_RATE_LIMITS: Record<Provider, ProviderRateLimit> = {
|
||||
openai: { concurrency: 3, startIntervalMs: 1100 },
|
||||
openrouter: { concurrency: 3, startIntervalMs: 1100 },
|
||||
dashscope: { concurrency: 3, startIntervalMs: 1100 },
|
||||
jimeng: { concurrency: 3, startIntervalMs: 1100 },
|
||||
seedream: { concurrency: 3, startIntervalMs: 1100 },
|
||||
};
|
||||
|
||||
function printUsage(): void {
|
||||
@@ -69,7 +72,7 @@ Options:
|
||||
--image <path> Output image path (required in single-image mode)
|
||||
--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|replicate Force provider (auto-detect by default)
|
||||
--provider google|openai|openrouter|dashscope|replicate|jimeng|seedream Force provider (auto-detect by default)
|
||||
-m, --model <id> Model ID
|
||||
--ar <ratio> Aspect ratio (e.g., 16:9, 1:1, 4:3)
|
||||
--size <WxH> Size (e.g., 1024x1024)
|
||||
@@ -107,11 +110,16 @@ Environment variables:
|
||||
GEMINI_API_KEY Gemini API key (alias for GOOGLE_API_KEY)
|
||||
DASHSCOPE_API_KEY DashScope API key
|
||||
REPLICATE_API_TOKEN Replicate API token
|
||||
JIMENG_ACCESS_KEY_ID Jimeng Access Key ID
|
||||
JIMENG_SECRET_ACCESS_KEY Jimeng Secret Access Key
|
||||
ARK_API_KEY Seedream/Ark API key
|
||||
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)
|
||||
OPENAI_BASE_URL Custom OpenAI endpoint
|
||||
OPENAI_IMAGE_USE_CHAT Use /chat/completions instead of /images/generations (true|false)
|
||||
OPENROUTER_BASE_URL Custom OpenRouter endpoint
|
||||
@@ -120,6 +128,8 @@ Environment variables:
|
||||
GOOGLE_BASE_URL Custom Google endpoint
|
||||
DASHSCOPE_BASE_URL Custom DashScope endpoint
|
||||
REPLICATE_BASE_URL Custom Replicate endpoint
|
||||
JIMENG_BASE_URL Custom Jimeng endpoint
|
||||
SEEDREAM_BASE_URL Custom Seedream endpoint
|
||||
BAOYU_IMAGE_GEN_MAX_WORKERS Override batch worker cap
|
||||
BAOYU_IMAGE_GEN_<PROVIDER>_CONCURRENCY Override provider concurrency
|
||||
BAOYU_IMAGE_GEN_<PROVIDER>_START_INTERVAL_MS Override provider start gap in ms
|
||||
@@ -127,7 +137,7 @@ Environment variables:
|
||||
Env file load order: CLI args > EXTEND.md > process.env > <cwd>/.baoyu-skills/.env > ~/.baoyu-skills/.env`);
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): CliArgs {
|
||||
export function parseArgs(argv: string[]): CliArgs {
|
||||
const out: CliArgs = {
|
||||
prompt: null,
|
||||
promptFiles: [],
|
||||
@@ -217,7 +227,9 @@ function parseArgs(argv: string[]): CliArgs {
|
||||
v !== "openai" &&
|
||||
v !== "openrouter" &&
|
||||
v !== "dashscope" &&
|
||||
v !== "replicate"
|
||||
v !== "replicate" &&
|
||||
v !== "jimeng" &&
|
||||
v !== "seedream"
|
||||
) {
|
||||
throw new Error(`Invalid provider: ${v}`);
|
||||
}
|
||||
@@ -327,12 +339,12 @@ async function loadEnv(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
function extractYamlFrontMatter(content: string): string | null {
|
||||
export function extractYamlFrontMatter(content: string): string | null {
|
||||
const match = content.match(/^---\s*\n([\s\S]*?)\n---\s*$/m);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
function parseSimpleYaml(yaml: string): Partial<ExtendConfig> {
|
||||
export function parseSimpleYaml(yaml: string): Partial<ExtendConfig> {
|
||||
const config: Partial<ExtendConfig> = {};
|
||||
const lines = yaml.split("\n");
|
||||
let currentKey: string | null = null;
|
||||
@@ -370,6 +382,8 @@ function parseSimpleYaml(yaml: string): Partial<ExtendConfig> {
|
||||
openrouter: null,
|
||||
dashscope: null,
|
||||
replicate: null,
|
||||
jimeng: null,
|
||||
seedream: null,
|
||||
};
|
||||
currentKey = "default_model";
|
||||
currentProvider = null;
|
||||
@@ -393,7 +407,9 @@ function parseSimpleYaml(yaml: string): Partial<ExtendConfig> {
|
||||
key === "openai" ||
|
||||
key === "openrouter" ||
|
||||
key === "dashscope" ||
|
||||
key === "replicate"
|
||||
key === "replicate" ||
|
||||
key === "jimeng" ||
|
||||
key === "seedream"
|
||||
)
|
||||
) {
|
||||
config.batch ??= {};
|
||||
@@ -407,7 +423,9 @@ function parseSimpleYaml(yaml: string): Partial<ExtendConfig> {
|
||||
key === "openai" ||
|
||||
key === "openrouter" ||
|
||||
key === "dashscope" ||
|
||||
key === "replicate"
|
||||
key === "replicate" ||
|
||||
key === "jimeng" ||
|
||||
key === "seedream"
|
||||
)
|
||||
) {
|
||||
const cleaned = value.replace(/['"]/g, "");
|
||||
@@ -456,7 +474,7 @@ async function loadExtendConfig(): Promise<Partial<ExtendConfig>> {
|
||||
return {};
|
||||
}
|
||||
|
||||
function mergeConfig(args: CliArgs, extend: Partial<ExtendConfig>): CliArgs {
|
||||
export function mergeConfig(args: CliArgs, extend: Partial<ExtendConfig>): CliArgs {
|
||||
return {
|
||||
...args,
|
||||
provider: args.provider ?? extend.default_provider ?? null,
|
||||
@@ -466,13 +484,13 @@ function mergeConfig(args: CliArgs, extend: Partial<ExtendConfig>): CliArgs {
|
||||
};
|
||||
}
|
||||
|
||||
function parsePositiveInt(value: string | undefined): number | null {
|
||||
export function parsePositiveInt(value: string | undefined): number | null {
|
||||
if (!value) return null;
|
||||
const parsed = parseInt(value, 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
||||
}
|
||||
|
||||
function parsePositiveBatchInt(value: unknown): number | null {
|
||||
export function parsePositiveBatchInt(value: unknown): number | null {
|
||||
if (value === null || value === undefined) return null;
|
||||
if (typeof value === "number") {
|
||||
return Number.isInteger(value) && value > 0 ? value : null;
|
||||
@@ -483,13 +501,13 @@ function parsePositiveBatchInt(value: unknown): number | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function getConfiguredMaxWorkers(extendConfig: Partial<ExtendConfig>): number {
|
||||
export function getConfiguredMaxWorkers(extendConfig: Partial<ExtendConfig>): number {
|
||||
const envValue = parsePositiveInt(process.env.BAOYU_IMAGE_GEN_MAX_WORKERS);
|
||||
const configValue = extendConfig.batch?.max_workers ?? null;
|
||||
return Math.max(1, envValue ?? configValue ?? DEFAULT_MAX_WORKERS);
|
||||
}
|
||||
|
||||
function getConfiguredProviderRateLimits(
|
||||
export function getConfiguredProviderRateLimits(
|
||||
extendConfig: Partial<ExtendConfig>
|
||||
): Record<Provider, ProviderRateLimit> {
|
||||
const configured: Record<Provider, ProviderRateLimit> = {
|
||||
@@ -498,9 +516,11 @@ function getConfiguredProviderRateLimits(
|
||||
openai: { ...DEFAULT_PROVIDER_RATE_LIMITS.openai },
|
||||
openrouter: { ...DEFAULT_PROVIDER_RATE_LIMITS.openrouter },
|
||||
dashscope: { ...DEFAULT_PROVIDER_RATE_LIMITS.dashscope },
|
||||
jimeng: { ...DEFAULT_PROVIDER_RATE_LIMITS.jimeng },
|
||||
seedream: { ...DEFAULT_PROVIDER_RATE_LIMITS.seedream },
|
||||
};
|
||||
|
||||
for (const provider of ["replicate", "google", "openai", "openrouter", "dashscope"] as Provider[]) {
|
||||
for (const provider of ["replicate", "google", "openai", "openrouter", "dashscope", "jimeng", "seedream"] as Provider[]) {
|
||||
const envPrefix = `BAOYU_IMAGE_GEN_${provider.toUpperCase()}`;
|
||||
const extendLimit = extendConfig.batch?.provider_limits?.[provider];
|
||||
configured[provider] = {
|
||||
@@ -540,14 +560,14 @@ async function readPromptFromStdin(): Promise<string | null> {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeOutputImagePath(p: string): string {
|
||||
export function normalizeOutputImagePath(p: string): string {
|
||||
const full = path.resolve(p);
|
||||
const ext = path.extname(full);
|
||||
if (ext) return full;
|
||||
return `${full}.png`;
|
||||
}
|
||||
|
||||
function detectProvider(args: CliArgs): Provider {
|
||||
export function detectProvider(args: CliArgs): Provider {
|
||||
if (
|
||||
args.referenceImages.length > 0 &&
|
||||
args.provider &&
|
||||
@@ -568,6 +588,8 @@ function detectProvider(args: CliArgs): Provider {
|
||||
const hasOpenrouter = !!process.env.OPENROUTER_API_KEY;
|
||||
const hasDashscope = !!process.env.DASHSCOPE_API_KEY;
|
||||
const hasReplicate = !!process.env.REPLICATE_API_TOKEN;
|
||||
const hasJimeng = !!(process.env.JIMENG_ACCESS_KEY_ID && process.env.JIMENG_SECRET_ACCESS_KEY);
|
||||
const hasSeedream = !!process.env.ARK_API_KEY;
|
||||
|
||||
if (args.referenceImages.length > 0) {
|
||||
if (hasGoogle) return "google";
|
||||
@@ -575,7 +597,7 @@ function detectProvider(args: CliArgs): Provider {
|
||||
if (hasOpenrouter) return "openrouter";
|
||||
if (hasReplicate) return "replicate";
|
||||
throw new Error(
|
||||
"Reference images require Google, OpenAI, OpenRouter or Replicate. Set GOOGLE_API_KEY/GEMINI_API_KEY, OPENAI_API_KEY, OPENROUTER_API_KEY, or REPLICATE_API_TOKEN, or remove --ref."
|
||||
"Reference images require Google, OpenAI, OpenRouter, or Replicate. Set GOOGLE_API_KEY/GEMINI_API_KEY, OPENAI_API_KEY, OPENROUTER_API_KEY, or REPLICATE_API_TOKEN, or remove --ref."
|
||||
);
|
||||
}
|
||||
|
||||
@@ -585,18 +607,20 @@ function detectProvider(args: CliArgs): Provider {
|
||||
hasOpenrouter && "openrouter",
|
||||
hasDashscope && "dashscope",
|
||||
hasReplicate && "replicate",
|
||||
hasJimeng && "jimeng",
|
||||
hasSeedream && "seedream",
|
||||
].filter(Boolean) as Provider[];
|
||||
|
||||
if (available.length === 1) return available[0]!;
|
||||
if (available.length > 1) return available[0]!;
|
||||
|
||||
throw new Error(
|
||||
"No API key found. Set GOOGLE_API_KEY, GEMINI_API_KEY, OPENAI_API_KEY, OPENROUTER_API_KEY, DASHSCOPE_API_KEY, or REPLICATE_API_TOKEN.\n" +
|
||||
"No API key found. Set GOOGLE_API_KEY, GEMINI_API_KEY, OPENAI_API_KEY, OPENROUTER_API_KEY, DASHSCOPE_API_KEY, REPLICATE_API_TOKEN, JIMENG keys, or ARK_API_KEY.\n" +
|
||||
"Create ~/.baoyu-skills/.env or <cwd>/.baoyu-skills/.env with your keys."
|
||||
);
|
||||
}
|
||||
|
||||
async function validateReferenceImages(referenceImages: string[]): Promise<void> {
|
||||
export async function validateReferenceImages(referenceImages: string[]): Promise<void> {
|
||||
for (const refPath of referenceImages) {
|
||||
const fullPath = path.resolve(refPath);
|
||||
try {
|
||||
@@ -607,7 +631,7 @@ async function validateReferenceImages(referenceImages: string[]): Promise<void>
|
||||
}
|
||||
}
|
||||
|
||||
function isRetryableGenerationError(error: unknown): boolean {
|
||||
export function isRetryableGenerationError(error: unknown): boolean {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
const nonRetryableMarkers = [
|
||||
"Reference image",
|
||||
@@ -632,6 +656,8 @@ async function loadProviderModule(provider: Provider): Promise<ProviderModule> {
|
||||
if (provider === "dashscope") return (await import("./providers/dashscope")) as ProviderModule;
|
||||
if (provider === "replicate") return (await import("./providers/replicate")) as ProviderModule;
|
||||
if (provider === "openrouter") return (await import("./providers/openrouter")) as ProviderModule;
|
||||
if (provider === "jimeng") return (await import("./providers/jimeng")) as ProviderModule;
|
||||
if (provider === "seedream") return (await import("./providers/seedream")) as ProviderModule;
|
||||
return (await import("./providers/openai")) as ProviderModule;
|
||||
}
|
||||
|
||||
@@ -658,6 +684,8 @@ function getModelForProvider(
|
||||
}
|
||||
if (provider === "dashscope" && extendConfig.default_model.dashscope) return extendConfig.default_model.dashscope;
|
||||
if (provider === "replicate" && extendConfig.default_model.replicate) return extendConfig.default_model.replicate;
|
||||
if (provider === "jimeng" && extendConfig.default_model.jimeng) return extendConfig.default_model.jimeng;
|
||||
if (provider === "seedream" && extendConfig.default_model.seedream) return extendConfig.default_model.seedream;
|
||||
}
|
||||
return providerModule.getDefaultModel();
|
||||
}
|
||||
@@ -685,7 +713,7 @@ async function prepareSingleTask(args: CliArgs, extendConfig: Partial<ExtendConf
|
||||
};
|
||||
}
|
||||
|
||||
async function loadBatchTasks(batchFilePath: string): Promise<LoadedBatchTasks> {
|
||||
export async function loadBatchTasks(batchFilePath: string): Promise<LoadedBatchTasks> {
|
||||
const resolvedBatchFilePath = path.resolve(batchFilePath);
|
||||
const content = await readFile(resolvedBatchFilePath, "utf8");
|
||||
const parsed = JSON.parse(content.replace(/^\uFEFF/, "")) as BatchFile;
|
||||
@@ -711,11 +739,11 @@ async function loadBatchTasks(batchFilePath: string): Promise<LoadedBatchTasks>
|
||||
throw new Error("Invalid batch file. Expected an array of tasks or an object with a tasks array.");
|
||||
}
|
||||
|
||||
function resolveBatchPath(batchDir: string, filePath: string): string {
|
||||
export function resolveBatchPath(batchDir: string, filePath: string): string {
|
||||
return path.isAbsolute(filePath) ? filePath : path.resolve(batchDir, filePath);
|
||||
}
|
||||
|
||||
function createTaskArgs(baseArgs: CliArgs, task: BatchTaskInput, batchDir: string): CliArgs {
|
||||
export function createTaskArgs(baseArgs: CliArgs, task: BatchTaskInput, batchDir: string): CliArgs {
|
||||
return {
|
||||
...baseArgs,
|
||||
prompt: task.prompt ?? null,
|
||||
@@ -854,7 +882,7 @@ function createProviderGate(providerRateLimits: Record<Provider, ProviderRateLim
|
||||
};
|
||||
}
|
||||
|
||||
function getWorkerCount(taskCount: number, jobs: number | null, maxWorkers: number): number {
|
||||
export function getWorkerCount(taskCount: number, jobs: number | null, maxWorkers: number): number {
|
||||
const requested = jobs ?? Math.min(taskCount, maxWorkers);
|
||||
return Math.max(1, Math.min(requested, taskCount, maxWorkers));
|
||||
}
|
||||
@@ -873,7 +901,7 @@ async function runBatchTasks(
|
||||
const acquireProvider = createProviderGate(providerRateLimits);
|
||||
const workerCount = getWorkerCount(tasks.length, jobs, maxWorkers);
|
||||
console.error(`Batch mode: ${tasks.length} tasks, ${workerCount} workers, parallel mode enabled.`);
|
||||
for (const provider of ["replicate", "google", "openai", "dashscope"] as Provider[]) {
|
||||
for (const provider of ["replicate", "google", "openai", "openrouter", "dashscope", "jimeng", "seedream"] as Provider[]) {
|
||||
const limit = providerRateLimits[provider];
|
||||
console.error(`- ${provider}: concurrency=${limit.concurrency}, startIntervalMs=${limit.startIntervalMs}`);
|
||||
}
|
||||
@@ -984,8 +1012,21 @@ async function main(): Promise<void> {
|
||||
await runSingleMode(mergedArgs, extendConfig);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(message);
|
||||
process.exit(1);
|
||||
});
|
||||
function isDirectExecution(metaUrl: string): boolean {
|
||||
const entryPath = process.argv[1];
|
||||
if (!entryPath) return false;
|
||||
|
||||
try {
|
||||
return path.resolve(entryPath) === fileURLToPath(metaUrl);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (isDirectExecution(import.meta.url)) {
|
||||
main().catch((error) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(message);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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感,构图混乱,文字模糊,扭曲";
|
||||
|
||||
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],
|
||||
];
|
||||
|
||||
function getSizeFromAspectRatio(ar: string | null, quality: CliArgs["quality"]): string {
|
||||
const is2k = quality === "2k";
|
||||
const defaultSize = is2k ? "1536*1536" : "1024*1024";
|
||||
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 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 @@ function getSizeFromAspectRatio(ar: string | null, quality: CliArgs["quality"]):
|
||||
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;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test, { type TestContext } from "node:test";
|
||||
|
||||
import type { CliArgs } from "../types.ts";
|
||||
import {
|
||||
addAspectRatioToPrompt,
|
||||
buildGoogleUrl,
|
||||
buildPromptWithAspect,
|
||||
extractInlineImageData,
|
||||
extractPredictedImageData,
|
||||
getGoogleImageSize,
|
||||
isGoogleImagen,
|
||||
isGoogleMultimodal,
|
||||
normalizeGoogleModelId,
|
||||
} from "./google.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;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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"),
|
||||
"gemini-3.1-flash-image-preview",
|
||||
);
|
||||
assert.equal(isGoogleMultimodal("models/gemini-3-pro-image-preview"), true);
|
||||
assert.equal(isGoogleImagen("imagen-3.0-generate-002"), true);
|
||||
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) => {
|
||||
useEnv(t, { GOOGLE_BASE_URL: "https://generativelanguage.googleapis.com" });
|
||||
assert.equal(
|
||||
buildGoogleUrl("models/demo:generateContent"),
|
||||
"https://generativelanguage.googleapis.com/v1beta/models/demo:generateContent",
|
||||
);
|
||||
});
|
||||
|
||||
test("Google URL and prompt helpers preserve existing v1beta paths and aspect hints", (t) => {
|
||||
useEnv(t, { GOOGLE_BASE_URL: "https://example.com/custom/v1beta/" });
|
||||
assert.equal(
|
||||
buildGoogleUrl("/models/demo:predict"),
|
||||
"https://example.com/custom/v1beta/models/demo:predict",
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
addAspectRatioToPrompt("A city skyline", "16:9"),
|
||||
"A city skyline Aspect ratio: 16:9.",
|
||||
);
|
||||
assert.equal(
|
||||
buildPromptWithAspect("A city skyline", "16:9", "2k"),
|
||||
"A city skyline Aspect ratio: 16:9. High resolution 2048px.",
|
||||
);
|
||||
});
|
||||
|
||||
test("Google response extractors find inline and predicted image payloads", () => {
|
||||
assert.equal(
|
||||
extractInlineImageData({
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [{ inlineData: { data: "inline-base64" } }],
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
"inline-base64",
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
extractPredictedImageData({
|
||||
predictions: [{ image: { imageBytes: "predicted-base64" } }],
|
||||
}),
|
||||
"predicted-base64",
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
extractPredictedImageData({
|
||||
generatedImages: [{ bytesBase64Encoded: "generated-base64" }],
|
||||
}),
|
||||
"generated-base64",
|
||||
);
|
||||
});
|
||||
@@ -17,16 +17,16 @@ export function getDefaultModel(): string {
|
||||
return process.env.GOOGLE_IMAGE_MODEL || "gemini-3-pro-image-preview";
|
||||
}
|
||||
|
||||
function normalizeGoogleModelId(model: string): string {
|
||||
export function normalizeGoogleModelId(model: string): string {
|
||||
return model.startsWith("models/") ? model.slice("models/".length) : model;
|
||||
}
|
||||
|
||||
function isGoogleMultimodal(model: string): boolean {
|
||||
export function isGoogleMultimodal(model: string): boolean {
|
||||
const normalized = normalizeGoogleModelId(model);
|
||||
return GOOGLE_MULTIMODAL_MODELS.some((m) => normalized.includes(m));
|
||||
}
|
||||
|
||||
function isGoogleImagen(model: string): boolean {
|
||||
export function isGoogleImagen(model: string): boolean {
|
||||
const normalized = normalizeGoogleModelId(model);
|
||||
return GOOGLE_IMAGEN_MODELS.some((m) => normalized.includes(m));
|
||||
}
|
||||
@@ -35,7 +35,7 @@ function getGoogleApiKey(): string | null {
|
||||
return process.env.GOOGLE_API_KEY || process.env.GEMINI_API_KEY || null;
|
||||
}
|
||||
|
||||
function getGoogleImageSize(args: CliArgs): "1K" | "2K" | "4K" {
|
||||
export function getGoogleImageSize(args: CliArgs): "1K" | "2K" | "4K" {
|
||||
if (args.imageSize) return args.imageSize as "1K" | "2K" | "4K";
|
||||
return args.quality === "2k" ? "2K" : "1K";
|
||||
}
|
||||
@@ -46,7 +46,7 @@ function getGoogleBaseUrl(): string {
|
||||
return base.replace(/\/+$/g, "");
|
||||
}
|
||||
|
||||
function buildGoogleUrl(pathname: string): string {
|
||||
export function buildGoogleUrl(pathname: string): string {
|
||||
const base = getGoogleBaseUrl();
|
||||
const cleanedPath = pathname.replace(/^\/+/g, "");
|
||||
if (base.endsWith("/v1beta")) return `${base}/${cleanedPath}`;
|
||||
@@ -162,7 +162,7 @@ async function postGoogleJson<T>(pathname: string, body: unknown): Promise<T> {
|
||||
return postGoogleJsonViaFetch<T>(url, apiKey, body);
|
||||
}
|
||||
|
||||
function buildPromptWithAspect(
|
||||
export function buildPromptWithAspect(
|
||||
prompt: string,
|
||||
ar: string | null,
|
||||
quality: CliArgs["quality"],
|
||||
@@ -177,7 +177,7 @@ function buildPromptWithAspect(
|
||||
return result;
|
||||
}
|
||||
|
||||
function addAspectRatioToPrompt(prompt: string, ar: string | null): string {
|
||||
export function addAspectRatioToPrompt(prompt: string, ar: string | null): string {
|
||||
if (!ar) return prompt;
|
||||
return `${prompt} Aspect ratio: ${ar}.`;
|
||||
}
|
||||
@@ -194,7 +194,7 @@ async function readImageAsBase64(
|
||||
return { data: buf.toString("base64"), mimeType };
|
||||
}
|
||||
|
||||
function extractInlineImageData(response: {
|
||||
export function extractInlineImageData(response: {
|
||||
candidates?: Array<{
|
||||
content?: { parts?: Array<{ inlineData?: { data?: string } }> };
|
||||
}>;
|
||||
@@ -208,7 +208,7 @@ function extractInlineImageData(response: {
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractPredictedImageData(response: {
|
||||
export function extractPredictedImageData(response: {
|
||||
predictions?: Array<any>;
|
||||
generatedImages?: Array<any>;
|
||||
}): string | null {
|
||||
|
||||
@@ -0,0 +1,467 @@
|
||||
import type { CliArgs } from "../types";
|
||||
import * as crypto from "node:crypto";
|
||||
|
||||
type JimengSizePreset = "normal" | "2k" | "4k";
|
||||
|
||||
export function getDefaultModel(): string {
|
||||
return process.env.JIMENG_IMAGE_MODEL || "jimeng_t2i_v40";
|
||||
}
|
||||
|
||||
function getAccessKey(): string | null {
|
||||
return process.env.JIMENG_ACCESS_KEY_ID || null;
|
||||
}
|
||||
|
||||
function getSecretKey(): string | null {
|
||||
return process.env.JIMENG_SECRET_ACCESS_KEY || null;
|
||||
}
|
||||
|
||||
function getRegion(): string {
|
||||
return process.env.JIMENG_REGION || "cn-north-1";
|
||||
}
|
||||
|
||||
function getBaseUrl(): string {
|
||||
return process.env.JIMENG_BASE_URL || "https://visual.volcengineapi.com";
|
||||
}
|
||||
|
||||
function resolveEndpoint(query: Record<string, string>): {
|
||||
url: string;
|
||||
host: string;
|
||||
canonicalUri: string;
|
||||
} {
|
||||
let baseUrl: URL;
|
||||
try {
|
||||
baseUrl = new URL(getBaseUrl());
|
||||
} catch {
|
||||
throw new Error(`Invalid JIMENG_BASE_URL: ${getBaseUrl()}`);
|
||||
}
|
||||
|
||||
baseUrl.search = "";
|
||||
for (const [key, value] of Object.entries(query).sort(([a], [b]) => a.localeCompare(b))) {
|
||||
baseUrl.searchParams.set(key, value);
|
||||
}
|
||||
|
||||
return {
|
||||
url: baseUrl.toString(),
|
||||
host: baseUrl.host,
|
||||
canonicalUri: baseUrl.pathname || "/",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Volcengine HMAC-SHA256 signature generation
|
||||
* Following the official documentation at:
|
||||
* https://www.volcengine.com/docs/85621/1817045
|
||||
*/
|
||||
function generateSignature(
|
||||
method: string,
|
||||
query: Record<string, string>,
|
||||
headers: Record<string, string>,
|
||||
body: string,
|
||||
accessKey: string,
|
||||
secretKey: string,
|
||||
region: string,
|
||||
service: string,
|
||||
canonicalUri: string
|
||||
): string {
|
||||
// 1. Create canonical request
|
||||
// Sort query parameters alphabetically
|
||||
const sortedQuery = Object.entries(query)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
|
||||
.join("&");
|
||||
|
||||
// Sort headers alphabetically and create canonical headers
|
||||
const sortedHeaders = Object.entries(headers)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([k, v]) => `${k.toLowerCase()}:${v.trim()}\n`)
|
||||
.join("");
|
||||
|
||||
const signedHeaders = Object.keys(headers)
|
||||
.sort()
|
||||
.map(k => k.toLowerCase())
|
||||
.join(";");
|
||||
|
||||
const hashedPayload = crypto.createHash("sha256").update(body, "utf8").digest("hex");
|
||||
|
||||
const canonicalRequest = [
|
||||
method,
|
||||
canonicalUri,
|
||||
sortedQuery,
|
||||
sortedHeaders,
|
||||
signedHeaders,
|
||||
hashedPayload,
|
||||
].join("\n");
|
||||
|
||||
const hashedCanonicalRequest = crypto
|
||||
.createHash("sha256")
|
||||
.update(canonicalRequest, "utf8")
|
||||
.digest("hex");
|
||||
|
||||
// 2. Create string to sign
|
||||
const algorithm = "HMAC-SHA256";
|
||||
const timestamp = headers["X-Date"] || headers["x-date"];
|
||||
if (!timestamp) {
|
||||
throw new Error("Jimeng signature generation requires an X-Date header.");
|
||||
}
|
||||
const dateStamp = timestamp.slice(0, 8);
|
||||
|
||||
const credentialScope = `${dateStamp}/${region}/${service}/request`;
|
||||
|
||||
const stringToSign = [
|
||||
algorithm,
|
||||
timestamp,
|
||||
credentialScope,
|
||||
hashedCanonicalRequest,
|
||||
].join("\n");
|
||||
|
||||
// 3. Calculate signature
|
||||
const kDate = crypto
|
||||
.createHmac("sha256", secretKey)
|
||||
.update(dateStamp)
|
||||
.digest();
|
||||
|
||||
const kRegion = crypto.createHmac("sha256", kDate).update(region).digest();
|
||||
const kService = crypto.createHmac("sha256", kRegion).update(service).digest();
|
||||
const kSigning = crypto.createHmac("sha256", kService).update("request").digest();
|
||||
|
||||
const signature = crypto
|
||||
.createHmac("sha256", kSigning)
|
||||
.update(stringToSign)
|
||||
.digest("hex");
|
||||
|
||||
// 4. Create authorization header
|
||||
return `${algorithm} Credential=${accessKey}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse aspect ratio string like "16:9", "1:1", "4:3" into width and height
|
||||
*/
|
||||
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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Supported size presets for different quality levels
|
||||
* Based on Volcengine Jimeng documentation
|
||||
*/
|
||||
const SIZE_PRESETS: Record<string, Record<string, string>> = {
|
||||
normal: {
|
||||
"1:1": "1024x1024",
|
||||
"4:3": "1360x1020",
|
||||
"16:9": "1536x864",
|
||||
"3:2": "1440x960",
|
||||
"21:9": "1920x824",
|
||||
},
|
||||
"2k": {
|
||||
"1:1": "2048x2048",
|
||||
"4:3": "2304x1728",
|
||||
"16:9": "2560x1440",
|
||||
"3:2": "2496x1664",
|
||||
"21:9": "3024x1296",
|
||||
},
|
||||
"4k": {
|
||||
"1:1": "4096x4096",
|
||||
"4:3": "4694x3520",
|
||||
"16:9": "5404x3040",
|
||||
"3:2": "4992x3328",
|
||||
"21:9": "6198x2656",
|
||||
},
|
||||
};
|
||||
|
||||
function normalizeDimensions(value: string): string | null {
|
||||
const match = value.trim().match(/^(\d+)\s*[xX*]\s*(\d+)$/);
|
||||
if (!match) return null;
|
||||
return `${match[1]}x${match[2]}`;
|
||||
}
|
||||
|
||||
function getClosestPresetSize(ar: string | null, qualityLevel: JimengSizePreset): string {
|
||||
const presets = SIZE_PRESETS[qualityLevel];
|
||||
const defaultSize = presets["1:1"]!;
|
||||
|
||||
if (!ar) return defaultSize;
|
||||
|
||||
const parsed = parseAspectRatio(ar);
|
||||
if (!parsed) return defaultSize;
|
||||
|
||||
const targetRatio = parsed.width / parsed.height;
|
||||
let bestMatch = defaultSize;
|
||||
let bestDiff = Infinity;
|
||||
|
||||
for (const [ratio, size] of Object.entries(presets)) {
|
||||
const [w, h] = ratio.split(":").map(Number);
|
||||
const presetRatio = w / h;
|
||||
const diff = Math.abs(presetRatio - targetRatio);
|
||||
if (diff < bestDiff) {
|
||||
bestDiff = diff;
|
||||
bestMatch = size;
|
||||
}
|
||||
}
|
||||
|
||||
return bestMatch;
|
||||
}
|
||||
|
||||
function normalizeImageSizePreset(imageSize: string, ar: string | null): string | null {
|
||||
const preset = imageSize.trim().toUpperCase();
|
||||
if (preset === "1K") return getClosestPresetSize(ar, "normal");
|
||||
if (preset === "2K") return getClosestPresetSize(ar, "2k");
|
||||
if (preset === "4K") return getClosestPresetSize(ar, "4k");
|
||||
return normalizeDimensions(imageSize);
|
||||
}
|
||||
|
||||
function getImageSize(ar: string | null, quality: CliArgs["quality"], imageSize?: string | null): string {
|
||||
if (imageSize) {
|
||||
const normalizedSize = normalizeImageSizePreset(imageSize, ar);
|
||||
if (normalizedSize) return normalizedSize;
|
||||
}
|
||||
|
||||
// Default to 2K quality if not specified
|
||||
const qualityLevel: JimengSizePreset = quality === "normal" ? "normal" : "2k";
|
||||
return getClosestPresetSize(ar, qualityLevel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 1: Submit async task to Volcengine Jimeng API
|
||||
*/
|
||||
async function submitTask(
|
||||
prompt: string,
|
||||
model: string,
|
||||
size: string,
|
||||
accessKey: string,
|
||||
secretKey: string,
|
||||
region: string
|
||||
): Promise<string> {
|
||||
// Query parameters for submit endpoint
|
||||
const query = {
|
||||
Action: "CVSync2AsyncSubmitTask",
|
||||
Version: "2022-08-31",
|
||||
};
|
||||
const endpoint = resolveEndpoint(query);
|
||||
|
||||
// Request body - Jimeng API expects width/height as separate integers
|
||||
const [width, height] = size.split("x").map(Number);
|
||||
const bodyObj = {
|
||||
req_key: model,
|
||||
prompt_text: prompt,
|
||||
// Use separate width and height parameters instead of size string
|
||||
width: width,
|
||||
height: height,
|
||||
// Optional: seed for reproducibility
|
||||
// seed: Math.floor(Math.random() * 999999),
|
||||
};
|
||||
|
||||
const body = JSON.stringify(bodyObj);
|
||||
|
||||
// Headers
|
||||
const timestampHeader = new Date().toISOString().replace(/[:\-]|\.\d{3}/g, "");
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
"X-Date": timestampHeader,
|
||||
"Host": endpoint.host,
|
||||
};
|
||||
|
||||
// Generate signature
|
||||
const authorization = generateSignature(
|
||||
"POST",
|
||||
query,
|
||||
headers,
|
||||
body,
|
||||
accessKey,
|
||||
secretKey,
|
||||
region,
|
||||
"cv",
|
||||
endpoint.canonicalUri
|
||||
);
|
||||
|
||||
console.error(`Submitting task to Jimeng (${model})...`, { width, height });
|
||||
|
||||
const res = await fetch(endpoint.url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...headers,
|
||||
"Authorization": authorization,
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
throw new Error(`Jimeng API submit error (${res.status}): ${err}`);
|
||||
}
|
||||
|
||||
const result = (await res.json()) as {
|
||||
code?: number;
|
||||
message?: string;
|
||||
data?: {
|
||||
task_id?: string;
|
||||
};
|
||||
};
|
||||
|
||||
// Volcengine API returns code 10000 for success
|
||||
if (result.code !== 10000 || !result.data?.task_id) {
|
||||
console.error("Submit response:", JSON.stringify(result, null, 2));
|
||||
throw new Error(`Failed to submit task: ${result.message || "Unknown error"}`);
|
||||
}
|
||||
|
||||
return result.data.task_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 2: Poll for task result
|
||||
* Returns image data directly as Uint8Array
|
||||
*/
|
||||
async function pollForResult(
|
||||
taskId: string,
|
||||
model: string,
|
||||
accessKey: string,
|
||||
secretKey: string,
|
||||
region: string
|
||||
): Promise<Uint8Array> {
|
||||
const maxAttempts = 60;
|
||||
const pollIntervalMs = 2000;
|
||||
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
// Query parameters for result endpoint
|
||||
const query = {
|
||||
Action: "CVSync2AsyncGetResult",
|
||||
Version: "2022-08-31",
|
||||
};
|
||||
const endpoint = resolveEndpoint(query);
|
||||
|
||||
// Request body - include req_key and task_id
|
||||
const bodyObj = {
|
||||
req_key: model,
|
||||
task_id: taskId,
|
||||
};
|
||||
|
||||
const body = JSON.stringify(bodyObj);
|
||||
|
||||
// Headers
|
||||
const timestampHeader = new Date().toISOString().replace(/[:\-]|\.\d{3}/g, "");
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
"X-Date": timestampHeader,
|
||||
"Host": endpoint.host,
|
||||
};
|
||||
|
||||
// Generate signature
|
||||
const authorization = generateSignature(
|
||||
"POST",
|
||||
query,
|
||||
headers,
|
||||
body,
|
||||
accessKey,
|
||||
secretKey,
|
||||
region,
|
||||
"cv",
|
||||
endpoint.canonicalUri
|
||||
);
|
||||
|
||||
const res = await fetch(endpoint.url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...headers,
|
||||
"Authorization": authorization,
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
throw new Error(`Jimeng API poll error (${res.status}): ${err}`);
|
||||
}
|
||||
|
||||
const result = (await res.json()) as {
|
||||
code?: number;
|
||||
message?: string;
|
||||
data?: {
|
||||
status?: string;
|
||||
image_urls?: string[];
|
||||
binary_data_base64?: string[];
|
||||
};
|
||||
};
|
||||
|
||||
// Volcengine API returns code 10000 for success
|
||||
if (result.code === 10000 && result.data) {
|
||||
const { status, image_urls, binary_data_base64 } = result.data;
|
||||
|
||||
// Check for base64 image data (preferred by Jimeng)
|
||||
if (binary_data_base64 && binary_data_base64.length > 0) {
|
||||
console.error("Image received as base64 data");
|
||||
const base64Data = binary_data_base64[0]!;
|
||||
// Convert base64 to Uint8Array
|
||||
const binaryString = Buffer.from(base64Data, "base64").toString("binary");
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
// Fallback to URL format
|
||||
if (status === "done" && image_urls && image_urls.length > 0) {
|
||||
// Download from URL
|
||||
console.error(`Downloading image from ${image_urls[0]}...`);
|
||||
const imgRes = await fetch(image_urls[0]!);
|
||||
if (!imgRes.ok) {
|
||||
throw new Error(`Failed to download image from ${image_urls[0]}`);
|
||||
}
|
||||
const buffer = await imgRes.arrayBuffer();
|
||||
return new Uint8Array(buffer);
|
||||
}
|
||||
|
||||
if (status === "in_queue" || status === "generating") {
|
||||
console.error(`Task status: ${status} (${attempt + 1}/${maxAttempts})`);
|
||||
await new Promise(resolve => setTimeout(resolve, pollIntervalMs));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (status === "fail") {
|
||||
throw new Error(`Jimeng task failed: ${result.message || "Generation failed"}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.error("Poll response:", JSON.stringify(result, null, 2));
|
||||
throw new Error(`Unexpected response during polling: ${result.message || "Unknown error"}`);
|
||||
}
|
||||
|
||||
throw new Error("Task timeout: image generation took too long");
|
||||
}
|
||||
|
||||
export async function generateImage(
|
||||
prompt: string,
|
||||
model: string,
|
||||
args: CliArgs
|
||||
): Promise<Uint8Array> {
|
||||
if (args.referenceImages.length > 0) {
|
||||
throw new Error(
|
||||
"Jimeng does not support reference images. Use --provider google, openai, openrouter, or replicate."
|
||||
);
|
||||
}
|
||||
|
||||
const accessKey = getAccessKey();
|
||||
const secretKey = getSecretKey();
|
||||
const region = getRegion();
|
||||
|
||||
if (!accessKey || !secretKey) {
|
||||
throw new Error(
|
||||
"JIMENG_ACCESS_KEY_ID and JIMENG_SECRET_ACCESS_KEY are required. " +
|
||||
"Get your credentials from https://console.volcengine.com/iam/keymanage"
|
||||
);
|
||||
}
|
||||
|
||||
const size = getImageSize(args.aspectRatio, args.quality, args.imageSize);
|
||||
|
||||
// Step 1: Submit task
|
||||
const taskId = await submitTask(prompt, model, size, accessKey, secretKey, region);
|
||||
|
||||
// Step 2: Poll for result (returns image data directly)
|
||||
const imageData = await pollForResult(taskId, model, accessKey, secretKey, region);
|
||||
|
||||
console.error("Image generation complete!");
|
||||
return imageData;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
extractImageFromResponse,
|
||||
getMimeType,
|
||||
getOpenAISize,
|
||||
parseAspectRatio,
|
||||
} from "./openai.ts";
|
||||
|
||||
test("OpenAI aspect-ratio parsing and size selection match model families", () => {
|
||||
assert.deepEqual(parseAspectRatio("16:9"), { width: 16, height: 9 });
|
||||
assert.equal(parseAspectRatio("wide"), null);
|
||||
assert.equal(parseAspectRatio("0:1"), null);
|
||||
|
||||
assert.equal(getOpenAISize("dall-e-3", "16:9", "2k"), "1792x1024");
|
||||
assert.equal(getOpenAISize("dall-e-3", "9:16", "normal"), "1024x1792");
|
||||
assert.equal(getOpenAISize("dall-e-2", "16:9", "2k"), "1024x1024");
|
||||
assert.equal(getOpenAISize("gpt-image-1.5", "16:9", "2k"), "1536x1024");
|
||||
assert.equal(getOpenAISize("gpt-image-1.5", "4:3", "2k"), "1024x1024");
|
||||
});
|
||||
|
||||
test("OpenAI mime-type detection covers supported reference image extensions", () => {
|
||||
assert.equal(getMimeType("frame.png"), "image/png");
|
||||
assert.equal(getMimeType("frame.jpg"), "image/jpeg");
|
||||
assert.equal(getMimeType("frame.webp"), "image/webp");
|
||||
assert.equal(getMimeType("frame.gif"), "image/gif");
|
||||
});
|
||||
|
||||
test("OpenAI response extraction supports base64 and URL download flows", async (t) => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
t.after(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
const fromBase64 = await extractImageFromResponse({
|
||||
data: [{ b64_json: Buffer.from("hello").toString("base64") }],
|
||||
});
|
||||
assert.equal(Buffer.from(fromBase64).toString("utf8"), "hello");
|
||||
|
||||
globalThis.fetch = async () =>
|
||||
new Response(Uint8Array.from([1, 2, 3]), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/octet-stream" },
|
||||
});
|
||||
|
||||
const fromUrl = await extractImageFromResponse({
|
||||
data: [{ url: "https://example.com/image.png" }],
|
||||
});
|
||||
assert.deepEqual([...fromUrl], [1, 2, 3]);
|
||||
|
||||
await assert.rejects(
|
||||
() => extractImageFromResponse({ data: [{}] }),
|
||||
/No image in response/,
|
||||
);
|
||||
});
|
||||
@@ -8,7 +8,7 @@ export function getDefaultModel(): string {
|
||||
|
||||
type OpenAIImageResponse = { data: Array<{ url?: string; b64_json?: string }> };
|
||||
|
||||
function parseAspectRatio(ar: string): { width: number; height: number } | null {
|
||||
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]!);
|
||||
@@ -23,7 +23,7 @@ type SizeMapping = {
|
||||
portrait: string;
|
||||
};
|
||||
|
||||
function getOpenAISize(
|
||||
export function getOpenAISize(
|
||||
model: string,
|
||||
ar: string | null,
|
||||
quality: CliArgs["quality"]
|
||||
@@ -201,7 +201,7 @@ async function generateWithOpenAIEdits(
|
||||
return extractImageFromResponse(result);
|
||||
}
|
||||
|
||||
function getMimeType(filename: string): string {
|
||||
export function getMimeType(filename: string): string {
|
||||
const ext = path.extname(filename).toLowerCase();
|
||||
if (ext === ".jpg" || ext === ".jpeg") return "image/jpeg";
|
||||
if (ext === ".webp") return "image/webp";
|
||||
@@ -209,7 +209,7 @@ function getMimeType(filename: string): string {
|
||||
return "image/png";
|
||||
}
|
||||
|
||||
async function extractImageFromResponse(result: OpenAIImageResponse): Promise<Uint8Array> {
|
||||
export async function extractImageFromResponse(result: OpenAIImageResponse): Promise<Uint8Array> {
|
||||
const img = result.data[0];
|
||||
|
||||
if (img?.b64_json) {
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import type { CliArgs } from "../types.ts";
|
||||
import {
|
||||
buildInput,
|
||||
extractOutputUrl,
|
||||
parseModelId,
|
||||
} from "./replicate.ts";
|
||||
|
||||
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("Replicate model parsing accepts official formats and rejects malformed ones", () => {
|
||||
assert.deepEqual(parseModelId("google/nano-banana-pro"), {
|
||||
owner: "google",
|
||||
name: "nano-banana-pro",
|
||||
version: null,
|
||||
});
|
||||
assert.deepEqual(parseModelId("owner/model:abc123"), {
|
||||
owner: "owner",
|
||||
name: "model",
|
||||
version: "abc123",
|
||||
});
|
||||
|
||||
assert.throws(
|
||||
() => parseModelId("just-a-model-name"),
|
||||
/Invalid Replicate model format/,
|
||||
);
|
||||
});
|
||||
|
||||
test("Replicate input builder maps aspect ratio, image count, quality, and refs", () => {
|
||||
assert.deepEqual(
|
||||
buildInput(
|
||||
"A robot painter",
|
||||
makeArgs({
|
||||
aspectRatio: "16:9",
|
||||
quality: "2k",
|
||||
n: 3,
|
||||
}),
|
||||
["data:image/png;base64,AAAA"],
|
||||
),
|
||||
{
|
||||
prompt: "A robot painter",
|
||||
aspect_ratio: "16:9",
|
||||
number_of_images: 3,
|
||||
resolution: "2K",
|
||||
output_format: "png",
|
||||
image_input: ["data:image/png;base64,AAAA"],
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
buildInput("A robot painter", makeArgs({ quality: "normal" }), ["ref"]),
|
||||
{
|
||||
prompt: "A robot painter",
|
||||
aspect_ratio: "match_input_image",
|
||||
resolution: "1K",
|
||||
output_format: "png",
|
||||
image_input: ["ref"],
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("Replicate output extraction supports string, array, and object URLs", () => {
|
||||
assert.equal(
|
||||
extractOutputUrl({ output: "https://example.com/a.png" } as never),
|
||||
"https://example.com/a.png",
|
||||
);
|
||||
assert.equal(
|
||||
extractOutputUrl({ output: ["https://example.com/b.png"] } as never),
|
||||
"https://example.com/b.png",
|
||||
);
|
||||
assert.equal(
|
||||
extractOutputUrl({ output: { url: "https://example.com/c.png" } } as never),
|
||||
"https://example.com/c.png",
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() => extractOutputUrl({ output: { invalid: true } } as never),
|
||||
/Unexpected Replicate output format/,
|
||||
);
|
||||
});
|
||||
@@ -20,7 +20,7 @@ function getBaseUrl(): string {
|
||||
return base.replace(/\/+$/g, "");
|
||||
}
|
||||
|
||||
function parseModelId(model: string): { owner: string; name: string; version: string | null } {
|
||||
export function parseModelId(model: string): { owner: string; name: string; version: string | null } {
|
||||
const [ownerName, version] = model.split(":");
|
||||
const parts = ownerName!.split("/");
|
||||
if (parts.length !== 2 || !parts[0] || !parts[1]) {
|
||||
@@ -31,7 +31,7 @@ function parseModelId(model: string): { owner: string; name: string; version: st
|
||||
return { owner: parts[0], name: parts[1], version: version || null };
|
||||
}
|
||||
|
||||
function buildInput(prompt: string, args: CliArgs, referenceImages: string[]): Record<string, unknown> {
|
||||
export function buildInput(prompt: string, args: CliArgs, referenceImages: string[]): Record<string, unknown> {
|
||||
const input: Record<string, unknown> = { prompt };
|
||||
|
||||
if (args.aspectRatio) {
|
||||
@@ -144,7 +144,7 @@ async function pollPrediction(apiToken: string, getUrl: string): Promise<Predict
|
||||
throw new Error(`Replicate prediction timed out after ${MAX_POLL_MS / 1000}s`);
|
||||
}
|
||||
|
||||
function extractOutputUrl(prediction: PredictionResponse): string {
|
||||
export function extractOutputUrl(prediction: PredictionResponse): string {
|
||||
const output = prediction.output;
|
||||
|
||||
if (typeof output === "string") return output;
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import type { CliArgs } from "../types";
|
||||
|
||||
export function getDefaultModel(): string {
|
||||
return process.env.SEEDREAM_IMAGE_MODEL || "doubao-seedream-5-0-260128";
|
||||
}
|
||||
|
||||
function getApiKey(): string | null {
|
||||
return process.env.ARK_API_KEY || null;
|
||||
}
|
||||
|
||||
function getBaseUrl(): string {
|
||||
return process.env.SEEDREAM_BASE_URL || "https://ark.cn-beijing.volces.com/api/v3";
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert aspect ratio to Seedream size format
|
||||
* Seedream API accepts: "2k" (default), "3k", or WIDTHxHEIGHT format
|
||||
* Note: API uses lowercase "2k"/"3k", not "2K"/"3K"
|
||||
*/
|
||||
function getSeedreamSize(ar: string | null, quality: CliArgs["quality"], imageSize?: string | null): string {
|
||||
// If explicit size is provided
|
||||
if (imageSize) {
|
||||
const upper = imageSize.toUpperCase();
|
||||
if (upper === "2K" || upper === "3K") {
|
||||
return upper.toLowerCase(); // API expects "2k" or "3k"
|
||||
}
|
||||
// For widthxheight format, pass through as-is
|
||||
if (imageSize.includes("x")) {
|
||||
return imageSize;
|
||||
}
|
||||
}
|
||||
|
||||
// Default to 2k (smallest option supported by API)
|
||||
return "2k";
|
||||
}
|
||||
|
||||
type SeedreamImageResponse = {
|
||||
model: string;
|
||||
created: number;
|
||||
data: Array<{
|
||||
url: string;
|
||||
size: string;
|
||||
}>;
|
||||
usage: {
|
||||
generated_images: number;
|
||||
output_tokens: number;
|
||||
total_tokens: number;
|
||||
};
|
||||
};
|
||||
|
||||
export async function generateImage(
|
||||
prompt: string,
|
||||
model: string,
|
||||
args: CliArgs
|
||||
): Promise<Uint8Array> {
|
||||
const apiKey = getApiKey();
|
||||
if (!apiKey) {
|
||||
throw new Error(
|
||||
"ARK_API_KEY is required. " +
|
||||
"Get your API key from https://console.volcengine.com/ark"
|
||||
);
|
||||
}
|
||||
|
||||
const baseUrl = getBaseUrl();
|
||||
const size = getSeedreamSize(args.aspectRatio, args.quality, args.imageSize);
|
||||
|
||||
console.error(`Calling Seedream API (${model}) with size: ${size}`);
|
||||
|
||||
const requestBody = {
|
||||
model,
|
||||
prompt,
|
||||
size,
|
||||
output_format: "png",
|
||||
watermark: false,
|
||||
};
|
||||
|
||||
const response = await fetch(`${baseUrl}/images/generations`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.text();
|
||||
throw new Error(`Seedream API error (${response.status}): ${err}`);
|
||||
}
|
||||
|
||||
const result = (await response.json()) as SeedreamImageResponse;
|
||||
|
||||
if (!result.data || result.data.length === 0) {
|
||||
throw new Error("No image data in Seedream response");
|
||||
}
|
||||
|
||||
const imageUrl = result.data[0].url;
|
||||
if (!imageUrl) {
|
||||
throw new Error("No image URL in Seedream response");
|
||||
}
|
||||
|
||||
// Download image from URL
|
||||
console.error(`Downloading image from ${imageUrl}...`);
|
||||
const imgResponse = await fetch(imageUrl);
|
||||
if (!imgResponse.ok) {
|
||||
throw new Error(`Failed to download image from ${imageUrl}`);
|
||||
}
|
||||
|
||||
const buffer = await imgResponse.arrayBuffer();
|
||||
return new Uint8Array(buffer);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
export type Provider = "google" | "openai" | "openrouter" | "dashscope" | "replicate";
|
||||
export type Provider = "google" | "openai" | "openrouter" | "dashscope" | "replicate" | "jimeng" | "seedream";
|
||||
export type Quality = "normal" | "2k";
|
||||
|
||||
export type CliArgs = {
|
||||
@@ -53,6 +53,8 @@ export type ExtendConfig = {
|
||||
openrouter: string | null;
|
||||
dashscope: string | null;
|
||||
replicate: string | null;
|
||||
jimeng: string | null;
|
||||
seedream: string | null;
|
||||
};
|
||||
batch?: {
|
||||
max_workers?: number | null;
|
||||
|
||||
@@ -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 = `
|
||||

|
||||
## “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);
|
||||
});
|
||||
+71
@@ -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(
|
||||
`\n\nText\n\n`,
|
||||
"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`b<\/code>/);
|
||||
});
|
||||
|
||||
test("emphasis with inline code containing backticks", () => {
|
||||
const html = render("*``a`b``*");
|
||||
assert.match(html, /<em[^>]*><code[^>]*>a`b<\/code><\/em>/);
|
||||
});
|
||||
|
||||
test("bold with inline code containing consecutive backticks", () => {
|
||||
const html = render("**```a``b```**");
|
||||
assert.match(html, /<code[^>]*>a``b<\/code>/);
|
||||
});
|
||||
|
||||
test("bold with inline code containing only backticks", () => {
|
||||
const html = render("**```` `` ````**");
|
||||
assert.match(html, /<code[^>]*>``<\/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 "";
|
||||
};
|
||||
|
||||
+307
@@ -0,0 +1,307 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
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 {
|
||||
discoverRunningChromeDebugPort,
|
||||
findChromeExecutable,
|
||||
findExistingChromeDebugPort,
|
||||
getFreePort,
|
||||
openPageSession,
|
||||
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();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function shellPathForPlatform(): string | null {
|
||||
if (process.platform === "win32") return null;
|
||||
return "/bin/bash";
|
||||
}
|
||||
|
||||
async function startFakeChromiumProcess(port: number): Promise<ChildProcess | null> {
|
||||
const shell = shellPathForPlatform();
|
||||
if (!shell) return null;
|
||||
|
||||
const child = spawn(
|
||||
shell,
|
||||
[
|
||||
"-lc",
|
||||
`exec -a chromium-mock ${JSON.stringify(process.execPath)} -e 'setInterval(() => {}, 1000)' -- --remote-debugging-port=${port}`,
|
||||
],
|
||||
{ stdio: "ignore" },
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
return child;
|
||||
}
|
||||
|
||||
async function stopProcess(child: ChildProcess | null): Promise<void> {
|
||||
if (!child) return;
|
||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
||||
|
||||
child.kill("SIGTERM");
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
|
||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
||||
await new Promise((resolve) => child.once("exit", 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("discoverRunningChromeDebugPort reads DevToolsActivePort from the provided user-data dir", async (t) => {
|
||||
const root = await makeTempDir("baoyu-cdp-user-data-");
|
||||
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 discoverRunningChromeDebugPort({
|
||||
userDataDirs: [root],
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
assert.deepEqual(found, {
|
||||
port,
|
||||
wsUrl: `ws://127.0.0.1:${port}/devtools/browser/demo`,
|
||||
});
|
||||
});
|
||||
|
||||
test("discoverRunningChromeDebugPort ignores unrelated debugging processes", async (t) => {
|
||||
if (process.platform === "win32") {
|
||||
t.skip("Process discovery fallback is not used on Windows.");
|
||||
return;
|
||||
}
|
||||
|
||||
const root = await makeTempDir("baoyu-cdp-user-data-");
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
|
||||
const port = await getFreePort();
|
||||
const server = await startDebugServer(port);
|
||||
t.after(() => closeServer(server));
|
||||
|
||||
const fakeChromium = await startFakeChromiumProcess(port);
|
||||
t.after(async () => { await stopProcess(fakeChromium); });
|
||||
|
||||
const found = await discoverRunningChromeDebugPort({
|
||||
userDataDirs: [root],
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
assert.equal(found, null);
|
||||
});
|
||||
|
||||
test("openPageSession reports whether it created a new target", async () => {
|
||||
const calls: string[] = [];
|
||||
const cdpExisting = {
|
||||
send: async <T>(method: string): Promise<T> => {
|
||||
calls.push(method);
|
||||
if (method === "Target.getTargets") {
|
||||
return {
|
||||
targetInfos: [{ targetId: "existing-target", type: "page", url: "https://gemini.google.com/app" }],
|
||||
} as T;
|
||||
}
|
||||
if (method === "Target.attachToTarget") return { sessionId: "session-existing" } as T;
|
||||
throw new Error(`Unexpected method: ${method}`);
|
||||
},
|
||||
};
|
||||
|
||||
const existing = await openPageSession({
|
||||
cdp: cdpExisting as never,
|
||||
reusing: false,
|
||||
url: "https://gemini.google.com/app",
|
||||
matchTarget: (target) => target.url.includes("gemini.google.com"),
|
||||
activateTarget: false,
|
||||
});
|
||||
|
||||
assert.deepEqual(existing, {
|
||||
sessionId: "session-existing",
|
||||
targetId: "existing-target",
|
||||
createdTarget: false,
|
||||
});
|
||||
assert.deepEqual(calls, ["Target.getTargets", "Target.attachToTarget"]);
|
||||
|
||||
const createCalls: string[] = [];
|
||||
const cdpCreated = {
|
||||
send: async <T>(method: string): Promise<T> => {
|
||||
createCalls.push(method);
|
||||
if (method === "Target.getTargets") return { targetInfos: [] } as T;
|
||||
if (method === "Target.createTarget") return { targetId: "created-target" } as T;
|
||||
if (method === "Target.attachToTarget") return { sessionId: "session-created" } as T;
|
||||
throw new Error(`Unexpected method: ${method}`);
|
||||
},
|
||||
};
|
||||
|
||||
const created = await openPageSession({
|
||||
cdp: cdpCreated as never,
|
||||
reusing: false,
|
||||
url: "https://gemini.google.com/app",
|
||||
matchTarget: (target) => target.url.includes("gemini.google.com"),
|
||||
activateTarget: false,
|
||||
});
|
||||
|
||||
assert.deepEqual(created, {
|
||||
sessionId: "session-created",
|
||||
targetId: "created-target",
|
||||
createdTarget: true,
|
||||
});
|
||||
assert.deepEqual(createCalls, ["Target.getTargets", "Target.createTarget", "Target.attachToTarget"]);
|
||||
});
|
||||
|
||||
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,19 @@ type FindExistingChromeDebugPortOptions = {
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
export type ChromeChannel = "stable" | "beta" | "canary" | "dev";
|
||||
|
||||
export type DiscoveredChrome = {
|
||||
port: number;
|
||||
wsUrl: string;
|
||||
};
|
||||
|
||||
type DiscoverRunningChromeOptions = {
|
||||
channels?: ChromeChannel[];
|
||||
userDataDirs?: string[];
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
type LaunchChromeOptions = {
|
||||
chromePath: string;
|
||||
profileDir: string;
|
||||
@@ -73,6 +86,7 @@ type OpenPageSessionOptions = {
|
||||
export type PageSession = {
|
||||
sessionId: string;
|
||||
targetId: string;
|
||||
createdTarget: boolean;
|
||||
};
|
||||
|
||||
export function sleep(ms: number): Promise<void> {
|
||||
@@ -173,16 +187,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 +234,85 @@ 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 = (options.userDataDirs ?? getDefaultChromeUserDataDirs(channels))
|
||||
.map((dir) => path.resolve(dir));
|
||||
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=") &&
|
||||
userDataDirs.some((dir) => line.includes(dir))
|
||||
);
|
||||
|
||||
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,
|
||||
@@ -376,10 +485,12 @@ export function killChrome(chrome: ChildProcess): void {
|
||||
|
||||
export async function openPageSession(options: OpenPageSessionOptions): Promise<PageSession> {
|
||||
let targetId: string;
|
||||
let createdTarget = false;
|
||||
|
||||
if (options.reusing) {
|
||||
const created = await options.cdp.send<{ targetId: string }>("Target.createTarget", { url: options.url });
|
||||
targetId = created.targetId;
|
||||
createdTarget = true;
|
||||
} else {
|
||||
const targets = await options.cdp.send<{ targetInfos: ChromeTargetInfo[] }>("Target.getTargets");
|
||||
const existing = targets.targetInfos.find(options.matchTarget);
|
||||
@@ -388,6 +499,7 @@ export async function openPageSession(options: OpenPageSessionOptions): Promise<
|
||||
} else {
|
||||
const created = await options.cdp.send<{ targetId: string }>("Target.createTarget", { url: options.url });
|
||||
targetId = created.targetId;
|
||||
createdTarget = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -404,5 +516,5 @@ export async function openPageSession(options: OpenPageSessionOptions): Promise<
|
||||
if (options.enableDom) await options.cdp.send("DOM.enable", {}, { sessionId });
|
||||
if (options.enableNetwork) await options.cdp.send("Network.enable", {}, { sessionId });
|
||||
|
||||
return { sessionId, targetId };
|
||||
return { sessionId, targetId, createdTarget };
|
||||
}
|
||||
|
||||
@@ -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 = `
|
||||

|
||||
## “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(
|
||||
`\n\nText\n\n`,
|
||||
"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`b<\/code>/);
|
||||
});
|
||||
|
||||
test("emphasis with inline code containing backticks", () => {
|
||||
const html = render("*``a`b``*");
|
||||
assert.match(html, /<em[^>]*><code[^>]*>a`b<\/code><\/em>/);
|
||||
});
|
||||
|
||||
test("bold with inline code containing consecutive backticks", () => {
|
||||
const html = render("**```a``b```**");
|
||||
assert.match(html, /<code[^>]*>a``b<\/code>/);
|
||||
});
|
||||
|
||||
test("bold with inline code containing only backticks", () => {
|
||||
const html = render("**```` `` ````**");
|
||||
assert.match(html, /<code[^>]*>``<\/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);
|
||||
|
||||
+307
@@ -0,0 +1,307 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
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 {
|
||||
discoverRunningChromeDebugPort,
|
||||
findChromeExecutable,
|
||||
findExistingChromeDebugPort,
|
||||
getFreePort,
|
||||
openPageSession,
|
||||
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();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function shellPathForPlatform(): string | null {
|
||||
if (process.platform === "win32") return null;
|
||||
return "/bin/bash";
|
||||
}
|
||||
|
||||
async function startFakeChromiumProcess(port: number): Promise<ChildProcess | null> {
|
||||
const shell = shellPathForPlatform();
|
||||
if (!shell) return null;
|
||||
|
||||
const child = spawn(
|
||||
shell,
|
||||
[
|
||||
"-lc",
|
||||
`exec -a chromium-mock ${JSON.stringify(process.execPath)} -e 'setInterval(() => {}, 1000)' -- --remote-debugging-port=${port}`,
|
||||
],
|
||||
{ stdio: "ignore" },
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
return child;
|
||||
}
|
||||
|
||||
async function stopProcess(child: ChildProcess | null): Promise<void> {
|
||||
if (!child) return;
|
||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
||||
|
||||
child.kill("SIGTERM");
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
|
||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
||||
await new Promise((resolve) => child.once("exit", 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("discoverRunningChromeDebugPort reads DevToolsActivePort from the provided user-data dir", async (t) => {
|
||||
const root = await makeTempDir("baoyu-cdp-user-data-");
|
||||
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 discoverRunningChromeDebugPort({
|
||||
userDataDirs: [root],
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
assert.deepEqual(found, {
|
||||
port,
|
||||
wsUrl: `ws://127.0.0.1:${port}/devtools/browser/demo`,
|
||||
});
|
||||
});
|
||||
|
||||
test("discoverRunningChromeDebugPort ignores unrelated debugging processes", async (t) => {
|
||||
if (process.platform === "win32") {
|
||||
t.skip("Process discovery fallback is not used on Windows.");
|
||||
return;
|
||||
}
|
||||
|
||||
const root = await makeTempDir("baoyu-cdp-user-data-");
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
|
||||
const port = await getFreePort();
|
||||
const server = await startDebugServer(port);
|
||||
t.after(() => closeServer(server));
|
||||
|
||||
const fakeChromium = await startFakeChromiumProcess(port);
|
||||
t.after(async () => { await stopProcess(fakeChromium); });
|
||||
|
||||
const found = await discoverRunningChromeDebugPort({
|
||||
userDataDirs: [root],
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
assert.equal(found, null);
|
||||
});
|
||||
|
||||
test("openPageSession reports whether it created a new target", async () => {
|
||||
const calls: string[] = [];
|
||||
const cdpExisting = {
|
||||
send: async <T>(method: string): Promise<T> => {
|
||||
calls.push(method);
|
||||
if (method === "Target.getTargets") {
|
||||
return {
|
||||
targetInfos: [{ targetId: "existing-target", type: "page", url: "https://gemini.google.com/app" }],
|
||||
} as T;
|
||||
}
|
||||
if (method === "Target.attachToTarget") return { sessionId: "session-existing" } as T;
|
||||
throw new Error(`Unexpected method: ${method}`);
|
||||
},
|
||||
};
|
||||
|
||||
const existing = await openPageSession({
|
||||
cdp: cdpExisting as never,
|
||||
reusing: false,
|
||||
url: "https://gemini.google.com/app",
|
||||
matchTarget: (target) => target.url.includes("gemini.google.com"),
|
||||
activateTarget: false,
|
||||
});
|
||||
|
||||
assert.deepEqual(existing, {
|
||||
sessionId: "session-existing",
|
||||
targetId: "existing-target",
|
||||
createdTarget: false,
|
||||
});
|
||||
assert.deepEqual(calls, ["Target.getTargets", "Target.attachToTarget"]);
|
||||
|
||||
const createCalls: string[] = [];
|
||||
const cdpCreated = {
|
||||
send: async <T>(method: string): Promise<T> => {
|
||||
createCalls.push(method);
|
||||
if (method === "Target.getTargets") return { targetInfos: [] } as T;
|
||||
if (method === "Target.createTarget") return { targetId: "created-target" } as T;
|
||||
if (method === "Target.attachToTarget") return { sessionId: "session-created" } as T;
|
||||
throw new Error(`Unexpected method: ${method}`);
|
||||
},
|
||||
};
|
||||
|
||||
const created = await openPageSession({
|
||||
cdp: cdpCreated as never,
|
||||
reusing: false,
|
||||
url: "https://gemini.google.com/app",
|
||||
matchTarget: (target) => target.url.includes("gemini.google.com"),
|
||||
activateTarget: false,
|
||||
});
|
||||
|
||||
assert.deepEqual(created, {
|
||||
sessionId: "session-created",
|
||||
targetId: "created-target",
|
||||
createdTarget: true,
|
||||
});
|
||||
assert.deepEqual(createCalls, ["Target.getTargets", "Target.createTarget", "Target.attachToTarget"]);
|
||||
});
|
||||
|
||||
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,19 @@ type FindExistingChromeDebugPortOptions = {
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
export type ChromeChannel = "stable" | "beta" | "canary" | "dev";
|
||||
|
||||
export type DiscoveredChrome = {
|
||||
port: number;
|
||||
wsUrl: string;
|
||||
};
|
||||
|
||||
type DiscoverRunningChromeOptions = {
|
||||
channels?: ChromeChannel[];
|
||||
userDataDirs?: string[];
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
type LaunchChromeOptions = {
|
||||
chromePath: string;
|
||||
profileDir: string;
|
||||
@@ -73,6 +86,7 @@ type OpenPageSessionOptions = {
|
||||
export type PageSession = {
|
||||
sessionId: string;
|
||||
targetId: string;
|
||||
createdTarget: boolean;
|
||||
};
|
||||
|
||||
export function sleep(ms: number): Promise<void> {
|
||||
@@ -173,16 +187,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 +234,85 @@ 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 = (options.userDataDirs ?? getDefaultChromeUserDataDirs(channels))
|
||||
.map((dir) => path.resolve(dir));
|
||||
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=") &&
|
||||
userDataDirs.some((dir) => line.includes(dir))
|
||||
);
|
||||
|
||||
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,
|
||||
@@ -376,10 +485,12 @@ export function killChrome(chrome: ChildProcess): void {
|
||||
|
||||
export async function openPageSession(options: OpenPageSessionOptions): Promise<PageSession> {
|
||||
let targetId: string;
|
||||
let createdTarget = false;
|
||||
|
||||
if (options.reusing) {
|
||||
const created = await options.cdp.send<{ targetId: string }>("Target.createTarget", { url: options.url });
|
||||
targetId = created.targetId;
|
||||
createdTarget = true;
|
||||
} else {
|
||||
const targets = await options.cdp.send<{ targetInfos: ChromeTargetInfo[] }>("Target.getTargets");
|
||||
const existing = targets.targetInfos.find(options.matchTarget);
|
||||
@@ -388,6 +499,7 @@ export async function openPageSession(options: OpenPageSessionOptions): Promise<
|
||||
} else {
|
||||
const created = await options.cdp.send<{ targetId: string }>("Target.createTarget", { url: options.url });
|
||||
targetId = created.targetId;
|
||||
createdTarget = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -404,5 +516,5 @@ export async function openPageSession(options: OpenPageSessionOptions): Promise<
|
||||
if (options.enableDom) await options.cdp.send("DOM.enable", {}, { sessionId });
|
||||
if (options.enableNetwork) await options.cdp.send("Network.enable", {}, { sessionId });
|
||||
|
||||
return { sessionId, targetId };
|
||||
return { sessionId, targetId, createdTarget };
|
||||
}
|
||||
|
||||
@@ -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 = `
|
||||

|
||||
## “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(
|
||||
`\n\nText\n\n`,
|
||||
"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`b<\/code>/);
|
||||
});
|
||||
|
||||
test("emphasis with inline code containing backticks", () => {
|
||||
const html = render("*``a`b``*");
|
||||
assert.match(html, /<em[^>]*><code[^>]*>a`b<\/code><\/em>/);
|
||||
});
|
||||
|
||||
test("bold with inline code containing consecutive backticks", () => {
|
||||
const html = render("**```a``b```**");
|
||||
assert.match(html, /<code[^>]*>a``b<\/code>/);
|
||||
});
|
||||
|
||||
test("bold with inline code containing only backticks", () => {
|
||||
const html = render("**```` `` ````**");
|
||||
assert.match(html, /<code[^>]*>``<\/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,307 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
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 {
|
||||
discoverRunningChromeDebugPort,
|
||||
findChromeExecutable,
|
||||
findExistingChromeDebugPort,
|
||||
getFreePort,
|
||||
openPageSession,
|
||||
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();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function shellPathForPlatform(): string | null {
|
||||
if (process.platform === "win32") return null;
|
||||
return "/bin/bash";
|
||||
}
|
||||
|
||||
async function startFakeChromiumProcess(port: number): Promise<ChildProcess | null> {
|
||||
const shell = shellPathForPlatform();
|
||||
if (!shell) return null;
|
||||
|
||||
const child = spawn(
|
||||
shell,
|
||||
[
|
||||
"-lc",
|
||||
`exec -a chromium-mock ${JSON.stringify(process.execPath)} -e 'setInterval(() => {}, 1000)' -- --remote-debugging-port=${port}`,
|
||||
],
|
||||
{ stdio: "ignore" },
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
return child;
|
||||
}
|
||||
|
||||
async function stopProcess(child: ChildProcess | null): Promise<void> {
|
||||
if (!child) return;
|
||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
||||
|
||||
child.kill("SIGTERM");
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
|
||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
||||
await new Promise((resolve) => child.once("exit", 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("discoverRunningChromeDebugPort reads DevToolsActivePort from the provided user-data dir", async (t) => {
|
||||
const root = await makeTempDir("baoyu-cdp-user-data-");
|
||||
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 discoverRunningChromeDebugPort({
|
||||
userDataDirs: [root],
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
assert.deepEqual(found, {
|
||||
port,
|
||||
wsUrl: `ws://127.0.0.1:${port}/devtools/browser/demo`,
|
||||
});
|
||||
});
|
||||
|
||||
test("discoverRunningChromeDebugPort ignores unrelated debugging processes", async (t) => {
|
||||
if (process.platform === "win32") {
|
||||
t.skip("Process discovery fallback is not used on Windows.");
|
||||
return;
|
||||
}
|
||||
|
||||
const root = await makeTempDir("baoyu-cdp-user-data-");
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
|
||||
const port = await getFreePort();
|
||||
const server = await startDebugServer(port);
|
||||
t.after(() => closeServer(server));
|
||||
|
||||
const fakeChromium = await startFakeChromiumProcess(port);
|
||||
t.after(async () => { await stopProcess(fakeChromium); });
|
||||
|
||||
const found = await discoverRunningChromeDebugPort({
|
||||
userDataDirs: [root],
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
assert.equal(found, null);
|
||||
});
|
||||
|
||||
test("openPageSession reports whether it created a new target", async () => {
|
||||
const calls: string[] = [];
|
||||
const cdpExisting = {
|
||||
send: async <T>(method: string): Promise<T> => {
|
||||
calls.push(method);
|
||||
if (method === "Target.getTargets") {
|
||||
return {
|
||||
targetInfos: [{ targetId: "existing-target", type: "page", url: "https://gemini.google.com/app" }],
|
||||
} as T;
|
||||
}
|
||||
if (method === "Target.attachToTarget") return { sessionId: "session-existing" } as T;
|
||||
throw new Error(`Unexpected method: ${method}`);
|
||||
},
|
||||
};
|
||||
|
||||
const existing = await openPageSession({
|
||||
cdp: cdpExisting as never,
|
||||
reusing: false,
|
||||
url: "https://gemini.google.com/app",
|
||||
matchTarget: (target) => target.url.includes("gemini.google.com"),
|
||||
activateTarget: false,
|
||||
});
|
||||
|
||||
assert.deepEqual(existing, {
|
||||
sessionId: "session-existing",
|
||||
targetId: "existing-target",
|
||||
createdTarget: false,
|
||||
});
|
||||
assert.deepEqual(calls, ["Target.getTargets", "Target.attachToTarget"]);
|
||||
|
||||
const createCalls: string[] = [];
|
||||
const cdpCreated = {
|
||||
send: async <T>(method: string): Promise<T> => {
|
||||
createCalls.push(method);
|
||||
if (method === "Target.getTargets") return { targetInfos: [] } as T;
|
||||
if (method === "Target.createTarget") return { targetId: "created-target" } as T;
|
||||
if (method === "Target.attachToTarget") return { sessionId: "session-created" } as T;
|
||||
throw new Error(`Unexpected method: ${method}`);
|
||||
},
|
||||
};
|
||||
|
||||
const created = await openPageSession({
|
||||
cdp: cdpCreated as never,
|
||||
reusing: false,
|
||||
url: "https://gemini.google.com/app",
|
||||
matchTarget: (target) => target.url.includes("gemini.google.com"),
|
||||
activateTarget: false,
|
||||
});
|
||||
|
||||
assert.deepEqual(created, {
|
||||
sessionId: "session-created",
|
||||
targetId: "created-target",
|
||||
createdTarget: true,
|
||||
});
|
||||
assert.deepEqual(createCalls, ["Target.getTargets", "Target.createTarget", "Target.attachToTarget"]);
|
||||
});
|
||||
|
||||
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,19 @@ type FindExistingChromeDebugPortOptions = {
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
export type ChromeChannel = "stable" | "beta" | "canary" | "dev";
|
||||
|
||||
export type DiscoveredChrome = {
|
||||
port: number;
|
||||
wsUrl: string;
|
||||
};
|
||||
|
||||
type DiscoverRunningChromeOptions = {
|
||||
channels?: ChromeChannel[];
|
||||
userDataDirs?: string[];
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
type LaunchChromeOptions = {
|
||||
chromePath: string;
|
||||
profileDir: string;
|
||||
@@ -73,6 +86,7 @@ type OpenPageSessionOptions = {
|
||||
export type PageSession = {
|
||||
sessionId: string;
|
||||
targetId: string;
|
||||
createdTarget: boolean;
|
||||
};
|
||||
|
||||
export function sleep(ms: number): Promise<void> {
|
||||
@@ -173,16 +187,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 +234,85 @@ 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 = (options.userDataDirs ?? getDefaultChromeUserDataDirs(channels))
|
||||
.map((dir) => path.resolve(dir));
|
||||
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=") &&
|
||||
userDataDirs.some((dir) => line.includes(dir))
|
||||
);
|
||||
|
||||
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,
|
||||
@@ -376,10 +485,12 @@ export function killChrome(chrome: ChildProcess): void {
|
||||
|
||||
export async function openPageSession(options: OpenPageSessionOptions): Promise<PageSession> {
|
||||
let targetId: string;
|
||||
let createdTarget = false;
|
||||
|
||||
if (options.reusing) {
|
||||
const created = await options.cdp.send<{ targetId: string }>("Target.createTarget", { url: options.url });
|
||||
targetId = created.targetId;
|
||||
createdTarget = true;
|
||||
} else {
|
||||
const targets = await options.cdp.send<{ targetInfos: ChromeTargetInfo[] }>("Target.getTargets");
|
||||
const existing = targets.targetInfos.find(options.matchTarget);
|
||||
@@ -388,6 +499,7 @@ export async function openPageSession(options: OpenPageSessionOptions): Promise<
|
||||
} else {
|
||||
const created = await options.cdp.send<{ targetId: string }>("Target.createTarget", { url: options.url });
|
||||
targetId = created.targetId;
|
||||
createdTarget = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -404,5 +516,5 @@ export async function openPageSession(options: OpenPageSessionOptions): Promise<
|
||||
if (options.enableDom) await options.cdp.send("DOM.enable", {}, { sessionId });
|
||||
if (options.enableNetwork) await options.cdp.send("Network.enable", {}, { sessionId });
|
||||
|
||||
return { sessionId, targetId };
|
||||
return { sessionId, targetId, createdTarget };
|
||||
}
|
||||
|
||||
@@ -21,7 +21,8 @@ Scripts in `scripts/` subdirectory. `{baseDir}` = this SKILL.md's directory path
|
||||
|
||||
| Script | Purpose |
|
||||
|--------|---------|
|
||||
| `scripts/chunk.ts` | Split markdown into chunks by AST blocks (sections, headings, paragraphs), with line/word fallback for oversized blocks. Use `--output-dir <dir>` to write chunks into `<dir>/chunks/` instead of `<source-dir>/chunks/` |
|
||||
| `scripts/main.ts` | CLI entry point. Default action splits markdown into chunks; also supports explicit `chunk` subcommand |
|
||||
| `scripts/chunk.ts` | Markdown chunking implementation used by `main.ts` and kept compatible for direct invocation |
|
||||
|
||||
## Preferences (EXTEND.md)
|
||||
|
||||
@@ -183,8 +184,8 @@ Before translating chunks:
|
||||
|
||||
1. **Extract terminology**: Scan entire document for proper nouns, technical terms, recurring phrases
|
||||
2. **Build session glossary**: Merge extracted terms with loaded glossaries, establish consistent translations
|
||||
3. **Split into chunks**: Use `${BUN_X} {baseDir}/scripts/chunk.ts <file> [--max-words <chunk_max_words>] [--output-dir <output-dir>]`
|
||||
- Parses markdown AST (headings, paragraphs, lists, code blocks, tables, etc.)
|
||||
3. **Split into chunks**: Use `${BUN_X} {baseDir}/scripts/main.ts <file> [--max-words <chunk_max_words>] [--output-dir <output-dir>]`
|
||||
- Parses markdown blocks (headings, paragraphs, lists, code blocks, tables, etc.)
|
||||
- Splits at markdown block boundaries to preserve structure
|
||||
- If a single block exceeds the threshold, falls back to line splitting, then word splitting
|
||||
4. **Assemble translation prompt**:
|
||||
|
||||
@@ -2,160 +2,25 @@
|
||||
"lockfileVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "baoyu-translate-chunk",
|
||||
"dependencies": {
|
||||
"remark-frontmatter": "^5.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-parse": "^11.0.0",
|
||||
"remark-stringify": "^11.0.0",
|
||||
"unified": "^11.0.5",
|
||||
"markdown-it": "14.1.1",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
|
||||
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||
|
||||
"@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
|
||||
"entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
||||
|
||||
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
|
||||
"linkify-it": ["linkify-it@5.0.0", "", { "dependencies": { "uc.micro": "^2.0.0" } }, "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ=="],
|
||||
|
||||
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
|
||||
"markdown-it": ["markdown-it@14.1.1", "", { "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", "linkify-it": "^5.0.0", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" }, "bin": { "markdown-it": "bin/markdown-it.mjs" } }, "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA=="],
|
||||
|
||||
"bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
|
||||
"mdurl": ["mdurl@2.0.0", "", {}, "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w=="],
|
||||
|
||||
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
|
||||
"punycode.js": ["punycode.js@2.3.1", "", {}, "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA=="],
|
||||
|
||||
"character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="],
|
||||
|
||||
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
|
||||
|
||||
"devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
|
||||
|
||||
"escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
|
||||
|
||||
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
|
||||
|
||||
"fault": ["fault@2.0.1", "", { "dependencies": { "format": "^0.2.0" } }, "sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ=="],
|
||||
|
||||
"format": ["format@0.2.2", "", {}, "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww=="],
|
||||
|
||||
"is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
|
||||
|
||||
"longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="],
|
||||
|
||||
"markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="],
|
||||
|
||||
"mdast-util-find-and-replace": ["mdast-util-find-and-replace@3.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="],
|
||||
|
||||
"mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.3", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q=="],
|
||||
|
||||
"mdast-util-frontmatter": ["mdast-util-frontmatter@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "escape-string-regexp": "^5.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "micromark-extension-frontmatter": "^2.0.0" } }, "sha512-LRqI9+wdgC25P0URIJY9vwocIzCcksduHQ9OF2joxQoyTNVduwLAFUzjoopuRJbJAReaKrNQKAZKL3uCMugWJA=="],
|
||||
|
||||
"mdast-util-gfm": ["mdast-util-gfm@3.1.0", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-gfm-autolink-literal": "^2.0.0", "mdast-util-gfm-footnote": "^2.0.0", "mdast-util-gfm-strikethrough": "^2.0.0", "mdast-util-gfm-table": "^2.0.0", "mdast-util-gfm-task-list-item": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ=="],
|
||||
|
||||
"mdast-util-gfm-autolink-literal": ["mdast-util-gfm-autolink-literal@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "ccount": "^2.0.0", "devlop": "^1.0.0", "mdast-util-find-and-replace": "^3.0.0", "micromark-util-character": "^2.0.0" } }, "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ=="],
|
||||
|
||||
"mdast-util-gfm-footnote": ["mdast-util-gfm-footnote@2.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0" } }, "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ=="],
|
||||
|
||||
"mdast-util-gfm-strikethrough": ["mdast-util-gfm-strikethrough@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg=="],
|
||||
|
||||
"mdast-util-gfm-table": ["mdast-util-gfm-table@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "markdown-table": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg=="],
|
||||
|
||||
"mdast-util-gfm-task-list-item": ["mdast-util-gfm-task-list-item@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ=="],
|
||||
|
||||
"mdast-util-phrasing": ["mdast-util-phrasing@4.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "unist-util-is": "^6.0.0" } }, "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w=="],
|
||||
|
||||
"mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA=="],
|
||||
|
||||
"mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="],
|
||||
|
||||
"micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="],
|
||||
|
||||
"micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="],
|
||||
|
||||
"micromark-extension-frontmatter": ["micromark-extension-frontmatter@2.0.0", "", { "dependencies": { "fault": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-C4AkuM3dA58cgZha7zVnuVxBhDsbttIMiytjgsM2XbHAB2faRVaHRle40558FBN+DJcrLNCoqG5mlrpdU4cRtg=="],
|
||||
|
||||
"micromark-extension-gfm": ["micromark-extension-gfm@3.0.0", "", { "dependencies": { "micromark-extension-gfm-autolink-literal": "^2.0.0", "micromark-extension-gfm-footnote": "^2.0.0", "micromark-extension-gfm-strikethrough": "^2.0.0", "micromark-extension-gfm-table": "^2.0.0", "micromark-extension-gfm-tagfilter": "^2.0.0", "micromark-extension-gfm-task-list-item": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w=="],
|
||||
|
||||
"micromark-extension-gfm-autolink-literal": ["micromark-extension-gfm-autolink-literal@2.1.0", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw=="],
|
||||
|
||||
"micromark-extension-gfm-footnote": ["micromark-extension-gfm-footnote@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw=="],
|
||||
|
||||
"micromark-extension-gfm-strikethrough": ["micromark-extension-gfm-strikethrough@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw=="],
|
||||
|
||||
"micromark-extension-gfm-table": ["micromark-extension-gfm-table@2.1.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg=="],
|
||||
|
||||
"micromark-extension-gfm-tagfilter": ["micromark-extension-gfm-tagfilter@2.0.0", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg=="],
|
||||
|
||||
"micromark-extension-gfm-task-list-item": ["micromark-extension-gfm-task-list-item@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw=="],
|
||||
|
||||
"micromark-factory-destination": ["micromark-factory-destination@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="],
|
||||
|
||||
"micromark-factory-label": ["micromark-factory-label@2.0.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg=="],
|
||||
|
||||
"micromark-factory-space": ["micromark-factory-space@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg=="],
|
||||
|
||||
"micromark-factory-title": ["micromark-factory-title@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw=="],
|
||||
|
||||
"micromark-factory-whitespace": ["micromark-factory-whitespace@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ=="],
|
||||
|
||||
"micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="],
|
||||
|
||||
"micromark-util-chunked": ["micromark-util-chunked@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA=="],
|
||||
|
||||
"micromark-util-classify-character": ["micromark-util-classify-character@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q=="],
|
||||
|
||||
"micromark-util-combine-extensions": ["micromark-util-combine-extensions@2.0.1", "", { "dependencies": { "micromark-util-chunked": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg=="],
|
||||
|
||||
"micromark-util-decode-numeric-character-reference": ["micromark-util-decode-numeric-character-reference@2.0.2", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw=="],
|
||||
|
||||
"micromark-util-decode-string": ["micromark-util-decode-string@2.0.1", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ=="],
|
||||
|
||||
"micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="],
|
||||
|
||||
"micromark-util-html-tag-name": ["micromark-util-html-tag-name@2.0.1", "", {}, "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA=="],
|
||||
|
||||
"micromark-util-normalize-identifier": ["micromark-util-normalize-identifier@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q=="],
|
||||
|
||||
"micromark-util-resolve-all": ["micromark-util-resolve-all@2.0.1", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg=="],
|
||||
|
||||
"micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="],
|
||||
|
||||
"micromark-util-subtokenize": ["micromark-util-subtokenize@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA=="],
|
||||
|
||||
"micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="],
|
||||
|
||||
"micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"remark-frontmatter": ["remark-frontmatter@5.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-frontmatter": "^2.0.0", "micromark-extension-frontmatter": "^2.0.0", "unified": "^11.0.0" } }, "sha512-XTFYvNASMe5iPN0719nPrdItC9aU0ssC4v14mH1BCi1u0n1gAocqcujWUrByftZTbLhRtiKRyjYTSIOcr69UVQ=="],
|
||||
|
||||
"remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="],
|
||||
|
||||
"remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="],
|
||||
|
||||
"remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="],
|
||||
|
||||
"trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="],
|
||||
|
||||
"unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="],
|
||||
|
||||
"unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="],
|
||||
|
||||
"unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="],
|
||||
|
||||
"unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="],
|
||||
|
||||
"unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="],
|
||||
|
||||
"vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
|
||||
|
||||
"vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
|
||||
|
||||
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
|
||||
"uc.micro": ["uc.micro@2.1.0", "", {}, "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A=="],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,137 +1,335 @@
|
||||
import { readFileSync, writeFileSync, mkdirSync } from "fs"
|
||||
import { basename, dirname, join } from "path"
|
||||
import { unified } from "unified"
|
||||
import remarkParse from "remark-parse"
|
||||
import remarkGfm from "remark-gfm"
|
||||
import remarkFrontmatter from "remark-frontmatter"
|
||||
import remarkStringify from "remark-stringify"
|
||||
import type { Root, Content } from "mdast"
|
||||
import { mkdirSync, readFileSync, writeFileSync } from "fs"
|
||||
import { dirname, join } from "path"
|
||||
import MarkdownIt from "markdown-it"
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
const file = args.find(a => !a.startsWith("--"))
|
||||
const maxWords = parseInt(args[args.indexOf("--max-words") + 1] || "5000")
|
||||
const outputDir = args.indexOf("--output-dir") !== -1 ? args[args.indexOf("--output-dir") + 1] : ""
|
||||
type BlockKind =
|
||||
| "heading"
|
||||
| "thematicBreak"
|
||||
| "html"
|
||||
| "code"
|
||||
| "flow"
|
||||
|
||||
if (!file) {
|
||||
console.error("Usage: chunk.ts <file> [--max-words 5000]")
|
||||
process.exit(1)
|
||||
interface Block {
|
||||
kind: BlockKind
|
||||
md: string
|
||||
words: number
|
||||
}
|
||||
|
||||
const content = readFileSync(file, "utf-8")
|
||||
interface Chunk {
|
||||
blocks: Block[]
|
||||
words: number
|
||||
}
|
||||
|
||||
const tree = unified()
|
||||
.use(remarkParse)
|
||||
.use(remarkGfm)
|
||||
.use(remarkFrontmatter, ["yaml"])
|
||||
.parse(content)
|
||||
export interface ChunkCliOptions {
|
||||
file: string
|
||||
maxWords: number
|
||||
outputDir: string
|
||||
}
|
||||
|
||||
const stringify = unified()
|
||||
.use(remarkStringify, { bullet: "-", emphasis: "*", strong: "*" })
|
||||
.use(remarkGfm)
|
||||
.use(remarkFrontmatter, ["yaml"])
|
||||
export interface ChunkResult {
|
||||
source: string
|
||||
chunks: number
|
||||
output_dir: string
|
||||
frontmatter: boolean
|
||||
words_per_chunk: number[]
|
||||
}
|
||||
|
||||
function nodeToMd(node: Content): string {
|
||||
const root: Root = { type: "root", children: [node] }
|
||||
return stringify.stringify(root).trim()
|
||||
const parser = new MarkdownIt({ html: true })
|
||||
|
||||
export function formatChunkUsage(command: string): string {
|
||||
return `Usage: ${command} <file> [--max-words 5000] [--output-dir <dir>]`
|
||||
}
|
||||
|
||||
export function runChunkCli(args: string[], command = "chunk.ts"): number {
|
||||
const parsed = parseChunkCliArgs(args)
|
||||
|
||||
if ("help" in parsed) {
|
||||
console.log(formatChunkUsage(command))
|
||||
return 0
|
||||
}
|
||||
|
||||
if ("error" in parsed) {
|
||||
console.error(parsed.error)
|
||||
console.error(formatChunkUsage(command))
|
||||
return 1
|
||||
}
|
||||
|
||||
const result = chunkMarkdownFile(parsed.file, {
|
||||
maxWords: parsed.maxWords,
|
||||
outputDir: parsed.outputDir,
|
||||
})
|
||||
|
||||
console.log(JSON.stringify(result))
|
||||
return 0
|
||||
}
|
||||
|
||||
export function chunkMarkdownFile(
|
||||
file: string,
|
||||
options: { maxWords?: number; outputDir?: string } = {}
|
||||
): ChunkResult {
|
||||
const maxWords = options.maxWords ?? 5000
|
||||
const outputDir = options.outputDir ?? ""
|
||||
|
||||
const rawContent = normalizeNewlines(readFileSync(file, "utf-8"))
|
||||
const { frontmatter, body } = extractFrontmatter(rawContent)
|
||||
const chunks = buildChunks(parseMarkdown(body), maxWords)
|
||||
|
||||
const dir = outputDir ? join(outputDir, "chunks") : join(dirname(file), "chunks")
|
||||
mkdirSync(dir, { recursive: true })
|
||||
|
||||
if (frontmatter) {
|
||||
writeFileSync(join(dir, "frontmatter.md"), frontmatter)
|
||||
}
|
||||
|
||||
chunks.forEach((chunk, index) => {
|
||||
const num = String(index + 1).padStart(2, "0")
|
||||
writeFileSync(join(dir, `chunk-${num}.md`), chunk.blocks.map(block => block.md).join("\n\n"))
|
||||
})
|
||||
|
||||
return {
|
||||
source: file,
|
||||
chunks: chunks.length,
|
||||
output_dir: dir,
|
||||
frontmatter: Boolean(frontmatter),
|
||||
words_per_chunk: chunks.map(chunk => chunk.words),
|
||||
}
|
||||
}
|
||||
|
||||
function parseChunkCliArgs(args: string[]):
|
||||
| ChunkCliOptions
|
||||
| { help: true }
|
||||
| { error: string } {
|
||||
let file = ""
|
||||
let maxWords = 5000
|
||||
let outputDir = ""
|
||||
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index]
|
||||
|
||||
if (arg === "-h" || arg === "--help") {
|
||||
return { help: true }
|
||||
}
|
||||
|
||||
if (arg === "--max-words") {
|
||||
const value = args[index + 1]
|
||||
if (!value) return { error: "Missing value for --max-words" }
|
||||
maxWords = parsePositiveInt(value, 0)
|
||||
if (maxWords <= 0) return { error: `Invalid --max-words value: ${value}` }
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (arg === "--output-dir") {
|
||||
const value = args[index + 1]
|
||||
if (!value) return { error: "Missing value for --output-dir" }
|
||||
outputDir = value
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (arg.startsWith("-")) {
|
||||
return { error: `Unknown option: ${arg}` }
|
||||
}
|
||||
|
||||
if (!file) {
|
||||
file = arg
|
||||
continue
|
||||
}
|
||||
|
||||
return { error: `Unexpected positional argument: ${arg}` }
|
||||
}
|
||||
|
||||
if (!file) {
|
||||
return { error: "Missing input file" }
|
||||
}
|
||||
|
||||
return { file, maxWords, outputDir }
|
||||
}
|
||||
|
||||
function parsePositiveInt(value: string | undefined, fallback: number): number {
|
||||
if (!value) return fallback
|
||||
const parsed = Number.parseInt(value, 10)
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback
|
||||
}
|
||||
|
||||
function normalizeNewlines(text: string): string {
|
||||
return text.replace(/^\uFEFF/, "").replace(/\r\n?/g, "\n")
|
||||
}
|
||||
|
||||
function trimBoundaryBlankLines(text: string): string {
|
||||
return text.replace(/^\n+/, "").replace(/\n+$/, "")
|
||||
}
|
||||
|
||||
function extractFrontmatter(content: string): { frontmatter: string; body: string } {
|
||||
const lines = content.split("\n")
|
||||
if (lines[0] !== "---") {
|
||||
return { frontmatter: "", body: content }
|
||||
}
|
||||
|
||||
for (let index = 1; index < lines.length; index += 1) {
|
||||
if (lines[index] === "---" || lines[index] === "...") {
|
||||
return {
|
||||
frontmatter: lines.slice(0, index + 1).join("\n"),
|
||||
body: lines.slice(index + 1).join("\n").replace(/^\n+/, ""),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { frontmatter: "", body: content }
|
||||
}
|
||||
|
||||
function parseMarkdown(content: string): Block[] {
|
||||
if (!content.trim()) return []
|
||||
|
||||
const lines = content.split("\n")
|
||||
const tokens = parser.parse(content, {})
|
||||
const blocks: Block[] = []
|
||||
|
||||
for (const token of tokens) {
|
||||
if (!token.map || token.level !== 0) continue
|
||||
if (token.nesting !== 1 && token.nesting !== 0) continue
|
||||
|
||||
const [startLine, endLine] = token.map
|
||||
const md = trimBoundaryBlankLines(lines.slice(startLine, endLine).join("\n"))
|
||||
if (!md) continue
|
||||
|
||||
blocks.push(makeBlock(tokenTypeToBlockKind(token.type), md))
|
||||
}
|
||||
|
||||
if (blocks.length === 0) {
|
||||
const body = trimBoundaryBlankLines(content)
|
||||
if (body) {
|
||||
blocks.push(makeBlock("flow", body))
|
||||
}
|
||||
}
|
||||
|
||||
return blocks
|
||||
}
|
||||
|
||||
function tokenTypeToBlockKind(tokenType: string): BlockKind {
|
||||
if (tokenType === "heading_open") return "heading"
|
||||
if (tokenType === "hr") return "thematicBreak"
|
||||
if (tokenType === "html_block") return "html"
|
||||
if (tokenType === "fence" || tokenType === "code_block") return "code"
|
||||
return "flow"
|
||||
}
|
||||
|
||||
function makeBlock(kind: BlockKind, md: string): Block {
|
||||
return {
|
||||
kind,
|
||||
md: trimBoundaryBlankLines(md),
|
||||
words: countWords(md),
|
||||
}
|
||||
}
|
||||
|
||||
function buildChunks(blocks: Block[], maxWordsPerChunk: number): Chunk[] {
|
||||
const sections = splitIntoSections(blocks)
|
||||
const normalizedBlocks: Block[] = []
|
||||
|
||||
for (const section of sections) {
|
||||
const sectionWords = section.reduce((sum, block) => sum + block.words, 0)
|
||||
if (sectionWords <= maxWordsPerChunk) {
|
||||
normalizedBlocks.push(makeBlock("flow", section.map(block => block.md).join("\n\n")))
|
||||
continue
|
||||
}
|
||||
|
||||
for (const block of section) {
|
||||
normalizedBlocks.push(...splitOversizedBlock(block, maxWordsPerChunk))
|
||||
}
|
||||
}
|
||||
|
||||
const chunks: Chunk[] = []
|
||||
let currentBlocks: Block[] = []
|
||||
let currentWords = 0
|
||||
|
||||
for (const block of normalizedBlocks) {
|
||||
if (currentWords + block.words > maxWordsPerChunk && currentBlocks.length > 0) {
|
||||
chunks.push({ blocks: currentBlocks, words: currentWords })
|
||||
currentBlocks = [block]
|
||||
currentWords = block.words
|
||||
continue
|
||||
}
|
||||
|
||||
currentBlocks.push(block)
|
||||
currentWords += block.words
|
||||
}
|
||||
|
||||
if (currentBlocks.length > 0) {
|
||||
chunks.push({ blocks: currentBlocks, words: currentWords })
|
||||
}
|
||||
|
||||
return chunks
|
||||
}
|
||||
|
||||
function splitIntoSections(blocks: Block[]): Block[][] {
|
||||
const sections: Block[][] = []
|
||||
let current: Block[] = []
|
||||
|
||||
for (const block of blocks) {
|
||||
if (block.kind === "heading" && current.length > 0) {
|
||||
sections.push(current)
|
||||
current = [block]
|
||||
continue
|
||||
}
|
||||
|
||||
current.push(block)
|
||||
}
|
||||
|
||||
if (current.length > 0) {
|
||||
sections.push(current)
|
||||
}
|
||||
|
||||
return sections
|
||||
}
|
||||
|
||||
function splitOversizedBlock(block: Block, maxWordsPerChunk: number): Block[] {
|
||||
if (block.words <= maxWordsPerChunk) return [block]
|
||||
|
||||
if (
|
||||
block.kind === "heading"
|
||||
|| block.kind === "thematicBreak"
|
||||
|| block.kind === "html"
|
||||
|| block.kind === "code"
|
||||
) {
|
||||
return [block]
|
||||
}
|
||||
|
||||
const lines = block.md.split("\n")
|
||||
if (lines.length <= 1) {
|
||||
return [block]
|
||||
}
|
||||
|
||||
const splitBlocks: Block[] = []
|
||||
let buffer: string[] = []
|
||||
let bufferWords = 0
|
||||
|
||||
for (const line of lines) {
|
||||
const lineWords = countWords(line)
|
||||
if (bufferWords + lineWords > maxWordsPerChunk && buffer.length > 0) {
|
||||
splitBlocks.push(makeBlock(block.kind, buffer.join("\n")))
|
||||
buffer = [line]
|
||||
bufferWords = lineWords
|
||||
continue
|
||||
}
|
||||
|
||||
buffer.push(line)
|
||||
bufferWords += lineWords
|
||||
}
|
||||
|
||||
if (buffer.length > 0) {
|
||||
splitBlocks.push(makeBlock(block.kind, buffer.join("\n")))
|
||||
}
|
||||
|
||||
return splitBlocks
|
||||
}
|
||||
|
||||
function countWords(text: string): number {
|
||||
const cleaned = text.replace(/[#*`\[\]()>|_~-]/g, " ")
|
||||
const cjk = cleaned.match(/[\u4e00-\u9fff\u3400-\u4dbf\uf900-\ufaff]/g)
|
||||
const latin = cleaned.match(/[a-zA-Z0-9]+/g)
|
||||
return (cjk?.length || 0) + (latin?.length || 0)
|
||||
return (cjk?.length ?? 0) + (latin?.length ?? 0)
|
||||
}
|
||||
|
||||
interface Block {
|
||||
md: string
|
||||
words: number
|
||||
if (import.meta.main) {
|
||||
process.exit(runChunkCli(process.argv.slice(2), process.argv[1] ?? "chunk.ts"))
|
||||
}
|
||||
|
||||
function splitNodeToBlocks(node: Content): Block[] {
|
||||
const md = nodeToMd(node)
|
||||
const words = countWords(md)
|
||||
|
||||
if (words <= maxWords) return [{ md, words }]
|
||||
|
||||
if (node.type === "heading" || node.type === "thematicBreak" || node.type === "html") {
|
||||
return [{ md, words }]
|
||||
}
|
||||
|
||||
if ("children" in node && Array.isArray(node.children)) {
|
||||
const blocks: Block[] = []
|
||||
for (const child of node.children as Content[]) {
|
||||
blocks.push(...splitNodeToBlocks(child))
|
||||
}
|
||||
return blocks
|
||||
}
|
||||
|
||||
const lines = md.split("\n")
|
||||
if (lines.length > 1) {
|
||||
const blocks: Block[] = []
|
||||
let buf: string[] = []
|
||||
let bufWords = 0
|
||||
for (const line of lines) {
|
||||
const lw = countWords(line)
|
||||
if (bufWords + lw > maxWords && buf.length > 0) {
|
||||
blocks.push({ md: buf.join("\n"), words: bufWords })
|
||||
buf = [line]
|
||||
bufWords = lw
|
||||
} else {
|
||||
buf.push(line)
|
||||
bufWords += lw
|
||||
}
|
||||
}
|
||||
if (buf.length > 0) blocks.push({ md: buf.join("\n"), words: bufWords })
|
||||
return blocks
|
||||
}
|
||||
|
||||
return [{ md, words }]
|
||||
}
|
||||
|
||||
let frontmatter = ""
|
||||
const blocks: Block[] = []
|
||||
|
||||
for (const node of tree.children) {
|
||||
if (node.type === "yaml") {
|
||||
frontmatter = `---\n${node.value}\n---`
|
||||
continue
|
||||
}
|
||||
blocks.push(...splitNodeToBlocks(node as Content))
|
||||
}
|
||||
|
||||
const chunks: { blocks: Block[]; words: number }[] = []
|
||||
let cur: Block[] = []
|
||||
let curWords = 0
|
||||
|
||||
for (const b of blocks) {
|
||||
if (curWords + b.words > maxWords && cur.length > 0) {
|
||||
chunks.push({ blocks: cur, words: curWords })
|
||||
cur = [b]
|
||||
curWords = b.words
|
||||
} else {
|
||||
cur.push(b)
|
||||
curWords += b.words
|
||||
}
|
||||
}
|
||||
if (cur.length > 0) chunks.push({ blocks: cur, words: curWords })
|
||||
|
||||
const dir = outputDir ? join(outputDir, "chunks") : join(dirname(file), "chunks")
|
||||
mkdirSync(dir, { recursive: true })
|
||||
|
||||
if (frontmatter) {
|
||||
writeFileSync(join(dir, "frontmatter.md"), frontmatter)
|
||||
}
|
||||
|
||||
chunks.forEach((chunk, i) => {
|
||||
const num = String(i + 1).padStart(2, "0")
|
||||
const out = join(dir, `chunk-${num}.md`)
|
||||
writeFileSync(out, chunk.blocks.map(b => b.md).join("\n\n"))
|
||||
})
|
||||
|
||||
console.log(JSON.stringify({
|
||||
source: file,
|
||||
chunks: chunks.length,
|
||||
output_dir: dir,
|
||||
frontmatter: !!frontmatter,
|
||||
words_per_chunk: chunks.map(c => c.words)
|
||||
}))
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env bun
|
||||
import path from "node:path"
|
||||
import process from "node:process"
|
||||
import { runChunkCli } from "./chunk.js"
|
||||
|
||||
function formatScriptCommand(fallback: string): string {
|
||||
const raw = process.argv[1]
|
||||
const displayPath = raw
|
||||
? (() => {
|
||||
const relative = path.relative(process.cwd(), raw)
|
||||
return relative && !relative.startsWith("..") ? relative : raw
|
||||
})()
|
||||
: fallback
|
||||
|
||||
const quotedPath = displayPath.includes(" ")
|
||||
? `"${displayPath.replace(/"/g, '\\"')}"`
|
||||
: displayPath
|
||||
|
||||
return `npx -y bun ${quotedPath}`
|
||||
}
|
||||
|
||||
function printUsage(exitCode: number): never {
|
||||
const cmd = formatScriptCommand("scripts/main.ts")
|
||||
console.log(`Baoyu Translate CLI
|
||||
|
||||
Usage:
|
||||
${cmd} <file> [--max-words 5000] [--output-dir <dir>]
|
||||
${cmd} chunk <file> [--max-words 5000] [--output-dir <dir>]
|
||||
|
||||
Commands:
|
||||
chunk Split markdown into chunks
|
||||
|
||||
Options:
|
||||
--max-words <n> Maximum words per chunk (default: 5000)
|
||||
--output-dir <dir> Write chunks into <dir>/chunks/
|
||||
-h, --help Show help
|
||||
`)
|
||||
process.exit(exitCode)
|
||||
}
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
|
||||
if (args.length === 0) {
|
||||
printUsage(1)
|
||||
}
|
||||
|
||||
if (args[0] === "-h" || args[0] === "--help") {
|
||||
printUsage(0)
|
||||
}
|
||||
|
||||
if (args[0] === "chunk") {
|
||||
process.exit(runChunkCli(args.slice(1), `${formatScriptCommand("scripts/main.ts")} chunk`))
|
||||
}
|
||||
|
||||
process.exit(runChunkCli(args, formatScriptCommand("scripts/main.ts")))
|
||||
@@ -1,9 +1,7 @@
|
||||
{
|
||||
"name": "baoyu-translate-chunk",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"remark-frontmatter": "^5.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-parse": "^11.0.0",
|
||||
"remark-stringify": "^11.0.0",
|
||||
"unified": "^11.0.5"
|
||||
"markdown-it": "14.1.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+307
@@ -0,0 +1,307 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
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 {
|
||||
discoverRunningChromeDebugPort,
|
||||
findChromeExecutable,
|
||||
findExistingChromeDebugPort,
|
||||
getFreePort,
|
||||
openPageSession,
|
||||
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();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function shellPathForPlatform(): string | null {
|
||||
if (process.platform === "win32") return null;
|
||||
return "/bin/bash";
|
||||
}
|
||||
|
||||
async function startFakeChromiumProcess(port: number): Promise<ChildProcess | null> {
|
||||
const shell = shellPathForPlatform();
|
||||
if (!shell) return null;
|
||||
|
||||
const child = spawn(
|
||||
shell,
|
||||
[
|
||||
"-lc",
|
||||
`exec -a chromium-mock ${JSON.stringify(process.execPath)} -e 'setInterval(() => {}, 1000)' -- --remote-debugging-port=${port}`,
|
||||
],
|
||||
{ stdio: "ignore" },
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
return child;
|
||||
}
|
||||
|
||||
async function stopProcess(child: ChildProcess | null): Promise<void> {
|
||||
if (!child) return;
|
||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
||||
|
||||
child.kill("SIGTERM");
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
|
||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
||||
await new Promise((resolve) => child.once("exit", 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("discoverRunningChromeDebugPort reads DevToolsActivePort from the provided user-data dir", async (t) => {
|
||||
const root = await makeTempDir("baoyu-cdp-user-data-");
|
||||
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 discoverRunningChromeDebugPort({
|
||||
userDataDirs: [root],
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
assert.deepEqual(found, {
|
||||
port,
|
||||
wsUrl: `ws://127.0.0.1:${port}/devtools/browser/demo`,
|
||||
});
|
||||
});
|
||||
|
||||
test("discoverRunningChromeDebugPort ignores unrelated debugging processes", async (t) => {
|
||||
if (process.platform === "win32") {
|
||||
t.skip("Process discovery fallback is not used on Windows.");
|
||||
return;
|
||||
}
|
||||
|
||||
const root = await makeTempDir("baoyu-cdp-user-data-");
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
|
||||
const port = await getFreePort();
|
||||
const server = await startDebugServer(port);
|
||||
t.after(() => closeServer(server));
|
||||
|
||||
const fakeChromium = await startFakeChromiumProcess(port);
|
||||
t.after(async () => { await stopProcess(fakeChromium); });
|
||||
|
||||
const found = await discoverRunningChromeDebugPort({
|
||||
userDataDirs: [root],
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
assert.equal(found, null);
|
||||
});
|
||||
|
||||
test("openPageSession reports whether it created a new target", async () => {
|
||||
const calls: string[] = [];
|
||||
const cdpExisting = {
|
||||
send: async <T>(method: string): Promise<T> => {
|
||||
calls.push(method);
|
||||
if (method === "Target.getTargets") {
|
||||
return {
|
||||
targetInfos: [{ targetId: "existing-target", type: "page", url: "https://gemini.google.com/app" }],
|
||||
} as T;
|
||||
}
|
||||
if (method === "Target.attachToTarget") return { sessionId: "session-existing" } as T;
|
||||
throw new Error(`Unexpected method: ${method}`);
|
||||
},
|
||||
};
|
||||
|
||||
const existing = await openPageSession({
|
||||
cdp: cdpExisting as never,
|
||||
reusing: false,
|
||||
url: "https://gemini.google.com/app",
|
||||
matchTarget: (target) => target.url.includes("gemini.google.com"),
|
||||
activateTarget: false,
|
||||
});
|
||||
|
||||
assert.deepEqual(existing, {
|
||||
sessionId: "session-existing",
|
||||
targetId: "existing-target",
|
||||
createdTarget: false,
|
||||
});
|
||||
assert.deepEqual(calls, ["Target.getTargets", "Target.attachToTarget"]);
|
||||
|
||||
const createCalls: string[] = [];
|
||||
const cdpCreated = {
|
||||
send: async <T>(method: string): Promise<T> => {
|
||||
createCalls.push(method);
|
||||
if (method === "Target.getTargets") return { targetInfos: [] } as T;
|
||||
if (method === "Target.createTarget") return { targetId: "created-target" } as T;
|
||||
if (method === "Target.attachToTarget") return { sessionId: "session-created" } as T;
|
||||
throw new Error(`Unexpected method: ${method}`);
|
||||
},
|
||||
};
|
||||
|
||||
const created = await openPageSession({
|
||||
cdp: cdpCreated as never,
|
||||
reusing: false,
|
||||
url: "https://gemini.google.com/app",
|
||||
matchTarget: (target) => target.url.includes("gemini.google.com"),
|
||||
activateTarget: false,
|
||||
});
|
||||
|
||||
assert.deepEqual(created, {
|
||||
sessionId: "session-created",
|
||||
targetId: "created-target",
|
||||
createdTarget: true,
|
||||
});
|
||||
assert.deepEqual(createCalls, ["Target.getTargets", "Target.createTarget", "Target.attachToTarget"]);
|
||||
});
|
||||
|
||||
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`);
|
||||
});
|
||||
+120
-8
@@ -43,6 +43,19 @@ type FindExistingChromeDebugPortOptions = {
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
export type ChromeChannel = "stable" | "beta" | "canary" | "dev";
|
||||
|
||||
export type DiscoveredChrome = {
|
||||
port: number;
|
||||
wsUrl: string;
|
||||
};
|
||||
|
||||
type DiscoverRunningChromeOptions = {
|
||||
channels?: ChromeChannel[];
|
||||
userDataDirs?: string[];
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
type LaunchChromeOptions = {
|
||||
chromePath: string;
|
||||
profileDir: string;
|
||||
@@ -73,6 +86,7 @@ type OpenPageSessionOptions = {
|
||||
export type PageSession = {
|
||||
sessionId: string;
|
||||
targetId: string;
|
||||
createdTarget: boolean;
|
||||
};
|
||||
|
||||
export function sleep(ms: number): Promise<void> {
|
||||
@@ -173,16 +187,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 +234,85 @@ 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 = (options.userDataDirs ?? getDefaultChromeUserDataDirs(channels))
|
||||
.map((dir) => path.resolve(dir));
|
||||
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=") &&
|
||||
userDataDirs.some((dir) => line.includes(dir))
|
||||
);
|
||||
|
||||
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,
|
||||
@@ -376,10 +485,12 @@ export function killChrome(chrome: ChildProcess): void {
|
||||
|
||||
export async function openPageSession(options: OpenPageSessionOptions): Promise<PageSession> {
|
||||
let targetId: string;
|
||||
let createdTarget = false;
|
||||
|
||||
if (options.reusing) {
|
||||
const created = await options.cdp.send<{ targetId: string }>("Target.createTarget", { url: options.url });
|
||||
targetId = created.targetId;
|
||||
createdTarget = true;
|
||||
} else {
|
||||
const targets = await options.cdp.send<{ targetInfos: ChromeTargetInfo[] }>("Target.getTargets");
|
||||
const existing = targets.targetInfos.find(options.matchTarget);
|
||||
@@ -388,6 +499,7 @@ export async function openPageSession(options: OpenPageSessionOptions): Promise<
|
||||
} else {
|
||||
const created = await options.cdp.send<{ targetId: string }>("Target.createTarget", { url: options.url });
|
||||
targetId = created.targetId;
|
||||
createdTarget = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -404,5 +516,5 @@ export async function openPageSession(options: OpenPageSessionOptions): Promise<
|
||||
if (options.enableDom) await options.cdp.send("DOM.enable", {}, { sessionId });
|
||||
if (options.enableNetwork) await options.cdp.send("Network.enable", {}, { sessionId });
|
||||
|
||||
return { sessionId, targetId };
|
||||
return { sessionId, targetId, createdTarget };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user