mirror of
https://github.com/JimLiu/baoyu-skills.git
synced 2026-07-14 06:39:48 +08:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 004236682d | |||
| 7e07c1bb84 | |||
| 6f38724163 | |||
| 374a6b28fd | |||
| 95970480c8 | |||
| fc324319d8 | |||
| 056fa06c3b | |||
| c62cda4424 | |||
| 12b43e166d | |||
| c1e1526c84 | |||
| 0279fa403d | |||
| 994e47d1be | |||
| 6a71d5080d | |||
| 1e6e6637ac | |||
| 873b60aee5 | |||
| b88ac59133 | |||
| d889c04084 | |||
| 5276fae6bd | |||
| c0941f8089 | |||
| 42b8b1fc99 |
@@ -6,14 +6,14 @@
|
|||||||
},
|
},
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"description": "Skills shared by Baoyu for improving daily work efficiency",
|
"description": "Skills shared by Baoyu for improving daily work efficiency",
|
||||||
"version": "1.60.0"
|
"version": "1.65.1"
|
||||||
},
|
},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
{
|
{
|
||||||
"name": "content-skills",
|
"name": "content-skills",
|
||||||
"description": "Content generation and publishing skills",
|
"description": "Content generation and publishing skills",
|
||||||
"source": "./",
|
"source": "./",
|
||||||
"strict": false,
|
"strict": true,
|
||||||
"skills": [
|
"skills": [
|
||||||
"./skills/baoyu-xhs-images",
|
"./skills/baoyu-xhs-images",
|
||||||
"./skills/baoyu-post-to-x",
|
"./skills/baoyu-post-to-x",
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
"name": "ai-generation-skills",
|
"name": "ai-generation-skills",
|
||||||
"description": "AI-powered generation backends",
|
"description": "AI-powered generation backends",
|
||||||
"source": "./",
|
"source": "./",
|
||||||
"strict": false,
|
"strict": true,
|
||||||
"skills": [
|
"skills": [
|
||||||
"./skills/baoyu-danger-gemini-web",
|
"./skills/baoyu-danger-gemini-web",
|
||||||
"./skills/baoyu-image-gen"
|
"./skills/baoyu-image-gen"
|
||||||
@@ -40,7 +40,7 @@
|
|||||||
"name": "utility-skills",
|
"name": "utility-skills",
|
||||||
"description": "Utility tools for content processing",
|
"description": "Utility tools for content processing",
|
||||||
"source": "./",
|
"source": "./",
|
||||||
"strict": false,
|
"strict": true,
|
||||||
"skills": [
|
"skills": [
|
||||||
"./skills/baoyu-danger-x-to-markdown",
|
"./skills/baoyu-danger-x-to-markdown",
|
||||||
"./skills/baoyu-compress-image",
|
"./skills/baoyu-compress-image",
|
||||||
|
|||||||
@@ -2,6 +2,61 @@
|
|||||||
|
|
||||||
English | [中文](./CHANGELOG.zh.md)
|
English | [中文](./CHANGELOG.zh.md)
|
||||||
|
|
||||||
|
## 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
|
||||||
|
- `baoyu-post-to-wechat`: add placeholder image upload support with deduplication for markdown-embedded images
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
- `baoyu-post-to-wechat`: fix frontmatter parsing to allow leading whitespace and optional trailing newline
|
||||||
|
|
||||||
|
### Refactor
|
||||||
|
- `baoyu-post-to-wechat`: replace `renderMarkdownToHtml` with `renderMarkdownWithPlaceholders` for structured output
|
||||||
|
|
||||||
|
## 1.64.0 - 2026-03-13
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- `baoyu-image-gen`: add OpenRouter provider with support for image generation, reference images, and configurable models
|
||||||
|
|
||||||
|
## 1.63.0 - 2026-03-13
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- `baoyu-url-to-markdown`: add hosted `defuddle.md` API fallback when local browser capture fails
|
||||||
|
- `baoyu-url-to-markdown`: extract YouTube transcript/caption text into markdown output
|
||||||
|
- `baoyu-url-to-markdown`: materialize shadow DOM content for better web-component page conversion
|
||||||
|
- `baoyu-url-to-markdown`: include language hint in markdown front matter when available
|
||||||
|
|
||||||
|
### Refactor
|
||||||
|
- `baoyu-url-to-markdown`: split monolithic converter into defuddle, legacy, and shared modules
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
- Fix Claude Code marketplace repo casing in READMEs
|
||||||
|
|
||||||
|
## 1.62.0 - 2026-03-12
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- `baoyu-infographic`: support flexible aspect ratios with custom W:H values (e.g., 3:4, 4:3, 2.35:1) in addition to named presets
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
- Set strict mode on plugins to prevent duplicated slash commands
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
- `baoyu-post-to-wechat`: replace credential-like placeholders
|
||||||
|
|
||||||
|
## 1.61.0 - 2026-03-11
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- `baoyu-post-to-wechat`: add multi-account support with `--account` CLI arg, EXTEND.md accounts block, isolated Chrome profiles, and credential resolution chain
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
- Exclude `out/dist/build` dirs and `bun.lockb` from skill release files
|
||||||
|
- Use proper MIME types in skill publish to fix ClawhHub rejection
|
||||||
|
|
||||||
## 1.60.0 - 2026-03-11
|
## 1.60.0 - 2026-03-11
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|||||||
@@ -2,6 +2,61 @@
|
|||||||
|
|
||||||
[English](./CHANGELOG.md) | 中文
|
[English](./CHANGELOG.md) | 中文
|
||||||
|
|
||||||
|
## 1.65.1 - 2026-03-13
|
||||||
|
|
||||||
|
### 重构
|
||||||
|
- `baoyu-translate`:将 chunk 解析从 remark/unified 替换为 markdown-it,新增 main.ts CLI 入口
|
||||||
|
|
||||||
|
## 1.65.0 - 2026-03-13
|
||||||
|
|
||||||
|
### 新功能
|
||||||
|
- `baoyu-post-to-wechat`:新增占位符图片上传支持,自动去重 Markdown 内嵌图片
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
- `baoyu-post-to-wechat`:修复 frontmatter 解析,允许前导空白和可选的尾随换行
|
||||||
|
|
||||||
|
### 重构
|
||||||
|
- `baoyu-post-to-wechat`:将 `renderMarkdownToHtml` 重构为 `renderMarkdownWithPlaceholders`,输出结构化结果
|
||||||
|
|
||||||
|
## 1.64.0 - 2026-03-13
|
||||||
|
|
||||||
|
### 新功能
|
||||||
|
- `baoyu-image-gen`:新增 OpenRouter 服务商,支持图像生成、参考图和可配置模型
|
||||||
|
|
||||||
|
## 1.63.0 - 2026-03-13
|
||||||
|
|
||||||
|
### 新功能
|
||||||
|
- `baoyu-url-to-markdown`:本地浏览器抓取失败时自动回退到 `defuddle.md` 托管 API
|
||||||
|
- `baoyu-url-to-markdown`:将 YouTube 字幕/文字记录提取到 Markdown 输出中
|
||||||
|
- `baoyu-url-to-markdown`:转换前展开 Shadow DOM 内容,提升 Web Component 页面的转换质量
|
||||||
|
- `baoyu-url-to-markdown`:Markdown front matter 中包含语言标识(如有)
|
||||||
|
|
||||||
|
### 重构
|
||||||
|
- `baoyu-url-to-markdown`:将单体转换器拆分为 defuddle、legacy 和 shared 三个模块
|
||||||
|
|
||||||
|
### 文档
|
||||||
|
- 修复 README 中 Claude Code marketplace 仓库名大小写
|
||||||
|
|
||||||
|
## 1.62.0 - 2026-03-12
|
||||||
|
|
||||||
|
### 新功能
|
||||||
|
- `baoyu-infographic`:支持灵活宽高比,可使用自定义 W:H 值(如 3:4、4:3、2.35:1),同时保留预设名称
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
- 设置插件严格模式,防止重复注册斜杠命令
|
||||||
|
|
||||||
|
### 文档
|
||||||
|
- `baoyu-post-to-wechat`:替换类似凭证的占位符
|
||||||
|
|
||||||
|
## 1.61.0 - 2026-03-11
|
||||||
|
|
||||||
|
### 新功能
|
||||||
|
- `baoyu-post-to-wechat`:新增多账号支持,通过 `--account` 参数选择账号,EXTEND.md 支持 accounts 配置块,每个账号独立 Chrome 配置目录和凭证解析链
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
- 排除 `out/dist/build` 目录和 `bun.lockb` 文件,避免打包到技能发布文件中
|
||||||
|
- 修复技能发布时 MIME 类型不正确导致 ClawhHub 拒绝的问题
|
||||||
|
|
||||||
## 1.60.0 - 2026-03-11
|
## 1.60.0 - 2026-03-11
|
||||||
|
|
||||||
### 新功能
|
### 新功能
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# CLAUDE.md
|
# CLAUDE.md
|
||||||
|
|
||||||
Claude Code marketplace plugin providing AI-powered content generation skills. Version: **1.60.0**.
|
Claude Code marketplace plugin providing AI-powered content generation skills. Version: **1.65.1**.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
@@ -31,7 +31,7 @@ Execute: `${BUN_X} skills/<skill>/scripts/main.ts [options]`
|
|||||||
|
|
||||||
- **Bun**: TypeScript runtime (`bun` preferred, fallback `npx -y bun`)
|
- **Bun**: TypeScript runtime (`bun` preferred, fallback `npx -y bun`)
|
||||||
- **Chrome**: Required for CDP-based skills (gemini-web, post-to-x/wechat/weibo, url-to-markdown). All CDP skills share a single profile, override via `BAOYU_CHROME_PROFILE_DIR` env var. Platform paths: [docs/chrome-profile.md](docs/chrome-profile.md)
|
- **Chrome**: Required for CDP-based skills (gemini-web, post-to-x/wechat/weibo, url-to-markdown). All CDP skills share a single profile, override via `BAOYU_CHROME_PROFILE_DIR` env var. Platform paths: [docs/chrome-profile.md](docs/chrome-profile.md)
|
||||||
- **Image generation APIs**: `baoyu-image-gen` requires API key (OpenAI, Google, DashScope, or Replicate) configured in EXTEND.md
|
- **Image generation APIs**: `baoyu-image-gen` requires API key (OpenAI, Google, OpenRouter, DashScope, or Replicate) configured in EXTEND.md
|
||||||
- **Gemini Web auth**: Browser cookies (first run opens Chrome for login, `--login` to refresh)
|
- **Gemini Web auth**: Browser cookies (first run opens Chrome for login, `--login` to refresh)
|
||||||
|
|
||||||
## Security
|
## Security
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ Publishing to ClawHub releases the published skill under `MIT-0`, per ClawHub's
|
|||||||
Run the following command in Claude Code:
|
Run the following command in Claude Code:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
/plugin marketplace add jimliu/baoyu-skills
|
/plugin marketplace add JimLiu/baoyu-skills
|
||||||
```
|
```
|
||||||
|
|
||||||
### Install Skills
|
### Install Skills
|
||||||
@@ -169,8 +169,9 @@ Generate professional infographics with 20 layout types and 17 visual styles. An
|
|||||||
# Specify both
|
# Specify both
|
||||||
/baoyu-infographic path/to/content.md --layout funnel --style corporate-memphis
|
/baoyu-infographic path/to/content.md --layout funnel --style corporate-memphis
|
||||||
|
|
||||||
# With aspect ratio
|
# With aspect ratio (named preset or custom W:H)
|
||||||
/baoyu-infographic path/to/content.md --aspect portrait
|
/baoyu-infographic path/to/content.md --aspect portrait
|
||||||
|
/baoyu-infographic path/to/content.md --aspect 3:4
|
||||||
```
|
```
|
||||||
|
|
||||||
**Options**:
|
**Options**:
|
||||||
@@ -178,7 +179,7 @@ Generate professional infographics with 20 layout types and 17 visual styles. An
|
|||||||
|--------|-------------|
|
|--------|-------------|
|
||||||
| `--layout <name>` | Information layout (20 options) |
|
| `--layout <name>` | Information layout (20 options) |
|
||||||
| `--style <name>` | Visual style (17 options, default: craft-handmade) |
|
| `--style <name>` | Visual style (17 options, default: craft-handmade) |
|
||||||
| `--aspect <ratio>` | landscape (16:9), portrait (9:16), square (1:1) |
|
| `--aspect <ratio>` | Named: landscape (16:9), portrait (9:16), square (1:1). Custom: any W:H ratio (e.g., 3:4, 4:3, 2.35:1) |
|
||||||
| `--lang <code>` | Output language (en, zh, ja, etc.) |
|
| `--lang <code>` | Output language (en, zh, ja, etc.) |
|
||||||
|
|
||||||
**Layouts** (information structure):
|
**Layouts** (information structure):
|
||||||
@@ -581,6 +582,47 @@ To obtain credentials:
|
|||||||
|
|
||||||
**Browser Method** (no API setup needed): Requires Google Chrome. First run opens browser for QR code login (session preserved).
|
**Browser Method** (no API setup needed): Requires Google Chrome. First run opens browser for QR code login (session preserved).
|
||||||
|
|
||||||
|
**Multi-Account Support**: Manage multiple WeChat Official Accounts via `EXTEND.md`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p .baoyu-skills/baoyu-post-to-wechat
|
||||||
|
```
|
||||||
|
|
||||||
|
Create `.baoyu-skills/baoyu-post-to-wechat/EXTEND.md`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Global settings (shared across all accounts)
|
||||||
|
default_theme: default
|
||||||
|
default_color: blue
|
||||||
|
|
||||||
|
# Account list
|
||||||
|
accounts:
|
||||||
|
- name: My Tech Blog
|
||||||
|
alias: tech-blog
|
||||||
|
default: false
|
||||||
|
default_publish_method: api
|
||||||
|
default_author: Author Name
|
||||||
|
need_open_comment: 1
|
||||||
|
only_fans_can_comment: 0
|
||||||
|
app_id: your_wechat_app_id
|
||||||
|
app_secret: your_wechat_app_secret
|
||||||
|
- name: AI Newsletter
|
||||||
|
alias: ai-news
|
||||||
|
default_publish_method: browser
|
||||||
|
default_author: AI Newsletter
|
||||||
|
need_open_comment: 1
|
||||||
|
only_fans_can_comment: 0
|
||||||
|
```
|
||||||
|
|
||||||
|
| Accounts configured | Behavior |
|
||||||
|
|---------------------|----------|
|
||||||
|
| No `accounts` block | Single-account mode (backward compatible) |
|
||||||
|
| 1 account | Auto-select, no prompt |
|
||||||
|
| 2+ accounts | Prompt to select, or use `--account <alias>` |
|
||||||
|
| 1 account has `default: true` | Pre-selected as default |
|
||||||
|
|
||||||
|
Each account gets an isolated Chrome profile for independent login sessions (browser method). API credentials can be set inline in EXTEND.md or via `.env` with alias-prefixed keys (e.g., `WECHAT_TECH_BLOG_APP_ID`).
|
||||||
|
|
||||||
#### baoyu-post-to-weibo
|
#### baoyu-post-to-weibo
|
||||||
|
|
||||||
Post content to Weibo (微博). Supports regular posts with text, images, and videos, and headline articles (头条文章) with Markdown input. Uses real Chrome with CDP to bypass anti-automation.
|
Post content to Weibo (微博). Supports regular posts with text, images, and videos, and headline articles (头条文章) with Markdown input. Uses real Chrome with CDP to bypass anti-automation.
|
||||||
@@ -623,7 +665,7 @@ AI-powered generation backends.
|
|||||||
|
|
||||||
#### baoyu-image-gen
|
#### baoyu-image-gen
|
||||||
|
|
||||||
AI SDK-based image generation using official OpenAI, Google and DashScope (Aliyun Tongyi Wanxiang) 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), and Replicate APIs. Supports text-to-image, reference images, aspect ratios, and quality presets.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Basic generation (auto-detect provider)
|
# Basic generation (auto-detect provider)
|
||||||
@@ -638,10 +680,16 @@ AI SDK-based image generation using official OpenAI, Google and DashScope (Aliyu
|
|||||||
# Specific provider
|
# Specific provider
|
||||||
/baoyu-image-gen --prompt "A cat" --image cat.png --provider openai
|
/baoyu-image-gen --prompt "A cat" --image cat.png --provider openai
|
||||||
|
|
||||||
|
# OpenRouter
|
||||||
|
/baoyu-image-gen --prompt "A cat" --image cat.png --provider openrouter
|
||||||
|
|
||||||
# DashScope (Aliyun Tongyi Wanxiang)
|
# DashScope (Aliyun Tongyi Wanxiang)
|
||||||
/baoyu-image-gen --prompt "一只可爱的猫" --image cat.png --provider dashscope
|
/baoyu-image-gen --prompt "一只可爱的猫" --image cat.png --provider dashscope
|
||||||
|
|
||||||
# With reference images (Google multimodal only)
|
# Replicate
|
||||||
|
/baoyu-image-gen --prompt "A cat" --image cat.png --provider replicate
|
||||||
|
|
||||||
|
# With reference images (Google, OpenAI, OpenRouter, or Replicate)
|
||||||
/baoyu-image-gen --prompt "Make it blue" --image out.png --ref source.png
|
/baoyu-image-gen --prompt "Make it blue" --image out.png --ref source.png
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -651,25 +699,31 @@ AI SDK-based image generation using official OpenAI, Google and DashScope (Aliyu
|
|||||||
| `--prompt`, `-p` | Prompt text |
|
| `--prompt`, `-p` | Prompt text |
|
||||||
| `--promptfiles` | Read prompt from files (concatenated) |
|
| `--promptfiles` | Read prompt from files (concatenated) |
|
||||||
| `--image` | Output image path (required) |
|
| `--image` | Output image path (required) |
|
||||||
| `--provider` | `google`, `openai` or `dashscope` (default: google) |
|
| `--provider` | `google`, `openai`, `openrouter`, `dashscope` or `replicate` (default: auto-detect; prefers google) |
|
||||||
| `--model`, `-m` | Model ID |
|
| `--model`, `-m` | Model ID |
|
||||||
| `--ar` | Aspect ratio (e.g., `16:9`, `1:1`, `4:3`) |
|
| `--ar` | Aspect ratio (e.g., `16:9`, `1:1`, `4:3`) |
|
||||||
| `--size` | Size (e.g., `1024x1024`) |
|
| `--size` | Size (e.g., `1024x1024`) |
|
||||||
| `--quality` | `normal` or `2k` (default: normal) |
|
| `--quality` | `normal` or `2k` (default: `2k`) |
|
||||||
| `--ref` | Reference images (Google multimodal only) |
|
| `--ref` | Reference images (Google, OpenAI, OpenRouter or Replicate) |
|
||||||
|
|
||||||
**Environment Variables** (see [Environment Configuration](#environment-configuration) for setup):
|
**Environment Variables** (see [Environment Configuration](#environment-configuration) for setup):
|
||||||
| Variable | Description | Default |
|
| Variable | Description | Default |
|
||||||
|----------|-------------|---------|
|
|----------|-------------|---------|
|
||||||
| `OPENAI_API_KEY` | OpenAI API key | - |
|
| `OPENAI_API_KEY` | OpenAI API key | - |
|
||||||
|
| `OPENROUTER_API_KEY` | OpenRouter API key | - |
|
||||||
| `GOOGLE_API_KEY` | Google API key | - |
|
| `GOOGLE_API_KEY` | Google API key | - |
|
||||||
| `DASHSCOPE_API_KEY` | DashScope API key (Aliyun) | - |
|
| `DASHSCOPE_API_KEY` | DashScope API key (Aliyun) | - |
|
||||||
|
| `REPLICATE_API_TOKEN` | Replicate API token | - |
|
||||||
| `OPENAI_IMAGE_MODEL` | OpenAI model | `gpt-image-1.5` |
|
| `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` |
|
| `GOOGLE_IMAGE_MODEL` | Google model | `gemini-3-pro-image-preview` |
|
||||||
| `DASHSCOPE_IMAGE_MODEL` | DashScope model | `z-image-turbo` |
|
| `DASHSCOPE_IMAGE_MODEL` | DashScope model | `z-image-turbo` |
|
||||||
|
| `REPLICATE_IMAGE_MODEL` | Replicate model | `google/nano-banana-pro` |
|
||||||
| `OPENAI_BASE_URL` | Custom OpenAI endpoint | - |
|
| `OPENAI_BASE_URL` | Custom OpenAI endpoint | - |
|
||||||
|
| `OPENROUTER_BASE_URL` | Custom OpenRouter endpoint | `https://openrouter.ai/api/v1` |
|
||||||
| `GOOGLE_BASE_URL` | Custom Google endpoint | - |
|
| `GOOGLE_BASE_URL` | Custom Google endpoint | - |
|
||||||
| `DASHSCOPE_BASE_URL` | Custom DashScope endpoint | - |
|
| `DASHSCOPE_BASE_URL` | Custom DashScope endpoint | - |
|
||||||
|
| `REPLICATE_BASE_URL` | Custom Replicate endpoint | - |
|
||||||
|
|
||||||
**Provider Auto-Selection**:
|
**Provider Auto-Selection**:
|
||||||
1. If `--provider` specified → use it
|
1. If `--provider` specified → use it
|
||||||
@@ -916,6 +970,11 @@ OPENAI_API_KEY=sk-xxx
|
|||||||
OPENAI_IMAGE_MODEL=gpt-image-1.5
|
OPENAI_IMAGE_MODEL=gpt-image-1.5
|
||||||
# OPENAI_BASE_URL=https://api.openai.com/v1
|
# OPENAI_BASE_URL=https://api.openai.com/v1
|
||||||
|
|
||||||
|
# OpenRouter
|
||||||
|
OPENROUTER_API_KEY=sk-or-xxx
|
||||||
|
OPENROUTER_IMAGE_MODEL=google/gemini-3.1-flash-image-preview
|
||||||
|
# OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
|
||||||
|
|
||||||
# Google
|
# Google
|
||||||
GOOGLE_API_KEY=xxx
|
GOOGLE_API_KEY=xxx
|
||||||
GOOGLE_IMAGE_MODEL=gemini-3-pro-image-preview
|
GOOGLE_IMAGE_MODEL=gemini-3-pro-image-preview
|
||||||
@@ -925,6 +984,11 @@ GOOGLE_IMAGE_MODEL=gemini-3-pro-image-preview
|
|||||||
DASHSCOPE_API_KEY=sk-xxx
|
DASHSCOPE_API_KEY=sk-xxx
|
||||||
DASHSCOPE_IMAGE_MODEL=z-image-turbo
|
DASHSCOPE_IMAGE_MODEL=z-image-turbo
|
||||||
# DASHSCOPE_BASE_URL=https://dashscope.aliyuncs.com/api/v1
|
# 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
|
||||||
EOF
|
EOF
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+72
-8
@@ -43,7 +43,7 @@ clawhub install baoyu-markdown-to-html
|
|||||||
在 Claude Code 中运行:
|
在 Claude Code 中运行:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
/plugin marketplace add jimliu/baoyu-skills
|
/plugin marketplace add JimLiu/baoyu-skills
|
||||||
```
|
```
|
||||||
|
|
||||||
### 安装技能
|
### 安装技能
|
||||||
@@ -169,8 +169,9 @@ clawhub install baoyu-markdown-to-html
|
|||||||
# 同时指定布局和风格
|
# 同时指定布局和风格
|
||||||
/baoyu-infographic path/to/content.md --layout funnel --style corporate-memphis
|
/baoyu-infographic path/to/content.md --layout funnel --style corporate-memphis
|
||||||
|
|
||||||
# 指定比例
|
# 指定比例(预设名称或自定义 W:H)
|
||||||
/baoyu-infographic path/to/content.md --aspect portrait
|
/baoyu-infographic path/to/content.md --aspect portrait
|
||||||
|
/baoyu-infographic path/to/content.md --aspect 3:4
|
||||||
```
|
```
|
||||||
|
|
||||||
**选项**:
|
**选项**:
|
||||||
@@ -178,7 +179,7 @@ clawhub install baoyu-markdown-to-html
|
|||||||
|------|------|
|
|------|------|
|
||||||
| `--layout <name>` | 信息布局(20 种选项) |
|
| `--layout <name>` | 信息布局(20 种选项) |
|
||||||
| `--style <name>` | 视觉风格(17 种选项,默认:craft-handmade) |
|
| `--style <name>` | 视觉风格(17 种选项,默认:craft-handmade) |
|
||||||
| `--aspect <ratio>` | landscape (16:9)、portrait (9:16)、square (1:1) |
|
| `--aspect <ratio>` | 预设:landscape (16:9)、portrait (9:16)、square (1:1)。自定义:任意 W:H 比例(如 3:4、4:3、2.35:1) |
|
||||||
| `--lang <code>` | 输出语言(en、zh、ja 等) |
|
| `--lang <code>` | 输出语言(en、zh、ja 等) |
|
||||||
|
|
||||||
**布局**(信息结构):
|
**布局**(信息结构):
|
||||||
@@ -581,6 +582,47 @@ WECHAT_APP_SECRET=你的AppSecret
|
|||||||
|
|
||||||
**浏览器方式**(无需 API 配置):需已安装 Google Chrome,首次运行需扫码登录(登录状态会保存)
|
**浏览器方式**(无需 API 配置):需已安装 Google Chrome,首次运行需扫码登录(登录状态会保存)
|
||||||
|
|
||||||
|
**多账号支持**:通过 `EXTEND.md` 管理多个微信公众号:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p .baoyu-skills/baoyu-post-to-wechat
|
||||||
|
```
|
||||||
|
|
||||||
|
创建 `.baoyu-skills/baoyu-post-to-wechat/EXTEND.md`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# 全局设置(所有账号共享)
|
||||||
|
default_theme: default
|
||||||
|
default_color: blue
|
||||||
|
|
||||||
|
# 账号列表
|
||||||
|
accounts:
|
||||||
|
- name: 宝玉的技术分享
|
||||||
|
alias: baoyu
|
||||||
|
default: false
|
||||||
|
default_publish_method: api
|
||||||
|
default_author: 宝玉
|
||||||
|
need_open_comment: 1
|
||||||
|
only_fans_can_comment: 0
|
||||||
|
app_id: 你的微信AppID
|
||||||
|
app_secret: 你的微信AppSecret
|
||||||
|
- name: AI 工具集
|
||||||
|
alias: ai-tools
|
||||||
|
default_publish_method: browser
|
||||||
|
default_author: AI 工具集
|
||||||
|
need_open_comment: 1
|
||||||
|
only_fans_can_comment: 0
|
||||||
|
```
|
||||||
|
|
||||||
|
| 账号配置情况 | 行为 |
|
||||||
|
|-------------|------|
|
||||||
|
| 无 `accounts` 块 | 单账号模式(向后兼容) |
|
||||||
|
| 1 个账号 | 自动选择,无需提示 |
|
||||||
|
| 2+ 个账号 | 提示选择,或使用 `--account <别名>` |
|
||||||
|
| 某账号设置 `default: true` | 预选为默认账号 |
|
||||||
|
|
||||||
|
每个账号拥有独立的 Chrome 配置目录,保证浏览器方式下的登录会话互不干扰。API 凭证可在 EXTEND.md 中直接配置,也可通过 `.env` 文件使用别名前缀的环境变量(如 `WECHAT_BAOYU_APP_ID`)。
|
||||||
|
|
||||||
#### baoyu-post-to-weibo
|
#### baoyu-post-to-weibo
|
||||||
|
|
||||||
发布内容到微博。支持文字、图片、视频发布和头条文章(长篇 Markdown)。使用真实 Chrome + CDP 绕过反自动化检测。
|
发布内容到微博。支持文字、图片、视频发布和头条文章(长篇 Markdown)。使用真实 Chrome + CDP 绕过反自动化检测。
|
||||||
@@ -623,7 +665,7 @@ AI 驱动的生成后端。
|
|||||||
|
|
||||||
#### baoyu-image-gen
|
#### baoyu-image-gen
|
||||||
|
|
||||||
基于 AI SDK 的图像生成,使用官方 OpenAI、Google 和 DashScope(阿里通义万相)API。支持文生图、参考图、宽高比和质量预设。
|
基于 AI SDK 的图像生成,支持 OpenAI、Google、OpenRouter、DashScope(阿里通义万相)和 Replicate API。支持文生图、参考图、宽高比和质量预设。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 基础生成(自动检测服务商)
|
# 基础生成(自动检测服务商)
|
||||||
@@ -638,10 +680,16 @@ AI 驱动的生成后端。
|
|||||||
# 指定服务商
|
# 指定服务商
|
||||||
/baoyu-image-gen --prompt "一只猫" --image cat.png --provider openai
|
/baoyu-image-gen --prompt "一只猫" --image cat.png --provider openai
|
||||||
|
|
||||||
|
# OpenRouter
|
||||||
|
/baoyu-image-gen --prompt "一只猫" --image cat.png --provider openrouter
|
||||||
|
|
||||||
# DashScope(阿里通义万相)
|
# DashScope(阿里通义万相)
|
||||||
/baoyu-image-gen --prompt "一只可爱的猫" --image cat.png --provider dashscope
|
/baoyu-image-gen --prompt "一只可爱的猫" --image cat.png --provider dashscope
|
||||||
|
|
||||||
# 带参考图(仅 Google 多模态支持)
|
# Replicate
|
||||||
|
/baoyu-image-gen --prompt "一只猫" --image cat.png --provider replicate
|
||||||
|
|
||||||
|
# 带参考图(Google、OpenAI、OpenRouter 或 Replicate)
|
||||||
/baoyu-image-gen --prompt "把它变成蓝色" --image out.png --ref source.png
|
/baoyu-image-gen --prompt "把它变成蓝色" --image out.png --ref source.png
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -651,25 +699,31 @@ AI 驱动的生成后端。
|
|||||||
| `--prompt`, `-p` | 提示词文本 |
|
| `--prompt`, `-p` | 提示词文本 |
|
||||||
| `--promptfiles` | 从文件读取提示词(多文件拼接) |
|
| `--promptfiles` | 从文件读取提示词(多文件拼接) |
|
||||||
| `--image` | 输出图片路径(必需) |
|
| `--image` | 输出图片路径(必需) |
|
||||||
| `--provider` | `google`、`openai` 或 `dashscope`(默认:google) |
|
| `--provider` | `google`、`openai`、`openrouter`、`dashscope` 或 `replicate`(默认:自动检测,优先 google) |
|
||||||
| `--model`, `-m` | 模型 ID |
|
| `--model`, `-m` | 模型 ID |
|
||||||
| `--ar` | 宽高比(如 `16:9`、`1:1`、`4:3`) |
|
| `--ar` | 宽高比(如 `16:9`、`1:1`、`4:3`) |
|
||||||
| `--size` | 尺寸(如 `1024x1024`) |
|
| `--size` | 尺寸(如 `1024x1024`) |
|
||||||
| `--quality` | `normal` 或 `2k`(默认:normal) |
|
| `--quality` | `normal` 或 `2k`(默认:`2k`) |
|
||||||
| `--ref` | 参考图片(仅 Google 多模态支持) |
|
| `--ref` | 参考图片(Google、OpenAI、OpenRouter 或 Replicate) |
|
||||||
|
|
||||||
**环境变量**(配置方法见[环境配置](#环境配置)):
|
**环境变量**(配置方法见[环境配置](#环境配置)):
|
||||||
| 变量 | 说明 | 默认值 |
|
| 变量 | 说明 | 默认值 |
|
||||||
|------|------|--------|
|
|------|------|--------|
|
||||||
| `OPENAI_API_KEY` | OpenAI API 密钥 | - |
|
| `OPENAI_API_KEY` | OpenAI API 密钥 | - |
|
||||||
|
| `OPENROUTER_API_KEY` | OpenRouter API 密钥 | - |
|
||||||
| `GOOGLE_API_KEY` | Google API 密钥 | - |
|
| `GOOGLE_API_KEY` | Google API 密钥 | - |
|
||||||
| `DASHSCOPE_API_KEY` | DashScope API 密钥(阿里云) | - |
|
| `DASHSCOPE_API_KEY` | DashScope API 密钥(阿里云) | - |
|
||||||
|
| `REPLICATE_API_TOKEN` | Replicate API Token | - |
|
||||||
| `OPENAI_IMAGE_MODEL` | OpenAI 模型 | `gpt-image-1.5` |
|
| `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` |
|
| `GOOGLE_IMAGE_MODEL` | Google 模型 | `gemini-3-pro-image-preview` |
|
||||||
| `DASHSCOPE_IMAGE_MODEL` | DashScope 模型 | `z-image-turbo` |
|
| `DASHSCOPE_IMAGE_MODEL` | DashScope 模型 | `z-image-turbo` |
|
||||||
|
| `REPLICATE_IMAGE_MODEL` | Replicate 模型 | `google/nano-banana-pro` |
|
||||||
| `OPENAI_BASE_URL` | 自定义 OpenAI 端点 | - |
|
| `OPENAI_BASE_URL` | 自定义 OpenAI 端点 | - |
|
||||||
|
| `OPENROUTER_BASE_URL` | 自定义 OpenRouter 端点 | `https://openrouter.ai/api/v1` |
|
||||||
| `GOOGLE_BASE_URL` | 自定义 Google 端点 | - |
|
| `GOOGLE_BASE_URL` | 自定义 Google 端点 | - |
|
||||||
| `DASHSCOPE_BASE_URL` | 自定义 DashScope 端点 | - |
|
| `DASHSCOPE_BASE_URL` | 自定义 DashScope 端点 | - |
|
||||||
|
| `REPLICATE_BASE_URL` | 自定义 Replicate 端点 | - |
|
||||||
|
|
||||||
**服务商自动选择**:
|
**服务商自动选择**:
|
||||||
1. 如果指定了 `--provider` → 使用指定的
|
1. 如果指定了 `--provider` → 使用指定的
|
||||||
@@ -916,6 +970,11 @@ OPENAI_API_KEY=sk-xxx
|
|||||||
OPENAI_IMAGE_MODEL=gpt-image-1.5
|
OPENAI_IMAGE_MODEL=gpt-image-1.5
|
||||||
# OPENAI_BASE_URL=https://api.openai.com/v1
|
# OPENAI_BASE_URL=https://api.openai.com/v1
|
||||||
|
|
||||||
|
# OpenRouter
|
||||||
|
OPENROUTER_API_KEY=sk-or-xxx
|
||||||
|
OPENROUTER_IMAGE_MODEL=google/gemini-3.1-flash-image-preview
|
||||||
|
# OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
|
||||||
|
|
||||||
# Google
|
# Google
|
||||||
GOOGLE_API_KEY=xxx
|
GOOGLE_API_KEY=xxx
|
||||||
GOOGLE_IMAGE_MODEL=gemini-3-pro-image-preview
|
GOOGLE_IMAGE_MODEL=gemini-3-pro-image-preview
|
||||||
@@ -925,6 +984,11 @@ GOOGLE_IMAGE_MODEL=gemini-3-pro-image-preview
|
|||||||
DASHSCOPE_API_KEY=sk-xxx
|
DASHSCOPE_API_KEY=sk-xxx
|
||||||
DASHSCOPE_IMAGE_MODEL=z-image-turbo
|
DASHSCOPE_IMAGE_MODEL=z-image-turbo
|
||||||
# DASHSCOPE_BASE_URL=https://dashscope.aliyuncs.com/api/v1
|
# 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
|
||||||
EOF
|
EOF
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -27,7 +27,9 @@ Release hooks are configured via `.releaserc.yml`. This repo does not stage a se
|
|||||||
|
|
||||||
`packages/` is the **only** source of truth. Never edit `skills/*/scripts/vendor/` directly.
|
`packages/` is the **only** source of truth. Never edit `skills/*/scripts/vendor/` directly.
|
||||||
|
|
||||||
Current package: `baoyu-chrome-cdp` (Chrome CDP utilities), consumed by 6 skills (`baoyu-danger-gemini-web`, `baoyu-danger-x-to-markdown`, `baoyu-post-to-wechat`, `baoyu-post-to-weibo`, `baoyu-post-to-x`, `baoyu-url-to-markdown`).
|
Current packages:
|
||||||
|
- `baoyu-chrome-cdp` (Chrome CDP utilities), consumed by 6 skills (`baoyu-danger-gemini-web`, `baoyu-danger-x-to-markdown`, `baoyu-post-to-wechat`, `baoyu-post-to-weibo`, `baoyu-post-to-x`, `baoyu-url-to-markdown`)
|
||||||
|
- `baoyu-md` (shared Markdown rendering and placeholder pipeline), consumed by 3 skills (`baoyu-markdown-to-html`, `baoyu-post-to-wechat`, `baoyu-post-to-weibo`)
|
||||||
|
|
||||||
**How it works**: Sync script copies packages into each consuming skill's `vendor/` directory and rewrites dependency specs to `file:./vendor/<name>`. Vendor copies are committed to git, making skills self-contained.
|
**How it works**: Sync script copies packages into each consuming skill's `vendor/` directory and rewrites dependency specs to `file:./vendor/<name>`. Vendor copies are committed to git, making skills self-contained.
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
{
|
{
|
||||||
|
"name": "baoyu-md",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts"
|
||||||
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fflate": "^0.8.2",
|
"fflate": "^0.8.2",
|
||||||
"front-matter": "^4.0.2",
|
"front-matter": "^4.0.2",
|
||||||
@@ -12,7 +12,7 @@ export function printUsage(): void {
|
|||||||
console.error(
|
console.error(
|
||||||
[
|
[
|
||||||
"Usage:",
|
"Usage:",
|
||||||
" npx tsx src/md/render.ts <markdown_file> [options]",
|
" npx tsx render.ts <markdown_file> [options]",
|
||||||
"",
|
"",
|
||||||
"Options:",
|
"Options:",
|
||||||
` --theme <name> Theme (${THEME_NAMES.join(", ")})`,
|
` --theme <name> Theme (${THEME_NAMES.join(", ")})`,
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { Lexer } from "marked";
|
||||||
|
|
||||||
|
export type FrontmatterFields = Record<string, string>;
|
||||||
|
|
||||||
|
export function parseFrontmatter(content: string): {
|
||||||
|
frontmatter: FrontmatterFields;
|
||||||
|
body: string;
|
||||||
|
} {
|
||||||
|
const match = content.match(/^\s*---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
|
||||||
|
if (!match) {
|
||||||
|
return { frontmatter: {}, body: content };
|
||||||
|
}
|
||||||
|
|
||||||
|
const frontmatter: FrontmatterFields = {};
|
||||||
|
const lines = match[1]!.split("\n");
|
||||||
|
for (const line of lines) {
|
||||||
|
const colonIdx = line.indexOf(":");
|
||||||
|
if (colonIdx <= 0) continue;
|
||||||
|
|
||||||
|
const key = line.slice(0, colonIdx).trim();
|
||||||
|
const value = line.slice(colonIdx + 1).trim();
|
||||||
|
frontmatter[key] = stripWrappingQuotes(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { frontmatter, body: match[2]! };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeFrontmatter(frontmatter: FrontmatterFields): string {
|
||||||
|
const entries = Object.entries(frontmatter);
|
||||||
|
if (entries.length === 0) return "";
|
||||||
|
return `---\n${entries.map(([key, value]) => `${key}: ${value}`).join("\n")}\n---\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stripWrappingQuotes(value: string): string {
|
||||||
|
if (!value) return value;
|
||||||
|
|
||||||
|
const doubleQuoted = value.startsWith('"') && value.endsWith('"');
|
||||||
|
const singleQuoted = value.startsWith("'") && value.endsWith("'");
|
||||||
|
const cjkDoubleQuoted = value.startsWith("\u201c") && value.endsWith("\u201d");
|
||||||
|
const cjkSingleQuoted = value.startsWith("\u2018") && value.endsWith("\u2019");
|
||||||
|
|
||||||
|
if (doubleQuoted || singleQuoted || cjkDoubleQuoted || cjkSingleQuoted) {
|
||||||
|
return value.slice(1, -1).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toFrontmatterString(value: unknown): string | undefined {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return stripWrappingQuotes(value);
|
||||||
|
}
|
||||||
|
if (typeof value === "number" || typeof value === "boolean") {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pickFirstString(
|
||||||
|
frontmatter: Record<string, unknown>,
|
||||||
|
keys: string[],
|
||||||
|
): string | undefined {
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = toFrontmatterString(frontmatter[key]);
|
||||||
|
if (value) return value;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractTitleFromMarkdown(markdown: string): string {
|
||||||
|
const tokens = Lexer.lex(markdown, { gfm: true, breaks: true });
|
||||||
|
for (const token of tokens) {
|
||||||
|
if (token.type !== "heading" || (token.depth !== 1 && token.depth !== 2)) continue;
|
||||||
|
return stripWrappingQuotes(token.text);
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractSummaryFromBody(body: string, maxLen: number): string {
|
||||||
|
const lines = body.split("\n");
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed) continue;
|
||||||
|
if (trimmed.startsWith("#")) continue;
|
||||||
|
if (trimmed.startsWith("![")) continue;
|
||||||
|
if (trimmed.startsWith(">")) continue;
|
||||||
|
if (trimmed.startsWith("-") || trimmed.startsWith("*")) continue;
|
||||||
|
if (/^\d+\./.test(trimmed)) continue;
|
||||||
|
if (trimmed.startsWith("```")) continue;
|
||||||
|
|
||||||
|
const cleanText = trimmed
|
||||||
|
.replace(/\*\*(.+?)\*\*/g, "$1")
|
||||||
|
.replace(/\*(.+?)\*/g, "$1")
|
||||||
|
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
||||||
|
.replace(/`([^`]+)`/g, "$1");
|
||||||
|
|
||||||
|
if (cleanText.length > 20) {
|
||||||
|
if (cleanText.length <= maxLen) return cleanText;
|
||||||
|
return `${cleanText.slice(0, maxLen - 3)}...`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "";
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import type { ReadTimeResults } from "reading-time";
|
||||||
|
|
||||||
|
import {
|
||||||
|
COLOR_PRESETS,
|
||||||
|
DEFAULT_STYLE,
|
||||||
|
FONT_FAMILY_MAP,
|
||||||
|
THEME_STYLE_DEFAULTS,
|
||||||
|
} from "./constants.js";
|
||||||
|
import {
|
||||||
|
extractSummaryFromBody,
|
||||||
|
extractTitleFromMarkdown,
|
||||||
|
pickFirstString,
|
||||||
|
stripWrappingQuotes,
|
||||||
|
} from "./content.js";
|
||||||
|
import { loadExtendConfig } from "./extend-config.js";
|
||||||
|
import {
|
||||||
|
buildCss,
|
||||||
|
buildHtmlDocument,
|
||||||
|
inlineCss,
|
||||||
|
loadCodeThemeCss,
|
||||||
|
modifyHtmlStructure,
|
||||||
|
normalizeInlineCss,
|
||||||
|
removeFirstHeading,
|
||||||
|
} from "./html-builder.js";
|
||||||
|
import { initRenderer, postProcessHtml, renderMarkdown } from "./renderer.js";
|
||||||
|
import { loadThemeCss, normalizeThemeCss } from "./themes.js";
|
||||||
|
import type { HtmlDocumentMeta, IOpts, StyleConfig, ThemeName } from "./types.js";
|
||||||
|
|
||||||
|
export interface RenderMarkdownDocumentOptions {
|
||||||
|
codeTheme?: string;
|
||||||
|
countStatus?: boolean;
|
||||||
|
citeStatus?: boolean;
|
||||||
|
defaultTitle?: string;
|
||||||
|
fontFamily?: string;
|
||||||
|
fontSize?: string;
|
||||||
|
isMacCodeBlock?: boolean;
|
||||||
|
isShowLineNumber?: boolean;
|
||||||
|
keepTitle?: boolean;
|
||||||
|
legend?: string;
|
||||||
|
primaryColor?: string;
|
||||||
|
theme?: ThemeName;
|
||||||
|
themeMode?: IOpts["themeMode"];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RenderMarkdownDocumentResult {
|
||||||
|
contentHtml: string;
|
||||||
|
html: string;
|
||||||
|
meta: HtmlDocumentMeta;
|
||||||
|
readingTime: ReadTimeResults;
|
||||||
|
style: StyleConfig;
|
||||||
|
yamlData: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveColorToken(value?: string): string | undefined {
|
||||||
|
if (!value) return undefined;
|
||||||
|
return COLOR_PRESETS[value] ?? value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveFontFamilyToken(value?: string): string | undefined {
|
||||||
|
if (!value) return undefined;
|
||||||
|
return FONT_FAMILY_MAP[value] ?? value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTimestamp(date = new Date()): string {
|
||||||
|
const pad = (value: number) => String(value).padStart(2, "0");
|
||||||
|
return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(
|
||||||
|
date.getDate(),
|
||||||
|
)}${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildMarkdownDocumentMeta(
|
||||||
|
markdown: string,
|
||||||
|
yamlData: Record<string, unknown>,
|
||||||
|
defaultTitle = "document",
|
||||||
|
): HtmlDocumentMeta {
|
||||||
|
const title = pickFirstString(yamlData, ["title"])
|
||||||
|
|| extractTitleFromMarkdown(markdown)
|
||||||
|
|| defaultTitle;
|
||||||
|
const author = pickFirstString(yamlData, ["author"]);
|
||||||
|
const description = pickFirstString(yamlData, ["description", "summary"])
|
||||||
|
|| extractSummaryFromBody(markdown, 120);
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: stripWrappingQuotes(title),
|
||||||
|
author: author ? stripWrappingQuotes(author) : undefined,
|
||||||
|
description: description ? stripWrappingQuotes(description) : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveMarkdownStyle(options: RenderMarkdownDocumentOptions = {}): StyleConfig {
|
||||||
|
const theme = options.theme ?? "default";
|
||||||
|
const themeDefaults = THEME_STYLE_DEFAULTS[theme] ?? {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
...DEFAULT_STYLE,
|
||||||
|
...themeDefaults,
|
||||||
|
...(options.primaryColor !== undefined ? { primaryColor: options.primaryColor } : {}),
|
||||||
|
...(options.fontFamily !== undefined ? { fontFamily: options.fontFamily } : {}),
|
||||||
|
...(options.fontSize !== undefined ? { fontSize: options.fontSize } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveRenderOptions(
|
||||||
|
options: RenderMarkdownDocumentOptions = {},
|
||||||
|
): RenderMarkdownDocumentOptions {
|
||||||
|
const extendConfig = loadExtendConfig();
|
||||||
|
|
||||||
|
return {
|
||||||
|
codeTheme: options.codeTheme ?? extendConfig.default_code_theme ?? "github",
|
||||||
|
countStatus: options.countStatus ?? extendConfig.count ?? false,
|
||||||
|
citeStatus: options.citeStatus ?? extendConfig.cite ?? false,
|
||||||
|
defaultTitle: options.defaultTitle,
|
||||||
|
fontFamily: options.fontFamily ?? resolveFontFamilyToken(extendConfig.default_font_family ?? undefined),
|
||||||
|
fontSize: options.fontSize ?? extendConfig.default_font_size ?? undefined,
|
||||||
|
isMacCodeBlock: options.isMacCodeBlock ?? extendConfig.mac_code_block ?? true,
|
||||||
|
isShowLineNumber: options.isShowLineNumber ?? extendConfig.show_line_number ?? false,
|
||||||
|
keepTitle: options.keepTitle ?? extendConfig.keep_title ?? false,
|
||||||
|
legend: options.legend ?? extendConfig.legend ?? "alt",
|
||||||
|
primaryColor: options.primaryColor ?? resolveColorToken(extendConfig.default_color ?? undefined),
|
||||||
|
theme: options.theme ?? extendConfig.default_theme ?? "default",
|
||||||
|
themeMode: options.themeMode,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renderMarkdownDocument(
|
||||||
|
markdown: string,
|
||||||
|
options: RenderMarkdownDocumentOptions = {},
|
||||||
|
): Promise<RenderMarkdownDocumentResult> {
|
||||||
|
const resolvedOptions = resolveRenderOptions(options);
|
||||||
|
const theme = resolvedOptions.theme ?? "default";
|
||||||
|
const codeTheme = resolvedOptions.codeTheme ?? "github";
|
||||||
|
const style = resolveMarkdownStyle(resolvedOptions);
|
||||||
|
|
||||||
|
const { baseCss, themeCss } = loadThemeCss(theme);
|
||||||
|
const css = normalizeThemeCss(buildCss(baseCss, themeCss, style));
|
||||||
|
const codeThemeCss = loadCodeThemeCss(codeTheme);
|
||||||
|
|
||||||
|
const renderer = initRenderer({
|
||||||
|
citeStatus: resolvedOptions.citeStatus ?? false,
|
||||||
|
countStatus: resolvedOptions.countStatus ?? false,
|
||||||
|
isMacCodeBlock: resolvedOptions.isMacCodeBlock ?? true,
|
||||||
|
isShowLineNumber: resolvedOptions.isShowLineNumber ?? false,
|
||||||
|
legend: resolvedOptions.legend ?? "alt",
|
||||||
|
themeMode: resolvedOptions.themeMode,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { yamlData, markdownContent, readingTime } = renderer.parseFrontMatterAndContent(markdown);
|
||||||
|
const { html: baseHtml, readingTime: readingTimeResult } = renderMarkdown(markdown, renderer);
|
||||||
|
|
||||||
|
let contentHtml = postProcessHtml(baseHtml, readingTimeResult, renderer);
|
||||||
|
if (!(resolvedOptions.keepTitle ?? false)) {
|
||||||
|
contentHtml = removeFirstHeading(contentHtml);
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta = buildMarkdownDocumentMeta(
|
||||||
|
markdownContent,
|
||||||
|
yamlData as Record<string, unknown>,
|
||||||
|
resolvedOptions.defaultTitle,
|
||||||
|
);
|
||||||
|
const html = buildHtmlDocument(meta, css, contentHtml, codeThemeCss);
|
||||||
|
const inlinedHtml = normalizeInlineCss(await inlineCss(html), style);
|
||||||
|
|
||||||
|
return {
|
||||||
|
contentHtml,
|
||||||
|
html: modifyHtmlStructure(inlinedHtml),
|
||||||
|
meta,
|
||||||
|
readingTime,
|
||||||
|
style,
|
||||||
|
yamlData: yamlData as Record<string, unknown>,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renderMarkdownFileToHtml(
|
||||||
|
inputPath: string,
|
||||||
|
options: RenderMarkdownDocumentOptions = {},
|
||||||
|
): Promise<RenderMarkdownDocumentResult & {
|
||||||
|
backupPath?: string;
|
||||||
|
outputPath: string;
|
||||||
|
}> {
|
||||||
|
const markdown = fs.readFileSync(inputPath, "utf-8");
|
||||||
|
const outputPath = path.resolve(
|
||||||
|
path.dirname(inputPath),
|
||||||
|
`${path.basename(inputPath, path.extname(inputPath))}.html`,
|
||||||
|
);
|
||||||
|
const result = await renderMarkdownDocument(markdown, {
|
||||||
|
...options,
|
||||||
|
defaultTitle: options.defaultTitle ?? path.basename(outputPath, ".html"),
|
||||||
|
});
|
||||||
|
|
||||||
|
let backupPath: string | undefined;
|
||||||
|
if (fs.existsSync(outputPath)) {
|
||||||
|
backupPath = `${outputPath}.bak-${formatTimestamp()}`;
|
||||||
|
fs.renameSync(outputPath, backupPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(outputPath, result.html, "utf-8");
|
||||||
|
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
backupPath,
|
||||||
|
outputPath,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import http from "node:http";
|
||||||
|
import https from "node:https";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
export interface ImagePlaceholder {
|
||||||
|
originalPath: string;
|
||||||
|
placeholder: string;
|
||||||
|
alt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolvedImageInfo extends ImagePlaceholder {
|
||||||
|
localPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function replaceMarkdownImagesWithPlaceholders(
|
||||||
|
markdown: string,
|
||||||
|
placeholderPrefix: string,
|
||||||
|
): {
|
||||||
|
images: ImagePlaceholder[];
|
||||||
|
markdown: string;
|
||||||
|
} {
|
||||||
|
const images: ImagePlaceholder[] = [];
|
||||||
|
let imageCounter = 0;
|
||||||
|
|
||||||
|
const rewritten = markdown.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_match, alt, src) => {
|
||||||
|
const placeholder = `${placeholderPrefix}${++imageCounter}`;
|
||||||
|
images.push({
|
||||||
|
alt,
|
||||||
|
originalPath: src,
|
||||||
|
placeholder,
|
||||||
|
});
|
||||||
|
return placeholder;
|
||||||
|
});
|
||||||
|
|
||||||
|
return { images, markdown: rewritten };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getImageExtension(urlOrPath: string): string {
|
||||||
|
const match = urlOrPath.match(/\.(jpg|jpeg|png|gif|webp)(\?|$)/i);
|
||||||
|
return match ? match[1]!.toLowerCase() : "png";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function downloadFile(url: string, destPath: string): Promise<void> {
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
const protocol = url.startsWith("https://") ? https : http;
|
||||||
|
const file = fs.createWriteStream(destPath);
|
||||||
|
|
||||||
|
const request = protocol.get(url, { headers: { "User-Agent": "Mozilla/5.0" } }, (response) => {
|
||||||
|
if (response.statusCode === 301 || response.statusCode === 302) {
|
||||||
|
const redirectUrl = response.headers.location;
|
||||||
|
if (redirectUrl) {
|
||||||
|
file.close();
|
||||||
|
fs.unlinkSync(destPath);
|
||||||
|
void downloadFile(redirectUrl, destPath).then(resolve).catch(reject);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.statusCode !== 200) {
|
||||||
|
file.close();
|
||||||
|
fs.unlinkSync(destPath);
|
||||||
|
reject(new Error(`Failed to download: ${response.statusCode}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
response.pipe(file);
|
||||||
|
file.on("finish", () => {
|
||||||
|
file.close();
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
request.on("error", (error) => {
|
||||||
|
file.close();
|
||||||
|
fs.unlink(destPath, () => {});
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
request.setTimeout(30_000, () => {
|
||||||
|
request.destroy();
|
||||||
|
reject(new Error("Download timeout"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveImagePath(
|
||||||
|
imagePath: string,
|
||||||
|
baseDir: string,
|
||||||
|
tempDir: string,
|
||||||
|
logLabel = "baoyu-md",
|
||||||
|
): Promise<string> {
|
||||||
|
if (imagePath.startsWith("http://") || imagePath.startsWith("https://")) {
|
||||||
|
const hash = createHash("md5").update(imagePath).digest("hex").slice(0, 8);
|
||||||
|
const ext = getImageExtension(imagePath);
|
||||||
|
const localPath = path.join(tempDir, `remote_${hash}.${ext}`);
|
||||||
|
|
||||||
|
if (!fs.existsSync(localPath)) {
|
||||||
|
console.error(`[${logLabel}] Downloading: ${imagePath}`);
|
||||||
|
await downloadFile(imagePath, localPath);
|
||||||
|
}
|
||||||
|
return localPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolved = path.isAbsolute(imagePath)
|
||||||
|
? imagePath
|
||||||
|
: path.resolve(baseDir, imagePath);
|
||||||
|
return resolveLocalWithFallback(resolved, logLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveContentImages(
|
||||||
|
images: ImagePlaceholder[],
|
||||||
|
baseDir: string,
|
||||||
|
tempDir: string,
|
||||||
|
logLabel = "baoyu-md",
|
||||||
|
): Promise<ResolvedImageInfo[]> {
|
||||||
|
const resolved: ResolvedImageInfo[] = [];
|
||||||
|
|
||||||
|
for (const image of images) {
|
||||||
|
resolved.push({
|
||||||
|
...image,
|
||||||
|
localPath: await resolveImagePath(image.originalPath, baseDir, tempDir, logLabel),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveLocalWithFallback(resolved: string, logLabel: string): string {
|
||||||
|
if (fs.existsSync(resolved)) {
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = path.extname(resolved);
|
||||||
|
const base = ext ? resolved.slice(0, -ext.length) : resolved;
|
||||||
|
const alternatives = [
|
||||||
|
`${base}.webp`,
|
||||||
|
`${base}.jpg`,
|
||||||
|
`${base}.jpeg`,
|
||||||
|
`${base}.png`,
|
||||||
|
`${base}.gif`,
|
||||||
|
`${base}_original.png`,
|
||||||
|
`${base}_original.jpg`,
|
||||||
|
].filter((candidate) => candidate !== resolved);
|
||||||
|
|
||||||
|
for (const alternative of alternatives) {
|
||||||
|
if (!fs.existsSync(alternative)) continue;
|
||||||
|
console.error(
|
||||||
|
`[${logLabel}] Image fallback: ${path.basename(resolved)} -> ${path.basename(alternative)}`,
|
||||||
|
);
|
||||||
|
return alternative;
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export * from "./cli.js";
|
||||||
|
export * from "./constants.js";
|
||||||
|
export * from "./content.js";
|
||||||
|
export * from "./document.js";
|
||||||
|
export * from "./extend-config.js";
|
||||||
|
export * from "./html-builder.js";
|
||||||
|
export * from "./images.js";
|
||||||
|
export * from "./renderer.js";
|
||||||
|
export * from "./themes.js";
|
||||||
|
export * from "./types.js";
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user