mirror of
https://github.com/JimLiu/baoyu-skills.git
synced 2026-07-13 22:29:48 +08:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f49acb9983 | |||
| 3fddcc0e6a | |||
| d4070b19d5 | |||
| 6430c67efe | |||
| 73a5f60ad3 | |||
| 7a2f475417 | |||
| bb494f07ba |
@@ -6,7 +6,7 @@
|
|||||||
},
|
},
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"description": "Skills shared by Baoyu for improving daily work efficiency",
|
"description": "Skills shared by Baoyu for improving daily work efficiency",
|
||||||
"version": "0.2.0"
|
"version": "0.3.1"
|
||||||
},
|
},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
{
|
{
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
"./skills/gemini-web",
|
"./skills/gemini-web",
|
||||||
"./skills/xhs-images",
|
"./skills/xhs-images",
|
||||||
"./skills/post-to-x",
|
"./skills/post-to-x",
|
||||||
|
"./skills/post-to-wechat",
|
||||||
"./skills/article-illustrator",
|
"./skills/article-illustrator",
|
||||||
"./skills/cover-image",
|
"./skills/cover-image",
|
||||||
"./skills/slide-deck"
|
"./skills/slide-deck"
|
||||||
|
|||||||
@@ -139,3 +139,4 @@ vite.config.js.timestamp-*
|
|||||||
vite.config.ts.timestamp-*
|
vite.config.ts.timestamp-*
|
||||||
tests-data/
|
tests-data/
|
||||||
|
|
||||||
|
.DS_Store
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# 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/
|
||||||
|
├── gemini-web/ # Core: Gemini API wrapper (text + image gen)
|
||||||
|
├── xhs-images/ # Xiaohongshu infographic series (1-10 images)
|
||||||
|
├── cover-image/ # Article cover images (2.35:1 aspect)
|
||||||
|
├── slide-deck/ # Presentation slides with outlines
|
||||||
|
├── article-illustrator/ # Smart illustration placement
|
||||||
|
└── post-to-x/ # X/Twitter posting automation
|
||||||
|
```
|
||||||
|
|
||||||
|
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/gemini-web/scripts/main.ts "Hello"
|
||||||
|
|
||||||
|
# Image generation
|
||||||
|
npx -y bun skills/gemini-web/scripts/main.ts --prompt "A cat" --image cat.png
|
||||||
|
|
||||||
|
# From prompt files
|
||||||
|
npx -y bun skills/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 `gemini-web` auth and `post-to-x` automation
|
||||||
|
- **No npm packages**: Self-contained TypeScript, no external dependencies
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
`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
|
||||||
|
|
||||||
|
1. Create `skills/<name>/SKILL.md` with YAML front matter
|
||||||
|
2. Add TypeScript in `skills/<name>/scripts/`
|
||||||
|
3. Add prompt templates in `skills/<name>/prompts/` if needed
|
||||||
|
4. Register in `marketplace.json` plugins[0].skills array
|
||||||
|
|
||||||
|
## Code Style
|
||||||
|
|
||||||
|
- TypeScript throughout, no comments
|
||||||
|
- Async/await patterns
|
||||||
|
- Short variable names
|
||||||
|
- Type-safe interfaces
|
||||||
@@ -56,16 +56,37 @@ Interacts with Gemini Web to generate text and images.
|
|||||||
|
|
||||||
### xhs-images
|
### xhs-images
|
||||||
|
|
||||||
Xiaohongshu (RedNote) infographic series generator. Breaks down content into 1-10 cartoon-style infographics.
|
Xiaohongshu (RedNote) infographic series generator. Breaks down content into 1-10 cartoon-style infographics with **Style × Layout** two-dimensional system.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Specify article path
|
# Auto-select style and layout
|
||||||
/xhs-images posts/ai-future/article.md
|
/xhs-images posts/ai-future/article.md
|
||||||
|
|
||||||
|
# Specify style
|
||||||
|
/xhs-images posts/ai-future/article.md --style notion
|
||||||
|
|
||||||
|
# Specify layout
|
||||||
|
/xhs-images posts/ai-future/article.md --layout dense
|
||||||
|
|
||||||
|
# Combine style and layout
|
||||||
|
/xhs-images posts/ai-future/article.md --style tech --layout list
|
||||||
|
|
||||||
# Direct content input
|
# Direct content input
|
||||||
/xhs-images 今日星座运势
|
/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 |
|
||||||
|
|
||||||
### cover-image
|
### cover-image
|
||||||
|
|
||||||
Generate hand-drawn style cover images for articles with multiple style options.
|
Generate hand-drawn style cover images for articles with multiple style options.
|
||||||
@@ -86,7 +107,7 @@ Available styles: `elegant` (default), `tech`, `warm`, `bold`, `minimal`, `playf
|
|||||||
|
|
||||||
### slide-deck
|
### slide-deck
|
||||||
|
|
||||||
Generate professional slide deck outlines from content with comprehensive style instructions.
|
Generate professional slide deck images from content. Creates comprehensive outlines with style instructions, then generates individual slide images.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# From markdown file
|
# From markdown file
|
||||||
@@ -96,11 +117,36 @@ Generate professional slide deck outlines from content with comprehensive style
|
|||||||
/slide-deck path/to/article.md --style corporate
|
/slide-deck path/to/article.md --style corporate
|
||||||
/slide-deck path/to/article.md --audience executives
|
/slide-deck path/to/article.md --audience executives
|
||||||
|
|
||||||
|
# Outline only (no image generation)
|
||||||
|
/slide-deck path/to/article.md --outline-only
|
||||||
|
|
||||||
# With language
|
# With language
|
||||||
/slide-deck path/to/article.md --lang zh
|
/slide-deck path/to/article.md --lang zh
|
||||||
```
|
```
|
||||||
|
|
||||||
Available styles: `editorial` (default), `corporate`, `technical`, `playful`, `minimal`, `storytelling`
|
Available styles: `editorial` (default), `corporate`, `technical`, `playful`, `minimal`, `storytelling`, `warm`, `retro-flat`, `notion`
|
||||||
|
|
||||||
|
### post-to-wechat
|
||||||
|
|
||||||
|
Post content to WeChat Official Account (微信公众号). Two modes available:
|
||||||
|
|
||||||
|
**Image-Text (图文)** - Multiple images with short title/content:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/post-to-wechat 图文 --markdown article.md --images ./photos/
|
||||||
|
/post-to-wechat 图文 --markdown article.md --image img1.png --image img2.png --image img3.png
|
||||||
|
/post-to-wechat 图文 --title "标题" --content "内容" --image img1.png --submit
|
||||||
|
```
|
||||||
|
|
||||||
|
**Article (文章)** - Full markdown/HTML with rich formatting:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/post-to-wechat 文章 --markdown article.md
|
||||||
|
/post-to-wechat 文章 --markdown article.md --theme grace
|
||||||
|
/post-to-wechat 文章 --html article.html
|
||||||
|
```
|
||||||
|
|
||||||
|
Prerequisites: Google Chrome installed. First run requires QR code login (session preserved).
|
||||||
|
|
||||||
## Disclaimer
|
## Disclaimer
|
||||||
|
|
||||||
|
|||||||
+50
-4
@@ -56,16 +56,37 @@
|
|||||||
|
|
||||||
### xhs-images
|
### xhs-images
|
||||||
|
|
||||||
小红书信息图系列生成器。将内容拆解为 1-10 张卡通风格信息图。
|
小红书信息图系列生成器。将内容拆解为 1-10 张卡通风格信息图,支持 **风格 × 布局** 二维系统。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 指定文章路径
|
# 自动选择风格和布局
|
||||||
/xhs-images posts/ai-future/article.md
|
/xhs-images posts/ai-future/article.md
|
||||||
|
|
||||||
|
# 指定风格
|
||||||
|
/xhs-images posts/ai-future/article.md --style notion
|
||||||
|
|
||||||
|
# 指定布局
|
||||||
|
/xhs-images posts/ai-future/article.md --layout dense
|
||||||
|
|
||||||
|
# 组合风格和布局
|
||||||
|
/xhs-images posts/ai-future/article.md --style tech --layout list
|
||||||
|
|
||||||
# 直接输入内容
|
# 直接输入内容
|
||||||
/xhs-images 今日星座运势
|
/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 步 | 流程、时间线 |
|
||||||
|
|
||||||
### cover-image
|
### cover-image
|
||||||
|
|
||||||
为文章生成手绘风格封面图,支持多种风格选项。
|
为文章生成手绘风格封面图,支持多种风格选项。
|
||||||
@@ -86,7 +107,7 @@
|
|||||||
|
|
||||||
### slide-deck
|
### slide-deck
|
||||||
|
|
||||||
从内容生成专业的幻灯片大纲,包含完整的样式说明。
|
从内容生成专业的幻灯片图片。先创建包含样式说明的完整大纲,然后逐页生成幻灯片图片。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 从 markdown 文件生成
|
# 从 markdown 文件生成
|
||||||
@@ -96,11 +117,36 @@
|
|||||||
/slide-deck path/to/article.md --style corporate
|
/slide-deck path/to/article.md --style corporate
|
||||||
/slide-deck path/to/article.md --audience executives
|
/slide-deck path/to/article.md --audience executives
|
||||||
|
|
||||||
|
# 仅生成大纲(不生成图片)
|
||||||
|
/slide-deck path/to/article.md --outline-only
|
||||||
|
|
||||||
# 指定语言
|
# 指定语言
|
||||||
/slide-deck path/to/article.md --lang zh
|
/slide-deck path/to/article.md --lang zh
|
||||||
```
|
```
|
||||||
|
|
||||||
可用风格:`editorial`(默认)、`corporate`、`technical`、`playful`、`minimal`、`storytelling`
|
可用风格:`editorial`(默认)、`corporate`、`technical`、`playful`、`minimal`、`storytelling`、`warm`、`retro-flat`、`notion`
|
||||||
|
|
||||||
|
### post-to-wechat
|
||||||
|
|
||||||
|
发布内容到微信公众号,支持两种模式:
|
||||||
|
|
||||||
|
**图文模式** - 多图配短标题和正文:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/post-to-wechat 图文 --markdown article.md --images ./photos/
|
||||||
|
/post-to-wechat 图文 --markdown article.md --image img1.png --image img2.png --image img3.png
|
||||||
|
/post-to-wechat 图文 --title "标题" --content "内容" --image img1.png --submit
|
||||||
|
```
|
||||||
|
|
||||||
|
**文章模式** - 完整 markdown/HTML 富文本格式:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/post-to-wechat 文章 --markdown article.md
|
||||||
|
/post-to-wechat 文章 --markdown article.md --theme grace
|
||||||
|
/post-to-wechat 文章 --html article.html
|
||||||
|
```
|
||||||
|
|
||||||
|
前置要求:已安装 Google Chrome,首次运行需扫码登录(登录状态会保存)
|
||||||
|
|
||||||
## 免责声明
|
## 免责声明
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ Analyze article structure and content, identify positions requiring visual aids,
|
|||||||
|
|
||||||
## Style Gallery
|
## Style Gallery
|
||||||
|
|
||||||
### 1. `elegant` (Default)
|
### 1. `elegant`
|
||||||
Refined, sophisticated, professional
|
Refined, sophisticated, professional
|
||||||
- **Colors**: Soft coral, muted teal, dusty rose, cream background
|
- **Colors**: Soft coral, muted teal, dusty rose, cream background
|
||||||
- **Elements**: Delicate line work, subtle icons, balanced composition
|
- **Elements**: Delicate line work, subtle icons, balanced composition
|
||||||
@@ -78,6 +78,12 @@ Raw, authentic, notebook-style
|
|||||||
- **Elements**: Rough lines, sketch marks, handwritten notes, arrows
|
- **Elements**: Rough lines, sketch marks, handwritten notes, arrows
|
||||||
- **Best for**: Ideas in progress, brainstorming, thought processes
|
- **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
|
## Auto Style Selection
|
||||||
|
|
||||||
When no `--style` is specified, analyze content to select the best style:
|
When no `--style` is specified, analyze content to select the best style:
|
||||||
@@ -92,6 +98,7 @@ When no `--style` is specified, analyze content to select the best style:
|
|||||||
| Nature, eco, wellness, health, organic, green | `nature` |
|
| Nature, eco, wellness, health, organic, green | `nature` |
|
||||||
| Idea, thought, concept, draft, brainstorm | `sketch` |
|
| Idea, thought, concept, draft, brainstorm | `sketch` |
|
||||||
| Business, professional, strategy, analysis | `elegant` |
|
| Business, professional, strategy, analysis | `elegant` |
|
||||||
|
| Knowledge, concept, productivity, SaaS, notion | `notion` |
|
||||||
|
|
||||||
## File Management
|
## File Management
|
||||||
|
|
||||||
@@ -194,17 +201,16 @@ Style notes: [specific style characteristics]
|
|||||||
|
|
||||||
### Step 5: Generate Images
|
### Step 5: Generate Images
|
||||||
|
|
||||||
For each illustration, call:
|
**Image Generation Skill Selection**:
|
||||||
|
1. Check available image generation skills
|
||||||
|
2. If multiple skills available, ask user to choose
|
||||||
|
|
||||||
```bash
|
**Generation Flow**:
|
||||||
/gemini-web --promptfiles [SKILL_ROOT]/skills/article-illustrator/prompts/system.md [ARTICLE_DIR]/imgs/prompts/illustration-[slug].md --image [ARTICLE_DIR]/imgs/illustration-[slug].png
|
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"
|
||||||
Generation flow:
|
4. On failure, auto-retry once
|
||||||
1. Generate images sequentially
|
5. If retry fails, log reason, continue to next
|
||||||
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: Update Article
|
### Step 6: Update Article
|
||||||
|
|
||||||
@@ -304,6 +310,15 @@ Accents: Single highlight color (blue, red, or yellow)
|
|||||||
Elements: Rough sketch lines, arrows, handwritten labels, crossed-out marks
|
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
|
## Notes
|
||||||
|
|
||||||
- Illustrations serve the content: supplement information, visualize concepts
|
- Illustrations serve the content: supplement information, visualize concepts
|
||||||
|
|||||||
@@ -191,11 +191,12 @@ Note: No title text, pure visual illustration only.
|
|||||||
|
|
||||||
### Step 5: Generate Image
|
### Step 5: Generate Image
|
||||||
|
|
||||||
Call gemini-web to generate the cover:
|
**Image Generation Skill Selection**:
|
||||||
|
1. Check available image generation skills
|
||||||
|
2. If multiple skills available, ask user to choose
|
||||||
|
|
||||||
```bash
|
**Generation**:
|
||||||
/gemini-web --promptfiles [SKILL_ROOT]/skills/cover-image/prompts/system.md [PROMPT_PATH] --image [OUTPUT_PATH]
|
Call selected image generation skill with prompt file and output path.
|
||||||
```
|
|
||||||
|
|
||||||
### Step 6: Output Summary
|
### Step 6: Output Summary
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
---
|
||||||
|
name: 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.
|
||||||
|
|
||||||
|
## Quick Usage
|
||||||
|
|
||||||
|
### Image-Text (图文) - Multiple images with title/content
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# From markdown file and image directory
|
||||||
|
npx -y bun ./scripts/wechat-browser.ts --markdown article.md --images ./images/
|
||||||
|
|
||||||
|
# With explicit parameters
|
||||||
|
npx -y bun ./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 ./scripts/wechat-article.ts --markdown article.md --theme grace
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
- 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 |
|
||||||
@@ -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: <,,>
|
||||||
|
*/
|
||||||
|
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, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/`/g, "`");
|
||||||
|
}
|
||||||
|
|
||||||
|
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, """);
|
||||||
|
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/>,并将空格转换为
|
||||||
|
formatted = formatted.replace(/\r\n/g, `<br/>`).replace(/\n/g, `<br/>`).replace(/(>[^<]+)|(^[^<]+)/g, (str: string) => str.replace(/\s/g, ` `))
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// 只将空格转换为
|
||||||
|
formatted = formatted.replace(/(>[^<]+)|(^[^<]+)/g, (str: string) => str.replace(/\s/g, ` `))
|
||||||
|
}
|
||||||
|
|
||||||
|
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 === `` ? ` ` : 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(/"/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,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);
|
||||||
|
});
|
||||||
@@ -82,6 +82,24 @@ Narrative-driven, cinematic, immersive
|
|||||||
- **Colors**: Rich charcoal (#2D2D2D), warm white (#FAF9F6), gold (#D4AF37)
|
- **Colors**: Rich charcoal (#2D2D2D), warm white (#FAF9F6), gold (#D4AF37)
|
||||||
- **Best for**: Case studies, journey narratives, brand stories
|
- **Best for**: Case studies, journey narratives, brand stories
|
||||||
|
|
||||||
|
### 7. `warm`
|
||||||
|
Cozy, healing, hand-drawn illustration style
|
||||||
|
- **Aesthetic**: Soft hand-drawn illustrations, cozy and healing atmosphere, gentle curves, watercolor textures
|
||||||
|
- **Colors**: Cream gradient background (#FFF8E7 → #FFE4C4), warm brown text (#5D4037), soft coral accents (#FF8A80)
|
||||||
|
- **Best for**: Personal growth, wellness, emotional content, lifestyle topics
|
||||||
|
|
||||||
|
### 8. `retro-flat`
|
||||||
|
Flat vector illustration with retro palette
|
||||||
|
- **Aesthetic**: Flat vector with clear black outlines (monoline), geometric simplification, toy-model cuteness, NO gradients
|
||||||
|
- **Colors**: Retro muted palette - dusty pink (#E8B4B8), sage green (#A8C686), mustard yellow (#E8B84A), off-white background (#F5F0E6)
|
||||||
|
- **Best for**: Tutorials, explainers, product introductions, educational content
|
||||||
|
|
||||||
|
### 9. `notion`
|
||||||
|
Minimalist hand-drawn line art, intellectual
|
||||||
|
- **Aesthetic**: Simple line doodles with hand-drawn wobble, geometric shapes, maximum whitespace, SaaS product feel
|
||||||
|
- **Colors**: Black outlines (#1A1A1A), white background (#FFFFFF), 1-2 pastel accents (blue #A8D4F0, yellow #F9E79F)
|
||||||
|
- **Best for**: Knowledge sharing, concept explanations, productivity content, SaaS presentations
|
||||||
|
|
||||||
## Audience Presets
|
## Audience Presets
|
||||||
|
|
||||||
| Audience | Approach |
|
| Audience | Approach |
|
||||||
@@ -138,6 +156,9 @@ path/to/
|
|||||||
| Simple, zen, focus, essential, one idea | `minimal` |
|
| Simple, zen, focus, essential, one idea | `minimal` |
|
||||||
| Fun, creative, workshop, educational | `playful` |
|
| Fun, creative, workshop, educational | `playful` |
|
||||||
| Research, analysis, thought leadership | `editorial` |
|
| 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` |
|
||||||
|
|
||||||
4. Extract key information:
|
4. Extract key information:
|
||||||
- Core narrative and key messages
|
- Core narrative and key messages
|
||||||
@@ -352,6 +373,41 @@ Primary Accent Color: Warm gold #D4AF37
|
|||||||
Visual Elements: Full-bleed photos, dramatic typography, chapter markers, emotional imagery
|
Visual Elements: Full-bleed photos, dramatic typography, chapter markers, emotional imagery
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### warm
|
||||||
|
```
|
||||||
|
Design Aesthetic: Cozy healing hand-drawn illustration style, soft sketch lines, warm and comforting atmosphere
|
||||||
|
Background Color: Cream gradient #FFF8E7 → #FFE4C4
|
||||||
|
Primary Font: Rounded hand-drawn style lettering
|
||||||
|
Secondary Font: Soft serif hand lettering
|
||||||
|
Primary Text Color: Warm brown #5D4037
|
||||||
|
Primary Accent Color: Soft coral #FF8A80, Peachy pink #FFAB91
|
||||||
|
Visual Elements: Soft watercolor textures, gentle curves, cozy illustrations, warm lighting, hearts and stars, plant motifs, cute characters
|
||||||
|
```
|
||||||
|
|
||||||
|
### retro-flat
|
||||||
|
```
|
||||||
|
Design Aesthetic: Flat vector illustration with clear uniform-width black outlines, geometric simplification, toy-model cuteness
|
||||||
|
Background Color: Off-white #F5F0E6, soft cream
|
||||||
|
Primary Font: Bold geometric sans-serif with black outline
|
||||||
|
Secondary Font: Clean sans-serif
|
||||||
|
Primary Text Color: Black #000000 (clean outlines)
|
||||||
|
Primary Accent Color: Retro muted palette - Dusty pink #E8B4B8, Sage green #A8C686, Mustard yellow #E8B84A, Muted blue #7BA7BC
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
### notion
|
||||||
|
```
|
||||||
|
Design Aesthetic: Minimalist hand-drawn line art with intellectual SaaS product feel, Notion-style doodles
|
||||||
|
Background Color: Pure white #FFFFFF, off-white #FAFAFA
|
||||||
|
Primary Font: Clean hand-drawn lettering, simple sans-serif
|
||||||
|
Secondary Font: Simple sans-serif labels
|
||||||
|
Primary Text Color: Black #1A1A1A, dark gray #4A4A4A
|
||||||
|
Primary Accent Color: Pastel blue #A8D4F0, Pastel yellow #F9E79F, Pastel pink #FADBD8
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
### Design Philosophy
|
### Design Philosophy
|
||||||
|
|||||||
+234
-32
@@ -10,20 +10,24 @@ Break down complex content into eye-catching infographic series for Xiaohongshu
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Auto-select style based on content
|
# Auto-select style and layout based on content
|
||||||
/xhs-images posts/ai-future/article.md
|
/xhs-images posts/ai-future/article.md
|
||||||
|
|
||||||
# Specify a style
|
# Specify style
|
||||||
/xhs-images posts/ai-future/article.md --style cute
|
/xhs-images posts/ai-future/article.md --style notion
|
||||||
/xhs-images posts/ai-future/article.md --style tech
|
|
||||||
/xhs-images posts/ai-future/article.md --style minimal
|
# Specify layout
|
||||||
|
/xhs-images posts/ai-future/article.md --layout dense
|
||||||
|
|
||||||
|
# Combine style and layout
|
||||||
|
/xhs-images posts/ai-future/article.md --style tech --layout list
|
||||||
|
|
||||||
# Direct content input
|
# Direct content input
|
||||||
/xhs-images
|
/xhs-images
|
||||||
[paste content]
|
[paste content]
|
||||||
|
|
||||||
# Direct input with style
|
# Direct input with options
|
||||||
/xhs-images --style bold
|
/xhs-images --style bold --layout comparison
|
||||||
[paste content]
|
[paste content]
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -31,7 +35,17 @@ Break down complex content into eye-catching infographic series for Xiaohongshu
|
|||||||
|
|
||||||
| Option | Description |
|
| Option | Description |
|
||||||
|--------|-------------|
|
|--------|-------------|
|
||||||
| `--style <name>` | Specify infographic style (see Style Gallery below) |
|
| `--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 Gallery
|
||||||
|
|
||||||
@@ -83,6 +97,56 @@ Vibrant, energetic, eye-catching
|
|||||||
- **Elements**: Bold shapes, comic-style elements, dynamic compositions
|
- **Elements**: Bold shapes, comic-style elements, dynamic compositions
|
||||||
- **Best for**: Exciting announcements, fun facts, engaging tutorials
|
- **Best for**: Exciting announcements, fun facts, engaging tutorials
|
||||||
|
|
||||||
|
### 9. `notion`
|
||||||
|
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 tips
|
||||||
|
|
||||||
|
## Layout Gallery
|
||||||
|
|
||||||
|
### 1. `sparse` (Default)
|
||||||
|
Minimal information, maximum impact
|
||||||
|
- **Density**: 1-2 key points per image
|
||||||
|
- **Whitespace**: 60-70% of canvas
|
||||||
|
- **Structure**: Single focal point, one core message
|
||||||
|
- **Best for**: Covers, quotes, impactful statements, emotional content
|
||||||
|
|
||||||
|
### 2. `balanced`
|
||||||
|
Standard content layout
|
||||||
|
- **Density**: 3-4 key points per image
|
||||||
|
- **Whitespace**: 40-50% of canvas
|
||||||
|
- **Structure**: Title + 3-4 bullet points or sections
|
||||||
|
- **Best for**: Regular content pages, tutorials, explanations
|
||||||
|
|
||||||
|
### 3. `dense`
|
||||||
|
High information density, knowledge card style
|
||||||
|
- **Density**: 5-8 key points per image
|
||||||
|
- **Whitespace**: 20-30% of canvas
|
||||||
|
- **Structure**: Multiple sections, structured grid, more text
|
||||||
|
- **Best for**: Summary cards, cheat sheets, comprehensive guides, 干货总结
|
||||||
|
|
||||||
|
### 4. `list`
|
||||||
|
Enumeration and ranking format
|
||||||
|
- **Density**: 4-7 items
|
||||||
|
- **Whitespace**: 30-40% of canvas
|
||||||
|
- **Structure**: Numbered or bulleted vertical list, consistent item format
|
||||||
|
- **Best for**: Top N lists, checklists, step-by-step guides, rankings
|
||||||
|
|
||||||
|
### 5. `comparison`
|
||||||
|
Side-by-side contrast layout
|
||||||
|
- **Density**: 2 main sections with 2-4 points each
|
||||||
|
- **Whitespace**: 30-40% of canvas
|
||||||
|
- **Structure**: Left vs Right, Before/After, Pros/Cons
|
||||||
|
- **Best for**: Comparisons, transformations, decision helpers, 对比图
|
||||||
|
|
||||||
|
### 6. `flow`
|
||||||
|
Process and timeline layout
|
||||||
|
- **Density**: 3-6 steps/stages
|
||||||
|
- **Whitespace**: 30-40% of canvas
|
||||||
|
- **Structure**: Connected nodes with arrows, sequential flow
|
||||||
|
- **Best for**: Processes, timelines, cause-effect chains, workflows
|
||||||
|
|
||||||
## Auto Style Selection
|
## Auto Style Selection
|
||||||
|
|
||||||
When no `--style` is specified, analyze content to select:
|
When no `--style` is specified, analyze content to select:
|
||||||
@@ -97,6 +161,27 @@ When no `--style` is specified, analyze content to select:
|
|||||||
| Professional, business, elegant, simple | `minimal` |
|
| Professional, business, elegant, simple | `minimal` |
|
||||||
| Classic, vintage, old, traditional | `retro` |
|
| Classic, vintage, old, traditional | `retro` |
|
||||||
| Fun, exciting, wow, amazing | `pop` |
|
| 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
|
## File Management
|
||||||
|
|
||||||
@@ -136,11 +221,11 @@ xhs-outputs/
|
|||||||
|
|
||||||
## Workflow
|
## Workflow
|
||||||
|
|
||||||
### Step 1: Analyze Content & Select Style
|
### Step 1: Analyze Content & Select Style/Layout
|
||||||
|
|
||||||
1. Read content
|
1. Read content
|
||||||
2. If `--style` specified, use that style
|
2. If `--style` specified, use that style; otherwise auto-select
|
||||||
3. Otherwise, scan for style signals and auto-select
|
3. If `--layout` specified, use that layout; otherwise auto-select per image
|
||||||
4. Determine image count based on content complexity:
|
4. Determine image count based on content complexity:
|
||||||
|
|
||||||
| Content Type | Image Count |
|
| Content Type | Image Count |
|
||||||
@@ -149,15 +234,18 @@ xhs-outputs/
|
|||||||
| Medium complexity / tutorial | 4-6 |
|
| Medium complexity / tutorial | 4-6 |
|
||||||
| Deep dive / multi-dimensional | 7-10 |
|
| 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
|
### Step 2: Generate Outline
|
||||||
|
|
||||||
Plan for each image with style-specific approach:
|
Plan for each image with style and layout specifications:
|
||||||
|
|
||||||
```markdown
|
```markdown
|
||||||
# Xiaohongshu Infographic Series Outline
|
# Xiaohongshu Infographic Series Outline
|
||||||
|
|
||||||
**Topic**: [topic description]
|
**Topic**: [topic description]
|
||||||
**Style**: [selected style]
|
**Style**: [selected style]
|
||||||
|
**Default Layout**: [selected layout or "varies"]
|
||||||
**Image Count**: N
|
**Image Count**: N
|
||||||
**Generated**: YYYY-MM-DD HH:mm
|
**Generated**: YYYY-MM-DD HH:mm
|
||||||
|
|
||||||
@@ -166,6 +254,7 @@ Plan for each image with style-specific approach:
|
|||||||
## Image 1 of N
|
## Image 1 of N
|
||||||
|
|
||||||
**Position**: Cover
|
**Position**: Cover
|
||||||
|
**Layout**: sparse
|
||||||
**Core Message**: [one-liner]
|
**Core Message**: [one-liner]
|
||||||
**Filename**: 01-cover.png
|
**Filename**: 01-cover.png
|
||||||
|
|
||||||
@@ -173,11 +262,24 @@ Plan for each image with style-specific approach:
|
|||||||
- Title: xxx
|
- Title: xxx
|
||||||
- Subtitle: xxx
|
- Subtitle: xxx
|
||||||
|
|
||||||
**Visual Concept**: [style-appropriate description]
|
**Visual Concept**: [style + layout appropriate description]
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Image 2 of N
|
## 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]
|
||||||
|
|
||||||
|
---
|
||||||
...
|
...
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -187,18 +289,19 @@ Save outline as `outline.md`.
|
|||||||
|
|
||||||
### Step 4: Generate Images One by One
|
### Step 4: Generate Images One by One
|
||||||
|
|
||||||
For each image, create a style-specific prompt file.
|
For each image, create a prompt file with style and layout specifications.
|
||||||
|
|
||||||
**Prompt Format**:
|
**Prompt Format**:
|
||||||
|
|
||||||
```markdown
|
```markdown
|
||||||
Infographic theme: [topic]
|
Infographic theme: [topic]
|
||||||
Style: [style name]
|
Style: [style name]
|
||||||
|
Layout: [layout name]
|
||||||
Position: [cover/content/ending]
|
Position: [cover/content/ending]
|
||||||
|
|
||||||
Visual composition:
|
Visual composition:
|
||||||
- Main visual: [style-appropriate description]
|
- Main visual: [style-appropriate description]
|
||||||
- Layout: [element positioning for 3:4 vertical format]
|
- Arrangement: [layout-specific structure]
|
||||||
- Decorative elements: [style-specific decorations]
|
- Decorative elements: [style-specific decorations]
|
||||||
|
|
||||||
Color scheme:
|
Color scheme:
|
||||||
@@ -208,21 +311,32 @@ Color scheme:
|
|||||||
|
|
||||||
Text content:
|
Text content:
|
||||||
- Title: 「xxx」(large, prominent)
|
- Title: 「xxx」(large, prominent)
|
||||||
- Key points: 「xxx」「xxx」
|
- Key points: [based on layout density]
|
||||||
|
|
||||||
Style notes: [specific style characteristics]
|
Layout instructions: [layout-specific guidance]
|
||||||
|
Style notes: [style-specific characteristics]
|
||||||
```
|
```
|
||||||
|
|
||||||
Then generate using:
|
**Layout-Specific Instructions**:
|
||||||
|
|
||||||
```bash
|
| Layout | Arrangement Instructions |
|
||||||
/gemini-web --promptfiles [SKILL_ROOT]/skills/xhs-images/prompts/system.md [TARGET_DIR]/prompts/01-cover.md --image [TARGET_DIR]/01-cover.png
|
|--------|-------------------------|
|
||||||
```
|
| `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 |
|
||||||
|
|
||||||
After each image:
|
**Image Generation Skill Selection**:
|
||||||
1. Confirm generation success
|
1. Check available image generation skills
|
||||||
2. Report progress: "Generated X/N"
|
2. If multiple skills available, ask user to choose
|
||||||
3. Continue to next
|
|
||||||
|
**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
|
### Step 5: Completion Report
|
||||||
|
|
||||||
@@ -231,13 +345,14 @@ Xiaohongshu Infographic Series Complete!
|
|||||||
|
|
||||||
Topic: [topic]
|
Topic: [topic]
|
||||||
Style: [style name]
|
Style: [style name]
|
||||||
|
Layout: [layout name or "varies"]
|
||||||
Location: [directory path]
|
Location: [directory path]
|
||||||
Images: N total
|
Images: N total
|
||||||
|
|
||||||
- 01-cover.png ✓ Cover
|
- 01-cover.png ✓ Cover (sparse)
|
||||||
- 02-content-1.png ✓ Content
|
- 02-content-1.png ✓ Content (balanced)
|
||||||
- 03-content-2.png ✓ Content
|
- 03-content-2.png ✓ Content (dense)
|
||||||
- 04-ending.png ✓ Ending
|
- 04-ending.png ✓ Ending (sparse)
|
||||||
|
|
||||||
Outline: outline.md
|
Outline: outline.md
|
||||||
```
|
```
|
||||||
@@ -316,11 +431,98 @@ Elements: Bold shapes, speech bubbles, comic-style effects, starburst
|
|||||||
Typography: Dynamic, energetic hand lettering with outlines
|
Typography: Dynamic, energetic hand lettering with outlines
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 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
|
||||||
|
```
|
||||||
|
|
||||||
|
## Layout Reference Details
|
||||||
|
|
||||||
|
### sparse
|
||||||
|
```
|
||||||
|
Information Density: Very Low (1-2 points)
|
||||||
|
Whitespace: 60-70%
|
||||||
|
Structure: Single centered focal point
|
||||||
|
Text Elements: Title only, or title + one subtitle/tagline
|
||||||
|
Visual Balance: Centered, symmetrical, breathing room on all sides
|
||||||
|
Best Pairing: Any style, especially effective with bold, minimal, notion
|
||||||
|
```
|
||||||
|
|
||||||
|
### balanced
|
||||||
|
```
|
||||||
|
Information Density: Medium (3-4 points)
|
||||||
|
Whitespace: 40-50%
|
||||||
|
Structure: Title at top, content sections below
|
||||||
|
Text Elements: Title + 3-4 bullet points or key messages
|
||||||
|
Visual Balance: Top-weighted title, evenly distributed content below
|
||||||
|
Best Pairing: All styles work well
|
||||||
|
```
|
||||||
|
|
||||||
|
### dense
|
||||||
|
```
|
||||||
|
Information Density: High (5-8 points)
|
||||||
|
Whitespace: 20-30%
|
||||||
|
Structure: Multi-section grid or stacked blocks
|
||||||
|
Text Elements: Title + multiple sections with headers + numerous points
|
||||||
|
Visual Balance: Organized chaos, clear section boundaries, compact spacing
|
||||||
|
Best Pairing: tech, notion, minimal (clean styles prevent visual overload)
|
||||||
|
```
|
||||||
|
|
||||||
|
### list
|
||||||
|
```
|
||||||
|
Information Density: Medium-High (4-7 items)
|
||||||
|
Whitespace: 30-40%
|
||||||
|
Structure: Vertical enumeration with numbers or bullets
|
||||||
|
Text Elements: Title + numbered/bulleted items, consistent format per item
|
||||||
|
Visual Balance: Left-aligned list, clear number/bullet hierarchy
|
||||||
|
Best Pairing: All styles, especially cute (checklist), bold (rankings)
|
||||||
|
```
|
||||||
|
|
||||||
|
### comparison
|
||||||
|
```
|
||||||
|
Information Density: Medium (2×2-4 points)
|
||||||
|
Whitespace: 30-40%
|
||||||
|
Structure: Two-column split with center divider
|
||||||
|
Text Elements: Title + left label + right label + mirrored points
|
||||||
|
Visual Balance: Symmetrical left/right, clear visual contrast
|
||||||
|
Best Pairing: bold (dramatic contrast), tech (data comparison), warm (before/after stories)
|
||||||
|
```
|
||||||
|
|
||||||
|
### flow
|
||||||
|
```
|
||||||
|
Information Density: Medium (3-6 steps)
|
||||||
|
Whitespace: 30-40%
|
||||||
|
Structure: Connected nodes with directional arrows
|
||||||
|
Text Elements: Title + step labels + optional descriptions per step
|
||||||
|
Visual Balance: Directional flow (top→bottom or left→right), clear progression
|
||||||
|
Best Pairing: tech (process diagrams), notion (simple flows), fresh (organic flows)
|
||||||
|
```
|
||||||
|
|
||||||
## Content Breakdown Principles
|
## Content Breakdown Principles
|
||||||
|
|
||||||
1. **Cover (Image 1)**: Strong visual impact, core title, attention hook
|
1. **Cover (Image 1)**: Strong visual impact, core title, attention hook → `sparse` layout
|
||||||
2. **Content (Middle)**: One core point per image, moderate information density
|
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
|
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
|
## Notes
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user