Compare commits

..

20 Commits

Author SHA1 Message Date
Jim Liu 宝玉 c6d96d4134 add new skill baoyu-comic 2026-01-16 01:12:21 -06:00
Jim Liu 宝玉 e174d642df feat: add quick install instructions to README files 2026-01-15 22:06:38 -06:00
Jim Liu 宝玉 db7eaa2847 feat: update version number to 0.4.2 and enhance description for baoyu-gemini-web skill 2026-01-15 17:05:58 -06:00
Jim Liu 宝玉 e3d2f5d03f Add clipboard paste functionality and documentation for X Articles and regular posts
- Implemented `paste-from-clipboard.ts` script to send real paste keystrokes across macOS, Linux, and Windows.
- Created detailed guides for publishing articles to X Articles and posting regular content, including usage examples and troubleshooting tips.
- Added support for Markdown formatting and image handling in X Articles.
- Documented manual workflows for image pasting and browser interactions using Playwright MCP.
2026-01-15 15:38:03 -06:00
Jim Liu 宝玉 da920bb830 feat: add baoyu- prefix 2026-01-15 14:18:56 -06:00
Jim Liu 宝玉 f49acb9983 feat: Update version number to 0.3.1 in marketplace.json 2026-01-15 10:59:03 -06:00
Jim Liu 宝玉 3fddcc0e6a feat: Update image generation steps in article-illustrator, cover-image, and xhs-images skills for improved user guidance 2026-01-15 10:58:20 -06:00
Jim Liu 宝玉 d4070b19d5 feat: Enhance xhs-images skill with style and layout options for improved infographic generation 2026-01-14 10:42:04 -06:00
Jim Liu 宝玉 6430c67efe feat: Add 'notion' style option to article-illustrator, slide-deck, and xhs-images skills for enhanced visual presentation 2026-01-14 10:06:53 -06:00
Jim Liu 宝玉 73a5f60ad3 feat: Add WeChat posting skill and update marketplace.json 2026-01-14 02:55:39 -06:00
Jim Liu 宝玉 7a2f475417 Create CLAUDE.md 2026-01-13 21:52:39 -06:00
Jim Liu 宝玉 bb494f07ba Update slide deck skill documentation to clarify image generation and add new styles 2026-01-13 20:55:47 -06:00
Jim Liu 宝玉 ced92e2231 Update marketplace.json version to 0.2.0 for improved clarity 2026-01-13 20:24:11 -06:00
Jim Liu 宝玉 102ef092ad Update slide deck skill documentation and prompt guidelines for improved clarity and functionality 2026-01-13 20:23:53 -06:00
Jim Liu 宝玉 cc553ddbb3 Refactor infographic generation skills and add new slide deck generator
- Updated article-illustrator prompts to emphasize hand-drawn style and layout principles.
- Enhanced xhs-images skill with multiple style options and auto-selection based on content signals.
- Created README in Chinese for better accessibility.
- Introduced cover-image skill for generating hand-drawn style cover images with various styles.
- Developed slide-deck skill to generate professional slide outlines with detailed structure and style instructions.
- Added prompts for cover-image and slide-deck skills to guide content generation.
2026-01-13 19:52:10 -06:00
Jim Liu 宝玉 2f4b5fcc85 add article-illustrator skill documentation with infographic guidelines 2026-01-13 19:15:43 -06:00
Jim Liu 宝玉 227b1dcdf6 new skill: post to x 2026-01-13 18:16:12 -06:00
Jim Liu 宝玉 b0ee7b3d33 refactor marketplace.json structure and remove obsolete plugin.json 2026-01-13 00:56:46 -06:00
Jim Liu 宝玉 e1aaae43b6 add readme 2026-01-13 00:49:59 -06:00
Jim Liu 宝玉 6eaa822542 add xhs-images prompt 2026-01-13 00:35:03 -06:00
107 changed files with 12033 additions and 28 deletions
+16 -7
View File
@@ -1,20 +1,29 @@
{
"name": "baoyu-skills",
"description": "Skills shared by Baoyu for improving daily work efficiency",
"owner": {
"name": "Jim Liu (宝玉)",
"email": "junminliu@gmail.com"
},
"metadata": {
"description": "Skills shared by Baoyu for improving daily work efficiency",
"version": "0.5.0"
},
"plugins": [
{
"name": "baoyu-skills",
"name": "content-skills",
"description": "Skills shared by Baoyu for improving daily work efficiency",
"version": "0.1.0",
"source": "./",
"author": {
"name": "Jim Liu (宝玉)",
"email": "junminliu@gmail.com"
}
"strict": false,
"skills": [
"./skills/baoyu-gemini-web",
"./skills/baoyu-xhs-images",
"./skills/baoyu-post-to-x",
"./skills/baoyu-post-to-wechat",
"./skills/baoyu-article-illustrator",
"./skills/baoyu-cover-image",
"./skills/baoyu-slide-deck",
"./skills/baoyu-comic"
]
}
]
}
-13
View File
@@ -1,13 +0,0 @@
{
"name": "baoyu-skills",
"description": "Skills shared by Baoyu for improving daily work efficiency",
"version": "0.1.0",
"author": {
"name": "Jim Liu (宝玉)",
"email": "junminliu@gmail.com"
},
"homepage": "https://github.com/baoyu/baoyu-skills",
"repository": "https://github.com/baoyu/baoyu-skills",
"license": "MIT",
"keywords": ["skills", "translation", "productivity", "workflows"]
}
+3
View File
@@ -137,3 +137,6 @@ dist
# Vite logs files
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
tests-data/
.DS_Store
+80
View File
@@ -0,0 +1,80 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Claude Code marketplace plugin providing AI-powered content generation skills. Skills use Gemini Web API (reverse-engineered) for text/image generation and Chrome CDP for browser automation.
## Architecture
```
skills/
├── baoyu-gemini-web/ # Core: Gemini API wrapper (text + image gen)
├── baoyu-xhs-images/ # Xiaohongshu infographic series (1-10 images)
├── baoyu-cover-image/ # Article cover images (2.35:1 aspect)
├── baoyu-slide-deck/ # Presentation slides with outlines
├── baoyu-article-illustrator/ # Smart illustration placement
├── baoyu-post-to-x/ # X/Twitter posting automation
└── baoyu-post-to-wechat/ # WeChat Official Account posting
```
Each skill contains:
- `SKILL.md` - YAML front matter (name, description) + documentation
- `scripts/` - TypeScript implementations
- `prompts/system.md` - AI generation guidelines (optional)
## Running Skills
All scripts run via Bun (no build step):
```bash
npx -y bun skills/<skill>/scripts/main.ts [options]
```
Examples:
```bash
# Text generation
npx -y bun skills/baoyu-gemini-web/scripts/main.ts "Hello"
# Image generation
npx -y bun skills/baoyu-gemini-web/scripts/main.ts --prompt "A cat" --image cat.png
# From prompt files
npx -y bun skills/baoyu-gemini-web/scripts/main.ts --promptfiles system.md content.md --image out.png
```
## Key Dependencies
- **Bun**: TypeScript runtime (via `npx -y bun`)
- **Chrome**: Required for `baoyu-gemini-web` auth and `baoyu-post-to-x` automation
- **No npm packages**: Self-contained TypeScript, no external dependencies
## Authentication
`baoyu-gemini-web` uses browser cookies for Google auth:
- First run opens Chrome for login
- Cookies cached in data directory
- Force refresh: `--login` flag
## Plugin Configuration
`.claude-plugin/marketplace.json` defines plugin metadata and skill paths. Version follows semver.
## Adding New Skills
**IMPORTANT**: All skills MUST use `baoyu-` prefix to avoid conflicts when users import this plugin.
1. Create `skills/baoyu-<name>/SKILL.md` with YAML front matter
- Directory name: `baoyu-<name>`
- SKILL.md `name` field: `baoyu-<name>`
2. Add TypeScript in `skills/baoyu-<name>/scripts/`
3. Add prompt templates in `skills/baoyu-<name>/prompts/` if needed
4. Register in `marketplace.json` plugins[0].skills array as `./skills/baoyu-<name>`
## Code Style
- TypeScript throughout, no comments
- Async/await patterns
- Short variable names
- Type-safe interfaces
+231
View File
@@ -0,0 +1,231 @@
# baoyu-skills
English | [中文](./README.zh.md)
Skills shared by Baoyu for improving daily work efficiency with Claude Code.
## Prerequisites
- Node.js environment installed
- Ability to run `npx bun` commands
## Installation
### Quick Install (Recommended)
```bash
npx add-skill jimliu/baoyu-skills
```
### Register as Plugin Marketplace
Run the following command in Claude Code:
```bash
/plugin marketplace add jimliu/baoyu-skills
```
### Install Skills
**Option 1: Via Browse UI**
1. Select **Browse and install plugins**
2. Select **baoyu-skills**
3. Select **content-skills**
4. Select **Install now**
**Option 2: Direct Install**
```bash
/plugin install content-skills@baoyu-skills
```
**Option 3: Ask the Agent**
Simply tell Claude Code:
> Please install Skills from github.com/JimLiu/baoyu-skills
## Update Skills
To update skills to the latest version:
1. Run `/plugin` in Claude Code
2. Switch to **Marketplaces** tab (use arrow keys or Tab)
3. Select **baoyu-skills**
4. Choose **Update marketplace**
You can also **Enable auto-update** to get the latest versions automatically.
![Update Skills](./screenshots/update-plugins.png)
## Available Skills
### baoyu-gemini-web
Interacts with Gemini Web to generate text and images.
**Text Generation:**
```bash
/baoyu-gemini-web "Hello, Gemini"
/baoyu-gemini-web --prompt "Explain quantum computing"
```
**Image Generation:**
```bash
/baoyu-gemini-web --prompt "A cute cat" --image cat.png
/baoyu-gemini-web --promptfiles system.md content.md --image out.png
```
### baoyu-xhs-images
Xiaohongshu (RedNote) infographic series generator. Breaks down content into 1-10 cartoon-style infographics with **Style × Layout** two-dimensional system.
```bash
# Auto-select style and layout
/baoyu-xhs-images posts/ai-future/article.md
# Specify style
/baoyu-xhs-images posts/ai-future/article.md --style notion
# Specify layout
/baoyu-xhs-images posts/ai-future/article.md --layout dense
# Combine style and layout
/baoyu-xhs-images posts/ai-future/article.md --style tech --layout list
# Direct content input
/baoyu-xhs-images 今日星座运势
```
**Styles** (visual aesthetics): `cute` (default), `fresh`, `tech`, `warm`, `bold`, `minimal`, `retro`, `pop`, `notion`
**Layouts** (information density):
| Layout | Density | Best for |
|--------|---------|----------|
| `sparse` | 1-2 pts | Covers, quotes |
| `balanced` | 3-4 pts | Regular content |
| `dense` | 5-8 pts | Knowledge cards, cheat sheets |
| `list` | 4-7 items | Checklists, rankings |
| `comparison` | 2 sides | Before/after, pros/cons |
| `flow` | 3-6 steps | Processes, timelines |
### baoyu-cover-image
Generate hand-drawn style cover images for articles with multiple style options.
```bash
# From markdown file (auto-select style)
/baoyu-cover-image path/to/article.md
# Specify a style
/baoyu-cover-image path/to/article.md --style tech
/baoyu-cover-image path/to/article.md --style warm
# Without title text
/baoyu-cover-image path/to/article.md --no-title
```
Available styles: `elegant` (default), `tech`, `warm`, `bold`, `minimal`, `playful`, `nature`, `retro`
### baoyu-slide-deck
Generate professional slide deck images from content. Creates comprehensive outlines with style instructions, then generates individual slide images.
```bash
# From markdown file
/baoyu-slide-deck path/to/article.md
# With style and audience
/baoyu-slide-deck path/to/article.md --style corporate
/baoyu-slide-deck path/to/article.md --audience executives
# Outline only (no image generation)
/baoyu-slide-deck path/to/article.md --outline-only
# With language
/baoyu-slide-deck path/to/article.md --lang zh
```
Available styles: `editorial` (default), `corporate`, `technical`, `playful`, `minimal`, `storytelling`, `warm`, `retro-flat`, `notion`
### baoyu-comic
Knowledge comic creator supporting multiple styles (Logicomix/Ligne Claire, Ohmsha manga guide). Creates original educational comics with detailed panel layouts and sequential image generation.
```bash
# From source material
/baoyu-comic posts/turing-story/source.md
# Specify style
/baoyu-comic posts/turing-story/source.md --style dramatic
/baoyu-comic posts/turing-story/source.md --style ohmsha
# Specify layout
/baoyu-comic posts/turing-story/source.md --layout cinematic
/baoyu-comic posts/turing-story/source.md --layout webtoon
# Direct content input
/baoyu-comic "The story of Alan Turing and the birth of computer science"
```
**Styles** (visual aesthetics):
| Style | Description | Best for |
|-------|-------------|----------|
| `classic` | Traditional Ligne Claire, balanced | Biographies, balanced narratives |
| `dramatic` | High contrast, intense | Breakthroughs, conflicts |
| `warm` | Nostalgic, personal | Personal stories, mentorship |
| `tech` | Modern, digital | Computing history, AI stories |
| `sepia` | Historical, archival | Pre-1950s, classical science |
| `vibrant` | Energetic, engaging | Science basics, young audience |
| `ohmsha` | Manga guide style, visual metaphors | Tutorials, complex concepts (ML, physics) |
**Layouts** (panel arrangement):
| Layout | Panels/Page | Best for |
|--------|-------------|----------|
| `standard` | 4-6 | Dialogue, narrative flow |
| `cinematic` | 2-4 | Dramatic moments, establishing shots |
| `dense` | 6-9 | Technical explanations, timelines |
| `splash` | 1-2 large | Key moments, revelations |
| `mixed` | 3-7 varies | Complex narratives, emotional arcs |
| `webtoon` | 3-5 vertical | Ohmsha tutorials, mobile reading |
### baoyu-post-to-wechat
Post content to WeChat Official Account (微信公众号). Two modes available:
**Image-Text (图文)** - Multiple images with short title/content:
```bash
/baoyu-post-to-wechat 图文 --markdown article.md --images ./photos/
/baoyu-post-to-wechat 图文 --markdown article.md --image img1.png --image img2.png --image img3.png
/baoyu-post-to-wechat 图文 --title "标题" --content "内容" --image img1.png --submit
```
**Article (文章)** - Full markdown/HTML with rich formatting:
```bash
/baoyu-post-to-wechat 文章 --markdown article.md
/baoyu-post-to-wechat 文章 --markdown article.md --theme grace
/baoyu-post-to-wechat 文章 --html article.html
```
Prerequisites: Google Chrome installed. First run requires QR code login (session preserved).
## Disclaimer
### baoyu-gemini-web
This skill uses the Gemini Web API (reverse-engineered).
**Warning:** This project uses unofficial API access via browser cookies. Use at your own risk.
- First run opens Chrome to authenticate with Google
- Cookies are cached for subsequent runs
- No guarantees on API stability or availability
## License
MIT
+231
View File
@@ -0,0 +1,231 @@
# baoyu-skills
[English](./README.md) | 中文
宝玉分享的 Claude Code 技能集,提升日常工作效率。
## 前置要求
- 已安装 Node.js 环境
- 能够运行 `npx bun` 命令
## 安装
### 快速安装(推荐)
```bash
npx add-skill jimliu/baoyu-skills
```
### 注册插件市场
在 Claude Code 中运行:
```bash
/plugin marketplace add jimliu/baoyu-skills
```
### 安装技能
**方式一:通过浏览界面**
1. 选择 **Browse and install plugins**
2. 选择 **baoyu-skills**
3. 选择 **content-skills**
4. 选择 **Install now**
**方式二:直接安装**
```bash
/plugin install content-skills@baoyu-skills
```
**方式三:告诉 Agent**
直接告诉 Claude Code
> 请帮我安装 github.com/JimLiu/baoyu-skills 中的 Skills
## 更新技能
更新技能到最新版本:
1. 在 Claude Code 中运行 `/plugin`
2. 切换到 **Marketplaces** 标签页(使用方向键或 Tab
3. 选择 **baoyu-skills**
4. 选择 **Update marketplace**
也可以选择 **Enable auto-update** 启用自动更新,每次启动时自动获取最新版本。
![更新技能](./screenshots/update-plugins.png)
## 可用技能
### baoyu-gemini-web
与 Gemini Web 交互,生成文本和图片。
**文本生成:**
```bash
/baoyu-gemini-web "你好,Gemini"
/baoyu-gemini-web --prompt "解释量子计算"
```
**图片生成:**
```bash
/baoyu-gemini-web --prompt "一只可爱的猫" --image cat.png
/baoyu-gemini-web --promptfiles system.md content.md --image out.png
```
### baoyu-xhs-images
小红书信息图系列生成器。将内容拆解为 1-10 张卡通风格信息图,支持 **风格 × 布局** 二维系统。
```bash
# 自动选择风格和布局
/baoyu-xhs-images posts/ai-future/article.md
# 指定风格
/baoyu-xhs-images posts/ai-future/article.md --style notion
# 指定布局
/baoyu-xhs-images posts/ai-future/article.md --layout dense
# 组合风格和布局
/baoyu-xhs-images posts/ai-future/article.md --style tech --layout list
# 直接输入内容
/baoyu-xhs-images 今日星座运势
```
**风格**(视觉美学):`cute`(默认)、`fresh``tech``warm``bold``minimal``retro``pop``notion`
**布局**(信息密度):
| 布局 | 密度 | 适用场景 |
|------|------|----------|
| `sparse` | 1-2 点 | 封面、金句 |
| `balanced` | 3-4 点 | 常规内容 |
| `dense` | 5-8 点 | 知识卡片、干货总结 |
| `list` | 4-7 项 | 清单、排行 |
| `comparison` | 双栏 | 对比、优劣 |
| `flow` | 3-6 步 | 流程、时间线 |
### baoyu-cover-image
为文章生成手绘风格封面图,支持多种风格选项。
```bash
# 从 markdown 文件生成(自动选择风格)
/baoyu-cover-image path/to/article.md
# 指定风格
/baoyu-cover-image path/to/article.md --style tech
/baoyu-cover-image path/to/article.md --style warm
# 不包含标题文字
/baoyu-cover-image path/to/article.md --no-title
```
可用风格:`elegant`(默认)、`tech``warm``bold``minimal``playful``nature``retro`
### baoyu-slide-deck
从内容生成专业的幻灯片图片。先创建包含样式说明的完整大纲,然后逐页生成幻灯片图片。
```bash
# 从 markdown 文件生成
/baoyu-slide-deck path/to/article.md
# 指定风格和受众
/baoyu-slide-deck path/to/article.md --style corporate
/baoyu-slide-deck path/to/article.md --audience executives
# 仅生成大纲(不生成图片)
/baoyu-slide-deck path/to/article.md --outline-only
# 指定语言
/baoyu-slide-deck path/to/article.md --lang zh
```
可用风格:`editorial`(默认)、`corporate``technical``playful``minimal``storytelling``warm``retro-flat``notion`
### baoyu-comic
知识漫画创作器,支持多种风格(Logicomix/清线风格、欧姆社漫画教程风格)。创作带有详细分镜布局的原创教育漫画,逐页生成图片。
```bash
# 从素材文件生成
/baoyu-comic posts/turing-story/source.md
# 指定风格
/baoyu-comic posts/turing-story/source.md --style dramatic
/baoyu-comic posts/turing-story/source.md --style ohmsha
# 指定布局
/baoyu-comic posts/turing-story/source.md --layout cinematic
/baoyu-comic posts/turing-story/source.md --layout webtoon
# 直接输入内容
/baoyu-comic "图灵的故事与计算机科学的诞生"
```
**风格**(视觉美学):
| 风格 | 描述 | 适用场景 |
|------|------|----------|
| `classic` | 传统清线风格,平衡 | 传记、平衡叙事 |
| `dramatic` | 高对比度,紧张感 | 突破时刻、冲突 |
| `warm` | 怀旧、个人化 | 个人故事、师生情 |
| `tech` | 现代、数字化 | 计算机史、AI 故事 |
| `sepia` | 历史、档案感 | 1950 年前、古典科学 |
| `vibrant` | 活力、吸引人 | 科普、青少年读者 |
| `ohmsha` | 欧姆社漫画风格,视觉比喻 | 教程、复杂概念(机器学习、物理) |
**布局**(分镜排列):
| 布局 | 每页分镜数 | 适用场景 |
|------|-----------|----------|
| `standard` | 4-6 | 对话、叙事推进 |
| `cinematic` | 2-4 | 戏剧性时刻、建立镜头 |
| `dense` | 6-9 | 技术说明、时间线 |
| `splash` | 1-2 大图 | 关键时刻、揭示 |
| `mixed` | 3-7 不等 | 复杂叙事、情感弧线 |
| `webtoon` | 3-5 竖向 | 欧姆社教程、手机阅读 |
### baoyu-post-to-wechat
发布内容到微信公众号,支持两种模式:
**图文模式** - 多图配短标题和正文:
```bash
/baoyu-post-to-wechat 图文 --markdown article.md --images ./photos/
/baoyu-post-to-wechat 图文 --markdown article.md --image img1.png --image img2.png --image img3.png
/baoyu-post-to-wechat 图文 --title "标题" --content "内容" --image img1.png --submit
```
**文章模式** - 完整 markdown/HTML 富文本格式:
```bash
/baoyu-post-to-wechat 文章 --markdown article.md
/baoyu-post-to-wechat 文章 --markdown article.md --theme grace
/baoyu-post-to-wechat 文章 --html article.html
```
前置要求:已安装 Google Chrome,首次运行需扫码登录(登录状态会保存)
## 免责声明
### baoyu-gemini-web
此技能使用 Gemini Web API(逆向工程)。
**警告:** 本项目通过浏览器 cookies 使用非官方 API。使用风险自负。
- 首次运行会打开 Chrome 进行 Google 身份验证
- Cookies 会被缓存供后续使用
- 不保证 API 的稳定性或可用性
## 许可证
MIT
Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

+328
View File
@@ -0,0 +1,328 @@
---
name: baoyu-article-illustrator
description: Smart article illustration skill. Analyzes article content and generates illustrations at positions requiring visual aids with multiple style options. Use when user asks to "add illustrations to article", "generate images for article", or "illustrate article".
---
# Smart Article Illustration Skill
Analyze article structure and content, identify positions requiring visual aids, and generate illustrations with flexible style options.
## Usage
```bash
# Auto-select style based on content
/baoyu-article-illustrator path/to/article.md
# Specify a style
/baoyu-article-illustrator path/to/article.md --style tech
/baoyu-article-illustrator path/to/article.md --style warm
/baoyu-article-illustrator path/to/article.md --style minimal
# Combine with other options
/baoyu-article-illustrator path/to/article.md --style playful
```
## Options
| Option | Description |
|--------|-------------|
| `--style <name>` | Specify illustration style (see Style Gallery below) |
## Style Gallery
### 1. `elegant`
Refined, sophisticated, professional
- **Colors**: Soft coral, muted teal, dusty rose, cream background
- **Elements**: Delicate line work, subtle icons, balanced composition
- **Best for**: Professional articles, thought leadership, business topics
### 2. `tech`
Modern, futuristic, digital
- **Colors**: Deep blue, electric cyan, purple, dark backgrounds
- **Elements**: Geometric shapes, circuit patterns, data visualizations, tech icons
- **Best for**: AI, programming, technology, digital transformation articles
### 3. `warm`
Friendly, approachable, human-centered
- **Colors**: Warm orange, golden yellow, terracotta, cream
- **Elements**: Rounded shapes, friendly characters, sun/light motifs, hearts
- **Best for**: Personal growth, lifestyle, education, human interest stories
### 4. `bold`
High contrast, impactful, energetic
- **Colors**: Vibrant red/orange, deep black, bright yellow accents
- **Elements**: Strong shapes, dramatic contrast, dynamic compositions
- **Best for**: Opinion pieces, important warnings, call-to-action content
### 5. `minimal`
Ultra-clean, zen-like, focused
- **Colors**: Black, white, single accent color
- **Elements**: Maximum whitespace, single focal element, simple lines
- **Best for**: Philosophy, minimalism, focused explanations
### 6. `playful`
Fun, creative, whimsical
- **Colors**: Pastel rainbow, bright pops, light backgrounds
- **Elements**: Doodles, quirky characters, speech bubbles, emoji-style icons
- **Best for**: Tutorials, beginner guides, casual content, fun topics
### 7. `nature`
Organic, calm, earthy
- **Colors**: Forest green, earth brown, sky blue, sand beige
- **Elements**: Plant motifs, natural textures, flowing lines, organic shapes
- **Best for**: Sustainability, wellness, outdoor topics, slow living
### 8. `sketch`
Raw, authentic, notebook-style
- **Colors**: Pencil gray, paper white, occasional color highlights
- **Elements**: Rough lines, sketch marks, handwritten notes, arrows
- **Best for**: Ideas in progress, brainstorming, thought processes
### 9. `notion` (Default)
Minimalist hand-drawn line art, intellectual
- **Colors**: Black outlines, white background, 1-2 pastel accents
- **Elements**: Simple line doodles, geometric shapes, hand-drawn wobble, maximum whitespace
- **Best for**: Knowledge sharing, concept explanations, SaaS content, productivity articles
## Auto Style Selection
When no `--style` is specified, analyze content to select the best style:
| Content Signals | Selected Style |
|----------------|----------------|
| AI, coding, tech, digital, algorithm, data | `tech` |
| Personal story, emotion, growth, life, feeling | `warm` |
| Warning, urgent, must-read, critical, important | `bold` |
| Simple, zen, focus, essential, core | `minimal` |
| Fun, easy, beginner, tutorial, guide, how-to | `playful` |
| Nature, eco, wellness, health, organic, green | `nature` |
| Idea, thought, concept, draft, brainstorm | `sketch` |
| Business, professional, strategy, analysis | `elegant` |
| Knowledge, concept, productivity, SaaS, notion | `notion` |
## File Management
Save illustrations to `imgs/` subdirectory in the same folder as the article:
```
path/to/
├── article.md
└── imgs/
├── outline.md
├── prompts/
│ ├── illustration-concept-a.md
│ ├── illustration-concept-b.md
│ └── ...
├── illustration-concept-a.png
├── illustration-concept-b.png
└── ...
```
## Workflow
### Step 1: Analyze Content & Select Style
1. Read article content
2. If `--style` specified, use that style
3. Otherwise, scan for style signals and auto-select
4. Extract key information:
- Main topic and themes
- Core messages per section
- Abstract concepts needing visualization
### Step 2: Identify Illustration Positions
**Three Purposes of Illustrations**:
1. **Information Supplement**: Help understand abstract concepts
2. **Concept Visualization**: Transform abstract ideas into concrete visuals
3. **Imagination Guidance**: Create atmosphere, enhance reading experience
**Content Suitable for Illustrations**:
- Abstract concepts needing visualization
- Processes/steps needing diagrams
- Comparisons needing visual representation
- Core arguments needing reinforcement
- Scenarios needing imagination guidance
**Illustration Count**:
- Consider at least 1 image per major section
- Prioritize core arguments and abstract concepts
- **Principle: More is better than fewer**
### Step 3: Generate Illustration Plan
```markdown
# Illustration Plan
**Article**: [article path]
**Style**: [selected style]
**Illustration Count**: N images
---
## Illustration 1
**Insert Position**: [section name] / [paragraph description]
**Purpose**: [why illustration needed here]
**Visual Content**: [what the image should show]
**Filename**: illustration-[slug].png
---
## Illustration 2
...
```
### Step 4: Create Prompt Files
Save prompts to `prompts/` directory with style-specific details.
**Prompt Format**:
```markdown
Illustration theme: [concept in 2-3 words]
Style: [style name]
Visual composition:
- Main visual: [description matching style]
- Layout: [element positioning]
- Decorative elements: [style-appropriate decorations]
Color scheme:
- Primary: [style primary color]
- Background: [style background color]
- Accent: [style accent color]
Text content (if any):
- [Any labels or captions in content language]
Style notes: [specific style characteristics]
```
### Step 5: Generate Images
**Image Generation Skill Selection**:
1. Check available image generation skills
2. If multiple skills available, ask user to choose
**Generation Flow**:
1. Call selected image generation skill with prompt file and output path
2. Generate images sequentially
3. After each image, output progress: "Generated X/N"
4. On failure, auto-retry once
5. If retry fails, log reason, continue to next
### Step 6: Update Article
Insert generated images at corresponding positions:
```markdown
![illustration description](imgs/illustration-[slug].png)
```
**Insertion Rules**:
- Insert image after corresponding paragraph
- Leave one blank line before and after image
- Alt text uses concise description in article's language
### Step 7: Output Summary
```
Article Illustration Complete!
Article: [article path]
Style: [style name]
Generated: X/N images successful
Illustration Positions:
- illustration-xxx.png → After section "Section Name"
- illustration-yyy.png → After section "Another Section"
...
[If any failures]
Failed:
- illustration-zzz.png: [failure reason]
```
## Style Reference Details
### elegant
```
Colors: Soft coral (#E8A598), muted teal (#5B8A8A), dusty rose (#D4A5A5)
Background: Warm cream (#F5F0E6), soft beige
Accents: Gold (#C9A962), copper
Elements: Delicate lines, refined icons, subtle gradients, balanced whitespace
```
### tech
```
Colors: Deep blue (#1A365D), electric cyan (#00D4FF), purple (#6B46C1)
Background: Dark gray (#1A202C), near-black (#0D1117)
Accents: Neon green (#00FF88), bright white
Elements: Circuit patterns, data nodes, geometric grids, glowing effects
```
### warm
```
Colors: Warm orange (#ED8936), golden yellow (#F6AD55), terracotta (#C05621)
Background: Cream (#FFFAF0), soft peach (#FED7AA)
Accents: Deep brown (#744210), soft red
Elements: Rounded shapes, smiling faces, sun rays, hearts, cozy lighting
```
### bold
```
Colors: Vibrant red (#E53E3E), bright orange (#DD6B20), electric yellow (#F6E05E)
Background: Deep black (#000000), dark charcoal
Accents: White, neon highlights
Elements: Strong shapes, exclamation marks, arrows, dramatic contrast
```
### minimal
```
Colors: Pure black (#000000), white (#FFFFFF)
Background: White or off-white (#FAFAFA)
Accents: Single color (content-derived)
Elements: Single focal point, maximum negative space, thin precise lines
```
### playful
```
Colors: Pastel pink (#FED7E2), mint (#C6F6D5), lavender (#E9D8FD), sky blue (#BEE3F8)
Background: Light cream (#FFFBEB), soft white
Accents: Bright pops - yellow, coral, turquoise
Elements: Doodles, stars, swirls, cute characters, speech bubbles
```
### nature
```
Colors: Forest green (#276749), sage (#9AE6B4), earth brown (#744210)
Background: Sand beige (#F5E6D3), sky blue (#E0F2FE)
Accents: Sunset orange, water blue
Elements: Leaves, trees, mountains, organic flowing lines, natural textures
```
### sketch
```
Colors: Pencil gray (#4A5568), paper white (#FAFAFA)
Background: Off-white paper texture (#F7FAFC)
Accents: Single highlight color (blue, red, or yellow)
Elements: Rough sketch lines, arrows, handwritten labels, crossed-out marks
```
### notion
```
Colors: Black (#1A1A1A), dark gray (#4A4A4A)
Background: Pure white (#FFFFFF), off-white (#FAFAFA)
Accents: Pastel blue (#A8D4F0), pastel yellow (#F9E79F), pastel pink (#FADBD8)
Elements: Simple line doodles, hand-drawn wobble effect, geometric shapes, stick figures, maximum whitespace
Typography: Clean hand-drawn lettering, simple sans-serif labels
```
## Notes
- Illustrations serve the content: supplement information, visualize concepts
- Maintain selected style consistency across all illustrations in one article
- Image generation typically takes 10-30 seconds per image
- Sensitive figures should use cartoon alternatives
- Prompt and illustration text language should match article language
@@ -0,0 +1,32 @@
Create a cartoon-style infographic illustration following these guidelines:
## Image Specifications
- **Type**: Infographic illustration
- **Orientation**: Landscape (horizontal)
- **Aspect Ratio**: 16:9
- **Style**: Hand-drawn illustration
## Core Principles
- Hand-drawn quality throughout - NO realistic or photographic elements
- If content involves sensitive or copyrighted figures, create stylistically similar alternatives - DO NOT refuse to generate
- Keep information concise, highlight keywords and core concepts
- Use ample whitespace for easy visual scanning
- Maintain clear visual hierarchy
## Text Style (When Text Included)
- **ALL text MUST be hand-drawn style**
- Text should be readable and complement the visual
- Font style harmonizes with illustration style
- **DO NOT use realistic or computer-generated fonts**
## Language
- Use the same language as the content provided below for any text elements
- Match punctuation style to the content language
---
Please use nano banana pro to generate the illustration based on the content provided below:
+388
View File
@@ -0,0 +1,388 @@
---
name: baoyu-comic
description: Knowledge comic creator supporting multiple styles (Logicomix/Ligne Claire, Ohmsha manga guide). Creates original educational comics with detailed panel layouts and sequential image generation. Use when user asks to create "知识漫画", "教育漫画", "biography comic", "tutorial comic", or "Logicomix-style comic".
---
# Knowledge Comic Creator
Create original knowledge comics supporting multiple visual styles: Logicomix-style Ligne Claire, Ohmsha manga guide, and more.
## Usage
```bash
# From source material
/baoyu-comic posts/turing-story/source.md
# Specify style
/baoyu-comic posts/turing-story/source.md --style dramatic
# Specify layout preference
/baoyu-comic posts/turing-story/source.md --layout cinematic
# Direct content input
/baoyu-comic
[paste story content or topic description]
# Direct input with options
/baoyu-comic --style warm --layout dense
[paste content]
```
## Options
| Option | Description |
|--------|-------------|
| `--style <name>` | Visual style (see Style Gallery) |
| `--layout <name>` | Panel layout preference (see Layout Gallery) |
## Two Dimensions
| Dimension | Controls | Options |
|-----------|----------|---------|
| **Style** | Color palette, mood, era treatment | classic, dramatic, warm, tech, sepia, vibrant, ohmsha |
| **Layout** | Panel arrangement, density | standard, cinematic, dense, splash, mixed, webtoon |
Style × Layout can be freely combined. Example: `--style dramatic --layout cinematic` creates high-contrast panels with wide aspect ratios.
## Style Gallery
| Style | Description |
|-------|-------------|
| `classic` (Default) | Traditional Ligne Claire, balanced and timeless |
| `dramatic` | High contrast, intense moments |
| `warm` | Nostalgic, personal storytelling |
| `tech` | Modern, digital-age narratives |
| `sepia` | Historical, archival feel |
| `vibrant` | Energetic, engaging, educational |
| `ohmsha` | Ohmsha Manga Guide style - educational manga with visual metaphors |
Detailed style definitions: `references/styles/<style>.md`
## Layout Gallery
| Layout | Description |
|--------|-------------|
| `standard` (Default) | Classic comic grid, versatile (4-6 panels) |
| `cinematic` | Wide panels, filmic feel (2-4 panels) |
| `dense` | Information-rich, educational focus (6-9 panels) |
| `splash` | Impact-focused, key moments (1-2 large + 2-3 small) |
| `mixed` | Dynamic, varied rhythm (3-7 panels) |
| `webtoon` | Vertical scrolling comic (竖版条漫) |
Detailed layout definitions: `references/layouts/<layout>.md`
## Auto Style Selection
When no `--style` is specified, analyze content to select:
| Content Signals | Selected Style |
|----------------|----------------|
| Tutorial, how-to, learning guide, beginner | `ohmsha` |
| Computing, AI, digital, programming | `tech` |
| Pre-1950, classical, ancient | `sepia` |
| Personal story, childhood, mentor | `warm` |
| Conflict, struggle, breakthrough | `dramatic` |
| Young audience, science basics, wonder | `vibrant` |
| Balanced narrative, biography | `classic` |
## Auto Layout Selection
When no `--layout` is specified, analyze content to select:
| Content Signals | Selected Layout |
|----------------|-----------------|
| Technical explanation, timeline | `dense` |
| Key discovery, major event | `splash` |
| Establishing scene, location | `cinematic` |
| Dialogue, conversation, debate | `standard` |
| Mixed content, varied pacing | `mixed` |
| Ohmsha style, tutorial, vertical scroll | `webtoon` |
## Style × Layout Matrix
| | standard | cinematic | dense | splash | mixed | webtoon |
|---|:---:|:---:|:---:|:---:|:---:|:---:|
| classic | ✓✓ | ✓✓ | ✓ | ✓✓ | ✓✓ | ✓ |
| dramatic | ✓ | ✓✓ | ✓ | ✓✓ | ✓✓ | ✓ |
| warm | ✓✓ | ✓✓ | ✓ | ✓ | ✓ | ✓ |
| tech | ✓✓ | ✓ | ✓✓ | ✓ | ✓✓ | ✓✓ |
| sepia | ✓✓ | ✓✓ | ✓ | ✓ | ✓ | ✓ |
| vibrant | ✓✓ | ✓ | ✓ | ✓✓ | ✓✓ | ✓✓ |
| ohmsha | ✓ | ✓ | ✓✓ | ✓ | ✓✓ | ✓✓ |
✓✓ = highly recommended, ✓ = works well
## File Management
### With Source Path
Save to `comic/` subdirectory in the same folder as the source:
```
posts/turing-story/
├── source.md
└── comic/
├── outline.md
├── prompts/
│ ├── 00-cover.md
│ ├── 01-page.md
│ └── ...
├── 00-cover.png
├── 01-page.png
└── ...
```
### Without Source Path
Save to `comic-outputs/YYYY-MM-DD/[topic-slug]/`:
```
comic-outputs/
└── 2026-01-16/
└── turing-biography/
├── outline.md
├── prompts/
│ ├── 00-cover.md
│ └── ...
├── 00-cover.png
└── ...
```
**Directory collision**: If slug exists, generate a different slug (do not overwrite).
## Workflow
### Step 1: Analyze Content & Determine Scope
1. Read source content
2. If `--style` specified, use it; otherwise auto-select
3. If `--layout` specified, use it; otherwise auto-select per page
4. Determine page count based on content complexity:
| Content Type | Page Count (incl. cover) |
|-------------|--------------------------|
| Single event / short story | 5-8 |
| Medium complexity / mini biography | 9-15 |
| Full biography / multi-thread | 16-25 |
### Step 2: Generate Outline
Plan cover + each page with detailed panel breakdowns:
```markdown
# [Comic Title] - Knowledge Biography Comic Outline
**Topic**: [topic description]
**Time Span**: [e.g., 1912-1954]
**Style**: [selected style]
**Default Layout**: [selected layout or "varies"]
**Page Count**: Cover + N pages
**Generated**: YYYY-MM-DD HH:mm
---
## Cover
**Filename**: 00-cover.png
**Core Message**: [one-liner]
**Visual Design**:
- Title typography style
- Main visual composition
- Color scheme
- Subtitle / time span notation
**Visual Prompt**:
[Detailed image generation prompt]
---
## Page 1 / N
**Filename**: 01-page.png
**Layout**: [standard/cinematic/dense/splash/mixed]
**Narrative Layer**: [Main narrative / Narrator layer / Mixed]
**Core Message**: [What this page conveys]
### Panel Layout
**Panel Count**: X
**Layout Type**: [grid/irregular/splash]
#### Panel 1 (Size: 1/3 page, Position: Top)
**Scene**: [Time, location]
**Image Description**:
- Camera angle: [bird's eye / low angle / eye level / close-up / wide shot]
- Characters: [pose, expression, action]
- Environment: [scene details, period markers]
- Lighting: [atmosphere description]
- Color tone: [palette reference]
**Text Elements**:
- Dialogue bubble (oval): "Character line"
- Narrator box (rectangular): 「Narrator commentary」
- Caption bar: [Background info text]
#### Panel 2...
**Page Hook**: [Cliffhanger or transition at page end]
**Visual Prompt**:
[Full page image generation prompt]
---
## Page 2 / N
...
```
### Step 3: Save Outline
Save as `outline.md` in target directory.
### Step 4: Generate Images Page by Page
For each page:
1. **Save prompt file** to `prompts/` subdirectory
2. **Call image generation skill**:
```bash
npx -y bun skills/baoyu-gemini-web/scripts/main.ts \
--promptfiles skills/baoyu-comic/prompts/system.md [target]/prompts/00-cover.md \
--image [target]/00-cover.png
```
After each generation:
1. Confirm success
2. Report progress: "Generated X/N pages"
3. Continue to next
### Step 5: Completion Report
```
Knowledge Biography Comic Complete!
Title: [Comic Title]
Time Span: [e.g., 1912-1954]
Style: [style name]
Layout: [layout name or "varies"]
Location: [directory path]
Pages: Cover + N pages
- 00-cover.png ✓ Cover
- 01-page.png ✓ Opening: [brief]
- 02-page.png ✓ [brief]
- ...
- XX-page.png ✓ Ending: [brief]
Outline: outline.md
```
## Narrative Structure Principles
### Cover Design
- Academic gravitas with visual appeal
- Title typography reflecting knowledge/science theme
- Composition hinting at core theme (character silhouette, iconic symbol, concept diagram)
- Subtitle or time span for epic scope
### Panel Composition Principles
| Panel Type | Recommended Count | Usage |
|-----------|-------------------|-------|
| Main narrative | 3-5 per page | Story progression |
| Concept diagram | 1-2 per page | Visualize abstractions |
| Narrator panel | 0-1 per page | Commentary, transition, questions |
| Splash (full/half) | Occasional | Major moments |
### Panel Size Reference
- **Full page (Splash)**: Major moments, key breakthroughs
- **Half page**: Important scenes, turning points
- **1/3 page**: Standard narrative panels
- **1/4 or smaller**: Quick progression, sequential action
### Concept Visualization Techniques
Transform abstract concepts into concrete visuals:
| Abstract Concept | Visual Approach |
|-----------------|-----------------|
| Neural network | Glowing nodes with connecting lines |
| Gradient descent | Ball rolling down valley terrain |
| Data flow | Luminous particles flowing through pipes |
| Algorithm iteration | Ascending spiral staircase |
| Breakthrough moment | Shattering barrier, piercing light |
| Logical proof | Building blocks assembling |
| Uncertainty | Forking paths, fog, multiple shadows |
### Text Element Design
| Text Type | Style | Usage |
|-----------|-------|-------|
| Character dialogue | Oval speech bubble | Main narrative speech |
| Narrator commentary | Rectangular / hand-drawn box | Explanation, commentary |
| Caption bar | Edge-mounted rectangle | Time, location info |
| Thought bubble | Cloud shape | Character inner monologue |
| Term label | Bold / special color | First appearance of technical terms |
## Ohmsha Style Special Requirements
When using `--style ohmsha`, follow these additional guidelines:
### Character Setup (Required)
Define characters before generating outline:
| Role | Default | Traits |
|------|---------|--------|
| Student (Role A) | 大雄 | Confused, asks basic but crucial questions, represents reader |
| Mentor (Role B) | 哆啦A梦 | Knowledgeable, patient, uses gadgets as technical metaphors |
| Antagonist (Role C, optional) | 胖虎 | Represents misunderstanding, or "noise" in the data |
User can specify custom characters via `--characters "Student:小明,Mentor:教授,Antagonist:Bug怪"`
### Outline Spec Block (Required)
Every ohmsha outline must start with a spec block:
```markdown
【漫画规格单】
- Language: [Same as input content]
- Style: Ohmsha (Manga Guide), Full Color
- Layout: Vertical Scrolling Comic (竖版条漫)
- Characters: [List character names and roles]
- Page Limit: ≤20 pages
```
### Visual Metaphor Rules (Critical)
**NEVER** create "talking heads" panels. Every technical concept must become:
1. **A tangible gadget/prop** - Something characters can hold, use, demonstrate
2. **An action scene** - Characters doing something that illustrates the concept
3. **A visual environment** - Stepping into a metaphorical space
### Page Title Convention
Avoid AI-style "Title: Subtitle" format. Use narrative descriptions:
- ❌ "Page 3: Introduction to Neural Networks"
- ✓ "Page 3: 大雄被海量单词淹没,哆啦A梦拿出'词向量压缩机'"
### Ending Requirements
- NO generic endings ("What will you choose?", "Thanks for reading")
- End with: Technical summary moment OR character achieving a small goal
- Final panel: Sense of accomplishment, not open-ended question
## Notes
- Image generation typically takes 30-60 seconds per page
- Auto-retry once on generation failure
- Historical figures rendered in portrait/illustration style for recognizability
- All text in Chinese unless source is in another language
- Each panel description should be detailed enough to serve as standalone AI art prompt
- Maintain style consistency across all pages in series
@@ -0,0 +1,98 @@
Create a knowledge biography comic page following these guidelines:
## Image Specifications
- **Type**: Comic book page with multiple panels
- **Orientation**: Portrait (vertical)
- **Aspect Ratio**: 2:3
- **Style**: See style-specific reference for visual guidelines
## Panel Structure
### Panel Borders
- Clean black lines (1-2px) around each panel
- White gutters between panels (8-12px)
- Panels arranged for clear reading flow
- Variety in panel sizes for visual rhythm
### Panel Composition
- Clear focal points in each panel
- Proper use of foreground, midground, background
- Camera angles vary: eye level, bird's eye, low angle, close-up, wide shot
- Action flows logically between panels
- Negative space used intentionally
## Text Elements
### Speech Bubbles
- **Dialogue**: Oval/elliptical bubbles with pointed tails
- White fill with thin black outline
- Tail points clearly to speaker
- Hand-lettered style font (not computer-generated)
### Narrator Boxes
- **Fourth Wall/Narrator**: Rectangular boxes
- Often positioned at panel edges (top or bottom)
- Slightly different fill color (cream or light yellow)
- Used for commentary, time jumps, explanations
### Thought Bubbles
- Cloud-shaped with bubble trail leading to thinker
- Softer outline than speech bubbles
- For internal monologue
### Caption Bars
- Rectangular bars at panel edges
- Time and place information
- "Meanwhile...", "Three years later..." type transitions
- Darker fill with white text, or vice versa
### Typography
- Hand-drawn lettering style throughout
- Bold for emphasis and key terms
- Consistent letter sizing
- Chinese text: use full-width punctuation "",。!
- Clear hierarchy: titles > dialogue > captions
## Scientific/Concept Visualization
When depicting abstract concepts:
| Concept | Visual Metaphor |
|---------|----------------|
| Neural networks | Glowing nodes connected by clean lines |
| Data flow | Luminous particles along simple paths |
| Algorithms | Geometric patterns, building blocks |
| Logic/proof | Interlocking puzzle pieces |
| Discovery | Light breaking through darkness |
| Uncertainty | Forking paths, question marks |
| Time | Clock motifs, calendar pages |
- Integrate diagrams naturally into narrative panels
- Use inset panels or thought-bubble style for explanations
- Simplified iconography over realistic depiction
## Fourth Wall / Narrator Character
When depicting narrator characters addressing the reader:
- Character may look directly out of panel
- Can appear in "present day" framing scenes
- Distinct visual treatment from main timeline
- Often at page edges or in dedicated panels
- May comment on or question the events shown
## Historical Accuracy
- Research period-specific details: costumes, technology, architecture
- Show aging naturally for characters across time periods
- Iconic items and locations rendered recognizably
- Balance accuracy with stylization
## Language
- All text in Chinese (中文) unless source material is in another language
- Use Chinese full-width punctuation: "",。!
---
Please generate the comic page based on the content provided below:
@@ -0,0 +1,23 @@
# cinematic
Wide panels, filmic feel
## Panel Structure
- **Panels per page**: 2-4
- **Structure**: Horizontal emphasis, wide aspect panels
- **Gutters**: Generous spacing (12-15px)
## Grid Configuration
- 1-2 columns, horizontal emphasis
- Panel sizes: Wide aspect ratios (3:1, 4:1)
- Reading flow: Horizontal sweep, filmic rhythm
## Best For
Establishing shots, dramatic moments, landscapes
## Best Style Pairings
dramatic, classic, sepia
@@ -0,0 +1,23 @@
# dense
Information-rich, educational focus
## Panel Structure
- **Panels per page**: 6-9
- **Structure**: Compact grid, smaller panels
- **Gutters**: Tight spacing (4-6px)
## Grid Configuration
- 3 columns × 3 rows
- Panel sizes: Compact, uniform
- Reading flow: Rapid progression, information-rich
## Best For
Technical explanations, complex narratives, timelines
## Best Style Pairings
tech, ohmsha, vibrant
@@ -0,0 +1,23 @@
# mixed
Dynamic, varied rhythm
## Panel Structure
- **Panels per page**: 3-7 (varies)
- **Structure**: Intentionally varied for pacing
- **Gutters**: Dynamic spacing
## Grid Configuration
- Intentionally irregular
- Panel sizes: Varied for pacing and emphasis
- Reading flow: Guides eye through varied rhythm
## Best For
Action sequences, emotional arcs, complex stories
## Best Style Pairings
dramatic, vibrant, ohmsha
@@ -0,0 +1,23 @@
# splash
Impact-focused, key moments
## Panel Structure
- **Panels per page**: 1-2 large + 2-3 small
- **Structure**: Dominant splash with supporting panels
- **Gutters**: Varied for emphasis
## Grid Configuration
- 1 dominant panel + 2-3 supporting
- Panel sizes: 50-70% splash, remainder small
- Reading flow: Splash dominates, supporting panels accent
## Best For
Revelations, breakthroughs, chapter openings
## Best Style Pairings
dramatic, classic, vibrant
@@ -0,0 +1,23 @@
# standard
Classic comic grid, versatile
## Panel Structure
- **Panels per page**: 4-6
- **Structure**: Regular grid with occasional variation
- **Gutters**: Consistent white space (8-10px)
## Grid Configuration
- 2-3 columns × 2-3 rows
- Panel sizes: Mostly equal, occasional variation
- Reading flow: Left→right, top→bottom (Z-pattern)
## Best For
Narrative flow, dialogue scenes
## Best Style Pairings
classic, warm, sepia
@@ -0,0 +1,30 @@
# webtoon
Vertical scrolling comic (竖版条漫)
## Panel Structure
- **Panels per page**: 3-5 vertically stacked
- **Structure**: Single column, vertical flow optimized for scrolling
- **Gutters**: Generous vertical spacing (20-40px), panels often bleed horizontally
## Grid Configuration
- Single column, vertical stack
- Panel sizes: Full width, variable height (1:1 to 1:2 aspect)
- Reading flow: Top→bottom continuous scroll
## Special Features
- Panels can extend beyond frame for dramatic effect
- Generous whitespace between beats
- Character close-ups alternate with wide explanation panels
- "Float" effect - elements can exist between panels
## Best For
Ohmsha-style tutorials, mobile reading, step-by-step guides
## Best Style Pairings
ohmsha, tech, vibrant
@@ -0,0 +1,54 @@
# classic
Traditional Ligne Claire, balanced and timeless
## Style Guidelines
### Line Work
- Uniform, clean outlines with consistent weight (approximately 2px)
- No hatching or cross-hatching for shading
- Sharp, precise edges on all elements
- Black ink outlines on all figures and objects
- Shadows indicated through flat color areas, not line techniques
### Character Design
- Slightly stylized/cartoonish characters with realistic proportions
- Distinctive, recognizable facial features
- Expressive faces with clear emotions
- Period-appropriate clothing with attention to detail
- Consistent character appearance across panels
### Background Treatment
- Detailed, realistic backgrounds with architectural accuracy
- Period-specific props and technology
- Clear spatial depth and perspective
- Environmental storytelling through details
- Contrast between simplified characters and detailed backgrounds
## Color Palette
- Primary: Clean blue (#3182CE), red (#E53E3E), yellow (#ECC94B)
- Skin: Warm tan (#F7CFAE)
- Background: Light cream (#FFFAF0), sky blue (#BEE3F8)
## Color Approach
- Flat colors without gradients (true to Ligne Claire tradition)
- Limited palette per page for cohesion
- Colors support narrative mood
- Consistent lighting logic within scenes
## Quality Markers
A good Ligne Claire comic page exhibits:
- ✓ Clean, uniform line weight throughout
- ✓ Flat colors without gradients
- ✓ Detailed backgrounds, stylized characters
- ✓ Clear panel borders and reading flow
- ✓ Hand-drawn text style
- ✓ Period-appropriate details
- ✓ Expressive but consistent characters
- ✓ Proper perspective in environments
## Best For
Educational content, balanced narratives, biography comics
@@ -0,0 +1,34 @@
# dramatic
High contrast, intense moments
## Style Guidelines
### Line Work
- 2-3px outlines, heavier on shadows
- Dramatic angles and perspectives
- Strong contrast between light and dark areas
### Character Design
- Intense expressions, dynamic poses
- Dramatic lighting on faces
- Sharp angular features emphasized
### Background Treatment
- High contrast, angular shadows
- Dramatic lighting effects
- Silhouettes and stark compositions
## Color Palette
- Primary: Deep navy (#1A365D), crimson (#9B2C2C), stark white
- Shadows: Heavy blacks, dramatic contrast
- Highlights: Sharp whites, limited mid-tones
## Mood
Tension, breakthrough moments, conflict
## Best For
Pivotal discoveries, conflicts, climactic scenes
@@ -0,0 +1,107 @@
# ohmsha
Ohmsha Manga Guide style - educational manga with visual metaphors
## Core Philosophy
Educational manga where every concept becomes a visual metaphor or action. NO talking heads - characters must DO things, not just explain.
## Visual Style
- **Type**: Manga-style educational comic
- **Orientation**: Portrait (vertical), optimized for scrolling
- **Colors**: Full color, bright and clean anime/manga aesthetic
- **Lines**: Clean manga lines (1.5-2px), smooth curves, expressive
## Character Design (Manga Style)
- Anime/manga proportions: slightly larger eyes, expressive faces
- **Student character**: Confused expressions, question marks (), sweat drops, represents reader
- **Mentor character**: Confident poses, explanatory gestures, produces gadgets/tools
- Clear emotional indicators using manga conventions (, , sweat drops, sparkles)
- Consistent character designs across all panels
## Background Treatment
- Simplified backgrounds during dialogue/explanation
- Detailed "imagination spaces" for concept visualization
- Technical diagrams styled as holographic displays or magical blueprints
- Screen tone effects for atmosphere
## Visual Metaphor Requirements (CRITICAL)
Every technical concept MUST be visualized as:
| Concept Type | Visualization Approach |
|-------------|----------------------|
| Algorithm | Gadget/machine that demonstrates the process |
| Data structure | Physical space characters can enter/explore |
| Mathematical formula | Transformation visible in environment |
| Abstract process | Tangible flow of particles/objects |
**Wrong approach**: Character points at blackboard explaining
**Right approach**: Character uses "Concept Visualizer" gadget, steps into metaphorical space
### Visual Metaphor Examples
| Concept | Wrong (Talking Head) | Right (Visual Metaphor) |
|---------|---------------------|------------------------|
| Attention mechanism | Character points at formula on blackboard | "Attention Flashlight" gadget illuminates key words in dark room |
| Gradient descent | "The algorithm minimizes loss" | Character rides ball rolling down mountain valley |
| Neural network | Diagram with arrows | Living network of glowing creatures passing messages |
| Overfitting | "The model memorized the data" | Character wearing clothes that fit only one specific pose |
## Panel Layout for Ohmsha
- Vertical scroll optimized (webtoon style)
- Single column, panels stack vertically
- Generous whitespace between major beats
- Panels can bleed to edges for impact
- "Float" elements between panels for emphasis
## Special Visual Elements
- **Gadget reveals**: Dramatic unveiling with sparkle effects
- **Concept spaces**: Rounded borders, glowing edges for "imagination mode"
- **Information displays**: Holographic UI style for technical details
- **Aha moments**: Radial lines, light burst effects
- **Confusion**: Spiral eyes, question marks floating above head
## Text Elements (Ohmsha)
- Hand-lettered manga style
- Sound effects integrated visually (ドキドキ, ピカーン, etc.)
- Speech bubbles: rounded for normal, spiky for excitement/shock
- Thought bubbles for internal process visualization
- Technical terms in bold with furigana-style annotations if needed
## Color Palette
- Primary: Bright blue (#4299E1), warm orange (#ED8936), soft green (#68D391)
- Skin: Anime-style warm (#FEEBC8)
- Background: Clean white, soft gradients
- Gadgets: Metallic accents (#FFD700, #C0C0C0), vibrant highlights
- Concept spaces: Pastel backgrounds, glowing accents
## Quality Markers (Ohmsha)
- ✓ Every concept is a visual metaphor, not just explained
- ✓ Characters are DOING things, not just talking
- ✓ Clear student/mentor dynamic
- ✓ Gadgets and props drive the explanation
- ✓ Expressive manga-style emotions
- ✓ Information density through visual design, not text walls
## Character Setup (Required)
Define characters before generating:
| Role | Default | Traits |
|------|---------|--------|
| Student (Role A) | 大雄 | Confused, asks basic but crucial questions, represents reader |
| Mentor (Role B) | 哆啦A梦 | Knowledgeable, patient, uses gadgets as technical metaphors |
| Antagonist (Role C, optional) | 胖虎 | Represents misunderstanding, or "noise" in the data |
## Best For
Technical tutorials, complex concepts (ML, physics, math), self-study material
@@ -0,0 +1,34 @@
# sepia
Historical, archival feel
## Style Guidelines
### Line Work
- 2px, classic weight with aged texture
- Vintage illustration style
- Period-appropriate techniques
### Character Design
- Period-accurate attire, formal poses
- Historical accuracy emphasized
- Classical proportions
### Background Treatment
- Historical settings, vintage details
- Aged paper effect
- Period architecture and props
## Color Palette
- Primary: Sepia brown (#8B7355), aged paper (#F5E6D3)
- Accents: Faded teal, muted burgundy
- Background: Yellowed paper, vintage tones
## Mood
Historical distance, period authenticity
## Best For
Pre-1950s stories, classical science, historical figures
@@ -0,0 +1,34 @@
# tech
Modern, digital-age narratives
## Style Guidelines
### Line Work
- 2px, precise geometric undertones
- Clean, technical precision
- Circuit-like patterns in backgrounds
### Character Design
- Contemporary clothing, focused expressions
- Modern tech accessories
- Clean, precise features
### Background Treatment
- Digital elements, screens, circuit motifs
- Grid patterns
- Glowing interface elements
## Color Palette
- Primary: Cyan (#00D4FF), deep blue (#1A365D), white
- Accents: Neon green (#00FF88), electric purple (#805AD5)
- Background: Dark gray (#1A202C), grid patterns
## Mood
Innovation, digital, contemporary
## Best For
Computing history, AI stories, modern tech
@@ -0,0 +1,34 @@
# vibrant
Energetic, engaging, educational
## Style Guidelines
### Line Work
- 2-2.5px, expressive weight variation
- Dynamic, energetic lines
- Emphasis on movement and action
### Character Design
- Expressive, animated, approachable
- Wide eyes, big reactions
- Dynamic poses
### Background Treatment
- Simplified, focus on action
- Bright, clean compositions
- Energy effects and motion lines
## Color Palette
- Primary: Bright red (#F56565), sunny yellow (#F6E05E), sky blue (#63B3ED)
- Accents: Magenta, lime green
- Background: Clean white, bright pastels
## Mood
Excitement, discovery, wonder
## Best For
Science explanations, "aha" moments, young audience
@@ -0,0 +1,34 @@
# warm
Nostalgic, personal storytelling
## Style Guidelines
### Line Work
- 1.5-2px, slightly softer edges
- Gentle curves, friendly feel
- Less rigid than classic style
### Character Design
- Friendly expressions, relaxed poses
- Warm, inviting character designs
- Approachable proportions
### Background Treatment
- Cozy interiors, warm lighting
- Nostalgic feel
- Soft focus on backgrounds
## Color Palette
- Primary: Golden (#D69E2E), orange (#DD6B20), soft brown (#8B6F47)
- Skin: Warm golden (#FEEBC8)
- Background: Warm cream (#FEF3C7), sunset tones
## Mood
Memory, personal journey, reflection
## Best For
Personal stories, childhood scenes, mentorship
+186
View File
@@ -0,0 +1,186 @@
---
name: baoyu-cover-image
description: Generate elegant cover images for articles. Analyzes content and creates eye-catching hand-drawn style cover images with multiple style options. Use when user asks to "generate cover image", "create article cover", or "make a cover for article".
---
# Cover Image Generator
Generate hand-drawn style cover images for articles with multiple style options.
## Usage
```bash
# From markdown file (auto-select style based on content)
/baoyu-cover-image path/to/article.md
# Specify a style
/baoyu-cover-image path/to/article.md --style tech
/baoyu-cover-image path/to/article.md --style warm
/baoyu-cover-image path/to/article.md --style bold
# Without title text
/baoyu-cover-image path/to/article.md --no-title
# Combine options
/baoyu-cover-image path/to/article.md --style minimal --no-title
# From direct text input
/baoyu-cover-image
[paste content or describe the topic]
# Direct input with style
/baoyu-cover-image --style playful
[paste content]
```
## Options
| Option | Description |
|--------|-------------|
| `--style <name>` | Specify cover style (see Style Gallery below) |
| `--no-title` | Generate cover without title text (visual only) |
## Style Gallery
| Style | Description |
|-------|-------------|
| `elegant` (Default) | Refined, sophisticated, understated |
| `tech` | Modern, clean, futuristic |
| `warm` | Friendly, approachable, human-centered |
| `bold` | High contrast, attention-grabbing, energetic |
| `minimal` | Ultra-clean, zen-like, focused |
| `playful` | Fun, creative, whimsical |
| `nature` | Organic, calm, earthy |
| `retro` | Vintage, nostalgic, classic |
Detailed style definitions: `references/styles/<style>.md`
## Auto Style Selection
When no `--style` is specified, the system analyzes content to select the best style:
| Content Signals | Selected Style |
|----------------|----------------|
| AI, coding, tech, digital, algorithm | `tech` |
| Personal story, emotion, growth, life | `warm` |
| Controversial, urgent, must-read, warning | `bold` |
| Simple, zen, focus, essential | `minimal` |
| Fun, easy, beginner, casual, tutorial | `playful` |
| Nature, eco, wellness, health, organic | `nature` |
| History, classic, vintage, old, traditional | `retro` |
| Business, professional, strategy, analysis | `elegant` |
## File Management
### With Article Path
Save to `imgs/` subdirectory in the same folder as the article:
```
path/to/
├── article.md
└── imgs/
├── prompts/
│ └── cover.md
└── cover.png
```
### Without Article Path
Save to current working directory:
```
./
├── cover-prompt.md
└── cover.png
```
## Workflow
### Step 1: Analyze Content
Extract key information:
- **Main topic**: What is the article about?
- **Core message**: What's the key takeaway?
- **Tone**: Serious, playful, inspiring, educational?
- **Keywords**: Identify style-signaling words
### Step 2: Select Style
If `--style` specified, use that style. Otherwise:
1. Scan content for style signals (see Auto Style Selection table)
2. Match signals to most appropriate style
3. Default to `elegant` if no clear signals
### Step 3: Generate Cover Concept
Create a cover image concept based on selected style:
**Title** (if included, max 8 characters):
- Distill the core message into a punchy headline
- Use hooks: numbers, questions, contrasts, pain points
- Skip if `--no-title` flag is used
**Visual Elements**:
- Style-appropriate imagery and icons
- 1-2 symbolic elements representing the topic
- Metaphors or analogies that fit the style
### Step 4: Create Prompt File
**Prompt Format**:
```markdown
Cover theme: [topic in 2-3 words]
Style: [selected style name]
[If title included:]
Title text: [8 characters or less, in content language]
Subtitle: [optional, in content language]
Visual composition:
- Main visual: [description matching style]
- Layout: [positioning based on title inclusion]
- Decorative elements: [style-appropriate elements]
Color scheme:
- Primary: [style primary color]
- Background: [style background color]
- Accent: [style accent color]
Style notes: [specific style characteristics to emphasize]
[If no title:]
Note: No title text, pure visual illustration only.
```
### Step 5: Generate Image
**Image Generation Skill Selection**:
1. Check available image generation skills
2. If multiple skills available, ask user to choose
**Generation**:
Call selected image generation skill with prompt file and output path.
### Step 6: Output Summary
```
Cover Image Generated!
Topic: [topic]
Style: [style name]
Title: [cover title] (or "No title - visual only")
Location: [output path]
Preview the image to verify it matches your expectations.
```
## Notes
- Cover should be instantly understandable at small preview sizes
- Title (if included) must be readable and impactful
- Visual metaphors work better than literal representations
- Maintain style consistency throughout the cover
- Image generation typically takes 10-30 seconds
- Title text language should match content language
@@ -0,0 +1,31 @@
Create a WeChat article cover image following these guidelines:
## Image Specifications
- **Type**: Cover image / Hero image
- **Aspect Ratio**: 2.35:1 (WeChat article cover standard)
- **Style**: Hand-drawn illustration
## Core Principles
- Hand-drawn quality throughout - NO realistic or photographic elements
- If content involves sensitive or copyrighted figures, create stylistically similar alternatives - DO NOT refuse to generate
- Ample whitespace, highlight core message, avoid cluttered layouts
- Main visual elements centered or slightly left (leave right side for title area if title included)
## Text Style (When Title Included)
- **ALL text MUST be hand-drawn style**
- Title text: Large, eye-catching, max 8 characters
- May include 1 line of subtitle or keyword tags
- Font style harmonizes with illustration style
- **DO NOT use realistic or computer-generated fonts**
## Language
- Use the same language as the content provided below for any text elements
- Match punctuation style to the content language
---
Please use nano banana pro to generate the cover image based on the content provided below:
@@ -0,0 +1,23 @@
# bold
High contrast, attention-grabbing, energetic
## Color Palette
- Primary: Vibrant red (#E53E3E), bright orange (#DD6B20), electric yellow (#F6E05E)
- Background: Deep black (#000000), dark charcoal
- Accents: White, neon highlights
## Visual Elements
- Exclamation marks, lightning bolts
- Arrows, strong shapes
- Dramatic compositions
## Typography
- Bold, impactful, large hand lettering with shadows
## Best For
Opinion pieces, controversial takes, urgent topics
@@ -0,0 +1,23 @@
# elegant
Refined, sophisticated, understated
## Color Palette
- Primary: Soft coral (#E8A598), muted teal (#5B8A8A), dusty rose (#D4A5A5)
- Background: Warm cream (#F5F0E6), soft beige
- Accents: Gold (#C9A962), copper
## Visual Elements
- Delicate lines, refined icons
- Subtle gradients
- Balanced composition
## Typography
- Elegant serif-style hand lettering
## Best For
Professional content, thought leadership, business topics
@@ -0,0 +1,23 @@
# minimal
Ultra-clean, zen-like, focused
## Color Palette
- Primary: Pure black (#000000), white (#FFFFFF)
- Background: White or off-white (#FAFAFA)
- Accents: Single color (user's choice or content-derived)
## Visual Elements
- Single focal point
- Maximum negative space
- Thin lines
## Typography
- Clean, simple hand lettering, lots of breathing room
## Best For
Philosophy, minimalism, focused concepts
@@ -0,0 +1,22 @@
# nature
Organic, calm, earthy
## Color Palette
- Primary: Forest green (#276749), sage (#9AE6B4), earth brown (#744210)
- Background: Sand beige (#F5E6D3), sky blue (#E0F2FE)
- Accents: Sunset orange, water blue
## Visual Elements
- Leaves, trees, mountains
- Sun, clouds, organic flowing lines
## Typography
- Organic, flowing hand lettering with natural textures
## Best For
Sustainability, wellness, outdoor topics, slow living
@@ -0,0 +1,23 @@
# playful
Fun, creative, whimsical
## Color Palette
- Primary: Pastel pink (#FED7E2), mint (#C6F6D5), lavender (#E9D8FD), sky blue (#BEE3F8)
- Background: Light cream (#FFFBEB), soft white
- Accents: Bright pops - yellow, coral, turquoise
## Visual Elements
- Doodles, stars, swirls
- Cute characters, emoji-style icons
- Playful compositions
## Typography
- Bouncy, irregular hand lettering, playful angles
## Best For
Casual content, tutorials, beginner guides, fun topics
@@ -0,0 +1,22 @@
# retro
Vintage, nostalgic, classic
## Color Palette
- Primary: Muted orange (#ED8936 at 70%), dusty pink (#FED7E2 at 80%), faded teal
- Background: Aged paper (#F5E6D3), sepia tones
- Accents: Faded red, vintage gold
## Visual Elements
- Halftone dots, vintage badges
- Classic icons, aged textures
## Typography
- Vintage-style hand lettering, classic serif influence
## Best For
History, retrospectives, classic topics, throwback content
@@ -0,0 +1,23 @@
# tech
Modern, clean, futuristic
## Color Palette
- Primary: Deep blue (#1A365D), electric cyan (#00D4FF), purple (#6B46C1)
- Background: Dark gray (#1A202C), near-black (#0D1117)
- Accents: Neon green (#00FF88), bright white
## Visual Elements
- Circuit patterns, data nodes
- Geometric grids, code snippets
- Glowing effects
## Typography
- Monospace-style hand lettering, glowing effects
## Best For
AI, programming, technology, digital transformation
@@ -0,0 +1,22 @@
# warm
Friendly, approachable, human-centered
## Color Palette
- Primary: Warm orange (#ED8936), golden yellow (#F6AD55), terracotta (#C05621)
- Background: Cream (#FFFAF0), soft peach (#FED7AA)
- Accents: Deep brown (#744210), soft red
## Visual Elements
- Rounded shapes, smiling faces
- Sun rays, hearts, warm lighting
## Typography
- Friendly rounded hand lettering
## Best For
Personal growth, lifestyle, education, human stories
@@ -1,6 +1,6 @@
---
name: gemini-web
description: Interacts with Gemini Web to generate text and images. Use when the user needs AI-generated content via Gemini, including text responses and image generation.
name: baoyu-gemini-web
description: Image generation skill using Gemini Web. Generates images from text prompts via Google Gemini. Also supports text generation. Use as the image generation backend for other skills like cover-image, xhs-images, article-illustrator.
---
# Gemini Web Client
@@ -11,6 +11,7 @@ description: Interacts with Gemini Web to generate text and images. Use when the
npx -y bun scripts/main.ts "Hello, Gemini"
npx -y bun scripts/main.ts --prompt "Explain quantum computing"
npx -y bun scripts/main.ts --prompt "A cute cat" --image cat.png
npx -y bun scripts/main.ts --promptfiles system.md content.md --image out.png
```
## Commands
@@ -60,6 +61,7 @@ npx -y bun scripts/main.ts "Hello" --json
| Option | Short | Description |
|--------|-------|-------------|
| `--prompt <text>` | `-p` | Prompt text |
| `--promptfiles <files...>` | | Read prompt from files (concatenated in order) |
| `--model <id>` | `-m` | Model: gemini-3-pro (default), gemini-2.5-pro, gemini-2.5-flash |
| `--image [path]` | | Generate image, save to path (default: generated.png) |
| `--json` | | Output as JSON |
@@ -108,3 +110,9 @@ npx -y bun scripts/main.ts "A photorealistic image of a golden retriever puppy"
```bash
npx -y bun scripts/main.ts "Hello" --json | jq '.text'
```
### Generate image from prompt files
```bash
# Concatenate system.md + content.md as prompt
npx -y bun scripts/main.ts --promptfiles system.md content.md --image output.png
```
@@ -100,7 +100,7 @@ export async function loadGeminiCookies(
}
log?.(
'[gemini-web] Missing Gemini auth cookies. Run `npx -y bun skills/gemini-web/scripts/main.ts --login` to sign in and refresh cookies.',
'[gemini-web] Missing Gemini auth cookies. Run `npx -y bun skills/baoyu-gemini-web/scripts/main.ts --login` to sign in and refresh cookies.',
);
return merged;
}
@@ -124,7 +124,7 @@ export function createGeminiWebExecutor(
const cookieMap = await loadGeminiCookies(runOptions.config, log);
if (!hasRequiredGeminiCookies(cookieMap)) {
throw new Error(
'Gemini browser mode requires auth cookies (missing __Secure-1PSID/__Secure-1PSIDTS). Run `npx -y bun skills/gemini-web/scripts/main.ts --login` to sign in and save cookies.',
'Gemini browser mode requires auth cookies (missing __Secure-1PSID/__Secure-1PSIDTS). Run `npx -y bun skills/baoyu-gemini-web/scripts/main.ts --login` to sign in and save cookies.',
);
}
@@ -16,12 +16,14 @@ function printUsage(exitCode = 0): never {
const profileDir = resolveGeminiWebChromeProfileDir();
console.log(`Usage:
npx -y bun skills/gemini-web/scripts/main.ts --prompt "Hello"
npx -y bun skills/gemini-web/scripts/main.ts "Hello"
npx -y bun skills/gemini-web/scripts/main.ts --prompt "A cute cat" --image generated.png
npx -y bun skills/baoyu-gemini-web/scripts/main.ts --prompt "Hello"
npx -y bun skills/baoyu-gemini-web/scripts/main.ts "Hello"
npx -y bun skills/baoyu-gemini-web/scripts/main.ts --prompt "A cute cat" --image generated.png
npx -y bun skills/baoyu-gemini-web/scripts/main.ts --promptfiles system.md content.md --image out.png
Options:
-p, --prompt <text> Prompt text
--promptfiles <files...> Read prompt from one or more files (concatenated in order)
-m, --model <id> gemini-3-pro | gemini-2.5-pro | gemini-2.5-flash (default: gemini-3-pro)
--json Output JSON
--image [path] Generate an image and save it (default: ./generated.png)
@@ -51,8 +53,22 @@ async function readPromptFromStdin(): Promise<string | null> {
return text ? text : null;
}
function readPromptFiles(filePaths: string[]): string {
const contents: string[] = [];
for (const filePath of filePaths) {
const resolved = path.isAbsolute(filePath) ? filePath : path.resolve(process.cwd(), filePath);
if (!fs.existsSync(resolved)) {
throw new Error(`Prompt file not found: ${resolved}`);
}
const content = fs.readFileSync(resolved, 'utf8').trim();
contents.push(content);
}
return contents.join('\n\n');
}
function parseArgs(argv: string[]): {
prompt?: string;
promptFiles?: string[];
model?: string;
json?: boolean;
imagePath?: string;
@@ -101,6 +117,19 @@ function parseArgs(argv: string[]): {
out.prompt = arg.slice('--prompt='.length);
continue;
}
if (arg === '--promptfiles') {
out.promptFiles = [];
while (i + 1 < argv.length) {
const next = argv[i + 1];
if (next && !next.startsWith('-')) {
out.promptFiles.push(next);
i += 1;
} else {
break;
}
}
continue;
}
if (arg === '--model' || arg === '-m') {
out.model = argv[i + 1] ?? '';
i += 1;
@@ -148,6 +177,7 @@ function parseArgs(argv: string[]): {
if (out.imagePath === '') delete out.imagePath;
if (out.cookiePath === '') delete out.cookiePath;
if (out.profileDir === '') delete out.profileDir;
if (out.promptFiles?.length === 0) delete out.promptFiles;
return out;
}
@@ -227,7 +257,8 @@ async function main(): Promise<void> {
}
const promptFromStdin = await readPromptFromStdin();
const prompt = args.prompt || promptFromStdin;
const promptFromFiles = args.promptFiles ? readPromptFiles(args.promptFiles) : null;
const prompt = promptFromFiles || args.prompt || promptFromStdin;
if (!prompt) printUsage(1);
let cookieMap = await ensureGeminiCookieMap({ cookiePath, profileDir });
+74
View File
@@ -0,0 +1,74 @@
---
name: baoyu-post-to-wechat
description: Post content to WeChat Official Account (微信公众号). Supports both article posting (文章) and image-text posting (图文).
---
# Post to WeChat Official Account (微信公众号)
Post content to WeChat Official Account using Chrome CDP automation.
## Script Directory
**Important**: All scripts are located in the `scripts/` subdirectory of this skill.
**Agent Execution Instructions**:
1. Determine this SKILL.md file's directory path as `SKILL_DIR`
2. Script path = `${SKILL_DIR}/scripts/<script-name>.ts`
3. Replace all `${SKILL_DIR}` in this document with the actual path
**Script Reference**:
| Script | Purpose |
|--------|---------|
| `scripts/wechat-browser.ts` | Image-text posts (图文) |
| `scripts/wechat-article.ts` | Full article posting (文章) |
| `scripts/md-to-wechat.ts` | Markdown → WeChat HTML conversion |
| `scripts/copy-to-clipboard.ts` | Copy content to clipboard |
| `scripts/paste-from-clipboard.ts` | Send real paste keystroke |
## Quick Usage
### Image-Text (图文) - Multiple images with title/content
```bash
# From markdown file and image directory
npx -y bun ${SKILL_DIR}/scripts/wechat-browser.ts --markdown article.md --images ./images/
# With explicit parameters
npx -y bun ${SKILL_DIR}/scripts/wechat-browser.ts --title "标题" --content "内容" --image img1.png --image img2.png --submit
```
### Article (文章) - Full markdown with formatting
```bash
# Post markdown article
npx -y bun ${SKILL_DIR}/scripts/wechat-article.ts --markdown article.md --theme grace
```
> **Note**: `${SKILL_DIR}` represents this skill's installation directory. Agent replaces with actual path at runtime.
## References
- **Image-Text Posting**: See `references/image-text-posting.md` for detailed image-text posting guide
- **Article Posting**: See `references/article-posting.md` for detailed article posting guide
## Prerequisites
- Google Chrome installed
- `bun` runtime (via `npx -y bun`)
- First run: log in to WeChat Official Account in the opened browser window
## Features
| Feature | Image-Text | Article |
|---------|------------|---------|
| Multiple images | ✓ (up to 9) | ✓ (inline) |
| Markdown support | Title/content extraction | Full formatting |
| Auto title compression | ✓ (to 20 chars) | ✗ |
| Content compression | ✓ (to 1000 chars) | ✗ |
| Themes | ✗ | ✓ (default, grace, simple) |
## Troubleshooting
- **Not logged in**: First run opens browser - scan QR code to log in, session is preserved
- **Chrome not found**: Set `WECHAT_BROWSER_CHROME_PATH` environment variable
- **Paste fails**: Check system clipboard permissions
@@ -0,0 +1,89 @@
# Article Posting (文章发表)
Post markdown articles to WeChat Official Account with full formatting support.
## Usage
```bash
# Post markdown article
npx -y bun ./scripts/wechat-article.ts --markdown article.md
# With theme
npx -y bun ./scripts/wechat-article.ts --markdown article.md --theme grace
# With explicit options
npx -y bun ./scripts/wechat-article.ts --markdown article.md --author "作者名" --summary "摘要"
```
## Parameters
| Parameter | Description |
|-----------|-------------|
| `--markdown <path>` | Markdown file to convert and post |
| `--theme <name>` | Theme: default, grace, or simple |
| `--title <text>` | Override title (auto-extracted from markdown) |
| `--author <name>` | Author name (default: 宝玉) |
| `--summary <text>` | Article summary |
| `--html <path>` | Pre-rendered HTML file (alternative to markdown) |
| `--profile <dir>` | Chrome profile directory |
## Markdown Format
```markdown
---
title: Article Title
author: Author Name
---
# Title (becomes article title)
Regular paragraph with **bold** and *italic*.
## Section Header
![Image description](./image.png)
- List item 1
- List item 2
> Blockquote text
[Link text](https://example.com)
```
## Image Handling
1. **Parse**: Images in markdown are replaced with `[[IMAGE_PLACEHOLDER_N]]`
2. **Render**: HTML is generated with placeholders in text
3. **Paste**: HTML content is pasted into WeChat editor
4. **Replace**: For each placeholder:
- Find and select the placeholder text
- Scroll into view
- Press Backspace to delete the placeholder
- Paste the image from clipboard
## Scripts
| Script | Purpose |
|--------|---------|
| `wechat-article.ts` | Main article publishing script |
| `md-to-wechat.ts` | Markdown to HTML with placeholders |
| `md/render.ts` | Markdown rendering with themes |
## Example Session
```
User: /post-to-wechat --markdown ./article.md
Claude:
1. Parses markdown, finds 5 images
2. Generates HTML with placeholders
3. Opens Chrome, navigates to WeChat editor
4. Pastes HTML content
5. For each image:
- Selects [[IMAGE_PLACEHOLDER_1]]
- Scrolls into view
- Presses Backspace to delete
- Pastes image
6. Reports: "Article composed with 5 images."
```
@@ -0,0 +1,92 @@
# Image-Text Posting (图文发表)
Post image-text messages with multiple images to WeChat Official Account.
## Usage
```bash
# Post with images and markdown file (title/content extracted automatically)
npx -y bun ./scripts/wechat-browser.ts --markdown source.md --images ./images/
# Post with explicit title and content
npx -y bun ./scripts/wechat-browser.ts --title "标题" --content "内容" --image img1.png --image img2.png
# Save as draft
npx -y bun ./scripts/wechat-browser.ts --markdown source.md --images ./images/ --submit
```
## Parameters
| Parameter | Description |
|-----------|-------------|
| `--markdown <path>` | Markdown file for title/content extraction |
| `--images <dir>` | Directory containing images (sorted by name) |
| `--title <text>` | Article title (max 20 chars, auto-compressed if too long) |
| `--content <text>` | Article content (max 1000 chars, auto-compressed if too long) |
| `--image <path>` | Single image file (can be repeated) |
| `--submit` | Save as draft (default: preview only) |
| `--profile <dir>` | Chrome profile directory |
## Auto Title/Content from Markdown
When using `--markdown`, the script:
1. **Parses frontmatter** for title and author:
```yaml
---
title: 文章标题
author: 作者名
---
```
2. **Falls back to H1** if no frontmatter title:
```markdown
# 这将成为标题
```
3. **Compresses title** to 20 characters if too long:
- Original: "如何在一天内彻底重塑你的人生"
- Compressed: "一天彻底重塑你的人生"
4. **Extracts first paragraphs** as content (max 1000 chars)
## Image Directory Mode
When using `--images <dir>`:
- All PNG/JPG files in directory are uploaded
- Files are sorted alphabetically by name
- Naming convention: `01-cover.png`, `02-content.png`, etc.
## Constraints
| Field | Max Length | Notes |
|-------|------------|-------|
| Title | 20 chars | Auto-compressed if longer |
| Content | 1000 chars | Auto-compressed if longer |
| Images | 9 max | WeChat limit |
## Example Session
```
User: /post-to-wechat --markdown ./article.md --images ./xhs-images/
Claude:
1. Parses markdown meta:
- Title: "如何在一天内彻底重塑你的人生" → "一天内重塑你的人生"
- Author: from frontmatter or default
2. Extracts content from first paragraphs
3. Finds 7 images in xhs-images/
4. Opens Chrome, navigates to WeChat "图文" editor
5. Uploads all images
6. Fills title and content
7. Reports: "Image-text posted with 7 images."
```
## Scripts
| Script | Purpose |
|--------|---------|
| `wechat-browser.ts` | Main image-text posting script |
| `cdp.ts` | Chrome DevTools Protocol utilities |
| `copy-to-clipboard.ts` | Clipboard operations |
+274
View File
@@ -0,0 +1,274 @@
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import { mkdir } from 'node:fs/promises';
import net from 'node:net';
import os from 'node:os';
import path from 'node:path';
import process from 'node:process';
export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export async function getFreePort(): Promise<number> {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.unref();
server.on('error', reject);
server.listen(0, '127.0.0.1', () => {
const address = server.address();
if (!address || typeof address === 'string') {
server.close(() => reject(new Error('Unable to allocate a free TCP port.')));
return;
}
const port = address.port;
server.close((err) => {
if (err) reject(err);
else resolve(port);
});
});
});
}
export function findChromeExecutable(): string | undefined {
const override = process.env.WECHAT_BROWSER_CHROME_PATH?.trim();
if (override && fs.existsSync(override)) return override;
const candidates: string[] = [];
switch (process.platform) {
case 'darwin':
candidates.push(
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
);
break;
case 'win32':
candidates.push(
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
);
break;
default:
candidates.push('/usr/bin/google-chrome', '/usr/bin/chromium');
break;
}
for (const p of candidates) {
if (fs.existsSync(p)) return p;
}
return undefined;
}
export function getDefaultProfileDir(): string {
const base = process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share');
return path.join(base, 'wechat-browser-profile');
}
async function fetchJson<T = unknown>(url: string): Promise<T> {
const res = await fetch(url, { redirect: 'follow' });
if (!res.ok) throw new Error(`Request failed: ${res.status} ${res.statusText}`);
return (await res.json()) as T;
}
async function waitForChromeDebugPort(port: number, timeoutMs: number): Promise<string> {
const start = Date.now();
let lastError: unknown = null;
while (Date.now() - start < timeoutMs) {
try {
const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(`http://127.0.0.1:${port}/json/version`);
if (version.webSocketDebuggerUrl) return version.webSocketDebuggerUrl;
lastError = new Error('Missing webSocketDebuggerUrl');
} catch (error) {
lastError = error;
}
await sleep(200);
}
throw new Error(`Chrome debug port not ready: ${lastError instanceof Error ? lastError.message : String(lastError)}`);
}
export class CdpConnection {
private ws: WebSocket;
private nextId = 0;
private pending = new Map<number, { resolve: (v: unknown) => void; reject: (e: Error) => void; timer: ReturnType<typeof setTimeout> | null }>();
private eventHandlers = new Map<string, Set<(params: unknown) => void>>();
private constructor(ws: WebSocket) {
this.ws = ws;
this.ws.addEventListener('message', (event) => {
try {
const data = typeof event.data === 'string' ? event.data : new TextDecoder().decode(event.data as ArrayBuffer);
const msg = JSON.parse(data) as { id?: number; method?: string; params?: unknown; result?: unknown; error?: { message?: string } };
if (msg.method) {
const handlers = this.eventHandlers.get(msg.method);
if (handlers) handlers.forEach((h) => h(msg.params));
}
if (msg.id) {
const pending = this.pending.get(msg.id);
if (pending) {
this.pending.delete(msg.id);
if (pending.timer) clearTimeout(pending.timer);
if (msg.error?.message) pending.reject(new Error(msg.error.message));
else pending.resolve(msg.result);
}
}
} catch {}
});
this.ws.addEventListener('close', () => {
for (const [id, pending] of this.pending.entries()) {
this.pending.delete(id);
if (pending.timer) clearTimeout(pending.timer);
pending.reject(new Error('CDP connection closed.'));
}
});
}
static async connect(url: string, timeoutMs: number): Promise<CdpConnection> {
const ws = new WebSocket(url);
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('CDP connection timeout.')), timeoutMs);
ws.addEventListener('open', () => { clearTimeout(timer); resolve(); });
ws.addEventListener('error', () => { clearTimeout(timer); reject(new Error('CDP connection failed.')); });
});
return new CdpConnection(ws);
}
on(method: string, handler: (params: unknown) => void): void {
if (!this.eventHandlers.has(method)) this.eventHandlers.set(method, new Set());
this.eventHandlers.get(method)!.add(handler);
}
async send<T = unknown>(method: string, params?: Record<string, unknown>, options?: { sessionId?: string; timeoutMs?: number }): Promise<T> {
const id = ++this.nextId;
const message: Record<string, unknown> = { id, method };
if (params) message.params = params;
if (options?.sessionId) message.sessionId = options.sessionId;
const timeoutMs = options?.timeoutMs ?? 15_000;
const result = await new Promise<unknown>((resolve, reject) => {
const timer = timeoutMs > 0 ? setTimeout(() => { this.pending.delete(id); reject(new Error(`CDP timeout: ${method}`)); }, timeoutMs) : null;
this.pending.set(id, { resolve, reject, timer });
this.ws.send(JSON.stringify(message));
});
return result as T;
}
close(): void {
try { this.ws.close(); } catch {}
}
}
export interface ChromeSession {
cdp: CdpConnection;
sessionId: string;
targetId: string;
}
export async function launchChrome(url: string, profileDir?: string): Promise<{ cdp: CdpConnection; chrome: ReturnType<typeof spawn> }> {
const chromePath = findChromeExecutable();
if (!chromePath) throw new Error('Chrome not found. Set WECHAT_BROWSER_CHROME_PATH env var.');
const profile = profileDir ?? getDefaultProfileDir();
await mkdir(profile, { recursive: true });
const port = await getFreePort();
console.log(`[cdp] Launching Chrome (profile: ${profile})`);
const chrome = spawn(chromePath, [
`--remote-debugging-port=${port}`,
`--user-data-dir=${profile}`,
'--no-first-run',
'--no-default-browser-check',
'--disable-blink-features=AutomationControlled',
'--start-maximized',
url,
], { stdio: 'ignore' });
const wsUrl = await waitForChromeDebugPort(port, 30_000);
const cdp = await CdpConnection.connect(wsUrl, 30_000);
return { cdp, chrome };
}
export async function getPageSession(cdp: CdpConnection, urlPattern: string): Promise<ChromeSession> {
const targets = await cdp.send<{ targetInfos: Array<{ targetId: string; url: string; type: string }> }>('Target.getTargets');
let pageTarget = targets.targetInfos.find((t) => t.type === 'page' && t.url.includes(urlPattern));
if (!pageTarget) throw new Error(`Page not found: ${urlPattern}`);
const { sessionId } = await cdp.send<{ sessionId: string }>('Target.attachToTarget', { targetId: pageTarget.targetId, flatten: true });
await cdp.send('Page.enable', {}, { sessionId });
await cdp.send('Runtime.enable', {}, { sessionId });
await cdp.send('DOM.enable', {}, { sessionId });
return { cdp, sessionId, targetId: pageTarget.targetId };
}
export async function waitForNewTab(cdp: CdpConnection, initialIds: Set<string>, urlPattern: string, timeoutMs = 30_000): Promise<string> {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const targets = await cdp.send<{ targetInfos: Array<{ targetId: string; url: string; type: string }> }>('Target.getTargets');
const newTab = targets.targetInfos.find(t => t.type === 'page' && !initialIds.has(t.targetId) && t.url.includes(urlPattern));
if (newTab) return newTab.targetId;
await sleep(500);
}
throw new Error(`New tab not found: ${urlPattern}`);
}
export async function clickElement(session: ChromeSession, selector: string): Promise<void> {
const posResult = await session.cdp.send<{ result: { value: string } }>('Runtime.evaluate', {
expression: `
(function() {
const el = document.querySelector('${selector}');
if (!el) return 'null';
el.scrollIntoView({ block: 'center' });
const rect = el.getBoundingClientRect();
return JSON.stringify({ x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 });
})()
`,
returnByValue: true,
}, { sessionId: session.sessionId });
if (posResult.result.value === 'null') throw new Error(`Element not found: ${selector}`);
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(50);
await session.cdp.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: pos.x, y: pos.y, button: 'left', clickCount: 1 }, { sessionId: session.sessionId });
}
export async function typeText(session: ChromeSession, text: string): Promise<void> {
const lines = text.split('\n');
for (let i = 0; i < lines.length; i++) {
if (lines[i].length > 0) {
await session.cdp.send('Input.insertText', { text: lines[i] }, { sessionId: session.sessionId });
}
if (i < lines.length - 1) {
await session.cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13 }, { sessionId: session.sessionId });
await session.cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13 }, { sessionId: session.sessionId });
}
await sleep(30);
}
}
export async function pasteFromClipboard(session: ChromeSession): Promise<void> {
const modifiers = process.platform === 'darwin' ? 4 : 2;
await session.cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'v', code: 'KeyV', modifiers, windowsVirtualKeyCode: 86 }, { sessionId: session.sessionId });
await session.cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'v', code: 'KeyV', modifiers, windowsVirtualKeyCode: 86 }, { sessionId: session.sessionId });
}
export async function evaluate<T = unknown>(session: ChromeSession, expression: string): Promise<T> {
const result = await session.cdp.send<{ result: { value: T } }>('Runtime.evaluate', {
expression,
returnByValue: true,
}, { sessionId: session.sessionId });
return result.result.value;
}
@@ -0,0 +1,380 @@
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import process from 'node:process';
const SUPPORTED_IMAGE_EXTS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp']);
function printUsage(exitCode = 0): never {
console.log(`Copy image or HTML to system clipboard
Supports:
- Image files (jpg, png, gif, webp) - copies as image data
- HTML content - copies as rich text for paste
Usage:
# Copy image to clipboard
npx -y bun copy-to-clipboard.ts image /path/to/image.jpg
# Copy HTML to clipboard
npx -y bun copy-to-clipboard.ts html "<p>Hello</p>"
# Copy HTML from file
npx -y bun copy-to-clipboard.ts html --file /path/to/content.html
`);
process.exit(exitCode);
}
function resolvePath(filePath: string): string {
return path.isAbsolute(filePath) ? filePath : path.resolve(process.cwd(), filePath);
}
function inferImageMimeType(imagePath: string): string {
const ext = path.extname(imagePath).toLowerCase();
switch (ext) {
case '.jpg':
case '.jpeg':
return 'image/jpeg';
case '.png':
return 'image/png';
case '.gif':
return 'image/gif';
case '.webp':
return 'image/webp';
default:
return 'application/octet-stream';
}
}
type RunResult = { stdout: string; stderr: string; exitCode: number };
async function runCommand(
command: string,
args: string[],
options?: { input?: string | Buffer; allowNonZeroExit?: boolean },
): Promise<RunResult> {
return await new Promise<RunResult>((resolve, reject) => {
const child = spawn(command, args, { stdio: ['pipe', 'pipe', 'pipe'] });
const stdoutChunks: Buffer[] = [];
const stderrChunks: Buffer[] = [];
child.stdout.on('data', (chunk) => stdoutChunks.push(Buffer.from(chunk)));
child.stderr.on('data', (chunk) => stderrChunks.push(Buffer.from(chunk)));
child.on('error', reject);
child.on('close', (code) => {
resolve({
stdout: Buffer.concat(stdoutChunks).toString('utf8'),
stderr: Buffer.concat(stderrChunks).toString('utf8'),
exitCode: code ?? 0,
});
});
if (options?.input != null) child.stdin.write(options.input);
child.stdin.end();
}).then((result) => {
if (!options?.allowNonZeroExit && result.exitCode !== 0) {
const details = result.stderr.trim() || result.stdout.trim();
throw new Error(`Command failed (${command}): exit ${result.exitCode}${details ? `\n${details}` : ''}`);
}
return result;
});
}
async function commandExists(command: string): Promise<boolean> {
if (process.platform === 'win32') {
const result = await runCommand('where', [command], { allowNonZeroExit: true });
return result.exitCode === 0 && result.stdout.trim().length > 0;
}
const result = await runCommand('which', [command], { allowNonZeroExit: true });
return result.exitCode === 0 && result.stdout.trim().length > 0;
}
async function runCommandWithFileStdin(command: string, args: string[], filePath: string): Promise<void> {
await new Promise<void>((resolve, reject) => {
const child = spawn(command, args, { stdio: ['pipe', 'pipe', 'pipe'] });
const stderrChunks: Buffer[] = [];
const stdoutChunks: Buffer[] = [];
child.stdout.on('data', (chunk) => stdoutChunks.push(Buffer.from(chunk)));
child.stderr.on('data', (chunk) => stderrChunks.push(Buffer.from(chunk)));
child.on('error', reject);
child.on('close', (code) => {
const exitCode = code ?? 0;
if (exitCode !== 0) {
const details = Buffer.concat(stderrChunks).toString('utf8').trim() || Buffer.concat(stdoutChunks).toString('utf8').trim();
reject(
new Error(`Command failed (${command}): exit ${exitCode}${details ? `\n${details}` : ''}`),
);
return;
}
resolve();
});
fs.createReadStream(filePath).on('error', reject).pipe(child.stdin);
});
}
async function withTempDir<T>(prefix: string, fn: (tempDir: string) => Promise<T>): Promise<T> {
const tempDir = await mkdtemp(path.join(os.tmpdir(), prefix));
try {
return await fn(tempDir);
} finally {
await rm(tempDir, { recursive: true, force: true });
}
}
function getMacSwiftClipboardSource(): string {
return `import AppKit
import Foundation
func die(_ message: String, _ code: Int32 = 1) -> Never {
FileHandle.standardError.write(message.data(using: .utf8)!)
exit(code)
}
if CommandLine.arguments.count < 3 {
die("Usage: clipboard.swift <image|html> <path>\\n")
}
let mode = CommandLine.arguments[1]
let inputPath = CommandLine.arguments[2]
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
switch mode {
case "image":
guard let image = NSImage(contentsOfFile: inputPath) else {
die("Failed to load image: \\(inputPath)\\n")
}
if !pasteboard.writeObjects([image]) {
die("Failed to write image to clipboard\\n")
}
case "html":
let url = URL(fileURLWithPath: inputPath)
let data: Data
do {
data = try Data(contentsOf: url)
} catch {
die("Failed to read HTML file: \\(inputPath)\\n")
}
_ = pasteboard.setData(data, forType: .html)
let options: [NSAttributedString.DocumentReadingOptionKey: Any] = [
.documentType: NSAttributedString.DocumentType.html,
.characterEncoding: String.Encoding.utf8.rawValue
]
if let attr = try? NSAttributedString(data: data, options: options, documentAttributes: nil) {
pasteboard.setString(attr.string, forType: .string)
if let rtf = try? attr.data(
from: NSRange(location: 0, length: attr.length),
documentAttributes: [.documentType: NSAttributedString.DocumentType.rtf]
) {
_ = pasteboard.setData(rtf, forType: .rtf)
}
} else if let html = String(data: data, encoding: .utf8) {
pasteboard.setString(html, forType: .string)
}
default:
die("Unknown mode: \\(mode)\\n")
}
`;
}
async function copyImageMac(imagePath: string): Promise<void> {
await withTempDir('copy-to-clipboard-', async (tempDir) => {
const swiftPath = path.join(tempDir, 'clipboard.swift');
await writeFile(swiftPath, getMacSwiftClipboardSource(), 'utf8');
await runCommand('swift', [swiftPath, 'image', imagePath]);
});
}
async function copyHtmlMac(htmlFilePath: string): Promise<void> {
await withTempDir('copy-to-clipboard-', async (tempDir) => {
const swiftPath = path.join(tempDir, 'clipboard.swift');
await writeFile(swiftPath, getMacSwiftClipboardSource(), 'utf8');
await runCommand('swift', [swiftPath, 'html', htmlFilePath]);
});
}
async function copyImageLinux(imagePath: string): Promise<void> {
const mime = inferImageMimeType(imagePath);
if (await commandExists('wl-copy')) {
await runCommandWithFileStdin('wl-copy', ['--type', mime], imagePath);
return;
}
if (await commandExists('xclip')) {
await runCommand('xclip', ['-selection', 'clipboard', '-t', mime, '-i', imagePath]);
return;
}
throw new Error('No clipboard tool found. Install `wl-clipboard` (wl-copy) or `xclip`.');
}
async function copyHtmlLinux(htmlFilePath: string): Promise<void> {
if (await commandExists('wl-copy')) {
await runCommandWithFileStdin('wl-copy', ['--type', 'text/html'], htmlFilePath);
return;
}
if (await commandExists('xclip')) {
await runCommand('xclip', ['-selection', 'clipboard', '-t', 'text/html', '-i', htmlFilePath]);
return;
}
throw new Error('No clipboard tool found. Install `wl-clipboard` (wl-copy) or `xclip`.');
}
async function copyImageWindows(imagePath: string): Promise<void> {
const ps = [
'param([string]$Path)',
'Add-Type -AssemblyName System.Windows.Forms',
'Add-Type -AssemblyName System.Drawing',
'$img = [System.Drawing.Image]::FromFile($Path)',
'[System.Windows.Forms.Clipboard]::SetImage($img)',
'$img.Dispose()',
].join('; ');
await runCommand('powershell.exe', ['-NoProfile', '-Sta', '-Command', ps, '-Path', imagePath]);
}
async function copyHtmlWindows(htmlFilePath: string): Promise<void> {
const ps = [
'param([string]$Path)',
'Add-Type -AssemblyName System.Windows.Forms',
'$html = Get-Content -Raw -LiteralPath $Path',
'[System.Windows.Forms.Clipboard]::SetText($html, [System.Windows.Forms.TextDataFormat]::Html)',
].join('; ');
await runCommand('powershell.exe', ['-NoProfile', '-Sta', '-Command', ps, '-Path', htmlFilePath]);
}
async function copyImageToClipboard(imagePathInput: string): Promise<void> {
const imagePath = resolvePath(imagePathInput);
const ext = path.extname(imagePath).toLowerCase();
if (!SUPPORTED_IMAGE_EXTS.has(ext)) {
throw new Error(
`Unsupported image type: ${ext || '(none)'} (supported: ${Array.from(SUPPORTED_IMAGE_EXTS).join(', ')})`,
);
}
if (!fs.existsSync(imagePath)) throw new Error(`File not found: ${imagePath}`);
switch (process.platform) {
case 'darwin':
await copyImageMac(imagePath);
return;
case 'linux':
await copyImageLinux(imagePath);
return;
case 'win32':
await copyImageWindows(imagePath);
return;
default:
throw new Error(`Unsupported platform: ${process.platform}`);
}
}
async function copyHtmlFileToClipboard(htmlFilePathInput: string): Promise<void> {
const htmlFilePath = resolvePath(htmlFilePathInput);
if (!fs.existsSync(htmlFilePath)) throw new Error(`File not found: ${htmlFilePath}`);
switch (process.platform) {
case 'darwin':
await copyHtmlMac(htmlFilePath);
return;
case 'linux':
await copyHtmlLinux(htmlFilePath);
return;
case 'win32':
await copyHtmlWindows(htmlFilePath);
return;
default:
throw new Error(`Unsupported platform: ${process.platform}`);
}
}
async function readStdinText(): Promise<string | null> {
if (process.stdin.isTTY) return null;
const chunks: Buffer[] = [];
for await (const chunk of process.stdin) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
const text = Buffer.concat(chunks).toString('utf8');
return text.length > 0 ? text : null;
}
async function copyHtmlToClipboard(args: string[]): Promise<void> {
let htmlFile: string | undefined;
const positional: string[] = [];
for (let i = 0; i < args.length; i += 1) {
const arg = args[i] ?? '';
if (arg === '--help' || arg === '-h') printUsage(0);
if (arg === '--file') {
htmlFile = args[i + 1];
i += 1;
continue;
}
if (arg.startsWith('--file=')) {
htmlFile = arg.slice('--file='.length);
continue;
}
if (arg === '--') {
positional.push(...args.slice(i + 1));
break;
}
if (arg.startsWith('-')) {
throw new Error(`Unknown option: ${arg}`);
}
positional.push(arg);
}
if (htmlFile && positional.length > 0) {
throw new Error('Do not pass HTML text when using --file.');
}
if (htmlFile) {
await copyHtmlFileToClipboard(htmlFile);
return;
}
const htmlFromArgs = positional.join(' ').trim();
const htmlFromStdin = (await readStdinText())?.trim() ?? '';
const html = htmlFromArgs || htmlFromStdin;
if (!html) throw new Error('Missing HTML input. Provide a string or use --file.');
await withTempDir('copy-to-clipboard-', async (tempDir) => {
const htmlPath = path.join(tempDir, 'input.html');
await writeFile(htmlPath, html, 'utf8');
await copyHtmlFileToClipboard(htmlPath);
});
}
async function main(): Promise<void> {
const argv = process.argv.slice(2);
if (argv.length === 0) printUsage(1);
const command = argv[0];
if (command === '--help' || command === '-h') printUsage(0);
if (command === 'image') {
const imagePath = argv[1];
if (!imagePath) throw new Error('Missing image path.');
await copyImageToClipboard(imagePath);
return;
}
if (command === 'html') {
await copyHtmlToClipboard(argv.slice(1));
return;
}
throw new Error(`Unknown command: ${command}`);
}
await main().catch((err) => {
const message = err instanceof Error ? err.message : String(err);
console.error(`Error: ${message}`);
process.exit(1);
});
@@ -0,0 +1,281 @@
import fs from 'node:fs';
import path from 'node:path';
import { mkdir, writeFile } from 'node:fs/promises';
import os from 'node:os';
import { createHash } from 'node:crypto';
import https from 'node:https';
import http from 'node:http';
import { spawnSync } from 'node:child_process';
import process from 'node:process';
interface ImageInfo {
placeholder: string;
localPath: string;
originalPath: string;
}
interface ParsedResult {
title: string;
author: string;
summary: string;
htmlPath: string;
contentImages: ImageInfo[];
}
function downloadFile(url: string, destPath: string): Promise<void> {
return 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);
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', (err) => {
file.close();
fs.unlink(destPath, () => {});
reject(err);
});
request.setTimeout(30000, () => {
request.destroy();
reject(new Error('Download timeout'));
});
});
}
function getImageExtension(urlOrPath: string): string {
const match = urlOrPath.match(/\.(jpg|jpeg|png|gif|webp)(\?|$)/i);
return match ? match[1]!.toLowerCase() : 'png';
}
async function resolveImagePath(imagePath: string, baseDir: string, tempDir: string): 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(`[md-to-wechat] Downloading: ${imagePath}`);
await downloadFile(imagePath, localPath);
}
return localPath;
}
if (path.isAbsolute(imagePath)) {
return imagePath;
}
return path.resolve(baseDir, imagePath);
}
function parseFrontmatter(content: string): { frontmatter: Record<string, string>; body: string } {
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
if (!match) return { frontmatter: {}, body: content };
const frontmatter: Record<string, string> = {};
const lines = match[1]!.split('\n');
for (const line of lines) {
const colonIdx = line.indexOf(':');
if (colonIdx > 0) {
const key = line.slice(0, colonIdx).trim();
let value = line.slice(colonIdx + 1).trim();
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
frontmatter[key] = value;
}
}
return { frontmatter, body: match[2]! };
}
async function parseMarkdownForWechat(
markdownPath: string,
options?: { title?: string; theme?: string; tempDir?: string },
): Promise<ParsedResult> {
const content = fs.readFileSync(markdownPath, 'utf-8');
const baseDir = path.dirname(markdownPath);
const tempDir = options?.tempDir ?? path.join(os.tmpdir(), 'wechat-article-images');
const theme = options?.theme ?? 'default';
await mkdir(tempDir, { recursive: true });
const { frontmatter, body } = parseFrontmatter(content);
let title = options?.title ?? frontmatter.title ?? '';
if (!title) {
const h1Match = body.match(/^#\s+(.+)$/m);
if (h1Match) title = h1Match[1]!;
}
const author = frontmatter.author ?? '';
let summary = frontmatter.summary ?? frontmatter.description ?? '';
if (!summary) {
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;
const cleanText = trimmed
.replace(/\*\*(.+?)\*\*/g, '$1')
.replace(/\*(.+?)\*/g, '$1')
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
.replace(/`([^`]+)`/g, '$1');
if (cleanText.length > 20) {
summary = cleanText.length > 120 ? cleanText.slice(0, 117) + '...' : cleanText;
break;
}
}
}
const images: Array<{ src: string; placeholder: string }> = [];
let imageCounter = 0;
const modifiedBody = body.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (match, alt, src) => {
const placeholder = `[[IMAGE_PLACEHOLDER_${++imageCounter}]]`;
images.push({ src, placeholder });
return placeholder;
});
const modifiedMarkdown = `---\n${Object.entries(frontmatter).map(([k, v]) => `${k}: ${v}`).join('\n')}\n---\n${modifiedBody}`;
const tempMdPath = path.join(tempDir, 'temp-article.md');
await writeFile(tempMdPath, modifiedMarkdown, 'utf-8');
const scriptDir = path.dirname(new URL(import.meta.url).pathname);
const renderScript = path.join(scriptDir, 'md', 'render.ts');
console.error(`[md-to-wechat] Rendering markdown with theme: ${theme}`);
const result = spawnSync('npx', ['-y', 'bun', renderScript, tempMdPath, '--theme', theme], {
stdio: ['inherit', 'pipe', 'pipe'],
cwd: baseDir,
});
if (result.status !== 0) {
const stderr = result.stderr?.toString() || '';
throw new Error(`Render failed: ${stderr}`);
}
const htmlPath = tempMdPath.replace(/\.md$/i, '.html');
if (!fs.existsSync(htmlPath)) {
throw new Error(`HTML file not generated: ${htmlPath}`);
}
const contentImages: ImageInfo[] = [];
for (const img of images) {
const localPath = await resolveImagePath(img.src, baseDir, tempDir);
contentImages.push({
placeholder: img.placeholder,
localPath,
originalPath: img.src,
});
}
return {
title,
author,
summary,
htmlPath,
contentImages,
};
}
function printUsage(): never {
console.log(`Convert Markdown to WeChat-ready HTML with image placeholders
Usage:
npx -y bun md-to-wechat.ts <markdown_file> [options]
Options:
--title <title> Override title
--theme <name> Theme name (default, grace, simple)
--help Show this help
Output JSON format:
{
"title": "Article Title",
"htmlPath": "/tmp/wechat-article-images/temp-article.html",
"contentImages": [
{
"placeholder": "[[IMAGE_PLACEHOLDER_1]]",
"localPath": "/tmp/wechat-article-images/img.png",
"originalPath": "imgs/image.png"
}
]
}
Example:
npx -y bun md-to-wechat.ts article.md
npx -y bun md-to-wechat.ts article.md --theme grace
`);
process.exit(0);
}
async function main(): Promise<void> {
const args = process.argv.slice(2);
if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
printUsage();
}
let markdownPath: string | undefined;
let title: string | undefined;
let theme: string | undefined;
for (let i = 0; i < args.length; i++) {
const arg = args[i]!;
if (arg === '--title' && args[i + 1]) {
title = args[++i];
} else if (arg === '--theme' && args[i + 1]) {
theme = args[++i];
} else if (!arg.startsWith('-')) {
markdownPath = arg;
}
}
if (!markdownPath) {
console.error('Error: Markdown file path required');
process.exit(1);
}
if (!fs.existsSync(markdownPath)) {
console.error(`Error: File not found: ${markdownPath}`);
process.exit(1);
}
const result = await parseMarkdownForWechat(markdownPath, { title, theme });
console.log(JSON.stringify(result, null, 2));
}
await main().catch((err) => {
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
});
@@ -0,0 +1,18 @@
This directory contains code adapted from the doocs/md project.
Original project: https://github.com/doocs/md
License: WTFPL (Do What The Fuck You Want To Public License)
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
Version 2, December 2004
Copyright (C) 2025 Doocs <admin@doocs.org>
Everyone is permitted to copy and distribute verbatim or modified
copies of this license document, and changing it is allowed as long
as the name is changed.
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. You just DO WHAT THE FUCK YOU WANT TO.
@@ -0,0 +1,284 @@
import type { MarkedExtension, Tokens } from 'marked'
export interface AlertOptions {
className?: string
variants?: AlertVariantItem[]
withoutStyle?: boolean
}
export interface AlertVariantItem {
type: string
icon: string
title?: string
titleClassName?: string
}
function ucfirst(str: string) {
return str.slice(0, 1).toUpperCase() + str.slice(1).toLowerCase()
}
/**
* https://github.com/bent10/marked-extensions/tree/main/packages/alert
* To support theme, we need to modify the source code.
* A [marked](https://marked.js.org/) extension to support [GFM alerts](https://github.com/orgs/community/discussions/16925).
*/
export function markedAlert(options: AlertOptions = {}): MarkedExtension {
const { className = `markdown-alert`, variants = [], withoutStyle = false } = options
const resolvedVariants = resolveVariants(variants)
// 提取公共的元数据构建逻辑
function buildMeta(variantType: string, matchedVariant: AlertVariantItem, fromContainer = false) {
return {
className,
variant: variantType,
icon: matchedVariant.icon,
title: matchedVariant.title ?? ucfirst(variantType),
titleClassName: `${className}-title`,
fromContainer,
}
}
// 提取公共的渲染逻辑
function renderAlert(token: any) {
const { meta, tokens = [] } = token
// @ts-expect-error marked renderer context has parser property
const text = this.parser.parse(tokens)
// 新主题系统:使用 CSS 选择器而非内联样式
let tmpl = `<blockquote class="${meta.className} ${meta.className}-${meta.variant}">\n`
tmpl += `<p class="${meta.titleClassName} alert-title-${meta.variant}">`
if (!withoutStyle) {
// 给 SVG 添加 class,通过 CSS 控制颜色
tmpl += meta.icon.replace(
`<svg`,
`<svg class="alert-icon-${meta.variant}"`,
)
}
tmpl += meta.title
tmpl += `</p>\n`
tmpl += text
tmpl += `</blockquote>\n`
return tmpl
}
return {
walkTokens(token) {
if (token.type !== `blockquote`)
return
const matchedVariant = resolvedVariants.find(({ type }) =>
new RegExp(createSyntaxPattern(type), `i`).test(token.text),
)
if (matchedVariant) {
const { type: variantType } = matchedVariant
const typeRegexp = new RegExp(createSyntaxPattern(variantType), `i`)
Object.assign(token, {
type: `alert`,
meta: buildMeta(variantType, matchedVariant),
})
const firstLine = token.tokens?.[0] as Tokens.Paragraph
const firstLineText = firstLine.raw?.replace(typeRegexp, ``).trim()
if (firstLineText) {
const patternToken = firstLine.tokens[0] as Tokens.Text
Object.assign(patternToken, {
raw: patternToken.raw.replace(typeRegexp, ``),
text: patternToken.text.replace(typeRegexp, ``),
})
if (firstLine.tokens[1]?.type === `br`) {
firstLine.tokens.splice(1, 1)
}
}
else {
token.tokens?.shift()
}
}
},
extensions: [
{
name: `alert`,
level: `block`,
renderer: renderAlert,
},
{
name: `alertContainer`,
level: `block`,
start(src) {
return src.match(/^:::/)?.index
},
tokenizer(src, _tokens) {
// eslint-disable-next-line regexp/no-super-linear-backtracking
const match = /^:::\s*(\w+)\s*\n([\s\S]*?)\n:::/.exec(src)
if (match) {
const [raw, variant, content] = match
const matchedVariant = resolvedVariants.find(v => v.type === variant)
if (!matchedVariant)
return
return {
type: `alert`,
raw,
text: content.trim(),
tokens: this.lexer.blockTokens(content.trim()),
meta: buildMeta(variant, matchedVariant, true),
}
}
},
renderer: renderAlert,
},
],
}
}
/**
* The default configuration for alert variants.
*/
const defaultAlertVariant: AlertVariantItem[] = [
{
type: `note`,
icon: `<svg class="octicon octicon-info" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.5 7.75A.75.75 0 0 1 7.25 7h1a.75.75 0 0 1 .75.75v2.75h.25a.75.75 0 0 1 0 1.5h-2a.75.75 0 0 1 0-1.5h.25v-2h-.25a.75.75 0 0 1-.75-.75ZM8 6a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z"></path></svg>`,
},
{
type: `info`,
icon: `<svg class="octicon octicon-info" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.5 7.75A.75.75 0 0 1 7.25 7h1a.75.75 0 0 1 .75.75v2.75h.25a.75.75 0 0 1 0 1.5h-2a.75.75 0 0 1 0-1.5h.25v-2h-.25a.75.75 0 0 1-.75-.75ZM8 6a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z"></path></svg>`,
},
{
type: `tip`,
icon: `<svg class="octicon octicon-light-bulb" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M8 1.5c-2.363 0-4 1.69-4 3.75 0 .984.424 1.625.984 2.304l.214.253c.223.264.47.556.673.848.284.411.537.896.621 1.49a.75.75 0 0 1-1.484.211c-.04-.282-.163-.547-.37-.847a8.456 8.456 0 0 0-.542-.68c-.084-.1-.173-.205-.268-.32C3.201 7.75 2.5 6.766 2.5 5.25 2.5 2.31 4.863 0 8 0s5.5 2.31 5.5 5.25c0 1.516-.701 2.5-1.328 3.259-.095.115-.184.22-.268.319-.207.245-.383.453-.541.681-.208.3-.33.565-.37.847a.751.751 0 0 1-1.485-.212c.084-.593.337-1.078.621-1.489.203-.292.45-.584.673-.848.075-.088.147-.173.213-.253.561-.679.985-1.32.985-2.304 0-2.06-1.637-3.75-4-3.75ZM5.75 12h4.5a.75.75 0 0 1 0 1.5h-4.5a.75.75 0 0 1 0-1.5ZM6 15.25a.75.75 0 0 1 .75-.75h2.5a.75.75 0 0 1 0 1.5h-2.5a.75.75 0 0 1-.75-.75Z"></path></svg>`,
},
{
type: `important`,
icon: `<svg class="octicon octicon-report" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M0 1.75C0 .784.784 0 1.75 0h12.5C15.216 0 16 .784 16 1.75v9.5A1.75 1.75 0 0 1 14.25 13H8.06l-2.573 2.573A1.458 1.458 0 0 1 3 14.543V13H1.75A1.75 1.75 0 0 1 0 11.25Zm1.75-.25a.25.25 0 0 0-.25.25v9.5c0 .138.112.25.25.25h2a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h6.5a.25.25 0 0 0 .25-.25v-9.5a.25.25 0 0 0-.25-.25Zm7 2.25v2.5a.75.75 0 0 1-1.5 0v-2.5a.75.75 0 0 1 1.5 0ZM9 9a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z"></path></svg>`,
},
{
type: `warning`,
icon: `<svg class="octicon octicon-alert" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M6.457 1.047c.659-1.234 2.427-1.234 3.086 0l6.082 11.378A1.75 1.75 0 0 1 14.082 15H1.918a1.75 1.75 0 0 1-1.543-2.575Zm1.763.707a.25.25 0 0 0-.44 0L1.698 13.132a.25.25 0 0 0 .22.368h12.164a.25.25 0 0 0 .22-.368Zm.53 3.996v2.5a.75.75 0 0 1-1.5 0v-2.5a.75.75 0 0 1 1.5 0ZM9 11a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z"></path></svg>`,
},
{
type: `caution`,
icon: `<svg class="octicon octicon-stop" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M4.47.22A.749.749 0 0 1 5 0h6c.199 0 .389.079.53.22l4.25 4.25c.141.14.22.331.22.53v6a.749.749 0 0 1-.22.53l-4.25 4.25A.749.749 0 0 1 11 16H5a.749.749 0 0 1-.53-.22L.22 11.53A.749.749 0 0 1 0 11V5c0-.199.079-.389.22-.53Zm.84 1.28L1.5 5.31v5.38l3.81 3.81h5.38l3.81-3.81V5.31L10.69 1.5ZM8 4a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5A.75.75 0 0 1 8 4Zm0 8a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z"></path></svg>`,
},
// Obsidian-style callouts
{
type: `abstract`,
title: `Abstract`,
icon: `<svg class="octicon octicon-clipboard" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M5.75 1a.75.75 0 0 0-.75.75v.5c0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75v-.5a.75.75 0 0 0-.75-.75Zm4.5-1.5a2.25 2.25 0 0 1 2.122 1.5H13a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V3a2 2 0 0 1 2-2h.628A2.25 2.25 0 0 1 5.75-.5ZM3.5 3v10a.5.5 0 0 0 .5.5h8a.5.5 0 0 0 .5-.5V3a.5.5 0 0 0-.5-.5h-8a.5.5 0 0 0-.5.5Z"></path></svg>`,
},
{
type: `summary`,
title: `Summary`,
icon: `<svg class="octicon octicon-clipboard" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M5.75 1a.75.75 0 0 0-.75.75v.5c0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75v-.5a.75.75 0 0 0-.75-.75Zm4.5-1.5a2.25 2.25 0 0 1 2.122 1.5H13a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V3a2 2 0 0 1 2-2h.628A2.25 2.25 0 0 1 5.75-.5ZM3.5 3v10a.5.5 0 0 0 .5.5h8a.5.5 0 0 0 .5-.5V3a.5.5 0 0 0-.5-.5h-8a.5.5 0 0 0-.5.5Z"></path></svg>`,
},
{
type: `tldr`,
title: `TL;DR`,
icon: `<svg class="octicon octicon-clipboard" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M5.75 1a.75.75 0 0 0-.75.75v.5c0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75v-.5a.75.75 0 0 0-.75-.75Zm4.5-1.5a2.25 2.25 0 0 1 2.122 1.5H13a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V3a2 2 0 0 1 2-2h.628A2.25 2.25 0 0 1 5.75-.5ZM3.5 3v10a.5.5 0 0 0 .5.5h8a.5.5 0 0 0 .5-.5V3a.5.5 0 0 0-.5-.5h-8a.5.5 0 0 0-.5.5Z"></path></svg>`,
},
{
type: `todo`,
title: `Todo`,
icon: `<svg class="octicon octicon-checklist" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M2.5 1.75v11.5c0 .138.112.25.25.25h3.17a.75.75 0 0 1 0 1.5H2.75A1.75 1.75 0 0 1 1 13.25V1.75C1 .784 1.784 0 2.75 0h8.5C12.216 0 13 .784 13 1.75v7.736a.75.75 0 0 1-1.5 0V1.75a.25.25 0 0 0-.25-.25h-8.5a.25.25 0 0 0-.25.25Zm10.97 8.72a.75.75 0 0 1 1.06 0l2 2a.75.75 0 0 1-1.06 1.06l-1.22-1.22v4.94a.75.75 0 0 1-1.5 0v-4.94l-1.22 1.22a.75.75 0 0 1-1.06-1.06Z"></path></svg>`,
},
{
type: `success`,
title: `Success`,
icon: `<svg class="octicon octicon-check-circle" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M8 16A8 8 0 1 1 8 0a8 8 0 0 1 0 16Zm3.78-9.72a.751.751 0 0 0-.018-1.042.751.751 0 0 0-1.042-.018L6.75 9.19 5.28 7.72a.751.751 0 0 0-1.042.018.751.751 0 0 0-.018 1.042l2 2a.75.75 0 0 0 1.06 0Z"></path></svg>`,
},
{
type: `done`,
title: `Done`,
icon: `<svg class="octicon octicon-check-circle" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M8 16A8 8 0 1 1 8 0a8 8 0 0 1 0 16Zm3.78-9.72a.751.751 0 0 0-.018-1.042.751.751 0 0 0-1.042-.018L6.75 9.19 5.28 7.72a.751.751 0 0 0-1.042.018.751.751 0 0 0-.018 1.042l2 2a.75.75 0 0 0 1.06 0Z"></path></svg>`,
},
{
type: `question`,
title: `Question`,
icon: `<svg class="octicon octicon-question" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.92 6.085h.001a.749.749 0 1 1-1.342-.67c.169-.339.436-.701.849-.977C6.845 4.16 7.369 4 8 4a2.756 2.756 0 0 1 1.637.525c.503.377.863.965.863 1.725 0 .448-.115.83-.329 1.15-.205.307-.47.513-.692.662-.109.072-.22.138-.313.195l-.006.004a6.24 6.24 0 0 0-.26.16.952.952 0 0 0-.276.245.75.75 0 0 1-1.248-.832c.184-.264.42-.489.692-.661.103-.067.207-.132.313-.195l.007-.004c.1-.061.182-.11.258-.161a.969.969 0 0 0 .277-.245C8.96 6.514 9 6.427 9 6.25a.612.612 0 0 0-.262-.525A1.27 1.27 0 0 0 8 5.5c-.369 0-.595.09-.74.187a1.01 1.01 0 0 0-.34.398ZM9 11a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z"></path></svg>`,
},
{
type: `help`,
title: `Help`,
icon: `<svg class="octicon octicon-question" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.92 6.085h.001a.749.749 0 1 1-1.342-.67c.169-.339.436-.701.849-.977C6.845 4.16 7.369 4 8 4a2.756 2.756 0 0 1 1.637.525c.503.377.863.965.863 1.725 0 .448-.115.83-.329 1.15-.205.307-.47.513-.692.662-.109.072-.22.138-.313.195l-.006.004a6.24 6.24 0 0 0-.26.16.952.952 0 0 0-.276.245.75.75 0 0 1-1.248-.832c.184-.264.42-.489.692-.661.103-.067.207-.132.313-.195l.007-.004c.1-.061.182-.11.258-.161a.969.969 0 0 0 .277-.245C8.96 6.514 9 6.427 9 6.25a.612.612 0 0 0-.262-.525A1.27 1.27 0 0 0 8 5.5c-.369 0-.595.09-.74.187a1.01 1.01 0 0 0-.34.398ZM9 11a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z"></path></svg>`,
},
{
type: `faq`,
title: `FAQ`,
icon: `<svg class="octicon octicon-question" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.92 6.085h.001a.749.749 0 1 1-1.342-.67c.169-.339.436-.701.849-.977C6.845 4.16 7.369 4 8 4a2.756 2.756 0 0 1 1.637.525c.503.377.863.965.863 1.725 0 .448-.115.83-.329 1.15-.205.307-.47.513-.692.662-.109.072-.22.138-.313.195l-.006.004a6.24 6.24 0 0 0-.26.16.952.952 0 0 0-.276.245.75.75 0 0 1-1.248-.832c.184-.264.42-.489.692-.661.103-.067.207-.132.313-.195l.007-.004c.1-.061.182-.11.258-.161a.969.969 0 0 0 .277-.245C8.96 6.514 9 6.427 9 6.25a.612.612 0 0 0-.262-.525A1.27 1.27 0 0 0 8 5.5c-.369 0-.595.09-.74.187a1.01 1.01 0 0 0-.34.398ZM9 11a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z"></path></svg>`,
},
{
type: `failure`,
title: `Failure`,
icon: `<svg class="octicon octicon-x-circle" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M2.343 13.657A8 8 0 1 1 13.658 2.343 8 8 0 0 1 2.343 13.657ZM6.03 4.97a.751.751 0 0 0-1.042.018.751.751 0 0 0-.018 1.042L6.94 8 4.97 9.97a.749.749 0 0 0 .326 1.275.749.749 0 0 0 .734-.215L8 9.06l1.97 1.97a.749.749 0 0 0 1.275-.326.749.749 0 0 0-.215-.734L9.06 8l1.97-1.97a.749.749 0 0 0-.326-1.275.749.749 0 0 0-.734.215L8 6.94Z"></path></svg>`,
},
{
type: `fail`,
title: `Fail`,
icon: `<svg class="octicon octicon-x-circle" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M2.343 13.657A8 8 0 1 1 13.658 2.343 8 8 0 0 1 2.343 13.657ZM6.03 4.97a.751.751 0 0 0-1.042.018.751.751 0 0 0-.018 1.042L6.94 8 4.97 9.97a.749.749 0 0 0 .326 1.275.749.749 0 0 0 .734-.215L8 9.06l1.97 1.97a.749.749 0 0 0 1.275-.326.749.749 0 0 0-.215-.734L9.06 8l1.97-1.97a.749.749 0 0 0-.326-1.275.749.749 0 0 0-.734.215L8 6.94Z"></path></svg>`,
},
{
type: `missing`,
title: `Missing`,
icon: `<svg class="octicon octicon-x-circle" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M2.343 13.657A8 8 0 1 1 13.658 2.343 8 8 0 0 1 2.343 13.657ZM6.03 4.97a.751.751 0 0 0-1.042.018.751.751 0 0 0-.018 1.042L6.94 8 4.97 9.97a.749.749 0 0 0 .326 1.275.749.749 0 0 0 .734-.215L8 9.06l1.97 1.97a.749.749 0 0 0 1.275-.326.749.749 0 0 0-.215-.734L9.06 8l1.97-1.97a.749.749 0 0 0-.326-1.275.749.749 0 0 0-.734.215L8 6.94Z"></path></svg>`,
},
{
type: `danger`,
title: `Danger`,
icon: `<svg class="octicon octicon-zap" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M9.504.43a1.516 1.516 0 0 1 2.437 1.713L10.415 5.5h2.123c1.57 0 2.346 1.909 1.22 3.004l-7.34 7.142a1.249 1.249 0 0 1-.871.354h-.302a1.25 1.25 0 0 1-1.157-1.723L5.633 10.5H3.462c-1.57 0-2.346-1.909-1.22-3.004ZM9.98 1.873a.016.016 0 0 0-.016.006L2.252 9.021a.75.75 0 0 0 .488 1.212h3.838a.75.75 0 0 1 .694 1.034L5.545 15.02a.016.016 0 0 0 .003.017.017.017 0 0 0 .018.004h.302a.016.016 0 0 0 .012-.005l7.34-7.142a.75.75 0 0 0-.488-1.212h-3.838a.75.75 0 0 1-.694-1.034l1.527-3.628a.016.016 0 0 0-.003-.017.017.017 0 0 0-.018-.004h-.302Z"></path></svg>`,
},
{
type: `error`,
title: `Error`,
icon: `<svg class="octicon octicon-zap" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M9.504.43a1.516 1.516 0 0 1 2.437 1.713L10.415 5.5h2.123c1.57 0 2.346 1.909 1.22 3.004l-7.34 7.142a1.249 1.249 0 0 1-.871.354h-.302a1.25 1.25 0 0 1-1.157-1.723L5.633 10.5H3.462c-1.57 0-2.346-1.909-1.22-3.004ZM9.98 1.873a.016.016 0 0 0-.016.006L2.252 9.021a.75.75 0 0 0 .488 1.212h3.838a.75.75 0 0 1 .694 1.034L5.545 15.02a.016.016 0 0 0 .003.017.017.017 0 0 0 .018.004h.302a.016.016 0 0 0 .012-.005l7.34-7.142a.75.75 0 0 0-.488-1.212h-3.838a.75.75 0 0 1-.694-1.034l1.527-3.628a.016.016 0 0 0-.003-.017.017.017 0 0 0-.018-.004h-.302Z"></path></svg>`,
},
{
type: `bug`,
title: `Bug`,
icon: `<svg class="octicon octicon-bug" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M4.72.22a.75.75 0 0 1 1.06 0l1 .999a3.488 3.488 0 0 1 2.441 0l.999-1a.748.748 0 0 1 1.265.332.75.75 0 0 1-.205.729l-.775.776c.616.63.995 1.493.995 2.444v.327c0 .1-.009.197-.025.292l.727.726a.75.75 0 1 1-1.06 1.06l-.727-.727a2.17 2.17 0 0 1-.292.026H9.25V7.5a.75.75 0 0 1-1.5 0V6.125H6.875a2.17 2.17 0 0 1-.292-.026l-.727.727a.75.75 0 1 1-1.06-1.06l.727-.726a2.17 2.17 0 0 1-.025-.292V4.5c0-.951.379-1.814.995-2.444l-.775-.776a.75.75 0 0 1 0-1.06Zm6.437 6.003A.608.608 0 0 0 11 6.072v-.026a3.999 3.999 0 0 0-.11-.936 2.488 2.488 0 0 0-5.78 0 3.992 3.992 0 0 0-.11.936v.026c0 .05.008.098.02.147h4.937a.612.612 0 0 0 .2-.02ZM2.25 7.5a.75.75 0 0 0 0 1.5h.5v1.25a4.75 4.75 0 0 0 2.478 4.166l.247.137a.75.75 0 1 0 .722-1.313l-.246-.137A3.25 3.25 0 0 1 4.25 10.25V9h7.5v1.25a3.25 3.25 0 0 1-1.701 2.853l-.246.137a.75.75 0 1 0 .722 1.313l.247-.137A4.75 4.75 0 0 0 13.25 10.25V9h.5a.75.75 0 0 0 0-1.5Z"></path></svg>`,
},
{
type: `example`,
title: `Example`,
icon: `<svg class="octicon octicon-list-unordered" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M5.75 2.5h8.5a.75.75 0 0 1 0 1.5h-8.5a.75.75 0 0 1 0-1.5Zm0 5h8.5a.75.75 0 0 1 0 1.5h-8.5a.75.75 0 0 1 0-1.5Zm0 5h8.5a.75.75 0 0 1 0 1.5h-8.5a.75.75 0 0 1 0-1.5ZM2 14a1 1 0 1 1 0-2 1 1 0 0 1 0 2Zm1-6a1 1 0 1 1-2 0 1 1 0 0 1 2 0ZM2 4a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z"></path></svg>`,
},
{
type: `quote`,
title: `Quote`,
icon: `<svg class="octicon octicon-quote" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M1.75 2h12.5c.966 0 1.75.784 1.75 1.75v8.5A1.75 1.75 0 0 1 14.25 14H1.75A1.75 1.75 0 0 1 0 12.25v-8.5C0 2.784.784 2 1.75 2ZM1.5 12.25c0 .138.112.25.25.25h12.5a.25.25 0 0 0 .25-.25v-8.5a.25.25 0 0 0-.25-.25H1.75a.25.25 0 0 0-.25.25ZM4 5.25a.75.75 0 0 1 .75-.75h6.5a.75.75 0 0 1 0 1.5h-6.5A.75.75 0 0 1 4 5.25Zm0 4a.75.75 0 0 1 .75-.75h6.5a.75.75 0 0 1 0 1.5h-6.5a.75.75 0 0 1-.75-.75Z"></path></svg>`,
},
{
type: `cite`,
title: `Cite`,
icon: `<svg class="octicon octicon-quote" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M1.75 2h12.5c.966 0 1.75.784 1.75 1.75v8.5A1.75 1.75 0 0 1 14.25 14H1.75A1.75 1.75 0 0 1 0 12.25v-8.5C0 2.784.784 2 1.75 2ZM1.5 12.25c0 .138.112.25.25.25h12.5a.25.25 0 0 0 .25-.25v-8.5a.25.25 0 0 0-.25-.25H1.75a.25.25 0 0 0-.25.25ZM4 5.25a.75.75 0 0 1 .75-.75h6.5a.75.75 0 0 1 0 1.5h-6.5A.75.75 0 0 1 4 5.25Zm0 4a.75.75 0 0 1 .75-.75h6.5a.75.75 0 0 1 0 1.5h-6.5a.75.75 0 0 1-.75-.75Z"></path></svg>`,
},
]
/**
* Resolves the variants configuration, combining the provided variants with
* the default variants.
*/
export function resolveVariants(variants: AlertVariantItem[]) {
if (!variants.length)
return defaultAlertVariant
return Object.values(
[...defaultAlertVariant, ...variants].reduce(
(map, item) => {
map[item.type] = item
return map
},
{} as { [key: string]: AlertVariantItem },
),
)
}
/**
* Returns regex pattern to match alert syntax.
*/
export function createSyntaxPattern(type: string) {
return `^(?:\\[!${type}])\\s*?\n*`
}
@@ -0,0 +1,89 @@
import type { MarkedExtension, Tokens } from 'marked'
/**
* A marked extension to support footnotes syntax.
* Syntax:
* This is a footnote reference[^1][^2].
*
* [^1]: .....
* [^2]: .....
*/
interface MapContent {
index: number
text: string
}
const fnMap = new Map<string, MapContent>()
export function markedFootnotes(): MarkedExtension {
return {
extensions: [
{
name: `footnoteDef`,
level: `block`,
start(src: string) {
fnMap.clear()
return src.match(/^\[\^/)?.index
},
tokenizer(src: string) {
const match = src.match(/^\[\^(.*)\]:(.*)/)
if (match) {
const [raw, fnId, text] = match
const index = fnMap.size + 1
fnMap.set(fnId, { index, text })
return {
type: `footnoteDef`,
raw,
fnId,
index,
text,
}
}
return undefined
},
renderer(token: Tokens.Generic) {
const { index, text, fnId } = token
const fnInner = `
<code>${index}.</code>
<span>${text}</span>
<a id="fnDef-${fnId}" href="#fnRef-${fnId}" style="color: var(--md-primary-color);">\u21A9\uFE0E</a>
<br>`
if (index === 1) {
return `
<p style="font-size: 80%;margin: 0.5em 8px;word-break:break-all;">${fnInner}`
}
if (index === fnMap.size) {
return `${fnInner}</p>`
}
return fnInner
},
},
{
name: `footnoteRef`,
level: `inline`,
start(src: string) {
return src.match(/\[\^/)?.index
},
tokenizer(src: string) {
const match = src.match(/^\[\^(.*?)\]/)
if (match) {
const [raw, fnId] = match
if (fnMap.has(fnId)) {
return {
type: `footnoteRef`,
raw,
fnId,
}
}
}
},
renderer(token: Tokens.Generic) {
const { fnId } = token
const { index } = fnMap.get(fnId) as MapContent
return `<sup style="color: var(--md-primary-color);">
<a href="#fnDef-${fnId}" id="fnRef-${fnId}">\[${index}\]</a>
</sup>`
},
},
],
}
}
@@ -0,0 +1,10 @@
// Markdown 扩展导出
export * from './alert.js'
export * from './footnotes.js'
export * from './infographic.js'
export * from './katex.js'
export * from './markup.js'
export * from './plantuml.js'
export * from './ruby.js'
export * from './slider.js'
export * from './toc.js'
@@ -0,0 +1,119 @@
import type { MarkedExtension } from 'marked'
interface InfographicOptions {
themeMode?: 'dark' | 'light'
}
async function renderInfographic(containerId: string, code: string, options?: InfographicOptions) {
if (typeof window === 'undefined')
return
try {
const { Infographic, setDefaultFont, setFontExtendFactor, exportToSVG } = await import('@antv/infographic')
setFontExtendFactor(1.1)
setDefaultFont('-apple-system-font, "system-ui", "Helvetica Neue", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei UI", "Microsoft YaHei", Arial, sans-serif')
const findContainer = (retries = 5, delay = 100) => {
const container = document.getElementById(containerId)
if (container) {
const isDark = options?.themeMode === 'dark'
// 从 CSS 变量中读取主题颜色
const root = document.documentElement
const computedStyle = getComputedStyle(root)
const primaryColor = computedStyle.getPropertyValue('--md-primary-color').trim()
const backgroundColor = computedStyle.getPropertyValue('--background').trim()
// 转换 HSL 格式
const toHSLString = (variant: string) => {
const vars = variant.split(' ')
if (vars.length === 3)
return `hsl(${vars.join(', ')})`
if (vars.length === 4)
return `hsla(${vars.join(', ')})`
return ''
}
const instance = new Infographic({
container,
svg: {
style: {
width: '100%',
height: '100%',
background: isDark ? '#000' : 'transparent',
},
background: false,
},
theme: isDark ? 'dark' : 'default',
themeConfig: {
colorPrimary: primaryColor || undefined,
colorBg: toHSLString(backgroundColor) || undefined,
},
})
instance.on('loaded', ({ node }) => {
exportToSVG(node, { removeIds: true }).then((svg) => {
container.replaceChildren(svg)
})
})
instance.render(code)
return
}
if (retries > 0) {
setTimeout(() => findContainer(retries - 1, delay), delay)
}
}
findContainer()
}
catch (error) {
console.error('Failed to render Infographic:', error)
const container = document.getElementById(containerId)
if (container) {
container.innerHTML = `<div style="color: red; padding: 10px; border: 1px solid red;">Infographic 渲染失败: ${error instanceof Error ? error.message : String(error)}</div>`
}
}
}
export function markedInfographic(options?: InfographicOptions): MarkedExtension {
const className = 'infographic-diagram'
return {
extensions: [
{
name: 'infographic',
level: 'block',
start(src: string) {
return src.match(/^```infographic/m)?.index
},
tokenizer(src: string) {
const match = /^```infographic\r?\n([\s\S]*?)\r?\n```/.exec(src)
if (match) {
return {
type: 'infographic',
raw: match[0],
text: match[1].trim(),
}
}
},
renderer(token: any) {
const id = `infographic-${Math.random().toString(36).slice(2, 11)}`
const code = token.text
renderInfographic(id, code, options)
return `<div id="${id}" class="${className}" style="width: 100%;">正在加载 Infographic...</div>`
},
},
],
walkTokens(token: any) {
if (token.type === 'code' && token.lang === 'infographic') {
token.type = 'infographic'
}
},
}
}
@@ -0,0 +1,162 @@
import type { MarkedExtension } from 'marked'
export interface MarkedKatexOptions {
nonStandard?: boolean
}
const inlineRule = /^(\${1,2})(?!\$)((?:\\.|[^\\\n])*?(?:\\.|[^\\\n$]))\1(?=[\s?!.,:?!。,:]|$)/
const inlineRuleNonStandard = /^(\${1,2})(?!\$)((?:\\.|[^\\\n])*?(?:\\.|[^\\\n$]))\1/ // Non-standard, even if there are no spaces before and after $ or $$, try to parse
const blockRule = /^\s{0,3}(\${1,2})[ \t]*\n([\s\S]+?)\n\s{0,3}\1[ \t]*(?:\n|$)/
// LaTeX style rules for \( ... \) and \[ ... \]
const inlineLatexRule = /^\\\(([^\\]*(?:\\.[^\\]*)*?)\\\)/
const blockLatexRule = /^\\\[([^\\]*(?:\\.[^\\]*)*?)\\\]/
function createRenderer(display: boolean, withStyle: boolean = true) {
return (token: any) => {
// @ts-expect-error MathJax is a global variable
window.MathJax.texReset()
// @ts-expect-error MathJax is a global variable
const mjxContainer = window.MathJax.tex2svg(token.text, { display })
const svg = mjxContainer.firstChild
const width = svg.style[`min-width`] || svg.getAttribute(`width`)
svg.removeAttribute(`width`)
// 行内公式对齐 https://groups.google.com/g/mathjax-users/c/zThKffrrCvE?pli=1
// 直接覆盖 style 会覆盖 MathJax 的样式,需要手动设置
// svg.style = `max-width: 300vw !important; display: initial; flex-shrink: 0;`
if (withStyle) {
svg.style.display = `initial`
svg.style.setProperty(`max-width`, `300vw`, `important`)
svg.style.flexShrink = `0`
svg.style.width = width
}
if (!display) {
// 新主题系统:使用 class 而非内联样式
return `<span class="katex-inline">${svg.outerHTML}</span>`
}
return `<section class="katex-block">${svg.outerHTML}</section>`
}
}
function inlineKatex(options: MarkedKatexOptions | undefined, renderer: any) {
const nonStandard = options && options.nonStandard
const ruleReg = nonStandard ? inlineRuleNonStandard : inlineRule
return {
name: `inlineKatex`,
level: `inline`,
start(src: string) {
let index
let indexSrc = src
while (indexSrc) {
index = indexSrc.indexOf(`$`)
if (index === -1) {
return
}
const f = nonStandard ? index > -1 : index === 0 || indexSrc.charAt(index - 1) === ` `
if (f) {
const possibleKatex = indexSrc.substring(index)
if (possibleKatex.match(ruleReg)) {
return index
}
}
indexSrc = indexSrc.substring(index + 1).replace(/^\$+/, ``)
}
},
tokenizer(src: string) {
const match = src.match(ruleReg)
if (match) {
return {
type: `inlineKatex`,
raw: match[0],
text: match[2].trim(),
displayMode: match[1].length === 2,
}
}
},
renderer,
}
}
function blockKatex(_options: MarkedKatexOptions | undefined, renderer: any) {
return {
name: `blockKatex`,
level: `block`,
tokenizer(src: string) {
const match = src.match(blockRule)
if (match) {
return {
type: `blockKatex`,
raw: match[0],
text: match[2].trim(),
displayMode: match[1].length === 2,
}
}
},
renderer,
}
}
function inlineLatexKatex(_options: MarkedKatexOptions | undefined, renderer: any) {
return {
name: `inlineLatexKatex`,
level: `inline`,
start(src: string) {
const index = src.indexOf(`\\(`)
return index !== -1 ? index : undefined
},
tokenizer(src: string) {
const match = src.match(inlineLatexRule)
if (match) {
return {
type: `inlineLatexKatex`,
raw: match[0],
text: match[1].trim(),
displayMode: false,
}
}
},
renderer,
}
}
function blockLatexKatex(_options: MarkedKatexOptions | undefined, renderer: any) {
return {
name: `blockLatexKatex`,
level: `block`,
start(src: string) {
const index = src.indexOf(`\\[`)
return index !== -1 ? index : undefined
},
tokenizer(src: string) {
const match = src.match(blockLatexRule)
if (match) {
return {
type: `blockLatexKatex`,
raw: match[0],
text: match[1].trim(),
displayMode: true,
}
}
},
renderer,
}
}
export function MDKatex(options: MarkedKatexOptions | undefined, withStyle: boolean = true): MarkedExtension {
return {
extensions: [
inlineKatex(options, createRenderer(false, withStyle)),
blockKatex(options, createRenderer(true, withStyle)),
inlineLatexKatex(options, createRenderer(false, withStyle)),
blockLatexKatex(options, createRenderer(true, withStyle)),
],
}
}
@@ -0,0 +1,87 @@
import type { MarkedExtension } from 'marked'
/**
* 扩展标记语法:
* - 高亮: ==文本==
* - 下划线: ++文本++
* - 波浪线: ~文本~
*/
export function markedMarkup(): MarkedExtension {
return {
extensions: [
// 高亮语法 ==文本==
{
name: `markup_highlight`,
level: `inline`,
start(src: string) {
return src.match(/==(?!=)/)?.index
},
tokenizer(src: string) {
const rule = /^==((?:[^=]|=(?!=))+)==/
const match = rule.exec(src)
if (match) {
return {
type: `markup_highlight`,
raw: match[0],
text: match[1],
}
}
},
renderer(token: any) {
// 新主题系统:使用 class 而非内联样式
return `<span class="markup-highlight">${token.text}</span>`
},
},
// 下划线语法 ++文本++
{
name: `markup_underline`,
level: `inline`,
start(src: string) {
return src.match(/\+\+(?!\+)/)?.index
},
tokenizer(src: string) {
const rule = /^\+\+((?:[^+]|\+(?!\+))+)\+\+/
const match = rule.exec(src)
if (match) {
return {
type: `markup_underline`,
raw: match[0],
text: match[1],
}
}
},
renderer(token: any) {
// 新主题系统:使用 class 而非内联样式
return `<span class="markup-underline">${token.text}</span>`
},
},
// 波浪线语法 ~文本~
{
name: `markup_wavyline`,
level: `inline`,
start(src: string) {
// 查找单个 ~ 但不是连续的 ~~
return src.match(/~(?!~)/)?.index
},
tokenizer(src: string) {
// 匹配 ~文本~ 但确保不是 ~~文本~~
const rule = /^~([^~\n]+)~(?!~)/
const match = rule.exec(src)
if (match) {
return {
type: `markup_wavyline`,
raw: match[0],
text: match[1],
}
}
},
renderer(token: any) {
// 新主题系统:使用 class 而非内联样式
return `<span class="markup-wavyline">${token.text}</span>`
},
},
],
}
}
@@ -0,0 +1,289 @@
import type { MarkedExtension, Tokens } from 'marked'
import { deflateSync } from 'fflate'
export interface PlantUMLOptions {
/**
* PlantUML 服务器地址
* @default 'https://www.plantuml.com/plantuml'
*/
serverUrl?: string
/**
* 渲染格式
* @default 'svg'
*/
format?: `svg` | `png`
/**
* CSS 类名
* @default 'plantuml-diagram'
*/
className?: string
/**
* 是否内嵌SVG内容(用于微信公众号等不支持外链图片的环境)
* @default false
*/
inlineSvg?: boolean
/**
* 自定义样式
*/
styles?: {
container?: Record<string, string | number>
}
}
/**
* PlantUML 专用的 6-bit 编码函数
* 基于官方文档 https://plantuml.com/text-encoding
*/
function encode6bit(b: number): string {
if (b < 10) {
return String.fromCharCode(48 + b)
}
b -= 10
if (b < 26) {
return String.fromCharCode(65 + b)
}
b -= 26
if (b < 26) {
return String.fromCharCode(97 + b)
}
b -= 26
if (b === 0) {
return `-`
}
if (b === 1) {
return `_`
}
return `?`
}
/**
* 将 3 个字节附加到编码字符串中
* 基于官方文档 https://plantuml.com/text-encoding
*/
function append3bytes(b1: number, b2: number, b3: number): string {
const c1 = b1 >> 2
const c2 = ((b1 & 0x3) << 4) | (b2 >> 4)
const c3 = ((b2 & 0xF) << 2) | (b3 >> 6)
const c4 = b3 & 0x3F
let r = ``
r += encode6bit(c1 & 0x3F)
r += encode6bit(c2 & 0x3F)
r += encode6bit(c3 & 0x3F)
r += encode6bit(c4 & 0x3F)
return r
}
/**
* PlantUML 专用的 base64 编码函数
* 基于官方文档 https://plantuml.com/text-encoding
*/
function encode64(data: string): string {
let r = ``
for (let i = 0; i < data.length; i += 3) {
if (i + 2 === data.length) {
r += append3bytes(data.charCodeAt(i), data.charCodeAt(i + 1), 0)
}
else if (i + 1 === data.length) {
r += append3bytes(data.charCodeAt(i), 0, 0)
}
else {
r += append3bytes(data.charCodeAt(i), data.charCodeAt(i + 1), data.charCodeAt(i + 2))
}
}
return r
}
/**
* 使用 fflate 库进行 Deflate 压缩
* 按照官方规范进行压缩
*/
function performDeflate(input: string): string {
try {
// 将字符串转换为字节数组
const inputBytes = new TextEncoder().encode(input)
// 使用 fflate 进行 deflate 压缩(最高压缩级别 9)
const compressed = deflateSync(inputBytes, { level: 9 })
// 将压缩后的字节数组转换为二进制字符串
return String.fromCharCode(...compressed)
}
catch (error) {
console.warn(`Deflate compression failed:`, error)
// 如果压缩失败,返回原始输入
return input
}
}
/**
* 编码 PlantUML 代码为服务器可识别的格式
* 按照官方规范:UTF-8 编码 -> Deflate 压缩 -> PlantUML Base64 编码
*/
function encodePlantUML(plantumlCode: string): string {
try {
// 步骤 1 & 2: UTF-8 编码 + Deflate 压缩
const deflated = performDeflate(plantumlCode)
// 步骤 3: PlantUML 专用的 base64 编码
return encode64(deflated)
}
catch (error) {
// 如果编码失败,回退到简单方案
console.warn(`PlantUML encoding failed, using fallback:`, error)
const utf8Bytes = new TextEncoder().encode(plantumlCode)
const base64 = btoa(String.fromCharCode(...utf8Bytes))
return `~1${base64.replace(/\+/g, `-`).replace(/\//g, `_`).replace(/=/g, ``)}`
}
}
/**
* 生成 PlantUML 图片 URL
*/
function generatePlantUMLUrl(code: string, options: Required<PlantUMLOptions>): string {
const encoded = encodePlantUML(code)
const formatPath = options.format === `svg` ? `svg` : `png`
return `${options.serverUrl}/${formatPath}/${encoded}`
}
/**
* 渲染 PlantUML 图表
*/
function renderPlantUMLDiagram(token: Tokens.Code, options: Required<PlantUMLOptions>): string {
const { text: code } = token
// 检查代码是否包含 PlantUML 标记
const finalCode = (!code.trim().includes(`@start`) || !code.trim().includes(`@end`))
? `@startuml\n${code.trim()}\n@enduml`
: code
const imageUrl = generatePlantUMLUrl(finalCode, options)
// 如果启用了内嵌SVG且格式是SVG
if (options.inlineSvg && options.format === `svg`) {
// 由于marked是同步的,我们需要返回一个占位符,然后异步替换
const placeholder = `plantuml-placeholder-${Math.random().toString(36).slice(2, 11)}`
// 异步获取SVG内容并替换
fetchSvgContent(imageUrl).then((svgContent) => {
const placeholderElement = document.querySelector(`[data-placeholder="${placeholder}"]`)
if (placeholderElement) {
placeholderElement.outerHTML = createPlantUMLHTML(imageUrl, options, svgContent)
}
})
const containerStyles = options.styles.container
? Object.entries(options.styles.container)
.map(([key, value]) => `${key.replace(/([A-Z])/g, `-$1`).toLowerCase()}: ${value}`)
.join(`; `)
: ``
return `<div class="${options.className}" style="${containerStyles}" data-placeholder="${placeholder}">
<div style="color: #666; font-style: italic;">正在加载PlantUML图表...</div>
</div>`
}
return createPlantUMLHTML(imageUrl, options)
}
/**
* 获取SVG内容
*/
async function fetchSvgContent(svgUrl: string): Promise<string> {
try {
const response = await fetch(svgUrl)
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
const svgContent = await response.text()
// 移除SVG根元素的固定尺寸,使其响应式
return svgContent
// 移除width和height属性
.replace(/(<svg[^>]*)\swidth="[^"]*"/g, `$1`)
.replace(/(<svg[^>]*)\sheight="[^"]*"/g, `$1`)
// 移除style中的width和height
.replace(/(<svg[^>]*style="[^"]*?)width:[^;]*;?/g, `$1`)
.replace(/(<svg[^>]*style="[^"]*?)height:[^;]*;?/g, `$1`)
}
catch (error) {
console.warn(`Failed to fetch SVG content from ${svgUrl}:`, error)
return `<div style="color: #666; font-style: italic;">PlantUML图表加载失败</div>`
}
}
/**
* 创建 PlantUML HTML 元素
*/
function createPlantUMLHTML(imageUrl: string, options: Required<PlantUMLOptions>, svgContent?: string): string {
const containerStyles = options.styles.container
? Object.entries(options.styles.container)
.map(([key, value]) => `${key.replace(/([A-Z])/g, `-$1`).toLowerCase()}: ${value}`)
.join(`; `)
: ``
// 如果有SVG内容,直接嵌入
if (svgContent) {
return `<div class="${options.className}" style="${containerStyles}">
${svgContent}
</div>`
}
// 否则使用图片链接
return `<div class="${options.className}" style="${containerStyles}">
<img src="${imageUrl}" alt="PlantUML Diagram" style="max-width: 100%; height: auto;" />
</div>`
}
/**
* PlantUML marked 扩展
*/
export function markedPlantUML(options: PlantUMLOptions = {}): MarkedExtension {
const resolvedOptions: Required<PlantUMLOptions> = {
serverUrl: options.serverUrl || `https://www.plantuml.com/plantuml`,
format: options.format || `svg`,
className: options.className || `plantuml-diagram`,
inlineSvg: options.inlineSvg || false,
styles: {
container: {
textAlign: `center`,
margin: `16px 8px`,
overflowX: `auto`,
...options.styles?.container,
},
},
}
return {
extensions: [
{
name: `plantuml`,
level: `block`,
start(src: string) {
// 匹配 ```plantuml 代码块
return src.match(/^```plantuml/m)?.index
},
tokenizer(src: string) {
// 匹配完整的 plantuml 代码块
const match = /^```plantuml\r?\n([\s\S]*?)\r?\n```/.exec(src)
if (match) {
const [raw, code] = match
return {
type: `plantuml`,
raw,
text: code.trim(),
}
}
},
renderer(token: any) {
return renderPlantUMLDiagram(token, resolvedOptions)
},
},
],
walkTokens(token: any) {
// 处理现有的代码块,如果语言是 plantuml 就转换类型
if (token.type === `code` && token.lang === `plantuml`) {
token.type = `plantuml`
}
},
}
}
@@ -0,0 +1,125 @@
import type { MarkedExtension } from 'marked'
/**
* 注音/拼音标注扩展
* https://talk.commonmark.org/t/proper-ruby-text-rb-syntax-support-in-markdown/2279
* https://www.w3.org/TR/ruby/
*
* 支持的格式:
* 1. [文字]{注音}
* 2. [文字]^(注音)
*
* 分隔符:
* - `・` (中点)
* - `` (全角句点)
* - `。` (中文句号)
* - `-` (英文减号)
*/
export function markedRuby(): MarkedExtension {
return {
extensions: [
{
name: `ruby`,
level: `inline`,
start(src: string) {
// 匹配以 [ 开头的格式
return src.match(/\[/)?.index
},
tokenizer(src: string) {
// 1. [文字]{注音}
const rule1 = /^\[([^\]]+)\]\{([^}]+)\}/
let match = rule1.exec(src)
if (match) {
return {
type: `ruby`,
raw: match[0],
text: match[1].trim(),
ruby: match[2].trim(),
format: `basic`,
}
}
// 2. [文字]^(注音)
const rule2 = /^\[([^\]]+)\]\^\(([^)]+)\)/
match = rule2.exec(src)
if (match) {
return {
type: `ruby`,
raw: match[0],
text: match[1].trim(),
ruby: match[2].trim(),
format: `basic-hat`,
}
}
return undefined
},
renderer(token: any) {
const { text, ruby, format } = token
// 检查是否有分隔符
const separatorRegex = /[・.。-]/g
const hasSeparators = separatorRegex.test(ruby)
if (hasSeparators) {
// 分割注音部分
const rubyParts = ruby.split(separatorRegex).filter((part: string) => part.trim() !== ``)
const textChars = text.split(``)
const result = []
if (textChars.length >= rubyParts.length) {
// 文字字符数量 >= 注音部分数量
// 按注音部分数量分割文字
let currentIndex = 0
for (let i = 0; i < rubyParts.length; i++) {
const rubyPart = rubyParts[i]
const remainingChars = textChars.length - currentIndex
const remainingParts = rubyParts.length - i
// 计算当前部分应该包含多少个字符,默认为 1
let charCount = 1
if (remainingParts === 1) {
// 最后一个部分,包含所有剩余字符
charCount = remainingChars
}
// 提取当前部分的文字
const currentText = textChars.slice(currentIndex, currentIndex + charCount).join(``)
result.push(`<ruby data-text="${currentText}" data-ruby="${rubyPart}" data-format="${format}">${currentText}<rp>(</rp><rt>${rubyPart}</rt><rp>)</rp></ruby>`)
currentIndex += charCount
}
// 处理剩余的字符
if (currentIndex < textChars.length) {
result.push(textChars.slice(currentIndex).join(``))
}
}
else {
// 文字字符数量 < 注音部分数量
// 每个字符对应一个注音部分,多余的注音被忽略
for (let i = 0; i < textChars.length; i++) {
const char = textChars[i]
const rubyPart = rubyParts[i] || ``
if (rubyPart) {
result.push(`<ruby data-text="${char}" data-ruby="${rubyPart}" data-format="${format}">${char}<rp>(</rp><rt>${rubyPart}</rt><rp>)</rp></ruby>`)
}
else {
result.push(char)
}
}
}
return result.join(``)
}
return `<ruby data-text="${text}" data-ruby="${ruby}" data-format="${format}">${text}<rp>(</rp><rt>${ruby}</rt><rp>)</rp></ruby>`
},
},
],
}
}
@@ -0,0 +1,73 @@
import type { MarkedExtension, Tokens } from 'marked'
/**
* A marked extension to support horizontal sliding images.
* Syntax: <![alt1](url1),![alt2](url2),![alt3](url3)>
*/
export function markedSlider(): MarkedExtension {
return {
extensions: [
{
name: `horizontalSlider`,
level: `block`,
start(src: string) {
return src.match(/^<!\[/)?.index
},
tokenizer(src: string) {
const rule = /^<(!\[.*?\]\(.*?\)(?:,!\[.*?\]\(.*?\))*)>/
const match = src.match(rule)
if (match) {
return {
type: `horizontalSlider`,
raw: match[0],
text: match[1],
}
}
return undefined
},
renderer(token: Tokens.Generic) {
const { text } = token
const imageMatches = text.match(/!\[(.*?)\]\((.*?)\)/g) || []
if (imageMatches.length === 0) {
return ``
}
const images = imageMatches.map((img: string) => {
const altMatch = img.match(/!\[(.*?)\]/) || []
const srcMatch = img.match(/\]\((.*?)\)/) || []
const alt = altMatch[1] || ``
const src = srcMatch[1] || ``
// 新主题系统:不再需要内联样式
return { src, alt }
})
// 使用微信公众号兼容的滑动容器布局
// 使用微信支持的section标签和特殊样式组合
return `
<section style="box-sizing: border-box; font-size: 16px;">
<section data-role="outer" style="font-family: 微软雅黑; font-size: 16px;">
<section data-role="paragraph" style="margin: 0px auto; box-sizing: border-box; width: 100%;">
<section style="margin: 0px auto; text-align: center;">
<section style="display: inline-block; width: 100%;">
<!-- 微信公众号支持的滑动图片容器 -->
<section style="overflow-x: scroll; -webkit-overflow-scrolling: touch; white-space: nowrap; width: 100%; text-align: center;">
${images.map((img: { src: string, alt: string }, _index: number) => `<section style="display: inline-block; width: 100%; margin-right: 0; vertical-align: top;">
<img src="${img.src}" alt="${img.alt}" title="${img.alt}" style="width: 100%; height: auto; border-radius: 4px; vertical-align: top;"/>
<p style="margin-top: 5px; font-size: 14px; color: #666; text-align: center; white-space: normal;">${img.alt}</p>
</section>`).join(``)}
</section>
</section>
</section>
</section>
</section>
<p style="font-size: 14px; color: #999; text-align: center; margin-top: 5px;"><<< 左右滑动看更多 >>></p>
</section>
`
},
},
],
}
}
@@ -0,0 +1,74 @@
import type { MarkedExtension } from 'marked'
/**
* marked 插件:支持 [TOC] 语法,自动生成嵌套目录
*/
export function markedToc(): MarkedExtension {
let headings: { text: string, depth: number, index: number }[] = []
let firstToken = true
return {
walkTokens(token) {
if (firstToken) {
headings = []
firstToken = false
}
if (token.type === `heading`) {
const text = token.text || ``
const depth = token.depth || 1
const index = headings.length
headings.push({ text, depth, index })
}
},
extensions: [
{
name: `toc`,
level: `block`,
start(src) {
// 只匹配独立一行的 [TOC],避免误伤
const match = src.match(/^\s*\[TOC\]\s*$/m)
return match ? match.index : undefined
},
tokenizer(src) {
const match = /^\[TOC\]/.exec(src)
if (match) {
return {
type: `toc`,
raw: match[0],
}
}
},
renderer() {
if (!headings.length)
return ``
let html = `<nav class="markdown-toc"><ul class="toc-ul toc-level-1 pl-4 border-l ml-2">`
let lastDepth = 1
headings.forEach(({ text, depth, index }) => {
if (depth > lastDepth) {
for (let i = lastDepth + 1; i <= depth; i++) {
html += `<ul class="toc-ul toc-level-${i} pl-4 border-l ml-2">`
}
}
else if (depth < lastDepth) {
for (let i = lastDepth; i > depth; i--) {
html += `</ul>`
}
}
html += `<li class="toc-li toc-level-${depth} mb-1"><a class="text-gray-700 hover:text-blue-600 underline transition-colors" href="#${index}">${text}</a></li>`
lastDepth = depth
})
for (let i = lastDepth; i > 1; i--) {
html += `</ul>`
}
html += `</ul></nav>`
firstToken = true
return html
},
},
],
}
}
@@ -0,0 +1,654 @@
#!/usr/bin/env npx tsx
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import frontMatter from "front-matter";
import hljs from "highlight.js/lib/core";
import { marked, type RendererObject, type Tokens } from "marked";
import readingTime, { type ReadTimeResults } from "reading-time";
import {
markedAlert,
markedFootnotes,
markedInfographic,
markedMarkup,
markedPlantUML,
markedRuby,
markedSlider,
markedToc,
MDKatex,
} from "./extensions/index.js";
import {
COMMON_LANGUAGES,
highlightAndFormatCode,
} from "./utils/languages.js";
type ThemeName = "default" | "grace" | "simple";
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
const THEME_DIR = path.resolve(SCRIPT_DIR, "themes");
const THEME_NAMES: ThemeName[] = ["default", "grace", "simple"];
const DEFAULT_STYLE = {
primaryColor: "#0F4C81",
fontFamily:
"-apple-system-font,BlinkMacSystemFont, Helvetica Neue, PingFang SC, Hiragino Sans GB , Microsoft YaHei UI , Microsoft YaHei ,Arial,sans-serif",
fontSize: "16px",
foreground: "0 0% 3.9%",
blockquoteBackground: "#f7f7f7",
};
Object.entries(COMMON_LANGUAGES).forEach(([name, lang]) => {
hljs.registerLanguage(name, lang);
});
export { hljs };
marked.setOptions({
breaks: true,
});
marked.use(markedSlider());
interface IOpts {
legend?: string;
citeStatus?: boolean;
countStatus?: boolean;
isMacCodeBlock?: boolean;
isShowLineNumber?: boolean;
themeMode?: "light" | "dark";
}
interface RendererAPI {
reset: (newOpts: Partial<IOpts>) => void;
setOptions: (newOpts: Partial<IOpts>) => void;
getOpts: () => IOpts;
parseFrontMatterAndContent: (markdown: string) => {
yamlData: Record<string, any>;
markdownContent: string;
readingTime: ReadTimeResults;
};
buildReadingTime: (reading: ReadTimeResults) => string;
buildFootnotes: () => string;
buildAddition: () => string;
createContainer: (html: string) => string;
}
interface ParseResult {
yamlData: Record<string, any>;
markdownContent: string;
readingTime: ReadTimeResults;
}
function escapeHtml(text: string): string {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;")
.replace(/`/g, "&#96;");
}
function buildAddition(): string {
return `
<style>
.preview-wrapper pre::before {
position: absolute;
top: 0;
right: 0;
color: #ccc;
text-align: center;
font-size: 0.8em;
padding: 5px 10px 0;
line-height: 15px;
height: 15px;
font-weight: 600;
}
</style>
`;
}
function buildFootnoteArray(footnotes: [number, string, string][]): string {
return footnotes
.map(([index, title, link]) =>
link === title
? `<code style="font-size: 90%; opacity: 0.6;">[${index}]</code>: <i style="word-break: break-all">${title}</i><br/>`
: `<code style="font-size: 90%; opacity: 0.6;">[${index}]</code> ${title}: <i style="word-break: break-all">${link}</i><br/>`
)
.join("\n");
}
function transform(legend: string, text: string | null, title: string | null): string {
const options = legend.split("-");
for (const option of options) {
if (option === "alt" && text) {
return text;
}
if (option === "title" && title) {
return title;
}
}
return "";
}
const macCodeSvg = `
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" x="0px" y="0px" width="45px" height="13px" viewBox="0 0 450 130">
<ellipse cx="50" cy="65" rx="50" ry="52" stroke="rgb(220,60,54)" stroke-width="2" fill="rgb(237,108,96)" />
<ellipse cx="225" cy="65" rx="50" ry="52" stroke="rgb(218,151,33)" stroke-width="2" fill="rgb(247,193,81)" />
<ellipse cx="400" cy="65" rx="50" ry="52" stroke="rgb(27,161,37)" stroke-width="2" fill="rgb(100,200,86)" />
</svg>
`.trim();
function parseFrontMatterAndContent(markdownText: string): ParseResult {
try {
const parsed = frontMatter(markdownText);
const yamlData = parsed.attributes;
const markdownContent = parsed.body;
const readingTimeResult = readingTime(markdownContent);
return {
yamlData: yamlData as Record<string, any>,
markdownContent,
readingTime: readingTimeResult,
};
} catch (error) {
console.error("Error parsing front-matter:", error);
return {
yamlData: {},
markdownContent: markdownText,
readingTime: readingTime(markdownText),
};
}
}
export function initRenderer(opts: IOpts = {}): RendererAPI {
const footnotes: [number, string, string][] = [];
let footnoteIndex = 0;
let codeIndex = 0;
const listOrderedStack: boolean[] = [];
const listCounters: number[] = [];
const isBrowser = typeof window !== "undefined";
function getOpts(): IOpts {
return opts;
}
function styledContent(styleLabel: string, content: string, tagName?: string): string {
const tag = tagName ?? styleLabel;
const className = `${styleLabel.replace(/_/g, "-")}`;
const headingAttr = /^h\d$/.test(tag) ? " data-heading=\"true\"" : "";
return `<${tag} class="${className}"${headingAttr}>${content}</${tag}>`;
}
function addFootnote(title: string, link: string): number {
const existingFootnote = footnotes.find(([, , existingLink]) => existingLink === link);
if (existingFootnote) {
return existingFootnote[0];
}
footnotes.push([++footnoteIndex, title, link]);
return footnoteIndex;
}
function reset(newOpts: Partial<IOpts>): void {
footnotes.length = 0;
footnoteIndex = 0;
setOptions(newOpts);
}
function setOptions(newOpts: Partial<IOpts>): void {
opts = { ...opts, ...newOpts };
marked.use(markedAlert());
if (isBrowser) {
marked.use(MDKatex({ nonStandard: true }, true));
}
marked.use(markedMarkup());
marked.use(markedInfographic({ themeMode: opts.themeMode }));
}
function buildReadingTime(readingTimeResult: ReadTimeResults): string {
if (!opts.countStatus) {
return "";
}
if (!readingTimeResult.words) {
return "";
}
return `
<blockquote class="md-blockquote">
<p class="md-blockquote-p">字数 ${readingTimeResult?.words},阅读大约需 ${Math.ceil(readingTimeResult?.minutes)} 分钟</p>
</blockquote>
`;
}
const buildFootnotes = () => {
if (!footnotes.length) {
return "";
}
return (
styledContent("h4", "引用链接")
+ styledContent("footnotes", buildFootnoteArray(footnotes), "p")
);
};
const renderer: RendererObject = {
heading({ tokens, depth }: Tokens.Heading) {
const text = this.parser.parseInline(tokens);
const tag = `h${depth}`;
return styledContent(tag, text);
},
paragraph({ tokens }: Tokens.Paragraph): string {
const text = this.parser.parseInline(tokens);
const isFigureImage = text.includes("<figure") && text.includes("<img");
const isEmpty = text.trim() === "";
if (isFigureImage || isEmpty) {
return text;
}
return styledContent("p", text);
},
blockquote({ tokens }: Tokens.Blockquote): string {
const text = this.parser.parse(tokens);
return styledContent("blockquote", text);
},
code({ text, lang = "" }: Tokens.Code): string {
if (lang.startsWith("mermaid")) {
if (isBrowser) {
clearTimeout(codeIndex as any);
codeIndex = setTimeout(async () => {
const windowRef = typeof window !== "undefined" ? (window as any) : undefined;
if (windowRef && windowRef.mermaid) {
const mermaid = windowRef.mermaid;
await mermaid.run();
} else {
const mermaid = await import("mermaid");
await mermaid.default.run();
}
}, 0) as any as number;
}
return `<pre class="mermaid">${text}</pre>`;
}
const langText = lang.split(" ")[0];
const isLanguageRegistered = hljs.getLanguage(langText);
const language = isLanguageRegistered ? langText : "plaintext";
const highlighted = highlightAndFormatCode(
text,
language,
hljs,
!!opts.isShowLineNumber
);
const span = `<span class="mac-sign" style="padding: 10px 14px 0;">${macCodeSvg}</span>`;
let pendingAttr = "";
if (!isLanguageRegistered && langText !== "plaintext") {
const escapedText = text.replace(/"/g, "&quot;");
pendingAttr = ` data-language-pending="${langText}" data-raw-code="${escapedText}" data-show-line-number="${opts.isShowLineNumber}"`;
}
const code = `<code class="language-${lang}"${pendingAttr}>${highlighted}</code>`;
return `<pre class="hljs code__pre">${span}${code}</pre>`;
},
codespan({ text }: Tokens.Codespan): string {
const escapedText = escapeHtml(text);
return styledContent("codespan", escapedText, "code");
},
list({ ordered, items, start = 1 }: Tokens.List) {
listOrderedStack.push(ordered);
listCounters.push(Number(start));
const html = items.map((item) => this.listitem(item)).join("");
listOrderedStack.pop();
listCounters.pop();
return styledContent(ordered ? "ol" : "ul", html);
},
listitem(token: Tokens.ListItem) {
const ordered = listOrderedStack[listOrderedStack.length - 1];
const idx = listCounters[listCounters.length - 1]!;
listCounters[listCounters.length - 1] = idx + 1;
const prefix = ordered ? `${idx}. ` : "• ";
let content: string;
try {
content = this.parser.parseInline(token.tokens);
} catch {
content = this.parser
.parse(token.tokens)
.replace(/^<p(?:\s[^>]*)?>([\s\S]*?)<\/p>/, "$1");
}
return styledContent("listitem", `${prefix}${content}`, "li");
},
image({ href, title, text }: Tokens.Image): string {
const newText = opts.legend ? transform(opts.legend, text, title) : "";
const subText = newText ? styledContent("figcaption", newText) : "";
const titleAttr = title ? ` title="${title}"` : "";
return `<figure><img src="${href}"${titleAttr} alt="${text}"/>${subText}</figure>`;
},
link({ href, title, text, tokens }: Tokens.Link): string {
const parsedText = this.parser.parseInline(tokens);
if (/^https?:\/\/mp\.weixin\.qq\.com/.test(href)) {
return `<a href="${href}" title="${title || text}">${parsedText}</a>`;
}
if (href === text) {
return parsedText;
}
if (opts.citeStatus) {
const ref = addFootnote(title || text, href);
return `<a href="${href}" title="${title || text}">${parsedText}<sup>[${ref}]</sup></a>`;
}
return `<a href="${href}" title="${title || text}">${parsedText}</a>`;
},
strong({ tokens }: Tokens.Strong): string {
return styledContent("strong", this.parser.parseInline(tokens));
},
em({ tokens }: Tokens.Em): string {
return styledContent("em", this.parser.parseInline(tokens));
},
table({ header, rows }: Tokens.Table): string {
const headerRow = header
.map((cell) => {
const text = this.parser.parseInline(cell.tokens);
return styledContent("th", text);
})
.join("");
const body = rows
.map((row) => {
const rowContent = row.map((cell) => this.tablecell(cell)).join("");
return styledContent("tr", rowContent);
})
.join("");
return `
<section style="max-width: 100%; overflow: auto">
<table class="preview-table">
<thead>${headerRow}</thead>
<tbody>${body}</tbody>
</table>
</section>
`;
},
tablecell(token: Tokens.TableCell): string {
const text = this.parser.parseInline(token.tokens);
return styledContent("td", text);
},
hr(_: Tokens.Hr): string {
return styledContent("hr", "");
},
};
marked.use({ renderer });
marked.use(markedMarkup());
marked.use(markedToc());
marked.use(markedSlider());
marked.use(markedAlert({}));
if (isBrowser) {
marked.use(MDKatex({ nonStandard: true }, true));
}
marked.use(markedFootnotes());
marked.use(
markedPlantUML({
inlineSvg: isBrowser,
})
);
marked.use(markedInfographic());
marked.use(markedRuby());
return {
buildAddition,
buildFootnotes,
setOptions,
reset,
parseFrontMatterAndContent,
buildReadingTime,
createContainer(content: string) {
return styledContent("container", content, "section");
},
getOpts,
};
}
function printUsage(): void {
console.error(
[
"Usage:",
" npx tsx src/md/render.ts <markdown_file> [--theme <name>]",
"",
"Options:",
` --theme Theme name (${THEME_NAMES.join(", ")})`,
].join("\n")
);
}
function parseArgs(argv: string[]): CliOptions | null {
let inputPath = "";
let theme: ThemeName = "default";
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (!arg.startsWith("--") && !inputPath) {
inputPath = arg;
continue;
}
if (arg === "--theme") {
theme = (argv[i + 1] || "") as ThemeName;
i += 1;
continue;
}
if (arg.startsWith("--theme=")) {
theme = arg.slice("--theme=".length) as ThemeName;
continue;
}
if (arg === "--help" || arg === "-h") {
return null;
}
console.error(`Unknown argument: ${arg}`);
return null;
}
if (!inputPath) {
return null;
}
if (!THEME_NAMES.includes(theme)) {
console.error(`Unknown theme: ${theme}`);
return null;
}
return {
inputPath,
theme,
};
}
interface CliOptions {
inputPath: string;
theme: ThemeName;
}
function renderMarkdown(raw: string, renderer: RendererAPI): {
html: string;
readingTime: ReadTimeResults;
} {
const { markdownContent, readingTime: readingTimeResult } =
renderer.parseFrontMatterAndContent(raw);
const html = marked.parse(markdownContent) as string;
return { html, readingTime: readingTimeResult };
}
function postProcessHtml(
baseHtml: string,
reading: ReadTimeResults,
renderer: RendererAPI
): string {
let html = baseHtml;
html = renderer.buildReadingTime(reading) + html;
html += renderer.buildFootnotes();
html += renderer.buildAddition();
html += `
<style>
.hljs.code__pre > .mac-sign {
display: ${renderer.getOpts().isMacCodeBlock ? "flex" : "none"};
}
</style>
`;
html += `
<style>
h2 strong {
color: inherit !important;
}
</style>
`;
return renderer.createContainer(html);
}
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())}`;
}
function ensureMarkdownPath(inputPath: string): void {
if (!inputPath.toLowerCase().endsWith(".md")) {
throw new Error("Input file must end with .md");
}
}
function loadThemeCss(theme: ThemeName): {
baseCss: string;
themeCss: string;
} {
const basePath = path.join(THEME_DIR, "base.css");
const themePath = path.join(THEME_DIR, `${theme}.css`);
if (!fs.existsSync(basePath)) {
throw new Error(`Missing base CSS: ${basePath}`);
}
if (!fs.existsSync(themePath)) {
throw new Error(`Missing theme CSS: ${themePath}`);
}
return {
baseCss: fs.readFileSync(basePath, "utf-8"),
themeCss: fs.readFileSync(themePath, "utf-8"),
};
}
function buildCss(baseCss: string, themeCss: string): string {
const variables = `
:root {
--md-primary-color: ${DEFAULT_STYLE.primaryColor};
--md-font-family: ${DEFAULT_STYLE.fontFamily};
--md-font-size: ${DEFAULT_STYLE.fontSize};
--foreground: ${DEFAULT_STYLE.foreground};
--blockquote-background: ${DEFAULT_STYLE.blockquoteBackground};
}
body {
margin: 0;
padding: 24px;
background: #ffffff;
}
#output {
max-width: 860px;
margin: 0 auto;
}
`.trim();
return [variables, baseCss, themeCss].join("\n\n");
}
function buildHtmlDocument(title: string, css: string, html: string): string {
return [
"<!doctype html>",
"<html>",
"<head>",
' <meta charset="utf-8" />',
' <meta name="viewport" content="width=device-width, initial-scale=1" />',
` <title>${title}</title>`,
` <style>${css}</style>`,
"</head>",
"<body>",
' <div id="output">',
html,
" </div>",
"</body>",
"</html>",
].join("\n");
}
function main(): void {
const options = parseArgs(process.argv.slice(2));
if (!options) {
printUsage();
process.exit(1);
}
const inputPath = path.resolve(process.cwd(), options.inputPath);
ensureMarkdownPath(inputPath);
if (!fs.existsSync(inputPath)) {
console.error(`File not found: ${inputPath}`);
process.exit(1);
}
const outputPath = path.resolve(
process.cwd(),
options.inputPath.replace(/\.md$/i, ".html")
);
const { baseCss, themeCss } = loadThemeCss(options.theme);
const css = buildCss(baseCss, themeCss);
const markdown = fs.readFileSync(inputPath, "utf-8");
const renderer = initRenderer({});
const { html: baseHtml, readingTime: readingTimeResult } = renderMarkdown(
markdown,
renderer
);
const content = postProcessHtml(baseHtml, readingTimeResult, renderer);
const title = path.basename(outputPath, ".html");
const html = buildHtmlDocument(title, css, content);
let backupPath = "";
if (fs.existsSync(outputPath)) {
backupPath = `${outputPath}.bak-${formatTimestamp()}`;
fs.renameSync(outputPath, backupPath);
}
fs.writeFileSync(outputPath, html, "utf-8");
if (backupPath) {
console.log(`Backup created: ${backupPath}`);
}
console.log(`HTML written: ${outputPath}`);
}
main();
@@ -0,0 +1,26 @@
/**
* MD 基础主题样式
* 包含所有元素的基础样式和 CSS 变量定义
*/
/* ==================== 容器样式 ==================== */
section,
container {
font-family: var(--md-font-family);
font-size: var(--md-font-size);
line-height: 1.75;
text-align: left;
}
/* 确保 #output 容器应用基础样式 */
#output {
font-family: var(--md-font-family);
font-size: var(--md-font-size);
line-height: 1.75;
text-align: left;
}
/* 去除第一个元素的 margin-top */
#output section > :first-child {
margin-top: 0 !important;
}
@@ -0,0 +1,433 @@
/**
* MD 默认主题(经典主题)
* 按 Alt/Option + Shift + F 可格式化
* 如需使用主题色,请使用 var(--md-primary-color) 代替颜色值
*/
/* ==================== 一级标题 ==================== */
h1 {
display: table;
padding: 0 1em;
border-bottom: 2px solid var(--md-primary-color);
margin: 2em auto 1em;
color: hsl(var(--foreground));
font-size: calc(var(--md-font-size) * 1.2);
font-weight: bold;
text-align: center;
}
/* ==================== 二级标题 ==================== */
h2 {
display: table;
padding: 0 0.2em;
margin: 4em auto 2em;
color: #fff;
background: var(--md-primary-color);
font-size: calc(var(--md-font-size) * 1.2);
font-weight: bold;
text-align: center;
}
/* ==================== 三级标题 ==================== */
h3 {
padding-left: 8px;
border-left: 3px solid var(--md-primary-color);
margin: 2em 8px 0.75em 0;
color: hsl(var(--foreground));
font-size: calc(var(--md-font-size) * 1.1);
font-weight: bold;
line-height: 1.2;
}
/* ==================== 四级标题 ==================== */
h4 {
margin: 2em 8px 0.5em;
color: var(--md-primary-color);
font-size: calc(var(--md-font-size) * 1);
font-weight: bold;
}
/* ==================== 五级标题 ==================== */
h5 {
margin: 1.5em 8px 0.5em;
color: var(--md-primary-color);
font-size: calc(var(--md-font-size) * 1);
font-weight: bold;
}
/* ==================== 六级标题 ==================== */
h6 {
margin: 1.5em 8px 0.5em;
font-size: calc(var(--md-font-size) * 1);
color: var(--md-primary-color);
}
/* ==================== 段落 ==================== */
p {
margin: 1.5em 8px;
letter-spacing: 0.1em;
color: hsl(var(--foreground));
}
/* ==================== 引用块 ==================== */
blockquote {
font-style: normal;
padding: 1em;
border-left: 4px solid var(--md-primary-color);
border-radius: 6px;
color: hsl(var(--foreground));
background: var(--blockquote-background);
margin-bottom: 1em;
}
blockquote > p {
display: block;
font-size: 1em;
letter-spacing: 0.1em;
color: hsl(var(--foreground));
margin: 0;
}
/* ==================== GFM 警告块 ==================== */
.alert-title-note,
.alert-title-tip,
.alert-title-info,
.alert-title-important,
.alert-title-warning,
.alert-title-caution,
.alert-title-abstract,
.alert-title-summary,
.alert-title-tldr,
.alert-title-todo,
.alert-title-success,
.alert-title-done,
.alert-title-question,
.alert-title-help,
.alert-title-faq,
.alert-title-failure,
.alert-title-fail,
.alert-title-missing,
.alert-title-danger,
.alert-title-error,
.alert-title-bug,
.alert-title-example,
.alert-title-quote,
.alert-title-cite {
display: flex;
align-items: center;
gap: 0.5em;
margin-bottom: 0.5em;
}
.alert-title-note {
color: #478be6;
}
.alert-title-tip {
color: #57ab5a;
}
.alert-title-info {
color: #93c5fd;
}
.alert-title-important {
color: #986ee2;
}
.alert-title-warning {
color: #c69026;
}
.alert-title-caution {
color: #e5534b;
}
/* Obsidian-style callout colors */
.alert-title-abstract,
.alert-title-summary,
.alert-title-tldr {
color: #00bfff;
}
.alert-title-todo {
color: #478be6;
}
.alert-title-success,
.alert-title-done {
color: #57ab5a;
}
.alert-title-question,
.alert-title-help,
.alert-title-faq {
color: #c69026;
}
.alert-title-failure,
.alert-title-fail,
.alert-title-missing {
color: #e5534b;
}
.alert-title-danger,
.alert-title-error {
color: #e5534b;
}
.alert-title-bug {
color: #e5534b;
}
.alert-title-example {
color: #986ee2;
}
.alert-title-quote,
.alert-title-cite {
color: #9ca3af;
}
/* GFM Alert SVG 图标颜色 */
.alert-icon-note {
fill: #478be6;
}
.alert-icon-tip {
fill: #57ab5a;
}
.alert-icon-info {
fill: #93c5fd;
}
.alert-icon-important {
fill: #986ee2;
}
.alert-icon-warning {
fill: #c69026;
}
.alert-icon-caution {
fill: #e5534b;
}
/* Obsidian-style callout icon colors */
.alert-icon-abstract,
.alert-icon-summary,
.alert-icon-tldr {
fill: #00bfff;
}
.alert-icon-todo {
fill: #478be6;
}
.alert-icon-success,
.alert-icon-done {
fill: #57ab5a;
}
.alert-icon-question,
.alert-icon-help,
.alert-icon-faq {
fill: #c69026;
}
.alert-icon-failure,
.alert-icon-fail,
.alert-icon-missing {
fill: #e5534b;
}
.alert-icon-danger,
.alert-icon-error {
fill: #e5534b;
}
.alert-icon-bug {
fill: #e5534b;
}
.alert-icon-example {
fill: #986ee2;
}
.alert-icon-quote,
.alert-icon-cite {
fill: #9ca3af;
}
/* ==================== 代码块 ==================== */
pre.code__pre,
.hljs.code__pre {
font-size: 90%;
overflow-x: auto;
border-radius: 8px;
padding: 0 !important;
line-height: 1.5;
margin: 10px 8px;
}
/* ==================== 图片 ==================== */
img {
display: block;
max-width: 100%;
margin: 0.1em auto 0.5em;
border-radius: 4px;
}
/* ==================== 列表 ==================== */
ol {
padding-left: 1em;
margin-left: 0;
color: hsl(var(--foreground));
}
ul {
list-style: circle;
padding-left: 1em;
margin-left: 0;
color: hsl(var(--foreground));
}
li {
display: block;
margin: 0.2em 8px;
color: hsl(var(--foreground));
}
/* ==================== 脚注 ==================== */
/* footnotes 在 buildFootnotes() 中渲染为 <p> 标签 */
p.footnotes {
margin: 0.5em 8px;
font-size: 80%;
color: hsl(var(--foreground));
}
/* ==================== 图表 ==================== */
figure {
margin: 1.5em 8px;
color: hsl(var(--foreground));
}
figcaption,
.md-figcaption {
text-align: center;
color: #888;
font-size: 0.8em;
}
/* ==================== 分隔线 ==================== */
hr {
border-style: solid;
border-width: 2px 0 0;
border-color: rgba(0, 0, 0, 0.1);
-webkit-transform-origin: 0 0;
-webkit-transform: scale(1, 0.5);
transform-origin: 0 0;
transform: scale(1, 0.5);
height: 0.4em;
margin: 1.5em 0;
}
/* ==================== 行内代码 ==================== */
code {
font-size: 90%;
color: #d14;
background: rgba(27, 31, 35, 0.05);
padding: 3px 5px;
border-radius: 4px;
}
/* 代码块内的 code 标签需要特殊处理(覆盖行内 code 样式) */
pre.code__pre > code,
.hljs.code__pre > code {
display: -webkit-box;
padding: 0.5em 1em 1em;
overflow-x: auto;
text-indent: 0;
color: inherit;
background: none;
white-space: nowrap;
margin: 0;
}
/* ==================== 强调 ==================== */
em {
font-style: italic;
font-size: inherit;
}
/* ==================== 链接 ==================== */
a {
color: #576b95;
text-decoration: none;
}
/* ==================== 粗体 ==================== */
strong {
color: var(--md-primary-color);
font-weight: bold;
font-size: inherit;
}
/* ==================== 表格 ==================== */
table {
color: hsl(var(--foreground));
}
thead {
font-weight: bold;
color: hsl(var(--foreground));
}
th {
border: 1px solid #dfdfdf;
padding: 0.25em 0.5em;
color: hsl(var(--foreground));
word-break: keep-all;
background: rgba(0, 0, 0, 0.05);
}
td {
border: 1px solid #dfdfdf;
padding: 0.25em 0.5em;
color: hsl(var(--foreground));
word-break: keep-all;
}
/* ==================== KaTeX 公式 ==================== */
.katex-inline {
max-width: 100%;
overflow-x: auto;
}
.katex-block {
max-width: 100%;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
padding: 0.5em 0;
text-align: center;
}
/* ==================== 标记高亮 ==================== */
.markup-highlight {
background-color: var(--md-primary-color);
padding: 2px 4px;
border-radius: 2px;
color: #fff;
}
.markup-underline {
text-decoration: underline;
text-decoration-color: var(--md-primary-color);
}
.markup-wavyline {
text-decoration: underline wavy;
text-decoration-color: var(--md-primary-color);
text-decoration-thickness: 2px;
}
@@ -0,0 +1,136 @@
/**
* MD 优雅主题 (@brzhang)
* 在默认主题基础上添加优雅的视觉效果
*/
/* ==================== 标题样式 ==================== */
h1 {
padding: 0.5em 1em;
border-bottom: 2px solid var(--md-primary-color);
font-size: calc(var(--md-font-size) * 1.4);
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.1);
}
h2 {
padding: 0.3em 1em;
border-radius: 8px;
font-size: calc(var(--md-font-size) * 1.3);
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
h3 {
padding-left: 12px;
font-size: calc(var(--md-font-size) * 1.2);
border-left: 4px solid var(--md-primary-color);
border-bottom: 1px dashed var(--md-primary-color);
}
h4 {
font-size: calc(var(--md-font-size) * 1.1);
}
h5 {
font-size: var(--md-font-size);
}
h6 {
font-size: var(--md-font-size);
}
/* ==================== 引用块 ==================== */
blockquote {
font-style: italic;
padding: 1em 1em 1em 2em;
border-left: 4px solid var(--md-primary-color);
border-radius: 6px;
color: rgba(0, 0, 0, 0.6);
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
margin-bottom: 1em;
}
.markdown-alert {
font-style: italic;
}
/* ==================== 代码块 ==================== */
pre.code__pre,
.hljs.code__pre {
box-shadow: inset 0 0 10px rgba(0, 0, 0, 0.05);
}
pre.code__pre > code,
.hljs.code__pre > code {
font-family:
'Fira Code',
Menlo,
Operator Mono,
Consolas,
Monaco,
monospace;
}
/* ==================== 图片 ==================== */
img {
border-radius: 8px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
figcaption,
.md-figcaption {
text-align: center;
color: #888;
font-size: 0.8em;
}
/* ==================== 列表 ==================== */
ol {
padding-left: 1.5em;
}
ul {
list-style: none;
padding-left: 1.5em;
}
li {
margin: 0.5em 8px;
}
/* ==================== 分隔线 ==================== */
hr {
height: 1px;
border: none;
margin: 2em 0;
background: linear-gradient(to right, rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.1), rgba(0, 0, 0, 0));
}
/* ==================== 表格 ==================== */
table {
border-collapse: separate;
border-spacing: 0;
border-radius: 8px;
margin: 1em 8px;
color: hsl(var(--foreground));
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
thead {
color: #fff;
}
td {
padding: 0.5em 1em;
}
/* ==================== 强调 ==================== */
em {
font-style: italic;
font-size: inherit;
}
/* ==================== 链接 ==================== */
a {
color: #576b95;
text-decoration: none;
}
@@ -0,0 +1,129 @@
/**
* MD 简洁主题 (@okooo5km)
* 简洁现代的设计风格
*/
/* ==================== 标题样式 ==================== */
h1 {
padding: 0.5em 1em;
font-size: calc(var(--md-font-size) * 1.4);
text-shadow: 1px 1px 3px rgba(0, 0, 0, 0.05);
}
h2 {
padding: 0.3em 1.2em;
font-size: calc(var(--md-font-size) * 1.3);
border-radius: 8px 24px 8px 24px;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.06);
}
h3 {
padding-left: 12px;
font-size: calc(var(--md-font-size) * 1.2);
border-radius: 6px;
line-height: 2.4em;
border-left: 4px solid var(--md-primary-color);
border-right: 1px solid color-mix(in srgb, var(--md-primary-color) 10%, transparent);
border-bottom: 1px solid color-mix(in srgb, var(--md-primary-color) 10%, transparent);
border-top: 1px solid color-mix(in srgb, var(--md-primary-color) 10%, transparent);
background: color-mix(in srgb, var(--md-primary-color) 8%, transparent);
}
h4 {
font-size: calc(var(--md-font-size) * 1.1);
border-radius: 6px;
}
h5 {
font-size: var(--md-font-size);
border-radius: 6px;
}
h6 {
font-size: var(--md-font-size);
border-radius: 6px;
}
/* ==================== 引用块 ==================== */
blockquote {
font-style: italic;
padding: 1em 1em 1em 2em;
color: rgba(0, 0, 0, 0.6);
border-bottom: 0.2px solid rgba(0, 0, 0, 0.04);
border-top: 0.2px solid rgba(0, 0, 0, 0.04);
border-right: 0.2px solid rgba(0, 0, 0, 0.04);
}
/* GFM Alert 样式覆盖 */
.markdown-alert-note,
.markdown-alert-tip,
.markdown-alert-info,
.markdown-alert-important,
.markdown-alert-warning,
.markdown-alert-caution {
font-style: italic;
}
/* ==================== 代码块 ==================== */
pre.code__pre,
.hljs.code__pre {
border: 1px solid rgba(0, 0, 0, 0.04);
}
pre.code__pre > code,
.hljs.code__pre > code {
font-family:
'Fira Code',
Menlo,
Operator Mono,
Consolas,
Monaco,
monospace;
}
/* ==================== 图片 ==================== */
img {
border-radius: 8px;
border: 1px solid rgba(0, 0, 0, 0.04);
}
figcaption,
.md-figcaption {
text-align: center;
color: #888;
font-size: 0.8em;
}
/* ==================== 列表 ==================== */
ol {
padding-left: 1.5em;
}
ul {
list-style: none;
padding-left: 1.5em;
}
li {
margin: 0.5em 8px;
}
/* ==================== 分隔线 ==================== */
hr {
height: 1px;
border: none;
margin: 2em 0;
background: linear-gradient(to right, rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.1), rgba(0, 0, 0, 0));
}
/* ==================== 强调 ==================== */
em {
font-style: italic;
font-size: inherit;
}
/* ==================== 链接 ==================== */
a {
color: #576b95;
text-decoration: none;
}
@@ -0,0 +1,238 @@
import type { LanguageFn } from 'highlight.js'
import bash from 'highlight.js/lib/languages/bash'
import c from 'highlight.js/lib/languages/c'
import cpp from 'highlight.js/lib/languages/cpp'
import csharp from 'highlight.js/lib/languages/csharp'
import css from 'highlight.js/lib/languages/css'
import diff from 'highlight.js/lib/languages/diff'
import go from 'highlight.js/lib/languages/go'
import graphql from 'highlight.js/lib/languages/graphql'
import ini from 'highlight.js/lib/languages/ini'
import java from 'highlight.js/lib/languages/java'
import javascript from 'highlight.js/lib/languages/javascript'
import json from 'highlight.js/lib/languages/json'
import kotlin from 'highlight.js/lib/languages/kotlin'
import less from 'highlight.js/lib/languages/less'
import lua from 'highlight.js/lib/languages/lua'
import makefile from 'highlight.js/lib/languages/makefile'
import markdown from 'highlight.js/lib/languages/markdown'
import objectivec from 'highlight.js/lib/languages/objectivec'
import perl from 'highlight.js/lib/languages/perl'
import php from 'highlight.js/lib/languages/php'
import phpTemplate from 'highlight.js/lib/languages/php-template'
import plaintext from 'highlight.js/lib/languages/plaintext'
import python from 'highlight.js/lib/languages/python'
import pythonRepl from 'highlight.js/lib/languages/python-repl'
import r from 'highlight.js/lib/languages/r'
import ruby from 'highlight.js/lib/languages/ruby'
import rust from 'highlight.js/lib/languages/rust'
import scss from 'highlight.js/lib/languages/scss'
import shell from 'highlight.js/lib/languages/shell'
import sql from 'highlight.js/lib/languages/sql'
import swift from 'highlight.js/lib/languages/swift'
import typescript from 'highlight.js/lib/languages/typescript'
import vbnet from 'highlight.js/lib/languages/vbnet'
import wasm from 'highlight.js/lib/languages/wasm'
import xml from 'highlight.js/lib/languages/xml'
import yaml from 'highlight.js/lib/languages/yaml'
export const COMMON_LANGUAGES: Record<string, LanguageFn> = {
bash,
c,
cpp,
csharp,
css,
diff,
go,
graphql,
ini,
java,
javascript,
json,
kotlin,
less,
lua,
makefile,
markdown,
objectivec,
perl,
php,
'php-template': phpTemplate,
plaintext,
python,
'python-repl': pythonRepl,
r,
ruby,
rust,
scss,
shell,
sql,
swift,
typescript,
vbnet,
wasm,
xml,
yaml,
}
// highlight.js CDN 配置
const HLJS_VERSION = `11.11.1`
const HLJS_CDN_BASE = `https://cdn-doocs.oss-cn-shenzhen.aliyuncs.com/npm/highlightjs/${HLJS_VERSION}`
// 缓存正在加载的语言
const loadingLanguages = new Map<string, Promise<void>>()
/**
* 生成语言包的 CDN URL
*/
function grammarUrlFor(language: string): string {
return `${HLJS_CDN_BASE}/es/languages/${language}.min.js`
}
/**
* 动态加载并注册语言
* @param language 语言名称
* @param hljs highlight.js 实例
*/
export async function loadAndRegisterLanguage(language: string, hljs: any): Promise<void> {
// 如果已经注册,直接返回
if (hljs.getLanguage(language)) {
return
}
// 如果正在加载,等待加载完成
if (loadingLanguages.has(language)) {
await loadingLanguages.get(language)
return
}
// 开始加载
const loadPromise = (async () => {
try {
const module = await import(/* @vite-ignore */ grammarUrlFor(language))
hljs.registerLanguage(language, module.default)
}
catch (error) {
console.warn(`Failed to load language: ${language}`, error)
throw error
}
finally {
loadingLanguages.delete(language)
}
})()
loadingLanguages.set(language, loadPromise)
await loadPromise
}
/**
* 格式化高亮后的代码,处理空格和制表符
*/
function formatHighlightedCode(html: string, preserveNewlines = false): string {
let formatted = html
// 将 span 之间的空格移到 span 内部
formatted = formatted.replace(/(<span[^>]*>[^<]*<\/span>)(\s+)(<span[^>]*>[^<]*<\/span>)/g, (_: string, span1: string, spaces: string, span2: string) => span1 + span2.replace(/^(<span[^>]*>)/, `$1${spaces}`))
formatted = formatted.replace(/(\s+)(<span[^>]*>)/g, (_: string, spaces: string, span: string) => span.replace(/^(<span[^>]*>)/, `$1${spaces}`))
// 替换制表符为4个空格
formatted = formatted.replace(/\t/g, ` `)
if (preserveNewlines) {
// 替换换行符为 <br/>,并将空格转换为 &nbsp;
formatted = formatted.replace(/\r\n/g, `<br/>`).replace(/\n/g, `<br/>`).replace(/(>[^<]+)|(^[^<]+)/g, (str: string) => str.replace(/\s/g, `&nbsp;`))
}
else {
// 只将空格转换为 &nbsp;
formatted = formatted.replace(/(>[^<]+)|(^[^<]+)/g, (str: string) => str.replace(/\s/g, `&nbsp;`))
}
return formatted
}
/**
* 高亮代码并格式化(支持行号)
* @param text 原始代码文本
* @param language 语言名称
* @param hljs highlight.js 实例
* @param showLineNumber 是否显示行号
* @returns 格式化后的 HTML
*/
export function highlightAndFormatCode(text: string, language: string, hljs: any, showLineNumber: boolean): string {
let highlighted = ``
if (showLineNumber) {
const rawLines = text.replace(/\r\n/g, `\n`).split(`\n`)
const highlightedLines = rawLines.map((lineRaw) => {
const lineHtml = hljs.highlight(lineRaw, { language }).value
const formatted = formatHighlightedCode(lineHtml, false)
return formatted === `` ? `&nbsp;` : formatted
})
const lineNumbersHtml = highlightedLines.map((_, idx) => `<section style="padding:0 10px 0 0;line-height:1.75">${idx + 1}</section>`).join(``)
const codeInnerHtml = highlightedLines.join(`<br/>`)
const codeLinesHtml = `<div style="white-space:pre;min-width:max-content;line-height:1.75">${codeInnerHtml}</div>`
const lineNumberColumnStyles = `text-align:right;padding:8px 0;border-right:1px solid rgba(0,0,0,0.04);user-select:none;background:var(--code-bg,transparent);`
highlighted = `
<section style="display:flex;align-items:flex-start;overflow-x:hidden;overflow-y:auto;width:100%;max-width:100%;padding:0;box-sizing:border-box">
<section class="line-numbers" style="${lineNumberColumnStyles}">${lineNumbersHtml}</section>
<section class="code-scroll" style="flex:1 1 auto;overflow-x:auto;overflow-y:visible;padding:8px;min-width:0;box-sizing:border-box">${codeLinesHtml}</section>
</section>
`
}
else {
const rawHighlighted = hljs.highlight(text, { language }).value
highlighted = formatHighlightedCode(rawHighlighted, true)
}
return highlighted
}
export function highlightCodeBlock(codeBlock: Element, language: string, hljs: any): void {
const rawCode = codeBlock.getAttribute(`data-raw-code`)
const showLineNumber = codeBlock.getAttribute(`data-show-line-number`) === `true`
if (!rawCode)
return
const text = rawCode.replace(/&quot;/g, `"`)
const highlighted = highlightAndFormatCode(text, language, hljs, showLineNumber)
codeBlock.innerHTML = highlighted
codeBlock.removeAttribute(`data-language-pending`)
codeBlock.removeAttribute(`data-raw-code`)
codeBlock.removeAttribute(`data-show-line-number`)
}
/**
* 高亮 DOM 中待处理的代码块
* 查找带有 data-language-pending 属性的代码块,动态加载语言后重新高亮
* @param hljs highlight.js 实例
* @param container 容器元素(可选,默认为 document)
*/
export function highlightPendingBlocks(hljs: any, container: Document | Element = document): void {
const pendingBlocks = container.querySelectorAll(`code[data-language-pending]`)
pendingBlocks.forEach((codeBlock) => {
const language = codeBlock.getAttribute(`data-language-pending`)
if (!language)
return
if (hljs.getLanguage(language)) {
// 语言已加载,直接高亮
highlightCodeBlock(codeBlock, language, hljs)
}
else {
// 动态加载语言后重新高亮
loadAndRegisterLanguage(language, hljs).then(() => {
highlightCodeBlock(codeBlock, language, hljs)
}).catch(() => {
// 加载失败,移除标记
codeBlock.removeAttribute(`data-language-pending`)
codeBlock.removeAttribute(`data-raw-code`)
codeBlock.removeAttribute(`data-show-line-number`)
})
}
})
}
@@ -0,0 +1,194 @@
import { spawnSync } from 'node:child_process';
import process from 'node:process';
function printUsage(exitCode = 0): never {
console.log(`Send real paste keystroke (Cmd+V / Ctrl+V) to the frontmost application
This bypasses CDP's synthetic events which websites can detect and ignore.
Usage:
npx -y bun paste-from-clipboard.ts [options]
Options:
--retries <n> Number of retry attempts (default: 3)
--delay <ms> Delay between retries in ms (default: 500)
--app <name> Target application to activate first (macOS only)
--help Show this help
Examples:
# Simple paste
npx -y bun paste-from-clipboard.ts
# Paste to Chrome with retries
npx -y bun paste-from-clipboard.ts --app "Google Chrome" --retries 5
# Quick paste with shorter delay
npx -y bun paste-from-clipboard.ts --delay 200
`);
process.exit(exitCode);
}
function sleepSync(ms: number): void {
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
}
function activateApp(appName: string): boolean {
if (process.platform !== 'darwin') return false;
// Activate and wait for app to be frontmost
const script = `
tell application "${appName}"
activate
delay 0.5
end tell
-- Verify app is frontmost
tell application "System Events"
set frontApp to name of first application process whose frontmost is true
if frontApp is not "${appName}" then
tell application "${appName}" to activate
delay 0.3
end if
end tell
`;
const result = spawnSync('osascript', ['-e', script], { stdio: 'pipe' });
return result.status === 0;
}
function pasteMac(retries: number, delayMs: number, targetApp?: string): boolean {
for (let i = 0; i < retries; i++) {
// Build script that activates app (if specified) and sends keystroke in one atomic operation
const script = targetApp
? `
tell application "${targetApp}"
activate
end tell
delay 0.3
tell application "System Events"
keystroke "v" using command down
end tell
`
: `
tell application "System Events"
keystroke "v" using command down
end tell
`;
const result = spawnSync('osascript', ['-e', script], { stdio: 'pipe' });
if (result.status === 0) {
return true;
}
const stderr = result.stderr?.toString().trim();
if (stderr) {
console.error(`[paste] osascript error: ${stderr}`);
}
if (i < retries - 1) {
console.error(`[paste] Attempt ${i + 1}/${retries} failed, retrying in ${delayMs}ms...`);
sleepSync(delayMs);
}
}
return false;
}
function pasteLinux(retries: number, delayMs: number): boolean {
// Try xdotool first (X11), then ydotool (Wayland)
const tools = [
{ cmd: 'xdotool', args: ['key', 'ctrl+v'] },
{ cmd: 'ydotool', args: ['key', '29:1', '47:1', '47:0', '29:0'] }, // Ctrl down, V down, V up, Ctrl up
];
for (const tool of tools) {
const which = spawnSync('which', [tool.cmd], { stdio: 'pipe' });
if (which.status !== 0) continue;
for (let i = 0; i < retries; i++) {
const result = spawnSync(tool.cmd, tool.args, { stdio: 'pipe' });
if (result.status === 0) {
return true;
}
if (i < retries - 1) {
console.error(`[paste] Attempt ${i + 1}/${retries} failed, retrying in ${delayMs}ms...`);
sleepSync(delayMs);
}
}
return false;
}
console.error('[paste] No supported tool found. Install xdotool (X11) or ydotool (Wayland).');
return false;
}
function pasteWindows(retries: number, delayMs: number): boolean {
const ps = `
Add-Type -AssemblyName System.Windows.Forms
[System.Windows.Forms.SendKeys]::SendWait("^v")
`;
for (let i = 0; i < retries; i++) {
const result = spawnSync('powershell.exe', ['-NoProfile', '-Command', ps], { stdio: 'pipe' });
if (result.status === 0) {
return true;
}
if (i < retries - 1) {
console.error(`[paste] Attempt ${i + 1}/${retries} failed, retrying in ${delayMs}ms...`);
sleepSync(delayMs);
}
}
return false;
}
function paste(retries: number, delayMs: number, targetApp?: string): boolean {
switch (process.platform) {
case 'darwin':
return pasteMac(retries, delayMs, targetApp);
case 'linux':
return pasteLinux(retries, delayMs);
case 'win32':
return pasteWindows(retries, delayMs);
default:
console.error(`[paste] Unsupported platform: ${process.platform}`);
return false;
}
}
async function main(): Promise<void> {
const args = process.argv.slice(2);
let retries = 3;
let delayMs = 500;
let targetApp: string | undefined;
for (let i = 0; i < args.length; i++) {
const arg = args[i] ?? '';
if (arg === '--help' || arg === '-h') {
printUsage(0);
}
if (arg === '--retries' && args[i + 1]) {
retries = parseInt(args[++i]!, 10) || 3;
} else if (arg === '--delay' && args[i + 1]) {
delayMs = parseInt(args[++i]!, 10) || 500;
} else if (arg === '--app' && args[i + 1]) {
targetApp = args[++i];
} else if (arg.startsWith('-')) {
console.error(`Unknown option: ${arg}`);
printUsage(1);
}
}
if (targetApp) {
console.log(`[paste] Target app: ${targetApp}`);
}
console.log(`[paste] Sending paste keystroke (retries=${retries}, delay=${delayMs}ms)...`);
const success = paste(retries, delayMs, targetApp);
if (success) {
console.log('[paste] Paste keystroke sent successfully');
} else {
console.error('[paste] Failed to send paste keystroke');
process.exit(1);
}
}
await main();
@@ -0,0 +1,335 @@
import { execSync, spawnSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import process from 'node:process';
const WECHAT_URL = 'https://mp.weixin.qq.com/';
const SESSION = 'wechat-post';
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function ab(cmd: string, json = false): string {
const fullCmd = `agent-browser --session ${SESSION} ${cmd}${json ? ' --json' : ''}`;
console.log(`[ab] ${fullCmd}`);
try {
const result = execSync(fullCmd, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
return result.trim();
} catch (e: unknown) {
const err = e as { stdout?: string; stderr?: string; message?: string };
console.error(`[ab] Error: ${err.stderr || err.message}`);
return err.stdout || '';
}
}
function abRaw(args: string[]): { success: boolean; output: string } {
const result = spawnSync('agent-browser', ['--session', SESSION, ...args], {
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe']
});
return {
success: result.status === 0,
output: result.stdout || result.stderr || ''
};
}
interface SnapshotElement {
ref: string;
role: string;
name: string;
}
function parseSnapshot(output: string): SnapshotElement[] {
const elements: SnapshotElement[] = [];
const refPattern = /\[ref=(@?\w+)\]/g;
const lines = output.split('\n');
for (const line of lines) {
const match = line.match(/\[ref=([@\w]+)\]/);
if (match) {
const ref = match[1].startsWith('@') ? match[1] : `@${match[1]}`;
const roleMatch = line.match(/^-\s+(\w+)/);
const nameMatch = line.match(/"([^"]+)"/);
elements.push({
ref,
role: roleMatch?.[1] || 'unknown',
name: nameMatch?.[1] || ''
});
}
}
return elements;
}
function findElementByText(snapshot: string, text: string): string | null {
const lines = snapshot.split('\n');
for (const line of lines) {
if (line.includes(`"${text}"`) || line.includes(text)) {
const match = line.match(/\[ref=([@\w]+)\]/);
if (match) {
return match[1].startsWith('@') ? match[1] : `@${match[1]}`;
}
}
}
return null;
}
function findElementBySelector(snapshot: string, selector: string): string | null {
return null;
}
interface WeChatOptions {
title: string;
content: string;
images: string[];
submit?: boolean;
keepOpen?: boolean;
}
async function postToWeChat(options: WeChatOptions): Promise<void> {
const { title, content, images, submit = false, keepOpen = true } = options;
if (title.length > 20) throw new Error(`Title too long: ${title.length} chars (max 20)`);
if (content.length > 1000) throw new Error(`Content too long: ${content.length} chars (max 1000)`);
if (images.length === 0) throw new Error('At least one image is required');
const absoluteImages = images.map(p => path.isAbsolute(p) ? p : path.resolve(process.cwd(), p));
for (const img of absoluteImages) {
if (!fs.existsSync(img)) throw new Error(`Image not found: ${img}`);
}
console.log('[wechat] Opening WeChat Official Account...');
ab(`open ${WECHAT_URL} --headed`);
await sleep(5000);
console.log('[wechat] Checking login status...');
let url = ab('get url');
console.log(`[wechat] Current URL: ${url}`);
const waitForLogin = async (timeoutMs = 120_000): Promise<boolean> => {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
url = ab('get url');
if (url.includes('/cgi-bin/home')) return true;
console.log('[wechat] Waiting for login...');
await sleep(3000);
}
return false;
};
if (!url.includes('/cgi-bin/home')) {
console.log('[wechat] Not logged in. Please scan QR code...');
const loggedIn = await waitForLogin();
if (!loggedIn) throw new Error('Login timeout');
}
console.log('[wechat] Logged in.');
await sleep(2000);
console.log('[wechat] Getting page snapshot...');
let snapshot = ab('snapshot');
console.log(snapshot);
console.log('[wechat] Looking for "图文" menu...');
const tuWenRef = findElementByText(snapshot, '图文');
if (!tuWenRef) {
console.log('[wechat] Using eval to find and click menu...');
ab(`eval "document.querySelectorAll('.new-creation__menu .new-creation__menu-item')[2].click()"`);
} else {
console.log(`[wechat] Clicking menu ref: ${tuWenRef}`);
ab(`click ${tuWenRef}`);
}
await sleep(4000);
console.log('[wechat] Checking for new tab...');
const tabsOutput = ab('tab');
console.log(`[wechat] Tabs: ${tabsOutput}`);
const tabLines = tabsOutput.split('\n');
const editorTabLine = tabLines.find(l => l.includes('appmsg') || (!l.includes('cgi-bin/home') && l.includes('mp.weixin.qq.com')));
if (tabLines.length > 1) {
const tabMatch = tabsOutput.match(/\[(\d+)\].*(?:appmsg|edit)/i);
if (tabMatch) {
console.log(`[wechat] Switching to editor tab ${tabMatch[1]}...`);
ab(`tab ${tabMatch[1]}`);
} else {
const lastTabMatch = tabsOutput.match(/\[(\d+)\]/g);
if (lastTabMatch && lastTabMatch.length > 1) {
const lastTab = lastTabMatch[lastTabMatch.length - 1].match(/\d+/)?.[0];
if (lastTab) {
console.log(`[wechat] Switching to last tab ${lastTab}...`);
ab(`tab ${lastTab}`);
}
}
}
}
await sleep(3000);
url = ab('get url');
console.log(`[wechat] Editor URL: ${url}`);
console.log('[wechat] Getting editor snapshot...');
snapshot = ab('snapshot');
console.log(snapshot.substring(0, 2000));
console.log('[wechat] Uploading images...');
const fileInputSelector = '.js_upload_btn_container input[type=file]';
ab(`eval "document.querySelector('${fileInputSelector}').style.display = 'block'"`);
await sleep(500);
const uploadResult = abRaw(['upload', `"${fileInputSelector}"`, ...absoluteImages]);
console.log(`[wechat] Upload result: ${uploadResult.output}`);
if (!uploadResult.success) {
console.log('[wechat] Using alternative upload method...');
for (const img of absoluteImages) {
console.log(`[wechat] Uploading: ${img}`);
ab(`eval "
const input = document.querySelector('${fileInputSelector}');
if (input) {
const dt = new DataTransfer();
fetch('file://${img}').then(r => r.blob()).then(b => {
const file = new File([b], '${path.basename(img)}', { type: 'image/png' });
dt.items.add(file);
input.files = dt.files;
input.dispatchEvent(new Event('change', { bubbles: true }));
});
}
"`);
await sleep(2000);
}
}
console.log('[wechat] Waiting for uploads to complete...');
await sleep(10000);
console.log('[wechat] Filling title...');
snapshot = ab('snapshot -i');
const titleRef = findElementByText(snapshot, 'title') || findElementByText(snapshot, '标题');
if (titleRef) {
ab(`fill ${titleRef} "${title.replace(/"/g, '\\"')}"`);
} else {
ab(`eval "const t = document.querySelector('#title'); if(t) { t.value = '${title.replace(/'/g, "\\'")}'; t.dispatchEvent(new Event('input', {bubbles: true})); }"`);
}
await sleep(500);
console.log('[wechat] Clicking on content editor...');
const editorRef = findElementByText(snapshot, 'js_pmEditorArea') || findElementByText(snapshot, 'textbox');
if (editorRef) {
ab(`click ${editorRef}`);
} else {
ab(`eval "document.querySelector('.js_pmEditorArea')?.click()"`);
}
await sleep(500);
console.log('[wechat] Typing content...');
const lines = content.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.length > 0) {
const escapedLine = line.replace(/"/g, '\\"').replace(/'/g, "\\'");
ab(`eval "document.execCommand('insertText', false, '${escapedLine}')"`);
}
if (i < lines.length - 1) {
ab('press Enter');
}
await sleep(100);
}
console.log('[wechat] Content typed.');
await sleep(1000);
if (submit) {
console.log('[wechat] Saving as draft...');
const submitRef = findElementByText(snapshot, 'js_submit') || findElementByText(snapshot, '保存');
if (submitRef) {
ab(`click ${submitRef}`);
} else {
ab(`eval "document.querySelector('#js_submit')?.click()"`);
}
await sleep(3000);
console.log('[wechat] Draft saved!');
} else {
console.log('[wechat] Article composed (preview mode). Add --submit to save as draft.');
}
if (!keepOpen) {
console.log('[wechat] Closing browser...');
ab('close');
} else {
console.log('[wechat] Done. Browser window left open.');
}
}
function printUsage(): never {
console.log(`Post to WeChat Official Account using agent-browser
Usage:
npx -y bun wechat-agent-browser.ts [options]
Options:
--title <text> Article title (max 20 chars, required)
--content <text> Article content (max 1000 chars, required)
--image <path> Add image (can be repeated, 1+ images, required)
--submit Save as draft (default: preview only)
--close Close browser after operation (default: keep open)
--help Show this help
Examples:
npx -y bun wechat-agent-browser.ts --title "测试" --content "内容" --image ./photo.png
npx -y bun wechat-agent-browser.ts --title "测试" --content "内容" --image a.png --image b.png --submit
`);
process.exit(0);
}
async function main(): Promise<void> {
const args = process.argv.slice(2);
if (args.includes('--help') || args.includes('-h')) printUsage();
const images: string[] = [];
let submit = false;
let keepOpen = true;
let title: string | undefined;
let content: string | undefined;
for (let i = 0; i < args.length; i++) {
const arg = args[i]!;
if (arg === '--image' && args[i + 1]) {
images.push(args[++i]!);
} else if (arg === '--title' && args[i + 1]) {
title = args[++i];
} else if (arg === '--content' && args[i + 1]) {
content = args[++i];
} else if (arg === '--submit') {
submit = true;
} else if (arg === '--close') {
keepOpen = false;
}
}
if (!title) {
console.error('Error: --title is required');
process.exit(1);
}
if (!content) {
console.error('Error: --content is required');
process.exit(1);
}
if (images.length === 0) {
console.error('Error: At least one --image is required');
process.exit(1);
}
await postToWeChat({ title, content, images, submit, keepOpen });
}
await main().catch((err) => {
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
});
@@ -0,0 +1,445 @@
import fs from 'node:fs';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import process from 'node:process';
import { launchChrome, getPageSession, waitForNewTab, clickElement, typeText, evaluate, sleep, type ChromeSession, type CdpConnection } from './cdp.ts';
const WECHAT_URL = 'https://mp.weixin.qq.com/';
interface ImageInfo {
placeholder: string;
localPath: string;
originalPath: string;
}
interface ArticleOptions {
title: string;
content?: string;
htmlFile?: string;
markdownFile?: string;
theme?: string;
author?: string;
summary?: string;
images?: string[];
contentImages?: ImageInfo[];
submit?: boolean;
profileDir?: string;
}
async function waitForLogin(session: ChromeSession, timeoutMs = 120_000): Promise<boolean> {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const url = await evaluate<string>(session, 'window.location.href');
if (url.includes('/cgi-bin/home')) return true;
await sleep(2000);
}
return false;
}
async function clickMenuByText(session: ChromeSession, text: string): 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 });
}
}
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);
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 });
}
async function copyImageToClipboard(imagePath: string): Promise<void> {
const scriptDir = path.dirname(new URL(import.meta.url).pathname);
const copyScript = path.join(scriptDir, './copy-to-clipboard.ts');
const result = spawnSync('npx', ['-y', 'bun', copyScript, 'image', imagePath], { stdio: 'inherit' });
if (result.status !== 0) throw new Error(`Failed to copy image: ${imagePath}`);
}
async function pasteInEditor(session: ChromeSession): Promise<void> {
const modifiers = process.platform === 'darwin' ? 4 : 2;
await session.cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'v', code: 'KeyV', modifiers, windowsVirtualKeyCode: 86 }, { sessionId: session.sessionId });
await sleep(50);
await session.cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'v', code: 'KeyV', modifiers, windowsVirtualKeyCode: 86 }, { sessionId: session.sessionId });
}
async function copyHtmlFromBrowser(cdp: CdpConnection, htmlFilePath: string): Promise<void> {
const absolutePath = path.isAbsolute(htmlFilePath) ? htmlFilePath : path.resolve(process.cwd(), htmlFilePath);
const fileUrl = `file://${absolutePath}`;
console.log(`[wechat] Opening HTML file in new tab: ${fileUrl}`);
const { targetId } = await cdp.send<{ targetId: string }>('Target.createTarget', { url: fileUrl });
const { sessionId } = await cdp.send<{ sessionId: string }>('Target.attachToTarget', { targetId, flatten: true });
await cdp.send('Page.enable', {}, { sessionId });
await cdp.send('Runtime.enable', {}, { sessionId });
await sleep(2000);
console.log('[wechat] Selecting #output content...');
await cdp.send<{ result: { value: unknown } }>('Runtime.evaluate', {
expression: `
(function() {
const output = document.querySelector('#output') || document.body;
const range = document.createRange();
range.selectNodeContents(output);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
return true;
})()
`,
returnByValue: true,
}, { sessionId });
await sleep(300);
console.log('[wechat] Copying with system Cmd+C...');
if (process.platform === 'darwin') {
spawnSync('osascript', ['-e', 'tell application "System Events" to keystroke "c" using command down']);
} else {
spawnSync('xdotool', ['key', 'ctrl+c']);
}
await sleep(1000);
console.log('[wechat] Closing HTML tab...');
await cdp.send('Target.closeTarget', { targetId });
}
async function pasteFromClipboardInEditor(): Promise<void> {
if (process.platform === 'darwin') {
spawnSync('osascript', ['-e', 'tell application "System Events" to keystroke "v" using command down']);
} else {
spawnSync('xdotool', ['key', 'ctrl+v']);
}
await sleep(1000);
}
async function parseMarkdownWithPlaceholders(markdownPath: string, theme?: string): Promise<{ title: string; author: string; summary: string; htmlPath: string; contentImages: ImageInfo[] }> {
const scriptDir = path.dirname(new URL(import.meta.url).pathname);
const mdToWechatScript = path.join(scriptDir, 'md-to-wechat.ts');
const args = ['-y', 'bun', mdToWechatScript, markdownPath];
if (theme) args.push('--theme', theme);
const result = spawnSync('npx', args, { stdio: ['inherit', 'pipe', 'pipe'] });
if (result.status !== 0) {
const stderr = result.stderr?.toString() || '';
throw new Error(`Failed to parse markdown: ${stderr}`);
}
const output = result.stdout.toString();
return JSON.parse(output);
}
function parseHtmlMeta(htmlPath: string): { title: string; author: string; summary: string } {
const content = fs.readFileSync(htmlPath, 'utf-8');
let title = '';
const titleMatch = content.match(/<title>([^<]+)<\/title>/i);
if (titleMatch) title = titleMatch[1]!;
let author = '';
const authorMatch = content.match(/<meta\s+name=["']author["']\s+content=["']([^"']+)["']/i);
if (authorMatch) author = authorMatch[1]!;
let summary = '';
const descMatch = content.match(/<meta\s+name=["']description["']\s+content=["']([^"']+)["']/i);
if (descMatch) summary = descMatch[1]!;
if (!summary) {
const firstPMatch = content.match(/<p[^>]*>([^<]+)<\/p>/i);
if (firstPMatch) {
const text = firstPMatch[1]!.replace(/<[^>]+>/g, '').trim();
if (text.length > 20) {
summary = text.length > 120 ? text.slice(0, 117) + '...' : text;
}
}
}
return { title, author, summary };
}
async function selectAndReplacePlaceholder(session: ChromeSession, placeholder: string): Promise<boolean> {
const result = await session.cdp.send<{ result: { value: boolean } }>('Runtime.evaluate', {
expression: `
(function() {
const editor = document.querySelector('.ProseMirror');
if (!editor) return false;
const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT, null, false);
let node;
while ((node = walker.nextNode())) {
const text = node.textContent || '';
const idx = text.indexOf(${JSON.stringify(placeholder)});
if (idx !== -1) {
node.parentElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
const range = document.createRange();
range.setStart(node, idx);
range.setEnd(node, idx + ${placeholder.length});
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
return true;
}
}
return false;
})()
`,
returnByValue: true,
}, { sessionId: session.sessionId });
return result.result.value;
}
async function pressDeleteKey(session: ChromeSession): Promise<void> {
await session.cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Backspace', code: 'Backspace', windowsVirtualKeyCode: 8 }, { sessionId: session.sessionId });
await sleep(50);
await session.cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Backspace', code: 'Backspace', windowsVirtualKeyCode: 8 }, { sessionId: session.sessionId });
}
export async function postArticle(options: ArticleOptions): Promise<void> {
const { title, content, htmlFile, markdownFile, theme, author, summary, images = [], submit = false, profileDir } = options;
let { contentImages = [] } = options;
let effectiveTitle = title || '';
let effectiveAuthor = author || '';
let effectiveSummary = summary || '';
let effectiveHtmlFile = htmlFile;
if (markdownFile) {
console.log(`[wechat] Parsing markdown: ${markdownFile}`);
const parsed = await parseMarkdownWithPlaceholders(markdownFile, theme);
effectiveTitle = effectiveTitle || parsed.title;
effectiveAuthor = effectiveAuthor || parsed.author;
effectiveSummary = effectiveSummary || parsed.summary;
effectiveHtmlFile = parsed.htmlPath;
contentImages = parsed.contentImages;
console.log(`[wechat] Title: ${effectiveTitle || '(empty)'}`);
console.log(`[wechat] Author: ${effectiveAuthor || '(empty)'}`);
console.log(`[wechat] Summary: ${effectiveSummary || '(empty)'}`);
console.log(`[wechat] Found ${contentImages.length} images to insert`);
} else if (htmlFile && fs.existsSync(htmlFile)) {
console.log(`[wechat] Parsing HTML: ${htmlFile}`);
const meta = parseHtmlMeta(htmlFile);
effectiveTitle = effectiveTitle || meta.title;
effectiveAuthor = effectiveAuthor || meta.author;
effectiveSummary = effectiveSummary || meta.summary;
effectiveHtmlFile = htmlFile;
console.log(`[wechat] Title: ${effectiveTitle || '(empty)'}`);
console.log(`[wechat] Author: ${effectiveAuthor || '(empty)'}`);
console.log(`[wechat] Summary: ${effectiveSummary || '(empty)'}`);
}
if (effectiveTitle && effectiveTitle.length > 64) throw new Error(`Title too long: ${effectiveTitle.length} chars (max 64)`);
if (!content && !effectiveHtmlFile) throw new Error('Either --content, --html, or --markdown is required');
const { cdp, chrome } = await launchChrome(WECHAT_URL, profileDir);
try {
console.log('[wechat] Waiting for page load...');
await sleep(3000);
let session = await getPageSession(cdp, 'mp.weixin.qq.com');
const url = await evaluate<string>(session, 'window.location.href');
if (!url.includes('/cgi-bin/home')) {
console.log('[wechat] Not logged in. Please scan QR code...');
const loggedIn = await waitForLogin(session);
if (!loggedIn) throw new Error('Login timeout');
}
console.log('[wechat] Logged in.');
await sleep(2000);
const targets = await cdp.send<{ targetInfos: Array<{ targetId: string; url: string; type: string }> }>('Target.getTargets');
const initialIds = new Set(targets.targetInfos.map(t => t.targetId));
await clickMenuByText(session, '文章');
await sleep(3000);
const editorTargetId = await waitForNewTab(cdp, initialIds, 'mp.weixin.qq.com');
console.log('[wechat] Editor tab opened.');
const { sessionId } = await cdp.send<{ sessionId: string }>('Target.attachToTarget', { targetId: editorTargetId, flatten: true });
session = { cdp, sessionId, targetId: editorTargetId };
await cdp.send('Page.enable', {}, { sessionId });
await cdp.send('Runtime.enable', {}, { sessionId });
await cdp.send('DOM.enable', {}, { sessionId });
await sleep(3000);
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 }));`);
}
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 }));`);
}
console.log('[wechat] Clicking on editor...');
await clickElement(session, '.ProseMirror');
await sleep(500);
if (effectiveHtmlFile && fs.existsSync(effectiveHtmlFile)) {
console.log(`[wechat] Copying HTML content from: ${effectiveHtmlFile}`);
await copyHtmlFromBrowser(cdp, effectiveHtmlFile);
await sleep(500);
console.log('[wechat] Pasting into editor...');
await pasteFromClipboardInEditor();
await sleep(3000);
if (contentImages.length > 0) {
console.log(`[wechat] Inserting ${contentImages.length} images...`);
for (let i = 0; i < contentImages.length; i++) {
const img = contentImages[i]!;
console.log(`[wechat] [${i + 1}/${contentImages.length}] Processing: ${img.placeholder}`);
const found = await selectAndReplacePlaceholder(session, img.placeholder);
if (!found) {
console.warn(`[wechat] Placeholder not found: ${img.placeholder}`);
continue;
}
await sleep(500);
console.log(`[wechat] Copying image: ${path.basename(img.localPath)}`);
await copyImageToClipboard(img.localPath);
await sleep(300);
console.log('[wechat] Deleting placeholder with Backspace...');
await pressDeleteKey(session);
await sleep(200);
console.log('[wechat] Pasting image...');
await pasteFromClipboardInEditor();
await sleep(3000);
}
console.log('[wechat] All images inserted.');
}
} else if (content) {
for (const img of images) {
if (fs.existsSync(img)) {
console.log(`[wechat] Pasting image: ${img}`);
await copyImageToClipboard(img);
await sleep(500);
await pasteInEditor(session);
await sleep(2000);
}
}
console.log('[wechat] Typing content...');
await typeText(session, content);
await sleep(1000);
}
if (effectiveSummary) {
console.log(`[wechat] Filling summary: ${effectiveSummary}`);
await evaluate(session, `document.querySelector('#js_description').value = ${JSON.stringify(effectiveSummary)}; document.querySelector('#js_description').dispatchEvent(new Event('input', { bubbles: true }));`);
}
console.log('[wechat] Saving as draft...');
await evaluate(session, `document.querySelector('#js_submit button').click()`);
await sleep(3000);
const saved = await evaluate<boolean>(session, `!!document.querySelector('.weui-desktop-toast')`);
if (saved) {
console.log('[wechat] Draft saved successfully!');
} else {
console.log('[wechat] Waiting for save confirmation...');
await sleep(5000);
}
console.log('[wechat] Done. Browser window left open.');
} finally {
cdp.close();
}
}
function printUsage(): never {
console.log(`Post article to WeChat Official Account
Usage:
npx -y bun wechat-article.ts [options]
Options:
--title <text> Article title (auto-extracted from markdown)
--content <text> Article content (use with --image)
--html <path> HTML file to paste (alternative to --content)
--markdown <path> Markdown file to convert and post (recommended)
--theme <name> Theme for markdown (default, grace, simple)
--author <name> Author name (default: 宝玉)
--summary <text> Article summary
--image <path> Content image, can repeat (only with --content)
--submit Save as draft
--profile <dir> Chrome profile directory
Examples:
npx -y bun wechat-article.ts --markdown article.md
npx -y bun wechat-article.ts --markdown article.md --theme grace --submit
npx -y bun wechat-article.ts --title "标题" --content "内容" --image img.png
npx -y bun wechat-article.ts --title "标题" --html article.html --submit
Markdown mode:
Images in markdown are converted to placeholders. After pasting HTML,
each placeholder is selected, scrolled into view, deleted, and replaced
with the actual image via paste.
`);
process.exit(0);
}
async function main(): Promise<void> {
const args = process.argv.slice(2);
if (args.includes('--help') || args.includes('-h')) printUsage();
const images: string[] = [];
let title: string | undefined;
let content: string | undefined;
let htmlFile: string | undefined;
let markdownFile: string | undefined;
let theme: string | undefined;
let author: string | undefined;
let summary: string | undefined;
let submit = false;
let profileDir: string | undefined;
for (let i = 0; i < args.length; i++) {
const arg = args[i]!;
if (arg === '--title' && args[i + 1]) title = args[++i];
else if (arg === '--content' && args[i + 1]) content = args[++i];
else if (arg === '--html' && args[i + 1]) htmlFile = args[++i];
else if (arg === '--markdown' && args[i + 1]) markdownFile = args[++i];
else if (arg === '--theme' && args[i + 1]) theme = args[++i];
else if (arg === '--author' && args[i + 1]) author = args[++i];
else if (arg === '--summary' && args[i + 1]) summary = args[++i];
else if (arg === '--image' && args[i + 1]) images.push(args[++i]!);
else if (arg === '--submit') submit = true;
else if (arg === '--profile' && args[i + 1]) profileDir = args[++i];
}
if (!markdownFile && !htmlFile && !title) { console.error('Error: --title is required (or use --markdown/--html)'); process.exit(1); }
if (!markdownFile && !htmlFile && !content) { console.error('Error: --content, --html, or --markdown is required'); process.exit(1); }
await postArticle({ title: title || '', content, htmlFile, markdownFile, theme, author, summary, images, submit, profileDir });
}
await main().catch((err) => {
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
});
@@ -0,0 +1,717 @@
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import { mkdir, readdir } from 'node:fs/promises';
import net from 'node:net';
import os from 'node:os';
import path from 'node:path';
import process from 'node:process';
const WECHAT_URL = 'https://mp.weixin.qq.com/';
interface MarkdownMeta {
title: string;
author: string;
content: string;
}
function parseMarkdownFile(filePath: string): MarkdownMeta {
const text = fs.readFileSync(filePath, 'utf-8');
let title = '';
let author = '';
let content = '';
const fmMatch = text.match(/^---\r?\n([\s\S]*?)\r?\n---/);
if (fmMatch) {
const fm = fmMatch[1]!;
const titleMatch = fm.match(/^title:\s*(.+)$/m);
if (titleMatch) title = titleMatch[1]!.trim().replace(/^["']|["']$/g, '');
const authorMatch = fm.match(/^author:\s*(.+)$/m);
if (authorMatch) author = authorMatch[1]!.trim().replace(/^["']|["']$/g, '');
}
const bodyText = fmMatch ? text.slice(fmMatch[0].length) : text;
if (!title) {
const h1Match = bodyText.match(/^#\s+(.+)$/m);
if (h1Match) title = h1Match[1]!.trim();
}
const lines = bodyText.split('\n');
const paragraphs: string[] = [];
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
if (trimmed.startsWith('#')) continue;
if (trimmed.startsWith('![')) continue;
if (trimmed.startsWith('---')) continue;
paragraphs.push(trimmed);
if (paragraphs.join('\n').length > 1200) break;
}
content = paragraphs.join('\n');
return { title, author, content };
}
function compressTitle(title: string, maxLen = 20): string {
if (title.length <= maxLen) return title;
const prefixes = ['如何', '为什么', '什么是', '怎样', '怎么', '关于'];
let t = title;
for (const p of prefixes) {
if (t.startsWith(p) && t.length > maxLen) {
t = t.slice(p.length);
if (t.length <= maxLen) return t;
}
}
const fillers = ['的', '了', '在', '是', '和', '与', '以及', '或者', '或', '还是', '而且', '并且', '但是', '但', '因为', '所以', '如果', '那么', '虽然', '不过', '然而', '——', '…'];
for (const f of fillers) {
if (t.length <= maxLen) break;
t = t.replace(new RegExp(f, 'g'), '');
}
if (t.length > maxLen) t = t.slice(0, maxLen);
return t;
}
function compressContent(content: string, maxLen = 1000): string {
if (content.length <= maxLen) return content;
const lines = content.split('\n');
const result: string[] = [];
let len = 0;
for (const line of lines) {
if (len + line.length + 1 > maxLen) {
const remaining = maxLen - len - 1;
if (remaining > 20) result.push(line.slice(0, remaining - 3) + '...');
break;
}
result.push(line);
len += line.length + 1;
}
return result.join('\n');
}
async function loadImagesFromDir(dir: string): Promise<string[]> {
const entries = await readdir(dir);
const images = entries
.filter(f => /\.(png|jpg|jpeg|gif|webp)$/i.test(f))
.sort()
.map(f => path.join(dir, f));
return images;
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function getFreePort(): Promise<number> {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.unref();
server.on('error', reject);
server.listen(0, '127.0.0.1', () => {
const address = server.address();
if (!address || typeof address === 'string') {
server.close(() => reject(new Error('Unable to allocate a free TCP port.')));
return;
}
const port = address.port;
server.close((err) => {
if (err) reject(err);
else resolve(port);
});
});
});
}
function findChromeExecutable(): string | undefined {
const override = process.env.WECHAT_BROWSER_CHROME_PATH?.trim();
if (override && fs.existsSync(override)) return override;
const candidates: string[] = [];
switch (process.platform) {
case 'darwin':
candidates.push(
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
'/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
'/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
);
break;
case 'win32':
candidates.push(
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe',
'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe',
);
break;
default:
candidates.push(
'/usr/bin/google-chrome',
'/usr/bin/google-chrome-stable',
'/usr/bin/chromium',
'/usr/bin/chromium-browser',
'/snap/bin/chromium',
'/usr/bin/microsoft-edge',
);
break;
}
for (const p of candidates) {
if (fs.existsSync(p)) return p;
}
return undefined;
}
function getDefaultProfileDir(): string {
const base = process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share');
return path.join(base, 'wechat-browser-profile');
}
async function fetchJson<T = unknown>(url: string): Promise<T> {
const res = await fetch(url, { redirect: 'follow' });
if (!res.ok) throw new Error(`Request failed: ${res.status} ${res.statusText}`);
return (await res.json()) as T;
}
async function waitForChromeDebugPort(port: number, timeoutMs: number): Promise<string> {
const start = Date.now();
let lastError: unknown = null;
while (Date.now() - start < timeoutMs) {
try {
const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(`http://127.0.0.1:${port}/json/version`);
if (version.webSocketDebuggerUrl) return version.webSocketDebuggerUrl;
lastError = new Error('Missing webSocketDebuggerUrl');
} catch (error) {
lastError = error;
}
await sleep(200);
}
throw new Error(`Chrome debug port not ready: ${lastError instanceof Error ? lastError.message : String(lastError)}`);
}
class CdpConnection {
private ws: WebSocket;
private nextId = 0;
private pending = new Map<number, { resolve: (v: unknown) => void; reject: (e: Error) => void; timer: ReturnType<typeof setTimeout> | null }>();
private eventHandlers = new Map<string, Set<(params: unknown) => void>>();
private constructor(ws: WebSocket) {
this.ws = ws;
this.ws.addEventListener('message', (event) => {
try {
const data = typeof event.data === 'string' ? event.data : new TextDecoder().decode(event.data as ArrayBuffer);
const msg = JSON.parse(data) as { id?: number; method?: string; params?: unknown; result?: unknown; error?: { message?: string } };
if (msg.method) {
const handlers = this.eventHandlers.get(msg.method);
if (handlers) handlers.forEach((h) => h(msg.params));
}
if (msg.id) {
const pending = this.pending.get(msg.id);
if (pending) {
this.pending.delete(msg.id);
if (pending.timer) clearTimeout(pending.timer);
if (msg.error?.message) pending.reject(new Error(msg.error.message));
else pending.resolve(msg.result);
}
}
} catch {}
});
this.ws.addEventListener('close', () => {
for (const [id, pending] of this.pending.entries()) {
this.pending.delete(id);
if (pending.timer) clearTimeout(pending.timer);
pending.reject(new Error('CDP connection closed.'));
}
});
}
static async connect(url: string, timeoutMs: number): Promise<CdpConnection> {
const ws = new WebSocket(url);
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('CDP connection timeout.')), timeoutMs);
ws.addEventListener('open', () => { clearTimeout(timer); resolve(); });
ws.addEventListener('error', () => { clearTimeout(timer); reject(new Error('CDP connection failed.')); });
});
return new CdpConnection(ws);
}
on(method: string, handler: (params: unknown) => void): void {
if (!this.eventHandlers.has(method)) this.eventHandlers.set(method, new Set());
this.eventHandlers.get(method)!.add(handler);
}
async send<T = unknown>(method: string, params?: Record<string, unknown>, options?: { sessionId?: string; timeoutMs?: number }): Promise<T> {
const id = ++this.nextId;
const message: Record<string, unknown> = { id, method };
if (params) message.params = params;
if (options?.sessionId) message.sessionId = options.sessionId;
const timeoutMs = options?.timeoutMs ?? 15_000;
const result = await new Promise<unknown>((resolve, reject) => {
const timer = timeoutMs > 0 ? setTimeout(() => { this.pending.delete(id); reject(new Error(`CDP timeout: ${method}`)); }, timeoutMs) : null;
this.pending.set(id, { resolve, reject, timer });
this.ws.send(JSON.stringify(message));
});
return result as T;
}
close(): void {
try { this.ws.close(); } catch {}
}
}
interface WeChatBrowserOptions {
title?: string;
content?: string;
images?: string[];
imagesDir?: string;
markdownFile?: string;
submit?: boolean;
timeoutMs?: number;
profileDir?: string;
chromePath?: string;
}
export async function postToWeChat(options: WeChatBrowserOptions): Promise<void> {
const { submit = false, timeoutMs = 120_000, profileDir = getDefaultProfileDir() } = options;
let title = options.title || '';
let content = options.content || '';
let images = options.images || [];
if (options.markdownFile) {
const absPath = path.isAbsolute(options.markdownFile) ? options.markdownFile : path.resolve(process.cwd(), options.markdownFile);
if (!fs.existsSync(absPath)) throw new Error(`Markdown file not found: ${absPath}`);
const meta = parseMarkdownFile(absPath);
if (!title) title = meta.title;
if (!content) content = meta.content;
console.log(`[wechat-browser] Parsed markdown: title="${meta.title}", content=${meta.content.length} chars`);
}
if (options.imagesDir) {
const absDir = path.isAbsolute(options.imagesDir) ? options.imagesDir : path.resolve(process.cwd(), options.imagesDir);
if (!fs.existsSync(absDir)) throw new Error(`Images directory not found: ${absDir}`);
images = await loadImagesFromDir(absDir);
console.log(`[wechat-browser] Found ${images.length} images in ${absDir}`);
}
if (title.length > 20) {
const original = title;
title = compressTitle(title, 20);
console.log(`[wechat-browser] Title compressed: "${original}" → "${title}"`);
}
if (content.length > 1000) {
const original = content.length;
content = compressContent(content, 1000);
console.log(`[wechat-browser] Content compressed: ${original}${content.length} chars`);
}
if (!title) throw new Error('Title is required (use --title or --markdown)');
if (!content) throw new Error('Content is required (use --content or --markdown)');
if (images.length === 0) throw new Error('At least one image is required (use --image or --images)');
for (const img of images) {
if (!fs.existsSync(img)) throw new Error(`Image not found: ${img}`);
}
const chromePath = options.chromePath ?? findChromeExecutable();
if (!chromePath) throw new Error('Chrome not found. Set WECHAT_BROWSER_CHROME_PATH env var.');
await mkdir(profileDir, { recursive: true });
const port = await getFreePort();
console.log(`[wechat-browser] Launching Chrome (profile: ${profileDir})`);
const chrome = spawn(chromePath, [
`--remote-debugging-port=${port}`,
`--user-data-dir=${profileDir}`,
'--no-first-run',
'--no-default-browser-check',
'--disable-blink-features=AutomationControlled',
'--start-maximized',
WECHAT_URL,
], { stdio: 'ignore' });
let cdp: CdpConnection | null = null;
try {
const wsUrl = await waitForChromeDebugPort(port, 30_000);
cdp = await CdpConnection.connect(wsUrl, 30_000);
const targets = await cdp.send<{ targetInfos: Array<{ targetId: string; url: string; type: string }> }>('Target.getTargets');
let pageTarget = targets.targetInfos.find((t) => t.type === 'page' && t.url.includes('mp.weixin.qq.com'));
if (!pageTarget) {
const { targetId } = await cdp.send<{ targetId: string }>('Target.createTarget', { url: WECHAT_URL });
pageTarget = { targetId, url: WECHAT_URL, type: 'page' };
}
let { sessionId } = await cdp.send<{ sessionId: string }>('Target.attachToTarget', { targetId: pageTarget.targetId, flatten: true });
await cdp.send('Page.enable', {}, { sessionId });
await cdp.send('Runtime.enable', {}, { sessionId });
await cdp.send('DOM.enable', {}, { sessionId });
console.log('[wechat-browser] Waiting for page load...');
await sleep(3000);
const checkLoginStatus = async (): Promise<boolean> => {
const result = await cdp!.send<{ result: { value: string } }>('Runtime.evaluate', {
expression: `window.location.href`,
returnByValue: true,
}, { sessionId });
return result.result.value.includes('/cgi-bin/home');
};
const waitForLogin = async (): Promise<boolean> => {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
if (await checkLoginStatus()) return true;
await sleep(2000);
}
return false;
};
let isLoggedIn = await checkLoginStatus();
if (!isLoggedIn) {
console.log('[wechat-browser] Not logged in. Please scan QR code to log in...');
isLoggedIn = await waitForLogin();
if (!isLoggedIn) throw new Error('Timed out waiting for login. Please log in first.');
}
console.log('[wechat-browser] Logged in.');
await sleep(2000);
console.log('[wechat-browser] Looking for "图文" menu...');
const menuResult = await cdp.send<{ result: { value: string } }>('Runtime.evaluate', {
expression: `
const menuItems = document.querySelectorAll('.new-creation__menu .new-creation__menu-item');
const count = menuItems.length;
const texts = Array.from(menuItems).map(m => m.querySelector('.new-creation__menu-title')?.textContent?.trim() || m.textContent?.trim() || '');
JSON.stringify({ count, texts });
`,
returnByValue: true,
}, { sessionId });
console.log(`[wechat-browser] Menu items: ${menuResult.result.value}`);
const getTargets = async () => {
return await cdp!.send<{ targetInfos: Array<{ targetId: string; url: string; type: string }> }>('Target.getTargets');
};
const initialTargets = await getTargets();
const initialIds = new Set(initialTargets.targetInfos.map(t => t.targetId));
console.log(`[wechat-browser] Initial targets count: ${initialTargets.targetInfos.length}`);
console.log('[wechat-browser] Finding "图文" menu position...');
const menuPos = await cdp.send<{ result: { value: string } }>('Runtime.evaluate', {
expression: `
(function() {
const menuItems = document.querySelectorAll('.new-creation__menu .new-creation__menu-item');
console.log('Found menu items:', menuItems.length);
for (const item of menuItems) {
const title = item.querySelector('.new-creation__menu-title');
const text = title?.textContent?.trim() || '';
console.log('Menu item text:', text);
if (text === '图文') {
item.scrollIntoView({ block: 'center' });
const rect = item.getBoundingClientRect();
console.log('Found 图文,rect:', JSON.stringify(rect));
return JSON.stringify({ x: rect.x + rect.width / 2, y: rect.y + rect.height / 2, width: rect.width, height: rect.height });
}
}
return 'null';
})()
`,
returnByValue: true,
}, { sessionId });
console.log(`[wechat-browser] Menu position: ${menuPos.result.value}`);
const pos = menuPos.result.value !== 'null' ? JSON.parse(menuPos.result.value) : null;
if (!pos) throw new Error('图文 menu not found or not visible');
console.log('[wechat-browser] Clicking "图文" menu with mouse events...');
await cdp.send('Input.dispatchMouseEvent', {
type: 'mousePressed',
x: pos.x,
y: pos.y,
button: 'left',
clickCount: 1,
}, { sessionId });
await sleep(100);
await cdp.send('Input.dispatchMouseEvent', {
type: 'mouseReleased',
x: pos.x,
y: pos.y,
button: 'left',
clickCount: 1,
}, { sessionId });
console.log('[wechat-browser] Waiting for editor...');
await sleep(3000);
const waitForEditor = async (): Promise<{ targetId: string; isNewTab: boolean } | null> => {
const start = Date.now();
while (Date.now() - start < 30_000) {
const targets = await getTargets();
const pageTargets = targets.targetInfos.filter(t => t.type === 'page');
for (const t of pageTargets) {
console.log(`[wechat-browser] Target: ${t.url}`);
}
const newTab = pageTargets.find(t => !initialIds.has(t.targetId) && t.url.includes('mp.weixin.qq.com'));
if (newTab) {
console.log(`[wechat-browser] Found new tab: ${newTab.url}`);
return { targetId: newTab.targetId, isNewTab: true };
}
const editorTab = pageTargets.find(t => t.url.includes('appmsg'));
if (editorTab) {
console.log(`[wechat-browser] Found editor tab: ${editorTab.url}`);
return { targetId: editorTab.targetId, isNewTab: !initialIds.has(editorTab.targetId) };
}
const currentUrl = await cdp!.send<{ result: { value: string } }>('Runtime.evaluate', {
expression: `window.location.href`,
returnByValue: true,
}, { sessionId });
console.log(`[wechat-browser] Current page URL: ${currentUrl.result.value}`);
if (currentUrl.result.value.includes('appmsg')) {
console.log(`[wechat-browser] Current page navigated to editor`);
return { targetId: pageTarget!.targetId, isNewTab: false };
}
await sleep(1000);
}
return null;
};
const editorInfo = await waitForEditor();
if (!editorInfo) {
const finalTargets = await getTargets();
console.log(`[wechat-browser] Final targets: ${finalTargets.targetInfos.filter(t => t.type === 'page').map(t => t.url).join(', ')}`);
throw new Error('Editor not found.');
}
if (editorInfo.isNewTab) {
console.log('[wechat-browser] Switching to editor tab...');
const editorSession = await cdp.send<{ sessionId: string }>('Target.attachToTarget', { targetId: editorInfo.targetId, flatten: true });
sessionId = editorSession.sessionId;
await cdp.send('Page.enable', {}, { sessionId });
await cdp.send('Runtime.enable', {}, { sessionId });
await cdp.send('DOM.enable', {}, { sessionId });
} else {
console.log('[wechat-browser] Editor opened in current page');
}
await cdp.send('Page.enable', {}, { sessionId });
await cdp.send('Runtime.enable', {}, { sessionId });
await cdp.send('DOM.enable', {}, { sessionId });
await sleep(2000);
console.log('[wechat-browser] Uploading all images at once...');
const absolutePaths = images.map(p => path.isAbsolute(p) ? p : path.resolve(process.cwd(), p));
console.log(`[wechat-browser] Images: ${absolutePaths.join(', ')}`);
const { root } = await cdp.send<{ root: { nodeId: number } }>('DOM.getDocument', {}, { sessionId });
const { nodeId } = await cdp.send<{ nodeId: number }>('DOM.querySelector', {
nodeId: root.nodeId,
selector: '.js_upload_btn_container input[type=file]',
}, { sessionId });
if (!nodeId) throw new Error('File input not found');
await cdp.send('DOM.setFileInputFiles', {
nodeId,
files: absolutePaths,
}, { sessionId });
await sleep(1000);
console.log('[wechat-browser] Filling title...');
await cdp.send('Runtime.evaluate', {
expression: `
const titleInput = document.querySelector('#title');
if (titleInput) {
titleInput.value = ${JSON.stringify(title)};
titleInput.dispatchEvent(new Event('input', { bubbles: true }));
} else {
throw new Error('Title input not found');
}
`,
}, { sessionId });
await sleep(500);
console.log('[wechat-browser] Clicking on content editor...');
const editorPos = await cdp.send<{ result: { value: string } }>('Runtime.evaluate', {
expression: `
(function() {
const editor = document.querySelector('.js_pmEditorArea');
if (editor) {
const rect = editor.getBoundingClientRect();
return JSON.stringify({ x: rect.x + 50, y: rect.y + 20 });
}
return 'null';
})()
`,
returnByValue: true,
}, { sessionId });
if (editorPos.result.value === 'null') throw new Error('Content editor not found');
const editorClickPos = JSON.parse(editorPos.result.value);
await cdp.send('Input.dispatchMouseEvent', {
type: 'mousePressed',
x: editorClickPos.x,
y: editorClickPos.y,
button: 'left',
clickCount: 1,
}, { sessionId });
await sleep(50);
await cdp.send('Input.dispatchMouseEvent', {
type: 'mouseReleased',
x: editorClickPos.x,
y: editorClickPos.y,
button: 'left',
clickCount: 1,
}, { sessionId });
await sleep(300);
console.log('[wechat-browser] Typing content with keyboard simulation...');
const lines = content.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.length > 0) {
await cdp.send('Input.insertText', { text: line }, { sessionId });
}
if (i < lines.length - 1) {
await cdp.send('Input.dispatchKeyEvent', {
type: 'keyDown',
key: 'Enter',
code: 'Enter',
windowsVirtualKeyCode: 13,
}, { sessionId });
await cdp.send('Input.dispatchKeyEvent', {
type: 'keyUp',
key: 'Enter',
code: 'Enter',
windowsVirtualKeyCode: 13,
}, { sessionId });
}
await sleep(50);
}
console.log('[wechat-browser] Content typed.');
await sleep(500);
if (submit) {
console.log('[wechat-browser] Saving as draft...');
await cdp.send('Runtime.evaluate', {
expression: `document.querySelector('#js_submit')?.click()`,
}, { sessionId });
await sleep(3000);
console.log('[wechat-browser] Draft saved!');
} else {
console.log('[wechat-browser] Article composed (preview mode). Add --submit to save as draft.');
}
} finally {
if (cdp) {
cdp.close();
}
console.log('[wechat-browser] Done. Browser window left open.');
}
}
function printUsage(): never {
console.log(`Post image-text (图文) to WeChat Official Account
Usage:
npx -y bun wechat-browser.ts [options]
Options:
--markdown <path> Markdown file for title/content extraction
--images <dir> Directory containing images (PNG/JPG)
--title <text> Article title (max 20 chars, auto-compressed)
--content <text> Article content (max 1000 chars, auto-compressed)
--image <path> Add image (can be repeated)
--submit Save as draft (default: preview only)
--profile <dir> Chrome profile directory
--help Show this help
Examples:
npx -y bun wechat-browser.ts --markdown article.md --images ./photos/
npx -y bun wechat-browser.ts --title "测试" --content "内容" --image ./photo.png
npx -y bun wechat-browser.ts --markdown article.md --images ./photos/ --submit
`);
process.exit(0);
}
async function main(): Promise<void> {
const args = process.argv.slice(2);
if (args.includes('--help') || args.includes('-h')) printUsage();
const images: string[] = [];
let submit = false;
let profileDir: string | undefined;
let title: string | undefined;
let content: string | undefined;
let markdownFile: string | undefined;
let imagesDir: string | undefined;
for (let i = 0; i < args.length; i++) {
const arg = args[i]!;
if (arg === '--image' && args[i + 1]) {
images.push(args[++i]!);
} else if (arg === '--images' && args[i + 1]) {
imagesDir = args[++i];
} else if (arg === '--title' && args[i + 1]) {
title = args[++i];
} else if (arg === '--content' && args[i + 1]) {
content = args[++i];
} else if (arg === '--markdown' && args[i + 1]) {
markdownFile = args[++i];
} else if (arg === '--submit') {
submit = true;
} else if (arg === '--profile' && args[i + 1]) {
profileDir = args[++i];
}
}
if (!markdownFile && !title) {
console.error('Error: --title or --markdown is required');
process.exit(1);
}
if (!markdownFile && !content) {
console.error('Error: --content or --markdown is required');
process.exit(1);
}
if (images.length === 0 && !imagesDir) {
console.error('Error: --image or --images is required');
process.exit(1);
}
await postToWeChat({ title, content, images: images.length > 0 ? images : undefined, imagesDir, markdownFile, submit, profileDir });
}
await main().catch((err) => {
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
});
+103
View File
@@ -0,0 +1,103 @@
---
name: baoyu-post-to-x
description: Post content and articles to X (Twitter). Supports regular posts with images and X Articles (long-form Markdown). Uses real Chrome with CDP to bypass anti-automation.
---
# Post to X (Twitter)
Post content, images, and long-form articles to X using real Chrome browser (bypasses anti-bot detection).
## Script Directory
**Important**: All scripts are located in the `scripts/` subdirectory of this skill.
**Agent Execution Instructions**:
1. Determine this SKILL.md file's directory path as `SKILL_DIR`
2. Script path = `${SKILL_DIR}/scripts/<script-name>.ts`
3. Replace all `${SKILL_DIR}` in this document with the actual path
**Script Reference**:
| Script | Purpose |
|--------|---------|
| `scripts/x-browser.ts` | Regular posts (text + images) |
| `scripts/x-article.ts` | Long-form article publishing (Markdown) |
| `scripts/md-to-html.ts` | Markdown → HTML conversion |
| `scripts/copy-to-clipboard.ts` | Copy content to clipboard |
| `scripts/paste-from-clipboard.ts` | Send real paste keystroke |
## Prerequisites
- Google Chrome or Chromium installed
- `bun` installed (for running scripts)
- First run: log in to X in the opened browser window
## References
- **Regular Posts**: See `references/regular-posts.md` for manual workflow, troubleshooting, and technical details
- **X Articles**: See `references/articles.md` for long-form article publishing guide
---
## Regular Posts
Text + up to 4 images.
```bash
# Preview mode (doesn't post)
npx -y bun ${SKILL_DIR}/scripts/x-browser.ts "Hello from Claude!" --image ./screenshot.png
# Actually post
npx -y bun ${SKILL_DIR}/scripts/x-browser.ts "Hello!" --image ./photo.png --submit
```
> **Note**: `${SKILL_DIR}` represents this skill's installation directory. Agent replaces with actual path at runtime.
**Parameters**:
| Parameter | Description |
|-----------|-------------|
| `<text>` | Post content (positional argument) |
| `--image <path>` | Image file path (can be repeated, max 4) |
| `--submit` | Actually post (default: preview only) |
| `--profile <dir>` | Custom Chrome profile directory |
---
## X Articles
Long-form Markdown articles (requires X Premium).
```bash
# Preview mode
npx -y bun ${SKILL_DIR}/scripts/x-article.ts article.md
# With cover image
npx -y bun ${SKILL_DIR}/scripts/x-article.ts article.md --cover ./cover.jpg
# Publish
npx -y bun ${SKILL_DIR}/scripts/x-article.ts article.md --submit
```
**Parameters**:
| Parameter | Description |
|-----------|-------------|
| `<markdown>` | Markdown file path (positional argument) |
| `--cover <path>` | Cover image path |
| `--title <text>` | Override article title |
| `--submit` | Actually publish (default: preview only) |
**Frontmatter** (optional):
```yaml
---
title: My Article Title
cover_image: /path/to/cover.jpg
---
```
---
## Notes
- First run requires manual login (session is saved)
- Always preview before using `--submit`
- Browser closes automatically after operation
- Supports macOS, Linux, and Windows
@@ -0,0 +1,176 @@
# X Articles - Detailed Guide
Publish Markdown articles to X Articles editor with rich text formatting and images.
## Prerequisites
- X Premium subscription (required for Articles)
- Google Chrome installed
- `bun` installed
## Usage
```bash
# Publish markdown article (preview mode)
npx -y bun ${SKILL_DIR}/scripts/x-article.ts article.md
# With custom cover image
npx -y bun ${SKILL_DIR}/scripts/x-article.ts article.md --cover ./cover.jpg
# Actually publish
npx -y bun ${SKILL_DIR}/scripts/x-article.ts article.md --submit
```
## Markdown Format
```markdown
---
title: My Article Title
cover_image: /path/to/cover.jpg
---
# Title (becomes article title)
Regular paragraph text with **bold** and *italic*.
## Section Header
More content here.
![Image alt text](./image.png)
- List item 1
- List item 2
1. Numbered item
2. Another item
> Blockquote text
[Link text](https://example.com)
\`\`\`
Code blocks become blockquotes (X doesn't support code)
\`\`\`
```
## Frontmatter Fields
| Field | Description |
|-------|-------------|
| `title` | Article title (or uses first H1) |
| `cover_image` | Cover image path or URL |
| `cover` | Alias for cover_image |
| `image` | Alias for cover_image |
## Image Handling
1. **Cover Image**: First image or `cover_image` from frontmatter
2. **Remote Images**: Automatically downloaded to temp directory
3. **Placeholders**: Images in content use `[[IMAGE_PLACEHOLDER_N]]` format
4. **Insertion**: Placeholders are found, selected, and replaced with actual images
## Markdown to HTML Script
Convert markdown and inspect structure:
```bash
# Get JSON with all metadata
npx -y bun ${SKILL_DIR}/scripts/md-to-html.ts article.md
# Output HTML only
npx -y bun ${SKILL_DIR}/scripts/md-to-html.ts article.md --html-only
# Save HTML to file
npx -y bun ${SKILL_DIR}/scripts/md-to-html.ts article.md --save-html /tmp/article.html
```
JSON output:
```json
{
"title": "Article Title",
"coverImage": "/path/to/cover.jpg",
"contentImages": [
{
"placeholder": "[[IMAGE_PLACEHOLDER_1]]",
"localPath": "/tmp/x-article-images/img.png",
"blockIndex": 5
}
],
"html": "<p>Content...</p>",
"totalBlocks": 20
}
```
## Supported Formatting
| Markdown | HTML Output |
|----------|-------------|
| `# H1` | Title only (not in body) |
| `## H2` - `###### H6` | `<h2>` |
| `**bold**` | `<strong>` |
| `*italic*` | `<em>` |
| `[text](url)` | `<a href>` |
| `> quote` | `<blockquote>` |
| `` `code` `` | `<code>` |
| ```` ``` ```` | `<blockquote>` (X limitation) |
| `- item` | `<ul><li>` |
| `1. item` | `<ol><li>` |
| `![](img)` | Image placeholder |
## Workflow
1. **Parse Markdown**: Extract title, cover, content images, generate HTML
2. **Launch Chrome**: Real browser with CDP, persistent login
3. **Navigate**: Open `x.com/compose/articles`
4. **Create Article**: Click create button if on list page
5. **Upload Cover**: Use file input for cover image
6. **Fill Title**: Type title into title field
7. **Paste Content**: Copy HTML to clipboard, paste into editor
8. **Insert Images**: For each placeholder (reverse order):
- Find placeholder text in editor
- Select the placeholder
- Copy image to clipboard
- Paste to replace selection
9. **Review**: Browser stays open for 60s preview
10. **Publish**: Only with `--submit` flag
## Example Session
```
User: /post-to-x article ./blog/my-post.md --cover ./thumbnail.png
Claude:
1. Parses markdown: title="My Post", 3 content images
2. Launches Chrome with CDP
3. Navigates to x.com/compose/articles
4. Clicks create button
5. Uploads thumbnail.png as cover
6. Fills title "My Post"
7. Pastes HTML content
8. Inserts 3 images at placeholder positions
9. Reports: "Article composed. Review and use --submit to publish."
```
## Troubleshooting
- **No create button**: Ensure X Premium subscription is active
- **Cover upload fails**: Check file path and format (PNG, JPEG)
- **Images not inserting**: Verify placeholders exist in pasted content
- **Content not pasting**: Check HTML clipboard: `npx -y bun ${SKILL_DIR}/scripts/copy-to-clipboard.ts html --file /tmp/test.html`
## How It Works
1. `md-to-html.ts` converts Markdown to HTML:
- Extracts frontmatter (title, cover)
- Converts markdown to HTML
- Replaces images with unique placeholders
- Downloads remote images locally
- Returns structured JSON
2. `x-article.ts` publishes via CDP:
- Launches real Chrome (bypasses detection)
- Uses persistent profile (saved login)
- Navigates and fills editor via DOM manipulation
- Pastes HTML from system clipboard
- Finds/selects/replaces each image placeholder
@@ -0,0 +1,100 @@
# Regular Posts - Detailed Guide
Detailed documentation for posting text and images to X.
## Manual Workflow
If you prefer step-by-step control:
### Step 1: Copy Image to Clipboard
```bash
npx -y bun ${SKILL_DIR}/scripts/copy-to-clipboard.ts image /path/to/image.png
```
### Step 2: Paste from Clipboard
```bash
# Simple paste to frontmost app
npx -y bun ${SKILL_DIR}/scripts/paste-from-clipboard.ts
# Paste to Chrome with retries
npx -y bun ${SKILL_DIR}/scripts/paste-from-clipboard.ts --app "Google Chrome" --retries 5
# Quick paste with shorter delay
npx -y bun ${SKILL_DIR}/scripts/paste-from-clipboard.ts --delay 200
```
### Step 3: Use Playwright MCP (if Chrome session available)
```bash
# Navigate
mcp__playwright__browser_navigate url="https://x.com/compose/post"
# Get element refs
mcp__playwright__browser_snapshot
# Type text
mcp__playwright__browser_click element="editor" ref="<ref>"
mcp__playwright__browser_type element="editor" ref="<ref>" text="Your content"
# Paste image (after copying to clipboard)
mcp__playwright__browser_press_key key="Meta+v" # macOS
# or
mcp__playwright__browser_press_key key="Control+v" # Windows/Linux
# Screenshot to verify
mcp__playwright__browser_take_screenshot filename="preview.png"
```
## Image Support
- Formats: PNG, JPEG, GIF, WebP
- Max 4 images per post
- Images copied to system clipboard, then pasted via keyboard shortcut
## Example Session
```
User: /post-to-x "Hello from Claude!" --image ./screenshot.png
Claude:
1. Runs: npx -y bun ${SKILL_DIR}/scripts/x-browser.ts "Hello from Claude!" --image ./screenshot.png
2. Chrome opens with X compose page
3. Text is typed into editor
4. Image is copied to clipboard and pasted
5. Browser stays open 30s for preview
6. Reports: "Post composed. Use --submit to post."
```
## Troubleshooting
- **Chrome not found**: Set `X_BROWSER_CHROME_PATH` environment variable
- **Not logged in**: First run opens Chrome - log in manually, cookies are saved
- **Image paste fails**:
- Verify clipboard script: `npx -y bun ${SKILL_DIR}/scripts/copy-to-clipboard.ts image <path>`
- On macOS, grant "Accessibility" permission to Terminal/iTerm in System Settings > Privacy & Security > Accessibility
- Keep Chrome window visible and in front during paste operations
- **osascript permission denied**: Grant Terminal accessibility permissions in System Preferences
- **Rate limited**: Wait a few minutes before retrying
## How It Works
The `x-browser.ts` script uses Chrome DevTools Protocol (CDP) to:
1. Launch real Chrome (not Playwright) with `--disable-blink-features=AutomationControlled`
2. Use persistent profile directory for saved login sessions
3. Interact with X via CDP commands (Runtime.evaluate, Input.dispatchKeyEvent)
4. **Paste images using osascript** (macOS): Sends real Cmd+V keystroke to Chrome, bypassing CDP's synthetic events that X can detect
This approach bypasses X's anti-automation detection that blocks Playwright/Puppeteer.
### Image Paste Mechanism (macOS)
CDP's `Input.dispatchKeyEvent` sends "synthetic" keyboard events that websites can detect. X ignores synthetic paste events for security. The solution:
1. Copy image to system clipboard via Swift/AppKit (`copy-to-clipboard.ts`)
2. Bring Chrome to front via `osascript`
3. Send real Cmd+V keystroke via `osascript` and System Events
4. Wait for upload to complete
This requires Terminal to have "Accessibility" permission in System Settings.
@@ -0,0 +1,380 @@
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import process from 'node:process';
const SUPPORTED_IMAGE_EXTS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp']);
function printUsage(exitCode = 0): never {
console.log(`Copy image or HTML to system clipboard
Supports:
- Image files (jpg, png, gif, webp) - copies as image data
- HTML content - copies as rich text for paste
Usage:
# Copy image to clipboard
npx -y bun copy-to-clipboard.ts image /path/to/image.jpg
# Copy HTML to clipboard
npx -y bun copy-to-clipboard.ts html "<p>Hello</p>"
# Copy HTML from file
npx -y bun copy-to-clipboard.ts html --file /path/to/content.html
`);
process.exit(exitCode);
}
function resolvePath(filePath: string): string {
return path.isAbsolute(filePath) ? filePath : path.resolve(process.cwd(), filePath);
}
function inferImageMimeType(imagePath: string): string {
const ext = path.extname(imagePath).toLowerCase();
switch (ext) {
case '.jpg':
case '.jpeg':
return 'image/jpeg';
case '.png':
return 'image/png';
case '.gif':
return 'image/gif';
case '.webp':
return 'image/webp';
default:
return 'application/octet-stream';
}
}
type RunResult = { stdout: string; stderr: string; exitCode: number };
async function runCommand(
command: string,
args: string[],
options?: { input?: string | Buffer; allowNonZeroExit?: boolean },
): Promise<RunResult> {
return await new Promise<RunResult>((resolve, reject) => {
const child = spawn(command, args, { stdio: ['pipe', 'pipe', 'pipe'] });
const stdoutChunks: Buffer[] = [];
const stderrChunks: Buffer[] = [];
child.stdout.on('data', (chunk) => stdoutChunks.push(Buffer.from(chunk)));
child.stderr.on('data', (chunk) => stderrChunks.push(Buffer.from(chunk)));
child.on('error', reject);
child.on('close', (code) => {
resolve({
stdout: Buffer.concat(stdoutChunks).toString('utf8'),
stderr: Buffer.concat(stderrChunks).toString('utf8'),
exitCode: code ?? 0,
});
});
if (options?.input != null) child.stdin.write(options.input);
child.stdin.end();
}).then((result) => {
if (!options?.allowNonZeroExit && result.exitCode !== 0) {
const details = result.stderr.trim() || result.stdout.trim();
throw new Error(`Command failed (${command}): exit ${result.exitCode}${details ? `\n${details}` : ''}`);
}
return result;
});
}
async function commandExists(command: string): Promise<boolean> {
if (process.platform === 'win32') {
const result = await runCommand('where', [command], { allowNonZeroExit: true });
return result.exitCode === 0 && result.stdout.trim().length > 0;
}
const result = await runCommand('which', [command], { allowNonZeroExit: true });
return result.exitCode === 0 && result.stdout.trim().length > 0;
}
async function runCommandWithFileStdin(command: string, args: string[], filePath: string): Promise<void> {
await new Promise<void>((resolve, reject) => {
const child = spawn(command, args, { stdio: ['pipe', 'pipe', 'pipe'] });
const stderrChunks: Buffer[] = [];
const stdoutChunks: Buffer[] = [];
child.stdout.on('data', (chunk) => stdoutChunks.push(Buffer.from(chunk)));
child.stderr.on('data', (chunk) => stderrChunks.push(Buffer.from(chunk)));
child.on('error', reject);
child.on('close', (code) => {
const exitCode = code ?? 0;
if (exitCode !== 0) {
const details = Buffer.concat(stderrChunks).toString('utf8').trim() || Buffer.concat(stdoutChunks).toString('utf8').trim();
reject(
new Error(`Command failed (${command}): exit ${exitCode}${details ? `\n${details}` : ''}`),
);
return;
}
resolve();
});
fs.createReadStream(filePath).on('error', reject).pipe(child.stdin);
});
}
async function withTempDir<T>(prefix: string, fn: (tempDir: string) => Promise<T>): Promise<T> {
const tempDir = await mkdtemp(path.join(os.tmpdir(), prefix));
try {
return await fn(tempDir);
} finally {
await rm(tempDir, { recursive: true, force: true });
}
}
function getMacSwiftClipboardSource(): string {
return `import AppKit
import Foundation
func die(_ message: String, _ code: Int32 = 1) -> Never {
FileHandle.standardError.write(message.data(using: .utf8)!)
exit(code)
}
if CommandLine.arguments.count < 3 {
die("Usage: clipboard.swift <image|html> <path>\\n")
}
let mode = CommandLine.arguments[1]
let inputPath = CommandLine.arguments[2]
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
switch mode {
case "image":
guard let image = NSImage(contentsOfFile: inputPath) else {
die("Failed to load image: \\(inputPath)\\n")
}
if !pasteboard.writeObjects([image]) {
die("Failed to write image to clipboard\\n")
}
case "html":
let url = URL(fileURLWithPath: inputPath)
let data: Data
do {
data = try Data(contentsOf: url)
} catch {
die("Failed to read HTML file: \\(inputPath)\\n")
}
_ = pasteboard.setData(data, forType: .html)
let options: [NSAttributedString.DocumentReadingOptionKey: Any] = [
.documentType: NSAttributedString.DocumentType.html,
.characterEncoding: String.Encoding.utf8.rawValue
]
if let attr = try? NSAttributedString(data: data, options: options, documentAttributes: nil) {
pasteboard.setString(attr.string, forType: .string)
if let rtf = try? attr.data(
from: NSRange(location: 0, length: attr.length),
documentAttributes: [.documentType: NSAttributedString.DocumentType.rtf]
) {
_ = pasteboard.setData(rtf, forType: .rtf)
}
} else if let html = String(data: data, encoding: .utf8) {
pasteboard.setString(html, forType: .string)
}
default:
die("Unknown mode: \\(mode)\\n")
}
`;
}
async function copyImageMac(imagePath: string): Promise<void> {
await withTempDir('copy-to-clipboard-', async (tempDir) => {
const swiftPath = path.join(tempDir, 'clipboard.swift');
await writeFile(swiftPath, getMacSwiftClipboardSource(), 'utf8');
await runCommand('swift', [swiftPath, 'image', imagePath]);
});
}
async function copyHtmlMac(htmlFilePath: string): Promise<void> {
await withTempDir('copy-to-clipboard-', async (tempDir) => {
const swiftPath = path.join(tempDir, 'clipboard.swift');
await writeFile(swiftPath, getMacSwiftClipboardSource(), 'utf8');
await runCommand('swift', [swiftPath, 'html', htmlFilePath]);
});
}
async function copyImageLinux(imagePath: string): Promise<void> {
const mime = inferImageMimeType(imagePath);
if (await commandExists('wl-copy')) {
await runCommandWithFileStdin('wl-copy', ['--type', mime], imagePath);
return;
}
if (await commandExists('xclip')) {
await runCommand('xclip', ['-selection', 'clipboard', '-t', mime, '-i', imagePath]);
return;
}
throw new Error('No clipboard tool found. Install `wl-clipboard` (wl-copy) or `xclip`.');
}
async function copyHtmlLinux(htmlFilePath: string): Promise<void> {
if (await commandExists('wl-copy')) {
await runCommandWithFileStdin('wl-copy', ['--type', 'text/html'], htmlFilePath);
return;
}
if (await commandExists('xclip')) {
await runCommand('xclip', ['-selection', 'clipboard', '-t', 'text/html', '-i', htmlFilePath]);
return;
}
throw new Error('No clipboard tool found. Install `wl-clipboard` (wl-copy) or `xclip`.');
}
async function copyImageWindows(imagePath: string): Promise<void> {
const ps = [
'param([string]$Path)',
'Add-Type -AssemblyName System.Windows.Forms',
'Add-Type -AssemblyName System.Drawing',
'$img = [System.Drawing.Image]::FromFile($Path)',
'[System.Windows.Forms.Clipboard]::SetImage($img)',
'$img.Dispose()',
].join('; ');
await runCommand('powershell.exe', ['-NoProfile', '-Sta', '-Command', ps, '-Path', imagePath]);
}
async function copyHtmlWindows(htmlFilePath: string): Promise<void> {
const ps = [
'param([string]$Path)',
'Add-Type -AssemblyName System.Windows.Forms',
'$html = Get-Content -Raw -LiteralPath $Path',
'[System.Windows.Forms.Clipboard]::SetText($html, [System.Windows.Forms.TextDataFormat]::Html)',
].join('; ');
await runCommand('powershell.exe', ['-NoProfile', '-Sta', '-Command', ps, '-Path', htmlFilePath]);
}
async function copyImageToClipboard(imagePathInput: string): Promise<void> {
const imagePath = resolvePath(imagePathInput);
const ext = path.extname(imagePath).toLowerCase();
if (!SUPPORTED_IMAGE_EXTS.has(ext)) {
throw new Error(
`Unsupported image type: ${ext || '(none)'} (supported: ${Array.from(SUPPORTED_IMAGE_EXTS).join(', ')})`,
);
}
if (!fs.existsSync(imagePath)) throw new Error(`File not found: ${imagePath}`);
switch (process.platform) {
case 'darwin':
await copyImageMac(imagePath);
return;
case 'linux':
await copyImageLinux(imagePath);
return;
case 'win32':
await copyImageWindows(imagePath);
return;
default:
throw new Error(`Unsupported platform: ${process.platform}`);
}
}
async function copyHtmlFileToClipboard(htmlFilePathInput: string): Promise<void> {
const htmlFilePath = resolvePath(htmlFilePathInput);
if (!fs.existsSync(htmlFilePath)) throw new Error(`File not found: ${htmlFilePath}`);
switch (process.platform) {
case 'darwin':
await copyHtmlMac(htmlFilePath);
return;
case 'linux':
await copyHtmlLinux(htmlFilePath);
return;
case 'win32':
await copyHtmlWindows(htmlFilePath);
return;
default:
throw new Error(`Unsupported platform: ${process.platform}`);
}
}
async function readStdinText(): Promise<string | null> {
if (process.stdin.isTTY) return null;
const chunks: Buffer[] = [];
for await (const chunk of process.stdin) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
const text = Buffer.concat(chunks).toString('utf8');
return text.length > 0 ? text : null;
}
async function copyHtmlToClipboard(args: string[]): Promise<void> {
let htmlFile: string | undefined;
const positional: string[] = [];
for (let i = 0; i < args.length; i += 1) {
const arg = args[i] ?? '';
if (arg === '--help' || arg === '-h') printUsage(0);
if (arg === '--file') {
htmlFile = args[i + 1];
i += 1;
continue;
}
if (arg.startsWith('--file=')) {
htmlFile = arg.slice('--file='.length);
continue;
}
if (arg === '--') {
positional.push(...args.slice(i + 1));
break;
}
if (arg.startsWith('-')) {
throw new Error(`Unknown option: ${arg}`);
}
positional.push(arg);
}
if (htmlFile && positional.length > 0) {
throw new Error('Do not pass HTML text when using --file.');
}
if (htmlFile) {
await copyHtmlFileToClipboard(htmlFile);
return;
}
const htmlFromArgs = positional.join(' ').trim();
const htmlFromStdin = (await readStdinText())?.trim() ?? '';
const html = htmlFromArgs || htmlFromStdin;
if (!html) throw new Error('Missing HTML input. Provide a string or use --file.');
await withTempDir('copy-to-clipboard-', async (tempDir) => {
const htmlPath = path.join(tempDir, 'input.html');
await writeFile(htmlPath, html, 'utf8');
await copyHtmlFileToClipboard(htmlPath);
});
}
async function main(): Promise<void> {
const argv = process.argv.slice(2);
if (argv.length === 0) printUsage(1);
const command = argv[0];
if (command === '--help' || command === '-h') printUsage(0);
if (command === 'image') {
const imagePath = argv[1];
if (!imagePath) throw new Error('Missing image path.');
await copyImageToClipboard(imagePath);
return;
}
if (command === 'html') {
await copyHtmlToClipboard(argv.slice(1));
return;
}
throw new Error(`Unknown command: ${command}`);
}
await main().catch((err) => {
const message = err instanceof Error ? err.message : String(err);
console.error(`Error: ${message}`);
process.exit(1);
});
@@ -0,0 +1,438 @@
import fs from 'node:fs';
import { mkdir, writeFile } from 'node:fs/promises';
import https from 'node:https';
import http from 'node:http';
import os from 'node:os';
import path from 'node:path';
import process from 'node:process';
import { createHash } from 'node:crypto';
interface ImageInfo {
placeholder: string;
localPath: string;
originalPath: string;
blockIndex: number;
}
interface ParsedMarkdown {
title: string;
coverImage: string | null;
contentImages: ImageInfo[];
html: string;
totalBlocks: number;
}
function parseFrontmatter(content: string): { frontmatter: Record<string, string>; body: string } {
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
if (!match) return { frontmatter: {}, body: content };
const frontmatter: Record<string, string> = {};
const lines = match[1]!.split('\n');
for (const line of lines) {
const colonIdx = line.indexOf(':');
if (colonIdx > 0) {
const key = line.slice(0, colonIdx).trim();
let value = line.slice(colonIdx + 1).trim();
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
frontmatter[key] = value;
}
}
return { frontmatter, body: match[2]! };
}
function downloadFile(url: string, destPath: string): Promise<void> {
return 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);
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', (err) => {
file.close();
fs.unlink(destPath, () => {});
reject(err);
});
request.setTimeout(30000, () => {
request.destroy();
reject(new Error('Download timeout'));
});
});
}
function getImageExtension(urlOrPath: string): string {
const match = urlOrPath.match(/\.(jpg|jpeg|png|gif|webp)(\?|$)/i);
return match ? match[1]!.toLowerCase() : 'png';
}
async function resolveImagePath(imagePath: string, baseDir: string, tempDir: string): 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(`[md-to-html] Downloading: ${imagePath}`);
await downloadFile(imagePath, localPath);
}
return localPath;
}
if (path.isAbsolute(imagePath)) {
return imagePath;
}
return path.resolve(baseDir, imagePath);
}
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function convertMarkdownToHtml(markdown: string, imageCallback: (src: string, alt: string) => string): { html: string; totalBlocks: number } {
const lines = markdown.split('\n');
const blocks: string[] = [];
let inCodeBlock = false;
let codeBlockContent: string[] = [];
let inList = false;
let listItems: string[] = [];
let listType: 'ul' | 'ol' = 'ul';
const flushList = () => {
if (listItems.length > 0) {
const tag = listType === 'ol' ? 'ol' : 'ul';
blocks.push(`<${tag}>${listItems.map((item) => `<li>${item}</li>`).join('')}</${tag}>`);
listItems = [];
inList = false;
}
};
const processInline = (text: string): string => {
// Bold
text = text.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
text = text.replace(/__(.+?)__/g, '<strong>$1</strong>');
// Italic
text = text.replace(/\*(.+?)\*/g, '<em>$1</em>');
text = text.replace(/_(.+?)_/g, '<em>$1</em>');
// Links
text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
// Inline code
text = text.replace(/`([^`]+)`/g, '<code>$1</code>');
return text;
};
for (let i = 0; i < lines.length; i++) {
const line = lines[i]!;
// Code block
if (line.startsWith('```')) {
if (inCodeBlock) {
// X doesn't support <pre><code>, convert to blockquote
const codeContent = codeBlockContent.map((l) => escapeHtml(l)).join('<br>');
blocks.push(`<blockquote>${codeContent}</blockquote>`);
codeBlockContent = [];
inCodeBlock = false;
} else {
flushList();
inCodeBlock = true;
}
continue;
}
if (inCodeBlock) {
codeBlockContent.push(line);
continue;
}
// Empty line
if (line.trim() === '') {
flushList();
continue;
}
// Image
const imgMatch = line.match(/^!\[([^\]]*)\]\(([^)]+)\)\s*$/);
if (imgMatch) {
flushList();
const placeholder = imageCallback(imgMatch[2]!, imgMatch[1]!);
blocks.push(`<p>${placeholder}</p>`);
continue;
}
// Heading (H1 is title, skip it; H2-H6 become H2)
const headingMatch = line.match(/^(#{1,6})\s+(.+)$/);
if (headingMatch) {
flushList();
const level = headingMatch[1]!.length;
if (level === 1) continue; // Skip H1, it's the title
blocks.push(`<h2>${processInline(headingMatch[2]!)}</h2>`);
continue;
}
// Blockquote
if (line.startsWith('> ')) {
flushList();
blocks.push(`<blockquote>${processInline(line.slice(2))}</blockquote>`);
continue;
}
// Unordered list
const ulMatch = line.match(/^[-*]\s+(.+)$/);
if (ulMatch) {
if (!inList || listType !== 'ul') {
flushList();
inList = true;
listType = 'ul';
}
listItems.push(processInline(ulMatch[1]!));
continue;
}
// Ordered list
const olMatch = line.match(/^\d+\.\s+(.+)$/);
if (olMatch) {
if (!inList || listType !== 'ol') {
flushList();
inList = true;
listType = 'ol';
}
listItems.push(processInline(olMatch[1]!));
continue;
}
// Horizontal rule
if (/^[-*_]{3,}\s*$/.test(line)) {
flushList();
blocks.push('<hr>');
continue;
}
// Regular paragraph
flushList();
blocks.push(`<p>${processInline(line)}</p>`);
}
flushList();
return {
html: blocks.join('\n'),
totalBlocks: blocks.length,
};
}
export async function parseMarkdown(
markdownPath: string,
options?: { coverImage?: string; title?: string; tempDir?: string },
): Promise<ParsedMarkdown> {
const content = fs.readFileSync(markdownPath, 'utf-8');
const baseDir = path.dirname(markdownPath);
const tempDir = options?.tempDir ?? path.join(os.tmpdir(), 'x-article-images');
await mkdir(tempDir, { recursive: true });
const { frontmatter, body } = parseFrontmatter(content);
// Extract title from frontmatter, option, or first H1
let title = options?.title ?? frontmatter.title ?? '';
if (!title) {
const h1Match = body.match(/^#\s+(.+)$/m);
if (h1Match) title = h1Match[1]!;
}
// Extract cover image from frontmatter or option
let coverImagePath = options?.coverImage ?? frontmatter.cover_image ?? frontmatter.coverImage ?? frontmatter.cover ?? frontmatter.image ?? frontmatter.featureImage ?? frontmatter.feature_image ?? null;
const images: Array<{ src: string; alt: string; blockIndex: number }> = [];
let imageCounter = 0;
const { html, totalBlocks } = convertMarkdownToHtml(body, (src, alt) => {
const placeholder = `[[IMAGE_PLACEHOLDER_${++imageCounter}]]`;
const currentBlockIndex = images.length; // Will be set properly after HTML generation
images.push({ src, alt, blockIndex: -1 }); // blockIndex set later
return placeholder;
});
// Update block indices by finding placeholders in HTML
const htmlLines = html.split('\n');
let blockIdx = 0;
for (const line of htmlLines) {
for (let i = 0; i < images.length; i++) {
const placeholder = `[[IMAGE_PLACEHOLDER_${i + 1}]]`;
if (line.includes(placeholder)) {
images[i]!.blockIndex = blockIdx;
}
}
blockIdx++;
}
// Resolve image paths (download remote, resolve relative)
const contentImages: ImageInfo[] = [];
let isFirstImage = true;
let coverPlaceholder: string | null = null;
for (let i = 0; i < images.length; i++) {
const img = images[i]!;
const localPath = await resolveImagePath(img.src, baseDir, tempDir);
// First image becomes cover if no cover specified
if (isFirstImage && !coverImagePath) {
coverImagePath = localPath;
coverPlaceholder = `[[IMAGE_PLACEHOLDER_${i + 1}]]`;
isFirstImage = false;
// Don't add to contentImages, it's the cover
continue;
}
isFirstImage = false;
contentImages.push({
placeholder: `[[IMAGE_PLACEHOLDER_${i + 1}]]`,
localPath,
originalPath: img.src,
blockIndex: img.blockIndex,
});
}
// Remove cover placeholder from HTML if first image was used as cover
let finalHtml = html;
if (coverPlaceholder) {
// Remove the placeholder and its containing <p> tag
finalHtml = finalHtml.replace(new RegExp(`<p>${coverPlaceholder.replace(/[[\]]/g, '\\$&')}</p>\\n?`, 'g'), '');
}
// Resolve cover image path
let resolvedCoverImage: string | null = null;
if (coverImagePath) {
resolvedCoverImage = await resolveImagePath(coverImagePath, baseDir, tempDir);
}
return {
title,
coverImage: resolvedCoverImage,
contentImages,
html: finalHtml,
totalBlocks,
};
}
function printUsage(): never {
console.log(`Convert Markdown to HTML for X Article publishing
Usage:
npx -y bun md-to-html.ts <markdown_file> [options]
Options:
--title <title> Override title from frontmatter
--cover <image> Override cover image from frontmatter
--output <json|html> Output format (default: json)
--html-only Output only the HTML content
--save-html <path> Save HTML to file
Frontmatter fields:
title: Article title (or use first H1)
cover_image: Cover image path or URL
cover: Alias for cover_image
image: Alias for cover_image
Example:
npx -y bun md-to-html.ts article.md --output json
npx -y bun md-to-html.ts article.md --html-only > /tmp/article.html
npx -y bun md-to-html.ts article.md --save-html /tmp/article.html
`);
process.exit(0);
}
async function main(): Promise<void> {
const args = process.argv.slice(2);
if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
printUsage();
}
let markdownPath: string | undefined;
let title: string | undefined;
let coverImage: string | undefined;
let outputFormat: 'json' | 'html' = 'json';
let htmlOnly = false;
let saveHtmlPath: string | undefined;
for (let i = 0; i < args.length; i++) {
const arg = args[i]!;
if (arg === '--title' && args[i + 1]) {
title = args[++i];
} else if (arg === '--cover' && args[i + 1]) {
coverImage = args[++i];
} else if (arg === '--output' && args[i + 1]) {
outputFormat = args[++i] as 'json' | 'html';
} else if (arg === '--html-only') {
htmlOnly = true;
} else if (arg === '--save-html' && args[i + 1]) {
saveHtmlPath = args[++i];
} else if (!arg.startsWith('-')) {
markdownPath = arg;
}
}
if (!markdownPath) {
console.error('Error: Markdown file path required');
process.exit(1);
}
if (!fs.existsSync(markdownPath)) {
console.error(`Error: File not found: ${markdownPath}`);
process.exit(1);
}
const result = await parseMarkdown(markdownPath, { title, coverImage });
if (saveHtmlPath) {
await writeFile(saveHtmlPath, result.html, 'utf-8');
console.error(`[md-to-html] HTML saved to: ${saveHtmlPath}`);
}
if (htmlOnly) {
console.log(result.html);
} else if (outputFormat === 'html') {
console.log(result.html);
} else {
console.log(JSON.stringify(result, null, 2));
}
}
await main().catch((err) => {
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
});
@@ -0,0 +1,194 @@
import { spawnSync } from 'node:child_process';
import process from 'node:process';
function printUsage(exitCode = 0): never {
console.log(`Send real paste keystroke (Cmd+V / Ctrl+V) to the frontmost application
This bypasses CDP's synthetic events which websites can detect and ignore.
Usage:
npx -y bun paste-from-clipboard.ts [options]
Options:
--retries <n> Number of retry attempts (default: 3)
--delay <ms> Delay between retries in ms (default: 500)
--app <name> Target application to activate first (macOS only)
--help Show this help
Examples:
# Simple paste
npx -y bun paste-from-clipboard.ts
# Paste to Chrome with retries
npx -y bun paste-from-clipboard.ts --app "Google Chrome" --retries 5
# Quick paste with shorter delay
npx -y bun paste-from-clipboard.ts --delay 200
`);
process.exit(exitCode);
}
function sleepSync(ms: number): void {
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
}
function activateApp(appName: string): boolean {
if (process.platform !== 'darwin') return false;
// Activate and wait for app to be frontmost
const script = `
tell application "${appName}"
activate
delay 0.5
end tell
-- Verify app is frontmost
tell application "System Events"
set frontApp to name of first application process whose frontmost is true
if frontApp is not "${appName}" then
tell application "${appName}" to activate
delay 0.3
end if
end tell
`;
const result = spawnSync('osascript', ['-e', script], { stdio: 'pipe' });
return result.status === 0;
}
function pasteMac(retries: number, delayMs: number, targetApp?: string): boolean {
for (let i = 0; i < retries; i++) {
// Build script that activates app (if specified) and sends keystroke in one atomic operation
const script = targetApp
? `
tell application "${targetApp}"
activate
end tell
delay 0.3
tell application "System Events"
keystroke "v" using command down
end tell
`
: `
tell application "System Events"
keystroke "v" using command down
end tell
`;
const result = spawnSync('osascript', ['-e', script], { stdio: 'pipe' });
if (result.status === 0) {
return true;
}
const stderr = result.stderr?.toString().trim();
if (stderr) {
console.error(`[paste] osascript error: ${stderr}`);
}
if (i < retries - 1) {
console.error(`[paste] Attempt ${i + 1}/${retries} failed, retrying in ${delayMs}ms...`);
sleepSync(delayMs);
}
}
return false;
}
function pasteLinux(retries: number, delayMs: number): boolean {
// Try xdotool first (X11), then ydotool (Wayland)
const tools = [
{ cmd: 'xdotool', args: ['key', 'ctrl+v'] },
{ cmd: 'ydotool', args: ['key', '29:1', '47:1', '47:0', '29:0'] }, // Ctrl down, V down, V up, Ctrl up
];
for (const tool of tools) {
const which = spawnSync('which', [tool.cmd], { stdio: 'pipe' });
if (which.status !== 0) continue;
for (let i = 0; i < retries; i++) {
const result = spawnSync(tool.cmd, tool.args, { stdio: 'pipe' });
if (result.status === 0) {
return true;
}
if (i < retries - 1) {
console.error(`[paste] Attempt ${i + 1}/${retries} failed, retrying in ${delayMs}ms...`);
sleepSync(delayMs);
}
}
return false;
}
console.error('[paste] No supported tool found. Install xdotool (X11) or ydotool (Wayland).');
return false;
}
function pasteWindows(retries: number, delayMs: number): boolean {
const ps = `
Add-Type -AssemblyName System.Windows.Forms
[System.Windows.Forms.SendKeys]::SendWait("^v")
`;
for (let i = 0; i < retries; i++) {
const result = spawnSync('powershell.exe', ['-NoProfile', '-Command', ps], { stdio: 'pipe' });
if (result.status === 0) {
return true;
}
if (i < retries - 1) {
console.error(`[paste] Attempt ${i + 1}/${retries} failed, retrying in ${delayMs}ms...`);
sleepSync(delayMs);
}
}
return false;
}
function paste(retries: number, delayMs: number, targetApp?: string): boolean {
switch (process.platform) {
case 'darwin':
return pasteMac(retries, delayMs, targetApp);
case 'linux':
return pasteLinux(retries, delayMs);
case 'win32':
return pasteWindows(retries, delayMs);
default:
console.error(`[paste] Unsupported platform: ${process.platform}`);
return false;
}
}
async function main(): Promise<void> {
const args = process.argv.slice(2);
let retries = 3;
let delayMs = 500;
let targetApp: string | undefined;
for (let i = 0; i < args.length; i++) {
const arg = args[i] ?? '';
if (arg === '--help' || arg === '-h') {
printUsage(0);
}
if (arg === '--retries' && args[i + 1]) {
retries = parseInt(args[++i]!, 10) || 3;
} else if (arg === '--delay' && args[i + 1]) {
delayMs = parseInt(args[++i]!, 10) || 500;
} else if (arg === '--app' && args[i + 1]) {
targetApp = args[++i];
} else if (arg.startsWith('-')) {
console.error(`Unknown option: ${arg}`);
printUsage(1);
}
}
if (targetApp) {
console.log(`[paste] Target app: ${targetApp}`);
}
console.log(`[paste] Sending paste keystroke (retries=${retries}, delay=${delayMs}ms)...`);
const success = paste(retries, delayMs, targetApp);
if (success) {
console.log('[paste] Paste keystroke sent successfully');
} else {
console.error('[paste] Failed to send paste keystroke');
process.exit(1);
}
}
await main();
+700
View File
@@ -0,0 +1,700 @@
import { spawn, spawnSync } from 'node:child_process';
import fs from 'node:fs';
import { mkdir, writeFile } from 'node:fs/promises';
import net from 'node:net';
import os from 'node:os';
import path from 'node:path';
import process from 'node:process';
import { parseMarkdown } from './md-to-html.js';
const X_ARTICLES_URL = 'https://x.com/compose/articles';
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function getFreePort(): Promise<number> {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.unref();
server.on('error', reject);
server.listen(0, '127.0.0.1', () => {
const address = server.address();
if (!address || typeof address === 'string') {
server.close(() => reject(new Error('Unable to allocate port')));
return;
}
server.close((err) => (err ? reject(err) : resolve(address.port)));
});
});
}
function findChromeExecutable(): string | undefined {
const override = process.env.X_BROWSER_CHROME_PATH?.trim();
if (override && fs.existsSync(override)) return override;
const candidates: string[] = [];
switch (process.platform) {
case 'darwin':
candidates.push(
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
);
break;
case 'win32':
candidates.push(
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
);
break;
default:
candidates.push('/usr/bin/google-chrome', '/usr/bin/chromium', '/usr/bin/chromium-browser');
break;
}
for (const p of candidates) if (fs.existsSync(p)) return p;
return undefined;
}
function getDefaultProfileDir(): string {
const base = process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share');
return path.join(base, 'x-browser-profile');
}
async function fetchJson<T>(url: string): Promise<T> {
const res = await fetch(url, { redirect: 'follow' });
if (!res.ok) throw new Error(`Request failed: ${res.status}`);
return res.json() as Promise<T>;
}
async function waitForChromeDebugPort(port: number, timeoutMs: number): Promise<string> {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
try {
const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(`http://127.0.0.1:${port}/json/version`);
if (version.webSocketDebuggerUrl) return version.webSocketDebuggerUrl;
} catch {}
await sleep(200);
}
throw new Error('Chrome debug port not ready');
}
class CdpConnection {
private ws: WebSocket;
private nextId = 0;
private pending = new Map<number, { resolve: (v: unknown) => void; reject: (e: Error) => void; timer: ReturnType<typeof setTimeout> | null }>();
private constructor(ws: WebSocket) {
this.ws = ws;
this.ws.addEventListener('message', (event) => {
try {
const data = typeof event.data === 'string' ? event.data : new TextDecoder().decode(event.data as ArrayBuffer);
const msg = JSON.parse(data) as { id?: number; result?: unknown; error?: { message?: string } };
if (msg.id) {
const pending = this.pending.get(msg.id);
if (pending) {
this.pending.delete(msg.id);
if (pending.timer) clearTimeout(pending.timer);
if (msg.error?.message) pending.reject(new Error(msg.error.message));
else pending.resolve(msg.result);
}
}
} catch {}
});
this.ws.addEventListener('close', () => {
for (const [id, pending] of this.pending.entries()) {
this.pending.delete(id);
if (pending.timer) clearTimeout(pending.timer);
pending.reject(new Error('CDP connection closed'));
}
});
}
static async connect(url: string, timeoutMs: number): Promise<CdpConnection> {
const ws = new WebSocket(url);
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('CDP connection timeout')), timeoutMs);
ws.addEventListener('open', () => { clearTimeout(timer); resolve(); });
ws.addEventListener('error', () => { clearTimeout(timer); reject(new Error('CDP connection failed')); });
});
return new CdpConnection(ws);
}
async send<T = unknown>(method: string, params?: Record<string, unknown>, options?: { sessionId?: string; timeoutMs?: number }): Promise<T> {
const id = ++this.nextId;
const message: Record<string, unknown> = { id, method };
if (params) message.params = params;
if (options?.sessionId) message.sessionId = options.sessionId;
const timeoutMs = options?.timeoutMs ?? 30_000;
return new Promise<T>((resolve, reject) => {
const timer = timeoutMs > 0 ? setTimeout(() => { this.pending.delete(id); reject(new Error(`CDP timeout: ${method}`)); }, timeoutMs) : null;
this.pending.set(id, { resolve: resolve as (v: unknown) => void, reject, timer });
this.ws.send(JSON.stringify(message));
});
}
close(): void {
try { this.ws.close(); } catch {}
}
}
function getScriptDir(): string {
return path.dirname(new URL(import.meta.url).pathname);
}
function copyImageToClipboard(imagePath: string): boolean {
const copyScript = path.join(getScriptDir(), 'copy-to-clipboard.ts');
const result = spawnSync('npx', ['-y', 'bun', copyScript, 'image', imagePath], { stdio: 'inherit' });
return result.status === 0;
}
function copyHtmlToClipboard(htmlPath: string): boolean {
const copyScript = path.join(getScriptDir(), 'copy-to-clipboard.ts');
const result = spawnSync('npx', ['-y', 'bun', copyScript, 'html', '--file', htmlPath], { stdio: 'inherit' });
return result.status === 0;
}
function pasteFromClipboard(targetApp?: string, retries = 3, delayMs = 500): boolean {
const pasteScript = path.join(getScriptDir(), 'paste-from-clipboard.ts');
const args = ['npx', '-y', 'bun', pasteScript, '--retries', String(retries), '--delay', String(delayMs)];
if (targetApp) {
args.push('--app', targetApp);
}
const result = spawnSync(args[0]!, args.slice(1), { stdio: 'inherit' });
return result.status === 0;
}
interface ArticleOptions {
markdownPath: string;
coverImage?: string;
title?: string;
submit?: boolean;
profileDir?: string;
chromePath?: string;
}
export async function publishArticle(options: ArticleOptions): Promise<void> {
const { markdownPath, submit = false, profileDir = getDefaultProfileDir() } = options;
console.log('[x-article] Parsing markdown...');
const parsed = await parseMarkdown(markdownPath, {
title: options.title,
coverImage: options.coverImage,
});
console.log(`[x-article] Title: ${parsed.title}`);
console.log(`[x-article] Cover: ${parsed.coverImage ?? 'none'}`);
console.log(`[x-article] Content images: ${parsed.contentImages.length}`);
// Save HTML to temp file
const htmlPath = path.join(os.tmpdir(), 'x-article-content.html');
await writeFile(htmlPath, parsed.html, 'utf-8');
console.log(`[x-article] HTML saved to: ${htmlPath}`);
const chromePath = options.chromePath ?? findChromeExecutable();
if (!chromePath) throw new Error('Chrome not found');
await mkdir(profileDir, { recursive: true });
const port = await getFreePort();
console.log(`[x-article] Launching Chrome...`);
const chrome = spawn(chromePath, [
`--remote-debugging-port=${port}`,
`--user-data-dir=${profileDir}`,
'--no-first-run',
'--no-default-browser-check',
'--disable-blink-features=AutomationControlled',
'--start-maximized',
X_ARTICLES_URL,
], { stdio: 'ignore' });
let cdp: CdpConnection | null = null;
try {
const wsUrl = await waitForChromeDebugPort(port, 30_000);
cdp = await CdpConnection.connect(wsUrl, 30_000);
// Get page target
const targets = await cdp.send<{ targetInfos: Array<{ targetId: string; url: string; type: string }> }>('Target.getTargets');
let pageTarget = targets.targetInfos.find((t) => t.type === 'page' && t.url.includes('x.com'));
if (!pageTarget) {
const { targetId } = await cdp.send<{ targetId: string }>('Target.createTarget', { url: X_ARTICLES_URL });
pageTarget = { targetId, url: X_ARTICLES_URL, type: 'page' };
}
const { sessionId } = await cdp.send<{ sessionId: string }>('Target.attachToTarget', { targetId: pageTarget.targetId, flatten: true });
await cdp.send('Page.enable', {}, { sessionId });
await cdp.send('Runtime.enable', {}, { sessionId });
await cdp.send('DOM.enable', {}, { sessionId });
console.log('[x-article] Waiting for articles page...');
await sleep(3000);
// Wait for and click "create" button
const waitForElement = async (selector: string, timeoutMs = 60_000): Promise<boolean> => {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const result = await cdp!.send<{ result: { value: boolean } }>('Runtime.evaluate', {
expression: `!!document.querySelector('${selector}')`,
returnByValue: true,
}, { sessionId });
if (result.result.value) return true;
await sleep(500);
}
return false;
};
const clickElement = async (selector: string): Promise<boolean> => {
const result = await cdp!.send<{ result: { value: boolean } }>('Runtime.evaluate', {
expression: `(() => { const el = document.querySelector('${selector}'); if (el) { el.click(); return true; } return false; })()`,
returnByValue: true,
}, { sessionId });
return result.result.value;
};
const typeText = async (selector: string, text: string): Promise<void> => {
await cdp!.send('Runtime.evaluate', {
expression: `(() => {
const el = document.querySelector('${selector}');
if (el) {
el.focus();
document.execCommand('insertText', false, ${JSON.stringify(text)});
}
})()`,
}, { sessionId });
};
const pressKey = async (key: string, modifiers = 0): Promise<void> => {
await cdp!.send('Input.dispatchKeyEvent', {
type: 'keyDown',
key,
code: `Key${key.toUpperCase()}`,
modifiers,
windowsVirtualKeyCode: key.toUpperCase().charCodeAt(0),
}, { sessionId });
await cdp!.send('Input.dispatchKeyEvent', {
type: 'keyUp',
key,
code: `Key${key.toUpperCase()}`,
modifiers,
windowsVirtualKeyCode: key.toUpperCase().charCodeAt(0),
}, { sessionId });
};
// Check if we're on the articles list page (has Write button)
console.log('[x-article] Looking for Write button...');
const writeButtonFound = await waitForElement('[data-testid="empty_state_button_text"]', 10_000);
if (writeButtonFound) {
console.log('[x-article] Clicking Write button...');
await cdp.send('Runtime.evaluate', {
expression: `document.querySelector('[data-testid="empty_state_button_text"]')?.click()`,
}, { sessionId });
await sleep(2000);
}
// Wait for editor (title textarea)
console.log('[x-article] Waiting for editor...');
const editorFound = await waitForElement('textarea[placeholder="Add a title"], textarea[name="Article Title"]', 30_000);
if (!editorFound) {
console.log('[x-article] Editor not found. Please ensure you have X Premium and are logged in.');
await sleep(60_000);
throw new Error('Editor not found');
}
// Upload cover image
if (parsed.coverImage) {
console.log('[x-article] Uploading cover image...');
// Click "Add photos or video" button
await cdp.send('Runtime.evaluate', {
expression: `document.querySelector('[aria-label="Add photos or video"]')?.click()`,
}, { sessionId });
await sleep(500);
// Use file input directly
const { root } = await cdp.send<{ root: { nodeId: number } }>('DOM.getDocument', {}, { sessionId });
const { nodeId } = await cdp.send<{ nodeId: number }>('DOM.querySelector', {
nodeId: root.nodeId,
selector: '[data-testid="fileInput"], input[type="file"][accept*="image"]',
}, { sessionId });
if (nodeId) {
await cdp.send('DOM.setFileInputFiles', {
nodeId,
files: [parsed.coverImage],
}, { sessionId });
console.log('[x-article] Cover image file set');
// Wait for Apply button to appear and click it
console.log('[x-article] Waiting for Apply button...');
const applyFound = await waitForElement('[data-testid="applyButton"]', 15_000);
if (applyFound) {
await cdp.send('Runtime.evaluate', {
expression: `document.querySelector('[data-testid="applyButton"]')?.click()`,
}, { sessionId });
console.log('[x-article] Cover image applied');
await sleep(1000);
} else {
console.log('[x-article] Apply button not found, continuing...');
}
}
}
// Fill title using keyboard input
if (parsed.title) {
console.log('[x-article] Filling title...');
// Focus title input
await cdp.send('Runtime.evaluate', {
expression: `document.querySelector('textarea[placeholder="Add a title"], textarea[name="Article Title"]')?.focus()`,
}, { sessionId });
await sleep(200);
// Type title character by character using insertText
await cdp.send('Input.insertText', { text: parsed.title }, { sessionId });
await sleep(300);
// Tab out to trigger save
await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Tab', code: 'Tab', windowsVirtualKeyCode: 9 }, { sessionId });
await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Tab', code: 'Tab', windowsVirtualKeyCode: 9 }, { sessionId });
await sleep(500);
}
// Insert HTML content
console.log('[x-article] Inserting content...');
// Read HTML content
const htmlContent = fs.readFileSync(htmlPath, 'utf-8');
// Focus on DraftEditor body
await cdp.send('Runtime.evaluate', {
expression: `(() => {
const editor = document.querySelector('.DraftEditor-editorContainer [contenteditable="true"]');
if (editor) {
editor.focus();
editor.click();
return true;
}
return false;
})()`,
}, { sessionId });
await sleep(300);
// Method 1: Simulate paste event with HTML data
console.log('[x-article] Attempting to insert HTML via paste event...');
const pasteResult = await cdp.send<{ result: { value: boolean } }>('Runtime.evaluate', {
expression: `(() => {
const editor = document.querySelector('.DraftEditor-editorContainer [contenteditable="true"]');
if (!editor) return false;
const html = ${JSON.stringify(htmlContent)};
// Create a paste event with HTML data
const dt = new DataTransfer();
dt.setData('text/html', html);
dt.setData('text/plain', html.replace(/<[^>]*>/g, ''));
const pasteEvent = new ClipboardEvent('paste', {
bubbles: true,
cancelable: true,
clipboardData: dt
});
editor.dispatchEvent(pasteEvent);
return true;
})()`,
returnByValue: true,
}, { sessionId });
await sleep(1000);
// Check if content was inserted
const contentCheck = await cdp.send<{ result: { value: number } }>('Runtime.evaluate', {
expression: `document.querySelector('.DraftEditor-editorContainer [data-contents="true"]')?.innerText?.length || 0`,
returnByValue: true,
}, { sessionId });
if (contentCheck.result.value > 50) {
console.log(`[x-article] Content inserted successfully (${contentCheck.result.value} chars)`);
} else {
console.log('[x-article] Paste event may not have worked, trying insertHTML...');
// Method 2: Use execCommand insertHTML
await cdp.send('Runtime.evaluate', {
expression: `(() => {
const editor = document.querySelector('.DraftEditor-editorContainer [contenteditable="true"]');
if (!editor) return false;
editor.focus();
document.execCommand('insertHTML', false, ${JSON.stringify(htmlContent)});
return true;
})()`,
}, { sessionId });
await sleep(1000);
// Check again
const check2 = await cdp.send<{ result: { value: number } }>('Runtime.evaluate', {
expression: `document.querySelector('.DraftEditor-editorContainer [data-contents="true"]')?.innerText?.length || 0`,
returnByValue: true,
}, { sessionId });
if (check2.result.value > 50) {
console.log(`[x-article] Content inserted via execCommand (${check2.result.value} chars)`);
} else {
console.log('[x-article] Auto-insert failed. HTML copied to clipboard - please paste manually (Cmd+V)');
copyHtmlToClipboard(htmlPath);
// Wait for manual paste
console.log('[x-article] Waiting 30s for manual paste...');
await sleep(30_000);
}
}
// Insert content images (reverse order to maintain positions)
if (parsed.contentImages.length > 0) {
console.log('[x-article] Inserting content images...');
// First, check what placeholders exist in the editor
const editorContent = await cdp.send<{ result: { value: string } }>('Runtime.evaluate', {
expression: `document.querySelector('.DraftEditor-editorContainer [data-contents="true"]')?.innerText || ''`,
returnByValue: true,
}, { sessionId });
console.log('[x-article] Checking for placeholders in content...');
for (const img of parsed.contentImages) {
if (editorContent.result.value.includes(img.placeholder)) {
console.log(`[x-article] Found: ${img.placeholder}`);
} else {
console.log(`[x-article] NOT found: ${img.placeholder}`);
}
}
// Process images in sequential order (1, 2, 3, ...)
const sortedImages = [...parsed.contentImages].sort((a, b) => a.blockIndex - b.blockIndex);
for (let i = 0; i < sortedImages.length; i++) {
const img = sortedImages[i]!;
console.log(`[x-article] [${i + 1}/${sortedImages.length}] Inserting image at placeholder: ${img.placeholder}`);
// Find, scroll to, and select the placeholder text in DraftEditor
const placeholderFound = await cdp.send<{ result: { value: boolean } }>('Runtime.evaluate', {
expression: `(() => {
const editor = document.querySelector('.DraftEditor-editorContainer [data-contents="true"]');
if (!editor) return false;
const placeholder = ${JSON.stringify(img.placeholder)};
// Search through all text nodes in the editor
const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT, null, false);
let node;
while ((node = walker.nextNode())) {
const text = node.textContent || '';
const idx = text.indexOf(placeholder);
if (idx !== -1) {
// Found the placeholder - scroll to it first
const parentElement = node.parentElement;
if (parentElement) {
parentElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
// Select it
const range = document.createRange();
range.setStart(node, idx);
range.setEnd(node, idx + placeholder.length);
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
return true;
}
}
return false;
})()`,
returnByValue: true,
}, { sessionId });
// Wait for scroll animation
await sleep(500);
if (!placeholderFound.result.value) {
console.warn(`[x-article] Placeholder not found in DOM: ${img.placeholder}`);
continue;
}
console.log(`[x-article] Placeholder selected, copying image: ${path.basename(img.localPath)}`);
// Copy image to clipboard
if (!copyImageToClipboard(img.localPath)) {
console.warn(`[x-article] Failed to copy image to clipboard`);
continue;
}
// Wait for clipboard to be fully ready
await sleep(800);
// Delete placeholder by pressing Enter (placeholder is already selected)
console.log(`[x-article] Deleting placeholder with Enter...`);
await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13 }, { sessionId });
await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13 }, { sessionId });
await sleep(300);
// Paste image using paste script (activates Chrome, sends real keystroke)
console.log(`[x-article] Pasting image...`);
if (pasteFromClipboard('Google Chrome', 5, 800)) {
console.log(`[x-article] Image pasted: ${path.basename(img.localPath)}`);
} else {
console.warn(`[x-article] Failed to paste image after retries`);
}
// Wait for image to upload
console.log(`[x-article] Waiting for upload...`);
await sleep(5000);
}
console.log('[x-article] All images processed.');
}
// Before preview: blur editor to trigger save
console.log('[x-article] Triggering content save...');
await cdp.send('Runtime.evaluate', {
expression: `(() => {
// Blur editor to trigger any pending saves
const editor = document.querySelector('.DraftEditor-editorContainer [contenteditable="true"]');
if (editor) {
editor.blur();
}
// Also click elsewhere to ensure focus is lost
document.body.click();
})()`,
}, { sessionId });
await sleep(1500);
// Click Preview button
console.log('[x-article] Opening preview...');
const previewClicked = await cdp.send<{ result: { value: boolean } }>('Runtime.evaluate', {
expression: `(() => {
// Try multiple selectors for preview button
const previewLink = document.querySelector('a[href*="/preview"]')
|| document.querySelector('[data-testid="previewButton"]')
|| document.querySelector('button[aria-label*="preview" i]');
if (previewLink) {
previewLink.click();
return true;
}
return false;
})()`,
returnByValue: true,
}, { sessionId });
if (previewClicked.result.value) {
console.log('[x-article] Preview opened');
await sleep(3000);
} else {
console.log('[x-article] Preview button not found');
}
// Check for publish button
if (submit) {
console.log('[x-article] Publishing...');
await cdp.send('Runtime.evaluate', {
expression: `(() => {
const publishBtn = document.querySelector('[data-testid="publishButton"], button[aria-label*="publish" i], button[aria-label*="发布" i]');
if (publishBtn && !publishBtn.disabled) {
publishBtn.click();
return true;
}
return false;
})()`,
}, { sessionId });
await sleep(3000);
console.log('[x-article] Article published!');
} else {
console.log('[x-article] Article composed (draft mode).');
console.log('[x-article] Browser remains open for manual review.');
}
} finally {
// Disconnect CDP but keep browser open
if (cdp) {
cdp.close();
}
// Don't kill Chrome - let user review and close manually
}
}
function printUsage(): never {
console.log(`Publish Markdown article to X (Twitter) Articles
Usage:
npx -y bun x-article.ts <markdown_file> [options]
Options:
--title <title> Override title
--cover <image> Override cover image
--submit Actually publish (default: draft only)
--profile <dir> Chrome profile directory
--help Show this help
Markdown frontmatter:
---
title: My Article Title
cover_image: /path/to/cover.jpg
---
Example:
npx -y bun x-article.ts article.md
npx -y bun x-article.ts article.md --cover ./hero.png
npx -y bun x-article.ts article.md --submit
`);
process.exit(0);
}
async function main(): Promise<void> {
const args = process.argv.slice(2);
if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
printUsage();
}
let markdownPath: string | undefined;
let title: string | undefined;
let coverImage: string | undefined;
let submit = false;
let profileDir: string | undefined;
for (let i = 0; i < args.length; i++) {
const arg = args[i]!;
if (arg === '--title' && args[i + 1]) {
title = args[++i];
} else if (arg === '--cover' && args[i + 1]) {
coverImage = args[++i];
} else if (arg === '--submit') {
submit = true;
} else if (arg === '--profile' && args[i + 1]) {
profileDir = args[++i];
} else if (!arg.startsWith('-')) {
markdownPath = arg;
}
}
if (!markdownPath) {
console.error('Error: Markdown file path required');
process.exit(1);
}
if (!fs.existsSync(markdownPath)) {
console.error(`Error: File not found: ${markdownPath}`);
process.exit(1);
}
await publishArticle({ markdownPath, title, coverImage, submit, profileDir });
}
await main().catch((err) => {
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
});
+419
View File
@@ -0,0 +1,419 @@
import { spawn, spawnSync } from 'node:child_process';
import fs from 'node:fs';
import { mkdir } from 'node:fs/promises';
import net from 'node:net';
import os from 'node:os';
import path from 'node:path';
import process from 'node:process';
const X_COMPOSE_URL = 'https://x.com/compose/post';
function getScriptDir(): string {
return path.dirname(new URL(import.meta.url).pathname);
}
function copyImageToClipboard(imagePath: string): boolean {
const copyScript = path.join(getScriptDir(), 'copy-to-clipboard.ts');
const result = spawnSync('npx', ['-y', 'bun', copyScript, 'image', imagePath], { stdio: 'inherit' });
return result.status === 0;
}
function pasteFromClipboard(targetApp?: string, retries = 3, delayMs = 500): boolean {
const pasteScript = path.join(getScriptDir(), 'paste-from-clipboard.ts');
const args = ['npx', '-y', 'bun', pasteScript, '--retries', String(retries), '--delay', String(delayMs)];
if (targetApp) {
args.push('--app', targetApp);
}
const result = spawnSync(args[0]!, args.slice(1), { stdio: 'inherit' });
return result.status === 0;
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function getFreePort(): Promise<number> {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.unref();
server.on('error', reject);
server.listen(0, '127.0.0.1', () => {
const address = server.address();
if (!address || typeof address === 'string') {
server.close(() => reject(new Error('Unable to allocate a free TCP port.')));
return;
}
const port = address.port;
server.close((err) => {
if (err) reject(err);
else resolve(port);
});
});
});
}
function findChromeExecutable(): string | undefined {
const override = process.env.X_BROWSER_CHROME_PATH?.trim();
if (override && fs.existsSync(override)) return override;
const candidates: string[] = [];
switch (process.platform) {
case 'darwin':
candidates.push(
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
'/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
'/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
);
break;
case 'win32':
candidates.push(
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe',
'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe',
);
break;
default:
candidates.push(
'/usr/bin/google-chrome',
'/usr/bin/google-chrome-stable',
'/usr/bin/chromium',
'/usr/bin/chromium-browser',
'/snap/bin/chromium',
'/usr/bin/microsoft-edge',
);
break;
}
for (const p of candidates) {
if (fs.existsSync(p)) return p;
}
return undefined;
}
function getDefaultProfileDir(): string {
const base = process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share');
return path.join(base, 'x-browser-profile');
}
async function fetchJson<T = unknown>(url: string): Promise<T> {
const res = await fetch(url, { redirect: 'follow' });
if (!res.ok) throw new Error(`Request failed: ${res.status} ${res.statusText}`);
return (await res.json()) as T;
}
async function waitForChromeDebugPort(port: number, timeoutMs: number): Promise<string> {
const start = Date.now();
let lastError: unknown = null;
while (Date.now() - start < timeoutMs) {
try {
const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(`http://127.0.0.1:${port}/json/version`);
if (version.webSocketDebuggerUrl) return version.webSocketDebuggerUrl;
lastError = new Error('Missing webSocketDebuggerUrl');
} catch (error) {
lastError = error;
}
await sleep(200);
}
throw new Error(`Chrome debug port not ready: ${lastError instanceof Error ? lastError.message : String(lastError)}`);
}
class CdpConnection {
private ws: WebSocket;
private nextId = 0;
private pending = new Map<number, { resolve: (v: unknown) => void; reject: (e: Error) => void; timer: ReturnType<typeof setTimeout> | null }>();
private eventHandlers = new Map<string, Set<(params: unknown) => void>>();
private constructor(ws: WebSocket) {
this.ws = ws;
this.ws.addEventListener('message', (event) => {
try {
const data = typeof event.data === 'string' ? event.data : new TextDecoder().decode(event.data as ArrayBuffer);
const msg = JSON.parse(data) as { id?: number; method?: string; params?: unknown; result?: unknown; error?: { message?: string } };
if (msg.method) {
const handlers = this.eventHandlers.get(msg.method);
if (handlers) handlers.forEach((h) => h(msg.params));
}
if (msg.id) {
const pending = this.pending.get(msg.id);
if (pending) {
this.pending.delete(msg.id);
if (pending.timer) clearTimeout(pending.timer);
if (msg.error?.message) pending.reject(new Error(msg.error.message));
else pending.resolve(msg.result);
}
}
} catch {}
});
this.ws.addEventListener('close', () => {
for (const [id, pending] of this.pending.entries()) {
this.pending.delete(id);
if (pending.timer) clearTimeout(pending.timer);
pending.reject(new Error('CDP connection closed.'));
}
});
}
static async connect(url: string, timeoutMs: number): Promise<CdpConnection> {
const ws = new WebSocket(url);
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('CDP connection timeout.')), timeoutMs);
ws.addEventListener('open', () => { clearTimeout(timer); resolve(); });
ws.addEventListener('error', () => { clearTimeout(timer); reject(new Error('CDP connection failed.')); });
});
return new CdpConnection(ws);
}
on(method: string, handler: (params: unknown) => void): void {
if (!this.eventHandlers.has(method)) this.eventHandlers.set(method, new Set());
this.eventHandlers.get(method)!.add(handler);
}
async send<T = unknown>(method: string, params?: Record<string, unknown>, options?: { sessionId?: string; timeoutMs?: number }): Promise<T> {
const id = ++this.nextId;
const message: Record<string, unknown> = { id, method };
if (params) message.params = params;
if (options?.sessionId) message.sessionId = options.sessionId;
const timeoutMs = options?.timeoutMs ?? 15_000;
const result = await new Promise<unknown>((resolve, reject) => {
const timer = timeoutMs > 0 ? setTimeout(() => { this.pending.delete(id); reject(new Error(`CDP timeout: ${method}`)); }, timeoutMs) : null;
this.pending.set(id, { resolve, reject, timer });
this.ws.send(JSON.stringify(message));
});
return result as T;
}
close(): void {
try { this.ws.close(); } catch {}
}
}
interface XBrowserOptions {
text?: string;
images?: string[];
submit?: boolean;
timeoutMs?: number;
profileDir?: string;
chromePath?: string;
}
export async function postToX(options: XBrowserOptions): Promise<void> {
const { text, images = [], submit = false, timeoutMs = 120_000, profileDir = getDefaultProfileDir() } = options;
const chromePath = options.chromePath ?? findChromeExecutable();
if (!chromePath) throw new Error('Chrome not found. Set X_BROWSER_CHROME_PATH env var.');
await mkdir(profileDir, { recursive: true });
const port = await getFreePort();
console.log(`[x-browser] Launching Chrome (profile: ${profileDir})`);
const chrome = spawn(chromePath, [
`--remote-debugging-port=${port}`,
`--user-data-dir=${profileDir}`,
'--no-first-run',
'--no-default-browser-check',
'--disable-blink-features=AutomationControlled',
'--start-maximized',
X_COMPOSE_URL,
], { stdio: 'ignore' });
let cdp: CdpConnection | null = null;
try {
const wsUrl = await waitForChromeDebugPort(port, 30_000);
cdp = await CdpConnection.connect(wsUrl, 30_000);
const targets = await cdp.send<{ targetInfos: Array<{ targetId: string; url: string; type: string }> }>('Target.getTargets');
let pageTarget = targets.targetInfos.find((t) => t.type === 'page' && t.url.includes('x.com'));
if (!pageTarget) {
const { targetId } = await cdp.send<{ targetId: string }>('Target.createTarget', { url: X_COMPOSE_URL });
pageTarget = { targetId, url: X_COMPOSE_URL, type: 'page' };
}
const { sessionId } = await cdp.send<{ sessionId: string }>('Target.attachToTarget', { targetId: pageTarget.targetId, flatten: true });
await cdp.send('Page.enable', {}, { sessionId });
await cdp.send('Runtime.enable', {}, { sessionId });
await cdp.send('Input.setIgnoreInputEvents', { ignore: false }, { sessionId });
console.log('[x-browser] Waiting for X editor...');
await sleep(3000);
const waitForEditor = async (): Promise<boolean> => {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const result = await cdp!.send<{ result: { value: boolean } }>('Runtime.evaluate', {
expression: `!!document.querySelector('[data-testid="tweetTextarea_0"]')`,
returnByValue: true,
}, { sessionId });
if (result.result.value) return true;
await sleep(1000);
}
return false;
};
const editorFound = await waitForEditor();
if (!editorFound) {
console.log('[x-browser] Editor not found. Please log in to X in the browser window.');
console.log('[x-browser] Waiting for login...');
const loggedIn = await waitForEditor();
if (!loggedIn) throw new Error('Timed out waiting for X editor. Please log in first.');
}
if (text) {
console.log('[x-browser] Typing text...');
await cdp.send('Runtime.evaluate', {
expression: `
const editor = document.querySelector('[data-testid="tweetTextarea_0"]');
if (editor) {
editor.focus();
document.execCommand('insertText', false, ${JSON.stringify(text)});
}
`,
}, { sessionId });
await sleep(500);
}
for (const imagePath of images) {
if (!fs.existsSync(imagePath)) {
console.warn(`[x-browser] Image not found: ${imagePath}`);
continue;
}
console.log(`[x-browser] Pasting image: ${imagePath}`);
if (!copyImageToClipboard(imagePath)) {
console.warn(`[x-browser] Failed to copy image to clipboard: ${imagePath}`);
continue;
}
// Wait for clipboard to be ready
await sleep(500);
// Focus the editor
await cdp.send('Runtime.evaluate', {
expression: `document.querySelector('[data-testid="tweetTextarea_0"]')?.focus()`,
}, { sessionId });
await sleep(200);
// Use paste script (handles platform differences, activates Chrome)
console.log('[x-browser] Pasting from clipboard...');
const pasteSuccess = pasteFromClipboard('Google Chrome', 5, 500);
if (!pasteSuccess) {
// Fallback to CDP (may not work for images on X)
console.log('[x-browser] Paste script failed, trying CDP fallback...');
const modifiers = process.platform === 'darwin' ? 4 : 2;
await cdp.send('Input.dispatchKeyEvent', {
type: 'keyDown',
key: 'v',
code: 'KeyV',
modifiers,
windowsVirtualKeyCode: 86,
}, { sessionId });
await cdp.send('Input.dispatchKeyEvent', {
type: 'keyUp',
key: 'v',
code: 'KeyV',
modifiers,
windowsVirtualKeyCode: 86,
}, { sessionId });
}
console.log('[x-browser] Waiting for image upload...');
await sleep(4000);
}
if (submit) {
console.log('[x-browser] Submitting post...');
await cdp.send('Runtime.evaluate', {
expression: `document.querySelector('[data-testid="tweetButton"]')?.click()`,
}, { sessionId });
await sleep(2000);
console.log('[x-browser] Post submitted!');
} else {
console.log('[x-browser] Post composed (preview mode). Add --submit to post.');
console.log('[x-browser] Browser will stay open for 30 seconds for preview...');
await sleep(30_000);
}
} finally {
if (cdp) {
try { await cdp.send('Browser.close', {}, { timeoutMs: 5_000 }); } catch {}
cdp.close();
}
setTimeout(() => {
if (!chrome.killed) try { chrome.kill('SIGKILL'); } catch {}
}, 2_000).unref?.();
try { chrome.kill('SIGTERM'); } catch {}
}
}
function printUsage(): never {
console.log(`Post to X (Twitter) using real Chrome browser
Usage:
npx -y bun x-browser.ts [options] [text]
Options:
--image <path> Add image (can be repeated, max 4)
--submit Actually post (default: preview only)
--profile <dir> Chrome profile directory
--help Show this help
Examples:
npx -y bun x-browser.ts "Hello from CLI!"
npx -y bun x-browser.ts "Check this out" --image ./screenshot.png
npx -y bun x-browser.ts "Post it!" --image a.png --image b.png --submit
`);
process.exit(0);
}
async function main(): Promise<void> {
const args = process.argv.slice(2);
if (args.includes('--help') || args.includes('-h')) printUsage();
const images: string[] = [];
let submit = false;
let profileDir: string | undefined;
const textParts: string[] = [];
for (let i = 0; i < args.length; i++) {
const arg = args[i]!;
if (arg === '--image' && args[i + 1]) {
images.push(args[++i]!);
} else if (arg === '--submit') {
submit = true;
} else if (arg === '--profile' && args[i + 1]) {
profileDir = args[++i];
} else if (!arg.startsWith('-')) {
textParts.push(arg);
}
}
const text = textParts.join(' ').trim() || undefined;
if (!text && images.length === 0) {
console.error('Error: Provide text or at least one image.');
process.exit(1);
}
await postToX({ text, images, submit, profileDir });
}
await main().catch((err) => {
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
});
+299
View File
@@ -0,0 +1,299 @@
---
name: baoyu-slide-deck
description: Generate professional slide deck images from content. Creates comprehensive outlines with style instructions, then generates individual slide images. Use when user asks to "create slides", "make a presentation", "generate deck", or "slide deck".
---
# Slide Deck Generator
Transform content into professional slide deck with comprehensive outlines and generated slide images.
## Usage
```bash
# From markdown file
/baoyu-slide-deck path/to/article.md
# With style preference
/baoyu-slide-deck path/to/article.md --style corporate
/baoyu-slide-deck path/to/article.md --style playful
/baoyu-slide-deck path/to/article.md --style technical
# With audience specification
/baoyu-slide-deck path/to/article.md --audience beginners
/baoyu-slide-deck path/to/article.md --audience executives
# With language
/baoyu-slide-deck path/to/article.md --lang zh
/baoyu-slide-deck path/to/article.md --lang en
# Outline only (no image generation)
/baoyu-slide-deck path/to/article.md --outline-only
# Direct content input
/baoyu-slide-deck
[paste content]
```
## Options
| Option | Description |
|--------|-------------|
| `--style <name>` | Visual style preset (see Style Gallery) |
| `--audience <type>` | Target audience level |
| `--lang <code>` | Output language (en, zh, etc.) |
| `--slides <number>` | Target slide count (max 20) |
| `--outline-only` | Generate outline only, skip image generation |
## Style Gallery
| Style | Description |
|-------|-------------|
| `editorial` (Default) | Clean, sophisticated, minimalist |
| `corporate` | Professional, trustworthy, polished |
| `technical` | Precise, data-driven, analytical |
| `playful` | Bold, energetic, engaging |
| `minimal` | Ultra-clean, zen-like, focused |
| `storytelling` | Narrative-driven, cinematic, immersive |
| `warm` | Cozy, healing, hand-drawn illustration style |
| `retro-flat` | Flat vector illustration with retro palette |
| `notion` | Minimalist hand-drawn line art, intellectual |
Detailed style definitions: `references/styles/<style>.md`
## Auto Style Selection
When no `--style` is specified, analyze content for style signals:
| Content Signals | Selected Style |
|----------------|----------------|
| AI, coding, tech, digital, algorithm, data | `technical` |
| Business, strategy, investment, corporate | `corporate` |
| Personal story, journey, narrative, emotion | `storytelling` |
| Simple, zen, focus, essential, one idea | `minimal` |
| Fun, creative, workshop, educational | `playful` |
| Research, analysis, thought leadership | `editorial` |
| Wellness, healing, cozy, self-care, lifestyle, comfort | `warm` |
| Tutorial, explainer, how-to, beginner, product, guide | `retro-flat` |
| Knowledge, concept, productivity, SaaS, notion, intellectual | `notion` |
## Audience Presets
| Audience | Approach |
|----------|----------|
| `beginners` | Step-by-step, more context, simpler visuals |
| `intermediate` | Balanced detail, some assumed knowledge |
| `experts` | Dense information, technical depth, less hand-holding |
| `executives` | High-level insights, key metrics, strategic focus |
| `general` | Accessible language, broad appeal, clear takeaways |
## File Management
### With Article Path
```
path/to/
├── article.md
└── slide-deck/
├── outline.md
├── prompts/
│ ├── 01-cover.md
│ ├── 02-content-1.md
│ └── ...
├── 01-cover.png
├── 02-content-1.png
└── ...
```
### Without Article Path
```
./baoyu-slide-deck-outputs/YYYY-MM-DD/[topic-slug]/
├── outline.md
├── prompts/
│ ├── 01-cover.md
│ └── ...
├── 01-cover.png
└── ...
```
## Workflow
### Step 1: Analyze Content & Select Style
1. Read source content
2. If `--style` specified, use that style
3. Otherwise, analyze content for style signals
4. Extract key information:
- Core narrative and key messages
- Important data points and statistics
- Logical flow and structure
- Target audience signals
### Step 2: Generate Outline
Create outline with `STYLE_INSTRUCTIONS` block and slide specifications.
**Outline Format**:
```markdown
# Slide Deck Outline: [Topic]
**Source**: [source file or "Direct input"]
**Style**: [selected style]
**Audience**: [target audience]
**Language**: [output language]
**Slide Count**: N slides
**Generated**: YYYY-MM-DD HH:mm
---
<STYLE_INSTRUCTIONS>
Design Aesthetic: [Overall style description]
Background Color: [Description and Hex Code]
Primary Font: [Font name for Headlines]
Secondary Font: [Font name for Body copy]
Color Palette:
Primary Text Color: [Hex Code]
Primary Accent Color: [Hex Code]
Visual Elements: [Lines, shapes, imagery style, etc.]
</STYLE_INSTRUCTIONS>
---
## Slide 1: [Descriptive Title]
**Position**: Cover
**Filename**: 01-cover.png
// NARRATIVE GOAL
[Storytelling purpose within the overall arc]
// KEY CONTENT
Headline: [Main message - narrative, not "Title: Subtitle" format]
Sub-headline: [Supporting context]
Body:
- [Key point 1 with specific data from source]
- [Key point 2 with specific data from source]
// VISUAL
[Detailed description of imagery, charts, graphics, or abstract visuals]
// LAYOUT
[Composition, hierarchy, spatial arrangement, focus points]
---
## Slide 2: [First Content]
...
## Slide N: [Back Cover]
...
```
**Required Slide Structure**:
1. **Slide 1**: Cover Slide (poster-style, heroic typography)
2. **Slides 2-N-1**: Content slides (consistent internal style)
3. **Slide N**: Back Cover (closing statement, not "Thank You")
### Step 3: Save Outline
Save outline as `outline.md` in target directory.
If `--outline-only` flag is set, stop here.
### Step 4: Create Prompt Files
For each slide, create a style-specific prompt file.
**Prompt Format**:
```markdown
Slide theme: [slide title]
Style: [style name]
Position: [cover/content/back-cover]
Visual composition:
- Main visual: [style-appropriate description from VISUAL section]
- Layout: [from LAYOUT section]
- Decorative elements: [style-specific decorations]
Color scheme:
- Background: [style background color]
- Primary text: [style text color]
- Accent: [style accent color]
Text content:
- Headline: [headline text]
- Sub-headline: [sub-headline if any]
- Body points: [bullet points if any]
Style notes: [specific style characteristics to emphasize]
```
### Step 5: Generate Images
For each slide, generate using:
```bash
/baoyu-gemini-web --promptfiles [SKILL_ROOT]/skills/baoyu-slide-deck/prompts/system.md [TARGET_DIR]/prompts/01-cover.md --image [TARGET_DIR]/01-cover.png
```
Generation flow:
1. Generate images sequentially
2. After each image, output progress: "Generated X/N"
3. On failure, auto-retry once
4. If retry fails, log reason, continue to next
### Step 6: Completion Report
```
Slide Deck Generated!
Topic: [topic]
Style: [style name]
Audience: [audience]
Location: [directory path]
Slides: N total
- 01-cover.png ✓ Cover
- 02-content-1.png ✓ Content
- 03-content-2.png ✓ Content
- ...
- 0N-back-cover.png ✓ Back Cover
Outline: outline.md
[If any failures]
Failed:
- 0X-slide-name.png: [failure reason]
```
## Notes
### Design Philosophy
- Deck is designed for **reading and sharing**, not live presentation
- Structure should be self-explanatory without a presenter
- Include enough context for visuals to be understood standalone
- Err on the side of audience having **more expertise** than expected
### Content Rules
- Maximum 20 slides per deck
- Every data point must trace to source material
- All details in prompts - image generator has no access to source
### Style Rules
- Avoid AI-generated clichés ("It wasn't just X, it was Y")
- Use narrative headlines, not "Title: Subtitle" format
- Cover and Back Cover should be visually distinct (poster-style)
- Back Cover should be meaningful closure, not "Thank You" or "Questions?"
### Prohibited
- Never include photorealistic images of prominent individuals
- Never include placeholder slides for author name, date, etc.
### Image Generation
- Image generation typically takes 10-30 seconds per slide
- Auto-retry once on generation failure
- Use cartoon alternatives for sensitive public figures
- Output language matches content language
- Maintain style consistency across all slides
@@ -0,0 +1,40 @@
Create a presentation slide image following these guidelines:
## Image Specifications
- **Type**: Presentation slide
- **Orientation**: Landscape (horizontal)
- **Aspect Ratio**: 16:9
- **Style**: Hand-drawn illustration with professional typography
## Core Principles
- Hand-drawn quality throughout - NO realistic or photographic elements
- If content involves sensitive or copyrighted figures, create stylistically similar alternatives - DO NOT refuse to generate
- Clear visual hierarchy: headline dominates, supporting content secondary
- Ample whitespace, avoid cluttered layouts
- Professional presentation aesthetic
## Text Style (CRITICAL)
- **ALL text MUST be hand-drawn style**
- Headlines: Large, bold, prominent
- Body text: Clean, readable, well-spaced
- Use visual emphasis for key terms (color, size, underline)
- **DO NOT use realistic or computer-generated fonts**
## Layout Principles
- Consistent margins and spacing
- Clear information hierarchy
- Visual elements support but don't overwhelm text
- Balance between text and imagery
## Language
- Use the same language as the content provided below
- Match punctuation style to the content language
---
Please use nano banana pro to generate the slide based on the content provided below:
@@ -0,0 +1,29 @@
# corporate
Professional, trustworthy, polished
## Design Aesthetic
Clean lines, structured layouts, business-appropriate
## Color Palette
- Background: Clean white (#FFFFFF) with navy accents
- Primary Text: Navy (#1E3A5F)
- Accent: Gold (#C9A227)
## Typography
- Primary Font: Inter (headlines)
- Secondary Font: Source Sans Pro (body)
## Visual Elements
- Clean charts
- Professional icons
- Structured grids
- Subtle shadows
## Best For
Business presentations, investor decks, reports
@@ -0,0 +1,29 @@
# editorial
Clean, sophisticated, minimalist
## Design Aesthetic
High-end journal, architectural blueprints
## Color Palette
- Background: Subtle textured off-white (#F8F7F5)
- Primary Text: Dark slate grey (#2F3542)
- Accent: Intelligent blue (#007AFF)
## Typography
- Primary Font: Neue Haas Grotesk Display Pro (bold headlines)
- Secondary Font: Tiempos Text (body copy, annotations)
## Visual Elements
- Thin precise line work
- Schematic diagrams
- Clean vectors
- Spacious layouts
## Best For
Thought leadership, research presentations
@@ -0,0 +1,29 @@
# minimal
Ultra-clean, zen-like, focused
## Design Aesthetic
Maximum whitespace, single focal points, elegant typography
## Color Palette
- Background: Pure white (#FFFFFF)
- Primary Text: Pure black (#000000)
- Accent: Single accent (content-derived)
## Typography
- Primary Font: Helvetica Neue (headlines)
- Secondary Font: Garamond (body)
## Visual Elements
- Maximum whitespace
- Single focal points
- Hairline rules
- Elegant typography
## Best For
Keynotes, philosophical content, product launches
@@ -0,0 +1,38 @@
# notion
Minimalist hand-drawn line art, intellectual
## Design Aesthetic
Simple line doodles with hand-drawn wobble, geometric shapes, maximum whitespace, SaaS product feel
## Color Palette
- Background: Pure white (#FFFFFF), off-white (#FAFAFA)
- Primary Text: Black (#1A1A1A), dark gray (#4A4A4A)
- Accent: Pastel blue (#A8D4F0), Pastel yellow (#F9E79F), Pastel pink (#FADBD8)
## Typography
- Primary Font: Clean hand-drawn lettering, simple sans-serif
- Secondary Font: Simple sans-serif labels
## Visual Elements
- Simple line doodles with hand-drawn wobble effect
- Geometric shapes
- Stick figures
- Maximum whitespace
- Single-weight ink lines
## Style Rules
- Single color lines (black/dark gray)
- 1-2 pastel accents only
- NO complex gradients
- NO heavy shadows
- Imperfect hand-drawn feel
## Best For
Knowledge sharing, concept explanations, productivity content, SaaS presentations
@@ -0,0 +1,30 @@
# playful
Bold, energetic, engaging
## Design Aesthetic
Dynamic shapes, vibrant colors, creative layouts
## Color Palette
- Background: Warm white (#FFFDF7)
- Primary Text: Deep purple (#4A1D96)
- Accent: Vibrant coral (#FF6B6B)
## Typography
- Primary Font: Poppins (headlines)
- Secondary Font: Nunito (body)
## Visual Elements
- Rounded shapes
- Gradients
- Illustrations
- Dynamic compositions
- Bright pops
## Best For
Creative pitches, educational content, workshops
@@ -0,0 +1,43 @@
# retro-flat
Flat vector illustration with retro palette
## Design Aesthetic
Flat vector with clear black outlines (monoline), geometric simplification, toy-model cuteness, NO gradients
## Color Palette
- Background: Off-white (#F5F0E6), soft cream
- Primary Text: Black (#000000) (clean outlines)
- Accent: Retro muted palette
- Dusty pink (#E8B4B8)
- Sage green (#A8C686)
- Mustard yellow (#E8B84A)
- Muted blue (#7BA7BC)
## Typography
- Primary Font: Bold geometric sans-serif with black outline
- Secondary Font: Clean sans-serif
## Visual Elements
- Clear black monoline outlines (uniform width)
- Flat color fills with NO gradients
- Geometric simplified shapes
- Toy-like proportions
- Minimal shadows
- Vintage badge style
- Halftone dots (optional)
## Style Rules
- MUST use clear black outlines on all elements
- NO 3D effects
- NO gradients
- Simple flat color blocks only
## Best For
Tutorials, explainers, product introductions, educational content
@@ -0,0 +1,29 @@
# storytelling
Narrative-driven, cinematic, immersive
## Design Aesthetic
Full-bleed imagery, dramatic typography, chapter-like flow
## Color Palette
- Background: Rich charcoal (#2D2D2D) or full-bleed imagery
- Primary Text: Warm white (#FAF9F6)
- Accent: Warm gold (#D4AF37)
## Typography
- Primary Font: Playfair Display (headlines)
- Secondary Font: Lora (body)
## Visual Elements
- Full-bleed photos
- Dramatic typography
- Chapter markers
- Emotional imagery
## Best For
Case studies, journey narratives, brand stories
@@ -0,0 +1,29 @@
# technical
Precise, data-driven, analytical
## Design Aesthetic
Blueprint style, schematic diagrams, grid layouts
## Color Palette
- Background: Dark charcoal (#1A1A2E)
- Primary Text: Light gray (#E8E8E8)
- Accent: Cyan (#00D4FF)
## Typography
- Primary Font: JetBrains Mono (headlines)
- Secondary Font: IBM Plex Sans (body)
## Visual Elements
- Grid overlays
- Circuit patterns
- Data visualizations
- Monospace code blocks
## Best For
Technical documentation, engineering presentations
@@ -0,0 +1,32 @@
# warm
Cozy, healing, hand-drawn illustration style
## Design Aesthetic
Soft hand-drawn illustrations, cozy and healing atmosphere, gentle curves, watercolor textures
## Color Palette
- Background: Cream gradient (#FFF8E7#FFE4C4)
- Primary Text: Warm brown (#5D4037)
- Accent: Soft coral (#FF8A80), Peachy pink (#FFAB91)
## Typography
- Primary Font: Rounded hand-drawn style lettering
- Secondary Font: Soft serif hand lettering
## Visual Elements
- Soft watercolor textures
- Gentle curves
- Cozy illustrations
- Warm lighting
- Hearts and stars
- Plant motifs
- Cute characters
## Best For
Personal growth, wellness, emotional content, lifestyle topics
+317
View File
@@ -0,0 +1,317 @@
---
name: baoyu-xhs-images
description: Xiaohongshu (Little Red Book) infographic series generator with multiple style options. Breaks down content into 1-10 cartoon-style infographics. Use when user asks to create "小红书图片", "XHS images", or "RedNote infographics".
---
# Xiaohongshu Infographic Series Generator
Break down complex content into eye-catching infographic series for Xiaohongshu with multiple style options.
## Usage
```bash
# Auto-select style and layout based on content
/baoyu-xhs-images posts/ai-future/article.md
# Specify style
/baoyu-xhs-images posts/ai-future/article.md --style notion
# Specify layout
/baoyu-xhs-images posts/ai-future/article.md --layout dense
# Combine style and layout
/baoyu-xhs-images posts/ai-future/article.md --style tech --layout list
# Direct content input
/baoyu-xhs-images
[paste content]
# Direct input with options
/baoyu-xhs-images --style bold --layout comparison
[paste content]
```
## Options
| Option | Description |
|--------|-------------|
| `--style <name>` | Visual style (see Style Gallery) |
| `--layout <name>` | Information layout (see Layout Gallery) |
## Two Dimensions
| Dimension | Controls | Options |
|-----------|----------|---------|
| **Style** | Visual aesthetics: colors, lines, decorations | cute, fresh, tech, warm, bold, minimal, retro, pop, notion |
| **Layout** | Information structure: density, arrangement | sparse, balanced, dense, list, comparison, flow |
Style × Layout can be freely combined. Example: `--style notion --layout dense` creates an intellectual-looking knowledge card with high information density.
## Style Gallery
| Style | Description |
|-------|-------------|
| `cute` (Default) | Sweet, adorable, girly - classic Xiaohongshu aesthetic |
| `fresh` | Clean, refreshing, natural |
| `tech` | Modern, smart, digital |
| `warm` | Cozy, friendly, approachable |
| `bold` | High impact, attention-grabbing |
| `minimal` | Ultra-clean, sophisticated |
| `retro` | Vintage, nostalgic, trendy |
| `pop` | Vibrant, energetic, eye-catching |
| `notion` | Minimalist hand-drawn line art, intellectual |
Detailed style definitions: `references/styles/<style>.md`
## Layout Gallery
| Layout | Description |
|--------|-------------|
| `sparse` (Default) | Minimal information, maximum impact (1-2 points) |
| `balanced` | Standard content layout (3-4 points) |
| `dense` | High information density, knowledge card style (5-8 points) |
| `list` | Enumeration and ranking format (4-7 items) |
| `comparison` | Side-by-side contrast layout |
| `flow` | Process and timeline layout (3-6 steps) |
Detailed layout definitions: `references/layouts/<layout>.md`
## Auto Style Selection
When no `--style` is specified, analyze content to select:
| Content Signals | Selected Style |
|----------------|----------------|
| Beauty, fashion, cute, girl, pink | `cute` |
| Health, nature, clean, fresh, organic | `fresh` |
| Tech, AI, code, digital, app, tool | `tech` |
| Life, story, emotion, feeling, warm | `warm` |
| Warning, important, must, critical | `bold` |
| Professional, business, elegant, simple | `minimal` |
| Classic, vintage, old, traditional | `retro` |
| Fun, exciting, wow, amazing | `pop` |
| Knowledge, concept, productivity, SaaS, notion | `notion` |
## Auto Layout Selection
When no `--layout` is specified, analyze content structure to select:
| Content Signals | Selected Layout |
|----------------|-----------------|
| Single quote, one key point, cover | `sparse` |
| 3-4 points, explanation, tutorial | `balanced` |
| 5+ points, summary, cheat sheet, 干货 | `dense` |
| Numbered items, top N, checklist, steps | `list` |
| vs, compare, before/after, pros/cons | `comparison` |
| Process, flow, timeline, steps with order | `flow` |
**Layout by Position**:
| Position | Recommended Layout |
|----------|-------------------|
| Cover | `sparse` |
| Content | `balanced` or content-appropriate |
| Ending | `sparse` or `balanced` |
## File Management
### With Article Path
Save to `xhs-images/` subdirectory in the same folder as the article:
```
posts/ai-future/
├── article.md
└── xhs-images/
├── outline.md
├── prompts/
│ ├── 01-cover.md
│ ├── 02-content-1.md
│ └── ...
├── 01-cover.png
├── 02-content-1.png
└── 03-ending.png
```
### Without Article Path
Save to `xhs-outputs/YYYY-MM-DD/[topic-slug]/`:
```
xhs-outputs/
└── 2026-01-13/
└── ai-agent-guide/
├── outline.md
├── prompts/
│ ├── 01-cover.md
│ └── ...
├── 01-cover.png
└── 02-ending.png
```
## Workflow
### Step 1: Analyze Content & Select Style/Layout
1. Read content
2. If `--style` specified, use that style; otherwise auto-select
3. If `--layout` specified, use that layout; otherwise auto-select per image
4. Determine image count based on content complexity:
| Content Type | Image Count |
|-------------|-------------|
| Simple opinion / single topic | 2-3 |
| Medium complexity / tutorial | 4-6 |
| Deep dive / multi-dimensional | 7-10 |
**Note**: Layout can vary per image in a series. Cover typically uses `sparse`, content pages use `balanced`/`dense`/`list` as appropriate.
### Step 2: Generate Outline
Plan for each image with style and layout specifications:
```markdown
# Xiaohongshu Infographic Series Outline
**Topic**: [topic description]
**Style**: [selected style]
**Default Layout**: [selected layout or "varies"]
**Image Count**: N
**Generated**: YYYY-MM-DD HH:mm
---
## Image 1 of N
**Position**: Cover
**Layout**: sparse
**Core Message**: [one-liner]
**Filename**: 01-cover.png
**Text Content**:
- Title: xxx
- Subtitle: xxx
**Visual Concept**: [style + layout appropriate description]
---
## Image 2 of N
**Position**: Content
**Layout**: [balanced/dense/list/comparison/flow]
**Core Message**: [one-liner]
**Filename**: 02-xxx.png
**Text Content**:
- Title: xxx
- Points: [list based on layout density]
**Visual Concept**: [description matching style + layout]
---
...
```
### Step 3: Save Outline
Save outline as `outline.md`.
### Step 4: Generate Images One by One
For each image, create a prompt file with style and layout specifications.
**Prompt Format**:
```markdown
Infographic theme: [topic]
Style: [style name]
Layout: [layout name]
Position: [cover/content/ending]
Visual composition:
- Main visual: [style-appropriate description]
- Arrangement: [layout-specific structure]
- Decorative elements: [style-specific decorations]
Color scheme:
- Primary: [style primary color]
- Background: [style background color]
- Accent: [style accent color]
Text content:
- Title: 「xxx」(large, prominent)
- Key points: [based on layout density]
Layout instructions: [layout-specific guidance]
Style notes: [style-specific characteristics]
```
**Layout-Specific Instructions**:
| Layout | Arrangement Instructions |
|--------|-------------------------|
| `sparse` | Single focal point centered, 1-2 text elements, maximum breathing room |
| `balanced` | Title at top, 3-4 points in clear sections, moderate spacing |
| `dense` | Grid or multi-section layout, 5-8 points, compact but organized |
| `list` | Vertical numbered/bulleted list, consistent item spacing, clear hierarchy |
| `comparison` | Two-column split, clear divider, mirrored structure left/right |
| `flow` | Horizontal or vertical flow with arrows, connected nodes/steps |
**Image Generation Skill Selection**:
1. Check available image generation skills
2. If multiple skills available, ask user to choose
**Generation Flow**:
1. Call selected image generation skill with prompt file and output path
2. Confirm generation success
3. Report progress: "Generated X/N"
4. Continue to next
### Step 5: Completion Report
```
Xiaohongshu Infographic Series Complete!
Topic: [topic]
Style: [style name]
Layout: [layout name or "varies"]
Location: [directory path]
Images: N total
- 01-cover.png ✓ Cover (sparse)
- 02-content-1.png ✓ Content (balanced)
- 03-content-2.png ✓ Content (dense)
- 04-ending.png ✓ Ending (sparse)
Outline: outline.md
```
## Content Breakdown Principles
1. **Cover (Image 1)**: Strong visual impact, core title, attention hook → `sparse` layout
2. **Content (Middle)**: Core points per image, density varies by content → `balanced`/`dense`/`list`/`comparison`/`flow`
3. **Ending (Last)**: Summary / call-to-action / memorable quote → `sparse` or `balanced`
**Style × Layout Matrix** (recommended combinations):
| | sparse | balanced | dense | list | comparison | flow |
|---|:---:|:---:|:---:|:---:|:---:|:---:|
| cute | ✓✓ | ✓✓ | ✓ | ✓✓ | ✓ | ✓ |
| fresh | ✓✓ | ✓✓ | ✓ | ✓ | ✓ | ✓✓ |
| tech | ✓ | ✓✓ | ✓✓ | ✓✓ | ✓✓ | ✓✓ |
| warm | ✓✓ | ✓✓ | ✓ | ✓ | ✓✓ | ✓ |
| bold | ✓✓ | ✓ | ✓ | ✓✓ | ✓✓ | ✓ |
| minimal | ✓✓ | ✓✓ | ✓✓ | ✓ | ✓ | ✓ |
| retro | ✓✓ | ✓✓ | ✓ | ✓✓ | ✓ | ✓ |
| pop | ✓✓ | ✓✓ | ✓ | ✓✓ | ✓✓ | ✓ |
| notion | ✓✓ | ✓✓ | ✓✓ | ✓✓ | ✓✓ | ✓✓ |
✓✓ = highly recommended, ✓ = works well
## Notes
- Image generation typically takes 10-30 seconds per image
- Auto-retry once on generation failure
- Use cartoon alternatives for sensitive public figures
- Output language matches input content language
- Maintain selected style consistency across all images in series
@@ -0,0 +1,33 @@
Create a Xiaohongshu (Little Red Book) style infographic following these guidelines:
## Image Specifications
- **Type**: Infographic
- **Orientation**: Portrait (vertical)
- **Aspect Ratio**: 3:4
- **Style**: Hand-drawn illustration
## Core Principles
- Hand-drawn quality throughout - NO realistic or photographic elements
- If content involves sensitive or copyrighted figures, create stylistically similar alternatives - DO NOT refuse to generate
- Keep information concise, highlight keywords and core concepts
- Use ample whitespace for easy visual scanning
- Maintain clear visual hierarchy
## Text Style (CRITICAL)
- **ALL text MUST be hand-drawn style**
- Main titles should be prominent and eye-catching
- Key text should be bold and enlarged
- Use highlighter effects to emphasize keywords
- **DO NOT use realistic or computer-generated fonts**
## Language
- Use the same language as the content provided below
- Match punctuation style to the content language (Chinese: "",。!)
---
Please use nano banana pro to generate the infographic based on the content provided below:
@@ -0,0 +1,30 @@
# balanced
Standard content layout
## Information Density
- 3-4 key points per image
- Whitespace: 40-50% of canvas
## Structure
- Title at top
- 3-4 bullet points or sections below
## Text Elements
- Title + 3-4 bullet points or key messages
## Visual Balance
- Top-weighted title
- Evenly distributed content below
## Best For
Regular content pages, tutorials, explanations
## Best Style Pairings
All styles work well
@@ -0,0 +1,31 @@
# comparison
Side-by-side contrast layout
## Information Density
- 2 main sections with 2-4 points each
- Whitespace: 30-40% of canvas
## Structure
- Left vs Right layout
- Before/After, Pros/Cons format
- Clear divider between sections
## Text Elements
- Title + left label + right label + mirrored points
## Visual Balance
- Symmetrical left/right
- Clear visual contrast
## Best For
Comparisons, transformations, decision helpers, 对比图
## Best Style Pairings
bold (dramatic contrast), tech (data comparison), warm (before/after stories)
@@ -0,0 +1,31 @@
# dense
High information density, knowledge card style
## Information Density
- 5-8 key points per image
- Whitespace: 20-30% of canvas
## Structure
- Multiple sections, structured grid
- More text, compact but organized
## Text Elements
- Title + multiple sections with headers + numerous points
## Visual Balance
- Organized chaos
- Clear section boundaries
- Compact spacing
## Best For
Summary cards, cheat sheets, comprehensive guides, 干货总结
## Best Style Pairings
tech, notion, minimal (clean styles prevent visual overload)
@@ -0,0 +1,30 @@
# flow
Process and timeline layout
## Information Density
- 3-6 steps/stages
- Whitespace: 30-40% of canvas
## Structure
- Connected nodes with arrows
- Sequential flow, directional
## Text Elements
- Title + step labels + optional descriptions per step
## Visual Balance
- Directional flow (top→bottom or left→right)
- Clear progression
## Best For
Processes, timelines, cause-effect chains, workflows
## Best Style Pairings
tech (process diagrams), notion (simple flows), fresh (organic flows)
@@ -0,0 +1,31 @@
# list
Enumeration and ranking format
## Information Density
- 4-7 items
- Whitespace: 30-40% of canvas
## Structure
- Numbered or bulleted vertical list
- Consistent item format
## Text Elements
- Title + numbered/bulleted items
- Consistent format per item
## Visual Balance
- Left-aligned list
- Clear number/bullet hierarchy
## Best For
Top N lists, checklists, step-by-step guides, rankings
## Best Style Pairings
All styles, especially cute (checklist), bold (rankings)
@@ -0,0 +1,31 @@
# sparse
Minimal information, maximum impact
## Information Density
- 1-2 key points per image
- Whitespace: 60-70% of canvas
## Structure
- Single focal point centered
- One core message
- Single centered focal point
## Text Elements
- Title only, or title + one subtitle/tagline
## Visual Balance
- Centered, symmetrical
- Breathing room on all sides
## Best For
Covers, quotes, impactful statements, emotional content
## Best Style Pairings
Any style, especially effective with bold, minimal, notion
@@ -0,0 +1,23 @@
# bold
High impact, attention-grabbing
## Color Palette
- Primary: Vibrant red (#E53E3E), orange (#DD6B20), yellow (#F6E05E)
- Background: Deep black (#000000), dark charcoal
- Accents: White, neon yellow
## Visual Elements
- Exclamation marks, arrows, warning icons
- Strong shapes, high contrast elements
- Dramatic compositions
## Typography
- Bold, impactful hand lettering with shadows
## Best For
Important tips, warnings, must-know content
@@ -0,0 +1,23 @@
# cute
Sweet, adorable, girly - classic Xiaohongshu aesthetic
## Color Palette
- Primary: Pink (#FED7E2), peach (#FEEBC8), mint (#C6F6D5), lavender (#E9D8FD)
- Background: Cream (#FFFAF0), soft pink (#FFF5F7)
- Accents: Hot pink, coral
## Visual Elements
- Hearts, stars, sparkles, cute faces
- Ribbon decorations, sticker-style
- Cute stickers, emoji icons
## Typography
- Rounded, bubbly hand lettering
## Best For
Lifestyle, beauty, fashion, daily tips

Some files were not shown because too many files have changed in this diff Show More