Compare commits

..

72 Commits

Author SHA1 Message Date
Jim Liu 宝玉 b53a5db60a chore: release v1.30.2 2026-02-06 16:13:51 -06:00
Jim Liu 宝玉 372e03cef3 refactor(baoyu-cover-image): optimize SKILL.md from 532 to 233 lines 2026-02-06 16:13:47 -06:00
Jim Liu 宝玉 ea8ad82786 chore: release v1.30.1 2026-02-06 16:07:34 -06:00
Jim Liu 宝玉 6fffe252c0 fix(baoyu-cover-image): enhance reference image analysis and prompt generation
- Add deep analysis template for extracting specific visual elements
- Require MUST INCORPORATE section with concrete, reproducible details
- Add integration approach for spatial layout instructions
- Clarify ref-capable backend requirements (Google/OpenAI)
2026-02-06 16:07:09 -06:00
Jim Liu 宝玉 86a84739e8 feat(baoyu-image-gen): add OpenAI GPT Image edits support for reference images
- Support --ref with OpenAI GPT Image models (gpt-image-1.5)
- Auto-select Google or OpenAI when --ref provided
- Change ref-related warnings to explicit errors with fix hints
- Add reference image validation before generation
- Improve retry logic to skip non-retryable errors
2026-02-06 16:06:58 -06:00
Jim Liu 宝玉 7f80100b3e chore: release v1.30.0 2026-02-06 14:56:00 -06:00
Jim Liu 宝玉 61960961ba feat(baoyu-cover-image): add font dimension with 4 styles
Add 6th dimension for typography control:
- clean: modern geometric sans-serif
- handwritten: warm hand-lettered style
- serif: classic elegant typography
- display: bold decorative headlines

Includes auto-selection rules, compatibility matrix, and warm-flat preset.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 14:55:11 -06:00
Jim Liu 宝玉 cdd9ee042d chore: release v1.29.0 2026-02-06 13:36:08 -06:00
Jim Liu 宝玉 c742bfa1af fix(baoyu-url-to-markdown): improve html extraction and markdown conversion 2026-02-06 13:35:28 -06:00
Jim Liu 宝玉 0faea4ecaa Merge pull request #38 from kingdomad/feature/baoyu-image-gen-extend-config
add EXTEND.md configuration support
2026-02-05 23:10:11 -06:00
Jim Liu 宝玉 7c9ec259a1 Merge pull request #36 from NantesCheval/fix/wechat-title-and-list-duplication
fix(baoyu-post-to-wechat): fix title and list number duplication in WeChat articles
2026-02-05 23:09:17 -06:00
史提芬达 24a17709a8 add EXTEND.md configuration support 2026-02-05 19:46:22 +08:00
AlexCheval a3849af0cf fix(baoyu-post-to-wechat): fix title and list number duplication in WeChat articles
- Remove title from body content when extracting it for WeChat title field
  to prevent duplicate title display (one in header, one in content)
- Remove manual ordered list prefix since HTML <ol> already provides numbering,
  preventing "1.1.", "2.2.", "3.3." duplication

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 10:04:34 +08:00
Jim Liu 宝玉 cc95e6fe05 chore: release v1.28.4 2026-02-03 11:49:41 -06:00
Jim Liu 宝玉 e7d9ed7917 fix(baoyu-post-to-wechat): remove extra empty lines after image paste and fix summary timing
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 11:49:13 -06:00
Jim Liu 宝玉 876c6332f6 feat(baoyu-markdown-to-html): add HTML meta tags and quote stripping for frontmatter
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 11:49:07 -06:00
Jim Liu 宝玉 502d2448b2 chore: release v1.28.3 2026-02-03 11:06:59 -06:00
Jim Liu 宝玉 b9e48d9483 fix(baoyu-post-to-wechat): fix placeholder matching to avoid WECHATIMGPH_1 matching WECHATIMGPH_10 2026-02-03 11:06:41 -06:00
Jim Liu 宝玉 b0554f8d0c chore: release v1.28.2 2026-02-03 11:01:25 -06:00
Jim Liu 宝玉 12f3d95639 fix(baoyu-post-to-x): improve Chrome reuse and placeholder matching in x-article 2026-02-03 11:01:09 -06:00
Jim Liu 宝玉 809fb29ca9 chore: release v1.28.1 2026-02-02 09:11:49 -06:00
Jim Liu 宝玉 be20bdf0be refactor(baoyu-article-illustrator): simplify SKILL.md and add Core Styles tier
- Extract detailed workflow to references/workflow.md
- Add Core Styles for quick selection (vector, minimal-flat, sci-fi, hand-drawn, editorial, scene)
- Add vector-illustration as recommended default style
- Add Illustration Purpose (information/visualization/imagination)
- Add default composition, character rendering, text styling to prompts
2026-02-02 09:11:43 -06:00
Jim Liu 宝玉 7c36b2a6a5 chore: release v1.28.0 2026-02-01 19:49:49 -06:00
Jim Liu 宝玉 7979cb9dde chore: ignore baoyu-skill-evolution directory 2026-02-01 19:49:22 -06:00
Jim Liu 宝玉 e563bbae7b feat(baoyu-post-to-wechat): add newspic article type support
- Add ArticleOptions interface with news/newspic types
- Track all image media IDs for newspic format
- Restructure publishToDraft for dual article type handling
2026-02-01 19:49:14 -06:00
Jim Liu 宝玉 c63d1eec69 refactor(baoyu-xhs-images): enforce blocking first-time setup
- Mark preferences loading as BLOCKING operation
- Update workflow flow diagram and checklist
2026-02-01 19:49:05 -06:00
Jim Liu 宝玉 78d6d23359 refactor(baoyu-comic): enforce blocking first-time setup
- Mark preferences loading as BLOCKING operation
- Update workflow flow diagram
2026-02-01 19:48:56 -06:00
Jim Liu 宝玉 ec6781a231 feat(baoyu-article-illustrator): add reference image support
- Add reference image handling with direct/style/palette usage types
- Enhance first-time setup as blocking operation
- Update prompt construction with reference frontmatter format
2026-02-01 19:48:46 -06:00
Jim Liu 宝玉 edf9030675 feat(baoyu-cover-image): add reference image support and visual elements library
- Add --ref parameter for reference images (style/palette/direct usage)
- Add visual-elements.md with icon vocabulary by topic
- Enhance first-time setup as blocking operation
- Remove character limits from titles, use original source titles
- Update prompt template with reference handling
2026-02-01 19:48:37 -06:00
Jim Liu 宝玉 10e1c426a0 chore: release v1.27.0 2026-02-01 02:15:08 -06:00
Jim Liu 宝玉 dbab83ff82 feat(baoyu-markdown-to-html): add remark-cjk-friendly for CJK emphasis
Preprocess markdown with remark-cjk-friendly to properly handle
CJK emphasis markers before HTML conversion.
2026-02-01 02:14:12 -06:00
Jim Liu 宝玉 478e66a93a docs(baoyu-infographic): clarify AskUserQuestion usage
Emphasize combining multiple questions into single call.
2026-02-01 02:14:06 -06:00
Jim Liu 宝玉 3a5866eb4b refactor(baoyu-format-markdown): use remark-cjk-friendly for CJK emphasis
Replace custom CJK emphasis handling with remark-cjk-friendly library,
significantly simplifying the codebase.
2026-02-01 02:13:58 -06:00
Jim Liu 宝玉 c0162bb3af chore: release v1.26.1 2026-01-29 22:02:42 -06:00
Jim Liu 宝玉 e9342a16f8 fix(baoyu-xhs-images): remove notebook style, add backup rules for prompts and images 2026-01-29 22:01:47 -06:00
Jim Liu 宝玉 5cb86d8e51 feat(baoyu-slide-deck): add backup rules for source, prompt, and slide image files 2026-01-29 22:01:42 -06:00
Jim Liu 宝玉 66ed08f010 feat(baoyu-infographic): add backup rules for source, analysis, prompt, and image files 2026-01-29 22:01:22 -06:00
Jim Liu 宝玉 b179166d28 feat(baoyu-cover-image): add backup rules for source and prompt files 2026-01-29 22:01:17 -06:00
Jim Liu 宝玉 4805526649 feat(baoyu-comic): add backup rules for source, character sheet, prompts, and page images 2026-01-29 22:00:59 -06:00
Jim Liu 宝玉 89159bf86e feat(baoyu-article-illustrator): add backup rules for source, prompt, and image files 2026-01-29 22:00:37 -06:00
Jim Liu 宝玉 b45b5d2c6e chore: release v1.26.0 2026-01-29 20:59:45 -06:00
Jim Liu 宝玉 62866426c5 feat(baoyu-xhs-images): add notebook and study-notes styles with mindmap and quadrant layouts
- notebook: hand-drawn infographic style with watercolor rendering and Morandi palette
- study-notes: realistic handwritten photo style with blue pen, red annotations, yellow highlighter
- mindmap: center radial layout (4-8 branches)
- quadrant: four-quadrant / circular section layout
2026-01-29 20:59:24 -06:00
Jim Liu 宝玉 28a7db6129 chore: release v1.25.4 2026-01-29 17:17:58 -06:00
Jim Liu 宝玉 7c5e81fd15 chore: release v1.25.3 2026-01-28 22:31:47 -06:00
Jim Liu 宝玉 058d25d02c feat(baoyu-format-markdown): add content type detection and CJK punctuation handling 2026-01-28 22:31:25 -06:00
Jim Liu 宝玉 fe09ec546f chore: release v1.25.2 2026-01-28 22:10:32 -06:00
Jim Liu 宝玉 cf611a4514 docs(baoyu-post-to-wechat): add WeChat API credentials configuration guide 2026-01-28 22:10:27 -06:00
Jim Liu 宝玉 7cf30e969f chore: release v1.25.1 2026-01-28 21:49:51 -06:00
Jim Liu 宝玉 18b71157db feat(baoyu-markdown-to-html): add CJK content pre-check step
Suggest formatting with baoyu-format-markdown before conversion
to fix bold markers with punctuation issues.
2026-01-28 21:49:47 -06:00
Jim Liu 宝玉 9904c490e4 chore: release v1.25.0 2026-01-28 21:38:06 -06:00
Jim Liu 宝玉 32ed09128c docs: add baoyu-format-markdown and baoyu-markdown-to-html to plugin registry 2026-01-28 21:37:49 -06:00
Jim Liu 宝玉 81e96ee43c feat(baoyu-post-to-wechat): add API publishing and external theme support
- Add wechat-api.ts for direct API-based article publishing
- Support plain text, HTML, and markdown inputs with auto-conversion
- Add external theme discovery from MD_THEME_DIR environment variable
- Improve CSS inlining with juice library integration
- Support WeChat API credentials via .baoyu-skills/.env
2026-01-28 21:37:40 -06:00
Jim Liu 宝玉 5f994589b3 feat(baoyu-markdown-to-html): add markdown to HTML converter skill
Converts markdown to styled HTML with inline CSS, code highlighting,
math, PlantUML, footnotes, alerts, and WeChat-compatible themes.
2026-01-28 21:37:30 -06:00
Jim Liu 宝玉 40363194be feat(baoyu-format-markdown): add markdown formatter skill
Formats plain text or markdown with frontmatter, titles, summaries,
headings, bold, lists, code blocks, and CJK typography fixes.
2026-01-28 21:37:23 -06:00
Jim Liu 宝玉 21a5167b61 chore: release v1.24.4 2026-01-28 14:54:44 -06:00
Jim Liu 宝玉 4d3957ca06 fix(baoyu-post-to-x): ensure Apply button click closes modal before continuing 2026-01-28 14:54:39 -06:00
Jim Liu 宝玉 013c72fce7 chore: release v1.24.3 2026-01-28 13:56:48 -06:00
Jim Liu 宝玉 40fadcb1f6 docs: emphasize updating prompt files before regenerating 2026-01-28 13:56:35 -06:00
Jim Liu 宝玉 499a1ee478 chore: release v1.24.2 2026-01-28 13:14:44 -06:00
Jim Liu 宝玉 fa89eaf2f7 refactor(baoyu-image-gen): default to sequential generation 2026-01-28 13:14:39 -06:00
Jim Liu 宝玉 7964e20bc3 chore: release v1.24.1 2026-01-28 12:29:43 -06:00
Jim Liu 宝玉 45c109ce88 Merge pull request #27 from JianJang2017/my-baoyu-skills
Adapting baoyu-image-gen to the Tongyi Text-to-Image Model
2026-01-28 12:23:34 -06:00
jianzhang50 74c08def06 docs: add Aliyun text-to-image model config to README.md 2026-01-28 22:04:53 +08:00
jianzhang50 907c8ab852 Adapting baoyu-image-gen to the Tongyi Text-to-Image Model 2026-01-28 09:04:57 +08:00
Jim Liu 宝玉 64945f3341 chore: release v1.24.0 2026-01-27 10:10:44 -06:00
Jim Liu 宝玉 050d37c344 docs: backfill third-party contributor attributions in changelogs 2026-01-27 10:06:22 -06:00
Jim Liu 宝玉 f9f168fc1f docs(release-skills): add third-party contributor attribution to release workflow 2026-01-27 10:06:15 -06:00
Jim Liu 宝玉 b1af3a3e45 fix(baoyu-post-to-wechat): improve title extraction, summary auto-fill, and content verification 2026-01-27 10:06:11 -06:00
Jim Liu 宝玉 0727296592 Merge pull request #23 from AliceLJY/feat/reuse-existing-chrome
feat(baoyu-post-to-wechat): reuse existing Chrome instead of requiring all windows closed
2026-01-27 08:03:46 -06:00
Jim Liu 宝玉 0e571b72fb chore: release v1.23.1 2026-01-27 00:59:34 -06:00
Jim Liu 宝玉 366630f8c3 fix(baoyu-compress-image): rename original as backup instead of deleting 2026-01-27 00:59:30 -06:00
安闲静雅 6bfafe0ec5 feat(baoyu-post-to-wechat): reuse existing Chrome instead of requiring all windows closed
Previously, the script always launched a new Chrome instance, which failed
when Chrome was already running due to profile lock conflicts on macOS.
Users had to close all Chrome windows before publishing.

This change adds auto-detection of existing Chrome debug ports and reuses
an already-logged-in WeChat tab when available, falling back to launching
a new instance only when no existing Chrome is found.

Changes:
- cdp.ts: add tryConnectExisting() and findExistingChromeDebugPort()
- wechat-article.ts: try existing Chrome before launching new one,
  reuse logged-in WeChat tab (identified by token= in URL),
  add waitForElement() for reliable menu detection,
  add --cdp-port option for manual override,
  fix process not exiting after completion

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 13:16:33 +08:00
78 changed files with 11810 additions and 809 deletions
+4 -2
View File
@@ -6,7 +6,7 @@
},
"metadata": {
"description": "Skills shared by Baoyu for improving daily work efficiency",
"version": "1.23.0"
"version": "1.30.2"
},
"plugins": [
{
@@ -43,7 +43,9 @@
"skills": [
"./skills/baoyu-danger-x-to-markdown",
"./skills/baoyu-compress-image",
"./skills/baoyu-url-to-markdown"
"./skills/baoyu-url-to-markdown",
"./skills/baoyu-format-markdown",
"./skills/baoyu-markdown-to-html"
]
}
]
+18 -5
View File
@@ -114,11 +114,17 @@ Display version change: `1.2.3 → 1.3.0`
For each detected changelog file:
1. **Identify language** from filename suffix
2. **Generate content in that language**:
2. **Detect third-party contributors**:
- Check merge commits: `git log ${LAST_TAG}..HEAD --merges --pretty=format:"%H %s"`
- For each merged PR, identify the PR author via `gh pr view <number> --json author --jq '.author.login'`
- Compare against repo owner (`gh repo view --json owner --jq '.owner.login'`)
- If PR author ≠ repo owner → third-party contributor
3. **Generate content in that language**:
- Section titles in target language
- Change descriptions written naturally in target language (not translated)
- Date format: YYYY-MM-DD (universal)
3. **Insert at file head** (preserve existing content)
- **Third-party contributions**: Append contributor attribution `(by @username)` to the changelog entry
4. **Insert at file head** (preserve existing content)
**Section Title Translations** (built-in):
@@ -138,6 +144,7 @@ For each detected changelog file:
### Features
- Description of new feature
- Description of third-party contribution (by @username)
### Fixes
- Description of fix
@@ -148,6 +155,12 @@ For each detected changelog file:
Only include sections that have changes. Omit empty sections.
**Third-Party Attribution Rules**:
- Only add `(by @username)` for contributors who are NOT the repo owner
- Use GitHub username with `@` prefix
- Place at the end of the changelog entry line
- Apply to all languages consistently (always use `(by @username)` format, not translated)
**Multi-language Example**:
English (CHANGELOG.md):
@@ -155,7 +168,7 @@ English (CHANGELOG.md):
## 1.3.0 - 2026-01-22
### Features
- Add user authentication module
- Add user authentication module (by @contributor1)
- Support OAuth2 login
### Fixes
@@ -167,7 +180,7 @@ Chinese (CHANGELOG.zh.md):
## 1.3.0 - 2026-01-22
### 新功能
- 新增用户认证模块
- 新增用户认证模块 (by @contributor1)
- 支持 OAuth2 登录
### 修复
@@ -179,7 +192,7 @@ Japanese (CHANGELOG.ja.md):
## 1.3.0 - 2026-01-22
### 新機能
- ユーザー認証モジュールを追加
- ユーザー認証モジュールを追加 (by @contributor1)
- OAuth2 ログインをサポート
### 修正
+7
View File
@@ -152,3 +152,10 @@ slide-deck/
infographic/
illustrations/
comic/
### IntelliJ IDEA ###
.idea
*.iws
*.iml
*.ipr
.claude/skills/baoyu-skill-evolution
+153 -4
View File
@@ -2,6 +2,152 @@
English | [中文](./CHANGELOG.zh.md)
## 1.30.2 - 2026-02-06
### Refactor
- `baoyu-cover-image`: optimize SKILL.md from 532 to 233 lines (56% reduction); extract reference image handling to `references/workflow/reference-images.md`; condense galleries to value-only tables with links.
## 1.30.1 - 2026-02-06
### Features
- `baoyu-image-gen`: add OpenAI GPT Image edits support for reference images (`--ref`); auto-select Google or OpenAI when ref provided.
### Fixes
- `baoyu-image-gen`: change ref-related warnings to explicit errors with fix hints; add reference image validation.
- `baoyu-cover-image`: enhance reference image analysis with deep extraction template; require MUST INCORPORATE section for concrete visual elements.
## 1.30.0 - 2026-02-06
### Features
- `baoyu-cover-image`: add font dimension with 4 typography styles (clean, handwritten, serif, display); includes auto-selection rules, compatibility matrix, and `warm-flat` style preset.
## 1.29.0 - 2026-02-06
### Features
- `baoyu-image-gen`: add EXTEND.md configuration support, including schema documentation and runtime preference loading in scripts (by @kingdomad).
### Fixes
- `baoyu-post-to-wechat`: fix duplicated title and ordered-list numbering in WeChat article publishing (by @NantesCheval).
- `baoyu-url-to-markdown`: replace regex-only conversion with multi-strategy content extraction and Turndown conversion; improve noise filtering for Substack-style pages.
## 1.28.4 - 2026-02-03
### Features
- `baoyu-markdown-to-html`: add author and description meta tags to generated HTML from YAML frontmatter; strip quotes from frontmatter values (supports both English and Chinese quotation marks).
### Fixes
- `baoyu-post-to-wechat`: remove extra empty lines after image paste; fix summary field timing to fill after content paste (prevents being overwritten).
## 1.28.3 - 2026-02-03
### Fixes
- `baoyu-post-to-wechat`: fix placeholder matching issue where `WECHATIMGPH_1` incorrectly matched `WECHATIMGPH_10`.
## 1.28.2 - 2026-02-03
### Fixes
- `baoyu-post-to-x`: reuse existing Chrome instance when available; fix placeholder matching issue where `XIMGPH_1` incorrectly matched `XIMGPH_10`; improve image sorting by placeholder index; use `execCommand` for more reliable placeholder deletion.
## 1.28.1 - 2026-02-02
### Refactor
- `baoyu-article-illustrator`: simplify main SKILL.md by extracting detailed procedures to `workflow.md`; add Core Styles tier (vector, minimal-flat, sci-fi, hand-drawn, editorial, scene) for quick selection; add `vector-illustration` as recommended default style; add Illustration Purpose (information/visualization/imagination) for better type/style recommendations; add default composition requirements, character rendering guidelines, and text styling rules to prompt construction.
## 1.28.0 - 2026-02-01
### Features
- `baoyu-cover-image`: add reference image support (`--ref` parameter) with direct/style/palette usage types; add visual elements library with icon vocabulary by topic.
- `baoyu-article-illustrator`: add reference image support with direct/style/palette usage types.
- `baoyu-post-to-wechat`: add `newspic` article type for image-text posts.
### Refactor
- `baoyu-cover-image`, `baoyu-article-illustrator`, `baoyu-comic`, `baoyu-xhs-images`: enforce first-time setup as blocking operation before any other workflow steps.
- `baoyu-cover-image`: remove character limits from titles, use original source titles.
## 1.26.1 - 2026-01-29
### Features
- `baoyu-article-illustrator`, `baoyu-comic`, `baoyu-cover-image`, `baoyu-infographic`, `baoyu-slide-deck`, `baoyu-xhs-images`: add backup rules for existing files—automatically renames source, prompt, and image files with timestamp suffix before overwriting.
### Fixes
- `baoyu-xhs-images`: remove `notebook` style (10 styles remaining).
## 1.26.0 - 2026-01-29
### Features
- `baoyu-xhs-images`: add `notebook` style (hand-drawn infographic with watercolor rendering and Morandi palette) and `study-notes` style (realistic handwritten photo aesthetic).
- `baoyu-xhs-images`: add `mindmap` (center radial) and `quadrant` (four-section grid) layouts.
## 1.25.4 - 2026-01-29
### Fixes
- `baoyu-markdown-to-html`: generate proper `<img>` tags with `data-local-path` attribute instead of text placeholders.
- `baoyu-post-to-wechat`: fix API publishing to read image paths from `data-local-path` attribute; fix title/cover extraction from corresponding `.md` frontmatter when publishing HTML files.
- `baoyu-post-to-wechat`: fix CLI argument parsing to handle unknown parameters gracefully; add `--summary` parameter support.
- `baoyu-post-to-wechat`: fix browser publishing to convert `<img>` tags back to text placeholders before paste.
## 1.25.3 - 2026-01-28
### Features
- `baoyu-format-markdown`: add content type detection with user confirmation for markdown files; add CJK punctuation handling to move paired punctuation outside emphasis markers.
## 1.25.2 - 2026-01-28
### Documentation
- `baoyu-post-to-wechat`: add WeChat API credentials configuration guide to README.
## 1.25.1 - 2026-01-28
### Features
- `baoyu-markdown-to-html`: add pre-check step for CJK content to suggest formatting with `baoyu-format-markdown` before conversion.
## 1.25.0 - 2026-01-28
### Features
- `baoyu-format-markdown`: add markdown formatter skill with frontmatter, typography, and CJK spacing support.
- `baoyu-markdown-to-html`: add markdown to HTML converter with WeChat-compatible themes, code highlighting, math, PlantUML, and alerts.
- `baoyu-post-to-wechat`: add API-based publishing method and external theme support.
## 1.24.4 - 2026-01-28
### Fixes
- `baoyu-post-to-x`: fix Apply button click for cover image modal; add retry logic and wait for modal close.
## 1.24.3 - 2026-01-28
### Documentation
- Emphasize updating prompt files before regenerating images in modification workflows (article-illustrator, slide-deck, xhs-images, cover-image, comic).
## 1.24.2 - 2026-01-28
### Refactor
- `baoyu-image-gen`: default to sequential generation; parallel available on request.
## 1.24.1 - 2026-01-28
### Features
- `baoyu-image-gen`: add Aliyun Tongyi Wanxiang (DashScope) text-to-image model support (by @JianJang2017).
### Documentation
- Add Aliyun text-to-image model configuration to README.
## 1.24.0 - 2026-01-27
### Features
- `baoyu-post-to-wechat`: reuse existing Chrome browser instead of requiring all windows closed (by @AliceLJY).
### Fixes
- `baoyu-post-to-wechat`: improves title extraction to support h1/h2 headings; adds summary auto-fill and content verification after paste/type; supports flexible HTML meta tag attribute ordering.
### Documentation
- `release-skills`: adds third-party contributor attribution rules to changelog workflow.
- Backfills missing third-party contributor attributions across historical changelog entries.
## 1.23.1 - 2026-01-27
### Fixes
- `baoyu-compress-image`: rename original file as `_original` backup instead of deleting after compression.
## 1.23.0 - 2026-01-26
### Refactor
@@ -20,7 +166,7 @@ English | [中文](./CHANGELOG.zh.md)
## 1.21.4 - 2026-01-25
### Fixes
- `baoyu-post-to-wechat`: adds Windows compatibility—uses `fileURLToPath` for correct path resolution, replaces system-dependent copy/paste tools (osascript/xdotool) with CDP keyboard events for cross-platform support.
- `baoyu-post-to-wechat`: adds Windows compatibility—uses `fileURLToPath` for correct path resolution, replaces system-dependent copy/paste tools (osascript/xdotool) with CDP keyboard events for cross-platform support (by @JadeLiang003).
- `baoyu-post-to-wechat`: fixes regressions from Windows compatibility PR—corrects broken `-fixed` filename references, restores frontmatter quote stripping, restores `--title` CLI parameter, fixes summary extraction to skip headings/quotes/lists, fixes argument parsing for single-dash flags, removes debug logs.
- `baoyu-article-illustrator`, `baoyu-cover-image`, `baoyu-xhs-images`: removes opacity option from watermark configuration.
@@ -170,7 +316,7 @@ English | [中文](./CHANGELOG.zh.md)
## 1.14.0 - 2026-01-22
### Fixes
- `baoyu-post-to-x`: improves video ready detection for more reliable video posting.
- `baoyu-post-to-x`: improves video ready detection for more reliable video posting (by @fkysly).
### Documentation
- `baoyu-slide-deck`: comprehensive SKILL.md enhancement—adds slide count guidance (recommended 8-25, max 30), audience guidelines table with audience-specific principles, style selection principles with content-type recommendations, layout selection tips with common mistakes to avoid, visual hierarchy principles, content density guidelines (McKinsey-style high-density principles), color selection guide, typography principles with font recommendations (English and Chinese fonts with multilingual pairing), and visual elements reference (backgrounds, typography treatments, geometric accents).
@@ -185,6 +331,9 @@ English | [中文](./CHANGELOG.zh.md)
## 1.12.0 - 2026-01-21
### Features
- `baoyu-post-to-x`: adds quote tweet support (by @threehotpot-bot).
### Refactor
- `baoyu-post-to-x`: extracts shared utilities to `x-utils.ts`—consolidates Chrome detection, CDP connection, clipboard operations, and helper functions from `x-article.ts`, `x-browser.ts`, `x-quote.ts`, and `x-video.ts` into a single reusable module.
@@ -200,7 +349,7 @@ English | [中文](./CHANGELOG.zh.md)
## 1.10.0 - 2026-01-21
### Features
- `baoyu-post-to-x`: adds video posting support—new `x-video.ts` script for posting text with video files (MP4, MOV, WebM). Supports preview mode and handles video processing timeouts.
- `baoyu-post-to-x`: adds video posting support—new `x-video.ts` script for posting text with video files (MP4, MOV, WebM). Supports preview mode and handles video processing timeouts (by @fkysly).
## 1.9.0 - 2026-01-20
@@ -256,7 +405,7 @@ English | [中文](./CHANGELOG.zh.md)
## 1.4.1 - 2026-01-18
### Fixes
- `baoyu-post-to-x`: supports multi-language UI selectors for X Articles (contributed by [@ianchenx](https://github.com/ianchenx)).
- `baoyu-post-to-x`: supports multi-language UI selectors for X Articles (by @ianchenx).
## 1.4.0 - 2026-01-18
+153 -4
View File
@@ -2,6 +2,152 @@
[English](./CHANGELOG.md) | 中文
## 1.30.2 - 2026-02-06
### 重构
- `baoyu-cover-image`:优化 SKILL.md 从 532 行精简至 233 行(减少 56%);将参考图片处理流程提取到 `references/workflow/reference-images.md`;画廊改为纯值表格并链接到详细参考文件。
## 1.30.1 - 2026-02-06
### 新功能
- `baoyu-image-gen`:新增 OpenAI GPT Image edits 支持参考图片(`--ref`);提供 ref 时自动选择 Google 或 OpenAI。
### 修复
- `baoyu-image-gen`:将 ref 相关警告改为明确错误提示;新增参考图片验证。
- `baoyu-cover-image`:增强参考图片分析,使用深度提取模板;要求 MUST INCORPORATE 章节以包含具体可复现的视觉元素。
## 1.30.0 - 2026-02-06
### 新功能
- `baoyu-cover-image`:新增字体维度,支持 4 种字体风格(clean、handwritten、serif、display);包含自动选择规则、兼容性矩阵和 `warm-flat` 风格预设。
## 1.29.0 - 2026-02-06
### 新功能
- `baoyu-image-gen`:新增 EXTEND.md 配置支持,补充配置 schema 文档并在脚本运行时读取偏好设置 (by @kingdomad)。
### 修复
- `baoyu-post-to-wechat`:修复公众号文章发布时标题和有序列表编号重复问题 (by @NantesCheval)。
- `baoyu-url-to-markdown`:将正则转换升级为多策略正文抽取 + Turndown 转换,提升 Substack 类页面的噪声过滤能力。
## 1.28.4 - 2026-02-03
### 新功能
- `baoyu-markdown-to-html`:从 YAML frontmatter 生成 author 和 description meta 标签;自动去除 frontmatter 值两端的引号(支持中英文引号)。
### 修复
- `baoyu-post-to-wechat`:移除图片粘贴后产生的多余空行;修复摘要填充时机,改为内容粘贴后填写(避免被覆盖)。
## 1.28.3 - 2026-02-03
### 修复
- `baoyu-post-to-wechat`:修复占位符匹配问题(`WECHATIMGPH_1` 错误匹配 `WECHATIMGPH_10`)。
## 1.28.2 - 2026-02-03
### 修复
- `baoyu-post-to-x`:复用已有 Chrome 实例;修复占位符匹配问题(`XIMGPH_1` 错误匹配 `XIMGPH_10`);改进图片按占位符序号排序;使用 `execCommand` 提高占位符删除可靠性。
## 1.28.1 - 2026-02-02
### 重构
- `baoyu-article-illustrator`:简化主 SKILL.md,将详细步骤提取到 `workflow.md`;新增 Core Styles 快速选择层(vector、minimal-flat、sci-fi、hand-drawn、editorial、scene);新增 `vector-illustration` 作为推荐默认风格;新增插图目的(information/visualization/imagination)以优化类型/风格推荐;在提示词构建中新增默认构图要求、人物渲染指南和文本样式规则。
## 1.28.0 - 2026-02-01
### 新功能
- `baoyu-cover-image`:新增参考图片支持(`--ref` 参数),支持 direct/style/palette 三种用法;新增视觉元素库,按主题分类图标词汇。
- `baoyu-article-illustrator`:新增参考图片支持,支持 direct/style/palette 三种用法。
- `baoyu-post-to-wechat`:新增 `newspic` 图文消息类型支持。
### 重构
- `baoyu-cover-image``baoyu-article-illustrator``baoyu-comic``baoyu-xhs-images`:强化首次设置为阻塞操作,必须在其他工作流步骤之前完成。
- `baoyu-cover-image`:移除标题字符数限制,使用原始来源标题。
## 1.26.1 - 2026-01-29
### 新功能
- `baoyu-article-illustrator``baoyu-comic``baoyu-cover-image``baoyu-infographic``baoyu-slide-deck``baoyu-xhs-images`:新增文件备份规则,覆盖前自动将现有源文件、提示词和图片重命名为带时间戳后缀的备份文件。
### 修复
- `baoyu-xhs-images`:移除 `notebook` 风格(保留 10 种风格)。
## 1.26.0 - 2026-01-29
### 新功能
- `baoyu-xhs-images`:新增 `notebook` 风格(水彩渲染手绘信息图 + 莫兰迪配色)和 `study-notes` 风格(真实手写照片美学)。
- `baoyu-xhs-images`:新增 `mindmap`(中心发散式)和 `quadrant`(四象限)布局。
## 1.25.4 - 2026-01-29
### 修复
- `baoyu-markdown-to-html`:生成带 `data-local-path` 属性的 `<img>` 标签,而非纯文本占位符。
- `baoyu-post-to-wechat`:修复 API 发布时从 `data-local-path` 属性读取图片路径;修复发布 HTML 文件时从对应 `.md` 的 frontmatter 提取标题和封面图。
- `baoyu-post-to-wechat`:修复命令行参数解析,正确跳过未知参数;新增 `--summary` 参数支持。
- `baoyu-post-to-wechat`:修复浏览器发布模式,粘贴前将 `<img>` 标签转换回文本占位符。
## 1.25.3 - 2026-01-28
### 新功能
- `baoyu-format-markdown`:新增内容类型检测,对已有 markdown 格式的文件提供用户确认选项;新增 CJK 配对标点处理,将括号、引号等标点移出加粗标记外。
## 1.25.2 - 2026-01-28
### 文档
- `baoyu-post-to-wechat`README 新增微信公众号 API 凭证配置说明。
## 1.25.1 - 2026-01-28
### 新功能
- `baoyu-markdown-to-html`:新增中文内容预检查,建议在转换前使用 `baoyu-format-markdown` 格式化以修复加粗标点问题。
## 1.25.0 - 2026-01-28
### 新功能
- `baoyu-format-markdown`:新增 markdown 格式化技能,支持 frontmatter、排版优化和中英文空格处理。
- `baoyu-markdown-to-html`:新增 markdown 转 HTML 技能,支持微信兼容主题、代码高亮、数学公式、PlantUML 和 alerts。
- `baoyu-post-to-wechat`:新增 API 发布方式和外部主题支持。
## 1.24.4 - 2026-01-28
### 修复
- `baoyu-post-to-x`:修复封面图上传后 Apply 按钮点击问题;增加重试逻辑并等待弹窗关闭后再继续。
## 1.24.3 - 2026-01-28
### 文档
- 在修改工作流中强调先更新提示词文件再生成图片(article-illustrator、slide-deck、xhs-images、cover-image、comic)。
## 1.24.2 - 2026-01-28
### 重构
- `baoyu-image-gen`:默认改为顺序生成图片;并行生成需明确请求。
## 1.24.1 - 2026-01-28
### 新功能
- `baoyu-image-gen`:新增阿里云通义万象(DashScope)文生图模型支持 (by @JianJang2017)。
### 文档
- README 中新增阿里云文生图模型配置说明。
## 1.24.0 - 2026-01-27
### 新功能
- `baoyu-post-to-wechat`:复用已打开的 Chrome 浏览器,无需关闭所有窗口 (by @AliceLJY)。
### 修复
- `baoyu-post-to-wechat`:改进标题提取,支持 h1/h2 标题;新增摘要自动填充和粘贴/输入后内容验证;支持 HTML meta 标签属性顺序灵活匹配。
### 文档
- `release-skills`:在发布流程中新增第三方贡献者署名规则。
- 补全历史 changelog 中缺失的第三方贡献者署名。
## 1.23.1 - 2026-01-27
### 修复
- `baoyu-compress-image`:压缩后将原始文件重命名为 `_original` 备份,不再删除。
## 1.23.0 - 2026-01-26
### 重构
@@ -20,7 +166,7 @@
## 1.21.4 - 2026-01-25
### 修复
- `baoyu-post-to-wechat`:新增 Windows 兼容性——使用 `fileURLToPath` 正确解析路径,将系统依赖的复制粘贴工具(osascript/xdotool)替换为 CDP 键盘事件,实现跨平台支持。
- `baoyu-post-to-wechat`:新增 Windows 兼容性——使用 `fileURLToPath` 正确解析路径,将系统依赖的复制粘贴工具(osascript/xdotool)替换为 CDP 键盘事件,实现跨平台支持 (by @JadeLiang003)
- `baoyu-post-to-wechat`:修复 Windows 兼容性 PR 引入的回退问题——修正错误的 `-fixed` 文件名引用、恢复 frontmatter 引号剥离、恢复 `--title` CLI 参数、修复摘要提取逻辑以正确跳过标题/引用/列表、修复单横线参数解析、移除调试日志。
- `baoyu-article-illustrator``baoyu-cover-image``baoyu-xhs-images`:移除水印配置中的透明度选项。
@@ -170,7 +316,7 @@
## 1.14.0 - 2026-01-22
### 修复
- `baoyu-post-to-x`:改进视频就绪检测,提升视频发布稳定性。
- `baoyu-post-to-x`:改进视频就绪检测,提升视频发布稳定性 (by @fkysly)
### 文档
- `baoyu-slide-deck`:SKILL.md 全面增强——新增幻灯片数量指南(推荐 8-25 张,最多 30 张)、受众指南表格及各受众特定原则、风格选择原则与内容类型推荐、布局选择技巧与常见错误提示、视觉层次原则、内容密度指南(麦肯锡风格高密度原则)、配色选择指南、字体排版原则与字体推荐(中英文字体及多语言搭配方案)、视觉元素参考(背景处理、字体处理、几何装饰)。
@@ -185,6 +331,9 @@
## 1.12.0 - 2026-01-21
### 新功能
- `baoyu-post-to-x`:新增引用推文(Quote Tweet)支持 (by @threehotpot-bot)。
### 重构
- `baoyu-post-to-x`:提取公共工具函数到 `x-utils.ts`——将 `x-article.ts``x-browser.ts``x-quote.ts``x-video.ts` 中重复的 Chrome 检测、CDP 连接、剪贴板操作等功能整合为统一的可复用模块。
@@ -200,7 +349,7 @@
## 1.10.0 - 2026-01-21
### 新功能
- `baoyu-post-to-x`:新增视频发布支持——新增 `x-video.ts` 脚本,支持发布带视频的推文(MP4、MOV、WebM 格式)。支持预览模式,自动处理视频上传等待。
- `baoyu-post-to-x`:新增视频发布支持——新增 `x-video.ts` 脚本,支持发布带视频的推文(MP4、MOV、WebM 格式)。支持预览模式,自动处理视频上传等待 (by @fkysly)
## 1.9.0 - 2026-01-20
@@ -256,7 +405,7 @@
## 1.4.1 - 2026-01-18
### 修复
- `baoyu-post-to-x`:支持 X Articles 多语言 UI 选择器(感谢 [@ianchenx](https://github.com/ianchenx) 贡献)
- `baoyu-post-to-x`:支持 X Articles 多语言 UI 选择器 (by @ianchenx)
## 1.4.0 - 2026-01-18
+74 -5
View File
@@ -55,7 +55,7 @@ Simply tell Claude Code:
|--------|-------------|--------|
| **content-skills** | Content generation and publishing | [xhs-images](#baoyu-xhs-images), [infographic](#baoyu-infographic), [cover-image](#baoyu-cover-image), [slide-deck](#baoyu-slide-deck), [comic](#baoyu-comic), [article-illustrator](#baoyu-article-illustrator), [post-to-x](#baoyu-post-to-x), [post-to-wechat](#baoyu-post-to-wechat) |
| **ai-generation-skills** | AI-powered generation backends | [image-gen](#baoyu-image-gen), [danger-gemini-web](#baoyu-danger-gemini-web) |
| **utility-skills** | Utility tools for content processing | [url-to-markdown](#baoyu-url-to-markdown), [danger-x-to-markdown](#baoyu-danger-x-to-markdown), [compress-image](#baoyu-compress-image) |
| **utility-skills** | Utility tools for content processing | [url-to-markdown](#baoyu-url-to-markdown), [danger-x-to-markdown](#baoyu-danger-x-to-markdown), [compress-image](#baoyu-compress-image), [format-markdown](#baoyu-format-markdown) |
## Update Skills
@@ -535,7 +535,28 @@ Post content to WeChat Official Account (微信公众号). Two modes available:
/baoyu-post-to-wechat 文章 --html article.html
```
Prerequisites: Google Chrome installed. First run requires QR code login (session preserved).
**Publishing Methods**:
| Method | Speed | Requirements |
|--------|-------|--------------|
| API (Recommended) | Fast | API credentials |
| Browser | Slow | Chrome, login session |
**API Configuration** (for faster publishing):
```bash
# Add to .baoyu-skills/.env (project-level) or ~/.baoyu-skills/.env (user-level)
WECHAT_APP_ID=your_app_id
WECHAT_APP_SECRET=your_app_secret
```
To obtain credentials:
1. Visit https://developers.weixin.qq.com/platform/
2. Go to: 我的业务 → 公众号 → 开发密钥
3. Create development key and copy AppID/AppSecret
4. Add your machine's IP to the whitelist
**Browser Method** (no API setup needed): Requires Google Chrome. First run opens browser for QR code login (session preserved).
### AI Generation Skills
@@ -543,7 +564,7 @@ AI-powered generation backends.
#### baoyu-image-gen
AI SDK-based image generation using official OpenAI and Google APIs. Supports text-to-image, reference images, aspect ratios, and quality presets.
AI SDK-based image generation using official OpenAI, Google and DashScope (Aliyun Tongyi Wanxiang) APIs. Supports text-to-image, reference images, aspect ratios, and quality presets.
```bash
# Basic generation (auto-detect provider)
@@ -558,6 +579,9 @@ AI SDK-based image generation using official OpenAI and Google APIs. Supports te
# Specific provider
/baoyu-image-gen --prompt "A cat" --image cat.png --provider openai
# DashScope (Aliyun Tongyi Wanxiang)
/baoyu-image-gen --prompt "一只可爱的猫" --image cat.png --provider dashscope
# With reference images (Google multimodal only)
/baoyu-image-gen --prompt "Make it blue" --image out.png --ref source.png
```
@@ -568,7 +592,7 @@ AI SDK-based image generation using official OpenAI and Google APIs. Supports te
| `--prompt`, `-p` | Prompt text |
| `--promptfiles` | Read prompt from files (concatenated) |
| `--image` | Output image path (required) |
| `--provider` | `google` or `openai` (default: google) |
| `--provider` | `google`, `openai` or `dashscope` (default: google) |
| `--model`, `-m` | Model ID |
| `--ar` | Aspect ratio (e.g., `16:9`, `1:1`, `4:3`) |
| `--size` | Size (e.g., `1024x1024`) |
@@ -580,15 +604,18 @@ AI SDK-based image generation using official OpenAI and Google APIs. Supports te
|----------|-------------|---------|
| `OPENAI_API_KEY` | OpenAI API key | - |
| `GOOGLE_API_KEY` | Google API key | - |
| `DASHSCOPE_API_KEY` | DashScope API key (Aliyun) | - |
| `OPENAI_IMAGE_MODEL` | OpenAI model | `gpt-image-1.5` |
| `GOOGLE_IMAGE_MODEL` | Google model | `gemini-3-pro-image-preview` |
| `DASHSCOPE_IMAGE_MODEL` | DashScope model | `z-image-turbo` |
| `OPENAI_BASE_URL` | Custom OpenAI endpoint | - |
| `GOOGLE_BASE_URL` | Custom Google endpoint | - |
| `DASHSCOPE_BASE_URL` | Custom DashScope endpoint | - |
**Provider Auto-Selection**:
1. If `--provider` specified → use it
2. If only one API key available → use that provider
3. If both available → default to Google
3. If multiple available → default to Google
#### baoyu-danger-gemini-web
@@ -672,6 +699,43 @@ Compress images to reduce file size while maintaining quality.
/baoyu-compress-image path/to/images/ --quality 80
```
#### baoyu-format-markdown
Format plain text or markdown files with proper frontmatter, titles, summaries, headings, bold, lists, and code blocks.
```bash
# Format a markdown file
/baoyu-format-markdown path/to/article.md
# Format with specific output
/baoyu-format-markdown path/to/draft.md
```
**Workflow**:
1. Read source file and analyze content structure
2. Check/create YAML frontmatter (title, slug, summary, featureImage)
3. Handle title: use existing, extract from H1, or generate candidates
4. Apply formatting: headings, bold, lists, code blocks, quotes
5. Save to `{filename}-formatted.md`
6. Run typography script: ASCII→fullwidth quotes, CJK spacing, autocorrect
**Frontmatter Fields**:
| Field | Processing |
|-------|------------|
| `title` | Use existing, extract H1, or generate candidates |
| `slug` | Infer from file path or generate from title |
| `summary` | Generate engaging summary (100-150 chars) |
| `featureImage` | Check for `imgs/cover.png` in same directory |
**Formatting Rules**:
| Element | Format |
|---------|--------|
| Titles | `#`, `##`, `###` hierarchy |
| Key points | `**bold**` |
| Parallel items | `-` unordered or `1.` ordered lists |
| Code/commands | `` `inline` `` or ` ```block``` ` |
| Quotes | `>` blockquote |
## Environment Configuration
Some skills require API keys or custom configuration. Environment variables can be set in `.env` files:
@@ -699,6 +763,11 @@ OPENAI_IMAGE_MODEL=gpt-image-1.5
GOOGLE_API_KEY=xxx
GOOGLE_IMAGE_MODEL=gemini-3-pro-image-preview
# GOOGLE_BASE_URL=https://generativelanguage.googleapis.com/v1beta
# DashScope (Aliyun Tongyi Wanxiang)
DASHSCOPE_API_KEY=sk-xxx
DASHSCOPE_IMAGE_MODEL=z-image-turbo
# DASHSCOPE_BASE_URL=https://dashscope.aliyuncs.com/api/v1
EOF
```
+74 -5
View File
@@ -55,7 +55,7 @@ npx skills add jimliu/baoyu-skills
|------|------|----------|
| **content-skills** | 内容生成和发布 | [xhs-images](#baoyu-xhs-images), [infographic](#baoyu-infographic), [cover-image](#baoyu-cover-image), [slide-deck](#baoyu-slide-deck), [comic](#baoyu-comic), [article-illustrator](#baoyu-article-illustrator), [post-to-x](#baoyu-post-to-x), [post-to-wechat](#baoyu-post-to-wechat) |
| **ai-generation-skills** | AI 生成后端 | [image-gen](#baoyu-image-gen), [danger-gemini-web](#baoyu-danger-gemini-web) |
| **utility-skills** | 内容处理工具 | [url-to-markdown](#baoyu-url-to-markdown), [danger-x-to-markdown](#baoyu-danger-x-to-markdown), [compress-image](#baoyu-compress-image) |
| **utility-skills** | 内容处理工具 | [url-to-markdown](#baoyu-url-to-markdown), [danger-x-to-markdown](#baoyu-danger-x-to-markdown), [compress-image](#baoyu-compress-image), [format-markdown](#baoyu-format-markdown) |
## 更新技能
@@ -535,7 +535,28 @@ npx skills add jimliu/baoyu-skills
/baoyu-post-to-wechat 文章 --html article.html
```
前置要求:已安装 Google Chrome,首次运行需扫码登录(登录状态会保存)
**发布方式**
| 方式 | 速度 | 要求 |
|------|------|------|
| API(推荐) | 快 | API 凭证 |
| 浏览器 | 慢 | Chrome,登录会话 |
**API 配置**(更快的发布方式):
```bash
# 添加到 .baoyu-skills/.env(项目级)或 ~/.baoyu-skills/.env(用户级)
WECHAT_APP_ID=你的AppID
WECHAT_APP_SECRET=你的AppSecret
```
获取凭证方法:
1. 访问 https://developers.weixin.qq.com/platform/
2. 进入:我的业务 → 公众号 → 开发密钥
3. 添加开发密钥,复制 AppID 和 AppSecret
4. 将你操作的机器 IP 加入白名单
**浏览器方式**(无需 API 配置):需已安装 Google Chrome,首次运行需扫码登录(登录状态会保存)
### AI 生成技能 (AI Generation Skills)
@@ -543,7 +564,7 @@ AI 驱动的生成后端。
#### baoyu-image-gen
基于 AI SDK 的图像生成,使用官方 OpenAIGoogle API。支持文生图、参考图、宽高比和质量预设。
基于 AI SDK 的图像生成,使用官方 OpenAIGoogle 和 DashScope(阿里通义万相)API。支持文生图、参考图、宽高比和质量预设。
```bash
# 基础生成(自动检测服务商)
@@ -558,6 +579,9 @@ AI 驱动的生成后端。
# 指定服务商
/baoyu-image-gen --prompt "一只猫" --image cat.png --provider openai
# DashScope(阿里通义万相)
/baoyu-image-gen --prompt "一只可爱的猫" --image cat.png --provider dashscope
# 带参考图(仅 Google 多模态支持)
/baoyu-image-gen --prompt "把它变成蓝色" --image out.png --ref source.png
```
@@ -568,7 +592,7 @@ AI 驱动的生成后端。
| `--prompt`, `-p` | 提示词文本 |
| `--promptfiles` | 从文件读取提示词(多文件拼接) |
| `--image` | 输出图片路径(必需) |
| `--provider` | `google``openai`(默认:google |
| `--provider` | `google``openai``dashscope`(默认:google |
| `--model`, `-m` | 模型 ID |
| `--ar` | 宽高比(如 `16:9``1:1``4:3` |
| `--size` | 尺寸(如 `1024x1024` |
@@ -580,15 +604,18 @@ AI 驱动的生成后端。
|------|------|--------|
| `OPENAI_API_KEY` | OpenAI API 密钥 | - |
| `GOOGLE_API_KEY` | Google API 密钥 | - |
| `DASHSCOPE_API_KEY` | DashScope API 密钥(阿里云) | - |
| `OPENAI_IMAGE_MODEL` | OpenAI 模型 | `gpt-image-1.5` |
| `GOOGLE_IMAGE_MODEL` | Google 模型 | `gemini-3-pro-image-preview` |
| `DASHSCOPE_IMAGE_MODEL` | DashScope 模型 | `z-image-turbo` |
| `OPENAI_BASE_URL` | 自定义 OpenAI 端点 | - |
| `GOOGLE_BASE_URL` | 自定义 Google 端点 | - |
| `DASHSCOPE_BASE_URL` | 自定义 DashScope 端点 | - |
**服务商自动选择**
1. 如果指定了 `--provider` → 使用指定的
2. 如果只有一个 API 密钥 → 使用对应服务商
3. 如果两个都有 → 默认使用 Google
3. 如果多个可用 → 默认使用 Google
#### baoyu-danger-gemini-web
@@ -672,6 +699,43 @@ AI 驱动的生成后端。
/baoyu-compress-image path/to/images/ --quality 80
```
#### baoyu-format-markdown
格式化纯文本或 Markdown 文件,添加 frontmatter、标题、摘要、层级标题、加粗、列表和代码块。
```bash
# 格式化 markdown 文件
/baoyu-format-markdown path/to/article.md
# 格式化指定文件
/baoyu-format-markdown path/to/draft.md
```
**工作流程**
1. 读取源文件并分析内容结构
2. 检查/创建 YAML frontmattertitle、slug、summary、featureImage
3. 处理标题:使用现有标题、提取 H1 或生成候选标题
4. 应用格式:层级标题、加粗、列表、代码块、引用
5. 保存为 `{文件名}-formatted.md`
6. 运行排版脚本:半角引号→全角引号、中英文空格、autocorrect
**Frontmatter 字段**
| 字段 | 处理方式 |
|------|----------|
| `title` | 使用现有、提取 H1 或生成候选 |
| `slug` | 从文件路径推断或根据标题生成 |
| `summary` | 生成吸引人的摘要(100-150 字) |
| `featureImage` | 检查同目录下 `imgs/cover.png` |
**格式化规则**
| 元素 | 格式 |
|------|------|
| 标题 | `#``##``###` 层级 |
| 重点内容 | `**加粗**` |
| 并列要点 | `-` 无序列表或 `1.` 有序列表 |
| 代码/命令 | `` `行内` `` 或 ` ```代码块``` ` |
| 引用 | `>` 引用块 |
## 环境配置
部分技能需要 API 密钥或自定义配置。环境变量可以在 `.env` 文件中设置:
@@ -699,6 +763,11 @@ OPENAI_IMAGE_MODEL=gpt-image-1.5
GOOGLE_API_KEY=xxx
GOOGLE_IMAGE_MODEL=gemini-3-pro-image-preview
# GOOGLE_BASE_URL=https://generativelanguage.googleapis.com/v1beta
# DashScope(阿里通义万相)
DASHSCOPE_API_KEY=sk-xxx
DASHSCOPE_IMAGE_MODEL=z-image-turbo
# DASHSCOPE_BASE_URL=https://dashscope.aliyuncs.com/api/v1
EOF
```
+57 -183
View File
@@ -16,6 +16,16 @@ Analyze articles, identify illustration positions, generate images with Type ×
Type × Style can be freely combined. Example: `--type infographic --style blueprint`
## Illustration Purpose
Auto-detected during content analysis. Influences type/style recommendations.
| Purpose | Description | Best Types |
|---------|-------------|------------|
| **information** | Help understand abstract concepts | infographic, flowchart, comparison |
| **visualization** | Turn abstract ideas into concrete visuals | framework, comparison, infographic |
| **imagination** | Create atmosphere, spark imagination | scene, timeline |
## Type Gallery
| Type | Best For |
@@ -27,92 +37,37 @@ Type × Style can be freely combined. Example: `--type infographic --style bluep
| `framework` | Methodologies, models, architecture |
| `timeline` | History, progress, evolution |
## Style Gallery
## Styles
| Style | Best For |
|-------|----------|
| `notion` (Default) | Knowledge sharing, SaaS, productivity |
| `elegant` | Business, thought leadership |
| `warm` | Personal growth, lifestyle, education |
| `minimal` | Philosophy, core concepts |
| `blueprint` | Architecture, system design |
| `watercolor` | Lifestyle, travel, creative |
| `editorial` | Tech explainers, journalism |
| `scientific` | Academic, technical research |
Full styles: [references/styles.md](references/styles.md)
## Auto Selection
| Content Signals | Type | Style |
|-----------------|------|-------|
| API, metrics, data, numbers | infographic | blueprint, notion |
| Story, emotion, journey | scene | warm, watercolor |
| How-to, steps, workflow | flowchart | notion, minimal |
| vs, pros/cons, before/after | comparison | notion, elegant |
| Framework, model, architecture | framework | blueprint, notion |
| History, timeline, progress | timeline | elegant, warm |
See [references/styles.md](references/styles.md) for:
- **Core Styles**: Simplified tier for quick selection (vector, minimal-flat, sci-fi, hand-drawn, editorial, scene)
- **Style Gallery**: Full 20+ style options with descriptions
- **Auto Selection**: Content signals → Type/Style recommendations
- **Compatibility Matrix**: Type × Style combinations
## Workflow
Copy this checklist and track progress:
```
Progress:
- [ ] Step 1: Pre-check
- [ ] 1.5 Load preferences (EXTEND.md) ⛔ BLOCKING
- [ ] 1.0 Reference images ⚠️ (if provided)
- [ ] 1.2-1.4 Config questions (1 AskUserQuestion, max 4 Qs)
- [ ] Step 2: Setup & Analyze
- [ ] Step 3: Confirm Settings ⚠️ REQUIRED
- [ ] Step 3: Confirm Settings (1 AskUserQuestion, max 4 Qs)
- [ ] Q1: Type ⚠️
- [ ] Q2: Density ⚠️ MUST ASK
- [ ] Q3: Style ⚠️
- [ ] Step 4: Generate Outline
- [ ] Step 5: Generate Images
- [ ] Step 6: Finalize
```
---
### Step 1: Pre-check
**1.1 Determine Input Type**
**1.5 Load Preferences (EXTEND.md) ⛔ BLOCKING**
| Input | Output Directory | Next |
|-------|------------------|------|
| File path | Ask user (1.2) | → 1.2 |
| Pasted content | `illustrations/{topic-slug}/` | → 1.4 |
**1.2 Determine Output Directory** (file path input only)
Check `default_output_dir` in preferences:
| Preference Value | Action |
|------------------|--------|
| `same-dir` | Use `{article-dir}/`, display "Output: {path}" |
| `imgs-subdir` | Use `{article-dir}/imgs/`, display "Output: {path}" |
| `illustrations-subdir` | Use `{article-dir}/illustrations/`, display "Output: {path}" |
| `independent` | Use `illustrations/{topic-slug}/`, display "Output: {path}" |
| Not configured | **MUST** ask with AskUserQuestion ↓ |
**AskUserQuestion** (when no preference):
- `{article-dir}/` - Same directory as article
- `{article-dir}/imgs/` - Images subdirectory
- `{article-dir}/illustrations/` - Illustrations subdirectory (Recommended)
- `illustrations/{topic-slug}/` - Independent directory
- Save as default - Remember this choice for future runs
**1.3 Check Existing Images**
Scan target directory for `.png/.jpg/.webp` files.
If images exist → AskUserQuestion: How to handle?
- `supplement` - Keep existing, generate only new positions
- `overwrite` - Overwrite same-name files
- `regenerate` - Clear all and regenerate
**1.4 Confirm Article Update Method** (file path input only)
AskUserQuestion: How to update article?
- `update` - Modify original file directly
- `copy` - Create `{name}-illustrated.md` copy
**1.5 Load Preferences (EXTEND.md)**
**CRITICAL**: If EXTEND.md not found, MUST complete first-time setup before ANY other steps.
```bash
test -f .baoyu-skills/baoyu-article-illustrator/EXTEND.md && echo "project"
@@ -121,166 +76,86 @@ test -f "$HOME/.baoyu-skills/baoyu-article-illustrator/EXTEND.md" && echo "user"
| Result | Action |
|--------|--------|
| Found | Read, parse, display summary |
| Not found | Ask with AskUserQuestion (see references/config/first-time-setup.md) |
| Found | Read, parse, display summary → Continue |
| Not found | ⛔ Run first-time setup ([references/config/first-time-setup.md](references/config/first-time-setup.md)) |
**Supports**: Watermark | Preferred type/style | Custom styles | Language | Output directory
**1.0-1.4**: Handle reference images, determine input type, ask config questions.
Full procedures: [references/workflow.md](references/workflow.md#step-1-pre-check)
---
### Step 2: Setup & Analyze
**2.1 Analyze Content**
| Analysis | Description |
|----------|-------------|
| Content type | Technical / Tutorial / Methodology / Narrative |
| Illustration purpose | information / visualization / imagination |
| Core arguments | 2-5 main points to visualize |
| Visual opportunities | Positions where illustrations add value |
| Recommended type | Based on content signals |
| Recommended density | Based on length and complexity |
**2.2 Extract Core Arguments**
**CRITICAL**: If article uses metaphors, do NOT illustrate literally. Visualize the **underlying concept**.
- Main thesis
- Key concepts reader needs
- Comparisons/contrasts
- Framework/model proposed
**CRITICAL**: If article uses metaphors (e.g., "电锯切西瓜"), do NOT illustrate literally. Visualize the **underlying concept**.
**2.3 Identify Positions**
**Illustrate**:
- Core arguments (REQUIRED)
- Abstract concepts
- Data comparisons
- Processes, workflows
**Do NOT Illustrate**:
- Metaphors literally
- Decorative scenes
- Generic illustrations
Full procedures: [references/workflow.md](references/workflow.md#step-2-setup--analyze)
---
### Step 3: Confirm Settings ⚠️
**Do NOT skip.** Use AskUserQuestion with 3-4 questions in ONE call.
**Do NOT skip.** Use ONE AskUserQuestion call with max 4 questions. **Q1, Q2, Q3 are ALL REQUIRED.**
**Q1: Illustration Type**
- [Recommended based on analysis] (Recommended)
- infographic / scene / flowchart / comparison / framework / timeline / mixed
| Question | Options |
|----------|---------|
| **Q1: Type** ⚠️ | [Recommended], infographic, scene, flowchart, comparison, framework, timeline, mixed |
| **Q2: Density** ⚠️ | minimal (1-2), balanced (3-5), per-section (Recommended), rich (6+) |
| **Q3: Style** ⚠️ | [Recommended], minimal-flat, sci-fi, hand-drawn, editorial, scene, Other |
| **Q4: Language** | When article language ≠ EXTEND.md setting |
**Q2: Density**
- minimal (1-2) - Core concepts only
- balanced (3-5) (Recommended) - Major sections
- rich (6+) - Comprehensive support
**Q3: Style** (ALWAYS ask, even with preferred_style in EXTEND.md)
If EXTEND.md has `preferred_style`:
- [Custom style name + brief description] (Recommended)
- [Top compatible built-in style 1]
- [Top compatible built-in style 2]
- [Top compatible built-in style 3]
If no `preferred_style`:
- [Best compatible from matrix] (Recommended)
- [Other ✓✓ style 1]
- [Other ✓✓ style 2]
- [Other ✓ style]
Style selection based on Type × Style compatibility matrix (references/styles.md).
Full specs: `references/styles/<style>.md`
**Q4** (only if source ≠ user language):
- Language: Source / User language
Full procedures: [references/workflow.md](references/workflow.md#step-3-confirm-settings-)
---
### Step 4: Generate Outline
Save as `outline.md`:
Save as `outline.md` with frontmatter (type, density, style, image_count, references) and illustration entries:
```yaml
---
type: infographic
density: balanced
style: blueprint
image_count: 4
---
## Illustration 1
**Position**: [section] / [paragraph]
**Purpose**: [why this helps]
**Visual Content**: [what to show]
**Type Application**: [how type applies]
**Filename**: 01-infographic-concept-name.png
## Illustration 2
...
```
**Requirements**:
- Each position justified by content needs
- Type applied consistently
- Style reflected in descriptions
- Count matches density
Full template: [references/workflow.md](references/workflow.md#step-4-generate-outline)
---
### Step 5: Generate Images
**5.1 Create Prompts**
1. **Create Prompts**: Follow [references/prompt-construction.md](references/prompt-construction.md)
2. **Select Generation Skill**: Check available skills
3. **Process References**: Handle `direct`/`style`/`palette` usage
4. **Apply Watermark**: If enabled in EXTEND.md
5. **Generate**: Sequential, retry once on failure
Follow [references/prompt-construction.md](references/prompt-construction.md). Save to `prompts/illustration-{slug}.md`.
**5.2 Select Generation Skill**
Check available skills. If multiple, ask user.
**5.3 Apply Watermark** (if enabled)
Add: `Include a subtle watermark "[content]" at [position].`
**5.4 Generate**
1. Generate sequentially
2. After each: "Generated X/N"
3. On failure: retry once, then log and continue
Full procedures: [references/workflow.md](references/workflow.md#step-5-generate-images)
---
### Step 6: Finalize
**6.1 Update Article**
Insert after corresponding paragraph:
```markdown
![description](illustrations/{slug}/NN-{type}-{slug}.png)
```
Alt text: concise description in article's language.
**6.2 Output Summary**
**Update Article**: Insert `![description](path/NN-{type}-{slug}.png)` after corresponding paragraphs.
**Output Summary**:
```
Article Illustration Complete!
Article: [path]
Type: [type] | Density: [level] | Style: [style]
Location: [directory]
Article: [path] | Type: [type] | Density: [level] | Style: [style]
Images: X/N generated
Positions:
- 01-xxx.png → After "[Section]"
- 02-yyy.png → After "[Section]"
[If failures]
Failed:
- NN-zzz.png: [reason]
```
---
@@ -290,6 +165,8 @@ Failed:
```
illustrations/{topic-slug}/
├── source-{slug}.{ext}
├── references/ # Only if references provided
│ └── NN-ref-{slug}.png
├── outline.md
├── prompts/
│ └── illustration-{slug}.md
@@ -303,7 +180,7 @@ illustrations/{topic-slug}/
| Action | Steps |
|--------|-------|
| **Edit** | Update prompt → Regenerate → Update reference |
| **Edit** | Update prompt file FIRST → Regenerate → Update reference |
| **Add** | Identify position → Create prompt → Generate → Update outline → Insert |
| **Delete** | Delete files → Remove reference → Update outline |
@@ -311,13 +188,10 @@ illustrations/{topic-slug}/
| File | Content |
|------|---------|
| [references/workflow.md](references/workflow.md) | Detailed workflow procedures |
| [references/usage.md](references/usage.md) | Command syntax and options |
| [references/styles.md](references/styles.md) | Style gallery & compatibility |
| [references/prompt-construction.md](references/prompt-construction.md) | Prompt templates |
| `references/styles/<style>.md` | Full style specifications |
| `references/config/preferences-schema.md` | EXTEND.md schema |
| `references/config/first-time-setup.md` | First-time setup flow |
## Extension Support
Custom configurations via EXTEND.md. See **Step 1.5** for paths and supported options.
@@ -9,6 +9,14 @@ description: First-time setup flow for baoyu-article-illustrator preferences
When no EXTEND.md is found, guide user through preference setup.
**⛔ BLOCKING OPERATION**: This setup MUST complete before ANY other workflow steps. Do NOT:
- Ask about reference images
- Ask about content/article
- Ask about type or style preferences
- Proceed to content analysis
ONLY ask the questions in this setup flow, save EXTEND.md, then continue.
## Setup Flow
```
@@ -1,5 +1,102 @@
# Prompt Construction
## Prompt File Format
Each prompt file uses YAML frontmatter + content:
```yaml
---
illustration_id: 01
type: infographic
style: blueprint
references: # ⚠️ ONLY if files EXIST in references/ directory
- ref_id: 01
filename: 01-ref-diagram.png
usage: direct # direct | style | palette
---
[Type-specific template content below...]
```
**⚠️ CRITICAL - When to include `references` field**:
| Situation | Action |
|-----------|--------|
| Reference file saved to `references/` | Include in frontmatter ✓ |
| Style extracted verbally (no file) | DO NOT include in frontmatter, append to prompt body instead |
| File path in frontmatter but file doesn't exist | ERROR - remove references field |
**Reference Usage Types** (only when file exists):
| Usage | Description | Generation Action |
|-------|-------------|-------------------|
| `direct` | Primary visual reference | Pass to `--ref` parameter |
| `style` | Style characteristics only | Describe style in prompt text |
| `palette` | Color palette extraction | Include colors in prompt |
**If no reference file but style/palette extracted verbally**, append directly to prompt body:
```
COLORS (from reference):
- Primary: #E8756D coral
- Secondary: #7ECFC0 mint
...
STYLE (from reference):
- Clean lines, minimal shadows
- Gradient backgrounds
...
```
---
## Default Composition Requirements
**Apply to ALL prompts by default**:
| Requirement | Description |
|-------------|-------------|
| **Clean composition** | Simple layouts, no visual clutter |
| **White space** | Generous margins, breathing room around elements |
| **No complex backgrounds** | Solid colors or subtle gradients only, avoid busy textures |
| **Centered or content-appropriate** | Main visual elements centered or positioned by content needs |
| **Matching graphics** | Use graphic elements that align with content theme |
| **Highlight core info** | White space draws attention to key information |
**Add to ALL prompts**:
> Clean composition with generous white space. Simple or no background. Main elements centered or positioned by content needs.
---
## Character Rendering
When depicting people:
| Guideline | Description |
|-----------|-------------|
| **Style** | Simplified cartoon silhouettes or symbolic expressions |
| **Avoid** | Realistic human portrayals, detailed faces |
| **Diversity** | Varied body types when showing multiple people |
| **Emotion** | Express through posture and simple gestures |
**Add to ALL prompts with human figures**:
> Human figures: simplified stylized silhouettes or symbolic representations, not photorealistic.
---
## Text in Illustrations
| Element | Guideline |
|---------|-----------|
| **Size** | Large, prominent, immediately readable |
| **Style** | Handwritten fonts preferred for warmth |
| **Content** | Concise keywords and core concepts only |
| **Language** | Match article language |
**Add to prompts with text**:
> Text should be large and prominent with handwritten-style fonts. Keep minimal, focus on keywords.
---
## Principles
Good prompts must include:
@@ -31,6 +128,13 @@ STYLE: [style characteristics]
ASPECT: 16:9
```
**Infographic + vector-illustration**:
```
Flat vector illustration infographic. Clean black outlines on all elements.
COLORS: Cream background (#F5F0E6), Coral Red (#E07A5F), Mint Green (#81B29A), Mustard Yellow (#F2CC8F)
ELEMENTS: Geometric simplified icons, no gradients, playful decorative elements (dots, stars)
```
### Scene
```
@@ -61,6 +165,13 @@ STYLE: [style characteristics]
ASPECT: 16:9
```
**Flowchart + vector-illustration**:
```
Flat vector flowchart with bold arrows and geometric step containers.
COLORS: Cream background (#F5F0E6), steps in Coral/Mint/Mustard, black outlines
ELEMENTS: Rounded rectangles, thick arrows, simple icons per step
```
### Comparison
```
@@ -79,6 +190,13 @@ STYLE: [style characteristics]
ASPECT: 16:9
```
**Comparison + vector-illustration**:
```
Flat vector comparison with split layout. Clear visual separation.
COLORS: Left side Coral (#E07A5F), Right side Mint (#81B29A), cream background
ELEMENTS: Bold icons, black outlines, centered divider line
```
### Framework
```
@@ -95,6 +213,13 @@ STYLE: [style characteristics]
ASPECT: 16:9
```
**Framework + vector-illustration**:
```
Flat vector framework diagram with geometric nodes and bold connectors.
COLORS: Cream background (#F5F0E6), nodes in Coral/Mint/Mustard/Blue, black outlines
ELEMENTS: Rounded rectangles or circles for nodes, thick connecting lines
```
### Timeline
```
@@ -1,10 +1,28 @@
# Style Reference
## Core Styles
Simplified style tier for quick selection:
| Core Style | Maps To | Best For |
|------------|---------|----------|
| `vector` | vector-illustration | Knowledge articles, tutorials, tech content |
| `minimal-flat` | notion | General, knowledge sharing, SaaS |
| `sci-fi` | blueprint | AI, frontier tech, system design |
| `hand-drawn` | sketch/warm | Relaxed, reflective, casual content |
| `editorial` | editorial | Processes, data, journalism |
| `scene` | warm/watercolor | Narratives, emotional, lifestyle |
Use Core Styles for most cases. See full Style Gallery below for granular control.
---
## Style Gallery
| Style | Description | Best For |
|-------|-------------|----------|
| `notion` (Default) | Minimalist hand-drawn line art | Knowledge sharing, SaaS, productivity |
| `vector-illustration` | Clean flat vector art with bold shapes | Knowledge articles, tutorials, tech content |
| `notion` | Minimalist hand-drawn line art | Knowledge sharing, SaaS, productivity |
| `elegant` | Refined, sophisticated | Business, thought leadership |
| `warm` | Friendly, approachable | Personal growth, lifestyle, education |
| `minimal` | Ultra-clean, zen-like | Philosophy, minimalism, core concepts |
@@ -12,19 +30,31 @@
| `watercolor` | Soft artistic with natural warmth | Lifestyle, travel, creative |
| `editorial` | Magazine-style infographic | Tech explainers, journalism |
| `scientific` | Academic precise diagrams | Biology, chemistry, technical research |
| `chalkboard` | Classroom chalk drawing style | Education, teaching, explanations |
| `fantasy-animation` | Ghibli/Disney-inspired hand-drawn | Storybook, magical, emotional |
| `flat` | Modern bold geometric shapes | Modern digital, contemporary |
| `flat-doodle` | Cute flat with bold outlines | Cute, friendly, approachable |
| `intuition-machine` | Technical briefing with aged paper | Technical briefings, academic |
| `nature` | Organic earthy illustration | Environmental, wellness |
| `pixel-art` | Retro 8-bit gaming aesthetic | Gaming, retro tech |
| `playful` | Whimsical pastel doodles | Fun, casual, educational |
| `retro` | 80s/90s neon geometric | 80s/90s nostalgic, bold |
| `sketch` | Raw pencil notebook style | Brainstorming, creative exploration |
| `sketch-notes` | Soft hand-drawn warm notes | Educational, warm notes |
| `vintage` | Aged parchment historical | Historical, heritage |
Full specifications: `references/styles/<style>.md`
## Type × Style Compatibility Matrix
| | notion | warm | minimal | blueprint | watercolor | elegant | editorial | scientific |
|---|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|
| infographic | ✓✓ | ✓ | ✓✓ | ✓✓ | ✓ | ✓✓ | ✓✓ | ✓✓ |
| scene | ✓ | ✓✓ | ✓ | ✗ | ✓✓ | ✓ | ✓ | ✗ |
| flowchart | ✓✓ | ✓ | ✓ | ✓✓ | ✗ | ✓ | ✓✓ | ✓ |
| comparison | ✓✓ | ✓ | ✓✓ | ✓ | ✓ | ✓✓ | ✓✓ | ✓ |
| framework | ✓✓ | ✓ | ✓✓ | ✓✓ | ✗ | ✓✓ | ✓ | ✓✓ |
| timeline | ✓✓ | ✓ | ✓ | ✓ | ✓✓ | ✓✓ | ✓✓ | ✓ |
| | vector-illustration | notion | warm | minimal | blueprint | watercolor | elegant | editorial | scientific |
|---|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|
| infographic | ✓✓ | ✓✓ | ✓ | ✓✓ | ✓✓ | ✓ | ✓✓ | ✓✓ | ✓✓ |
| scene | ✓ | ✓ | ✓✓ | ✓ | ✗ | ✓✓ | ✓ | ✓ | ✗ |
| flowchart | ✓✓ | ✓✓ | ✓ | ✓ | ✓✓ | ✗ | ✓ | ✓✓ | ✓ |
| comparison | ✓✓ | ✓✓ | ✓ | ✓✓ | ✓ | ✓ | ✓✓ | ✓✓ | ✓ |
| framework | ✓✓ | ✓✓ | ✓ | ✓✓ | ✓✓ | ✗ | ✓✓ | ✓ | ✓✓ |
| timeline | ✓ | ✓✓ | ✓ | ✓ | ✓ | ✓✓ | ✓✓ | ✓✓ | ✓ |
✓✓ = highly recommended | ✓ = compatible | ✗ = not recommended
@@ -32,30 +62,57 @@ Full specifications: `references/styles/<style>.md`
| Type | Primary Style | Secondary Styles |
|------|---------------|------------------|
| infographic | blueprint | notion, editorial, scientific |
| infographic | vector-illustration | notion, blueprint, editorial |
| scene | warm | watercolor, elegant |
| flowchart | notion | blueprint, editorial |
| comparison | notion | elegant, editorial |
| framework | blueprint | notion, scientific |
| flowchart | vector-illustration | notion, blueprint |
| comparison | vector-illustration | notion, elegant |
| framework | blueprint | vector-illustration, notion |
| timeline | elegant | warm, editorial |
## Auto Selection by Content Signals
| Content Signals | Recommended Type | Recommended Style |
|-----------------|------------------|-------------------|
| API, metrics, data, comparison, numbers | infographic | blueprint, notion |
| API, metrics, data, comparison, numbers | infographic | blueprint, vector-illustration |
| Knowledge, concept, tutorial, learning, guide | infographic | vector-illustration, notion |
| Tech, AI, programming, development, code | infographic | vector-illustration, blueprint |
| How-to, steps, workflow, process, tutorial | flowchart | vector-illustration, notion |
| Framework, model, architecture, principles | framework | blueprint, vector-illustration |
| vs, pros/cons, before/after, alternatives | comparison | vector-illustration, notion |
| Story, emotion, journey, experience, personal | scene | warm, watercolor |
| How-to, steps, workflow, process, tutorial | flowchart | notion, minimal |
| vs, pros/cons, before/after, alternatives | comparison | notion, elegant |
| Framework, model, architecture, principles | framework | blueprint, notion |
| History, timeline, progress, evolution | timeline | elegant, warm |
| Knowledge, concept, productivity, SaaS, tool | infographic | notion |
| Productivity, SaaS, tool, app, software | infographic | notion, vector-illustration |
| Business, professional, strategy, corporate | framework | elegant |
| Biology, chemistry, medical, scientific | infographic | scientific |
| Explainer, journalism, magazine, investigation | infographic | editorial |
## Style Characteristics by Type
### infographic + vector-illustration
- Clean flat vector shapes, bold geometric forms
- Vibrant but harmonious color palette
- Clear visual hierarchy with icons and labels
- Modern, professional, highly readable
- Perfect for knowledge articles and tutorials
### flowchart + vector-illustration
- Bold arrows and connectors
- Distinct step containers with icons
- Clean progression flow
- High contrast for readability
### comparison + vector-illustration
- Split layout with clear visual separation
- Bold iconography for each side
- Color-coded distinctions
- Easy at-a-glance comparison
### framework + vector-illustration
- Geometric node representations
- Clear hierarchical structure
- Bold connecting lines
- Modern system diagram aesthetic
### infographic + blueprint
- Technical precision, schematic lines
- Grid-based layout, clear zones
@@ -0,0 +1,355 @@
# Detailed Workflow Procedures
## Step 1: Pre-check
### 1.0 Detect & Save Reference Images ⚠️ REQUIRED if images provided
Check if user provided reference images. Handle based on input type:
| Input Type | Action |
|------------|--------|
| Image file path provided | Copy to `references/` subdirectory → can use `--ref` |
| Image in conversation (no path) | **ASK user for file path** with AskUserQuestion |
| User can't provide path | Extract style/palette verbally → append to prompts (NO frontmatter references) |
**CRITICAL**: Only add `references` to prompt frontmatter if files are ACTUALLY SAVED to `references/` directory.
**If user provides file path**:
1. Copy to `references/NN-ref-{slug}.png`
2. Create description: `references/NN-ref-{slug}.md`
3. Verify files exist before proceeding
**If user can't provide path** (extracted verbally):
1. Analyze image visually, extract: colors, style, composition
2. Create `references/extracted-style.md` with extracted info
3. DO NOT add `references` to prompt frontmatter
4. Instead, append extracted style/colors directly to prompt text
**Description File Format** (only when file saved):
```yaml
---
ref_id: NN
filename: NN-ref-{slug}.png
---
[User's description or auto-generated description]
```
**Verification** (only for saved files):
```
Reference Images Saved:
- 01-ref-{slug}.png ✓ (can use --ref)
- 02-ref-{slug}.png ✓ (can use --ref)
```
**Or for extracted style**:
```
Reference Style Extracted (no file):
- Colors: #E8756D coral, #7ECFC0 mint...
- Style: minimal flat vector, clean lines...
→ Will append to prompt text (not --ref)
```
---
### 1.1 Determine Input Type
| Input | Output Directory | Next |
|-------|------------------|------|
| File path | Ask user (1.2) | → 1.2 |
| Pasted content | `illustrations/{topic-slug}/` | → 1.4 |
**Backup rule for pasted content**: If `source.md` exists in target directory, rename to `source-backup-YYYYMMDD-HHMMSS.md` before saving.
### 1.2-1.4 Configuration (file path input only)
Check preferences and existing state, then ask ALL needed questions in ONE AskUserQuestion call (max 4 questions).
**Questions to include** (skip if preference exists or not applicable):
| Question | When to Ask | Options |
|----------|-------------|---------|
| Output directory | No `default_output_dir` in EXTEND.md | `{article-dir}/`, `{article-dir}/imgs/` (Recommended), `{article-dir}/illustrations/`, `illustrations/{topic-slug}/` |
| Existing images | Target dir has `.png/.jpg/.webp` files | `supplement`, `overwrite`, `regenerate` |
| Article update | Always (file path input) | `update`, `copy` |
**Preference Values** (if configured, skip asking):
| `default_output_dir` | Path |
|----------------------|------|
| `same-dir` | `{article-dir}/` |
| `imgs-subdir` | `{article-dir}/imgs/` |
| `illustrations-subdir` | `{article-dir}/illustrations/` |
| `independent` | `illustrations/{topic-slug}/` |
### 1.5 Load Preferences (EXTEND.md) ⛔ BLOCKING
**CRITICAL**: If EXTEND.md not found, MUST complete first-time setup before ANY other questions or steps. Do NOT proceed to reference images, do NOT ask about content, do NOT ask about type/style — ONLY complete the preferences setup first.
```bash
test -f .baoyu-skills/baoyu-article-illustrator/EXTEND.md && echo "project"
test -f "$HOME/.baoyu-skills/baoyu-article-illustrator/EXTEND.md" && echo "user"
```
| Result | Action |
|--------|--------|
| Found | Read, parse, display summary → Continue |
| Not found | ⛔ **BLOCKING**: Run first-time setup ONLY ([config/first-time-setup.md](config/first-time-setup.md)) → Complete and save EXTEND.md → Then continue |
**Supports**: Watermark | Preferred type/style | Custom styles | Language | Output directory
---
## Step 2: Setup & Analyze
### 2.1 Analyze Content
| Analysis | Description |
|----------|-------------|
| Content type | Technical / Tutorial / Methodology / Narrative |
| Illustration purpose | information / visualization / imagination |
| Core arguments | 2-5 main points to visualize |
| Visual opportunities | Positions where illustrations add value |
| Recommended type | Based on content signals and purpose |
| Recommended density | Based on length and complexity |
### 2.2 Extract Core Arguments
- Main thesis
- Key concepts reader needs
- Comparisons/contrasts
- Framework/model proposed
**CRITICAL**: If article uses metaphors (e.g., "电锯切西瓜"), do NOT illustrate literally. Visualize the **underlying concept**.
### 2.3 Identify Positions
**Illustrate**:
- Core arguments (REQUIRED)
- Abstract concepts
- Data comparisons
- Processes, workflows
**Do NOT Illustrate**:
- Metaphors literally
- Decorative scenes
- Generic illustrations
### 2.4 Analyze Reference Images (if provided in Step 1.0)
For each reference image:
| Analysis | Description |
|----------|-------------|
| Visual characteristics | Style, colors, composition |
| Content/subject | What the reference depicts |
| Suitable positions | Which sections match this reference |
| Style match | Which illustration types/styles align |
| Usage recommendation | `direct` / `style` / `palette` |
| Usage | When to Use |
|-------|-------------|
| `direct` | Reference matches desired output closely |
| `style` | Extract visual style characteristics only |
| `palette` | Extract color scheme only |
---
## Step 3: Confirm Settings ⚠️
**Do NOT skip.** Use ONE AskUserQuestion call with max 4 questions. **Q1, Q2, Q3 are ALL REQUIRED.**
### Q1: Illustration Type ⚠️ REQUIRED
- [Recommended based on analysis] (Recommended)
- infographic / scene / flowchart / comparison / framework / timeline / mixed
### Q2: Density ⚠️ REQUIRED - DO NOT SKIP
- minimal (1-2) - Core concepts only
- balanced (3-5) - Major sections
- per-section - At least 1 per section/chapter (Recommended)
- rich (6+) - Comprehensive coverage
### Q3: Style ⚠️ REQUIRED (ALWAYS ask, even with preferred_style in EXTEND.md)
If EXTEND.md has `preferred_style`:
- [Custom style name + brief description] (Recommended)
- [Top compatible core style 1]
- [Top compatible core style 2]
- Other (see full Style Gallery)
If no `preferred_style` (present Core Styles first):
- [Best compatible core style] (Recommended)
- [Other compatible core style 1]
- [Other compatible core style 2]
- Other (see full Style Gallery)
**Core Styles** (simplified selection):
| Core Style | Best For |
|------------|----------|
| `minimal-flat` | General, knowledge sharing, SaaS |
| `sci-fi` | AI, frontier tech, system design |
| `hand-drawn` | Relaxed, reflective, casual |
| `editorial` | Processes, data, journalism |
| `scene` | Narratives, emotional, lifestyle |
Style selection based on Type × Style compatibility matrix (styles.md).
Full specs: `styles/<style>.md`
### Q4: Image Text Language ⚠️ REQUIRED when article language ≠ EXTEND.md `language`
Detect article language from content. If different from EXTEND.md `language` setting, MUST ask:
- Article language (match article content) (Recommended)
- EXTEND.md language (user's general preference)
**Skip only if**: Article language matches EXTEND.md `language`, or EXTEND.md has no `language` setting.
### Display Reference Usage (if references detected in Step 1.0)
When presenting outline preview to user, show reference assignments:
```
Reference Images:
| Ref | Filename | Recommended Usage |
|-----|----------|-------------------|
| 01 | 01-ref-diagram.png | direct → Illustration 1, 3 |
| 02 | 02-ref-chart.png | palette → Illustration 2 |
```
---
## Step 4: Generate Outline
Save as `outline.md`:
```yaml
---
type: infographic
density: balanced
style: blueprint
image_count: 4
references: # Only if references provided
- ref_id: 01
filename: 01-ref-diagram.png
description: "Technical diagram showing system architecture"
- ref_id: 02
filename: 02-ref-chart.png
description: "Color chart with brand palette"
---
## Illustration 1
**Position**: [section] / [paragraph]
**Purpose**: [why this helps]
**Visual Content**: [what to show]
**Type Application**: [how type applies]
**References**: [01] # Optional: list ref_ids used
**Reference Usage**: direct # direct | style | palette
**Filename**: 01-infographic-concept-name.png
## Illustration 2
...
```
**Requirements**:
- Each position justified by content needs
- Type applied consistently
- Style reflected in descriptions
- Count matches density
- References assigned based on Step 2.4 analysis
---
## Step 5: Generate Images
### 5.1 Create Prompts
Follow [prompt-construction.md](prompt-construction.md). Save to `prompts/illustration-{slug}.md`.
- **Backup rule**: If prompt file exists, rename to `prompts/illustration-{slug}-backup-YYYYMMDD-HHMMSS.md`
**CRITICAL - References in Frontmatter**:
- Only add `references` field if files ACTUALLY EXIST in `references/` directory
- If style/palette was extracted verbally (no file), append info to prompt BODY instead
- Before writing frontmatter, verify: `test -f references/NN-ref-{slug}.png`
### 5.2 Select Generation Skill
Check available skills. If multiple, ask user.
### 5.3 Process References ⚠️ REQUIRED if references saved in Step 1.0
**DO NOT SKIP if user provided reference images.** For each illustration with references:
1. **VERIFY files exist first**:
```bash
test -f references/NN-ref-{slug}.png && echo "exists" || echo "MISSING"
```
- If file MISSING but in frontmatter → ERROR, fix frontmatter or remove references field
- If file exists → proceed with processing
2. Read prompt frontmatter for reference info
3. Process based on usage type:
| Usage | Action | Example |
|-------|--------|---------|
| `direct` | Add reference path to `--ref` parameter | `--ref references/01-ref-brand.png` |
| `style` | Analyze reference, append style traits to prompt | "Style: clean lines, gradient backgrounds..." |
| `palette` | Extract colors from reference, append to prompt | "Colors: #E8756D coral, #7ECFC0 mint..." |
4. Check image generation skill capability:
| Skill Supports `--ref` | Action |
|------------------------|--------|
| Yes (e.g., baoyu-image-gen with Google) | Pass reference images via `--ref` |
| No | Convert to text description, append to prompt |
**Verification**: Before generating, confirm reference processing:
```
Reference Processing:
- Illustration 1: using 01-ref-brand.png (direct) ✓
- Illustration 2: extracted palette from 02-ref-style.png ✓
```
### 5.4 Apply Watermark (if enabled)
Add: `Include a subtle watermark "[content]" at [position].`
### 5.5 Generate
1. For each illustration:
- **Backup rule**: If image file exists, rename to `NN-{type}-{slug}-backup-YYYYMMDD-HHMMSS.md`
- If references with `direct` usage: include `--ref` parameter
- Generate image
2. After each: "Generated X/N"
3. On failure: retry once, then log and continue
---
## Step 6: Finalize
### 6.1 Update Article
Insert after corresponding paragraph:
```markdown
![description](illustrations/{slug}/NN-{type}-{slug}.png)
```
Alt text: concise description in article's language.
### 6.2 Output Summary
```
Article Illustration Complete!
Article: [path]
Type: [type] | Density: [level] | Style: [style]
Location: [directory]
Images: X/N generated
Positions:
- 01-xxx.png → After "[Section]"
- 02-yyy.png → After "[Section]"
[If failures]
Failed:
- NN-zzz.png: [reason]
```
+38 -4
View File
@@ -153,7 +153,11 @@ Technical terms remain in English.
```
Comic Progress:
- [ ] Step 1: Setup & Analyze (1.1 Preferences, 1.2 Analyze, 1.3 Check existing)
- [ ] Step 1: Setup & Analyze
- [ ] 1.1 Preferences (EXTEND.md) ⛔ BLOCKING
- [ ] Found → load preferences → continue
- [ ] Not found → run first-time setup → MUST complete before other steps
- [ ] 1.2 Analyze, 1.3 Check existing
- [ ] Step 2: Confirmation - Style & options ⚠️ REQUIRED
- [ ] Step 3: Generate storyboard + characters
- [ ] Step 4: Review outline (conditional)
@@ -169,14 +173,22 @@ Comic Progress:
### Flow
```
Input → Preferences → Analyze → [Check Existing?] → [Confirm: Style + Reviews] → Storyboard → [Review?] → Prompts → [Review?] → Images → PDF → Complete
Input → [Preferences] ─┬─ Found → Continue
└─ Not found → First-Time Setup ⛔ BLOCKING
└─ Complete setup → Save EXTEND.md → Continue
┌─────────────────────────────────────────────────────────────────────┘
Analyze → [Check Existing?] → [Confirm: Style + Reviews] → Storyboard → [Review?] → Prompts → [Review?] → Images → PDF → Complete
```
### Step Summary
| Step | Action | Key Output |
|------|--------|------------|
| 1.1 | Load EXTEND.md preferences | Config loaded |
| 1.1 | Load EXTEND.md preferences ⛔ BLOCKING if not found | Config loaded |
| 1.2 | Analyze content | `analysis.md` |
| 1.3 | Check existing directory | Handle conflicts |
| 2 | Confirm style, focus, audience, reviews | User preferences |
@@ -194,6 +206,7 @@ Input → Preferences → Analyze → [Check Existing?] → [Confirm: Style + Re
**Character reference is MANDATORY for visual consistency.**
**7.1 Generate character sheet first**:
- **Backup rule**: If `characters/characters.png` exists, rename to `characters/characters-backup-YYYYMMDD-HHMMSS.png`
```bash
# Use Reference Sheet Prompt from characters/characters.md
npx -y bun ${SKILL_DIR}/../baoyu-image-gen/scripts/main.ts \
@@ -214,6 +227,10 @@ Compress to reduce token usage when used as reference image:
| Supports `--ref` | Pass `characters/characters.png` with EVERY page |
| No `--ref` support | Prepend character descriptions to EVERY prompt file |
**Backup rules for page generation**:
- If prompt file exists: rename to `prompts/NN-{cover|page}-[slug]-backup-YYYYMMDD-HHMMSS.md`
- If image file exists: rename to `NN-{cover|page}-[slug]-backup-YYYYMMDD-HHMMSS.png`
```bash
# Example: ALWAYS include --ref for consistency
npx -y bun ${SKILL_DIR}/../baoyu-image-gen/scripts/main.ts \
@@ -224,13 +241,20 @@ npx -y bun ${SKILL_DIR}/../baoyu-image-gen/scripts/main.ts \
**Full workflow details**: [references/workflow.md](references/workflow.md)
### EXTEND.md Paths
### EXTEND.md Paths ⛔ BLOCKING
**CRITICAL**: If EXTEND.md not found, MUST complete first-time setup before ANY other questions or steps. Do NOT proceed to content analysis, do NOT ask about art style, do NOT ask about tone — ONLY complete the preferences setup first.
| Path | Location |
|------|----------|
| `.baoyu-skills/baoyu-comic/EXTEND.md` | Project directory |
| `$HOME/.baoyu-skills/baoyu-comic/EXTEND.md` | User home |
| Result | Action |
|--------|--------|
| Found | Read, parse, display summary → Continue |
| Not found | ⛔ **BLOCKING**: Run first-time setup ONLY ([references/config/first-time-setup.md](references/config/first-time-setup.md)) → Complete and save EXTEND.md → Then continue |
**EXTEND.md Supports**: Watermark | Preferred art/tone/layout | Custom style definitions | Character presets | Language preference
Schema: [references/config/preferences-schema.md](references/config/preferences-schema.md)
@@ -259,6 +283,16 @@ Schema: [references/config/preferences-schema.md](references/config/preferences-
- [config/first-time-setup.md](references/config/first-time-setup.md) - First-time setup
- [config/watermark-guide.md](references/config/watermark-guide.md) - Watermark configuration
## Page Modification
| Action | Steps |
|--------|-------|
| **Edit** | **Update prompt file FIRST**`--regenerate N` → Regenerate PDF |
| **Add** | Create prompt at position → Generate with character ref → Renumber subsequent → Update storyboard → Regenerate PDF |
| **Delete** | Remove files → Renumber subsequent → Update storyboard → Regenerate PDF |
**IMPORTANT**: When updating pages, ALWAYS update the prompt file (`prompts/NN-{cover|page}-[slug].md`) FIRST before regenerating. This ensures changes are documented and reproducible.
## Notes
- Image generation: 10-30 seconds per page
@@ -9,6 +9,14 @@ description: First-time setup flow for baoyu-comic preferences
When no EXTEND.md is found, guide user through preference setup.
**⛔ BLOCKING OPERATION**: This setup MUST complete before ANY other workflow steps. Do NOT:
- Ask about content/source material
- Ask about art style or tone
- Ask about layout preferences
- Proceed to content analysis
ONLY ask the questions in this setup flow, save EXTEND.md, then continue.
## Setup Flow
```
+9 -5
View File
@@ -84,6 +84,7 @@ Read source content, save it if needed, and perform deep analysis.
1. **Save source content** (if not already a file):
- If user provides a file path: use as-is
- If user pastes content: save to `source.md` in target directory
- **Backup rule**: If `source.md` exists, rename to `source-backup-YYYYMMDD-HHMMSS.md`
2. Read source content
3. **Deep analysis** following `analysis-framework.md`:
- Target audience identification
@@ -329,6 +330,7 @@ Create image generation prompts for all pages.
1. Create prompt following art style + tone guidelines
2. Include character visual descriptions for consistency
3. Save to `prompts/NN-{cover|page}-[slug].md`
- **Backup rule**: If prompt file exists, rename to `prompts/NN-{cover|page}-[slug]-backup-YYYYMMDD-HHMMSS.md`
**Prompt File Format**:
```markdown
@@ -405,8 +407,9 @@ With confirmed prompts from Step 5/6:
### 7.1 Generate Character Reference Sheet (first)
1. Use Reference Sheet Prompt from `characters/characters.md`
2. Generate → `characters/characters.png`
3. This ensures visual consistency for all subsequent pages
2. **Backup rule**: If `characters/characters.png` exists, rename to `characters/characters-backup-YYYYMMDD-HHMMSS.png`
3. Generate → `characters/characters.png`
4. This ensures visual consistency for all subsequent pages
### 7.2 Generate Comic Pages
@@ -453,9 +456,10 @@ When skill does NOT support reference images, create combined prompt files:
**For each page (cover + pages)**:
1. Read prompt from `prompts/NN-{cover|page}-[slug].md`
2. Generate image using Strategy A or B (based on skill capability)
3. Save to `NN-{cover|page}-[slug].png`
4. Report progress after each generation: "Generated X/N: [page title]"
2. **Backup rule**: If image file exists, rename to `NN-{cover|page}-[slug]-backup-YYYYMMDD-HHMMSS.png`
3. Generate image using Strategy A or B (based on skill capability)
4. Save to `NN-{cover|page}-[slug].png`
5. Report progress after each generation: "Generated X/N: [page title]"
**Session Management**:
If image generation skill supports `--sessionId`:
+3 -1
View File
@@ -147,7 +147,9 @@ async function processFile(
const outputSize = statSync(tempOutput).size;
if (!opts.keep && absInput !== output) {
unlinkSync(absInput);
const ext = extname(absInput);
const base = absInput.slice(0, -ext.length);
renameSync(absInput, `${base}_original${ext}`);
}
renameSync(tempOutput, output);
+107 -162
View File
@@ -1,6 +1,6 @@
---
name: baoyu-cover-image
description: Generates article cover images with 5 dimensions (type, palette, rendering, text, mood) combining 9 color palettes and 6 rendering styles. Supports cinematic (2.35:1), widescreen (16:9), and square (1:1) aspects. Use when user asks to "generate cover image", "create article cover", "make cover", or mentions "封面图".
description: Generates article cover images with 5 dimensions (type, palette, rendering, text, mood) combining 9 color palettes and 6 rendering styles. Supports cinematic (2.35:1), widescreen (16:9), and square (1:1) aspects. Use when user asks to "generate cover image", "create article cover", or "make cover".
---
# Cover Image Generator
@@ -10,29 +10,23 @@ Generate elegant cover images for articles with 5-dimensional customization.
## Usage
```bash
# Auto-select all dimensions based on content
# Auto-select dimensions based on content
/baoyu-cover-image path/to/article.md
# Quick mode: skip confirmation, use auto-selection
# Quick mode: skip confirmation
/baoyu-cover-image article.md --quick
# Specify dimensions (new 5D system)
# Specify dimensions
/baoyu-cover-image article.md --type conceptual --palette warm --rendering flat-vector
/baoyu-cover-image article.md --text title-subtitle --mood bold
# Style presets (backward-compatible shorthand for palette + rendering)
# Style presets (shorthand for palette + rendering)
/baoyu-cover-image article.md --style blueprint
/baoyu-cover-image article.md --style blueprint --rendering hand-drawn # override rendering
# Visual only (no title text)
/baoyu-cover-image article.md --no-title
# With reference images
/baoyu-cover-image article.md --ref style-ref.png
# Direct content input
/baoyu-cover-image
[paste content]
# Direct input with options
/baoyu-cover-image --palette mono --rendering digital --aspect 1:1 --quick
/baoyu-cover-image --palette mono --aspect 1:1 --quick
[paste content]
```
@@ -40,115 +34,70 @@ Generate elegant cover images for articles with 5-dimensional customization.
| Option | Description |
|--------|-------------|
| `--type <name>` | Cover type: hero, conceptual, typography, metaphor, scene, minimal |
| `--palette <name>` | Color palette: warm, elegant, cool, dark, earth, vivid, pastel, mono, retro |
| `--rendering <name>` | Rendering style: flat-vector, hand-drawn, painterly, digital, pixel, chalk |
| `--style <name>` | Preset shorthand (expands to palette + rendering, see [Style Presets](references/style-presets.md)) |
| `--text <level>` | Text density: none, title-only, title-subtitle, text-rich |
| `--mood <level>` | Emotional intensity: subtle, balanced, bold |
| `--type <name>` | hero, conceptual, typography, metaphor, scene, minimal |
| `--palette <name>` | warm, elegant, cool, dark, earth, vivid, pastel, mono, retro |
| `--rendering <name>` | flat-vector, hand-drawn, painterly, digital, pixel, chalk |
| `--style <name>` | Preset shorthand (see [Style Presets](references/style-presets.md)) |
| `--text <level>` | none, title-only, title-subtitle, text-rich |
| `--mood <level>` | subtle, balanced, bold |
| `--font <name>` | clean, handwritten, serif, display |
| `--aspect <ratio>` | 16:9 (default), 2.35:1, 4:3, 3:2, 1:1, 3:4 |
| `--lang <code>` | Title language (en, zh, ja, etc.) |
| `--no-title` | Alias for `--text none` |
| `--quick` | Skip confirmation, use auto-selection for missing dimensions |
| `--quick` | Skip confirmation, use auto-selection |
| `--ref <files...>` | Reference images for style/composition guidance |
## Five Dimensions
| Dimension | Controls | Values | Default |
|-----------|----------|--------|---------|
| **Type** | Visual composition, information structure | hero, conceptual, typography, metaphor, scene, minimal | auto |
| **Palette** | Colors, color scheme, decorative hints | warm, elegant, cool, dark, earth, vivid, pastel, mono, retro | auto |
| **Rendering** | Line quality, texture, depth, element style | flat-vector, hand-drawn, painterly, digital, pixel, chalk | auto |
| **Text** | Text density, information hierarchy | none, title-only, title-subtitle, text-rich | title-only |
| **Mood** | Emotional intensity, visual weight | subtle, balanced, bold | balanced |
| Dimension | Values | Default |
|-----------|--------|---------|
| **Type** | hero, conceptual, typography, metaphor, scene, minimal | auto |
| **Palette** | warm, elegant, cool, dark, earth, vivid, pastel, mono, retro | auto |
| **Rendering** | flat-vector, hand-drawn, painterly, digital, pixel, chalk | auto |
| **Text** | none, title-only, title-subtitle, text-rich | title-only |
| **Mood** | subtle, balanced, bold | balanced |
| **Font** | clean, handwritten, serif, display | clean |
Dimensions can be freely combined. Auto-selection rules: [references/auto-selection.md](references/auto-selection.md)
Auto-selection rules: [references/auto-selection.md](references/auto-selection.md)
## Type Gallery
## Galleries
| Type | Description | Best For |
|------|-------------|----------|
| `hero` | Large visual impact, title overlay | Product launch, brand promotion, major announcements |
| `conceptual` | Concept visualization, abstract core ideas | Technical articles, methodology, architecture design |
| `typography` | Text-focused layout, prominent title | Opinion pieces, quotes, insights |
| `metaphor` | Visual metaphor, concrete expressing abstract | Philosophy, growth, personal development |
| `scene` | Atmospheric scene, narrative feel | Stories, travel, lifestyle |
| `minimal` | Minimalist composition, generous whitespace | Zen, focus, core concepts |
**Types**: hero, conceptual, typography, metaphor, scene, minimal
→ Details: [references/types.md](references/types.md)
Type composition details: [references/types.md](references/types.md)
**Palettes**: warm, elegant, cool, dark, earth, vivid, pastel, mono, retro
→ Details: [references/palettes/](references/palettes/)
## Palette Gallery
**Renderings**: flat-vector, hand-drawn, painterly, digital, pixel, chalk
→ Details: [references/renderings/](references/renderings/)
| Palette | Vibe | Primary Colors |
|---------|------|----------------|
| `warm` | Friendly, approachable | Orange, golden yellow, terracotta |
| `elegant` | Sophisticated, refined | Soft coral, muted teal, dusty rose |
| `cool` | Technical, professional | Engineering blue, navy, cyan |
| `dark` | Cinematic, premium | Electric purple, cyan, magenta |
| `earth` | Natural, organic | Forest green, sage, earth brown |
| `vivid` | Energetic, bold | Bright red, neon green, electric blue |
| `pastel` | Gentle, whimsical | Soft pink, mint, lavender |
| `mono` | Clean, focused | Black, near-black, white |
| `retro` | Nostalgic, vintage | Muted orange, dusty pink, maroon |
**Text Levels**: none (pure visual) | title-only (default) | title-subtitle | text-rich (with tags)
→ Details: [references/dimensions/text.md](references/dimensions/text.md)
Palette definitions: [references/palettes/](references/palettes/)
**Mood Levels**: subtle (low contrast) | balanced (default) | bold (high contrast)
→ Details: [references/dimensions/mood.md](references/dimensions/mood.md)
## Rendering Gallery
| Rendering | Description | Key Characteristics |
|-----------|-------------|---------------------|
| `flat-vector` | Clean modern vector | Uniform outlines, flat fills, geometric icons |
| `hand-drawn` | Sketchy organic illustration | Imperfect strokes, paper texture, doodles |
| `painterly` | Soft watercolor/paint | Brush strokes, color bleeds, soft edges |
| `digital` | Polished modern digital | Precise edges, subtle gradients, UI components |
| `pixel` | Retro 8-bit pixel art | Pixel grid, dithering, chunky shapes |
| `chalk` | Chalk on blackboard | Chalk strokes, dust effects, board texture |
Rendering definitions: [references/renderings/](references/renderings/)
## Text & Mood
| Text Level | Title | Subtitle | Tags | Use Case |
|------------|:-----:|:--------:|:----:|----------|
| `none` | - | - | - | Pure visual, no text |
| `title-only` | ✓ (≤8 字) | - | - | Simple headline (default) |
| `title-subtitle` | ✓ | ✓ (≤15 字) | - | Title + supporting context |
| `text-rich` | ✓ | ✓ | ✓ (2-4) | Information-dense |
| Mood | Contrast | Saturation | Weight | Use Case |
|------|:--------:|:----------:|:------:|----------|
| `subtle` | Low | Muted | Light | Corporate, thought leadership |
| `balanced` | Medium | Normal | Medium | General articles (default) |
| `bold` | High | Vivid | Heavy | Announcements, promotions |
Full guides: [references/dimensions/text.md](references/dimensions/text.md) | [references/dimensions/mood.md](references/dimensions/mood.md)
## Style Presets & Compatibility
- **Style Presets**: `--style X` expands to palette + rendering. See [references/style-presets.md](references/style-presets.md)
- **Compatibility Matrices**: Palette×Rendering, Type×Rendering, Type×Text, Type×Mood. See [references/compatibility.md](references/compatibility.md)
- ✓✓ = highly recommended | ✓ = compatible | ✗ = not recommended
**Fonts**: clean (sans-serif) | handwritten | serif | display (bold decorative)
→ Details: [references/dimensions/font.md](references/dimensions/font.md)
## File Structure
Output directory depends on `default_output_dir` preference:
| Preference | Output Path |
|------------|-------------|
| `same-dir` | `{article-dir}/` |
| `imgs-subdir` | `{article-dir}/imgs/` |
| `independent` (default) | `cover-image/{topic-slug}/` |
| Pasted content | `cover-image/{topic-slug}/` (always) |
Output directory per `default_output_dir` preference:
- `same-dir`: `{article-dir}/`
- `imgs-subdir`: `{article-dir}/imgs/`
- `independent` (default): `cover-image/{topic-slug}/`
```
<output-dir>/
├── source-{slug}.{ext} # Source files (text, images, etc.)
├── source-{slug}.{ext} # Source files
├── refs/ # Reference images (if provided)
│ ├── ref-01-{slug}.{ext}
│ └── ref-01-{slug}.md # Description file
├── prompts/cover.md # Generation prompt
└── cover.png # Output image
```
**Slug**: Extract main topic (2-4 words, kebab-case). Example: "The Future of AI" → `future-of-ai`
**Conflict**: If directory exists, append timestamp: `{topic-slug}-YYYYMMDD-HHMMSS`
**Source Files**: Copy all sources with naming `source-{slug}.{ext}` (multiple supported)
**Slug**: 2-4 words, kebab-case. Conflict: append `-YYYYMMDD-HHMMSS`
## Workflow
@@ -156,9 +105,9 @@ Output directory depends on `default_output_dir` preference:
```
Cover Image Progress:
- [ ] Step 0: Check preferences (EXTEND.md) ⚠️ REQUIRED if not found
- [ ] Step 1: Analyze content + determine output directory ⚠️ MUST ask if not configured
- [ ] Step 2: Confirm options (5 dimensions) ⚠️ REQUIRED unless --quick or all specified
- [ ] Step 0: Check preferences (EXTEND.md) ⛔ BLOCKING
- [ ] Step 1: Analyze content + save refs + determine output dir
- [ ] Step 2: Confirm options (6 dimensions) ⚠️ unless --quick
- [ ] Step 3: Create prompt
- [ ] Step 4: Generate image
- [ ] Step 5: Completion report
@@ -167,73 +116,67 @@ Cover Image Progress:
### Flow
```
Input → [Step 0: Preferences/Setup] → Analyze → [Output Dir ⚠️][Confirm: 5 Dimensions] → Prompt → Generate → Complete
(skip if --quick or all specified)
Input → [Step 0: Preferences] ─┬─ Found → Continue
└─ Not found → First-Time Setup ⛔ BLOCKING → Save EXTEND.md → Continue
Analyze + Save Refs → [Output Dir] → [Confirm: 6 Dimensions] → Prompt → Generate → Complete
(skip if --quick or all specified)
```
### Step 0: Load Preferences (EXTEND.md) ⚠️
**Purpose**: Load user preferences or run first-time setup. **Do NOT skip setup if EXTEND.md not found.**
Use Bash to check EXTEND.md existence (priority order):
### Step 0: Load Preferences ⛔ BLOCKING
Check EXTEND.md existence (priority: project → user):
```bash
# Check project-level first
test -f .baoyu-skills/baoyu-cover-image/EXTEND.md && echo "project"
# Then user-level (cross-platform: $HOME works on macOS/Linux/WSL)
test -f "$HOME/.baoyu-skills/baoyu-cover-image/EXTEND.md" && echo "user"
```
| Result | Action |
|--------|--------|
| Found | Read, parse, display preferences summary → Continue to Step 1 |
| Not found | ⚠️ MUST run first-time setup ([references/config/first-time-setup.md](references/config/first-time-setup.md)) → Then continue to Step 1 |
| Found | Load, display summary → Continue |
| Not found | ⛔ Run first-time setup ([references/config/first-time-setup.md](references/config/first-time-setup.md)) → Save → Continue |
**Preferences Summary** (when found):
```
Preferences loaded from [project/user]:
• Watermark: [enabled/disabled] [content if enabled]
• Type/Palette/Rendering: [value or "auto"]
• Text: [value or "title-only"] | Mood: [value or "balanced"]
• Aspect: [default_aspect] | Output: [dir or "not set — will ask in Step 1.5"]
• Quick mode: [enabled/disabled] | Language: [value or "auto"]
```
**EXTEND.md Supports**: Watermark | Preferred type | Preferred palette | Preferred rendering | Preferred text | Preferred mood | Default aspect ratio | Default output directory | Quick mode | Custom palette definitions | Language preference
Schema: [references/config/preferences-schema.md](references/config/preferences-schema.md)
**CRITICAL**: If not found, complete setup BEFORE any other steps or questions.
### Step 1: Analyze Content
1. **Save source content** (if pasted, save to `source.md` in target directory; if file path, use as-is)
2. **Content analysis**: Extract topic, core message, tone, keywords; identify visual metaphors; detect content type
3. **Language detection**: Detect source language, note user's input language, compare with EXTEND.md preference
4. **Determine output directory** per File Structure rules. If no `default_output_dir` preference + file path input, include in Step 2 Q4
1. **Save reference images** (if provided) → [references/workflow/reference-images.md](references/workflow/reference-images.md)
2. **Save source content** (if pasted, save to `source.md`)
3. **Analyze content**: topic, tone, keywords, visual metaphors
4. **Deep analyze references** ⚠️: Extract specific, concrete elements (see reference-images.md)
5. **Detect language**: Compare source, user input, EXTEND.md preference
6. **Determine output directory**: Per File Structure rules
### Step 2: Confirm Options ⚠️
Validate all 5 dimensions + aspect ratio. Full confirmation flow: [references/workflow/confirm-options.md](references/workflow/confirm-options.md)
**Skip Conditions**:
Full confirmation flow: [references/workflow/confirm-options.md](references/workflow/confirm-options.md)
| Condition | Skipped | Still Asked |
|-----------|---------|-------------|
| `--quick` or `quick_mode: true` | 5 dimensions | Aspect ratio (unless `--aspect`) |
| All 5 + `--aspect` specified | All | None |
| `--quick` or `quick_mode: true` | 6 dimensions | Aspect ratio (unless `--aspect`) |
| All 6 + `--aspect` specified | All | None |
### Step 3: Create Prompt
Save to `prompts/cover.md`. Full template: [references/workflow/prompt-template.md](references/workflow/prompt-template.md)
Save to `prompts/cover.md`. Template: [references/workflow/prompt-template.md](references/workflow/prompt-template.md)
**CRITICAL - References in Frontmatter**:
- Files saved to `refs/` → Add to frontmatter `references` list
- Style extracted verbally (no file) → Omit `references`, describe in body
- Before writing → Verify: `test -f refs/ref-NN-{slug}.{ext}`
**Reference elements in body** MUST be detailed, prefixed with "MUST"/"REQUIRED", with integration approach.
### Step 4: Generate Image
1. Backup existing `cover.png` `cover-backup-YYYYMMDD-HHMMSS.png` (if regenerating)
2. Check available image generation skills; if multiple, ask user preference
3. Call selected skill with prompt file path, output path (`cover.png`), aspect ratio
4. On failure: auto-retry once before reporting error
1. **Backup existing** `cover.png` if regenerating
2. **Check image generation skills**; if multiple, ask preference
3. **Process references** from prompt frontmatter:
- `direct` usage → pass via `--ref` (use ref-capable backend)
- `style`/`palette` → extract traits, append to prompt
4. **Generate**: Call skill with prompt file, output path, aspect ratio
5. On failure: auto-retry once
### Step 5: Completion Report
@@ -242,47 +185,49 @@ Cover Generated!
Topic: [topic]
Type: [type] | Palette: [palette] | Rendering: [rendering]
Text: [text] | Mood: [mood] | Aspect: [ratio]
Title: [title text or "visual only"]
Text: [text] | Mood: [mood] | Font: [font] | Aspect: [ratio]
Title: [title or "visual only"]
Language: [lang] | Watermark: [enabled/disabled]
References: [N images or "extracted style" or "none"]
Location: [directory path]
Files:
✓ source-{slug}.{ext}
✓ prompts/cover.md
✓ cover.png
[✓ cover-backup-{timestamp}.png (if regenerated)]
```
## Image Modification
| Action | Steps |
|--------|-------|
| **Regenerate** | Backup existing → Update prompt → Regenerate with same settings |
| **Change dimension** | Backup existing → Confirm new value → Update prompt → Regenerate |
| **Regenerate** | Backup → Update prompt file FIRST → Regenerate |
| **Change dimension** | Backup → Confirm new value → Update prompt → Regenerate |
All modifications automatically backup existing `cover.png` before regenerating.
## Composition Principles
## Notes
- **Whitespace**: 40-60% breathing room
- **Visual anchor**: Main element centered or offset left
- **Characters**: Simplified silhouettes; NO realistic humans
- **Title**: Use exact title from user/source; never invent
- Cover must be readable at small preview sizes
- Visual metaphors > literal representations
- Title: max 8 chars, readable, impactful
- Two confirmation points: Step 0 (first-time setup) + Step 2 (options) - skip Step 2 with `--quick`
- Use confirmed language for title text
- Maintain watermark consistency if enabled
- Check compatibility matrices when selecting combinations
- `--no-title` is alias for `--text none`
- `--style` presets are backward-compatible; explicit `--palette`/`--rendering` override preset values
## Extension Support
Custom configurations via EXTEND.md. See **Step 0** for paths.
Supports: Watermark | Preferred dimensions | Default aspect/output | Quick mode | Custom palettes | Language
Schema: [references/config/preferences-schema.md](references/config/preferences-schema.md)
## References
**Dimensions**: [text.md](references/dimensions/text.md) | [mood.md](references/dimensions/mood.md)
**Dimensions**: [text.md](references/dimensions/text.md) | [mood.md](references/dimensions/mood.md) | [font.md](references/dimensions/font.md)
**Palettes**: [references/palettes/](references/palettes/)
**Renderings**: [references/renderings/](references/renderings/)
**Types**: [references/types.md](references/types.md)
**Auto-Selection**: [references/auto-selection.md](references/auto-selection.md)
**Style Presets**: [references/style-presets.md](references/style-presets.md)
**Compatibility**: [references/compatibility.md](references/compatibility.md)
**Types**: [references/types.md](references/types.md)
**Workflow**: [confirm-options.md](references/workflow/confirm-options.md) | [prompt-template.md](references/workflow/prompt-template.md)
**Visual Elements**: [references/visual-elements.md](references/visual-elements.md)
**Workflow**: [confirm-options.md](references/workflow/confirm-options.md) | [prompt-template.md](references/workflow/prompt-template.md) | [reference-images.md](references/workflow/reference-images.md)
**Config**: [preferences-schema.md](references/config/preferences-schema.md) | [first-time-setup.md](references/config/first-time-setup.md) | [watermark-guide.md](references/config/watermark-guide.md)
@@ -58,3 +58,14 @@ Default: `title-only`
| Launch, announcement, promotion, event, gaming, entertainment | `bold` |
Default: `balanced`
## Auto Font Selection
| Signals | Font |
|---------|------|
| Personal, lifestyle, human, warm, friendly, story | `handwritten` |
| Technical, professional, clean, modern, minimal, data | `clean` |
| Editorial, academic, luxury, classic, literary | `serif` |
| Announcement, entertainment, promotion, bold, event, gaming | `display` |
Default: `clean`
@@ -39,8 +39,8 @@ Apply the specified rendering's characteristics:
### Text (Density Level)
- `none`: No text elements, full visual area
- `title-only`: Single headline (≤8 characters), 85% visual area
- `title-subtitle`: Title + context (≤15 chars), 75% visual area
- `title-only`: Single headline, 85% visual area
- `title-subtitle`: Title + context, 75% visual area
- `text-rich`: Title + subtitle + 2-4 keyword tags, 60% visual area
### Mood (Emotional Intensity)
@@ -50,17 +50,44 @@ Apply the specified rendering's characteristics:
## Text Style (When Title Included)
- Title text: Large, eye-catching, max 8 characters
- Subtitle: Secondary, max 15 characters (if title-subtitle or text-rich)
- **Title source**: Use the exact title provided by user, or extract from source content. Do NOT invent or modify titles.
- Title text: Large, eye-catching, faithful to source
- Subtitle: Secondary element (if title-subtitle or text-rich)
- Tags: 2-4 keyword badges (if text-rich)
- Font style harmonizes with rendering style
- **Engagement hooks**: Use numbers ("3个关键"), questions ("Why?"), contrasts ("A vs B"), pain points ("别再X了"), or suspense ("隐藏的X") for compelling titles
## Composition Guidance
**Icon Vocabulary**: Represent concepts with simple, recognizable icons rather than detailed illustrations. Use the rendering style to determine icon complexity (flat-vector = geometric, hand-drawn = sketchy, etc.)
### Layout Principles
**Character Handling**: When people are needed, use simplified silhouettes or abstract representations. NO realistic faces, detailed anatomy, or photographic representations.
- **Generous whitespace**: Maintain 40-60% breathing room; avoid cluttered compositions
- **Visual anchor placement**: Main element centered or offset left (reserve right side for title if included)
- **Information hierarchy**: One dominant focal point, 1-2 supporting elements, decorative accents
- **Clean backgrounds**: Solid colors or subtle gradients; no complex textures or patterns
### Icon & Symbol Vocabulary
Represent concepts with simple, recognizable icons rather than detailed illustrations:
| Category | Examples |
|----------|----------|
| Tech | Code window, gear, circuit, cloud, lock, API brackets |
| Ideas | Lightbulb, rocket, target, puzzle, key, magnifier |
| Communication | Speech bubble, chat dots, megaphone, mail |
| Growth | Plant/sprout, tree, arrow, chart, mountain |
| Tools | Wrench, pencil, brush, checklist, clock |
Use the rendering style to determine icon complexity (flat-vector = geometric, hand-drawn = sketchy, etc.)
Full library: [references/visual-elements.md](visual-elements.md)
### Character Handling
When people are needed:
- Use simplified silhouettes or abstract stick figures
- Symbolic representations (head + shoulders outline)
- NO realistic faces, detailed anatomy, or photographic representations
- Cartoon/icon style consistent with rendering choice
## Mood Application
@@ -77,6 +104,16 @@ Apply mood adjustments to the base palette:
- Use the same language as the content provided below for any text elements
- Match punctuation style to the content language
## Reference Images
When reference images are provided:
- **Style extraction**: Identify rendering technique, line quality, texture, and visual vocabulary
- **Composition learning**: Note layout patterns, whitespace usage, element placement
- **Mood matching**: Capture the emotional tone and visual weight
- **Adaptation**: Apply extracted characteristics while respecting the specified Type, Palette, and Rendering dimensions
- **Priority**: If reference style conflicts with specified dimensions, dimensions take precedence for structural choices; reference influences decorative details
---
Please generate the cover image based on the content provided below:
@@ -48,3 +48,12 @@
| metaphor | ✓✓ | ✓✓ | ✓ |
| scene | ✓✓ | ✓✓ | ✓ |
| minimal | ✓✓ | ✓✓ | ✗ |
## Font × Rendering
| | flat-vector | hand-drawn | painterly | digital | pixel | chalk |
|---|:---:|:---:|:---:|:---:|:---:|:---:|
| clean | ✓✓ | ✗ | ✗ | ✓✓ | ✓ | ✗ |
| handwritten | ✓ | ✓✓ | ✓✓ | ✓ | ✗ | ✓✓ |
| serif | ✓ | ✗ | ✓ | ✓✓ | ✗ | ✗ |
| display | ✓✓ | ✓ | ✓ | ✓✓ | ✓✓ | ✓ |
@@ -9,6 +9,14 @@ description: First-time setup flow for baoyu-cover-image preferences
When no EXTEND.md is found, guide user through preference setup.
**⛔ BLOCKING OPERATION**: This setup MUST complete before ANY other workflow steps. Do NOT:
- Ask about reference images
- Ask about content/article
- Ask about dimensions (type, palette, rendering)
- Proceed to content analysis
ONLY ask the questions in this setup flow, save EXTEND.md, then continue.
## Setup Flow
```
@@ -103,8 +103,8 @@ custom_palettes:
| Value | Description |
|-------|-------------|
| `none` | Pure visual, no text elements |
| `title-only` | Single headline (≤8 characters) |
| `title-subtitle` | Title + subtitle (≤15 characters) |
| `title-only` | Single headline |
| `title-subtitle` | Title + subtitle |
| `text-rich` | Title + subtitle + keyword tags (2-4) |
## Mood Options
@@ -0,0 +1,163 @@
---
name: font-dimension
description: Typography style dimension for cover images
---
# Font Dimension
Controls typography style and character feel.
## Values
| Font | Visual Style | Line Quality | Character |
|------|--------------|--------------|-----------|
| `clean` | Geometric sans-serif | Sharp, uniform | Modern, precise, neutral |
| `handwritten` | Hand-lettered, brush | Organic, varied | Warm, personal, friendly |
| `serif` | Classic serifs, elegant | Refined, structured | Editorial, authoritative |
| `display` | Bold, decorative | Heavy, expressive | Attention-grabbing, playful |
## Detail
### clean
Modern, universal typography with neutral character.
**Characteristics**:
- Geometric sans-serif letterforms
- Sharp, uniform line weight
- Clean edges, no flourishes
- High readability at all sizes
- Minimal personality, maximum clarity
**Use Cases**:
- Technical documentation
- Professional/corporate content
- Minimal design approaches
- Data-driven articles
- Modern brand aesthetics
**Prompt Hints**:
- Use clean geometric sans-serif typography
- Modern, minimal letterforms
- Sharp edges, uniform stroke weight
- High contrast against background
### handwritten
Warm, organic typography with personal character.
**Characteristics**:
- Hand-lettered or brush style
- Organic, varied line weight
- Natural imperfections
- Approachable, human feel
- Casual yet intentional
**Use Cases**:
- Personal stories
- Lifestyle content
- Wellness and self-improvement
- Creative tutorials
- Friendly brand voices
**Prompt Hints**:
- Use warm hand-lettered typography with organic brush strokes
- Friendly, personal feel
- Natural variation in stroke weight
- Approachable, human character
### serif
Classic, elegant typography with editorial authority.
**Characteristics**:
- Traditional serif letterforms
- Refined, structured strokes
- Elegant proportions
- Timeless sophistication
- Formal, trustworthy feel
**Use Cases**:
- Editorial content
- Academic articles
- Luxury brand content
- Historical topics
- Literary pieces
**Prompt Hints**:
- Use elegant serif typography with refined letterforms
- Classic, editorial character
- Structured, proportional spacing
- Authoritative, sophisticated feel
### display
Bold, decorative typography for maximum impact.
**Characteristics**:
- Heavy, expressive letterforms
- Decorative elements
- Strong visual presence
- Playful or dramatic character
- Designed for headlines
**Use Cases**:
- Announcements
- Entertainment content
- Promotional materials
- Event marketing
- Gaming topics
**Prompt Hints**:
- Use bold decorative display typography
- Heavy, expressive headlines
- Strong visual impact
- Attention-grabbing character
## Default
`clean` — Universal, pairs well with most rendering styles.
## Rendering Compatibility
| Font × Rendering | flat-vector | hand-drawn | painterly | digital | pixel | chalk |
|------------------|:-----------:|:----------:|:---------:|:-------:|:-----:|:-----:|
| clean | ✓✓ | ✗ | ✗ | ✓✓ | ✓ | ✗ |
| handwritten | ✓ | ✓✓ | ✓✓ | ✓ | ✗ | ✓✓ |
| serif | ✓ | ✗ | ✓ | ✓✓ | ✗ | ✗ |
| display | ✓✓ | ✓ | ✓ | ✓✓ | ✓✓ | ✓ |
✓✓ = highly recommended | ✓ = compatible | ✗ = not recommended
## Type Compatibility
| Font × Type | hero | conceptual | typography | metaphor | scene | minimal |
|-------------|:----:|:----------:|:----------:|:--------:|:-----:|:-------:|
| clean | ✓ | ✓✓ | ✓✓ | ✓ | ✗ | ✓✓ |
| handwritten | ✓✓ | ✓ | ✓ | ✓✓ | ✓✓ | ✓ |
| serif | ✓ | ✓ | ✓✓ | ✓ | ✓ | ✓ |
| display | ✓✓ | ✓ | ✓✓ | ✓ | ✓ | ✗ |
## Palette Interaction
Font style adapts to palette characteristics:
| Palette Category | clean | handwritten | serif | display |
|------------------|-------|-------------|-------|---------|
| Warm (warm, earth, pastel) | Softer weight | Natural fit | Warm tones | Playful energy |
| Cool (cool, mono, elegant) | Perfect match | Contrast | Classic pairing | Bold statement |
| Dark (dark, vivid) | High contrast | Glow effects | Dramatic | Maximum impact |
| Vintage (retro) | Modern contrast | Nostalgic fit | Period-appropriate | Retro headlines |
## Auto Selection
When `--font` is omitted, select based on signals:
| Signals | Font |
|---------|------|
| Personal, lifestyle, human, warm, friendly, story | `handwritten` |
| Technical, professional, clean, modern, minimal, data | `clean` |
| Editorial, academic, luxury, classic, literary | `serif` |
| Announcement, entertainment, promotion, bold, event, gaming | `display` |
Default: `clean`
@@ -12,8 +12,8 @@ Controls text density and information hierarchy on cover images.
| Value | Title | Subtitle | Tags | Visual Area |
|-------|:-----:|:--------:|:----:|:-----------:|
| `none` | - | - | - | 100% |
| `title-only` | ✓ (≤8字) | - | - | 85% |
| `title-subtitle` | ✓ | ✓ (≤15字) | - | 75% |
| `title-only` | ✓ | - | - | 85% |
| `title-subtitle` | ✓ | ✓ | - | 75% |
| `text-rich` | ✓ | ✓ | ✓ (2-4) | 60% |
## Detail
@@ -43,24 +43,14 @@ Single headline, maximum impact.
- Strong brand recognition
**Composition**:
- Title: ≤8 characters, prominent
- Title: prominent placement
- Reserved zone: top or bottom 15%
- Visual supports title message
**Title Guidelines**:
- Punchy, action-oriented
- Use exact title from source content or user-provided title
- Do NOT invent or modify titles
- Match content language
- Use engagement hooks (see below)
**Engagement Hooks**:
| Hook Type | Examples (EN) | Examples (ZH) |
|-----------|---------------|---------------|
| Numbers | "3 Traps", "5 Keys" | "3个陷阱", "5个关键" |
| Questions | "Why X?", "How?" | "为什么X?", "怎么做?" |
| Contrasts | "A vs B", "Old→New" | "A还是B", "旧→新" |
| Pain Points | "Stop X", "Never Do" | "别再X了", "千万别" |
| Suspense | "Hidden X", "Secret" | "隐藏的X", "秘密" |
### title-subtitle
@@ -72,14 +62,14 @@ Title with supporting context.
- Content with dual messages
**Composition**:
- Title: ≤8 characters, primary
- Subtitle: ≤15 characters, secondary
- Title: primary element
- Subtitle: secondary element
- Reserved zone: 25%
- Clear hierarchy between title/subtitle
**Title Guidelines**:
- Use engagement hooks: numbers, questions, contrasts, pain points, suspense
- Punchy, action-oriented
- Use exact title from source content or user-provided title
- Do NOT invent or modify titles
**Subtitle Guidelines**:
- Clarify or contextualize title
@@ -104,8 +94,8 @@ Information-dense cover with multiple text elements.
- Clear visual hierarchy
**Title Guidelines**:
- Use engagement hooks: numbers, questions, contrasts, pain points, suspense
- Punchy, action-oriented
- Use exact title from source content or user-provided title
- Do NOT invent or modify titles
**Tag Guidelines**:
- 2-4 tags maximum
@@ -22,6 +22,7 @@
| `vector-illustration` | `retro` | `flat-vector` |
| `vintage` | `retro` | `hand-drawn` |
| `warm` | `warm` | `hand-drawn` |
| `warm-flat` | `warm` | `flat-vector` |
| `watercolor` | `earth` | `painterly` |
## Override Examples
@@ -0,0 +1,101 @@
# Visual Elements Library
Icon and symbol vocabulary organized by topic. Use these as building blocks for cover compositions.
## Tech & Development
| Element | Use For |
|---------|---------|
| Code window / Terminal | Programming, development |
| Gear / Cog | Engineering, settings, process |
| Circuit board / Chip | Hardware, AI, computing |
| Binary / Data stream | Data, algorithms |
| API brackets `</>` | Web development, APIs |
| Cloud | Cloud computing, SaaS |
| Lock / Shield | Security, privacy |
| Network nodes | Distributed systems, connections |
## Ideas & Innovation
| Element | Use For |
|---------|---------|
| Lightbulb | Ideas, insights, innovation |
| Rocket | Launch, growth, startups |
| Target / Bullseye | Goals, precision, focus |
| Puzzle piece | Problem solving, integration |
| Key | Solutions, access, unlocking |
| Magnifying glass | Analysis, search, discovery |
| Chart / Graph | Data, trends, growth |
| Arrow / Path | Direction, journey, progress |
## Communication & Collaboration
| Element | Use For |
|---------|---------|
| Speech bubble | Communication, dialogue |
| Chat dots `...` | Conversation, messaging |
| Handshake | Partnership, agreement |
| Team / Figures | Collaboration, community |
| Mail / Envelope | Notifications, outreach |
| Megaphone | Announcements, marketing |
| Network / Web | Social, connections |
## Nature & Growth
| Element | Use For |
|---------|---------|
| Plant / Sprout | Growth, organic, sustainability |
| Tree | Established, structure, branching |
| Leaf | Eco, natural, fresh |
| Sun / Rays | Energy, positivity, new beginnings |
| Mountain | Challenge, achievement, scale |
| Wave | Flow, change, rhythm |
| Seed → Plant | Transformation, potential |
## Tools & Actions
| Element | Use For |
|---------|---------|
| Wrench / Hammer | Building, fixing, tools |
| Pencil / Pen | Writing, creation, editing |
| Brush | Design, creativity, art |
| Scissors | Cutting, editing, trimming |
| Clock / Timer | Time, scheduling, deadlines |
| Calendar | Planning, events, milestones |
| Checklist / Checkbox | Tasks, completion, validation |
## Abstract Concepts
| Element | Use For |
|---------|---------|
| Infinity ∞ | Continuous, endless, loops |
| Yin-yang | Balance, duality, harmony |
| Spiral | Evolution, recursion, cycles |
| Stack / Layers | Depth, hierarchy, structure |
| Bridge | Connection, transition, spanning |
| Door / Portal | Opportunity, entry, access |
| Mirror / Reflection | Self-improvement, analysis |
## Combination Patterns
Create visual metaphors by combining elements:
| Combination | Represents |
|-------------|------------|
| Lightbulb + Gear | Innovative engineering |
| Plant + Code | Organic tech growth |
| Rocket + Target | Precise acceleration |
| Key + Lock | Security solutions |
| Bridge + People | Team connections |
| Magnifier + Data | Analytics, insights |
## Rendering-Specific Treatment
| Rendering | Element Style |
|-----------|---------------|
| `flat-vector` | Geometric, simple shapes, uniform fills |
| `hand-drawn` | Sketchy, organic, doodle-like |
| `painterly` | Soft edges, brush strokes |
| `digital` | Precise, gradient hints, polished |
| `pixel` | 8-bit chunky, grid-aligned |
| `chalk` | Dusty, textured, board style |
@@ -2,22 +2,22 @@
## Purpose
Validate all 5 dimensions + aspect ratio.
Validate all 6 dimensions + aspect ratio.
## Skip Conditions
| Condition | Skipped Questions | Still Asked |
|-----------|-------------------|-------------|
| `--quick` flag | Type, Palette, Rendering, Text, Mood | **Aspect Ratio** (unless `--aspect` specified) |
| All 5 dimensions + `--aspect` specified | All | None |
| `quick_mode: true` in EXTEND.md | Type, Palette, Rendering, Text, Mood | **Aspect Ratio** (unless `--aspect` specified) |
| Otherwise | None | All 6 questions |
| `--quick` flag | Type, Palette, Rendering, Text, Mood, Font | **Aspect Ratio** (unless `--aspect` specified) |
| All 6 dimensions + `--aspect` specified | All | None |
| `quick_mode: true` in EXTEND.md | Type, Palette, Rendering, Text, Mood, Font | **Aspect Ratio** (unless `--aspect` specified) |
| Otherwise | None | All 7 questions |
**Important**: Aspect ratio is ALWAYS asked unless explicitly specified via `--aspect` CLI flag. User presets in EXTEND.md are shown as recommended option, not auto-selected.
## Quick Mode Output
When skipping 5 dimensions:
When skipping 6 dimensions:
```
Quick Mode: Auto-selected dimensions
@@ -26,8 +26,9 @@ Quick Mode: Auto-selected dimensions
• Rendering: [rendering] ([reason])
• Text: [text] ([reason])
• Mood: [mood] ([reason])
• Font: [font] ([reason])
[Then ask Question 6: Aspect Ratio]
[Then ask Question 7: Aspect Ratio]
```
## Confirmation Flow
@@ -91,7 +92,26 @@ options:
description: "Polished, precise, subtle gradients"
```
### Q4: Other Settings (skip if all remaining dimensions already specified)
### Q4: Font (skip if `--font`)
```yaml
header: "Font"
question: "Which font style?"
multiSelect: false
options:
- label: "[auto-recommended font] (Recommended)"
description: "[reason based on content signals]"
- label: "clean"
description: "Modern geometric sans-serif - tech, professional"
- label: "handwritten"
description: "Warm hand-lettered - personal, friendly"
- label: "serif"
description: "Classic elegant - editorial, luxury"
- label: "display"
description: "Bold decorative - announcements, entertainment"
```
### Q5: Other Settings (skip if all remaining dimensions already specified)
Combine remaining settings into one question. Include: Output Dir (if no preference + file path input), Text, Mood, Aspect. Show auto-selected values as recommended option. User can accept all or type adjustments via "Other".
@@ -3,6 +3,19 @@
Save to `prompts/cover.md`:
```markdown
---
type: cover
palette: [confirmed palette]
rendering: [confirmed rendering]
references:
- ref_id: 01
filename: refs/ref-01-{slug}.{ext}
usage: direct | style | palette
- ref_id: 02
filename: refs/ref-02-{slug}.{ext}
usage: direct | style | palette
---
# Content Context
Article title: [full original title from source]
Content summary: [2-3 sentence summary of key points and themes]
@@ -13,6 +26,7 @@ Cover theme: [2-3 words visual interpretation]
Type: [confirmed type]
Palette: [confirmed palette]
Rendering: [confirmed rendering]
Font: [confirmed font]
Text level: [confirmed text level]
Mood: [confirmed mood]
Aspect ratio: [confirmed ratio]
@@ -21,9 +35,9 @@ Language: [confirmed language]
# Text Elements
[Based on text level:]
- none: "No text elements"
- title-only: "Title: [max 8 chars headline]"
- title-subtitle: "Title: [headline] / Subtitle: [max 15 chars context]"
- text-rich: "Title: [headline] / Subtitle: [context] / Tags: [2-4 keywords]"
- title-only: "Title: [exact title from source or user]"
- title-subtitle: "Title: [title] / Subtitle: [context]"
- text-rich: "Title: [title] / Subtitle: [context] / Tags: [2-4 keywords]"
# Mood Application
[Based on mood level:]
@@ -31,6 +45,13 @@ Language: [confirmed language]
- balanced: "Use medium contrast, normal saturation, balanced visual weight"
- bold: "Use high contrast, vivid saturated colors, heavy visual weight, dynamic energy"
# Font Application
[Based on font style:]
- clean: "Use clean geometric sans-serif typography. Modern, minimal letterforms."
- handwritten: "Use warm hand-lettered typography with organic brush strokes. Friendly, personal feel."
- serif: "Use elegant serif typography with refined letterforms. Classic, editorial character."
- display: "Use bold decorative display typography. Heavy, expressive headlines."
# Composition
Type composition:
- [Type-specific layout and structure]
@@ -46,14 +67,37 @@ Type notes: [key characteristics from type definition]
Palette notes: [key characteristics from palette definition]
[Watermark section if enabled]
[Reference images section if provided — REQUIRED, see below]
```
## Reference-Driven Design ⚠️ HIGH PRIORITY
When reference images are provided, they are the **primary visual input** and MUST strongly influence the output. The cover should look like it belongs to the same visual family as the references.
**Passing `--ref` alone is NOT enough.** Image generation models often ignore reference images unless the prompt text explicitly describes what to reproduce. Always combine `--ref` with detailed textual instructions.
## Content-Driven Design
- Article title and summary inform the visual metaphor choice
- Keywords guide decorative elements and symbols
- The skill controls visual style; the content drives meaning
## Visual Element Selection
Match content themes to icon vocabulary:
| Content Theme | Suggested Elements |
|---------------|-------------------|
| Programming/Dev | Code window, terminal, API brackets, gear |
| AI/ML | Brain, neural network, robot, circuit |
| Growth/Business | Chart, rocket, plant, mountain, arrow |
| Security | Lock, shield, key, fingerprint |
| Communication | Speech bubble, megaphone, mail, handshake |
| Tools/Methods | Wrench, checklist, pencil, puzzle |
Full library: [../visual-elements.md](../visual-elements.md)
## Type-Specific Composition
| Type | Composition Guidelines |
@@ -68,8 +112,8 @@ Palette notes: [key characteristics from palette definition]
## Title Guidelines
When text level includes title:
- Max 8 characters, punchy headline
- Use engagement hooks: numbers ("3个陷阱"), questions ("Why X?"), contrasts ("A vs B"), pain points ("别再X了")
- **Source**: Use the exact title provided by user, or extract from source content
- **Do NOT invent titles**: Stay faithful to the original
- Match confirmed language
## Watermark Application
@@ -82,3 +126,122 @@ The watermark should be legible but not distracting from the main content.
```
Reference: `config/watermark-guide.md`
## Reference Image Handling
When user provides reference images (`--ref` or pasted images):
### ⚠️ CRITICAL - Frontmatter References
**MUST add `references` field in YAML frontmatter** when reference files are saved to `refs/`:
```yaml
---
type: cover
palette: warm
rendering: flat-vector
references:
- ref_id: 01
filename: refs/ref-01-podcast-thumbnail.jpg
usage: style
---
```
| Field | Description |
|-------|-------------|
| `ref_id` | Sequential number (01, 02, ...) |
| `filename` | Relative path from prompt file's parent directory |
| `usage` | `direct` / `style` / `palette` |
**Omit `references` field entirely** if no reference files saved (style extracted verbally only).
### When to Include References in Frontmatter
| Situation | Frontmatter Action | Generation Action |
|-----------|-------------------|-------------------|
| Reference file saved to `refs/` | Add to `references` list ✓ | Pass via `--ref` parameter |
| Style extracted verbally (no file) | Omit `references` field | Describe in prompt body only |
| File path in frontmatter but doesn't exist | ERROR - fix or remove | Generation will fail |
**Before writing prompt with references, verify**: `test -f refs/ref-NN-{slug}.{ext}`
### Reference Usage Types
| Usage | When to Use | Generation Action |
|-------|-------------|-------------------|
| `direct` | Reference matches desired output closely | Pass to `--ref` parameter |
| `style` | Extract visual style characteristics only | Describe style in prompt text |
| `palette` | Extract color palette only | Include colors in prompt |
### Step 1: Analyze References
For each reference image, extract:
- **Style**: Rendering technique, line quality, texture
- **Composition**: Layout, visual hierarchy, focal points
- **Color mood**: Palette characteristics (without specific colors)
- **Elements**: Key visual elements and symbols used
### Step 2: Embed in Prompt ⚠️ CRITICAL
**Passing `--ref` alone is NOT enough.** Image generation models frequently ignore reference images unless the prompt text explicitly and forcefully describes what to reproduce. You MUST always write detailed textual instructions regardless of whether `--ref` is used.
**If file saved (with or without `--ref` support)**:
- Pass ref images via `--ref` parameter if skill supports it
- **ALWAYS** add a detailed mandatory section in the prompt body:
```
# Reference Style — MUST INCORPORATE
CRITICAL: The generated cover MUST visually reference the provided images. The cover must feel like it belongs to the same visual family.
## From Ref 1 ([filename]) — REQUIRED elements:
- [Brand element]: [Specific description of logo/wordmark treatment, e.g., "The logo uses vertical parallel lines (|||) for the letter 'm'. Reproduce this exact treatment."]
- [Signature pattern]: [Specific description, e.g., "Woven intersecting curves forming a diamond/lozenge grid pattern. This MUST appear prominently as a banner, border, or background section."]
- [Colors]: [Exact hex values, e.g., "Dark teal #2D4A3E background, cream #F5F0E0 text"]
- [Typography]: [Specific treatment, e.g., "Uppercase text with wide letter-spacing"]
- [Layout element]: [Specific spatial element, e.g., "Bottom banner strip in dark color"]
## From Ref 2 ([filename]) — REQUIRED elements:
[Same detailed breakdown]
## Integration approach:
[Specific layout instruction describing how reference elements combine with the cover content, e.g., "Use a SPLIT LAYOUT: main illustration area (warm cream background) occupies ~65% of the image, while a dark teal BANNER STRIP (with the woven line pattern from Ref 2) runs along the bottom ~35%, containing branding elements from Ref 1."]
```
**Key rules**:
- Each visual element gets its own bullet with "MUST" or "REQUIRED"
- Descriptions must be **specific enough to reproduce** — not vague ("clean style")
- The integration approach must describe **exact spatial arrangement**
- After generation, verify reference elements are visible; if not, strengthen and regenerate
**If style/palette extracted verbally (NO file saved)**:
- DO NOT add references metadata to prompt
- Append extracted info directly to prompt body using the same MUST INCORPORATE format above:
```
# Reference Style — MUST INCORPORATE (extracted from visual analysis)
CRITICAL: Apply these specific visual elements extracted from the reference images.
## REQUIRED elements:
- [Same detailed bullet format as above]
## Integration approach:
[Same spatial layout instruction]
```
### Reference Analysis Template
Use this format when analyzing reference images. Extract **specific, concrete, reproducible** details — not vague summaries.
| Aspect | Analysis Points | Good Example | Bad Example |
|--------|-----------------|--------------|-------------|
| **Brand elements** | Logos, wordmarks, distinctive typography | "Logo 'm' formed by 3 vertical lines" | "Has a logo" |
| **Signature patterns** | Unique motifs, textures, geometric patterns | "Woven curves forming diamond grid" | "Has patterns" |
| **Colors** | Exact hex values or close approximations | "#2D4A3E dark teal, #F5F0E0 cream" | "Dark and light" |
| **Layout** | Spatial zones, banner placement, proportions | "Bottom 30% is dark banner with branding" | "Has a banner" |
| **Typography** | Font style, weight, case, spacing, position | "Uppercase, wide letter-spacing, right-aligned" | "Has text" |
| **Rendering** | Line quality, texture, depth treatment | "Topographic contour lines as background texture" | "Clean style" |
| **Elements** | Icon vocabulary, decorative motifs | "Geometric intersecting line ornaments at corners" | "Has decorations" |
**Output**: Each extracted element should be written as a **copy-pasteable prompt instruction** prefixed with "MUST" or "REQUIRED".
@@ -0,0 +1,86 @@
# Reference Image Handling
Guide for processing user-provided reference images in cover generation.
## Input Detection
| Input Type | Action |
|------------|--------|
| Image file path provided | Copy to `refs/` → can use `--ref` |
| Image in conversation (no path) | **ASK user for file path** with AskUserQuestion |
| User can't provide path | Extract style/palette verbally → append to prompt (NO frontmatter references) |
**CRITICAL**: Only add `references` to prompt frontmatter if files are ACTUALLY SAVED to `refs/` directory.
## File Saving
**If user provides file path**:
1. Copy to `refs/ref-NN-{slug}.{ext}` (NN = 01, 02, ...)
2. Create description: `refs/ref-NN-{slug}.md`
3. Verify files exist before proceeding
**Description File Format**:
```yaml
---
ref_id: NN
filename: ref-NN-{slug}.{ext}
usage: direct | style | palette
---
[User's description or auto-generated description]
```
| Usage | When to Use |
|-------|-------------|
| `direct` | Reference matches desired output closely |
| `style` | Extract visual style characteristics only |
| `palette` | Extract color scheme only |
## Verbal Extraction (No File)
When user can't provide file path:
1. Analyze image visually, extract: colors, style, composition
2. Create `refs/extracted-style.md` with extracted info
3. DO NOT add `references` to prompt frontmatter
4. Append extracted style/colors directly to prompt text
## Deep Analysis ⚠️ CRITICAL
References are high-priority inputs. Extract **specific, concrete, reproducible** elements:
| Analysis | Description | Example (good vs bad) |
|----------|-------------|----------------------|
| **Brand elements** | Logos, wordmarks, specific typography | Good: "Logo uses vertical parallel lines for 'm'" / Bad: "Has a logo" |
| **Signature patterns** | Unique decorative motifs, textures | Good: "Woven intersecting curves forming diamond grid" / Bad: "Has patterns" |
| **Color palette** | Exact hex values for key colors | Good: "#2D4A3E dark teal, #F5F0E0 cream" / Bad: "Dark and light colors" |
| **Layout structure** | Specific spatial arrangement | Good: "Bottom 30% dark banner with branding" / Bad: "Has a banner" |
| **Typography** | Font style, weight, spacing, case | Good: "Uppercase, wide letter-spacing" / Bad: "Has text" |
| **Content/subject** | What the reference depicts | Factual description |
| **Usage recommendation** | `direct` / `style` / `palette` | Based on analysis |
**Output format**: List each element as bullet that can be copy-pasted into prompt as mandatory instruction.
## Verification Output
**For saved files**:
```
Reference Images Saved:
- ref-01-{slug}.png ✓ (can use --ref)
- ref-02-{slug}.png ✓ (can use --ref)
```
**For extracted style**:
```
Reference Style Extracted (no file):
- Colors: #E8756D coral, #7ECFC0 mint...
- Style: minimal flat vector, clean lines...
→ Will append to prompt text (not --ref)
```
## Priority Rules
When user provides references, they are **HIGH PRIORITY**:
- **References override defaults**: If reference conflicts with preferred palette/rendering, reference takes precedence
- **Concrete > abstract**: Extract specific elements — not vague "clean style"
- **Mandatory language**: Use "MUST", "REQUIRED" in prompt for reference elements
- **Visible in output**: Verify elements are present after generation; strengthen prompt if not
+298
View File
@@ -0,0 +1,298 @@
---
name: baoyu-format-markdown
description: Formats plain text or markdown files with frontmatter, titles, summaries, headings, bold, lists, and code blocks. Use when user asks to "format markdown", "beautify article", "add formatting", or improve article layout. Outputs to {filename}-formatted.md.
---
# Markdown Formatter
Transforms plain text or markdown files into well-structured markdown with proper frontmatter, formatting, and typography.
## Script Directory
Scripts in `scripts/` subdirectory. Replace `${SKILL_DIR}` with this SKILL.md's directory path.
| Script | Purpose |
|--------|---------|
| `scripts/main.ts` | Main entry point with CLI options (uses remark-cjk-friendly for CJK emphasis) |
| `scripts/quotes.ts` | Replace ASCII quotes with fullwidth quotes |
| `scripts/autocorrect.ts` | Add CJK/English spacing via autocorrect |
## Preferences (EXTEND.md)
Use Bash to check EXTEND.md existence (priority order):
```bash
# Check project-level first
test -f .baoyu-skills/baoyu-format-markdown/EXTEND.md && echo "project"
# Then user-level (cross-platform: $HOME works on macOS/Linux/WSL)
test -f "$HOME/.baoyu-skills/baoyu-format-markdown/EXTEND.md" && echo "user"
```
┌──────────────────────────────────────────────────────────┬───────────────────┐
│ Path │ Location │
├──────────────────────────────────────────────────────────┼───────────────────┤
│ .baoyu-skills/baoyu-format-markdown/EXTEND.md │ Project directory │
├──────────────────────────────────────────────────────────┼───────────────────┤
│ $HOME/.baoyu-skills/baoyu-format-markdown/EXTEND.md │ User home │
└──────────────────────────────────────────────────────────┴───────────────────┘
┌───────────┬───────────────────────────────────────────────────────────────────────────┐
│ Result │ Action │
├───────────┼───────────────────────────────────────────────────────────────────────────┤
│ Found │ Read, parse, apply settings │
├───────────┼───────────────────────────────────────────────────────────────────────────┤
│ Not found │ Use defaults │
└───────────┴───────────────────────────────────────────────────────────────────────────┘
**EXTEND.md Supports**: Default formatting options | Summary length preferences
## Usage
Claude performs content analysis and formatting (Steps 1-6), then runs the script for typography fixes (Step 7).
## Workflow
### Step 1: Read Source File
Read the user-specified markdown or plain text file.
### Step 1.5: Detect Content Type & Confirm
**Content Type Detection:**
| Indicator | Classification |
|-----------|----------------|
| Has `---` YAML frontmatter | Markdown |
| Has `#`, `##`, `###` headings | Markdown |
| Has `**bold**`, `*italic*` | Markdown |
| Has `- ` or `1. ` lists | Markdown |
| Has ``` code blocks | Markdown |
| Has `> ` blockquotes | Markdown |
| None of above | Plain text |
**Decision Flow:**
┌─────────────────┬────────────────────────────────────────────────┐
│ Content Type │ Action │
├─────────────────┼────────────────────────────────────────────────┤
│ Plain text │ Proceed to Step 2 (format to markdown) │
├─────────────────┼────────────────────────────────────────────────┤
│ Markdown │ Use AskUserQuestion to confirm optimization │
└─────────────────┴────────────────────────────────────────────────┘
**If Markdown detected, ask user:**
```
Detected existing markdown formatting. What would you like to do?
1. Optimize formatting (Recommended)
- Add/improve frontmatter, headings, bold, lists
- Run typography script (spacing, emphasis fixes)
- Output: {filename}-formatted.md
2. Keep original formatting
- Preserve existing markdown structure
- Run typography script (spacing, emphasis fixes)
- Output: {filename}-formatted.md
3. Typography fixes only
- Run typography script on original file in-place
- No copy created, modifies original file directly
```
**Based on user choice:**
- **Optimize**: Continue to Step 2-8 (full workflow)
- **Keep original**: Skip Steps 2-5, copy file → Step 6-8 (run script on copy)
- **Typography only**: Skip Steps 2-6, run Step 7 on original file directly
### Step 2: Analyze Content Structure
Identify:
- Existing title (H1 `#`)
- Paragraph separations
- Keywords suitable for **bold**
- Parallel content convertible to lists
- Code snippets
- Quotations
### Step 3: Check/Create Frontmatter
Check for YAML frontmatter (`---` block). Create if missing.
**Meta field handling:**
| Field | Processing |
|-------|------------|
| `title` | See Step 4 |
| `slug` | Infer from file path (e.g., `posts/2026/01/10/vibe-coding/` → `vibe-coding`) or generate from title |
| `summary` | Generate engaging summary (100-150 characters) |
| `featureImage` | Check if `imgs/cover.png` exists in same directory; if so, use relative path |
### Step 4: Title Handling
**Logic:**
1. If frontmatter already has `title` → use it, no H1 in body
2. If first line is H1 → extract to frontmatter `title`, remove H1 from body
3. If neither exists → generate candidate titles
**Title generation flow:**
1. Generate 3 candidate titles based on content
2. Use `AskUserQuestion` tool:
```
Select a title:
1. [Title A] (Recommended)
2. [Title B]
3. [Title C]
```
3. If no selection within a few seconds, use recommended (option 1)
**Title principles:**
- Concise, max 20 characters
- Captures core message
- Engaging, sparks reading interest
- Accurate, avoids clickbait
**Important:** Once title is in frontmatter, body should NOT have H1 (avoid duplication)
### Step 5: Format Processing
**Formatting rules:**
| Element | Format |
|---------|--------|
| Titles | Use `#`, `##`, `###` hierarchy |
| Key points | Use `**bold**` |
| Parallel items | Convert to `-` unordered or `1.` ordered lists |
| Code/commands | Use `` `inline` `` or ` ```block``` ` |
| Quotes/sayings | Use `>` blockquote |
| Separators | Use `---` where appropriate |
**Formatting principles:**
- Preserve original content and viewpoints
- Add formatting only, do not modify text
- Formatting serves readability
- Avoid over-formatting
### Step 6: Save Formatted File
Save as `{original-filename}-formatted.md`
Examples:
- `final.md``final-formatted.md`
- `draft-v1.md``draft-v1-formatted.md`
**If user chose "Keep original formatting" (from Step 1.5):**
- Copy original file to `{filename}-formatted.md` without modifications
- Proceed to Step 7 for typography fixes only
**Backup existing file:**
If `{filename}-formatted.md` already exists, backup before overwriting:
```bash
# Check if formatted file exists
if [ -f "{filename}-formatted.md" ]; then
# Backup with timestamp
mv "{filename}-formatted.md" "{filename}-formatted.backup-$(date +%Y%m%d-%H%M%S).md"
fi
```
Example:
- `final-formatted.md` exists → backup to `final-formatted.backup-20260128-143052.md`
### Step 7: Execute Text Formatting Script
After saving, **must** run the formatting script:
```bash
npx -y bun ${SKILL_DIR}/scripts/main.ts {output-file-path} [options]
```
**Script Options:**
| Option | Short | Description | Default |
|--------|-------|-------------|---------|
| `--quotes` | `-q` | Replace ASCII quotes with fullwidth quotes `"..."` | false |
| `--no-quotes` | | Do not replace quotes | |
| `--spacing` | `-s` | Add CJK/English spacing via autocorrect | true |
| `--no-spacing` | | Do not add CJK/English spacing | |
| `--emphasis` | `-e` | Fix CJK emphasis punctuation issues | true |
| `--no-emphasis` | | Do not fix CJK emphasis issues | |
| `--help` | `-h` | Show help message | |
**Examples:**
```bash
# Default: spacing + emphasis enabled, quotes disabled
npx -y bun ${SKILL_DIR}/scripts/main.ts article.md
# Enable all features including quote replacement
npx -y bun ${SKILL_DIR}/scripts/main.ts article.md --quotes
# Only fix emphasis issues, skip spacing
npx -y bun ${SKILL_DIR}/scripts/main.ts article.md --no-spacing
# Disable all processing except frontmatter formatting
npx -y bun ${SKILL_DIR}/scripts/main.ts article.md --no-spacing --no-emphasis
```
**Script performs (based on options):**
1. Fix CJK emphasis/bold punctuation issues (default: enabled)
2. Add CJK/English mixed text spacing via autocorrect (default: enabled)
3. Replace ASCII quotes `"..."` with fullwidth quotes `"..."` (default: disabled)
4. Format frontmatter YAML (always enabled)
### Step 8: Display Results
```
**Formatting complete**
File: posts/2026/01/09/example/final-formatted.md
Changes:
- Added title: [title content]
- Added X bold markers
- Added X lists
- Added X code blocks
```
## Formatting Example
**Before:**
```
This is plain text. First point is efficiency improvement. Second point is cost reduction. Third point is experience optimization. Use npm install to install dependencies.
```
**After:**
```markdown
---
title: Three Core Advantages
slug: three-core-advantages
summary: Discover the three key benefits that drive success in modern projects.
---
This is plain text.
**Main advantages:**
- Efficiency improvement
- Cost reduction
- Experience optimization
Use `npm install` to install dependencies.
```
## Notes
- Preserve original writing style and tone
- Specify correct language for code blocks (e.g., `python`, `javascript`)
- Maintain CJK/English spacing standards
- Do not add content not present in original
## Extension Support
Custom configurations via EXTEND.md. See **Preferences** section for paths and supported options.
@@ -0,0 +1,10 @@
import { execSync } from "child_process";
export function applyAutocorrect(filePath: string): boolean {
try {
execSync(`npx autocorrect-node --fix "${filePath}"`, { stdio: "inherit" });
return true;
} catch {
return false;
}
}
@@ -0,0 +1,165 @@
{
"lockfileVersion": 1,
"workspaces": {
"": {
"dependencies": {
"remark-frontmatter": "^5.0.0",
"remark-gfm": "^4.0.1",
"remark-parse": "^11.0.0",
"remark-stringify": "^11.0.0",
"unified": "^11.0.5",
"unist-util-visit": "^5.1.0",
"yaml": "^2.8.2",
},
},
},
"packages": {
"@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
"@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
"bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
"character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="],
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
"devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
"escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
"fault": ["fault@2.0.1", "", { "dependencies": { "format": "^0.2.0" } }, "sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ=="],
"format": ["format@0.2.2", "", {}, "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww=="],
"is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
"longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="],
"markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="],
"mdast-util-find-and-replace": ["mdast-util-find-and-replace@3.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="],
"mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA=="],
"mdast-util-frontmatter": ["mdast-util-frontmatter@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "escape-string-regexp": "^5.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "micromark-extension-frontmatter": "^2.0.0" } }, "sha512-LRqI9+wdgC25P0URIJY9vwocIzCcksduHQ9OF2joxQoyTNVduwLAFUzjoopuRJbJAReaKrNQKAZKL3uCMugWJA=="],
"mdast-util-gfm": ["mdast-util-gfm@3.1.0", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-gfm-autolink-literal": "^2.0.0", "mdast-util-gfm-footnote": "^2.0.0", "mdast-util-gfm-strikethrough": "^2.0.0", "mdast-util-gfm-table": "^2.0.0", "mdast-util-gfm-task-list-item": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ=="],
"mdast-util-gfm-autolink-literal": ["mdast-util-gfm-autolink-literal@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "ccount": "^2.0.0", "devlop": "^1.0.0", "mdast-util-find-and-replace": "^3.0.0", "micromark-util-character": "^2.0.0" } }, "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ=="],
"mdast-util-gfm-footnote": ["mdast-util-gfm-footnote@2.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0" } }, "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ=="],
"mdast-util-gfm-strikethrough": ["mdast-util-gfm-strikethrough@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg=="],
"mdast-util-gfm-table": ["mdast-util-gfm-table@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "markdown-table": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg=="],
"mdast-util-gfm-task-list-item": ["mdast-util-gfm-task-list-item@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ=="],
"mdast-util-phrasing": ["mdast-util-phrasing@4.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "unist-util-is": "^6.0.0" } }, "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w=="],
"mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA=="],
"mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="],
"micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="],
"micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="],
"micromark-extension-frontmatter": ["micromark-extension-frontmatter@2.0.0", "", { "dependencies": { "fault": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-C4AkuM3dA58cgZha7zVnuVxBhDsbttIMiytjgsM2XbHAB2faRVaHRle40558FBN+DJcrLNCoqG5mlrpdU4cRtg=="],
"micromark-extension-gfm": ["micromark-extension-gfm@3.0.0", "", { "dependencies": { "micromark-extension-gfm-autolink-literal": "^2.0.0", "micromark-extension-gfm-footnote": "^2.0.0", "micromark-extension-gfm-strikethrough": "^2.0.0", "micromark-extension-gfm-table": "^2.0.0", "micromark-extension-gfm-tagfilter": "^2.0.0", "micromark-extension-gfm-task-list-item": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w=="],
"micromark-extension-gfm-autolink-literal": ["micromark-extension-gfm-autolink-literal@2.1.0", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw=="],
"micromark-extension-gfm-footnote": ["micromark-extension-gfm-footnote@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw=="],
"micromark-extension-gfm-strikethrough": ["micromark-extension-gfm-strikethrough@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw=="],
"micromark-extension-gfm-table": ["micromark-extension-gfm-table@2.1.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg=="],
"micromark-extension-gfm-tagfilter": ["micromark-extension-gfm-tagfilter@2.0.0", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg=="],
"micromark-extension-gfm-task-list-item": ["micromark-extension-gfm-task-list-item@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw=="],
"micromark-factory-destination": ["micromark-factory-destination@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="],
"micromark-factory-label": ["micromark-factory-label@2.0.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg=="],
"micromark-factory-space": ["micromark-factory-space@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg=="],
"micromark-factory-title": ["micromark-factory-title@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw=="],
"micromark-factory-whitespace": ["micromark-factory-whitespace@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ=="],
"micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="],
"micromark-util-chunked": ["micromark-util-chunked@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA=="],
"micromark-util-classify-character": ["micromark-util-classify-character@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q=="],
"micromark-util-combine-extensions": ["micromark-util-combine-extensions@2.0.1", "", { "dependencies": { "micromark-util-chunked": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg=="],
"micromark-util-decode-numeric-character-reference": ["micromark-util-decode-numeric-character-reference@2.0.2", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw=="],
"micromark-util-decode-string": ["micromark-util-decode-string@2.0.1", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ=="],
"micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="],
"micromark-util-html-tag-name": ["micromark-util-html-tag-name@2.0.1", "", {}, "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA=="],
"micromark-util-normalize-identifier": ["micromark-util-normalize-identifier@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q=="],
"micromark-util-resolve-all": ["micromark-util-resolve-all@2.0.1", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg=="],
"micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="],
"micromark-util-subtokenize": ["micromark-util-subtokenize@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA=="],
"micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="],
"micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"remark-frontmatter": ["remark-frontmatter@5.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-frontmatter": "^2.0.0", "micromark-extension-frontmatter": "^2.0.0", "unified": "^11.0.0" } }, "sha512-XTFYvNASMe5iPN0719nPrdItC9aU0ssC4v14mH1BCi1u0n1gAocqcujWUrByftZTbLhRtiKRyjYTSIOcr69UVQ=="],
"remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="],
"remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="],
"remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="],
"trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="],
"unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="],
"unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="],
"unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="],
"unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="],
"unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="],
"vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
"vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
"yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="],
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
}
}
@@ -0,0 +1,176 @@
import { readFileSync, writeFileSync } from "fs";
import { unified } from "unified";
import remarkParse from "remark-parse";
import remarkCjkFriendly from "remark-cjk-friendly";
import remarkGfm from "remark-gfm";
import remarkFrontmatter from "remark-frontmatter";
import remarkStringify from "remark-stringify";
import { visit } from "unist-util-visit";
import YAML from "yaml";
import { replaceQuotes } from "./quotes";
import { applyAutocorrect } from "./autocorrect";
export interface FormatOptions {
quotes?: boolean;
spacing?: boolean;
emphasis?: boolean;
}
export interface FormatResult {
success: boolean;
filePath: string;
quotesFixed: boolean;
spacingApplied: boolean;
emphasisFixed: boolean;
error?: string;
}
const DEFAULT_OPTIONS: Required<FormatOptions> = {
quotes: false,
spacing: true,
emphasis: true,
};
function decodeHtmlEntities(text: string): string {
return text.replace(/&#x([0-9A-Fa-f]+);/g, (_, hex) =>
String.fromCodePoint(parseInt(hex, 16))
);
}
function formatFrontmatter(value: string): string | null {
try {
const doc = YAML.parseDocument(value);
return doc.toString({ lineWidth: 0 }).trimEnd();
} catch {
return null;
}
}
function formatMarkdownContent(
content: string,
options: Required<FormatOptions>
): string {
const processor = unified()
.use(remarkParse)
.use(options.emphasis ? remarkCjkFriendly : [])
.use(remarkGfm)
.use(remarkFrontmatter, ["yaml"])
.use(remarkStringify, {
wrap: false,
});
const tree = processor.parse(content);
visit(tree, (node) => {
if (node.type === "text" && options.quotes) {
const textNode = node as { value: string };
textNode.value = replaceQuotes(textNode.value);
return;
}
if (node.type === "yaml") {
const yamlNode = node as { value: string };
const formatted = formatFrontmatter(yamlNode.value);
if (formatted !== null) {
yamlNode.value = formatted;
}
return;
}
});
let result = processor.stringify(tree);
if (options.emphasis) {
result = decodeHtmlEntities(result);
}
return result;
}
export function formatMarkdown(
filePath: string,
options?: FormatOptions
): FormatResult {
const opts: Required<FormatOptions> = { ...DEFAULT_OPTIONS, ...options };
const result: FormatResult = {
success: false,
filePath,
quotesFixed: false,
spacingApplied: false,
emphasisFixed: false,
};
try {
const content = readFileSync(filePath, "utf-8");
const formattedContent = formatMarkdownContent(content, opts);
result.quotesFixed = opts.quotes;
result.emphasisFixed = opts.emphasis;
writeFileSync(filePath, formattedContent, "utf-8");
if (opts.spacing) {
result.spacingApplied = applyAutocorrect(filePath);
}
result.success = true;
console.log(`✓ Formatted: ${filePath}`);
} catch (error) {
result.error = error instanceof Error ? error.message : String(error);
console.error(`✗ Format failed: ${result.error}`);
}
return result;
}
function parseArgs(args: string[]): { filePath: string; options: FormatOptions } {
const options: FormatOptions = {};
let filePath = "";
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === "--quotes" || arg === "-q") {
options.quotes = true;
} else if (arg === "--no-quotes") {
options.quotes = false;
} else if (arg === "--spacing" || arg === "-s") {
options.spacing = true;
} else if (arg === "--no-spacing") {
options.spacing = false;
} else if (arg === "--emphasis" || arg === "-e") {
options.emphasis = true;
} else if (arg === "--no-emphasis") {
options.emphasis = false;
} else if (arg === "--help" || arg === "-h") {
console.log(`Usage: npx -y bun scripts/main.ts <file.md> [options]
Options:
-q, --quotes Replace ASCII quotes with fullwidth quotes (default: false)
--no-quotes Do not replace quotes
-s, --spacing Add CJK/English spacing via autocorrect (default: true)
--no-spacing Do not add CJK/English spacing
-e, --emphasis Fix CJK emphasis punctuation issues (default: true)
--no-emphasis Do not fix CJK emphasis issues
-h, --help Show this help message`);
process.exit(0);
} else if (!arg.startsWith("-")) {
filePath = arg;
}
}
return { filePath, options };
}
if (import.meta.url === `file://${process.argv[1]}`) {
const { filePath, options } = parseArgs(process.argv.slice(2));
if (!filePath) {
console.error("Usage: npx -y bun scripts/main.ts <file.md> [options]");
console.error("Use --help for more information.");
process.exit(1);
}
const result = formatMarkdown(filePath, options);
if (!result.success) {
process.exit(1);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,12 @@
{
"dependencies": {
"remark-cjk-friendly": "^1.1.0",
"remark-frontmatter": "^5.0.0",
"remark-gfm": "^4.0.1",
"remark-parse": "^11.0.0",
"remark-stringify": "^11.0.0",
"unified": "^11.0.5",
"unist-util-visit": "^5.1.0",
"yaml": "^2.8.2"
}
}
@@ -0,0 +1,5 @@
export function replaceQuotes(content: string): string {
return content
.replace(/"([^"]+)"/g, "\u201c$1\u201d")
.replace(/「([^」]+)」/g, "\u201c$1\u201d");
}
+37 -18
View File
@@ -1,11 +1,11 @@
---
name: baoyu-image-gen
description: AI image generation with OpenAI and Google APIs. Supports text-to-image, reference images, aspect ratios, and parallel generation (recommended 4 concurrent subagents). Use when user asks to generate, create, or draw images.
description: AI image generation with OpenAI, Google and DashScope APIs. Supports text-to-image, reference images, aspect ratios. Sequential by default; parallel generation available on request. Use when user asks to generate, create, or draw images.
---
# Image Generation (AI SDK)
Official API-based image generation. Supports OpenAI and Google providers.
Official API-based image generation. Supports OpenAI, Google and DashScope (阿里通义万象) providers.
## Script Directory
@@ -41,7 +41,9 @@ test -f "$HOME/.baoyu-skills/baoyu-image-gen/EXTEND.md" && echo "user"
│ Not found │ Use defaults │
└───────────┴───────────────────────────────────────────────────────────────────────────┘
**EXTEND.md Supports**: Default provider | Default quality | Default aspect ratio
**EXTEND.md Supports**: Default provider | Default quality | Default aspect ratio | Default image size | Default models
Schema: `references/config/preferences-schema.md`
## Usage
@@ -58,11 +60,17 @@ npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "A cat" --image out.png --quali
# From prompt files
npx -y bun ${SKILL_DIR}/scripts/main.ts --promptfiles system.md content.md --image out.png
# With reference images (Google multimodal only)
# With reference images (Google multimodal or OpenAI edits)
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "Make blue" --image out.png --ref source.png
# With reference images (explicit provider/model)
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "Make blue" --image out.png --provider google --model gemini-3-pro-image-preview --ref source.png
# Specific provider
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "A cat" --image out.png --provider openai
# DashScope (阿里通义万象)
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "一只可爱的猫" --image out.png --provider dashscope
```
## Options
@@ -72,13 +80,13 @@ npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "A cat" --image out.png --provi
| `--prompt <text>`, `-p` | Prompt text |
| `--promptfiles <files...>` | Read prompt from files (concatenated) |
| `--image <path>` | Output image path (required) |
| `--provider google\|openai` | Force provider (default: google) |
| `--model <id>`, `-m` | Model ID |
| `--provider google\|openai\|dashscope` | Force provider (default: google) |
| `--model <id>`, `-m` | Model ID (`--ref` with OpenAI requires GPT Image model, e.g. `gpt-image-1.5`) |
| `--ar <ratio>` | Aspect ratio (e.g., `16:9`, `1:1`, `4:3`) |
| `--size <WxH>` | Size (e.g., `1024x1024`) |
| `--quality normal\|2k` | Quality preset (default: 2k) |
| `--imageSize 1K\|2K\|4K` | Image size for Google (default: from quality) |
| `--ref <files...>` | Reference images (Google multimodal only) |
| `--ref <files...>` | Reference images. Supported by Google multimodal and OpenAI edits (GPT Image models). If provider omitted: Google first, then OpenAI |
| `--n <count>` | Number of images |
| `--json` | JSON output |
@@ -88,18 +96,22 @@ npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "A cat" --image out.png --provi
|----------|-------------|
| `OPENAI_API_KEY` | OpenAI API key |
| `GOOGLE_API_KEY` | Google API key |
| `DASHSCOPE_API_KEY` | DashScope API key (阿里云) |
| `OPENAI_IMAGE_MODEL` | OpenAI model override |
| `GOOGLE_IMAGE_MODEL` | Google model override |
| `DASHSCOPE_IMAGE_MODEL` | DashScope model override (default: z-image-turbo) |
| `OPENAI_BASE_URL` | Custom OpenAI endpoint |
| `GOOGLE_BASE_URL` | Custom Google endpoint |
| `DASHSCOPE_BASE_URL` | Custom DashScope endpoint |
**Load Priority**: CLI args > env vars > `<cwd>/.baoyu-skills/.env` > `~/.baoyu-skills/.env`
**Load Priority**: CLI args > EXTEND.md > env vars > `<cwd>/.baoyu-skills/.env` > `~/.baoyu-skills/.env`
## Provider Selection
1. `--provider` specified → use it
2. Only one API key available → use that provider
3. Both available → default to Google
1. `--ref` provided + no `--provider` → auto-select Google first, then OpenAI
2. `--provider` specified → use it (if `--ref`, must be `google` or `openai`)
3. Only one API key available → use that provider
4. Multiple available → default to Google
## Quality Presets
@@ -118,31 +130,38 @@ Supported: `1:1`, `16:9`, `9:16`, `4:3`, `3:4`, `2.35:1`
- Google Imagen: uses `aspectRatio` parameter
- OpenAI: maps to closest supported size
## Parallel Generation
## Generation Mode
Supports concurrent image generation via background subagents for batch operations.
**Default**: Sequential generation (one image at a time). This ensures stable output and easier debugging.
**Parallel Generation**: Only use when user explicitly requests parallel/concurrent generation.
| Mode | When to Use |
|------|-------------|
| Sequential (default) | Normal usage, single images, small batches |
| Parallel | User explicitly requests, large batches (10+) |
**Parallel Settings** (when requested):
| Setting | Value |
|---------|-------|
| Recommended concurrency | 4 subagents |
| Max concurrency | 8 subagents |
| Use case | Batch generation (slides, comics, infographics) |
| Use case | Large batch generation when user requests parallel |
**Agent Implementation**:
**Agent Implementation** (parallel mode only):
```
# Launch multiple generations in parallel using Task tool
# Each Task runs as background subagent with run_in_background=true
# Collect results via TaskOutput when all complete
```
**Best Practice**: When generating 4+ images, spawn background subagents (recommended 4 concurrent) instead of sequential execution.
## Error Handling
- Missing API key → error with setup instructions
- Generation failure → auto-retry once
- Invalid aspect ratio → warning, proceed with default
- Reference images with non-multimodal model → warning, ignore refs
- Reference images with unsupported provider/model → error with fix hint (switch to Google multimodal or OpenAI GPT Image edits)
## Extension Support
@@ -0,0 +1,66 @@
---
name: preferences-schema
description: EXTEND.md YAML schema for baoyu-image-gen user preferences
---
# Preferences Schema
## Full Schema
```yaml
---
version: 1
default_provider: null # google|openai|dashscope|null (null = auto-detect)
default_quality: null # normal|2k|null (null = use default: 2k)
default_aspect_ratio: null # "16:9"|"1:1"|"4:3"|"3:4"|"2.35:1"|null
default_image_size: null # 1K|2K|4K|null (Google only, overrides quality)
default_model:
google: null # e.g., "gemini-3-pro-image-preview"
openai: null # e.g., "gpt-image-1.5"
dashscope: null # e.g., "z-image-turbo"
---
```
## Field Reference
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `version` | int | 1 | Schema version |
| `default_provider` | string\|null | null | Default provider (null = auto-detect) |
| `default_quality` | string\|null | null | Default quality (null = 2k) |
| `default_aspect_ratio` | string\|null | null | Default aspect ratio |
| `default_image_size` | string\|null | null | Google image size (overrides quality) |
| `default_model.google` | string\|null | null | Google default model |
| `default_model.openai` | string\|null | null | OpenAI default model |
| `default_model.dashscope` | string\|null | null | DashScope default model |
## Examples
**Minimal**:
```yaml
---
version: 1
default_provider: google
default_quality: 2k
---
```
**Full**:
```yaml
---
version: 1
default_provider: google
default_quality: 2k
default_aspect_ratio: "16:9"
default_image_size: 2K
default_model:
google: "gemini-3-pro-image-preview"
openai: "gpt-image-1.5"
dashscope: "z-image-turbo"
---
```
+162 -20
View File
@@ -1,8 +1,8 @@
import path from "node:path";
import process from "node:process";
import { homedir } from "node:os";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import type { CliArgs, Provider } from "./types";
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
import type { CliArgs, Provider, ExtendConfig } from "./types";
function printUsage(): void {
console.log(`Usage:
@@ -14,13 +14,13 @@ Options:
-p, --prompt <text> Prompt text
--promptfiles <files...> Read prompt from files (concatenated)
--image <path> Output image path (required)
--provider google|openai Force provider (auto-detect by default)
--provider google|openai|dashscope Force provider (auto-detect by default)
-m, --model <id> Model ID
--ar <ratio> Aspect ratio (e.g., 16:9, 1:1, 4:3)
--size <WxH> Size (e.g., 1024x1024)
--quality normal|2k Quality preset (default: 2k)
--imageSize 1K|2K|4K Image size for Google (default: from quality)
--ref <files...> Reference images (Google multimodal only)
--ref <files...> Reference images (Google multimodal or OpenAI edits)
--n <count> Number of images (default: 1)
--json JSON output
-h, --help Show help
@@ -29,12 +29,15 @@ Environment variables:
OPENAI_API_KEY OpenAI API key
GOOGLE_API_KEY Google API key
GEMINI_API_KEY Gemini API key (alias for GOOGLE_API_KEY)
DASHSCOPE_API_KEY DashScope API key (阿里云通义万象)
OPENAI_IMAGE_MODEL Default OpenAI model (gpt-image-1.5)
GOOGLE_IMAGE_MODEL Default Google model (gemini-3-pro-image-preview)
DASHSCOPE_IMAGE_MODEL Default DashScope model (z-image-turbo)
OPENAI_BASE_URL Custom OpenAI endpoint
GOOGLE_BASE_URL Custom Google endpoint
DASHSCOPE_BASE_URL Custom DashScope endpoint
Env file load order: CLI args > process.env > <cwd>/.baoyu-skills/.env > ~/.baoyu-skills/.env`);
Env file load order: CLI args > EXTEND.md > process.env > <cwd>/.baoyu-skills/.env > ~/.baoyu-skills/.env`);
}
function parseArgs(argv: string[]): CliArgs {
@@ -46,7 +49,7 @@ function parseArgs(argv: string[]): CliArgs {
model: null,
aspectRatio: null,
size: null,
quality: "2k",
quality: null,
imageSize: null,
referenceImages: [],
n: 1,
@@ -105,7 +108,7 @@ function parseArgs(argv: string[]): CliArgs {
if (a === "--provider") {
const v = argv[++i];
if (v !== "google" && v !== "openai") throw new Error(`Invalid provider: ${v}`);
if (v !== "google" && v !== "openai" && v !== "dashscope") throw new Error(`Invalid provider: ${v}`);
out.provider = v;
continue;
}
@@ -212,6 +215,87 @@ async function loadEnv(): Promise<void> {
}
}
function extractYamlFrontMatter(content: string): string | null {
const match = content.match(/^---\s*\n([\s\S]*?)\n---\s*$/m);
return match ? match[1] : null;
}
function parseSimpleYaml(yaml: string): Partial<ExtendConfig> {
const config: Partial<ExtendConfig> = {};
const lines = yaml.split("\n");
let currentKey: string | null = null;
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
if (trimmed.includes(":") && !trimmed.startsWith("-")) {
const colonIdx = trimmed.indexOf(":");
const key = trimmed.slice(0, colonIdx).trim();
let value = trimmed.slice(colonIdx + 1).trim();
if (value === "null" || value === "") {
value = "null";
}
if (key === "version") {
config.version = value === "null" ? 1 : parseInt(value, 10);
} else if (key === "default_provider") {
config.default_provider = value === "null" ? null : (value as Provider);
} else if (key === "default_quality") {
config.default_quality = value === "null" ? null : (value as "normal" | "2k");
} else if (key === "default_aspect_ratio") {
const cleaned = value.replace(/['"]/g, "");
config.default_aspect_ratio = cleaned === "null" ? null : cleaned;
} else if (key === "default_image_size") {
config.default_image_size = value === "null" ? null : (value as "1K" | "2K" | "4K");
} else if (key === "default_model") {
config.default_model = { google: null, openai: null, dashscope: null };
currentKey = "default_model";
} else if (currentKey === "default_model" && (key === "google" || key === "openai" || key === "dashscope")) {
const cleaned = value.replace(/['"]/g, "");
config.default_model![key] = cleaned === "null" ? null : cleaned;
}
}
}
return config;
}
async function loadExtendConfig(): Promise<Partial<ExtendConfig>> {
const home = homedir();
const cwd = process.cwd();
const paths = [
path.join(cwd, ".baoyu-skills", "baoyu-image-gen", "EXTEND.md"),
path.join(home, ".baoyu-skills", "baoyu-image-gen", "EXTEND.md"),
];
for (const p of paths) {
try {
const content = await readFile(p, "utf8");
const yaml = extractYamlFrontMatter(content);
if (!yaml) continue;
return parseSimpleYaml(yaml);
} catch {
continue;
}
}
return {};
}
function mergeConfig(args: CliArgs, extend: Partial<ExtendConfig>): CliArgs {
return {
...args,
provider: args.provider ?? extend.default_provider ?? null,
quality: args.quality ?? extend.default_quality ?? null,
aspectRatio: args.aspectRatio ?? extend.default_aspect_ratio ?? null,
imageSize: args.imageSize ?? extend.default_image_size ?? null,
};
}
async function readPromptFromFiles(files: string[]): Promise<string> {
const parts: string[] = [];
for (const f of files) {
@@ -239,30 +323,72 @@ function normalizeOutputImagePath(p: string): string {
}
function detectProvider(args: CliArgs): Provider {
if (args.referenceImages.length > 0 && args.provider && args.provider !== "google" && args.provider !== "openai") {
throw new Error(
"Reference images require a ref-capable provider. Use --provider google (Gemini multimodal) or --provider openai (GPT Image edits)."
);
}
if (args.provider) return args.provider;
const hasGoogle = !!(process.env.GOOGLE_API_KEY || process.env.GEMINI_API_KEY);
const hasOpenai = !!process.env.OPENAI_API_KEY;
const hasDashscope = !!process.env.DASHSCOPE_API_KEY;
if (hasGoogle && !hasOpenai) return "google";
if (hasOpenai && !hasGoogle) return "openai";
if (hasGoogle && hasOpenai) return "google";
if (args.referenceImages.length > 0) {
if (hasGoogle) return "google";
if (hasOpenai) return "openai";
throw new Error(
"Reference images require Google or OpenAI. Set GOOGLE_API_KEY/GEMINI_API_KEY or OPENAI_API_KEY, or remove --ref."
);
}
const available = [hasGoogle && "google", hasOpenai && "openai", hasDashscope && "dashscope"].filter(Boolean) as Provider[];
if (available.length === 1) return available[0]!;
if (available.length > 1) return available[0]!;
throw new Error(
"No API key found. Set GOOGLE_API_KEY, GEMINI_API_KEY, or OPENAI_API_KEY.\n" +
"No API key found. Set GOOGLE_API_KEY, GEMINI_API_KEY, OPENAI_API_KEY, or DASHSCOPE_API_KEY.\n" +
"Create ~/.baoyu-skills/.env or <cwd>/.baoyu-skills/.env with your keys."
);
}
async function validateReferenceImages(referenceImages: string[]): Promise<void> {
for (const refPath of referenceImages) {
const fullPath = path.resolve(refPath);
try {
await access(fullPath);
} catch {
throw new Error(`Reference image not found: ${fullPath}`);
}
}
}
type ProviderModule = {
getDefaultModel: () => string;
generateImage: (prompt: string, model: string, args: CliArgs) => Promise<Uint8Array>;
};
function isRetryableGenerationError(error: unknown): boolean {
const msg = error instanceof Error ? error.message : String(error);
const nonRetryableMarkers = [
"Reference image",
"not supported",
"only supported",
"No API key found",
"is required",
];
return !nonRetryableMarkers.some((marker) => msg.includes(marker));
}
async function loadProviderModule(provider: Provider): Promise<ProviderModule> {
if (provider === "google") {
return (await import("./providers/google")) as ProviderModule;
}
if (provider === "dashscope") {
return (await import("./providers/dashscope")) as ProviderModule;
}
return (await import("./providers/openai")) as ProviderModule;
}
@@ -275,9 +401,13 @@ async function main(): Promise<void> {
}
await loadEnv();
const extendConfig = await loadExtendConfig();
const mergedArgs = mergeConfig(args, extendConfig);
let prompt: string | null = args.prompt;
if (!prompt && args.promptFiles.length > 0) prompt = await readPromptFromFiles(args.promptFiles);
if (!mergedArgs.quality) mergedArgs.quality = "2k";
let prompt: string | null = mergedArgs.prompt;
if (!prompt && mergedArgs.promptFiles.length > 0) prompt = await readPromptFromFiles(mergedArgs.promptFiles);
if (!prompt) prompt = await readPromptFromStdin();
if (!prompt) {
@@ -287,27 +417,39 @@ async function main(): Promise<void> {
return;
}
if (!args.imagePath) {
if (!mergedArgs.imagePath) {
console.error("Error: --image is required");
printUsage();
process.exitCode = 1;
return;
}
const provider = detectProvider(args);
if (mergedArgs.referenceImages.length > 0) {
await validateReferenceImages(mergedArgs.referenceImages);
}
const provider = detectProvider(mergedArgs);
const providerModule = await loadProviderModule(provider);
const model = args.model || providerModule.getDefaultModel();
const outputPath = normalizeOutputImagePath(args.imagePath);
let model = mergedArgs.model;
if (!model && extendConfig.default_model) {
if (provider === "google") model = extendConfig.default_model.google ?? null;
if (provider === "openai") model = extendConfig.default_model.openai ?? null;
if (provider === "dashscope") model = extendConfig.default_model.dashscope ?? null;
}
model = model || providerModule.getDefaultModel();
const outputPath = normalizeOutputImagePath(mergedArgs.imagePath);
let imageData: Uint8Array;
let retried = false;
while (true) {
try {
imageData = await providerModule.generateImage(prompt, model, args);
imageData = await providerModule.generateImage(prompt, model, mergedArgs);
break;
} catch (e) {
if (!retried) {
if (!retried && isRetryableGenerationError(e)) {
retried = true;
console.error("Generation failed, retrying...");
continue;
@@ -320,7 +462,7 @@ async function main(): Promise<void> {
await mkdir(dir, { recursive: true });
await writeFile(outputPath, imageData);
if (args.json) {
if (mergedArgs.json) {
console.log(
JSON.stringify(
{
@@ -0,0 +1,139 @@
import type { CliArgs } from "../types";
export function getDefaultModel(): string {
return process.env.DASHSCOPE_IMAGE_MODEL || "z-image-turbo";
}
function getApiKey(): string | null {
return process.env.DASHSCOPE_API_KEY || null;
}
function getBaseUrl(): string {
const base = process.env.DASHSCOPE_BASE_URL || "https://dashscope.aliyuncs.com";
return base.replace(/\/+$/g, "");
}
function parseAspectRatio(ar: string): { width: number; height: number } | null {
const match = ar.match(/^(\d+(?:\.\d+)?):(\d+(?:\.\d+)?)$/);
if (!match) return null;
const w = parseFloat(match[1]!);
const h = parseFloat(match[2]!);
if (w <= 0 || h <= 0) return null;
return { width: w, height: h };
}
function getSizeFromAspectRatio(ar: string | null, quality: CliArgs["quality"]): string {
const baseSize = quality === "2k" ? 1440 : 1024;
if (!ar) return `${baseSize}*${baseSize}`;
const parsed = parseAspectRatio(ar);
if (!parsed) return `${baseSize}*${baseSize}`;
const ratio = parsed.width / parsed.height;
if (Math.abs(ratio - 1) < 0.1) {
return `${baseSize}*${baseSize}`;
}
if (ratio > 1) {
const w = Math.round(baseSize * ratio);
return `${w}*${baseSize}`;
}
const h = Math.round(baseSize / ratio);
return `${baseSize}*${h}`;
}
function normalizeSize(size: string): string {
return size.replace("x", "*");
}
export async function generateImage(
prompt: string,
model: string,
args: CliArgs
): Promise<Uint8Array> {
const apiKey = getApiKey();
if (!apiKey) throw new Error("DASHSCOPE_API_KEY is required");
if (args.referenceImages.length > 0) {
throw new Error(
"Reference images are not supported with DashScope provider in baoyu-image-gen. Use --provider google with a Gemini multimodal model."
);
}
const size = args.size ? normalizeSize(args.size) : getSizeFromAspectRatio(args.aspectRatio, args.quality);
const url = `${getBaseUrl()}/api/v1/services/aigc/multimodal-generation/generation`;
const body = {
model,
input: {
messages: [
{
role: "user",
content: [{ text: prompt }],
},
],
},
parameters: {
prompt_extend: false,
size,
},
};
console.log(`Generating image with DashScope (${model})...`, { size });
const res = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify(body),
});
if (!res.ok) {
const err = await res.text();
throw new Error(`DashScope API error (${res.status}): ${err}`);
}
const result = await res.json() as {
output?: {
result_image?: string;
choices?: Array<{
message?: {
content?: Array<{ image?: string }>;
};
}>;
};
};
let imageData: string | null = null;
if (result.output?.result_image) {
imageData = result.output.result_image;
} else if (result.output?.choices?.[0]?.message?.content) {
const content = result.output.choices[0].message.content;
for (const item of content) {
if (item.image) {
imageData = item.image;
break;
}
}
}
if (!imageData) {
console.error("Response:", JSON.stringify(result, null, 2));
throw new Error("No image in response");
}
if (imageData.startsWith("http://") || imageData.startsWith("https://")) {
const imgRes = await fetch(imageData);
if (!imgRes.ok) throw new Error("Failed to download image");
const buf = await imgRes.arrayBuffer();
return new Uint8Array(buf);
}
return Uint8Array.from(Buffer.from(imageData, "base64"));
}
@@ -216,13 +216,17 @@ export async function generateImage(
): Promise<Uint8Array> {
if (isGoogleImagen(model)) {
if (args.referenceImages.length > 0) {
console.error("Warning: Reference images not supported with Imagen models, ignoring.");
throw new Error(
"Reference images are not supported with Imagen models. Use gemini-3-pro-image-preview or gemini-3-flash-preview."
);
}
return generateWithImagen(prompt, model, args);
}
if (!isGoogleMultimodal(model) && args.referenceImages.length > 0) {
console.error("Warning: Reference images are only supported with Gemini multimodal models.");
throw new Error(
"Reference images are only supported with Gemini multimodal models. Use gemini-3-pro-image-preview or gemini-3-flash-preview."
);
}
return generateWithGemini(prompt, model, args);
@@ -1,9 +1,13 @@
import path from "node:path";
import { readFile } from "node:fs/promises";
import type { CliArgs } from "../types";
export function getDefaultModel(): string {
return process.env.OPENAI_IMAGE_MODEL || "gpt-image-1.5";
}
type OpenAIImageResponse = { data: Array<{ url?: string; b64_json?: string }> };
function parseAspectRatio(ar: string): { width: number; height: number } | null {
const match = ar.match(/^(\d+(?:\.\d+)?):(\d+(?:\.\d+)?)$/);
if (!match) return null;
@@ -66,20 +70,32 @@ export async function generateImage(
if (!apiKey) throw new Error("OPENAI_API_KEY is required");
if (args.referenceImages.length > 0) {
console.error("Warning: Reference images not supported with OpenAI, ignoring.");
}
const size = args.size || getOpenAISize(model, args.aspectRatio, args.quality);
const body: Record<string, any> = {
model,
prompt,
size,
};
if (args.referenceImages.length > 0) {
if (model.includes("dall-e-2") || model.includes("dall-e-3")) {
throw new Error(
"Reference images with OpenAI in this skill require GPT Image models. Use --model gpt-image-1.5 (or another gpt-image model)."
);
}
return generateWithOpenAIEdits(baseURL, apiKey, prompt, model, size, args.referenceImages, args.quality);
}
return generateWithOpenAIGenerations(baseURL, apiKey, prompt, model, size, args.quality);
}
async function generateWithOpenAIGenerations(
baseURL: string,
apiKey: string,
prompt: string,
model: string,
size: string,
quality: CliArgs["quality"]
): Promise<Uint8Array> {
const body: Record<string, any> = { model, prompt, size };
if (model.includes("dall-e-3")) {
body.quality = args.quality === "2k" ? "hd" : "standard";
body.quality = quality === "2k" ? "hd" : "standard";
}
const res = await fetch(`${baseURL}/images/generations`, {
@@ -96,7 +112,62 @@ export async function generateImage(
throw new Error(`OpenAI API error: ${err}`);
}
const result = (await res.json()) as { data: Array<{ url?: string; b64_json?: string }> };
const result = (await res.json()) as OpenAIImageResponse;
return extractImageFromResponse(result);
}
async function generateWithOpenAIEdits(
baseURL: string,
apiKey: string,
prompt: string,
model: string,
size: string,
referenceImages: string[],
quality: CliArgs["quality"]
): Promise<Uint8Array> {
const form = new FormData();
form.append("model", model);
form.append("prompt", prompt);
form.append("size", size);
if (model.includes("gpt-image")) {
form.append("quality", quality === "2k" ? "high" : "medium");
}
for (const refPath of referenceImages) {
const bytes = await readFile(refPath);
const filename = path.basename(refPath);
const mimeType = getMimeType(filename);
const blob = new Blob([bytes], { type: mimeType });
form.append("image[]", blob, filename);
}
const res = await fetch(`${baseURL}/images/edits`, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
},
body: form,
});
if (!res.ok) {
const err = await res.text();
throw new Error(`OpenAI edits API error: ${err}`);
}
const result = (await res.json()) as OpenAIImageResponse;
return extractImageFromResponse(result);
}
function getMimeType(filename: string): string {
const ext = path.extname(filename).toLowerCase();
if (ext === ".jpg" || ext === ".jpeg") return "image/jpeg";
if (ext === ".webp") return "image/webp";
if (ext === ".gif") return "image/gif";
return "image/png";
}
async function extractImageFromResponse(result: OpenAIImageResponse): Promise<Uint8Array> {
const img = result.data[0];
if (img?.b64_json) {
+15 -2
View File
@@ -1,4 +1,4 @@
export type Provider = "google" | "openai";
export type Provider = "google" | "openai" | "dashscope";
export type Quality = "normal" | "2k";
export type CliArgs = {
@@ -9,10 +9,23 @@ export type CliArgs = {
model: string | null;
aspectRatio: string | null;
size: string | null;
quality: Quality;
quality: Quality | null;
imageSize: string | null;
referenceImages: string[];
n: number;
json: boolean;
help: boolean;
};
export type ExtendConfig = {
version: number;
default_provider: Provider | null;
default_quality: Quality | null;
default_aspect_ratio: string | null;
default_image_size: "1K" | "2K" | "4K" | null;
default_model: {
google: string | null;
openai: string | null;
dashscope: string | null;
};
};
+17 -6
View File
@@ -153,10 +153,12 @@ Schema: `references/config/preferences-schema.md`
**1.2 Analyze Content → `analysis.md`**
1. Save source content (file path or paste → `source.md`)
- **Backup rule**: If `source.md` exists, rename to `source-backup-YYYYMMDD-HHMMSS.md`
2. Analyze: topic, data type, complexity, tone, audience
3. Detect source language and user language
4. Extract design instructions from user input
5. Save analysis
- **Backup rule**: If `analysis.md` exists, rename to `analysis-backup-YYYYMMDD-HHMMSS.md`
See `references/analysis-framework.md` for detailed format.
@@ -182,13 +184,20 @@ Recommend 3-5 layout×style combinations based on:
### Step 4: Confirm Options
Present all options in single confirmation:
1. **Combination** (always): 3+ options with rationale
2. **Aspect** (always): landscape/portrait/square
3. **Language** (only if source ≠ user language): which language for text
Use **single AskUserQuestion call** with multiple questions to confirm all options together:
| Question | When | Options |
|----------|------|---------|
| **Combination** | Always | 3+ layout×style combos with rationale |
| **Aspect** | Always | landscape (16:9), portrait (9:16), square (1:1) |
| **Language** | Only if source ≠ user language | Language for text content |
**Important**: Do NOT split into separate AskUserQuestion calls. Combine all applicable questions into one call.
### Step 5: Generate Prompt → `prompts/infographic.md`
**Backup rule**: If `prompts/infographic.md` exists, rename to `prompts/infographic-backup-YYYYMMDD-HHMMSS.md`
Combine:
1. Layout definition from `references/layouts/<layout>.md`
2. Style definition from `references/styles/<style>.md`
@@ -199,8 +208,10 @@ Combine:
### Step 6: Generate Image
1. Select available image generation skill (ask user if multiple)
2. Call with prompt file and output path
3. On failure, auto-retry once
2. **Check for existing file**: Before generating, check if `infographic.png` exists
- If exists: Rename to `infographic-backup-YYYYMMDD-HHMMSS.png`
3. Call with prompt file and output path
4. On failure, auto-retry once
### Step 7: Output Summary
+193
View File
@@ -0,0 +1,193 @@
---
name: baoyu-markdown-to-html
description: Converts Markdown to styled HTML with WeChat-compatible themes. Supports code highlighting, math, PlantUML, footnotes, alerts, and infographics. Use when user asks for "markdown to html", "convert md to html", "md转html", or needs styled HTML output from markdown.
---
# Markdown to HTML Converter
Converts Markdown files to beautifully styled HTML with inline CSS, optimized for WeChat Official Account and other platforms.
## Script Directory
**Agent Execution**: Determine this SKILL.md directory as `SKILL_DIR`, then use `${SKILL_DIR}/scripts/<name>.ts`.
| Script | Purpose |
|--------|---------|
| `scripts/main.ts` | Main entry point |
## Preferences (EXTEND.md)
Use Bash to check EXTEND.md existence (priority order):
```bash
# Check project-level first
test -f .baoyu-skills/baoyu-markdown-to-html/EXTEND.md && echo "project"
# Then user-level (cross-platform: $HOME works on macOS/Linux/WSL)
test -f "$HOME/.baoyu-skills/baoyu-markdown-to-html/EXTEND.md" && echo "user"
```
┌──────────────────────────────────────────────────────────────┬───────────────────┐
│ Path │ Location │
├──────────────────────────────────────────────────────────────┼───────────────────┤
│ .baoyu-skills/baoyu-markdown-to-html/EXTEND.md │ Project directory │
├──────────────────────────────────────────────────────────────┼───────────────────┤
│ $HOME/.baoyu-skills/baoyu-markdown-to-html/EXTEND.md │ User home │
└──────────────────────────────────────────────────────────────┴───────────────────┘
┌───────────┬───────────────────────────────────────────────────────────────────────────┐
│ Result │ Action │
├───────────┼───────────────────────────────────────────────────────────────────────────┤
│ Found │ Read, parse, apply settings │
├───────────┼───────────────────────────────────────────────────────────────────────────┤
│ Not found │ Use defaults │
└───────────┴───────────────────────────────────────────────────────────────────────────┘
**EXTEND.md Supports**: Default theme | Custom CSS variables | Code block style
## Workflow
### Step 0: Pre-check (Chinese Content)
**Condition**: Only execute if input file contains Chinese text.
**Detection**:
1. Read input markdown file
2. Check if content contains CJK characters (Chinese/Japanese/Korean)
3. If no CJK content → skip to Step 1
**Format Suggestion**:
If CJK content detected AND `baoyu-format-markdown` skill is available:
Use `AskUserQuestion` to ask whether to format first. Formatting can fix:
- Bold markers with punctuation inside causing `**` parse failures
- CJK/English spacing issues
**If user agrees**: Invoke `baoyu-format-markdown` skill to format the file, then use formatted file as input.
**If user declines**: Continue with original file.
### Step 1: Confirm Theme
Before converting, use AskUserQuestion to confirm the theme (unless user already specified):
| Theme | Description |
|-------|-------------|
| `default` (Recommended) | 经典主题 - 传统排版,标题居中带底边,二级标题白字彩底 |
| `grace` | 优雅主题 - 文字阴影,圆角卡片,精致引用块 |
| `simple` | 简洁主题 - 现代极简风,不对称圆角,清爽留白 |
### Step 2: Convert
```bash
npx -y bun ${SKILL_DIR}/scripts/main.ts <markdown_file> --theme <theme>
```
### Step 3: Report Result
Display the output path from JSON result. If backup was created, mention it.
## Usage
```bash
npx -y bun ${SKILL_DIR}/scripts/main.ts <markdown_file> [options]
```
**Options:**
| Option | Description | Default |
|--------|-------------|---------|
| `--theme <name>` | Theme name (default, grace, simple) | default |
| `--title <title>` | Override title from frontmatter | |
| `--keep-title` | Keep the first heading in content | false (removed) |
| `--help` | Show help | |
**Examples:**
```bash
# Basic conversion (uses default theme, removes first heading)
npx -y bun ${SKILL_DIR}/scripts/main.ts article.md
# With specific theme
npx -y bun ${SKILL_DIR}/scripts/main.ts article.md --theme grace
# Keep the first heading in content
npx -y bun ${SKILL_DIR}/scripts/main.ts article.md --keep-title
# Override title
npx -y bun ${SKILL_DIR}/scripts/main.ts article.md --title "My Article"
```
## Output
**File location**: Same directory as input markdown file.
- Input: `/path/to/article.md`
- Output: `/path/to/article.html`
**Conflict handling**: If HTML file already exists, it will be backed up first:
- Backup: `/path/to/article.html.bak-YYYYMMDDHHMMSS`
**JSON output to stdout:**
```json
{
"title": "Article Title",
"author": "Author Name",
"summary": "Article summary...",
"htmlPath": "/path/to/article.html",
"backupPath": "/path/to/article.html.bak-20260128180000",
"contentImages": [
{
"placeholder": "MDTOHTMLIMGPH_1",
"localPath": "/path/to/img.png",
"originalPath": "imgs/image.png"
}
]
}
```
## Themes
| Theme | Description |
|-------|-------------|
| `default` | 经典主题 - 传统排版,标题居中带底边,二级标题白字彩底 |
| `grace` | 优雅主题 - 文字阴影,圆角卡片,精致引用块 (by @brzhang) |
| `simple` | 简洁主题 - 现代极简风,不对称圆角,清爽留白 (by @okooo5km) |
## Supported Markdown Features
| Feature | Syntax |
|---------|--------|
| Headings | `# H1` to `###### H6` |
| Bold/Italic | `**bold**`, `*italic*` |
| Code blocks | ` ```lang ` with syntax highlighting |
| Inline code | `` `code` `` |
| Tables | GitHub-flavored markdown tables |
| Images | `![alt](src)` |
| Links | `[text](url)` with footnote references |
| Blockquotes | `> quote` |
| Lists | `-` unordered, `1.` ordered |
| Alerts | `> [!NOTE]`, `> [!WARNING]`, etc. |
| Footnotes | `[^1]` references |
| Ruby text | `{base|annotation}` |
| Mermaid | ` ```mermaid ` diagrams |
| PlantUML | ` ```plantuml ` diagrams |
## Frontmatter
Supports YAML frontmatter for metadata:
```yaml
---
title: Article Title
author: Author Name
description: Article summary
---
```
If no title is found, extracts from first H1/H2 heading or uses filename.
## Extension Support
Custom configurations via EXTEND.md. See **Preferences** section for paths and supported options.
@@ -0,0 +1,333 @@
import fs from 'node:fs';
import path from 'node:path';
import { writeFile } from 'node:fs/promises';
import os from 'node:os';
import { createHash } from 'node:crypto';
import { fileURLToPath } from 'node:url';
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;
backupPath?: string;
contentImages: ImageInfo[];
}
function formatTimestamp(date = new Date()): string {
const pad = (v: number) => String(v).padStart(2, '0');
return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`;
}
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(`[markdown-to-html] 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]! };
}
export async function convertMarkdown(markdownPath: string, options?: { title?: string; theme?: string; keepTitle?: boolean }): Promise<ParsedResult> {
const baseDir = path.dirname(markdownPath);
const content = fs.readFileSync(markdownPath, 'utf-8');
const theme = options?.theme ?? 'default';
const keepTitle = options?.keepTitle ?? false;
const { frontmatter, body } = parseFrontmatter(content);
const stripQuotes = (s?: string): string => {
if (!s) return '';
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
return s.slice(1, -1);
}
if ((s.startsWith('\u201c') && s.endsWith('\u201d')) || (s.startsWith('\u2018') && s.endsWith('\u2019'))) {
return s.slice(1, -1);
}
return s;
};
let title = options?.title ?? stripQuotes(frontmatter.title) ?? '';
if (!title) {
const lines = body.split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
const headingMatch = trimmed.match(/^#{1,2}\s+(.+)$/);
if (headingMatch) title = headingMatch[1]!;
break;
}
}
if (!title) title = path.basename(markdownPath, path.extname(markdownPath));
const author = stripQuotes(frontmatter.author);
let summary = stripQuotes(frontmatter.description) || stripQuotes(frontmatter.summary);
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 = `MDTOHTMLIMGPH_${++imageCounter}`;
images.push({ src, placeholder });
return placeholder;
});
const modifiedMarkdown = `---\n${Object.entries(frontmatter).map(([k, v]) => `${k}: ${v}`).join('\n')}\n---\n${modifiedBody}`;
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'markdown-to-html-'));
const tempMdPath = path.join(tempDir, 'temp-article.md');
await writeFile(tempMdPath, modifiedMarkdown, 'utf-8');
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const renderScript = path.join(__dirname, 'md', 'render.ts');
console.error(`[markdown-to-html] Rendering with theme: ${theme}, keepTitle: ${keepTitle}`);
const args = ['-y', 'bun', renderScript, tempMdPath, '--theme', theme];
if (keepTitle) args.push('--keep-title');
const result = spawnSync('npx', args, {
stdio: ['inherit', 'pipe', 'pipe'],
cwd: baseDir,
});
if (result.status !== 0) {
const stderr = result.stderr?.toString() || '';
throw new Error(`Render failed: ${stderr}`);
}
const tempHtmlPath = tempMdPath.replace(/\.md$/i, '.html');
if (!fs.existsSync(tempHtmlPath)) {
throw new Error(`HTML file not generated: ${tempHtmlPath}`);
}
const finalHtmlPath = markdownPath.replace(/\.md$/i, '.html');
let backupPath: string | undefined;
if (fs.existsSync(finalHtmlPath)) {
backupPath = `${finalHtmlPath}.bak-${formatTimestamp()}`;
console.error(`[markdown-to-html] Backing up existing file to: ${backupPath}`);
fs.renameSync(finalHtmlPath, backupPath);
}
fs.copyFileSync(tempHtmlPath, finalHtmlPath);
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,
});
}
let htmlContent = fs.readFileSync(finalHtmlPath, 'utf-8');
for (const img of contentImages) {
const imgTag = `<img src="${img.placeholder}" data-local-path="${img.localPath}" style="display: block; width: 100%; margin: 1.5em auto;">`;
htmlContent = htmlContent.replace(img.placeholder, imgTag);
}
fs.writeFileSync(finalHtmlPath, htmlContent, 'utf-8');
console.error(`[markdown-to-html] HTML saved to: ${finalHtmlPath}`);
return {
title,
author,
summary,
htmlPath: finalHtmlPath,
backupPath,
contentImages,
};
}
function printUsage(): never {
console.log(`Convert Markdown to styled HTML
Usage:
npx -y bun main.ts <markdown_file> [options]
Options:
--title <title> Override title
--theme <name> Theme name (default, grace, simple). Default: default
--keep-title Keep the first heading in content. Default: false (removed)
--help Show this help
Output:
HTML file saved to same directory as input markdown file.
Example: article.md -> article.html
If HTML file already exists, it will be backed up first:
article.html -> article.html.bak-YYYYMMDDHHMMSS
Output JSON format:
{
"title": "Article Title",
"htmlPath": "/path/to/article.html",
"backupPath": "/path/to/article.html.bak-20260128180000",
"contentImages": [...]
}
Example:
npx -y bun main.ts article.md
npx -y bun main.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;
let keepTitle = false;
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 === '--keep-title') {
keepTitle = true;
} else if (!arg.startsWith('-')) {
markdownPath = arg;
}
}
if (!markdownPath) {
console.error('Error: Markdown file path is required');
process.exit(1);
}
if (!fs.existsSync(markdownPath)) {
console.error(`Error: File not found: ${markdownPath}`);
process.exit(1);
}
const result = await convertMarkdown(markdownPath, { title, theme, keepTitle });
console.log(JSON.stringify(result, null, 2));
}
await main().catch((err) => {
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
});
@@ -0,0 +1,18 @@
This directory contains code adapted from the doocs/md project.
Original project: https://github.com/doocs/md
License: WTFPL (Do What The Fuck You Want To Public License)
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
Version 2, December 2004
Copyright (C) 2025 Doocs <admin@doocs.org>
Everyone is permitted to copy and distribute verbatim or modified
copies of this license document, and changing it is allowed as long
as the name is changed.
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. You just DO WHAT THE FUCK YOU WANT TO.
@@ -0,0 +1,284 @@
import type { MarkedExtension, Tokens } from 'marked'
export interface AlertOptions {
className?: string
variants?: AlertVariantItem[]
withoutStyle?: boolean
}
export interface AlertVariantItem {
type: string
icon: string
title?: string
titleClassName?: string
}
function ucfirst(str: string) {
return str.slice(0, 1).toUpperCase() + str.slice(1).toLowerCase()
}
/**
* https://github.com/bent10/marked-extensions/tree/main/packages/alert
* To support theme, we need to modify the source code.
* A [marked](https://marked.js.org/) extension to support [GFM alerts](https://github.com/orgs/community/discussions/16925).
*/
export function markedAlert(options: AlertOptions = {}): MarkedExtension {
const { className = `markdown-alert`, variants = [], withoutStyle = false } = options
const resolvedVariants = resolveVariants(variants)
// 提取公共的元数据构建逻辑
function buildMeta(variantType: string, matchedVariant: AlertVariantItem, fromContainer = false) {
return {
className,
variant: variantType,
icon: matchedVariant.icon,
title: matchedVariant.title ?? ucfirst(variantType),
titleClassName: `${className}-title`,
fromContainer,
}
}
// 提取公共的渲染逻辑
function renderAlert(token: any) {
const { meta, tokens = [] } = token
// @ts-expect-error marked renderer context has parser property
const text = this.parser.parse(tokens)
// 新主题系统:使用 CSS 选择器而非内联样式
let tmpl = `<blockquote class="${meta.className} ${meta.className}-${meta.variant}">\n`
tmpl += `<p class="${meta.titleClassName} alert-title-${meta.variant}">`
if (!withoutStyle) {
// 给 SVG 添加 class,通过 CSS 控制颜色
tmpl += meta.icon.replace(
`<svg`,
`<svg class="alert-icon-${meta.variant}"`,
)
}
tmpl += meta.title
tmpl += `</p>\n`
tmpl += text
tmpl += `</blockquote>\n`
return tmpl
}
return {
walkTokens(token) {
if (token.type !== `blockquote`)
return
const matchedVariant = resolvedVariants.find(({ type }) =>
new RegExp(createSyntaxPattern(type), `i`).test(token.text),
)
if (matchedVariant) {
const { type: variantType } = matchedVariant
const typeRegexp = new RegExp(createSyntaxPattern(variantType), `i`)
Object.assign(token, {
type: `alert`,
meta: buildMeta(variantType, matchedVariant),
})
const firstLine = token.tokens?.[0] as Tokens.Paragraph
const firstLineText = firstLine.raw?.replace(typeRegexp, ``).trim()
if (firstLineText) {
const patternToken = firstLine.tokens[0] as Tokens.Text
Object.assign(patternToken, {
raw: patternToken.raw.replace(typeRegexp, ``),
text: patternToken.text.replace(typeRegexp, ``),
})
if (firstLine.tokens[1]?.type === `br`) {
firstLine.tokens.splice(1, 1)
}
}
else {
token.tokens?.shift()
}
}
},
extensions: [
{
name: `alert`,
level: `block`,
renderer: renderAlert,
},
{
name: `alertContainer`,
level: `block`,
start(src) {
return src.match(/^:::/)?.index
},
tokenizer(src, _tokens) {
// eslint-disable-next-line regexp/no-super-linear-backtracking
const match = /^:::\s*(\w+)\s*\n([\s\S]*?)\n:::/.exec(src)
if (match) {
const [raw, variant, content] = match
const matchedVariant = resolvedVariants.find(v => v.type === variant)
if (!matchedVariant)
return
return {
type: `alert`,
raw,
text: content.trim(),
tokens: this.lexer.blockTokens(content.trim()),
meta: buildMeta(variant, matchedVariant, true),
}
}
},
renderer: renderAlert,
},
],
}
}
/**
* The default configuration for alert variants.
*/
const defaultAlertVariant: AlertVariantItem[] = [
{
type: `note`,
icon: `<svg class="octicon octicon-info" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.5 7.75A.75.75 0 0 1 7.25 7h1a.75.75 0 0 1 .75.75v2.75h.25a.75.75 0 0 1 0 1.5h-2a.75.75 0 0 1 0-1.5h.25v-2h-.25a.75.75 0 0 1-.75-.75ZM8 6a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z"></path></svg>`,
},
{
type: `info`,
icon: `<svg class="octicon octicon-info" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.5 7.75A.75.75 0 0 1 7.25 7h1a.75.75 0 0 1 .75.75v2.75h.25a.75.75 0 0 1 0 1.5h-2a.75.75 0 0 1 0-1.5h.25v-2h-.25a.75.75 0 0 1-.75-.75ZM8 6a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z"></path></svg>`,
},
{
type: `tip`,
icon: `<svg class="octicon octicon-light-bulb" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M8 1.5c-2.363 0-4 1.69-4 3.75 0 .984.424 1.625.984 2.304l.214.253c.223.264.47.556.673.848.284.411.537.896.621 1.49a.75.75 0 0 1-1.484.211c-.04-.282-.163-.547-.37-.847a8.456 8.456 0 0 0-.542-.68c-.084-.1-.173-.205-.268-.32C3.201 7.75 2.5 6.766 2.5 5.25 2.5 2.31 4.863 0 8 0s5.5 2.31 5.5 5.25c0 1.516-.701 2.5-1.328 3.259-.095.115-.184.22-.268.319-.207.245-.383.453-.541.681-.208.3-.33.565-.37.847a.751.751 0 0 1-1.485-.212c.084-.593.337-1.078.621-1.489.203-.292.45-.584.673-.848.075-.088.147-.173.213-.253.561-.679.985-1.32.985-2.304 0-2.06-1.637-3.75-4-3.75ZM5.75 12h4.5a.75.75 0 0 1 0 1.5h-4.5a.75.75 0 0 1 0-1.5ZM6 15.25a.75.75 0 0 1 .75-.75h2.5a.75.75 0 0 1 0 1.5h-2.5a.75.75 0 0 1-.75-.75Z"></path></svg>`,
},
{
type: `important`,
icon: `<svg class="octicon octicon-report" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M0 1.75C0 .784.784 0 1.75 0h12.5C15.216 0 16 .784 16 1.75v9.5A1.75 1.75 0 0 1 14.25 13H8.06l-2.573 2.573A1.458 1.458 0 0 1 3 14.543V13H1.75A1.75 1.75 0 0 1 0 11.25Zm1.75-.25a.25.25 0 0 0-.25.25v9.5c0 .138.112.25.25.25h2a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h6.5a.25.25 0 0 0 .25-.25v-9.5a.25.25 0 0 0-.25-.25Zm7 2.25v2.5a.75.75 0 0 1-1.5 0v-2.5a.75.75 0 0 1 1.5 0ZM9 9a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z"></path></svg>`,
},
{
type: `warning`,
icon: `<svg class="octicon octicon-alert" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M6.457 1.047c.659-1.234 2.427-1.234 3.086 0l6.082 11.378A1.75 1.75 0 0 1 14.082 15H1.918a1.75 1.75 0 0 1-1.543-2.575Zm1.763.707a.25.25 0 0 0-.44 0L1.698 13.132a.25.25 0 0 0 .22.368h12.164a.25.25 0 0 0 .22-.368Zm.53 3.996v2.5a.75.75 0 0 1-1.5 0v-2.5a.75.75 0 0 1 1.5 0ZM9 11a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z"></path></svg>`,
},
{
type: `caution`,
icon: `<svg class="octicon octicon-stop" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M4.47.22A.749.749 0 0 1 5 0h6c.199 0 .389.079.53.22l4.25 4.25c.141.14.22.331.22.53v6a.749.749 0 0 1-.22.53l-4.25 4.25A.749.749 0 0 1 11 16H5a.749.749 0 0 1-.53-.22L.22 11.53A.749.749 0 0 1 0 11V5c0-.199.079-.389.22-.53Zm.84 1.28L1.5 5.31v5.38l3.81 3.81h5.38l3.81-3.81V5.31L10.69 1.5ZM8 4a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5A.75.75 0 0 1 8 4Zm0 8a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z"></path></svg>`,
},
// Obsidian-style callouts
{
type: `abstract`,
title: `Abstract`,
icon: `<svg class="octicon octicon-clipboard" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M5.75 1a.75.75 0 0 0-.75.75v.5c0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75v-.5a.75.75 0 0 0-.75-.75Zm4.5-1.5a2.25 2.25 0 0 1 2.122 1.5H13a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V3a2 2 0 0 1 2-2h.628A2.25 2.25 0 0 1 5.75-.5ZM3.5 3v10a.5.5 0 0 0 .5.5h8a.5.5 0 0 0 .5-.5V3a.5.5 0 0 0-.5-.5h-8a.5.5 0 0 0-.5.5Z"></path></svg>`,
},
{
type: `summary`,
title: `Summary`,
icon: `<svg class="octicon octicon-clipboard" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M5.75 1a.75.75 0 0 0-.75.75v.5c0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75v-.5a.75.75 0 0 0-.75-.75Zm4.5-1.5a2.25 2.25 0 0 1 2.122 1.5H13a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V3a2 2 0 0 1 2-2h.628A2.25 2.25 0 0 1 5.75-.5ZM3.5 3v10a.5.5 0 0 0 .5.5h8a.5.5 0 0 0 .5-.5V3a.5.5 0 0 0-.5-.5h-8a.5.5 0 0 0-.5.5Z"></path></svg>`,
},
{
type: `tldr`,
title: `TL;DR`,
icon: `<svg class="octicon octicon-clipboard" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M5.75 1a.75.75 0 0 0-.75.75v.5c0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75v-.5a.75.75 0 0 0-.75-.75Zm4.5-1.5a2.25 2.25 0 0 1 2.122 1.5H13a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V3a2 2 0 0 1 2-2h.628A2.25 2.25 0 0 1 5.75-.5ZM3.5 3v10a.5.5 0 0 0 .5.5h8a.5.5 0 0 0 .5-.5V3a.5.5 0 0 0-.5-.5h-8a.5.5 0 0 0-.5.5Z"></path></svg>`,
},
{
type: `todo`,
title: `Todo`,
icon: `<svg class="octicon octicon-checklist" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M2.5 1.75v11.5c0 .138.112.25.25.25h3.17a.75.75 0 0 1 0 1.5H2.75A1.75 1.75 0 0 1 1 13.25V1.75C1 .784 1.784 0 2.75 0h8.5C12.216 0 13 .784 13 1.75v7.736a.75.75 0 0 1-1.5 0V1.75a.25.25 0 0 0-.25-.25h-8.5a.25.25 0 0 0-.25.25Zm10.97 8.72a.75.75 0 0 1 1.06 0l2 2a.75.75 0 0 1-1.06 1.06l-1.22-1.22v4.94a.75.75 0 0 1-1.5 0v-4.94l-1.22 1.22a.75.75 0 0 1-1.06-1.06Z"></path></svg>`,
},
{
type: `success`,
title: `Success`,
icon: `<svg class="octicon octicon-check-circle" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M8 16A8 8 0 1 1 8 0a8 8 0 0 1 0 16Zm3.78-9.72a.751.751 0 0 0-.018-1.042.751.751 0 0 0-1.042-.018L6.75 9.19 5.28 7.72a.751.751 0 0 0-1.042.018.751.751 0 0 0-.018 1.042l2 2a.75.75 0 0 0 1.06 0Z"></path></svg>`,
},
{
type: `done`,
title: `Done`,
icon: `<svg class="octicon octicon-check-circle" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M8 16A8 8 0 1 1 8 0a8 8 0 0 1 0 16Zm3.78-9.72a.751.751 0 0 0-.018-1.042.751.751 0 0 0-1.042-.018L6.75 9.19 5.28 7.72a.751.751 0 0 0-1.042.018.751.751 0 0 0-.018 1.042l2 2a.75.75 0 0 0 1.06 0Z"></path></svg>`,
},
{
type: `question`,
title: `Question`,
icon: `<svg class="octicon octicon-question" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.92 6.085h.001a.749.749 0 1 1-1.342-.67c.169-.339.436-.701.849-.977C6.845 4.16 7.369 4 8 4a2.756 2.756 0 0 1 1.637.525c.503.377.863.965.863 1.725 0 .448-.115.83-.329 1.15-.205.307-.47.513-.692.662-.109.072-.22.138-.313.195l-.006.004a6.24 6.24 0 0 0-.26.16.952.952 0 0 0-.276.245.75.75 0 0 1-1.248-.832c.184-.264.42-.489.692-.661.103-.067.207-.132.313-.195l.007-.004c.1-.061.182-.11.258-.161a.969.969 0 0 0 .277-.245C8.96 6.514 9 6.427 9 6.25a.612.612 0 0 0-.262-.525A1.27 1.27 0 0 0 8 5.5c-.369 0-.595.09-.74.187a1.01 1.01 0 0 0-.34.398ZM9 11a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z"></path></svg>`,
},
{
type: `help`,
title: `Help`,
icon: `<svg class="octicon octicon-question" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.92 6.085h.001a.749.749 0 1 1-1.342-.67c.169-.339.436-.701.849-.977C6.845 4.16 7.369 4 8 4a2.756 2.756 0 0 1 1.637.525c.503.377.863.965.863 1.725 0 .448-.115.83-.329 1.15-.205.307-.47.513-.692.662-.109.072-.22.138-.313.195l-.006.004a6.24 6.24 0 0 0-.26.16.952.952 0 0 0-.276.245.75.75 0 0 1-1.248-.832c.184-.264.42-.489.692-.661.103-.067.207-.132.313-.195l.007-.004c.1-.061.182-.11.258-.161a.969.969 0 0 0 .277-.245C8.96 6.514 9 6.427 9 6.25a.612.612 0 0 0-.262-.525A1.27 1.27 0 0 0 8 5.5c-.369 0-.595.09-.74.187a1.01 1.01 0 0 0-.34.398ZM9 11a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z"></path></svg>`,
},
{
type: `faq`,
title: `FAQ`,
icon: `<svg class="octicon octicon-question" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.92 6.085h.001a.749.749 0 1 1-1.342-.67c.169-.339.436-.701.849-.977C6.845 4.16 7.369 4 8 4a2.756 2.756 0 0 1 1.637.525c.503.377.863.965.863 1.725 0 .448-.115.83-.329 1.15-.205.307-.47.513-.692.662-.109.072-.22.138-.313.195l-.006.004a6.24 6.24 0 0 0-.26.16.952.952 0 0 0-.276.245.75.75 0 0 1-1.248-.832c.184-.264.42-.489.692-.661.103-.067.207-.132.313-.195l.007-.004c.1-.061.182-.11.258-.161a.969.969 0 0 0 .277-.245C8.96 6.514 9 6.427 9 6.25a.612.612 0 0 0-.262-.525A1.27 1.27 0 0 0 8 5.5c-.369 0-.595.09-.74.187a1.01 1.01 0 0 0-.34.398ZM9 11a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z"></path></svg>`,
},
{
type: `failure`,
title: `Failure`,
icon: `<svg class="octicon octicon-x-circle" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M2.343 13.657A8 8 0 1 1 13.658 2.343 8 8 0 0 1 2.343 13.657ZM6.03 4.97a.751.751 0 0 0-1.042.018.751.751 0 0 0-.018 1.042L6.94 8 4.97 9.97a.749.749 0 0 0 .326 1.275.749.749 0 0 0 .734-.215L8 9.06l1.97 1.97a.749.749 0 0 0 1.275-.326.749.749 0 0 0-.215-.734L9.06 8l1.97-1.97a.749.749 0 0 0-.326-1.275.749.749 0 0 0-.734.215L8 6.94Z"></path></svg>`,
},
{
type: `fail`,
title: `Fail`,
icon: `<svg class="octicon octicon-x-circle" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M2.343 13.657A8 8 0 1 1 13.658 2.343 8 8 0 0 1 2.343 13.657ZM6.03 4.97a.751.751 0 0 0-1.042.018.751.751 0 0 0-.018 1.042L6.94 8 4.97 9.97a.749.749 0 0 0 .326 1.275.749.749 0 0 0 .734-.215L8 9.06l1.97 1.97a.749.749 0 0 0 1.275-.326.749.749 0 0 0-.215-.734L9.06 8l1.97-1.97a.749.749 0 0 0-.326-1.275.749.749 0 0 0-.734.215L8 6.94Z"></path></svg>`,
},
{
type: `missing`,
title: `Missing`,
icon: `<svg class="octicon octicon-x-circle" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M2.343 13.657A8 8 0 1 1 13.658 2.343 8 8 0 0 1 2.343 13.657ZM6.03 4.97a.751.751 0 0 0-1.042.018.751.751 0 0 0-.018 1.042L6.94 8 4.97 9.97a.749.749 0 0 0 .326 1.275.749.749 0 0 0 .734-.215L8 9.06l1.97 1.97a.749.749 0 0 0 1.275-.326.749.749 0 0 0-.215-.734L9.06 8l1.97-1.97a.749.749 0 0 0-.326-1.275.749.749 0 0 0-.734.215L8 6.94Z"></path></svg>`,
},
{
type: `danger`,
title: `Danger`,
icon: `<svg class="octicon octicon-zap" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M9.504.43a1.516 1.516 0 0 1 2.437 1.713L10.415 5.5h2.123c1.57 0 2.346 1.909 1.22 3.004l-7.34 7.142a1.249 1.249 0 0 1-.871.354h-.302a1.25 1.25 0 0 1-1.157-1.723L5.633 10.5H3.462c-1.57 0-2.346-1.909-1.22-3.004ZM9.98 1.873a.016.016 0 0 0-.016.006L2.252 9.021a.75.75 0 0 0 .488 1.212h3.838a.75.75 0 0 1 .694 1.034L5.545 15.02a.016.016 0 0 0 .003.017.017.017 0 0 0 .018.004h.302a.016.016 0 0 0 .012-.005l7.34-7.142a.75.75 0 0 0-.488-1.212h-3.838a.75.75 0 0 1-.694-1.034l1.527-3.628a.016.016 0 0 0-.003-.017.017.017 0 0 0-.018-.004h-.302Z"></path></svg>`,
},
{
type: `error`,
title: `Error`,
icon: `<svg class="octicon octicon-zap" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M9.504.43a1.516 1.516 0 0 1 2.437 1.713L10.415 5.5h2.123c1.57 0 2.346 1.909 1.22 3.004l-7.34 7.142a1.249 1.249 0 0 1-.871.354h-.302a1.25 1.25 0 0 1-1.157-1.723L5.633 10.5H3.462c-1.57 0-2.346-1.909-1.22-3.004ZM9.98 1.873a.016.016 0 0 0-.016.006L2.252 9.021a.75.75 0 0 0 .488 1.212h3.838a.75.75 0 0 1 .694 1.034L5.545 15.02a.016.016 0 0 0 .003.017.017.017 0 0 0 .018.004h.302a.016.016 0 0 0 .012-.005l7.34-7.142a.75.75 0 0 0-.488-1.212h-3.838a.75.75 0 0 1-.694-1.034l1.527-3.628a.016.016 0 0 0-.003-.017.017.017 0 0 0-.018-.004h-.302Z"></path></svg>`,
},
{
type: `bug`,
title: `Bug`,
icon: `<svg class="octicon octicon-bug" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M4.72.22a.75.75 0 0 1 1.06 0l1 .999a3.488 3.488 0 0 1 2.441 0l.999-1a.748.748 0 0 1 1.265.332.75.75 0 0 1-.205.729l-.775.776c.616.63.995 1.493.995 2.444v.327c0 .1-.009.197-.025.292l.727.726a.75.75 0 1 1-1.06 1.06l-.727-.727a2.17 2.17 0 0 1-.292.026H9.25V7.5a.75.75 0 0 1-1.5 0V6.125H6.875a2.17 2.17 0 0 1-.292-.026l-.727.727a.75.75 0 1 1-1.06-1.06l.727-.726a2.17 2.17 0 0 1-.025-.292V4.5c0-.951.379-1.814.995-2.444l-.775-.776a.75.75 0 0 1 0-1.06Zm6.437 6.003A.608.608 0 0 0 11 6.072v-.026a3.999 3.999 0 0 0-.11-.936 2.488 2.488 0 0 0-5.78 0 3.992 3.992 0 0 0-.11.936v.026c0 .05.008.098.02.147h4.937a.612.612 0 0 0 .2-.02ZM2.25 7.5a.75.75 0 0 0 0 1.5h.5v1.25a4.75 4.75 0 0 0 2.478 4.166l.247.137a.75.75 0 1 0 .722-1.313l-.246-.137A3.25 3.25 0 0 1 4.25 10.25V9h7.5v1.25a3.25 3.25 0 0 1-1.701 2.853l-.246.137a.75.75 0 1 0 .722 1.313l.247-.137A4.75 4.75 0 0 0 13.25 10.25V9h.5a.75.75 0 0 0 0-1.5Z"></path></svg>`,
},
{
type: `example`,
title: `Example`,
icon: `<svg class="octicon octicon-list-unordered" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M5.75 2.5h8.5a.75.75 0 0 1 0 1.5h-8.5a.75.75 0 0 1 0-1.5Zm0 5h8.5a.75.75 0 0 1 0 1.5h-8.5a.75.75 0 0 1 0-1.5Zm0 5h8.5a.75.75 0 0 1 0 1.5h-8.5a.75.75 0 0 1 0-1.5ZM2 14a1 1 0 1 1 0-2 1 1 0 0 1 0 2Zm1-6a1 1 0 1 1-2 0 1 1 0 0 1 2 0ZM2 4a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z"></path></svg>`,
},
{
type: `quote`,
title: `Quote`,
icon: `<svg class="octicon octicon-quote" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M1.75 2h12.5c.966 0 1.75.784 1.75 1.75v8.5A1.75 1.75 0 0 1 14.25 14H1.75A1.75 1.75 0 0 1 0 12.25v-8.5C0 2.784.784 2 1.75 2ZM1.5 12.25c0 .138.112.25.25.25h12.5a.25.25 0 0 0 .25-.25v-8.5a.25.25 0 0 0-.25-.25H1.75a.25.25 0 0 0-.25.25ZM4 5.25a.75.75 0 0 1 .75-.75h6.5a.75.75 0 0 1 0 1.5h-6.5A.75.75 0 0 1 4 5.25Zm0 4a.75.75 0 0 1 .75-.75h6.5a.75.75 0 0 1 0 1.5h-6.5a.75.75 0 0 1-.75-.75Z"></path></svg>`,
},
{
type: `cite`,
title: `Cite`,
icon: `<svg class="octicon octicon-quote" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M1.75 2h12.5c.966 0 1.75.784 1.75 1.75v8.5A1.75 1.75 0 0 1 14.25 14H1.75A1.75 1.75 0 0 1 0 12.25v-8.5C0 2.784.784 2 1.75 2ZM1.5 12.25c0 .138.112.25.25.25h12.5a.25.25 0 0 0 .25-.25v-8.5a.25.25 0 0 0-.25-.25H1.75a.25.25 0 0 0-.25.25ZM4 5.25a.75.75 0 0 1 .75-.75h6.5a.75.75 0 0 1 0 1.5h-6.5A.75.75 0 0 1 4 5.25Zm0 4a.75.75 0 0 1 .75-.75h6.5a.75.75 0 0 1 0 1.5h-6.5a.75.75 0 0 1-.75-.75Z"></path></svg>`,
},
]
/**
* Resolves the variants configuration, combining the provided variants with
* the default variants.
*/
export function resolveVariants(variants: AlertVariantItem[]) {
if (!variants.length)
return defaultAlertVariant
return Object.values(
[...defaultAlertVariant, ...variants].reduce(
(map, item) => {
map[item.type] = item
return map
},
{} as { [key: string]: AlertVariantItem },
),
)
}
/**
* Returns regex pattern to match alert syntax.
*/
export function createSyntaxPattern(type: string) {
return `^(?:\\[!${type}])\\s*?\n*`
}
@@ -0,0 +1,89 @@
import type { MarkedExtension, Tokens } from 'marked'
/**
* A marked extension to support footnotes syntax.
* Syntax:
* This is a footnote reference[^1][^2].
*
* [^1]: .....
* [^2]: .....
*/
interface MapContent {
index: number
text: string
}
const fnMap = new Map<string, MapContent>()
export function markedFootnotes(): MarkedExtension {
return {
extensions: [
{
name: `footnoteDef`,
level: `block`,
start(src: string) {
fnMap.clear()
return src.match(/^\[\^/)?.index
},
tokenizer(src: string) {
const match = src.match(/^\[\^(.*)\]:(.*)/)
if (match) {
const [raw, fnId, text] = match
const index = fnMap.size + 1
fnMap.set(fnId, { index, text })
return {
type: `footnoteDef`,
raw,
fnId,
index,
text,
}
}
return undefined
},
renderer(token: Tokens.Generic) {
const { index, text, fnId } = token
const fnInner = `
<code>${index}.</code>
<span>${text}</span>
<a id="fnDef-${fnId}" href="#fnRef-${fnId}" style="color: var(--md-primary-color);">\u21A9\uFE0E</a>
<br>`
if (index === 1) {
return `
<p style="font-size: 80%;margin: 0.5em 8px;word-break:break-all;">${fnInner}`
}
if (index === fnMap.size) {
return `${fnInner}</p>`
}
return fnInner
},
},
{
name: `footnoteRef`,
level: `inline`,
start(src: string) {
return src.match(/\[\^/)?.index
},
tokenizer(src: string) {
const match = src.match(/^\[\^(.*?)\]/)
if (match) {
const [raw, fnId] = match
if (fnMap.has(fnId)) {
return {
type: `footnoteRef`,
raw,
fnId,
}
}
}
},
renderer(token: Tokens.Generic) {
const { fnId } = token
const { index } = fnMap.get(fnId) as MapContent
return `<sup style="color: var(--md-primary-color);">
<a href="#fnDef-${fnId}" id="fnRef-${fnId}">\[${index}\]</a>
</sup>`
},
},
],
}
}
@@ -0,0 +1,10 @@
// Markdown 扩展导出
export * from './alert.js'
export * from './footnotes.js'
export * from './infographic.js'
export * from './katex.js'
export * from './markup.js'
export * from './plantuml.js'
export * from './ruby.js'
export * from './slider.js'
export * from './toc.js'
@@ -0,0 +1,119 @@
import type { MarkedExtension } from 'marked'
interface InfographicOptions {
themeMode?: 'dark' | 'light'
}
async function renderInfographic(containerId: string, code: string, options?: InfographicOptions) {
if (typeof window === 'undefined')
return
try {
const { Infographic, setDefaultFont, setFontExtendFactor, exportToSVG } = await import('@antv/infographic')
setFontExtendFactor(1.1)
setDefaultFont('-apple-system-font, "system-ui", "Helvetica Neue", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei UI", "Microsoft YaHei", Arial, sans-serif')
const findContainer = (retries = 5, delay = 100) => {
const container = document.getElementById(containerId)
if (container) {
const isDark = options?.themeMode === 'dark'
// 从 CSS 变量中读取主题颜色
const root = document.documentElement
const computedStyle = getComputedStyle(root)
const primaryColor = computedStyle.getPropertyValue('--md-primary-color').trim()
const backgroundColor = computedStyle.getPropertyValue('--background').trim()
// 转换 HSL 格式
const toHSLString = (variant: string) => {
const vars = variant.split(' ')
if (vars.length === 3)
return `hsl(${vars.join(', ')})`
if (vars.length === 4)
return `hsla(${vars.join(', ')})`
return ''
}
const instance = new Infographic({
container,
svg: {
style: {
width: '100%',
height: '100%',
background: isDark ? '#000' : 'transparent',
},
background: false,
},
theme: isDark ? 'dark' : 'default',
themeConfig: {
colorPrimary: primaryColor || undefined,
colorBg: toHSLString(backgroundColor) || undefined,
},
})
instance.on('loaded', ({ node }) => {
exportToSVG(node, { removeIds: true }).then((svg) => {
container.replaceChildren(svg)
})
})
instance.render(code)
return
}
if (retries > 0) {
setTimeout(() => findContainer(retries - 1, delay), delay)
}
}
findContainer()
}
catch (error) {
console.error('Failed to render Infographic:', error)
const container = document.getElementById(containerId)
if (container) {
container.innerHTML = `<div style="color: red; padding: 10px; border: 1px solid red;">Infographic 渲染失败: ${error instanceof Error ? error.message : String(error)}</div>`
}
}
}
export function markedInfographic(options?: InfographicOptions): MarkedExtension {
const className = 'infographic-diagram'
return {
extensions: [
{
name: 'infographic',
level: 'block',
start(src: string) {
return src.match(/^```infographic/m)?.index
},
tokenizer(src: string) {
const match = /^```infographic\r?\n([\s\S]*?)\r?\n```/.exec(src)
if (match) {
return {
type: 'infographic',
raw: match[0],
text: match[1].trim(),
}
}
},
renderer(token: any) {
const id = `infographic-${Math.random().toString(36).slice(2, 11)}`
const code = token.text
renderInfographic(id, code, options)
return `<div id="${id}" class="${className}" style="width: 100%;">正在加载 Infographic...</div>`
},
},
],
walkTokens(token: any) {
if (token.type === 'code' && token.lang === 'infographic') {
token.type = 'infographic'
}
},
}
}
@@ -0,0 +1,162 @@
import type { MarkedExtension } from 'marked'
export interface MarkedKatexOptions {
nonStandard?: boolean
}
const inlineRule = /^(\${1,2})(?!\$)((?:\\.|[^\\\n])*?(?:\\.|[^\\\n$]))\1(?=[\s?!.,:?!。,:]|$)/
const inlineRuleNonStandard = /^(\${1,2})(?!\$)((?:\\.|[^\\\n])*?(?:\\.|[^\\\n$]))\1/ // Non-standard, even if there are no spaces before and after $ or $$, try to parse
const blockRule = /^\s{0,3}(\${1,2})[ \t]*\n([\s\S]+?)\n\s{0,3}\1[ \t]*(?:\n|$)/
// LaTeX style rules for \( ... \) and \[ ... \]
const inlineLatexRule = /^\\\(([^\\]*(?:\\.[^\\]*)*?)\\\)/
const blockLatexRule = /^\\\[([^\\]*(?:\\.[^\\]*)*?)\\\]/
function createRenderer(display: boolean, withStyle: boolean = true) {
return (token: any) => {
// @ts-expect-error MathJax is a global variable
window.MathJax.texReset()
// @ts-expect-error MathJax is a global variable
const mjxContainer = window.MathJax.tex2svg(token.text, { display })
const svg = mjxContainer.firstChild
const width = svg.style[`min-width`] || svg.getAttribute(`width`)
svg.removeAttribute(`width`)
// 行内公式对齐 https://groups.google.com/g/mathjax-users/c/zThKffrrCvE?pli=1
// 直接覆盖 style 会覆盖 MathJax 的样式,需要手动设置
// svg.style = `max-width: 300vw !important; display: initial; flex-shrink: 0;`
if (withStyle) {
svg.style.display = `initial`
svg.style.setProperty(`max-width`, `300vw`, `important`)
svg.style.flexShrink = `0`
svg.style.width = width
}
if (!display) {
// 新主题系统:使用 class 而非内联样式
return `<span class="katex-inline">${svg.outerHTML}</span>`
}
return `<section class="katex-block">${svg.outerHTML}</section>`
}
}
function inlineKatex(options: MarkedKatexOptions | undefined, renderer: any) {
const nonStandard = options && options.nonStandard
const ruleReg = nonStandard ? inlineRuleNonStandard : inlineRule
return {
name: `inlineKatex`,
level: `inline`,
start(src: string) {
let index
let indexSrc = src
while (indexSrc) {
index = indexSrc.indexOf(`$`)
if (index === -1) {
return
}
const f = nonStandard ? index > -1 : index === 0 || indexSrc.charAt(index - 1) === ` `
if (f) {
const possibleKatex = indexSrc.substring(index)
if (possibleKatex.match(ruleReg)) {
return index
}
}
indexSrc = indexSrc.substring(index + 1).replace(/^\$+/, ``)
}
},
tokenizer(src: string) {
const match = src.match(ruleReg)
if (match) {
return {
type: `inlineKatex`,
raw: match[0],
text: match[2].trim(),
displayMode: match[1].length === 2,
}
}
},
renderer,
}
}
function blockKatex(_options: MarkedKatexOptions | undefined, renderer: any) {
return {
name: `blockKatex`,
level: `block`,
tokenizer(src: string) {
const match = src.match(blockRule)
if (match) {
return {
type: `blockKatex`,
raw: match[0],
text: match[2].trim(),
displayMode: match[1].length === 2,
}
}
},
renderer,
}
}
function inlineLatexKatex(_options: MarkedKatexOptions | undefined, renderer: any) {
return {
name: `inlineLatexKatex`,
level: `inline`,
start(src: string) {
const index = src.indexOf(`\\(`)
return index !== -1 ? index : undefined
},
tokenizer(src: string) {
const match = src.match(inlineLatexRule)
if (match) {
return {
type: `inlineLatexKatex`,
raw: match[0],
text: match[1].trim(),
displayMode: false,
}
}
},
renderer,
}
}
function blockLatexKatex(_options: MarkedKatexOptions | undefined, renderer: any) {
return {
name: `blockLatexKatex`,
level: `block`,
start(src: string) {
const index = src.indexOf(`\\[`)
return index !== -1 ? index : undefined
},
tokenizer(src: string) {
const match = src.match(blockLatexRule)
if (match) {
return {
type: `blockLatexKatex`,
raw: match[0],
text: match[1].trim(),
displayMode: true,
}
}
},
renderer,
}
}
export function MDKatex(options: MarkedKatexOptions | undefined, withStyle: boolean = true): MarkedExtension {
return {
extensions: [
inlineKatex(options, createRenderer(false, withStyle)),
blockKatex(options, createRenderer(true, withStyle)),
inlineLatexKatex(options, createRenderer(false, withStyle)),
blockLatexKatex(options, createRenderer(true, withStyle)),
],
}
}
@@ -0,0 +1,87 @@
import type { MarkedExtension } from 'marked'
/**
* 扩展标记语法:
* - 高亮: ==文本==
* - 下划线: ++文本++
* - 波浪线: ~文本~
*/
export function markedMarkup(): MarkedExtension {
return {
extensions: [
// 高亮语法 ==文本==
{
name: `markup_highlight`,
level: `inline`,
start(src: string) {
return src.match(/==(?!=)/)?.index
},
tokenizer(src: string) {
const rule = /^==((?:[^=]|=(?!=))+)==/
const match = rule.exec(src)
if (match) {
return {
type: `markup_highlight`,
raw: match[0],
text: match[1],
}
}
},
renderer(token: any) {
// 新主题系统:使用 class 而非内联样式
return `<span class="markup-highlight">${token.text}</span>`
},
},
// 下划线语法 ++文本++
{
name: `markup_underline`,
level: `inline`,
start(src: string) {
return src.match(/\+\+(?!\+)/)?.index
},
tokenizer(src: string) {
const rule = /^\+\+((?:[^+]|\+(?!\+))+)\+\+/
const match = rule.exec(src)
if (match) {
return {
type: `markup_underline`,
raw: match[0],
text: match[1],
}
}
},
renderer(token: any) {
// 新主题系统:使用 class 而非内联样式
return `<span class="markup-underline">${token.text}</span>`
},
},
// 波浪线语法 ~文本~
{
name: `markup_wavyline`,
level: `inline`,
start(src: string) {
// 查找单个 ~ 但不是连续的 ~~
return src.match(/~(?!~)/)?.index
},
tokenizer(src: string) {
// 匹配 ~文本~ 但确保不是 ~~文本~~
const rule = /^~([^~\n]+)~(?!~)/
const match = rule.exec(src)
if (match) {
return {
type: `markup_wavyline`,
raw: match[0],
text: match[1],
}
}
},
renderer(token: any) {
// 新主题系统:使用 class 而非内联样式
return `<span class="markup-wavyline">${token.text}</span>`
},
},
],
}
}
@@ -0,0 +1,289 @@
import type { MarkedExtension, Tokens } from 'marked'
import { deflateSync } from 'fflate'
export interface PlantUMLOptions {
/**
* PlantUML 服务器地址
* @default 'https://www.plantuml.com/plantuml'
*/
serverUrl?: string
/**
* 渲染格式
* @default 'svg'
*/
format?: `svg` | `png`
/**
* CSS 类名
* @default 'plantuml-diagram'
*/
className?: string
/**
* 是否内嵌SVG内容(用于微信公众号等不支持外链图片的环境)
* @default false
*/
inlineSvg?: boolean
/**
* 自定义样式
*/
styles?: {
container?: Record<string, string | number>
}
}
/**
* PlantUML 专用的 6-bit 编码函数
* 基于官方文档 https://plantuml.com/text-encoding
*/
function encode6bit(b: number): string {
if (b < 10) {
return String.fromCharCode(48 + b)
}
b -= 10
if (b < 26) {
return String.fromCharCode(65 + b)
}
b -= 26
if (b < 26) {
return String.fromCharCode(97 + b)
}
b -= 26
if (b === 0) {
return `-`
}
if (b === 1) {
return `_`
}
return `?`
}
/**
* 将 3 个字节附加到编码字符串中
* 基于官方文档 https://plantuml.com/text-encoding
*/
function append3bytes(b1: number, b2: number, b3: number): string {
const c1 = b1 >> 2
const c2 = ((b1 & 0x3) << 4) | (b2 >> 4)
const c3 = ((b2 & 0xF) << 2) | (b3 >> 6)
const c4 = b3 & 0x3F
let r = ``
r += encode6bit(c1 & 0x3F)
r += encode6bit(c2 & 0x3F)
r += encode6bit(c3 & 0x3F)
r += encode6bit(c4 & 0x3F)
return r
}
/**
* PlantUML 专用的 base64 编码函数
* 基于官方文档 https://plantuml.com/text-encoding
*/
function encode64(data: string): string {
let r = ``
for (let i = 0; i < data.length; i += 3) {
if (i + 2 === data.length) {
r += append3bytes(data.charCodeAt(i), data.charCodeAt(i + 1), 0)
}
else if (i + 1 === data.length) {
r += append3bytes(data.charCodeAt(i), 0, 0)
}
else {
r += append3bytes(data.charCodeAt(i), data.charCodeAt(i + 1), data.charCodeAt(i + 2))
}
}
return r
}
/**
* 使用 fflate 库进行 Deflate 压缩
* 按照官方规范进行压缩
*/
function performDeflate(input: string): string {
try {
// 将字符串转换为字节数组
const inputBytes = new TextEncoder().encode(input)
// 使用 fflate 进行 deflate 压缩(最高压缩级别 9)
const compressed = deflateSync(inputBytes, { level: 9 })
// 将压缩后的字节数组转换为二进制字符串
return String.fromCharCode(...compressed)
}
catch (error) {
console.warn(`Deflate compression failed:`, error)
// 如果压缩失败,返回原始输入
return input
}
}
/**
* 编码 PlantUML 代码为服务器可识别的格式
* 按照官方规范:UTF-8 编码 -> Deflate 压缩 -> PlantUML Base64 编码
*/
function encodePlantUML(plantumlCode: string): string {
try {
// 步骤 1 & 2: UTF-8 编码 + Deflate 压缩
const deflated = performDeflate(plantumlCode)
// 步骤 3: PlantUML 专用的 base64 编码
return encode64(deflated)
}
catch (error) {
// 如果编码失败,回退到简单方案
console.warn(`PlantUML encoding failed, using fallback:`, error)
const utf8Bytes = new TextEncoder().encode(plantumlCode)
const base64 = btoa(String.fromCharCode(...utf8Bytes))
return `~1${base64.replace(/\+/g, `-`).replace(/\//g, `_`).replace(/=/g, ``)}`
}
}
/**
* 生成 PlantUML 图片 URL
*/
function generatePlantUMLUrl(code: string, options: Required<PlantUMLOptions>): string {
const encoded = encodePlantUML(code)
const formatPath = options.format === `svg` ? `svg` : `png`
return `${options.serverUrl}/${formatPath}/${encoded}`
}
/**
* 渲染 PlantUML 图表
*/
function renderPlantUMLDiagram(token: Tokens.Code, options: Required<PlantUMLOptions>): string {
const { text: code } = token
// 检查代码是否包含 PlantUML 标记
const finalCode = (!code.trim().includes(`@start`) || !code.trim().includes(`@end`))
? `@startuml\n${code.trim()}\n@enduml`
: code
const imageUrl = generatePlantUMLUrl(finalCode, options)
// 如果启用了内嵌SVG且格式是SVG
if (options.inlineSvg && options.format === `svg`) {
// 由于marked是同步的,我们需要返回一个占位符,然后异步替换
const placeholder = `plantuml-placeholder-${Math.random().toString(36).slice(2, 11)}`
// 异步获取SVG内容并替换
fetchSvgContent(imageUrl).then((svgContent) => {
const placeholderElement = document.querySelector(`[data-placeholder="${placeholder}"]`)
if (placeholderElement) {
placeholderElement.outerHTML = createPlantUMLHTML(imageUrl, options, svgContent)
}
})
const containerStyles = options.styles.container
? Object.entries(options.styles.container)
.map(([key, value]) => `${key.replace(/([A-Z])/g, `-$1`).toLowerCase()}: ${value}`)
.join(`; `)
: ``
return `<div class="${options.className}" style="${containerStyles}" data-placeholder="${placeholder}">
<div style="color: #666; font-style: italic;">正在加载PlantUML图表...</div>
</div>`
}
return createPlantUMLHTML(imageUrl, options)
}
/**
* 获取SVG内容
*/
async function fetchSvgContent(svgUrl: string): Promise<string> {
try {
const response = await fetch(svgUrl)
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
const svgContent = await response.text()
// 移除SVG根元素的固定尺寸,使其响应式
return svgContent
// 移除width和height属性
.replace(/(<svg[^>]*)\swidth="[^"]*"/g, `$1`)
.replace(/(<svg[^>]*)\sheight="[^"]*"/g, `$1`)
// 移除style中的width和height
.replace(/(<svg[^>]*style="[^"]*?)width:[^;]*;?/g, `$1`)
.replace(/(<svg[^>]*style="[^"]*?)height:[^;]*;?/g, `$1`)
}
catch (error) {
console.warn(`Failed to fetch SVG content from ${svgUrl}:`, error)
return `<div style="color: #666; font-style: italic;">PlantUML图表加载失败</div>`
}
}
/**
* 创建 PlantUML HTML 元素
*/
function createPlantUMLHTML(imageUrl: string, options: Required<PlantUMLOptions>, svgContent?: string): string {
const containerStyles = options.styles.container
? Object.entries(options.styles.container)
.map(([key, value]) => `${key.replace(/([A-Z])/g, `-$1`).toLowerCase()}: ${value}`)
.join(`; `)
: ``
// 如果有SVG内容,直接嵌入
if (svgContent) {
return `<div class="${options.className}" style="${containerStyles}">
${svgContent}
</div>`
}
// 否则使用图片链接
return `<div class="${options.className}" style="${containerStyles}">
<img src="${imageUrl}" alt="PlantUML Diagram" style="max-width: 100%; height: auto;" />
</div>`
}
/**
* PlantUML marked 扩展
*/
export function markedPlantUML(options: PlantUMLOptions = {}): MarkedExtension {
const resolvedOptions: Required<PlantUMLOptions> = {
serverUrl: options.serverUrl || `https://www.plantuml.com/plantuml`,
format: options.format || `svg`,
className: options.className || `plantuml-diagram`,
inlineSvg: options.inlineSvg || false,
styles: {
container: {
textAlign: `center`,
margin: `16px 8px`,
overflowX: `auto`,
...options.styles?.container,
},
},
}
return {
extensions: [
{
name: `plantuml`,
level: `block`,
start(src: string) {
// 匹配 ```plantuml 代码块
return src.match(/^```plantuml/m)?.index
},
tokenizer(src: string) {
// 匹配完整的 plantuml 代码块
const match = /^```plantuml\r?\n([\s\S]*?)\r?\n```/.exec(src)
if (match) {
const [raw, code] = match
return {
type: `plantuml`,
raw,
text: code.trim(),
}
}
},
renderer(token: any) {
return renderPlantUMLDiagram(token, resolvedOptions)
},
},
],
walkTokens(token: any) {
// 处理现有的代码块,如果语言是 plantuml 就转换类型
if (token.type === `code` && token.lang === `plantuml`) {
token.type = `plantuml`
}
},
}
}
@@ -0,0 +1,125 @@
import type { MarkedExtension } from 'marked'
/**
* 注音/拼音标注扩展
* https://talk.commonmark.org/t/proper-ruby-text-rb-syntax-support-in-markdown/2279
* https://www.w3.org/TR/ruby/
*
* 支持的格式:
* 1. [文字]{注音}
* 2. [文字]^(注音)
*
* 分隔符:
* - `・` (中点)
* - `` (全角句点)
* - `。` (中文句号)
* - `-` (英文减号)
*/
export function markedRuby(): MarkedExtension {
return {
extensions: [
{
name: `ruby`,
level: `inline`,
start(src: string) {
// 匹配以 [ 开头的格式
return src.match(/\[/)?.index
},
tokenizer(src: string) {
// 1. [文字]{注音}
const rule1 = /^\[([^\]]+)\]\{([^}]+)\}/
let match = rule1.exec(src)
if (match) {
return {
type: `ruby`,
raw: match[0],
text: match[1].trim(),
ruby: match[2].trim(),
format: `basic`,
}
}
// 2. [文字]^(注音)
const rule2 = /^\[([^\]]+)\]\^\(([^)]+)\)/
match = rule2.exec(src)
if (match) {
return {
type: `ruby`,
raw: match[0],
text: match[1].trim(),
ruby: match[2].trim(),
format: `basic-hat`,
}
}
return undefined
},
renderer(token: any) {
const { text, ruby, format } = token
// 检查是否有分隔符
const separatorRegex = /[・.。-]/g
const hasSeparators = separatorRegex.test(ruby)
if (hasSeparators) {
// 分割注音部分
const rubyParts = ruby.split(separatorRegex).filter((part: string) => part.trim() !== ``)
const textChars = text.split(``)
const result = []
if (textChars.length >= rubyParts.length) {
// 文字字符数量 >= 注音部分数量
// 按注音部分数量分割文字
let currentIndex = 0
for (let i = 0; i < rubyParts.length; i++) {
const rubyPart = rubyParts[i]
const remainingChars = textChars.length - currentIndex
const remainingParts = rubyParts.length - i
// 计算当前部分应该包含多少个字符,默认为 1
let charCount = 1
if (remainingParts === 1) {
// 最后一个部分,包含所有剩余字符
charCount = remainingChars
}
// 提取当前部分的文字
const currentText = textChars.slice(currentIndex, currentIndex + charCount).join(``)
result.push(`<ruby data-text="${currentText}" data-ruby="${rubyPart}" data-format="${format}">${currentText}<rp>(</rp><rt>${rubyPart}</rt><rp>)</rp></ruby>`)
currentIndex += charCount
}
// 处理剩余的字符
if (currentIndex < textChars.length) {
result.push(textChars.slice(currentIndex).join(``))
}
}
else {
// 文字字符数量 < 注音部分数量
// 每个字符对应一个注音部分,多余的注音被忽略
for (let i = 0; i < textChars.length; i++) {
const char = textChars[i]
const rubyPart = rubyParts[i] || ``
if (rubyPart) {
result.push(`<ruby data-text="${char}" data-ruby="${rubyPart}" data-format="${format}">${char}<rp>(</rp><rt>${rubyPart}</rt><rp>)</rp></ruby>`)
}
else {
result.push(char)
}
}
}
return result.join(``)
}
return `<ruby data-text="${text}" data-ruby="${ruby}" data-format="${format}">${text}<rp>(</rp><rt>${ruby}</rt><rp>)</rp></ruby>`
},
},
],
}
}
@@ -0,0 +1,73 @@
import type { MarkedExtension, Tokens } from 'marked'
/**
* A marked extension to support horizontal sliding images.
* Syntax: <![alt1](url1),![alt2](url2),![alt3](url3)>
*/
export function markedSlider(): MarkedExtension {
return {
extensions: [
{
name: `horizontalSlider`,
level: `block`,
start(src: string) {
return src.match(/^<!\[/)?.index
},
tokenizer(src: string) {
const rule = /^<(!\[.*?\]\(.*?\)(?:,!\[.*?\]\(.*?\))*)>/
const match = src.match(rule)
if (match) {
return {
type: `horizontalSlider`,
raw: match[0],
text: match[1],
}
}
return undefined
},
renderer(token: Tokens.Generic) {
const { text } = token
const imageMatches = text.match(/!\[(.*?)\]\((.*?)\)/g) || []
if (imageMatches.length === 0) {
return ``
}
const images = imageMatches.map((img: string) => {
const altMatch = img.match(/!\[(.*?)\]/) || []
const srcMatch = img.match(/\]\((.*?)\)/) || []
const alt = altMatch[1] || ``
const src = srcMatch[1] || ``
// 新主题系统:不再需要内联样式
return { src, alt }
})
// 使用微信公众号兼容的滑动容器布局
// 使用微信支持的section标签和特殊样式组合
return `
<section style="box-sizing: border-box; font-size: 16px;">
<section data-role="outer" style="font-family: 微软雅黑; font-size: 16px;">
<section data-role="paragraph" style="margin: 0px auto; box-sizing: border-box; width: 100%;">
<section style="margin: 0px auto; text-align: center;">
<section style="display: inline-block; width: 100%;">
<!-- 微信公众号支持的滑动图片容器 -->
<section style="overflow-x: scroll; -webkit-overflow-scrolling: touch; white-space: nowrap; width: 100%; text-align: center;">
${images.map((img: { src: string, alt: string }, _index: number) => `<section style="display: inline-block; width: 100%; margin-right: 0; vertical-align: top;">
<img src="${img.src}" alt="${img.alt}" title="${img.alt}" style="width: 100%; height: auto; border-radius: 4px; vertical-align: top;"/>
<p style="margin-top: 5px; font-size: 14px; color: #666; text-align: center; white-space: normal;">${img.alt}</p>
</section>`).join(``)}
</section>
</section>
</section>
</section>
</section>
<p style="font-size: 14px; color: #999; text-align: center; margin-top: 5px;"><<< 左右滑动看更多 >>></p>
</section>
`
},
},
],
}
}
@@ -0,0 +1,74 @@
import type { MarkedExtension } from 'marked'
/**
* marked 插件:支持 [TOC] 语法,自动生成嵌套目录
*/
export function markedToc(): MarkedExtension {
let headings: { text: string, depth: number, index: number }[] = []
let firstToken = true
return {
walkTokens(token) {
if (firstToken) {
headings = []
firstToken = false
}
if (token.type === `heading`) {
const text = token.text || ``
const depth = token.depth || 1
const index = headings.length
headings.push({ text, depth, index })
}
},
extensions: [
{
name: `toc`,
level: `block`,
start(src) {
// 只匹配独立一行的 [TOC],避免误伤
const match = src.match(/^\s*\[TOC\]\s*$/m)
return match ? match.index : undefined
},
tokenizer(src) {
const match = /^\[TOC\]/.exec(src)
if (match) {
return {
type: `toc`,
raw: match[0],
}
}
},
renderer() {
if (!headings.length)
return ``
let html = `<nav class="markdown-toc"><ul class="toc-ul toc-level-1 pl-4 border-l ml-2">`
let lastDepth = 1
headings.forEach(({ text, depth, index }) => {
if (depth > lastDepth) {
for (let i = lastDepth + 1; i <= depth; i++) {
html += `<ul class="toc-ul toc-level-${i} pl-4 border-l ml-2">`
}
}
else if (depth < lastDepth) {
for (let i = lastDepth; i > depth; i--) {
html += `</ul>`
}
}
html += `<li class="toc-li toc-level-${depth} mb-1"><a class="text-gray-700 hover:text-blue-600 underline transition-colors" href="#${index}">${text}</a></li>`
lastDepth = depth
})
for (let i = lastDepth; i > 1; i--) {
html += `</ul>`
}
html += `</ul></nav>`
firstToken = true
return html
},
},
],
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,14 @@
{
"dependencies": {
"fflate": "^0.8.2",
"front-matter": "^4.0.2",
"highlight.js": "^11.11.1",
"juice": "^11.0.1",
"marked": "^15.0.6",
"reading-time": "^1.5.0",
"remark-cjk-friendly": "^1.1.0",
"remark-parse": "^11.0.0",
"remark-stringify": "^11.0.0",
"unified": "^11.0.5"
}
}
@@ -0,0 +1,879 @@
#!/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 { unified } from "unified";
import remarkParse from "remark-parse";
import remarkCjkFriendly from "remark-cjk-friendly";
import remarkStringify from "remark-stringify";
import {
markedAlert,
markedFootnotes,
markedInfographic,
markedMarkup,
markedPlantUML,
markedRuby,
markedSlider,
markedToc,
MDKatex,
} from "./extensions/index.js";
import {
COMMON_LANGUAGES,
highlightAndFormatCode,
} from "./utils/languages.js";
type ThemeName = string;
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
const THEME_DIR = path.resolve(SCRIPT_DIR, "themes");
const EXTERNAL_THEME_CONFIG_PATH =
process.env.MD_THEME_CONFIG_PATH
|| "/Users/jimliu/GitHub/md/packages/shared/src/configs/theme.ts";
const EXTERNAL_THEME_DIR =
process.env.MD_THEME_DIR
|| path.resolve(path.dirname(EXTERNAL_THEME_CONFIG_PATH), "theme-css");
const FALLBACK_THEMES: 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 };
function stripOutputScope(cssContent: string): string {
let css = cssContent;
css = css.replace(/#output\s*\{/g, "body {");
css = css.replace(/#output\s+/g, "");
css = css.replace(/^#output\s*/gm, "");
return css;
}
function discoverThemesFromDir(dir: string): string[] {
if (!fs.existsSync(dir)) {
return [];
}
return fs
.readdirSync(dir)
.filter((name) => name.endsWith(".css"))
.map((name) => name.replace(/\.css$/i, ""))
.filter((name) => name.toLowerCase() !== "base");
}
function readThemeNamesFromConfig(configPath: string): string[] {
if (!fs.existsSync(configPath)) {
return [];
}
const content = fs.readFileSync(configPath, "utf-8");
const match = content.match(/themeOptionsMap\s*=\s*\{([\s\S]*?)\n\}/);
if (!match) {
return [];
}
return Array.from(match[1].matchAll(/^\s*([a-zA-Z0-9_-]+)\s*:/gm)).map(
(item) => item[1]!
);
}
function resolveThemeNames(): ThemeName[] {
const localThemes = discoverThemesFromDir(THEME_DIR);
const externalThemes = discoverThemesFromDir(EXTERNAL_THEME_DIR);
const configThemes = readThemeNamesFromConfig(EXTERNAL_THEME_CONFIG_PATH);
const combined = new Set<ThemeName>([
...localThemes,
...externalThemes,
...configThemes,
]);
const resolved = Array.from(combined).filter((name) =>
fs.existsSync(path.join(THEME_DIR, `${name}.css`))
|| fs.existsSync(path.join(EXTERNAL_THEME_DIR, `${name}.css`))
);
return resolved.length ? resolved : FALLBACK_THEMES;
}
const THEME_NAMES: ThemeName[] = resolveThemeNames();
marked.setOptions({
breaks: true,
});
marked.use(markedSlider());
interface IOpts {
legend?: string;
citeStatus?: boolean;
countStatus?: boolean;
isMacCodeBlock?: boolean;
isShowLineNumber?: boolean;
themeMode?: "light" | "dark";
}
interface RendererAPI {
reset: (newOpts: Partial<IOpts>) => void;
setOptions: (newOpts: Partial<IOpts>) => void;
getOpts: () => IOpts;
parseFrontMatterAndContent: (markdown: string) => {
yamlData: Record<string, any>;
markdownContent: string;
readingTime: ReadTimeResults;
};
buildReadingTime: (reading: ReadTimeResults) => string;
buildFootnotes: () => string;
buildAddition: () => string;
createContainer: (html: string) => string;
}
interface ParseResult {
yamlData: Record<string, any>;
markdownContent: string;
readingTime: ReadTimeResults;
}
function escapeHtml(text: string): string {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;")
.replace(/`/g, "&#96;");
}
function buildAddition(): string {
return `
<style>
.preview-wrapper pre::before {
position: absolute;
top: 0;
right: 0;
color: #ccc;
text-align: center;
font-size: 0.8em;
padding: 5px 10px 0;
line-height: 15px;
height: 15px;
font-weight: 600;
}
</style>
`;
}
function buildFootnoteArray(footnotes: [number, string, string][]): string {
return footnotes
.map(([index, title, link]) =>
link === title
? `<code style="font-size: 90%; opacity: 0.6;">[${index}]</code>: <i style="word-break: break-all">${title}</i><br/>`
: `<code style="font-size: 90%; opacity: 0.6;">[${index}]</code> ${title}: <i style="word-break: break-all">${link}</i><br/>`
)
.join("\n");
}
function transform(legend: string, text: string | null, title: string | null): string {
const options = legend.split("-");
for (const option of options) {
if (option === "alt" && text) {
return text;
}
if (option === "title" && title) {
return title;
}
}
return "";
}
const macCodeSvg = `
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" x="0px" y="0px" width="45px" height="13px" viewBox="0 0 450 130">
<ellipse cx="50" cy="65" rx="50" ry="52" stroke="rgb(220,60,54)" stroke-width="2" fill="rgb(237,108,96)" />
<ellipse cx="225" cy="65" rx="50" ry="52" stroke="rgb(218,151,33)" stroke-width="2" fill="rgb(247,193,81)" />
<ellipse cx="400" cy="65" rx="50" ry="52" stroke="rgb(27,161,37)" stroke-width="2" fill="rgb(100,200,86)" />
</svg>
`.trim();
function parseFrontMatterAndContent(markdownText: string): ParseResult {
try {
const parsed = frontMatter(markdownText);
const yamlData = parsed.attributes;
const markdownContent = parsed.body;
const readingTimeResult = readingTime(markdownContent);
return {
yamlData: yamlData as Record<string, any>,
markdownContent,
readingTime: readingTimeResult,
};
} catch (error) {
console.error("Error parsing front-matter:", error);
return {
yamlData: {},
markdownContent: markdownText,
readingTime: readingTime(markdownText),
};
}
}
export function initRenderer(opts: IOpts = {}): RendererAPI {
const footnotes: [number, string, string][] = [];
let footnoteIndex = 0;
let codeIndex = 0;
const listOrderedStack: boolean[] = [];
const listCounters: number[] = [];
const isBrowser = typeof window !== "undefined";
function getOpts(): IOpts {
return opts;
}
function styledContent(styleLabel: string, content: string, tagName?: string): string {
const tag = tagName ?? styleLabel;
const className = `${styleLabel.replace(/_/g, "-")}`;
const headingAttr = /^h\d$/.test(tag) ? " data-heading=\"true\"" : "";
return `<${tag} class="${className}"${headingAttr}>${content}</${tag}>`;
}
function addFootnote(title: string, link: string): number {
const existingFootnote = footnotes.find(([, , existingLink]) => existingLink === link);
if (existingFootnote) {
return existingFootnote[0];
}
footnotes.push([++footnoteIndex, title, link]);
return footnoteIndex;
}
function reset(newOpts: Partial<IOpts>): void {
footnotes.length = 0;
footnoteIndex = 0;
setOptions(newOpts);
}
function setOptions(newOpts: Partial<IOpts>): void {
opts = { ...opts, ...newOpts };
marked.use(markedAlert());
if (isBrowser) {
marked.use(MDKatex({ nonStandard: true }, true));
}
marked.use(markedMarkup());
marked.use(markedInfographic({ themeMode: opts.themeMode }));
}
function buildReadingTime(readingTimeResult: ReadTimeResults): string {
if (!opts.countStatus) {
return "";
}
if (!readingTimeResult.words) {
return "";
}
return `
<blockquote class="md-blockquote">
<p class="md-blockquote-p">字数 ${readingTimeResult?.words},阅读大约需 ${Math.ceil(readingTimeResult?.minutes)} 分钟</p>
</blockquote>
`;
}
const buildFootnotes = () => {
if (!footnotes.length) {
return "";
}
return (
styledContent("h4", "引用链接")
+ styledContent("footnotes", buildFootnoteArray(footnotes), "p")
);
};
const renderer: RendererObject = {
heading({ tokens, depth }: Tokens.Heading) {
const text = this.parser.parseInline(tokens);
const tag = `h${depth}`;
return styledContent(tag, text);
},
paragraph({ tokens }: Tokens.Paragraph): string {
const text = this.parser.parseInline(tokens);
const isFigureImage = text.includes("<figure") && text.includes("<img");
const isEmpty = text.trim() === "";
if (isFigureImage || isEmpty) {
return text;
}
return styledContent("p", text);
},
blockquote({ tokens }: Tokens.Blockquote): string {
const text = this.parser.parse(tokens);
return styledContent("blockquote", text);
},
code({ text, lang = "" }: Tokens.Code): string {
if (lang.startsWith("mermaid")) {
if (isBrowser) {
clearTimeout(codeIndex as any);
codeIndex = setTimeout(async () => {
const windowRef = typeof window !== "undefined" ? (window as any) : undefined;
if (windowRef && windowRef.mermaid) {
const mermaid = windowRef.mermaid;
await mermaid.run();
} else {
const mermaid = await import("mermaid");
await mermaid.default.run();
}
}, 0) as any as number;
}
return `<pre class="mermaid">${text}</pre>`;
}
const langText = lang.split(" ")[0];
const isLanguageRegistered = hljs.getLanguage(langText);
const language = isLanguageRegistered ? langText : "plaintext";
const highlighted = highlightAndFormatCode(
text,
language,
hljs,
!!opts.isShowLineNumber
);
const span = `<span class="mac-sign" style="padding: 10px 14px 0;">${macCodeSvg}</span>`;
let pendingAttr = "";
if (!isLanguageRegistered && langText !== "plaintext") {
const escapedText = text.replace(/"/g, "&quot;");
pendingAttr = ` data-language-pending="${langText}" data-raw-code="${escapedText}" data-show-line-number="${opts.isShowLineNumber}"`;
}
const code = `<code class="language-${lang}"${pendingAttr}>${highlighted}</code>`;
return `<pre class="hljs code__pre">${span}${code}</pre>`;
},
codespan({ text }: Tokens.Codespan): string {
const escapedText = escapeHtml(text);
return styledContent("codespan", escapedText, "code");
},
list({ ordered, items, start = 1 }: Tokens.List) {
listOrderedStack.push(ordered);
listCounters.push(Number(start));
const html = items.map((item) => this.listitem(item)).join("");
listOrderedStack.pop();
listCounters.pop();
return styledContent(ordered ? "ol" : "ul", html);
},
listitem(token: Tokens.ListItem) {
const ordered = listOrderedStack[listOrderedStack.length - 1];
const idx = listCounters[listCounters.length - 1]!;
listCounters[listCounters.length - 1] = idx + 1;
const prefix = ordered ? `${idx}. ` : "• ";
let content: string;
try {
content = this.parser.parseInline(token.tokens);
} catch {
content = this.parser
.parse(token.tokens)
.replace(/^<p(?:\s[^>]*)?>([\s\S]*?)<\/p>/, "$1");
}
return styledContent("listitem", `${prefix}${content}`, "li");
},
image({ href, title, text }: Tokens.Image): string {
const newText = opts.legend ? transform(opts.legend, text, title) : "";
const subText = newText ? styledContent("figcaption", newText) : "";
const titleAttr = title ? ` title="${title}"` : "";
return `<figure><img src="${href}"${titleAttr} alt="${text}"/>${subText}</figure>`;
},
link({ href, title, text, tokens }: Tokens.Link): string {
const parsedText = this.parser.parseInline(tokens);
if (/^https?:\/\/mp\.weixin\.qq\.com/.test(href)) {
return `<a href="${href}" title="${title || text}">${parsedText}</a>`;
}
if (href === text) {
return parsedText;
}
if (opts.citeStatus) {
const ref = addFootnote(title || text, href);
return `<a href="${href}" title="${title || text}">${parsedText}<sup>[${ref}]</sup></a>`;
}
return `<a href="${href}" title="${title || text}">${parsedText}</a>`;
},
strong({ tokens }: Tokens.Strong): string {
return styledContent("strong", this.parser.parseInline(tokens));
},
em({ tokens }: Tokens.Em): string {
return styledContent("em", this.parser.parseInline(tokens));
},
table({ header, rows }: Tokens.Table): string {
const headerRow = header
.map((cell) => {
const text = this.parser.parseInline(cell.tokens);
return styledContent("th", text);
})
.join("");
const body = rows
.map((row) => {
const rowContent = row.map((cell) => this.tablecell(cell)).join("");
return styledContent("tr", rowContent);
})
.join("");
return `
<section style="max-width: 100%; overflow: auto">
<table class="preview-table">
<thead>${headerRow}</thead>
<tbody>${body}</tbody>
</table>
</section>
`;
},
tablecell(token: Tokens.TableCell): string {
const text = this.parser.parseInline(token.tokens);
return styledContent("td", text);
},
hr(_: Tokens.Hr): string {
return styledContent("hr", "");
},
};
marked.use({ renderer });
marked.use(markedMarkup());
marked.use(markedToc());
marked.use(markedSlider());
marked.use(markedAlert({}));
if (isBrowser) {
marked.use(MDKatex({ nonStandard: true }, true));
}
marked.use(markedFootnotes());
marked.use(
markedPlantUML({
inlineSvg: isBrowser,
})
);
marked.use(markedInfographic());
marked.use(markedRuby());
return {
buildAddition,
buildFootnotes,
setOptions,
reset,
parseFrontMatterAndContent,
buildReadingTime,
createContainer(content: string) {
return styledContent("container", content, "section");
},
getOpts,
};
}
function printUsage(): void {
console.error(
[
"Usage:",
" npx tsx src/md/render.ts <markdown_file> [--theme <name>]",
"",
"Options:",
` --theme Theme name (${THEME_NAMES.join(", ")})`,
].join("\n")
);
}
function parseArgs(argv: string[]): CliOptions | null {
let inputPath = "";
let theme: ThemeName = "default";
let keepTitle = false;
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 === "--keep-title") {
keepTitle = true;
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,
keepTitle,
};
}
interface CliOptions {
inputPath: string;
theme: ThemeName;
keepTitle: boolean;
}
function preprocessCjkEmphasis(markdown: string): string {
const processor = unified()
.use(remarkParse)
.use(remarkCjkFriendly);
const tree = processor.parse(markdown);
const visit = (node: any, parent?: any, index?: number) => {
if (node.children) {
for (let i = 0; i < node.children.length; i++) {
visit(node.children[i], node, i);
}
}
if (node.type === "strong" && parent && typeof index === "number") {
const text = extractText(node);
parent.children[index] = { type: "html", value: `<strong>${text}</strong>` };
}
if (node.type === "emphasis" && parent && typeof index === "number") {
const text = extractText(node);
parent.children[index] = { type: "html", value: `<em>${text}</em>` };
}
};
const extractText = (node: any): string => {
if (node.type === "text") return node.value;
if (node.children) return node.children.map(extractText).join("");
return "";
};
visit(tree);
const stringify = unified().use(remarkStringify);
let result = stringify.stringify(tree);
result = result.replace(/&#x([0-9A-Fa-f]+);/g, (_, hex) =>
String.fromCodePoint(parseInt(hex, 16))
);
return result;
}
function renderMarkdown(raw: string, renderer: RendererAPI): {
html: string;
readingTime: ReadTimeResults;
} {
const preprocessed = preprocessCjkEmphasis(raw);
const { markdownContent, readingTime: readingTimeResult } =
renderer.parseFrontMatterAndContent(preprocessed);
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 basePathCandidates = [
path.join(THEME_DIR, "base.css"),
path.join(EXTERNAL_THEME_DIR, "base.css"),
];
const themePathCandidates = [
path.join(THEME_DIR, `${theme}.css`),
path.join(EXTERNAL_THEME_DIR, `${theme}.css`),
];
const basePath = basePathCandidates.find((candidate) =>
fs.existsSync(candidate)
);
const themePath = themePathCandidates.find((candidate) =>
fs.existsSync(candidate)
);
if (!basePath) {
throw new Error(
`Missing base CSS. Checked: ${basePathCandidates.join(", ")}`
);
}
if (!themePath) {
throw new Error(
`Missing theme CSS for "${theme}". Checked: ${themePathCandidates.join(", ")}`
);
}
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 normalizeThemeCss(css: string): string {
return stripOutputScope(css);
}
interface HtmlDocumentMeta {
title: string;
author?: string;
description?: string;
}
function buildHtmlDocument(meta: HtmlDocumentMeta, css: string, html: string): string {
const lines = [
"<!doctype html>",
"<html>",
"<head>",
' <meta charset="utf-8" />',
' <meta name="viewport" content="width=device-width, initial-scale=1" />',
` <title>${meta.title}</title>`,
];
if (meta.author) {
lines.push(` <meta name="author" content="${meta.author}" />`);
}
if (meta.description) {
lines.push(` <meta name="description" content="${meta.description}" />`);
}
lines.push(
` <style>${css}</style>`,
"</head>",
"<body>",
' <div id="output">',
html,
" </div>",
"</body>",
"</html>"
);
return lines.join("\n");
}
async function inlineCss(html: string): Promise<string> {
try {
const { default: juice } = await import("juice");
return juice(html, {
inlinePseudoElements: true,
preserveImportant: true,
resolveCSSVariables: false,
});
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
throw new Error(
`Missing dependency "juice" for CSS inlining. Install it first (e.g. "bun add juice" or "npm add juice"). Original error: ${detail}`
);
}
}
function normalizeCssText(cssText: string): string {
return cssText
.replace(/var\(--md-primary-color\)/g, DEFAULT_STYLE.primaryColor)
.replace(/var\(--md-font-family\)/g, DEFAULT_STYLE.fontFamily)
.replace(/var\(--md-font-size\)/g, DEFAULT_STYLE.fontSize)
.replace(/var\(--blockquote-background\)/g, DEFAULT_STYLE.blockquoteBackground)
.replace(/hsl\(var\(--foreground\)\)/g, "#3f3f3f")
.replace(/--md-primary-color:\s*[^;"']+;?/g, "")
.replace(/--md-font-family:\s*[^;"']+;?/g, "")
.replace(/--md-font-size:\s*[^;"']+;?/g, "")
.replace(/--blockquote-background:\s*[^;"']+;?/g, "")
.replace(/--foreground:\s*[^;"']+;?/g, "");
}
function normalizeInlineCss(html: string): string {
let output = html;
output = output.replace(
/<style([^>]*)>([\s\S]*?)<\/style>/gi,
(_match, attrs: string, cssText: string) =>
`<style${attrs}>${normalizeCssText(cssText)}</style>`
);
output = output.replace(
/style="([^"]*)"/gi,
(_match, cssText: string) => `style="${normalizeCssText(cssText)}"`
);
output = output.replace(
/style='([^']*)'/gi,
(_match, cssText: string) => `style='${normalizeCssText(cssText)}'`
);
return output;
}
function modifyHtmlStructure(htmlString: string): string {
let output = htmlString;
const pattern =
/<li([^>]*)>([\s\S]*?)(<ul[\s\S]*?<\/ul>|<ol[\s\S]*?<\/ol>)<\/li>/i;
while (pattern.test(output)) {
output = output.replace(pattern, "<li$1>$2</li>$3");
}
return output;
}
function removeFirstHeading(html: string): string {
return html.replace(/<h[12][^>]*>[\s\S]*?<\/h[12]>/, "");
}
async function main(): Promise<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 = normalizeThemeCss(buildCss(baseCss, themeCss));
const markdown = fs.readFileSync(inputPath, "utf-8");
const renderer = initRenderer({});
const { yamlData } = renderer.parseFrontMatterAndContent(markdown);
const { html: baseHtml, readingTime: readingTimeResult } = renderMarkdown(
markdown,
renderer
);
let content = postProcessHtml(baseHtml, readingTimeResult, renderer);
if (!options.keepTitle) {
content = removeFirstHeading(content);
}
const stripQuotes = (s?: string): string | undefined => {
if (!s) return s;
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
return s.slice(1, -1);
}
if ((s.startsWith('\u201c') && s.endsWith('\u201d')) || (s.startsWith('\u2018') && s.endsWith('\u2019'))) {
return s.slice(1, -1);
}
return s;
};
const meta: HtmlDocumentMeta = {
title: stripQuotes(yamlData.title) || path.basename(outputPath, ".html"),
author: stripQuotes(yamlData.author),
description: stripQuotes(yamlData.description) || stripQuotes(yamlData.summary),
};
const html = buildHtmlDocument(meta, css, content);
const inlinedHtml = normalizeInlineCss(await inlineCss(html));
const finalHtml = modifyHtmlStructure(inlinedHtml);
let backupPath = "";
if (fs.existsSync(outputPath)) {
backupPath = `${outputPath}.bak-${formatTimestamp()}`;
fs.renameSync(outputPath, backupPath);
}
fs.writeFileSync(outputPath, finalHtml, "utf-8");
if (backupPath) {
console.log(`Backup created: ${backupPath}`);
}
console.log(`HTML written: ${outputPath}`);
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
@@ -0,0 +1,34 @@
/**
* 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;
}
/* ==================== Global resets ==================== */
blockquote {
margin-top: 0;
margin-right: 0;
margin-bottom: 0;
margin-left: 0;
}
/* 去除第一个元素的 margin-top */
#output section > :first-child {
margin-top: 0 !important;
}
@@ -0,0 +1,433 @@
/**
* MD 默认主题(经典主题)
* 按 Alt/Option + Shift + F 可格式化
* 如需使用主题色,请使用 var(--md-primary-color) 代替颜色值
*/
/* ==================== 一级标题 ==================== */
h1 {
display: table;
padding: 0 1em;
border-bottom: 2px solid var(--md-primary-color);
margin: 2em auto 1em;
color: hsl(var(--foreground));
font-size: calc(var(--md-font-size) * 1.2);
font-weight: bold;
text-align: center;
}
/* ==================== 二级标题 ==================== */
h2 {
display: table;
padding: 0 0.2em;
margin: 4em auto 2em;
color: #fff;
background: var(--md-primary-color);
font-size: calc(var(--md-font-size) * 1.2);
font-weight: bold;
text-align: center;
}
/* ==================== 三级标题 ==================== */
h3 {
padding-left: 8px;
border-left: 3px solid var(--md-primary-color);
margin: 2em 8px 0.75em 0;
color: hsl(var(--foreground));
font-size: calc(var(--md-font-size) * 1.1);
font-weight: bold;
line-height: 1.2;
}
/* ==================== 四级标题 ==================== */
h4 {
margin: 2em 8px 0.5em;
color: var(--md-primary-color);
font-size: calc(var(--md-font-size) * 1);
font-weight: bold;
}
/* ==================== 五级标题 ==================== */
h5 {
margin: 1.5em 8px 0.5em;
color: var(--md-primary-color);
font-size: calc(var(--md-font-size) * 1);
font-weight: bold;
}
/* ==================== 六级标题 ==================== */
h6 {
margin: 1.5em 8px 0.5em;
font-size: calc(var(--md-font-size) * 1);
color: var(--md-primary-color);
}
/* ==================== 段落 ==================== */
p {
margin: 1.5em 8px;
letter-spacing: 0.1em;
color: hsl(var(--foreground));
}
/* ==================== 引用块 ==================== */
blockquote {
font-style: normal;
padding: 1em;
border-left: 4px solid var(--md-primary-color);
border-radius: 6px;
color: hsl(var(--foreground));
background: var(--blockquote-background);
margin-bottom: 1em;
}
blockquote > p {
display: block;
font-size: 1em;
letter-spacing: 0.1em;
color: hsl(var(--foreground));
margin: 0;
}
/* ==================== GFM 警告块 ==================== */
.alert-title-note,
.alert-title-tip,
.alert-title-info,
.alert-title-important,
.alert-title-warning,
.alert-title-caution,
.alert-title-abstract,
.alert-title-summary,
.alert-title-tldr,
.alert-title-todo,
.alert-title-success,
.alert-title-done,
.alert-title-question,
.alert-title-help,
.alert-title-faq,
.alert-title-failure,
.alert-title-fail,
.alert-title-missing,
.alert-title-danger,
.alert-title-error,
.alert-title-bug,
.alert-title-example,
.alert-title-quote,
.alert-title-cite {
display: flex;
align-items: center;
gap: 0.5em;
margin-bottom: 0.5em;
}
.alert-title-note {
color: #478be6;
}
.alert-title-tip {
color: #57ab5a;
}
.alert-title-info {
color: #93c5fd;
}
.alert-title-important {
color: #986ee2;
}
.alert-title-warning {
color: #c69026;
}
.alert-title-caution {
color: #e5534b;
}
/* Obsidian-style callout colors */
.alert-title-abstract,
.alert-title-summary,
.alert-title-tldr {
color: #00bfff;
}
.alert-title-todo {
color: #478be6;
}
.alert-title-success,
.alert-title-done {
color: #57ab5a;
}
.alert-title-question,
.alert-title-help,
.alert-title-faq {
color: #c69026;
}
.alert-title-failure,
.alert-title-fail,
.alert-title-missing {
color: #e5534b;
}
.alert-title-danger,
.alert-title-error {
color: #e5534b;
}
.alert-title-bug {
color: #e5534b;
}
.alert-title-example {
color: #986ee2;
}
.alert-title-quote,
.alert-title-cite {
color: #9ca3af;
}
/* GFM Alert SVG 图标颜色 */
.alert-icon-note {
fill: #478be6;
}
.alert-icon-tip {
fill: #57ab5a;
}
.alert-icon-info {
fill: #93c5fd;
}
.alert-icon-important {
fill: #986ee2;
}
.alert-icon-warning {
fill: #c69026;
}
.alert-icon-caution {
fill: #e5534b;
}
/* Obsidian-style callout icon colors */
.alert-icon-abstract,
.alert-icon-summary,
.alert-icon-tldr {
fill: #00bfff;
}
.alert-icon-todo {
fill: #478be6;
}
.alert-icon-success,
.alert-icon-done {
fill: #57ab5a;
}
.alert-icon-question,
.alert-icon-help,
.alert-icon-faq {
fill: #c69026;
}
.alert-icon-failure,
.alert-icon-fail,
.alert-icon-missing {
fill: #e5534b;
}
.alert-icon-danger,
.alert-icon-error {
fill: #e5534b;
}
.alert-icon-bug {
fill: #e5534b;
}
.alert-icon-example {
fill: #986ee2;
}
.alert-icon-quote,
.alert-icon-cite {
fill: #9ca3af;
}
/* ==================== 代码块 ==================== */
pre.code__pre,
.hljs.code__pre {
font-size: 90%;
overflow-x: auto;
border-radius: 8px;
padding: 0 !important;
line-height: 1.5;
margin: 10px 8px;
}
/* ==================== 图片 ==================== */
img {
display: block;
max-width: 100%;
margin: 0.1em auto 0.5em;
border-radius: 4px;
}
/* ==================== 列表 ==================== */
ol {
padding-left: 1em;
margin-left: 0;
color: hsl(var(--foreground));
}
ul {
list-style: circle;
padding-left: 1em;
margin-left: 0;
color: hsl(var(--foreground));
}
li {
display: block;
margin: 0.2em 8px;
color: hsl(var(--foreground));
}
/* ==================== 脚注 ==================== */
/* footnotes 在 buildFootnotes() 中渲染为 <p> 标签 */
p.footnotes {
margin: 0.5em 8px;
font-size: 80%;
color: hsl(var(--foreground));
}
/* ==================== 图表 ==================== */
figure {
margin: 1.5em 8px;
color: hsl(var(--foreground));
}
figcaption,
.md-figcaption {
text-align: center;
color: #888;
font-size: 0.8em;
}
/* ==================== 分隔线 ==================== */
hr {
border-style: solid;
border-width: 2px 0 0;
border-color: rgba(0, 0, 0, 0.1);
-webkit-transform-origin: 0 0;
-webkit-transform: scale(1, 0.5);
transform-origin: 0 0;
transform: scale(1, 0.5);
height: 0.4em;
margin: 1.5em 0;
}
/* ==================== 行内代码 ==================== */
code {
font-size: 90%;
color: #d14;
background: rgba(27, 31, 35, 0.05);
padding: 3px 5px;
border-radius: 4px;
}
/* 代码块内的 code 标签需要特殊处理(覆盖行内 code 样式) */
pre.code__pre > code,
.hljs.code__pre > code {
display: -webkit-box;
padding: 0.5em 1em 1em;
overflow-x: auto;
text-indent: 0;
color: inherit;
background: none;
white-space: nowrap;
margin: 0;
}
/* ==================== 强调 ==================== */
em {
font-style: italic;
font-size: inherit;
}
/* ==================== 链接 ==================== */
a {
color: #576b95;
text-decoration: none;
}
/* ==================== 粗体 ==================== */
strong {
color: var(--md-primary-color);
font-weight: bold;
font-size: inherit;
}
/* ==================== 表格 ==================== */
table {
color: hsl(var(--foreground));
}
thead {
font-weight: bold;
color: hsl(var(--foreground));
}
th {
border: 1px solid #dfdfdf;
padding: 0.25em 0.5em;
color: hsl(var(--foreground));
word-break: keep-all;
background: rgba(0, 0, 0, 0.05);
}
td {
border: 1px solid #dfdfdf;
padding: 0.25em 0.5em;
color: hsl(var(--foreground));
word-break: keep-all;
}
/* ==================== KaTeX 公式 ==================== */
.katex-inline {
max-width: 100%;
overflow-x: auto;
}
.katex-block {
max-width: 100%;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
padding: 0.5em 0;
text-align: center;
}
/* ==================== 标记高亮 ==================== */
.markup-highlight {
background-color: var(--md-primary-color);
padding: 2px 4px;
border-radius: 2px;
color: #fff;
}
.markup-underline {
text-decoration: underline;
text-decoration-color: var(--md-primary-color);
}
.markup-wavyline {
text-decoration: underline wavy;
text-decoration-color: var(--md-primary-color);
text-decoration-thickness: 2px;
}
@@ -0,0 +1,136 @@
/**
* MD 优雅主题 (@brzhang)
* 在默认主题基础上添加优雅的视觉效果
*/
/* ==================== 标题样式 ==================== */
h1 {
padding: 0.5em 1em;
border-bottom: 2px solid var(--md-primary-color);
font-size: calc(var(--md-font-size) * 1.4);
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.1);
}
h2 {
padding: 0.3em 1em;
border-radius: 8px;
font-size: calc(var(--md-font-size) * 1.3);
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
h3 {
padding-left: 12px;
font-size: calc(var(--md-font-size) * 1.2);
border-left: 4px solid var(--md-primary-color);
border-bottom: 1px dashed var(--md-primary-color);
}
h4 {
font-size: calc(var(--md-font-size) * 1.1);
}
h5 {
font-size: var(--md-font-size);
}
h6 {
font-size: var(--md-font-size);
}
/* ==================== 引用块 ==================== */
blockquote {
font-style: italic;
padding: 1em 1em 1em 2em;
border-left: 4px solid var(--md-primary-color);
border-radius: 6px;
color: rgba(0, 0, 0, 0.6);
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
margin-bottom: 1em;
}
.markdown-alert {
font-style: italic;
}
/* ==================== 代码块 ==================== */
pre.code__pre,
.hljs.code__pre {
box-shadow: inset 0 0 10px rgba(0, 0, 0, 0.05);
}
pre.code__pre > code,
.hljs.code__pre > code {
font-family:
'Fira Code',
Menlo,
Operator Mono,
Consolas,
Monaco,
monospace;
}
/* ==================== 图片 ==================== */
img {
border-radius: 8px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
figcaption,
.md-figcaption {
text-align: center;
color: #888;
font-size: 0.8em;
}
/* ==================== 列表 ==================== */
ol {
padding-left: 1.5em;
}
ul {
list-style: none;
padding-left: 1.5em;
}
li {
margin: 0.5em 8px;
}
/* ==================== 分隔线 ==================== */
hr {
height: 1px;
border: none;
margin: 2em 0;
background: linear-gradient(to right, rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.1), rgba(0, 0, 0, 0));
}
/* ==================== 表格 ==================== */
table {
border-collapse: separate;
border-spacing: 0;
border-radius: 8px;
margin: 1em 8px;
color: hsl(var(--foreground));
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
thead {
color: #fff;
}
td {
padding: 0.5em 1em;
}
/* ==================== 强调 ==================== */
em {
font-style: italic;
font-size: inherit;
}
/* ==================== 链接 ==================== */
a {
color: #576b95;
text-decoration: none;
}
@@ -0,0 +1,129 @@
/**
* MD 简洁主题 (@okooo5km)
* 简洁现代的设计风格
*/
/* ==================== 标题样式 ==================== */
h1 {
padding: 0.5em 1em;
font-size: calc(var(--md-font-size) * 1.4);
text-shadow: 1px 1px 3px rgba(0, 0, 0, 0.05);
}
h2 {
padding: 0.3em 1.2em;
font-size: calc(var(--md-font-size) * 1.3);
border-radius: 8px 24px 8px 24px;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.06);
}
h3 {
padding-left: 12px;
font-size: calc(var(--md-font-size) * 1.2);
border-radius: 6px;
line-height: 2.4em;
border-left: 4px solid var(--md-primary-color);
border-right: 1px solid color-mix(in srgb, var(--md-primary-color) 10%, transparent);
border-bottom: 1px solid color-mix(in srgb, var(--md-primary-color) 10%, transparent);
border-top: 1px solid color-mix(in srgb, var(--md-primary-color) 10%, transparent);
background: color-mix(in srgb, var(--md-primary-color) 8%, transparent);
}
h4 {
font-size: calc(var(--md-font-size) * 1.1);
border-radius: 6px;
}
h5 {
font-size: var(--md-font-size);
border-radius: 6px;
}
h6 {
font-size: var(--md-font-size);
border-radius: 6px;
}
/* ==================== 引用块 ==================== */
blockquote {
font-style: italic;
padding: 1em 1em 1em 2em;
color: rgba(0, 0, 0, 0.6);
border-bottom: 0.2px solid rgba(0, 0, 0, 0.04);
border-top: 0.2px solid rgba(0, 0, 0, 0.04);
border-right: 0.2px solid rgba(0, 0, 0, 0.04);
}
/* GFM Alert 样式覆盖 */
.markdown-alert-note,
.markdown-alert-tip,
.markdown-alert-info,
.markdown-alert-important,
.markdown-alert-warning,
.markdown-alert-caution {
font-style: italic;
}
/* ==================== 代码块 ==================== */
pre.code__pre,
.hljs.code__pre {
border: 1px solid rgba(0, 0, 0, 0.04);
}
pre.code__pre > code,
.hljs.code__pre > code {
font-family:
'Fira Code',
Menlo,
Operator Mono,
Consolas,
Monaco,
monospace;
}
/* ==================== 图片 ==================== */
img {
border-radius: 8px;
border: 1px solid rgba(0, 0, 0, 0.04);
}
figcaption,
.md-figcaption {
text-align: center;
color: #888;
font-size: 0.8em;
}
/* ==================== 列表 ==================== */
ol {
padding-left: 1.5em;
}
ul {
list-style: none;
padding-left: 1.5em;
}
li {
margin: 0.5em 8px;
}
/* ==================== 分隔线 ==================== */
hr {
height: 1px;
border: none;
margin: 2em 0;
background: linear-gradient(to right, rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.1), rgba(0, 0, 0, 0));
}
/* ==================== 强调 ==================== */
em {
font-style: italic;
font-size: inherit;
}
/* ==================== 链接 ==================== */
a {
color: #576b95;
text-decoration: none;
}
@@ -0,0 +1,238 @@
import type { LanguageFn } from 'highlight.js'
import bash from 'highlight.js/lib/languages/bash'
import c from 'highlight.js/lib/languages/c'
import cpp from 'highlight.js/lib/languages/cpp'
import csharp from 'highlight.js/lib/languages/csharp'
import css from 'highlight.js/lib/languages/css'
import diff from 'highlight.js/lib/languages/diff'
import go from 'highlight.js/lib/languages/go'
import graphql from 'highlight.js/lib/languages/graphql'
import ini from 'highlight.js/lib/languages/ini'
import java from 'highlight.js/lib/languages/java'
import javascript from 'highlight.js/lib/languages/javascript'
import json from 'highlight.js/lib/languages/json'
import kotlin from 'highlight.js/lib/languages/kotlin'
import less from 'highlight.js/lib/languages/less'
import lua from 'highlight.js/lib/languages/lua'
import makefile from 'highlight.js/lib/languages/makefile'
import markdown from 'highlight.js/lib/languages/markdown'
import objectivec from 'highlight.js/lib/languages/objectivec'
import perl from 'highlight.js/lib/languages/perl'
import php from 'highlight.js/lib/languages/php'
import phpTemplate from 'highlight.js/lib/languages/php-template'
import plaintext from 'highlight.js/lib/languages/plaintext'
import python from 'highlight.js/lib/languages/python'
import pythonRepl from 'highlight.js/lib/languages/python-repl'
import r from 'highlight.js/lib/languages/r'
import ruby from 'highlight.js/lib/languages/ruby'
import rust from 'highlight.js/lib/languages/rust'
import scss from 'highlight.js/lib/languages/scss'
import shell from 'highlight.js/lib/languages/shell'
import sql from 'highlight.js/lib/languages/sql'
import swift from 'highlight.js/lib/languages/swift'
import typescript from 'highlight.js/lib/languages/typescript'
import vbnet from 'highlight.js/lib/languages/vbnet'
import wasm from 'highlight.js/lib/languages/wasm'
import xml from 'highlight.js/lib/languages/xml'
import yaml from 'highlight.js/lib/languages/yaml'
export const COMMON_LANGUAGES: Record<string, LanguageFn> = {
bash,
c,
cpp,
csharp,
css,
diff,
go,
graphql,
ini,
java,
javascript,
json,
kotlin,
less,
lua,
makefile,
markdown,
objectivec,
perl,
php,
'php-template': phpTemplate,
plaintext,
python,
'python-repl': pythonRepl,
r,
ruby,
rust,
scss,
shell,
sql,
swift,
typescript,
vbnet,
wasm,
xml,
yaml,
}
// highlight.js CDN 配置
const HLJS_VERSION = `11.11.1`
const HLJS_CDN_BASE = `https://cdn-doocs.oss-cn-shenzhen.aliyuncs.com/npm/highlightjs/${HLJS_VERSION}`
// 缓存正在加载的语言
const loadingLanguages = new Map<string, Promise<void>>()
/**
* 生成语言包的 CDN URL
*/
function grammarUrlFor(language: string): string {
return `${HLJS_CDN_BASE}/es/languages/${language}.min.js`
}
/**
* 动态加载并注册语言
* @param language 语言名称
* @param hljs highlight.js 实例
*/
export async function loadAndRegisterLanguage(language: string, hljs: any): Promise<void> {
// 如果已经注册,直接返回
if (hljs.getLanguage(language)) {
return
}
// 如果正在加载,等待加载完成
if (loadingLanguages.has(language)) {
await loadingLanguages.get(language)
return
}
// 开始加载
const loadPromise = (async () => {
try {
const module = await import(/* @vite-ignore */ grammarUrlFor(language))
hljs.registerLanguage(language, module.default)
}
catch (error) {
console.warn(`Failed to load language: ${language}`, error)
throw error
}
finally {
loadingLanguages.delete(language)
}
})()
loadingLanguages.set(language, loadPromise)
await loadPromise
}
/**
* 格式化高亮后的代码,处理空格和制表符
*/
function formatHighlightedCode(html: string, preserveNewlines = false): string {
let formatted = html
// 将 span 之间的空格移到 span 内部
formatted = formatted.replace(/(<span[^>]*>[^<]*<\/span>)(\s+)(<span[^>]*>[^<]*<\/span>)/g, (_: string, span1: string, spaces: string, span2: string) => span1 + span2.replace(/^(<span[^>]*>)/, `$1${spaces}`))
formatted = formatted.replace(/(\s+)(<span[^>]*>)/g, (_: string, spaces: string, span: string) => span.replace(/^(<span[^>]*>)/, `$1${spaces}`))
// 替换制表符为4个空格
formatted = formatted.replace(/\t/g, ` `)
if (preserveNewlines) {
// 替换换行符为 <br/>,并将空格转换为 &nbsp;
formatted = formatted.replace(/\r\n/g, `<br/>`).replace(/\n/g, `<br/>`).replace(/(>[^<]+)|(^[^<]+)/g, (str: string) => str.replace(/\s/g, `&nbsp;`))
}
else {
// 只将空格转换为 &nbsp;
formatted = formatted.replace(/(>[^<]+)|(^[^<]+)/g, (str: string) => str.replace(/\s/g, `&nbsp;`))
}
return formatted
}
/**
* 高亮代码并格式化(支持行号)
* @param text 原始代码文本
* @param language 语言名称
* @param hljs highlight.js 实例
* @param showLineNumber 是否显示行号
* @returns 格式化后的 HTML
*/
export function highlightAndFormatCode(text: string, language: string, hljs: any, showLineNumber: boolean): string {
let highlighted = ``
if (showLineNumber) {
const rawLines = text.replace(/\r\n/g, `\n`).split(`\n`)
const highlightedLines = rawLines.map((lineRaw) => {
const lineHtml = hljs.highlight(lineRaw, { language }).value
const formatted = formatHighlightedCode(lineHtml, false)
return formatted === `` ? `&nbsp;` : formatted
})
const lineNumbersHtml = highlightedLines.map((_, idx) => `<section style="padding:0 10px 0 0;line-height:1.75">${idx + 1}</section>`).join(``)
const codeInnerHtml = highlightedLines.join(`<br/>`)
const codeLinesHtml = `<div style="white-space:pre;min-width:max-content;line-height:1.75">${codeInnerHtml}</div>`
const lineNumberColumnStyles = `text-align:right;padding:8px 0;border-right:1px solid rgba(0,0,0,0.04);user-select:none;background:var(--code-bg,transparent);`
highlighted = `
<section style="display:flex;align-items:flex-start;overflow-x:hidden;overflow-y:auto;width:100%;max-width:100%;padding:0;box-sizing:border-box">
<section class="line-numbers" style="${lineNumberColumnStyles}">${lineNumbersHtml}</section>
<section class="code-scroll" style="flex:1 1 auto;overflow-x:auto;overflow-y:visible;padding:8px;min-width:0;box-sizing:border-box">${codeLinesHtml}</section>
</section>
`
}
else {
const rawHighlighted = hljs.highlight(text, { language }).value
highlighted = formatHighlightedCode(rawHighlighted, true)
}
return highlighted
}
export function highlightCodeBlock(codeBlock: Element, language: string, hljs: any): void {
const rawCode = codeBlock.getAttribute(`data-raw-code`)
const showLineNumber = codeBlock.getAttribute(`data-show-line-number`) === `true`
if (!rawCode)
return
const text = rawCode.replace(/&quot;/g, `"`)
const highlighted = highlightAndFormatCode(text, language, hljs, showLineNumber)
codeBlock.innerHTML = highlighted
codeBlock.removeAttribute(`data-language-pending`)
codeBlock.removeAttribute(`data-raw-code`)
codeBlock.removeAttribute(`data-show-line-number`)
}
/**
* 高亮 DOM 中待处理的代码块
* 查找带有 data-language-pending 属性的代码块,动态加载语言后重新高亮
* @param hljs highlight.js 实例
* @param container 容器元素(可选,默认为 document)
*/
export function highlightPendingBlocks(hljs: any, container: Document | Element = document): void {
const pendingBlocks = container.querySelectorAll(`code[data-language-pending]`)
pendingBlocks.forEach((codeBlock) => {
const language = codeBlock.getAttribute(`data-language-pending`)
if (!language)
return
if (hljs.getLanguage(language)) {
// 语言已加载,直接高亮
highlightCodeBlock(codeBlock, language, hljs)
}
else {
// 动态加载语言后重新高亮
loadAndRegisterLanguage(language, hljs).then(() => {
highlightCodeBlock(codeBlock, language, hljs)
}).catch(() => {
// 加载失败,移除标记
codeBlock.removeAttribute(`data-language-pending`)
codeBlock.removeAttribute(`data-raw-code`)
codeBlock.removeAttribute(`data-show-line-number`)
})
}
})
}
+242 -24
View File
@@ -1,10 +1,14 @@
---
name: baoyu-post-to-wechat
description: Posts content to WeChat Official Account (微信公众号) via Chrome CDP automation. Supports article posting (文章) with full markdown formatting and image-text posting (图文) with multiple images. Use when user mentions "发布公众号", "post to wechat", "微信公众号", or "图文/文章".
description: Posts content to WeChat Official Account (微信公众号) via API or Chrome CDP. Supports article posting (文章) with HTML, markdown, or plain text input, and image-text posting (图文) with multiple images. Use when user mentions "发布公众号", "post to wechat", "微信公众号", or "图文/文章".
---
# Post to WeChat Official Account
## Language
**Match user's language**: Respond in the same language the user uses. If user writes in Chinese, respond in Chinese. If user writes in English, respond in English.
## Script Directory
**Agent Execution**: Determine this SKILL.md directory as `SKILL_DIR`, then use `${SKILL_DIR}/scripts/<name>.ts`.
@@ -12,8 +16,8 @@ description: Posts content to WeChat Official Account (微信公众号) via Chro
| Script | Purpose |
|--------|---------|
| `scripts/wechat-browser.ts` | Image-text posts (图文) |
| `scripts/wechat-article.ts` | Article posting (文章) |
| `scripts/md-to-wechat.ts` | Markdown → WeChat HTML |
| `scripts/wechat-article.ts` | Article posting via browser (文章) |
| `scripts/wechat-api.ts` | Article posting via API (文章) |
## Preferences (EXTEND.md)
@@ -43,38 +47,229 @@ test -f "$HOME/.baoyu-skills/baoyu-post-to-wechat/EXTEND.md" && echo "user"
│ Not found │ Use defaults │
└───────────┴───────────────────────────────────────────────────────────────────────────┘
**EXTEND.md Supports**: Default theme | Auto-submit preference | Chrome profile path
**EXTEND.md Supports**: Default theme | Default publishing method (api/browser) | Default author | Chrome profile path
## Usage
## Image-Text Posting (图文)
### Image-Text (图文)
For short posts with multiple images (up to 9):
```bash
npx -y bun ${SKILL_DIR}/scripts/wechat-browser.ts --markdown article.md --images ./images/
npx -y bun ${SKILL_DIR}/scripts/wechat-browser.ts --title "标题" --content "内容" --image img.png --submit
```
### Article (文章)
See [references/image-text-posting.md](references/image-text-posting.md) for details.
Before posting, ask user to choose a theme using AskUserQuestion:
## Article Posting Workflow (文章)
Copy this checklist and check off items as you complete them:
```
Publishing Progress:
- [ ] Step 0: Load preferences (EXTEND.md)
- [ ] Step 1: Determine input type
- [ ] Step 2: Check markdown-to-html skill
- [ ] Step 3: Convert to HTML
- [ ] Step 4: Validate metadata (title, summary)
- [ ] Step 5: Select method and configure credentials
- [ ] Step 6: Publish to WeChat
- [ ] Step 7: Report completion
```
### Step 0: Load Preferences
Check and load EXTEND.md settings (see Preferences section above).
### Step 1: Determine Input Type
| Input Type | Detection | Action |
|------------|-----------|--------|
| HTML file | Path ends with `.html`, file exists | Skip to Step 4 |
| Markdown file | Path ends with `.md`, file exists | Continue to Step 2 |
| Plain text | Not a file path, or file doesn't exist | Save to markdown, then Step 2 |
**Plain Text Handling**:
1. Generate slug from content (first 2-4 meaningful words, kebab-case)
2. Create directory and save file:
```bash
mkdir -p "$(pwd)/post-to-wechat/$(date +%Y-%m-%d)"
# Save content to: post-to-wechat/yyyy-MM-dd/[slug].md
```
3. Continue processing as markdown file
**Slug Examples**:
- "Understanding AI Models" → `understanding-ai-models`
- "人工智能的未来" → `ai-future` (translate to English for slug)
### Step 2: Check Markdown-to-HTML Skill
**Skip if**: Input is `.html` file
**Skill Discovery**:
```bash
# Check if baoyu-markdown-to-html exists
test -f skills/baoyu-markdown-to-html/SKILL.md && echo "found"
```
| Result | Action |
|--------|--------|
| Found | Read its SKILL.md, continue to Step 3 |
| Multiple skills | AskUserQuestion to choose |
| Not found | Show installation suggestion |
**When Not Found**:
```
No markdown-to-html skill found.
Suggested installation:
https://github.com/JimLiu/baoyu-skills/blob/main/skills/baoyu-markdown-to-html/SKILL.md
Options:
A) Cancel - install the skill first
B) Continue - provide HTML file manually
```
### Step 3: Convert Markdown to HTML
**Skip if**: Input is `.html` file
1. **Ask theme preference** (unless specified in EXTEND.md or CLI):
| Theme | Description |
|-------|-------------|
| `default` | 经典主题 - 传统排版,标题居中带底边,二级标题白字彩底 |
| `grace` | 优雅主题 - 文字阴影,圆角卡片,精致引用块 (by @brzhang) |
| `simple` | 简洁主题 - 现代极简风,不对称圆角,清爽留白 (by @okooo5km) |
| `grace` | 优雅主题 - 文字阴影,圆角卡片,精致引用块 |
| `simple` | 简洁主题 - 现代极简风,不对称圆角,清爽留白 |
Default: `default`. If user has already specified a theme, skip the question.
2. **Execute conversion** (using the discovered skill):
**Workflow**:
1. Generate HTML preview and print the full `htmlPath` from JSON output so user can click to preview:
```bash
npx -y bun ${SKILL_DIR}/scripts/md-to-wechat.ts article.md --theme <chosen-theme>
npx -y bun ${MD_TO_HTML_SKILL_DIR}/scripts/main.ts <markdown_file> --theme <theme>
```
2. Post to WeChat:
3. **Parse JSON output** to get: `htmlPath`, `title`, `author`, `summary`, `contentImages`
### Step 4: Validate Metadata
Check extracted metadata from Step 3 (or HTML meta tags if direct HTML input).
| Field | If Missing |
|-------|------------|
| Title | Prompt: "Enter title, or press Enter to auto-generate from content" |
| Summary | Prompt: "Enter summary, or press Enter to auto-generate (recommended for SEO)" |
**Auto-Generation Logic**:
- **Title**: First H1/H2 heading, or first sentence
- **Summary**: First paragraph, truncated to 120 characters
### Step 5: Select Publishing Method and Configure
**Ask publishing method** (unless specified in EXTEND.md or CLI):
| Method | Speed | Requirements |
|--------|-------|--------------|
| `api` (Recommended) | Fast | API credentials |
| `browser` | Slow | Chrome, login session |
**If API Selected - Check Credentials**:
```bash
npx -y bun ${SKILL_DIR}/scripts/wechat-article.ts --markdown article.md --theme <chosen-theme>
# Check project-level
test -f .baoyu-skills/.env && grep -q "WECHAT_APP_ID" .baoyu-skills/.env && echo "project"
# Check user-level
test -f "$HOME/.baoyu-skills/.env" && grep -q "WECHAT_APP_ID" "$HOME/.baoyu-skills/.env" && echo "user"
```
**If Credentials Missing - Guide Setup**:
```
WeChat API credentials not found.
To obtain credentials:
1. Visit https://mp.weixin.qq.com
2. Go to: 开发 → 基本配置
3. Copy AppID and AppSecret
Where to save?
A) Project-level: .baoyu-skills/.env (this project only)
B) User-level: ~/.baoyu-skills/.env (all projects)
```
After location choice, prompt for values and write to `.env`:
```
WECHAT_APP_ID=<user_input>
WECHAT_APP_SECRET=<user_input>
```
### Step 6: Publish to WeChat
**API method**:
```bash
npx -y bun ${SKILL_DIR}/scripts/wechat-api.ts <html_file> [--title <title>] [--summary <summary>]
```
**Browser method**:
```bash
npx -y bun ${SKILL_DIR}/scripts/wechat-article.ts --html <html_file>
```
### Step 7: Completion Report
**For API method**, include draft management link:
```
WeChat Publishing Complete!
Input: [type] - [path]
Method: API
Theme: [theme name]
Article:
• Title: [title]
• Summary: [summary]
• Images: [N] inline images
Result:
✓ Draft saved to WeChat Official Account
• media_id: [media_id]
Next Steps:
→ Manage drafts: https://mp.weixin.qq.com (登录后进入「内容管理」→「草稿箱」)
Files created:
[• post-to-wechat/yyyy-MM-dd/slug.md (if plain text)]
[• slug.html (converted)]
```
**For Browser method**:
```
WeChat Publishing Complete!
Input: [type] - [path]
Method: Browser
Theme: [theme name]
Article:
• Title: [title]
• Summary: [summary]
• Images: [N] inline images
Result:
✓ Draft saved to WeChat Official Account
Files created:
[• post-to-wechat/yyyy-MM-dd/slug.md (if plain text)]
[• slug.html (converted)]
```
## Detailed References
@@ -86,24 +281,47 @@ npx -y bun ${SKILL_DIR}/scripts/wechat-article.ts --markdown article.md --theme
## Feature Comparison
| Feature | Image-Text | Article |
|---------|------------|---------|
| Multiple images | ✓ (up to 9) | ✓ (inline) |
| Markdown support | Title/content extraction | Full formatting |
| Auto compression | ✓ (title: 20, content: 1000 chars) | ✗ |
| Themes | ✗ | ✓ (default, grace, simple) |
| Feature | Image-Text | Article (API) | Article (Browser) |
|---------|------------|---------------|-------------------|
| Plain text input | ✗ | ✓ | ✓ |
| HTML input | ✗ | ✓ | ✓ |
| Markdown input | Title/content | ✓ (via skill) | ✓ (via skill) |
| Multiple images | ✓ (up to 9) | ✓ (inline) | ✓ (inline) |
| Themes | ✗ | ✓ | ✓ |
| Auto-generate metadata | ✗ | ✓ | ✓ |
| Requires Chrome | ✓ | ✗ | ✓ |
| Requires API credentials | ✗ | ✓ | ✗ |
| Speed | Medium | Fast | Slow |
## Prerequisites
**For API method**:
- WeChat Official Account API credentials
- Guided setup in Step 5, or manually set in `.baoyu-skills/.env`
**For Browser method**:
- Google Chrome
- First run: log in to WeChat Official Account (session preserved)
**For Markdown conversion**:
- A markdown-to-html skill (e.g., `baoyu-markdown-to-html`)
- If not installed, the workflow will suggest installation
**Config File Locations** (priority order):
1. Environment variables
2. `<cwd>/.baoyu-skills/.env`
3. `~/.baoyu-skills/.env`
## Troubleshooting
| Issue | Solution |
|-------|----------|
| Not logged in | First run opens browser - scan QR to log in |
| No markdown-to-html skill | Install `baoyu-markdown-to-html` from suggested URL |
| Missing API credentials | Follow guided setup in Step 5 |
| Access token error | Check if API credentials are valid and not expired |
| Not logged in (browser) | First run opens browser - scan QR to log in |
| Chrome not found | Set `WECHAT_BROWSER_CHROME_PATH` env var |
| Title/summary missing | Use auto-generation or provide manually |
| Paste fails | Check system clipboard permissions |
## Extension Support
@@ -171,6 +171,37 @@ export interface ChromeSession {
targetId: string;
}
export async function tryConnectExisting(port: number): Promise<CdpConnection | null> {
try {
const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(`http://127.0.0.1:${port}/json/version`);
if (version.webSocketDebuggerUrl) {
const cdp = await CdpConnection.connect(version.webSocketDebuggerUrl, 5_000);
return cdp;
}
} catch {}
return null;
}
export async function findExistingChromeDebugPort(): Promise<number | null> {
if (process.platform !== 'darwin' && process.platform !== 'linux') return null;
try {
const { execSync } = await import('node:child_process');
const cmd = process.platform === 'darwin'
? `lsof -nP -iTCP -sTCP:LISTEN 2>/dev/null | grep -i 'google\\|chrome' | awk '{print $9}' | sed 's/.*://'`
: `ss -tlnp 2>/dev/null | grep -i chrome | awk '{print $4}' | sed 's/.*://'`;
const output = execSync(cmd, { encoding: 'utf-8', timeout: 5_000 }).trim();
if (!output) return null;
const ports = output.split('\n').map(p => parseInt(p, 10)).filter(p => !isNaN(p) && p > 0);
for (const port of ports) {
try {
const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(`http://127.0.0.1:${port}/json/version`);
if (version.webSocketDebuggerUrl) return port;
} catch {}
}
} catch {}
return null;
}
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.');
@@ -120,16 +120,28 @@ export async function convertMarkdown(markdownPath: string, options?: { title?:
const { frontmatter, body } = parseFrontmatter(content);
let title = options?.title ?? frontmatter.title ?? '';
let bodyWithoutTitle = body;
if (!title) {
const h1Match = body.match(/^#\s+(.+)$/m);
if (h1Match) title = h1Match[1]!;
const lines = body.split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
const headingMatch = trimmed.match(/^#{1,2}\s+(.+)$/);
if (headingMatch) {
title = headingMatch[1]!;
bodyWithoutTitle = body.replace(/^#{1,2}\s+.+\r?\n?/, '');
}
break;
}
} else {
bodyWithoutTitle = body.replace(/^#{1,2}\s+.+\r?\n?/, '');
}
if (!title) title = path.basename(markdownPath, path.extname(markdownPath));
const author = frontmatter.author || '';
let summary = frontmatter.description || frontmatter.summary || '';
if (!summary) {
const lines = body.split('\n');
const lines = bodyWithoutTitle.split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
@@ -155,7 +167,7 @@ export async function convertMarkdown(markdownPath: string, options?: { title?:
const images: Array<{ src: string; placeholder: string }> = [];
let imageCounter = 0;
const modifiedBody = body.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (match, alt, src) => {
const modifiedBody = bodyWithoutTitle.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (match, alt, src) => {
const placeholder = `WECHATIMGPH_${++imageCounter}`;
images.push({ src, placeholder });
return placeholder;
+153 -13
View File
@@ -24,11 +24,17 @@ import {
highlightAndFormatCode,
} from "./utils/languages.js";
type ThemeName = "default" | "grace" | "simple";
type ThemeName = string;
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 EXTERNAL_THEME_CONFIG_PATH =
process.env.MD_THEME_CONFIG_PATH
|| "/Users/jimliu/GitHub/md/packages/shared/src/configs/theme.ts";
const EXTERNAL_THEME_DIR =
process.env.MD_THEME_DIR
|| path.resolve(path.dirname(EXTERNAL_THEME_CONFIG_PATH), "theme-css");
const FALLBACK_THEMES: ThemeName[] = ["default", "grace", "simple"];
const DEFAULT_STYLE = {
primaryColor: "#0F4C81",
@@ -45,6 +51,57 @@ Object.entries(COMMON_LANGUAGES).forEach(([name, lang]) => {
export { hljs };
function stripOutputScope(cssContent: string): string {
let css = cssContent;
css = css.replace(/#output\s*\{/g, "body {");
css = css.replace(/#output\s+/g, "");
css = css.replace(/^#output\s*/gm, "");
return css;
}
function discoverThemesFromDir(dir: string): string[] {
if (!fs.existsSync(dir)) {
return [];
}
return fs
.readdirSync(dir)
.filter((name) => name.endsWith(".css"))
.map((name) => name.replace(/\.css$/i, ""))
.filter((name) => name.toLowerCase() !== "base");
}
function readThemeNamesFromConfig(configPath: string): string[] {
if (!fs.existsSync(configPath)) {
return [];
}
const content = fs.readFileSync(configPath, "utf-8");
const match = content.match(/themeOptionsMap\s*=\s*\{([\s\S]*?)\n\}/);
if (!match) {
return [];
}
return Array.from(match[1].matchAll(/^\s*([a-zA-Z0-9_-]+)\s*:/gm)).map(
(item) => item[1]!
);
}
function resolveThemeNames(): ThemeName[] {
const localThemes = discoverThemesFromDir(THEME_DIR);
const externalThemes = discoverThemesFromDir(EXTERNAL_THEME_DIR);
const configThemes = readThemeNamesFromConfig(EXTERNAL_THEME_CONFIG_PATH);
const combined = new Set<ThemeName>([
...localThemes,
...externalThemes,
...configThemes,
]);
const resolved = Array.from(combined).filter((name) =>
fs.existsSync(path.join(THEME_DIR, `${name}.css`))
|| fs.existsSync(path.join(EXTERNAL_THEME_DIR, `${name}.css`))
);
return resolved.length ? resolved : FALLBACK_THEMES;
}
const THEME_NAMES: ThemeName[] = resolveThemeNames();
marked.setOptions({
breaks: true,
});
@@ -317,7 +374,7 @@ export function initRenderer(opts: IOpts = {}): RendererAPI {
listCounters[listCounters.length - 1] = idx + 1;
const prefix = ordered ? `${idx}. ` : "• ";
const prefix = ordered ? "" : "• ";
let content: string;
try {
@@ -542,15 +599,31 @@ function loadThemeCss(theme: ThemeName): {
baseCss: string;
themeCss: string;
} {
const basePath = path.join(THEME_DIR, "base.css");
const themePath = path.join(THEME_DIR, `${theme}.css`);
const basePathCandidates = [
path.join(THEME_DIR, "base.css"),
path.join(EXTERNAL_THEME_DIR, "base.css"),
];
const themePathCandidates = [
path.join(THEME_DIR, `${theme}.css`),
path.join(EXTERNAL_THEME_DIR, `${theme}.css`),
];
const basePath = basePathCandidates.find((candidate) =>
fs.existsSync(candidate)
);
const themePath = themePathCandidates.find((candidate) =>
fs.existsSync(candidate)
);
if (!fs.existsSync(basePath)) {
throw new Error(`Missing base CSS: ${basePath}`);
if (!basePath) {
throw new Error(
`Missing base CSS. Checked: ${basePathCandidates.join(", ")}`
);
}
if (!fs.existsSync(themePath)) {
throw new Error(`Missing theme CSS: ${themePath}`);
if (!themePath) {
throw new Error(
`Missing theme CSS for "${theme}". Checked: ${themePathCandidates.join(", ")}`
);
}
return {
@@ -584,6 +657,10 @@ body {
return [variables, baseCss, themeCss].join("\n\n");
}
function normalizeThemeCss(css: string): string {
return stripOutputScope(css);
}
function buildHtmlDocument(title: string, css: string, html: string): string {
return [
"<!doctype html>",
@@ -603,7 +680,65 @@ function buildHtmlDocument(title: string, css: string, html: string): string {
].join("\n");
}
function main(): void {
async function inlineCss(html: string): Promise<string> {
try {
const { default: juice } = await import("juice");
return juice(html, {
inlinePseudoElements: true,
preserveImportant: true,
resolveCSSVariables: false,
});
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
throw new Error(
`Missing dependency "juice" for CSS inlining. Install it first (e.g. "bun add juice" or "npm add juice"). Original error: ${detail}`
);
}
}
function normalizeCssText(cssText: string): string {
return cssText
.replace(/var\(--md-primary-color\)/g, DEFAULT_STYLE.primaryColor)
.replace(/var\(--md-font-family\)/g, DEFAULT_STYLE.fontFamily)
.replace(/var\(--md-font-size\)/g, DEFAULT_STYLE.fontSize)
.replace(/var\(--blockquote-background\)/g, DEFAULT_STYLE.blockquoteBackground)
.replace(/hsl\(var\(--foreground\)\)/g, "#3f3f3f")
.replace(/--md-primary-color:\s*[^;"']+;?/g, "")
.replace(/--md-font-family:\s*[^;"']+;?/g, "")
.replace(/--md-font-size:\s*[^;"']+;?/g, "")
.replace(/--blockquote-background:\s*[^;"']+;?/g, "")
.replace(/--foreground:\s*[^;"']+;?/g, "");
}
function normalizeInlineCss(html: string): string {
let output = html;
output = output.replace(
/<style([^>]*)>([\s\S]*?)<\/style>/gi,
(_match, attrs: string, cssText: string) =>
`<style${attrs}>${normalizeCssText(cssText)}</style>`
);
output = output.replace(
/style="([^"]*)"/gi,
(_match, cssText: string) => `style="${normalizeCssText(cssText)}"`
);
output = output.replace(
/style='([^']*)'/gi,
(_match, cssText: string) => `style='${normalizeCssText(cssText)}'`
);
return output;
}
function modifyHtmlStructure(htmlString: string): string {
let output = htmlString;
const pattern =
/<li([^>]*)>([\s\S]*?)(<ul[\s\S]*?<\/ul>|<ol[\s\S]*?<\/ol>)<\/li>/i;
while (pattern.test(output)) {
output = output.replace(pattern, "<li$1>$2</li>$3");
}
return output;
}
async function main(): Promise<void> {
const options = parseArgs(process.argv.slice(2));
if (!options) {
printUsage();
@@ -624,7 +759,7 @@ function main(): void {
);
const { baseCss, themeCss } = loadThemeCss(options.theme);
const css = buildCss(baseCss, themeCss);
const css = normalizeThemeCss(buildCss(baseCss, themeCss));
const markdown = fs.readFileSync(inputPath, "utf-8");
const renderer = initRenderer({});
@@ -636,6 +771,8 @@ function main(): void {
const title = path.basename(outputPath, ".html");
const html = buildHtmlDocument(title, css, content);
const inlinedHtml = normalizeInlineCss(await inlineCss(html));
const finalHtml = modifyHtmlStructure(inlinedHtml);
let backupPath = "";
if (fs.existsSync(outputPath)) {
@@ -643,7 +780,7 @@ function main(): void {
fs.renameSync(outputPath, backupPath);
}
fs.writeFileSync(outputPath, html, "utf-8");
fs.writeFileSync(outputPath, finalHtml, "utf-8");
if (backupPath) {
console.log(`Backup created: ${backupPath}`);
@@ -651,4 +788,7 @@ function main(): void {
console.log(`HTML written: ${outputPath}`);
}
main();
main().catch((error) => {
console.error(error);
process.exit(1);
});
@@ -0,0 +1,604 @@
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
interface WechatConfig {
appId: string;
appSecret: string;
}
interface AccessTokenResponse {
access_token?: string;
errcode?: number;
errmsg?: string;
}
interface UploadResponse {
media_id: string;
url: string;
errcode?: number;
errmsg?: string;
}
interface PublishResponse {
media_id?: string;
errcode?: number;
errmsg?: string;
}
type ArticleType = "news" | "newspic";
interface ArticleOptions {
title: string;
author?: string;
digest?: string;
content: string;
thumbMediaId: string;
articleType: ArticleType;
imageMediaIds?: string[];
}
const TOKEN_URL = "https://api.weixin.qq.com/cgi-bin/token";
const UPLOAD_URL = "https://api.weixin.qq.com/cgi-bin/material/add_material";
const DRAFT_URL = "https://api.weixin.qq.com/cgi-bin/draft/add";
function loadEnvFile(envPath: string): Record<string, string> {
const env: Record<string, string> = {};
if (!fs.existsSync(envPath)) return env;
const content = fs.readFileSync(envPath, "utf-8");
for (const line of content.split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const eqIdx = trimmed.indexOf("=");
if (eqIdx > 0) {
const key = trimmed.slice(0, eqIdx).trim();
let value = trimmed.slice(eqIdx + 1).trim();
if ((value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
env[key] = value;
}
}
return env;
}
function loadConfig(): WechatConfig {
const cwdEnvPath = path.join(process.cwd(), ".baoyu-skills", ".env");
const homeEnvPath = path.join(os.homedir(), ".baoyu-skills", ".env");
const cwdEnv = loadEnvFile(cwdEnvPath);
const homeEnv = loadEnvFile(homeEnvPath);
const appId = process.env.WECHAT_APP_ID || cwdEnv.WECHAT_APP_ID || homeEnv.WECHAT_APP_ID;
const appSecret = process.env.WECHAT_APP_SECRET || cwdEnv.WECHAT_APP_SECRET || homeEnv.WECHAT_APP_SECRET;
if (!appId || !appSecret) {
throw new Error(
"Missing WECHAT_APP_ID or WECHAT_APP_SECRET.\n" +
"Set via environment variables or in .baoyu-skills/.env file."
);
}
return { appId, appSecret };
}
async function fetchAccessToken(appId: string, appSecret: string): Promise<string> {
const url = `${TOKEN_URL}?grant_type=client_credential&appid=${appId}&secret=${appSecret}`;
const res = await fetch(url);
if (!res.ok) {
throw new Error(`Failed to fetch access token: ${res.status}`);
}
const data = await res.json() as AccessTokenResponse;
if (data.errcode) {
throw new Error(`Access token error ${data.errcode}: ${data.errmsg}`);
}
if (!data.access_token) {
throw new Error("No access_token in response");
}
return data.access_token;
}
async function uploadImage(
imagePath: string,
accessToken: string,
baseDir?: string
): Promise<UploadResponse> {
let fileBuffer: Buffer;
let filename: string;
let contentType: string;
if (imagePath.startsWith("http://") || imagePath.startsWith("https://")) {
const response = await fetch(imagePath);
if (!response.ok) {
throw new Error(`Failed to download image: ${imagePath}`);
}
const buffer = await response.arrayBuffer();
if (buffer.byteLength === 0) {
throw new Error(`Remote image is empty: ${imagePath}`);
}
fileBuffer = Buffer.from(buffer);
const urlPath = imagePath.split("?")[0];
filename = path.basename(urlPath) || "image.jpg";
contentType = response.headers.get("content-type") || "image/jpeg";
} else {
const resolvedPath = path.isAbsolute(imagePath)
? imagePath
: path.resolve(baseDir || process.cwd(), imagePath);
if (!fs.existsSync(resolvedPath)) {
throw new Error(`Image not found: ${resolvedPath}`);
}
const stats = fs.statSync(resolvedPath);
if (stats.size === 0) {
throw new Error(`Local image is empty: ${resolvedPath}`);
}
fileBuffer = fs.readFileSync(resolvedPath);
filename = path.basename(resolvedPath);
const ext = path.extname(filename).toLowerCase();
const mimeTypes: Record<string, string> = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".webp": "image/webp",
};
contentType = mimeTypes[ext] || "image/jpeg";
}
const boundary = `----WebKitFormBoundary${Date.now().toString(16)}`;
const header = [
`--${boundary}`,
`Content-Disposition: form-data; name="media"; filename="${filename}"`,
`Content-Type: ${contentType}`,
"",
"",
].join("\r\n");
const footer = `\r\n--${boundary}--\r\n`;
const headerBuffer = Buffer.from(header, "utf-8");
const footerBuffer = Buffer.from(footer, "utf-8");
const body = Buffer.concat([headerBuffer, fileBuffer, footerBuffer]);
const url = `${UPLOAD_URL}?access_token=${accessToken}&type=image`;
const res = await fetch(url, {
method: "POST",
headers: {
"Content-Type": `multipart/form-data; boundary=${boundary}`,
},
body,
});
const data = await res.json() as UploadResponse;
if (data.errcode && data.errcode !== 0) {
throw new Error(`Upload failed ${data.errcode}: ${data.errmsg}`);
}
if (data.url?.startsWith("http://")) {
data.url = data.url.replace(/^http:\/\//i, "https://");
}
return data;
}
async function uploadImagesInHtml(
html: string,
accessToken: string,
baseDir: string
): Promise<{ html: string; firstMediaId: string; allMediaIds: string[] }> {
const imgRegex = /<img[^>]*\ssrc=["']([^"']+)["'][^>]*>/gi;
const matches = [...html.matchAll(imgRegex)];
if (matches.length === 0) {
return { html, firstMediaId: "", allMediaIds: [] };
}
let firstMediaId = "";
let updatedHtml = html;
const allMediaIds: string[] = [];
for (const match of matches) {
const [fullTag, src] = match;
if (!src) continue;
if (src.startsWith("https://mmbiz.qpic.cn")) {
if (!firstMediaId) {
firstMediaId = src;
}
continue;
}
const localPathMatch = fullTag.match(/data-local-path=["']([^"']+)["']/);
const imagePath = localPathMatch ? localPathMatch[1]! : src;
console.error(`[wechat-api] Uploading image: ${imagePath}`);
try {
const resp = await uploadImage(imagePath, accessToken, baseDir);
const newTag = fullTag
.replace(/\ssrc=["'][^"']+["']/, ` src="${resp.url}"`)
.replace(/\sdata-local-path=["'][^"']+["']/, "");
updatedHtml = updatedHtml.replace(fullTag, newTag);
allMediaIds.push(resp.media_id);
if (!firstMediaId) {
firstMediaId = resp.media_id;
}
} catch (err) {
console.error(`[wechat-api] Failed to upload ${imagePath}:`, err);
}
}
return { html: updatedHtml, firstMediaId, allMediaIds };
}
async function publishToDraft(
options: ArticleOptions,
accessToken: string
): Promise<PublishResponse> {
const url = `${DRAFT_URL}?access_token=${accessToken}`;
let article: Record<string, unknown>;
if (options.articleType === "newspic") {
if (!options.imageMediaIds || options.imageMediaIds.length === 0) {
throw new Error("newspic requires at least one image");
}
article = {
article_type: "newspic",
title: options.title,
content: options.content,
need_open_comment: 1,
only_fans_can_comment: 0,
image_info: {
image_list: options.imageMediaIds.map(id => ({ image_media_id: id })),
},
};
if (options.author) article.author = options.author;
} else {
article = {
article_type: "news",
title: options.title,
content: options.content,
thumb_media_id: options.thumbMediaId,
need_open_comment: 1,
only_fans_can_comment: 0,
};
if (options.author) article.author = options.author;
if (options.digest) article.digest = options.digest;
}
const res = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ articles: [article] }),
});
const data = await res.json() as PublishResponse;
if (data.errcode && data.errcode !== 0) {
throw new Error(`Publish failed ${data.errcode}: ${data.errmsg}`);
}
return data;
}
function parseFrontmatter(content: string): { frontmatter: Record<string, string>; body: string } {
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
if (!match) return { frontmatter: {}, body: content };
const frontmatter: Record<string, string> = {};
const lines = match[1]!.split("\n");
for (const line of lines) {
const colonIdx = line.indexOf(":");
if (colonIdx > 0) {
const key = line.slice(0, colonIdx).trim();
let value = line.slice(colonIdx + 1).trim();
if ((value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
frontmatter[key] = value;
}
}
return { frontmatter, body: match[2]! };
}
function renderMarkdownToHtml(markdownPath: string, theme: string = "default"): string {
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const renderScript = path.join(__dirname, "md", "render.ts");
const baseDir = path.dirname(markdownPath);
console.error(`[wechat-api] Rendering markdown with theme: ${theme}`);
const result = spawnSync("npx", ["-y", "bun", renderScript, markdownPath, "--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 = markdownPath.replace(/\.md$/i, ".html");
if (!fs.existsSync(htmlPath)) {
throw new Error(`HTML file not generated: ${htmlPath}`);
}
return htmlPath;
}
function extractHtmlContent(htmlPath: string): string {
const html = fs.readFileSync(htmlPath, "utf-8");
const match = html.match(/<div id="output">([\s\S]*?)<\/div>\s*<\/body>/);
if (match) {
return match[1]!.trim();
}
const bodyMatch = html.match(/<body[^>]*>([\s\S]*?)<\/body>/i);
return bodyMatch ? bodyMatch[1]!.trim() : html;
}
function printUsage(): never {
console.log(`Publish article to WeChat Official Account draft using API
Usage:
npx -y bun wechat-api.ts <file> [options]
Arguments:
file Markdown (.md) or HTML (.html) file
Options:
--type <type> Article type: news (文章, default) or newspic (图文)
--title <title> Override title
--author <name> Author name (max 16 chars)
--summary <text> Article summary/digest (max 128 chars)
--theme <name> Theme name for markdown (default, grace, simple). Default: default
--cover <path> Cover image path (local or URL)
--dry-run Parse and render only, don't publish
--help Show this help
Frontmatter Fields (markdown):
title Article title
author Author name
digest/summary Article summary
featureImage/coverImage/cover/image Cover image path
Comments:
Comments are enabled by default, open to all users.
Environment Variables:
WECHAT_APP_ID WeChat App ID
WECHAT_APP_SECRET WeChat App Secret
Config File Locations (in priority order):
1. Environment variables
2. <cwd>/.baoyu-skills/.env
3. ~/.baoyu-skills/.env
Example:
npx -y bun wechat-api.ts article.md
npx -y bun wechat-api.ts article.md --theme grace --cover cover.png
npx -y bun wechat-api.ts article.md --author "Author Name" --summary "Brief intro"
npx -y bun wechat-api.ts article.html --title "My Article"
npx -y bun wechat-api.ts images/ --type newspic --title "Photo Album"
npx -y bun wechat-api.ts article.md --dry-run
`);
process.exit(0);
}
interface CliArgs {
filePath: string;
isHtml: boolean;
articleType: ArticleType;
title?: string;
author?: string;
summary?: string;
theme: string;
cover?: string;
dryRun: boolean;
}
function parseArgs(argv: string[]): CliArgs {
if (argv.length === 0 || argv.includes("--help") || argv.includes("-h")) {
printUsage();
}
const args: CliArgs = {
filePath: "",
isHtml: false,
articleType: "news",
theme: "default",
dryRun: false,
};
for (let i = 0; i < argv.length; i++) {
const arg = argv[i]!;
if (arg === "--type" && argv[i + 1]) {
const t = argv[++i]!.toLowerCase();
if (t === "news" || t === "newspic") {
args.articleType = t;
}
} else if (arg === "--title" && argv[i + 1]) {
args.title = argv[++i];
} else if (arg === "--author" && argv[i + 1]) {
args.author = argv[++i];
} else if (arg === "--summary" && argv[i + 1]) {
args.summary = argv[++i];
} else if (arg === "--theme" && argv[i + 1]) {
args.theme = argv[++i]!;
} else if (arg === "--cover" && argv[i + 1]) {
args.cover = argv[++i];
} else if (arg === "--dry-run") {
args.dryRun = true;
} else if (arg.startsWith("--") && argv[i + 1] && !argv[i + 1]!.startsWith("-")) {
i++;
} else if (!arg.startsWith("-")) {
args.filePath = arg;
}
}
if (!args.filePath) {
console.error("Error: File path required");
process.exit(1);
}
args.isHtml = args.filePath.toLowerCase().endsWith(".html");
return args;
}
function extractHtmlTitle(html: string): string {
const titleMatch = html.match(/<title>([^<]+)<\/title>/i);
if (titleMatch) return titleMatch[1]!;
const h1Match = html.match(/<h1[^>]*>([^<]+)<\/h1>/i);
if (h1Match) return h1Match[1]!.replace(/<[^>]+>/g, "").trim();
return "";
}
async function main(): Promise<void> {
const args = parseArgs(process.argv.slice(2));
const filePath = path.resolve(args.filePath);
if (!fs.existsSync(filePath)) {
console.error(`Error: File not found: ${filePath}`);
process.exit(1);
}
const baseDir = path.dirname(filePath);
let title = args.title || "";
let author = args.author || "";
let digest = args.summary || "";
let htmlPath: string;
let htmlContent: string;
let frontmatter: Record<string, string> = {};
if (args.isHtml) {
htmlPath = filePath;
htmlContent = extractHtmlContent(htmlPath);
const mdPath = filePath.replace(/\.html$/i, ".md");
if (fs.existsSync(mdPath)) {
const mdContent = fs.readFileSync(mdPath, "utf-8");
const parsed = parseFrontmatter(mdContent);
frontmatter = parsed.frontmatter;
if (!title && frontmatter.title) title = frontmatter.title;
if (!author) author = frontmatter.author || "";
if (!digest) digest = frontmatter.digest || frontmatter.summary || frontmatter.description || "";
}
if (!title) {
title = extractHtmlTitle(fs.readFileSync(htmlPath, "utf-8"));
}
console.error(`[wechat-api] Using HTML file: ${htmlPath}`);
} else {
const content = fs.readFileSync(filePath, "utf-8");
const parsed = parseFrontmatter(content);
frontmatter = parsed.frontmatter;
const body = parsed.body;
title = title || frontmatter.title || "";
if (!title) {
const h1Match = body.match(/^#\s+(.+)$/m);
if (h1Match) title = h1Match[1]!;
}
if (!author) author = frontmatter.author || "";
if (!digest) digest = frontmatter.digest || frontmatter.summary || frontmatter.description || "";
console.error(`[wechat-api] Theme: ${args.theme}`);
htmlPath = renderMarkdownToHtml(filePath, args.theme);
console.error(`[wechat-api] HTML generated: ${htmlPath}`);
htmlContent = extractHtmlContent(htmlPath);
}
if (!title) {
console.error("Error: No title found. Provide via --title, frontmatter, or <title> tag.");
process.exit(1);
}
console.error(`[wechat-api] Title: ${title}`);
if (author) console.error(`[wechat-api] Author: ${author}`);
if (digest) console.error(`[wechat-api] Digest: ${digest.slice(0, 50)}...`);
console.error(`[wechat-api] Type: ${args.articleType}`);
if (args.dryRun) {
console.log(JSON.stringify({
articleType: args.articleType,
title,
author: author || undefined,
digest: digest || undefined,
htmlPath,
contentLength: htmlContent.length,
}, null, 2));
return;
}
const config = loadConfig();
console.error("[wechat-api] Fetching access token...");
const accessToken = await fetchAccessToken(config.appId, config.appSecret);
console.error("[wechat-api] Uploading images...");
const { html: processedHtml, firstMediaId, allMediaIds } = await uploadImagesInHtml(
htmlContent,
accessToken,
baseDir
);
htmlContent = processedHtml;
let thumbMediaId = "";
const coverPath = args.cover ||
frontmatter.featureImage ||
frontmatter.coverImage ||
frontmatter.cover ||
frontmatter.image;
if (coverPath) {
console.error(`[wechat-api] Uploading cover: ${coverPath}`);
const coverResp = await uploadImage(coverPath, accessToken, baseDir);
thumbMediaId = coverResp.media_id;
} else if (firstMediaId) {
if (firstMediaId.startsWith("https://")) {
console.error(`[wechat-api] Uploading first image as cover: ${firstMediaId}`);
const coverResp = await uploadImage(firstMediaId, accessToken, baseDir);
thumbMediaId = coverResp.media_id;
} else {
thumbMediaId = firstMediaId;
}
}
if (args.articleType === "news" && !thumbMediaId) {
console.error("Error: No cover image. Provide via --cover, frontmatter.featureImage, or include an image in content.");
process.exit(1);
}
if (args.articleType === "newspic" && allMediaIds.length === 0) {
console.error("Error: newspic requires at least one image in content.");
process.exit(1);
}
console.error("[wechat-api] Publishing to draft...");
const result = await publishToDraft({
title,
author: author || undefined,
digest: digest || undefined,
content: htmlContent,
thumbMediaId,
articleType: args.articleType,
imageMediaIds: args.articleType === "newspic" ? allMediaIds : undefined,
}, accessToken);
console.log(JSON.stringify({
success: true,
media_id: result.media_id,
title,
articleType: args.articleType,
}, null, 2));
console.error(`[wechat-api] Published successfully! media_id: ${result.media_id}`);
}
await main().catch((err) => {
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
});
@@ -3,7 +3,7 @@ import path from 'node:path';
import { spawnSync } from 'node:child_process';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
import { launchChrome, getPageSession, waitForNewTab, clickElement, typeText, evaluate, sleep, type ChromeSession, type CdpConnection } from './cdp.ts';
import { launchChrome, tryConnectExisting, findExistingChromeDebugPort, getPageSession, waitForNewTab, clickElement, typeText, evaluate, sleep, type ChromeSession, type CdpConnection } from './cdp.ts';
const WECHAT_URL = 'https://mp.weixin.qq.com/';
@@ -25,6 +25,7 @@ interface ArticleOptions {
contentImages?: ImageInfo[];
submit?: boolean;
profileDir?: string;
cdpPort?: number;
}
async function waitForLogin(session: ChromeSession, timeoutMs = 120_000): Promise<boolean> {
@@ -37,6 +38,16 @@ async function waitForLogin(session: ChromeSession, timeoutMs = 120_000): Promis
return false;
}
async function waitForElement(session: ChromeSession, selector: string, timeoutMs = 10_000): Promise<boolean> {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const found = await evaluate<boolean>(session, `!!document.querySelector('${selector}')`);
if (found) return true;
await sleep(500);
}
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', {
@@ -104,7 +115,7 @@ async function sendPaste(cdp?: CdpConnection, sessionId?: string): Promise<void>
}
}
async function copyHtmlFromBrowser(cdp: CdpConnection, htmlFilePath: string): Promise<void> {
async function copyHtmlFromBrowser(cdp: CdpConnection, htmlFilePath: string, contentImages: ImageInfo[] = []): Promise<void> {
const absolutePath = path.isAbsolute(htmlFilePath) ? htmlFilePath : path.resolve(process.cwd(), htmlFilePath);
const fileUrl = `file://${absolutePath}`;
@@ -117,6 +128,28 @@ async function copyHtmlFromBrowser(cdp: CdpConnection, htmlFilePath: string): Pr
await cdp.send('Runtime.enable', {}, { sessionId });
await sleep(2000);
if (contentImages.length > 0) {
console.log('[wechat] Replacing img tags with placeholders for browser paste...');
const replacements = contentImages.map(img => ({ placeholder: img.placeholder, localPath: img.localPath }));
await cdp.send<{ result: { value: unknown } }>('Runtime.evaluate', {
expression: `
(function() {
const replacements = ${JSON.stringify(replacements)};
for (const r of replacements) {
const imgs = document.querySelectorAll('img[src="' + r.placeholder + '"], img[data-local-path="' + r.localPath + '"]');
for (const img of imgs) {
const text = document.createTextNode(r.placeholder);
img.parentNode.replaceChild(text, img);
}
}
return true;
})()
`,
returnByValue: true,
}, { sessionId });
await sleep(500);
}
console.log('[wechat] Selecting #output content...');
await cdp.send<{ result: { value: unknown } }>('Runtime.evaluate', {
expression: `
@@ -165,7 +198,7 @@ async function parseMarkdownWithPlaceholders(markdownPath: string, theme?: strin
return JSON.parse(output);
}
function parseHtmlMeta(htmlPath: string): { title: string; author: string; summary: string } {
function parseHtmlMeta(htmlPath: string): { title: string; author: string; summary: string; contentImages: ImageInfo[] } {
const content = fs.readFileSync(htmlPath, 'utf-8');
let title = '';
@@ -173,11 +206,13 @@ function parseHtmlMeta(htmlPath: string): { title: string; author: string; summa
if (titleMatch) title = titleMatch[1]!;
let author = '';
const authorMatch = content.match(/<meta\s+name=["']author["']\s+content=["']([^"']+)["']/i);
const authorMatch = content.match(/<meta\s+name=["']author["']\s+content=["']([^"']+)["']/i)
|| content.match(/<meta\s+content=["']([^"']+)["']\s+name=["']author["']/i);
if (authorMatch) author = authorMatch[1]!;
let summary = '';
const descMatch = content.match(/<meta\s+name=["']description["']\s+content=["']([^"']+)["']/i);
const descMatch = content.match(/<meta\s+name=["']description["']\s+content=["']([^"']+)["']/i)
|| content.match(/<meta\s+content=["']([^"']+)["']\s+name=["']description["']/i);
if (descMatch) summary = descMatch[1]!;
if (!summary) {
@@ -190,7 +225,45 @@ function parseHtmlMeta(htmlPath: string): { title: string; author: string; summa
}
}
return { title, author, summary };
const mdPath = htmlPath.replace(/\.html$/i, '.md');
if (fs.existsSync(mdPath)) {
const mdContent = fs.readFileSync(mdPath, 'utf-8');
const fmMatch = mdContent.match(/^---\r?\n([\s\S]*?)\r?\n---/);
if (fmMatch) {
const lines = fmMatch[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);
}
if (key === 'title' && !title) title = value;
if (key === 'author' && !author) author = value;
if ((key === 'description' || key === 'summary') && !summary) summary = value;
}
}
}
}
const contentImages: ImageInfo[] = [];
const imgRegex = /<img[^>]*\ssrc=["']([^"']+)["'][^>]*>/gi;
const matches = [...content.matchAll(imgRegex)];
for (const match of matches) {
const [fullTag, src] = match;
if (!src || src.startsWith('http')) continue;
const localPathMatch = fullTag.match(/data-local-path=["']([^"']+)["']/);
if (localPathMatch) {
contentImages.push({
placeholder: src,
localPath: localPathMatch[1]!,
originalPath: src,
});
}
}
return { title, author, summary, contentImages };
}
async function selectAndReplacePlaceholder(session: ChromeSession, placeholder: string): Promise<boolean> {
@@ -200,22 +273,31 @@ async function selectAndReplacePlaceholder(session: ChromeSession, placeholder:
const editor = document.querySelector('.ProseMirror');
if (!editor) return false;
const placeholder = ${JSON.stringify(placeholder)};
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' });
let searchStart = 0;
let idx;
// Search for exact match (not prefix of longer placeholder like XIMGPH_1 in XIMGPH_10)
while ((idx = text.indexOf(placeholder, searchStart)) !== -1) {
const afterIdx = idx + placeholder.length;
const charAfter = text[afterIdx];
// Exact match if next char is not a digit
if (charAfter === undefined || !/\\d/.test(charAfter)) {
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;
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;
}
searchStart = afterIdx;
}
}
return false;
@@ -233,8 +315,73 @@ async function pressDeleteKey(session: ChromeSession): Promise<void> {
await session.cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Backspace', code: 'Backspace', windowsVirtualKeyCode: 8 }, { sessionId: session.sessionId });
}
async function removeExtraEmptyLineAfterImage(session: ChromeSession): Promise<boolean> {
const removed = await evaluate<boolean>(session, `
(function() {
const editor = document.querySelector('.ProseMirror');
if (!editor) return false;
const sel = window.getSelection();
if (!sel || sel.rangeCount === 0) return false;
let node = sel.anchorNode;
if (!node) return false;
let element = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement;
if (!element || !editor.contains(element)) return false;
const isEmptyParagraph = (el) => {
if (!el || el.tagName !== 'P') return false;
const text = (el.textContent || '').trim();
if (text.length > 0) return false;
return el.querySelectorAll('img, figure, video, iframe').length === 0;
};
const hasImage = (el) => {
if (!el) return false;
return !!el.querySelector('img, figure img, picture img');
};
const placeCursorAfter = (el) => {
if (!el) return;
const range = document.createRange();
range.setStartAfter(el);
range.collapse(true);
sel.removeAllRanges();
sel.addRange(range);
};
// Case 1: caret is inside an empty paragraph right after an image block.
const emptyPara = element.closest('p');
if (emptyPara && editor.contains(emptyPara) && isEmptyParagraph(emptyPara)) {
const prev = emptyPara.previousElementSibling;
if (prev && hasImage(prev)) {
emptyPara.remove();
placeCursorAfter(prev);
return true;
}
}
// Case 2: caret is on the image block itself; remove the next empty paragraph.
const imageBlock = element.closest('figure, p');
if (imageBlock && editor.contains(imageBlock) && hasImage(imageBlock)) {
const next = imageBlock.nextElementSibling;
if (next && isEmptyParagraph(next)) {
next.remove();
placeCursorAfter(imageBlock);
return true;
}
}
return false;
})()
`);
if (removed) console.log('[wechat] Removed extra empty line after image.');
return removed;
}
export async function postArticle(options: ArticleOptions): Promise<void> {
const { title, content, htmlFile, markdownFile, theme, author, summary, images = [], submit = false, profileDir } = options;
const { title, content, htmlFile, markdownFile, theme, author, summary, images = [], submit = false, profileDir, cdpPort } = options;
let { contentImages = [] } = options;
let effectiveTitle = title || '';
let effectiveAuthor = author || '';
@@ -260,24 +407,79 @@ export async function postArticle(options: ArticleOptions): Promise<void> {
effectiveAuthor = effectiveAuthor || meta.author;
effectiveSummary = effectiveSummary || meta.summary;
effectiveHtmlFile = htmlFile;
if (meta.contentImages.length > 0) {
contentImages = meta.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`);
}
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);
let cdp: CdpConnection;
let chrome: ReturnType<typeof import('node:child_process').spawn> | null = null;
// Try connecting to existing Chrome: explicit port > auto-detect > launch new
const portToTry = cdpPort ?? await findExistingChromeDebugPort();
if (portToTry) {
const existing = await tryConnectExisting(portToTry);
if (existing) {
console.log(`[cdp] Connected to existing Chrome on port ${portToTry}`);
cdp = existing;
} else {
console.log(`[cdp] Port ${portToTry} not available, launching new Chrome...`);
const launched = await launchChrome(WECHAT_URL, profileDir);
cdp = launched.cdp;
chrome = launched.chrome;
}
} else {
const launched = await launchChrome(WECHAT_URL, profileDir);
cdp = launched.cdp;
chrome = launched.chrome;
}
try {
console.log('[wechat] Waiting for page load...');
await sleep(3000);
let session = await getPageSession(cdp, 'mp.weixin.qq.com');
let session: ChromeSession;
if (!chrome) {
// Reusing existing Chrome: find an already-logged-in tab (has token in URL)
const allTargets = await cdp.send<{ targetInfos: Array<{ targetId: string; url: string; type: string }> }>('Target.getTargets');
const loggedInTab = allTargets.targetInfos.find(t => t.type === 'page' && t.url.includes('mp.weixin.qq.com') && t.url.includes('token='));
const wechatTab = loggedInTab || allTargets.targetInfos.find(t => t.type === 'page' && t.url.includes('mp.weixin.qq.com'));
if (wechatTab) {
console.log(`[wechat] Reusing existing tab: ${wechatTab.url.substring(0, 80)}...`);
const { sessionId: reuseSid } = await cdp.send<{ sessionId: string }>('Target.attachToTarget', { targetId: wechatTab.targetId, flatten: true });
await cdp.send('Page.enable', {}, { sessionId: reuseSid });
await cdp.send('Runtime.enable', {}, { sessionId: reuseSid });
await cdp.send('DOM.enable', {}, { sessionId: reuseSid });
session = { cdp, sessionId: reuseSid, targetId: wechatTab.targetId };
// Navigate to home if not already there
const currentUrl = await evaluate<string>(session, 'window.location.href');
if (!currentUrl.includes('/cgi-bin/home')) {
console.log('[wechat] Navigating to home...');
await evaluate(session, `window.location.href = '${WECHAT_URL}cgi-bin/home?t=home/index'`);
await sleep(5000);
}
} else {
// No WeChat tab found, create one
console.log('[wechat] No WeChat tab found, opening...');
await cdp.send('Target.createTarget', { url: WECHAT_URL });
await sleep(5000);
session = await getPageSession(cdp, 'mp.weixin.qq.com');
}
} else {
session = await getPageSession(cdp, 'mp.weixin.qq.com');
}
const url = await evaluate<string>(session, 'window.location.href');
if (!url.includes('/cgi-bin/home')) {
if (!url.includes('/cgi-bin/')) {
console.log('[wechat] Not logged in. Please scan QR code...');
const loggedIn = await waitForLogin(session);
if (!loggedIn) throw new Error('Login timeout');
@@ -285,6 +487,10 @@ export async function postArticle(options: ArticleOptions): Promise<void> {
console.log('[wechat] Logged in.');
await sleep(2000);
// Wait for menu to be ready
const menuReady = await waitForElement(session, '.new-creation__menu', 20_000);
if (!menuReady) throw new Error('Home page menu did not load');
const targets = await cdp.send<{ targetInfos: Array<{ targetId: string; url: string; type: string }> }>('Target.getTargets');
const initialIds = new Set(targets.targetInfos.map(t => t.targetId));
@@ -313,6 +519,17 @@ export async function postArticle(options: ArticleOptions): Promise<void> {
await evaluate(session, `document.querySelector('#author').value = ${JSON.stringify(effectiveAuthor)}; document.querySelector('#author').dispatchEvent(new Event('input', { bubbles: true }));`);
}
await sleep(500);
if (effectiveTitle) {
const actualTitle = await evaluate<string>(session, `document.querySelector('#title')?.value || ''`);
if (actualTitle === effectiveTitle) {
console.log('[wechat] Title verified OK.');
} else {
console.warn(`[wechat] Title verification failed. Expected: "${effectiveTitle}", got: "${actualTitle}"`);
}
}
console.log('[wechat] Clicking on editor...');
await clickElement(session, '.ProseMirror');
await sleep(1000);
@@ -323,12 +540,26 @@ export async function postArticle(options: ArticleOptions): Promise<void> {
if (effectiveHtmlFile && fs.existsSync(effectiveHtmlFile)) {
console.log(`[wechat] Copying HTML content from: ${effectiveHtmlFile}`);
await copyHtmlFromBrowser(cdp, effectiveHtmlFile);
await copyHtmlFromBrowser(cdp, effectiveHtmlFile, contentImages);
await sleep(500);
console.log('[wechat] Pasting into editor...');
await pasteFromClipboardInEditor(session);
await sleep(3000);
const editorHasContent = await evaluate<boolean>(session, `
(function() {
const editor = document.querySelector('.ProseMirror');
if (!editor) return false;
const text = editor.innerText?.trim() || '';
return text.length > 0;
})()
`);
if (editorHasContent) {
console.log('[wechat] Body content verified OK.');
} else {
console.warn('[wechat] Body content verification failed: editor appears empty after paste.');
}
if (contentImages.length > 0) {
console.log(`[wechat] Inserting ${contentImages.length} images...`);
for (let i = 0; i < contentImages.length; i++) {
@@ -354,6 +585,7 @@ export async function postArticle(options: ArticleOptions): Promise<void> {
console.log('[wechat] Pasting image...');
await pasteFromClipboardInEditor(session);
await sleep(3000);
await removeExtraEmptyLineAfterImage(session);
}
console.log('[wechat] All images inserted.');
}
@@ -365,17 +597,51 @@ export async function postArticle(options: ArticleOptions): Promise<void> {
await sleep(500);
await pasteInEditor(session);
await sleep(2000);
await removeExtraEmptyLineAfterImage(session);
}
}
console.log('[wechat] Typing content...');
await typeText(session, content);
await sleep(1000);
const editorHasContent = await evaluate<boolean>(session, `
(function() {
const editor = document.querySelector('.ProseMirror');
if (!editor) return false;
const text = editor.innerText?.trim() || '';
return text.length > 0;
})()
`);
if (editorHasContent) {
console.log('[wechat] Body content verified OK.');
} else {
console.warn('[wechat] Body content verification failed: editor appears empty after typing.');
}
}
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] Filling summary (after content paste): ${effectiveSummary}`);
await evaluate(session, `
(function() {
const el = document.querySelector('#js_description');
if (!el) return;
el.focus();
el.select();
el.value = ${JSON.stringify(effectiveSummary)};
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
el.dispatchEvent(new Event('blur', { bubbles: true }));
})()
`);
await sleep(500);
const actualSummary = await evaluate<string>(session, `document.querySelector('#js_description')?.value || ''`);
if (actualSummary === effectiveSummary) {
console.log('[wechat] Summary verified OK.');
} else {
console.warn(`[wechat] Summary verification failed. Expected: "${effectiveSummary}", got: "${actualSummary}"`);
}
}
console.log('[wechat] Saving as draft...');
@@ -413,6 +679,7 @@ Options:
--image <path> Content image, can repeat (only with --content)
--submit Save as draft
--profile <dir> Chrome profile directory
--cdp-port <port> Connect to existing Chrome debug port instead of launching new instance
Examples:
npx -y bun wechat-article.ts --markdown article.md
@@ -442,6 +709,7 @@ async function main(): Promise<void> {
let summary: string | undefined;
let submit = false;
let profileDir: string | undefined;
let cdpPort: number | undefined;
for (let i = 0; i < args.length; i++) {
const arg = args[i]!;
@@ -455,15 +723,18 @@ async function main(): Promise<void> {
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];
else if (arg === '--cdp-port' && args[i + 1]) cdpPort = parseInt(args[++i]!, 10);
}
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 postArticle({ title: title || '', content, htmlFile, markdownFile, theme, author, summary, images, submit, profileDir, cdpPort });
}
await main().catch((err) => {
await main().then(() => {
process.exit(0);
}).catch((err) => {
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
});
+149 -46
View File
@@ -61,6 +61,25 @@ interface ArticleOptions {
chromePath?: string;
}
async function findExistingDebugPort(profileDir: string): Promise<number | null> {
const portFile = path.join(profileDir, 'DevToolsActivePort');
if (!fs.existsSync(portFile)) return null;
try {
const content = fs.readFileSync(portFile, 'utf-8').trim();
if (!content) return null;
const [portLine] = content.split(/\r?\n/);
const port = Number(portLine);
if (!Number.isFinite(port) || port <= 0) return null;
// Verify the port is actually active.
await waitForChromeDebugPort(port, 1500, { includeLastError: true });
return port;
} catch {
return null;
}
}
export async function publishArticle(options: ArticleOptions): Promise<void> {
const { markdownPath, submit = false, profileDir = getDefaultProfileDir() } = options;
@@ -83,28 +102,33 @@ export async function publishArticle(options: ArticleOptions): Promise<void> {
if (!chromePath) throw new Error('Chrome not found');
await mkdir(profileDir, { recursive: true });
const port = await getFreePort();
const existingPort = await findExistingDebugPort(profileDir);
const port = existingPort ?? await getFreePort();
console.log(`[x-article] Launching Chrome...`);
const chrome = spawn(chromePath, [
`--remote-debugging-port=${port}`,
`--user-data-dir=${profileDir}`,
'--no-first-run',
'--no-default-browser-check',
'--disable-blink-features=AutomationControlled',
'--start-maximized',
X_ARTICLES_URL,
], { stdio: 'ignore' });
if (existingPort) {
console.log(`[x-article] Reusing existing Chrome instance on port ${port}`);
} else {
console.log(`[x-article] Launching Chrome...`);
spawn(chromePath, [
`--remote-debugging-port=${port}`,
`--user-data-dir=${profileDir}`,
'--no-first-run',
'--no-default-browser-check',
'--disable-blink-features=AutomationControlled',
'--start-maximized',
X_ARTICLES_URL,
], { stdio: 'ignore' });
}
let cdp: CdpConnection | null = null;
try {
const wsUrl = await waitForChromeDebugPort(port, 30_000);
const wsUrl = await waitForChromeDebugPort(port, 30_000, { includeLastError: true });
cdp = await CdpConnection.connect(wsUrl, 30_000, { defaultTimeoutMs: 30_000 });
// Get page target
const targets = await cdp.send<{ targetInfos: Array<{ targetId: string; url: string; type: string }> }>('Target.getTargets');
let pageTarget = targets.targetInfos.find((t) => t.type === 'page' && t.url.includes('x.com'));
let pageTarget = targets.targetInfos.find((t) => t.type === 'page' && t.url.startsWith(X_ARTICLES_URL));
if (!pageTarget) {
const { targetId } = await cdp.send<{ targetId: string }>('Target.createTarget', { url: X_ARTICLES_URL });
@@ -229,11 +253,51 @@ export async function publishArticle(options: ArticleOptions): Promise<void> {
console.log('[x-article] Waiting for Apply button...');
const applyFound = await waitForElement('[data-testid="applyButton"]', 15_000);
if (applyFound) {
await cdp.send('Runtime.evaluate', {
expression: `document.querySelector('[data-testid="applyButton"]')?.click()`,
}, { sessionId });
console.log('[x-article] Cover image applied');
await sleep(1000);
// Check if modal is present
const isModalOpen = async (): Promise<boolean> => {
const result = await cdp!.send<{ result: { value: boolean } }>('Runtime.evaluate', {
expression: `!!document.querySelector('[role="dialog"][aria-modal="true"]')`,
returnByValue: true,
}, { sessionId });
return result.result.value;
};
// Click Apply button with retry logic
const maxRetries = 3;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
console.log(`[x-article] Clicking Apply button (attempt ${attempt}/${maxRetries})...`);
await cdp.send('Runtime.evaluate', {
expression: `document.querySelector('[data-testid="applyButton"]')?.click()`,
}, { sessionId });
// Wait for modal to close (up to 5 seconds per attempt)
const closeTimeout = 5000;
const checkInterval = 300;
const startTime = Date.now();
let modalClosed = false;
while (Date.now() - startTime < closeTimeout) {
await sleep(checkInterval);
const stillOpen = await isModalOpen();
if (!stillOpen) {
modalClosed = true;
break;
}
}
if (modalClosed) {
console.log('[x-article] Cover image applied, modal closed');
await sleep(500);
break;
}
if (attempt < maxRetries) {
console.log('[x-article] Modal still open, retrying...');
} else {
console.log('[x-article] Modal did not close after all attempts, continuing anyway...');
}
}
} else {
console.log('[x-article] Apply button not found, continuing...');
}
@@ -369,15 +433,23 @@ export async function publishArticle(options: ArticleOptions): Promise<void> {
console.log('[x-article] Checking for placeholders in content...');
for (const img of parsed.contentImages) {
if (editorContent.result.value.includes(img.placeholder)) {
// Use regex for exact match (not followed by digit, e.g., XIMGPH_1 should not match XIMGPH_10)
const regex = new RegExp(img.placeholder + '(?!\\d)');
if (regex.test(editorContent.result.value)) {
console.log(`[x-article] Found: ${img.placeholder}`);
} else {
console.log(`[x-article] NOT found: ${img.placeholder}`);
}
}
// Process images in sequential order (1, 2, 3, ...)
const sortedImages = [...parsed.contentImages].sort((a, b) => a.blockIndex - b.blockIndex);
// Process images in XIMGPH order (1, 2, 3, ...) regardless of blockIndex
const getPlaceholderIndex = (placeholder: string): number => {
const match = placeholder.match(/XIMGPH_(\d+)/);
return match ? Number(match[1]) : Number.POSITIVE_INFINITY;
};
const sortedImages = [...parsed.contentImages].sort(
(a, b) => getPlaceholderIndex(a.placeholder) - getPlaceholderIndex(b.placeholder),
);
for (let i = 0; i < sortedImages.length; i++) {
const img = sortedImages[i]!;
@@ -400,22 +472,30 @@ export async function publishArticle(options: ArticleOptions): Promise<void> {
while ((node = walker.nextNode())) {
const text = node.textContent || '';
const idx = text.indexOf(placeholder);
if (idx !== -1) {
// Found the placeholder - scroll to it first
const parentElement = node.parentElement;
if (parentElement) {
parentElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
let searchStart = 0;
let idx;
// Search for exact match (not prefix of longer placeholder like XIMGPH_1 in XIMGPH_10)
while ((idx = text.indexOf(placeholder, searchStart)) !== -1) {
const afterIdx = idx + placeholder.length;
const charAfter = text[afterIdx];
// Exact match if next char is not a digit (XIMGPH_1 should not match XIMGPH_10)
if (charAfter === undefined || !/\\d/.test(charAfter)) {
// Found exact placeholder - scroll to it first
const parentElement = node.parentElement;
if (parentElement) {
parentElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
// Select it
const range = document.createRange();
range.setStart(node, idx);
range.setEnd(node, idx + placeholder.length);
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
return true;
// Select it
const range = document.createRange();
range.setStart(node, idx);
range.setEnd(node, idx + placeholder.length);
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
return true;
}
searchStart = afterIdx;
}
}
return false;
@@ -465,31 +545,54 @@ export async function publishArticle(options: ArticleOptions): Promise<void> {
// Wait for clipboard to be fully ready
await sleep(1000);
// Delete placeholder by pressing Backspace (more reliable than Enter for replacing selection)
// Delete placeholder using execCommand (more reliable than keyboard events for DraftJS)
console.log(`[x-article] Deleting placeholder...`);
await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Backspace', code: 'Backspace', windowsVirtualKeyCode: 8 }, { sessionId });
await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Backspace', code: 'Backspace', windowsVirtualKeyCode: 8 }, { sessionId });
const deleteResult = await cdp.send<{ result: { value: boolean } }>('Runtime.evaluate', {
expression: `(() => {
const sel = window.getSelection();
if (!sel || sel.isCollapsed) return false;
// Try execCommand delete first
if (document.execCommand('delete', false)) return true;
// Fallback: replace selection with empty using insertText
document.execCommand('insertText', false, '');
return true;
})()`,
returnByValue: true,
}, { sessionId });
// Wait and verify placeholder is deleted
await sleep(500);
// Check that placeholder is no longer in editor
// Check that placeholder is no longer in editor (exact match, not substring)
const afterDelete = await cdp.send<{ result: { value: boolean } }>('Runtime.evaluate', {
expression: `(() => {
const editor = document.querySelector('.DraftEditor-editorContainer [data-contents="true"]');
if (!editor) return true;
return !editor.innerText.includes(${JSON.stringify(img.placeholder)});
const text = editor.innerText;
const placeholder = ${JSON.stringify(img.placeholder)};
// Use regex to find exact match (not followed by digit)
const regex = new RegExp(placeholder + '(?!\\\\d)');
return !regex.test(text);
})()`,
returnByValue: true,
}, { sessionId });
if (!afterDelete.result.value) {
console.warn(`[x-article] Placeholder may not have been deleted, trying again...`);
// Try selecting and deleting again
console.warn(`[x-article] Placeholder may not have been deleted, trying dispatchEvent...`);
// Try selecting and deleting with InputEvent
await selectPlaceholder(1);
await sleep(300);
await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Backspace', code: 'Backspace', windowsVirtualKeyCode: 8 }, { sessionId });
await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Backspace', code: 'Backspace', windowsVirtualKeyCode: 8 }, { sessionId });
await cdp.send('Runtime.evaluate', {
expression: `(() => {
const editor = document.querySelector('.DraftEditor-editorContainer [contenteditable="true"]');
if (!editor) return;
editor.focus();
// Dispatch beforeinput and input events for deletion
const beforeEvent = new InputEvent('beforeinput', { inputType: 'deleteContentBackward', bubbles: true, cancelable: true });
editor.dispatchEvent(beforeEvent);
const inputEvent = new InputEvent('input', { inputType: 'deleteContentBackward', bubbles: true });
editor.dispatchEvent(inputEvent);
})()`,
}, { sessionId });
await sleep(500);
}
+9 -3
View File
@@ -226,6 +226,7 @@ Schema: `references/config/preferences-schema.md`
**1.2 Analyze Content**
1. Save source content (if pasted, save as `source.md`)
- **Backup rule**: If `source.md` exists, rename to `source-backup-YYYYMMDD-HHMMSS.md`
2. Follow `references/analysis-framework.md` for content analysis
3. Analyze content signals for style recommendations
4. Detect source language
@@ -483,6 +484,7 @@ options:
- Add slide-specific content
- If `Layout:` specified, include layout guidance from `references/layouts.md`
3. Save to `prompts/` directory
- **Backup rule**: If prompt file exists, rename to `prompts/NN-slide-{slug}-backup-YYYYMMDD-HHMMSS.md`
**After generation**:
- If `--prompts-only`, stop here and output prompt summary
@@ -539,7 +541,9 @@ options:
**Standard flow**:
1. Select available image generation skill
2. Generate session ID: `slides-{topic-slug}-{timestamp}`
3. Generate each slide sequentially with same session ID
3. For each slide:
- **Backup rule**: If image file exists, rename to `NN-slide-{slug}-backup-YYYYMMDD-HHMMSS.png`
- Generate image sequentially with same session ID
4. Report progress: "Generated X/N" (in user's language)
5. Auto-retry once on failure before reporting error
@@ -626,16 +630,18 @@ Flow:
| Action | Command | Manual Steps |
|--------|---------|--------------|
| **Edit** | `--regenerate N` | Update prompt → Regenerate image → Regenerate PDF |
| **Edit** | `--regenerate N` | **Update prompt file FIRST** → Regenerate image → Regenerate PDF |
| **Add** | Manual | Create prompt → Generate image → Renumber subsequent → Update outline → Regenerate PDF |
| **Delete** | Manual | Remove files → Renumber subsequent → Update outline → Regenerate PDF |
### Edit Single Slide
1. Update prompt in `prompts/NN-slide-{slug}.md`
1. **Update prompt file FIRST** in `prompts/NN-slide-{slug}.md`
2. Run: `/baoyu-slide-deck <dir> --regenerate N`
3. Or manually regenerate image + PDF
**IMPORTANT**: When updating slides, ALWAYS update the prompt file (`prompts/NN-slide-{slug}.md`) FIRST before regenerating. This ensures changes are documented and reproducible.
### Add New Slide
1. Create prompt at position: `prompts/NN-slide-{new-slug}.md`
File diff suppressed because it is too large Load Diff
+48 -26
View File
@@ -1,6 +1,6 @@
---
name: baoyu-xhs-images
description: Generates Xiaohongshu (Little Red Book) infographic series with 9 visual styles and 6 layouts. Breaks content into 1-10 cartoon-style images optimized for XHS engagement. Use when user mentions "小红书图片", "XHS images", "RedNote infographics", "小红书种草", or wants social media infographics for Chinese platforms.
description: Generates Xiaohongshu (Little Red Book) infographic series with 10 visual styles and 8 layouts. Breaks content into 1-10 cartoon-style images optimized for XHS engagement. Use when user mentions "小红书图片", "XHS images", "RedNote infographics", "小红书种草", or wants social media infographics for Chinese platforms.
---
# Xiaohongshu Infographic Series Generator
@@ -42,8 +42,8 @@ Break down complex content into eye-catching infographic series for Xiaohongshu
| Dimension | Controls | Options |
|-----------|----------|---------|
| **Style** | Visual aesthetics: colors, lines, decorations | cute, fresh, warm, bold, minimal, retro, pop, notion, chalkboard |
| **Layout** | Information structure: density, arrangement | sparse, balanced, dense, list, comparison, flow |
| **Style** | Visual aesthetics: colors, lines, decorations | cute, fresh, warm, bold, minimal, retro, pop, notion, chalkboard, study-notes |
| **Layout** | Information structure: density, arrangement | sparse, balanced, dense, list, comparison, flow, mindmap, quadrant |
Style × Layout can be freely combined. Example: `--style notion --layout dense` creates an intellectual-looking knowledge card with high information density.
@@ -60,6 +60,7 @@ Style × Layout can be freely combined. Example: `--style notion --layout dense`
| `pop` | Vibrant, energetic, eye-catching |
| `notion` | Minimalist hand-drawn line art, intellectual |
| `chalkboard` | Colorful chalk on black board, educational |
| `study-notes` | Realistic handwritten photo style, blue pen + red annotations + yellow highlighter |
Detailed style definitions: `references/presets/<style>.md`
@@ -73,6 +74,8 @@ Detailed style definitions: `references/presets/<style>.md`
| `list` | Enumeration and ranking format (4-7 items) |
| `comparison` | Side-by-side contrast layout |
| `flow` | Process and timeline layout (3-6 steps) |
| `mindmap` | Center radial mind map layout (4-8 branches) |
| `quadrant` | Four-quadrant / circular section layout |
Detailed layout definitions: `references/elements/canvas.md`
@@ -89,6 +92,7 @@ Detailed layout definitions: `references/elements/canvas.md`
| Fun, exciting, wow, amazing | `pop` | sparse/list |
| Knowledge, concept, productivity, SaaS | `notion` | dense/list |
| Education, tutorial, learning, teaching, classroom | `chalkboard` | balanced/dense |
| Notes, handwritten, study guide, knowledge, realistic, photo | `study-notes` | dense/list/mindmap |
## Outline Strategies
@@ -164,7 +168,9 @@ Copy and track progress:
```
XHS Infographic Progress:
- [ ] Step 0: Check preferences (EXTEND.md) ⚠️ REQUIRED if not found
- [ ] Step 0: Check preferences (EXTEND.md) ⛔ BLOCKING
- [ ] Found → load preferences → continue
- [ ] Not found → run first-time setup → MUST complete before Step 1
- [ ] Step 1: Analyze content → analysis.md
- [ ] Step 2: Confirmation 1 - Content understanding ⚠️ REQUIRED
- [ ] Step 3: Generate 3 outline + style variants
@@ -176,12 +182,22 @@ XHS Infographic Progress:
### Flow
```
Input → Analyze → [Confirm 1] → 3 Outlines → [Confirm 2: Outline + Style + Elements] → Generate → Complete
Input → [Step 0: Preferences] ─┬─ Found → Continue
└─ Not found → First-Time Setup ⛔ BLOCKING
└─ Complete setup → Save EXTEND.md → Continue
┌───────────────────────────────────────────────────────────────────────────┘
Analyze → [Confirm 1] → 3 Outlines → [Confirm 2: Outline + Style + Elements] → Generate → Complete
```
### Step 0: Load Preferences (EXTEND.md) ⚠️
### Step 0: Load Preferences (EXTEND.md) ⛔ BLOCKING
**Purpose**: Load user preferences or run first-time setup. **Do NOT skip setup if EXTEND.md not found.**
**Purpose**: Load user preferences or run first-time setup.
**CRITICAL**: If EXTEND.md not found, MUST complete first-time setup before ANY other questions or steps. Do NOT proceed to content analysis, do NOT ask about style, do NOT ask about layout — ONLY complete the preferences setup first.
Use Bash to check EXTEND.md existence (priority order):
@@ -201,13 +217,13 @@ test -f "$HOME/.baoyu-skills/baoyu-xhs-images/EXTEND.md" && echo "user"
│ $HOME/.baoyu-skills/baoyu-xhs-images/EXTEND.md │ User home │
└────────────────────────────────────────────────────┴───────────────────┘
┌───────────┬───────────────────────────────────────────────────────────────────────────┐
│ Result │ Action
├───────────┼───────────────────────────────────────────────────────────────────────────┤
│ Found │ Read, parse, display summary → Continue to Step 1 │
├───────────┼───────────────────────────────────────────────────────────────────────────┤
│ Not found │ ⚠️ MUST run first-time setup (see below) → Then continue to Step 1
└───────────┴───────────────────────────────────────────────────────────────────────────┘
┌───────────┬─────────────────────────────────────────────────────────────────────────────────────────────────────
│ Result │ Action
├───────────┼─────────────────────────────────────────────────────────────────────────────────────────────────────
│ Found │ Read, parse, display summary → Continue to Step 1
├───────────┼─────────────────────────────────────────────────────────────────────────────────────────────────────
│ Not found │ ⛔ BLOCKING: Run first-time setup ONLY (see below) → Complete and save EXTEND.md → Then Step 1 │
└───────────┴─────────────────────────────────────────────────────────────────────────────────────────────────────
**First-Time Setup** (when EXTEND.md not found):
@@ -227,6 +243,7 @@ Read source content, save it if needed, and perform deep analysis.
1. **Save source content** (if not already a file):
- If user provides a file path: use as-is
- If user pastes content: save to `source.md` in target directory
- **Backup rule**: If `source.md` exists, rename to `source-backup-YYYYMMDD-HHMMSS.md`
2. Read source content
3. **Deep analysis** following `references/workflows/analysis-framework.md`:
- Content type classification (种草/干货/测评/教程/避坑...)
@@ -353,7 +370,9 @@ With confirmed outline + style + layout:
**For each image (cover + content + ending)**:
1. Save prompt to `prompts/NN-{type}-[slug].md` (in user's preferred language)
- **Backup rule**: If prompt file exists, rename to `prompts/NN-{type}-[slug]-backup-YYYYMMDD-HHMMSS.md`
2. Generate image using confirmed style and layout
- **Backup rule**: If image file exists, rename to `NN-{type}-[slug]-backup-YYYYMMDD-HHMMSS.png`
3. Report progress after each generation
**Watermark Application** (if enabled in preferences):
@@ -403,10 +422,12 @@ Files:
| Action | Steps |
|--------|-------|
| **Edit** | Update prompt → Regenerate with same session ID |
| **Edit** | **Update prompt file FIRST** → Regenerate with same session ID |
| **Add** | Specify position → Create prompt → Generate → Renumber subsequent files (NN+1) → Update outline |
| **Delete** | Remove files → Renumber subsequent (NN-1) → Update outline |
**IMPORTANT**: When updating images, ALWAYS update the prompt file (`prompts/NN-{type}-[slug].md`) FIRST before regenerating. This ensures changes are documented and reproducible.
## Content Breakdown Principles
1. **Cover (Image 1)**: Hook + visual impact → `sparse` layout
@@ -415,17 +436,18 @@ Files:
**Style × Layout Matrix** (✓✓ = highly recommended, ✓ = works well):
| | sparse | balanced | dense | list | comparison | flow |
|---|:---:|:---:|:---:|:---:|:---:|:---:|
| cute | ✓✓ | ✓✓ | ✓ | ✓✓ | ✓ | ✓ |
| fresh | ✓✓ | ✓✓ | ✓ | ✓ | ✓ | ✓✓ |
| warm | ✓✓ | ✓✓ | ✓ | ✓ | ✓✓ | ✓ |
| bold | ✓✓ | ✓ | ✓ | ✓✓ | ✓✓ | ✓ |
| minimal | ✓✓ | ✓✓ | ✓✓ | ✓ | ✓ | ✓ |
| retro | ✓✓ | ✓✓ | ✓ | ✓✓ | ✓ | ✓ |
| pop | ✓✓ | ✓✓ | ✓ | ✓✓ | ✓✓ | ✓ |
| notion | ✓✓ | ✓✓ | ✓✓ | ✓✓ | ✓✓ | ✓✓ |
| chalkboard | ✓✓ | ✓✓ | ✓✓ | ✓✓ | ✓ | ✓✓ |
| | sparse | balanced | dense | list | comparison | flow | mindmap | quadrant |
|---|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|
| cute | ✓✓ | ✓✓ | ✓ | ✓✓ | ✓ | ✓ | ✓ | ✓ |
| fresh | ✓✓ | ✓✓ | ✓ | ✓ | ✓ | ✓✓ | ✓ | ✓ |
| warm | ✓✓ | ✓✓ | ✓ | ✓ | ✓✓ | ✓ | ✓ | ✓ |
| bold | ✓✓ | ✓ | ✓ | ✓✓ | ✓✓ | ✓ | ✓ | ✓✓ |
| minimal | ✓✓ | ✓✓ | ✓✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| retro | ✓✓ | ✓✓ | ✓ | ✓✓ | ✓ | ✓ | ✓ | ✓ |
| pop | ✓✓ | ✓✓ | ✓ | ✓✓ | ✓✓ | ✓ | ✓ | ✓ |
| notion | ✓✓ | ✓✓ | ✓✓ | ✓✓ | ✓✓ | ✓✓ | ✓✓ | ✓✓ |
| chalkboard | ✓✓ | ✓✓ | ✓✓ | ✓✓ | ✓ | ✓✓ | ✓✓ | ✓ |
| study-notes | ✗ | ✓ | ✓✓ | ✓✓ | ✓ | ✓ | ✓✓ | ✓ |
## References
@@ -9,6 +9,14 @@ description: First-time setup flow for baoyu-xhs-images preferences
When no EXTEND.md is found, guide user through preference setup.
**⛔ BLOCKING OPERATION**: This setup MUST complete before ANY other workflow steps. Do NOT:
- Ask about content/article
- Ask about style or layout
- Ask about target audience
- Proceed to content analysis
ONLY ask the questions in this setup flow, save EXTEND.md, then continue.
## Setup Flow
```
@@ -51,6 +51,8 @@ Avoid placing critical content in these areas:
| list | Vertical enumeration | 4-7 | Rankings, checklists, step guides |
| comparison | Left vs Right | 2 sections | Before/after, pros/cons |
| flow | Connected nodes | 3-6 steps | Processes, timelines, workflows |
| mindmap | Center radial | 4-8 branches | Concept maps, brainstorming, topic overview |
| quadrant | 4-section grid | 4 sections | SWOT analysis, priority matrix, classification |
## Layout by Position
@@ -106,3 +108,15 @@ For multi-element compositions:
- Directional flow (top→bottom or left→right)
- Connected nodes with arrows
- Clear progression indicators
### Mindmap Layout
- Central topic node
- Radial branches outward
- Hierarchical sub-branches
- Organic curved connections
### Quadrant Layout
- 4-section grid (2×2)
- Clear axis labels
- Each quadrant with distinct content
- Optional circular variant for cycles
@@ -0,0 +1,115 @@
---
name: study-notes
category: realistic
---
# Study Notes Style
Realistic handwritten photo aesthetic - student notes style, dense and messy but readable.
## Element Combination
```yaml
canvas:
ratio: portrait-3-4
grid: single
image_effects:
cutout: none
stroke: none
filter: natural-photo
typography:
decorated: none
tags: none
direction: horizontal
decorations:
emphasis: circle-mark | underline | checkmark | cross | star-simple
background: lined-paper-white
doodles: arrows-simple | margin-notes | corrections | explanatory-diagrams
frames: none
```
## Color Palette (Three-Color Annotation System)
| Role | Colors | Hex |
|------|--------|-----|
| Primary | Blue ballpoint, Black ink | #1E3A5F, #1A1A1A |
| Highlights | Yellow highlighter | #FFFF00 (50% opacity) |
| Accents | Red pen (circles, underlines) | #CC0000 |
| Background | White lined paper | #FFFFFF |
## Visual Elements
- Realistic photo perspective: top-down view of study desk
- Hand holding blue ballpoint pen, actively underlining
- Extremely dense handwritten content, filling entire page
- Red pen annotations: circles, underlines, stars, boxes
- Yellow highlighter marking key terms
- Correction marks, cramped notes squeezed into margins
- Simple hand-drawn symbols: → * ✓ ✗ !
- Varying pen pressure creating lighter and darker strokes
## Typography
- Authentic student handwriting
- Messy but readable, clear structure maintained
- Varying font sizes (large titles, small body, tiny margin notes)
- CJK optimized
## Content Structure
Three-section layout:
### Top Section
- Core topic (circled multiple times in red)
- First section title + 3-4 key points
- Arrow connections, red underlines
### Middle Section
- Second section title (red pen box)
- Numbered steps ①②③
- Specific methods and supplementary notes
### Bottom Section
- Third section title (red star)
- Time points / key metrics
- Key quotes / core tips (tiny corner notes)
## Best Layout Pairings
| Layout | Compatibility | Use Case |
|--------|---------------|----------|
| sparse | ✗ | Not suitable - style requires dense content |
| balanced | ✓ | When content is lighter |
| dense | ✓✓ | Best fit - knowledge notes, summaries |
| list | ✓✓ | Step checklists, rankings |
| comparison | ✓ | Comparative analysis |
| flow | ✓ | Process flows |
| mindmap | ✓✓ | Mind map notes |
| quadrant | ✓ | Quadrant analysis |
## Best For
- Study guides, exam notes
- Knowledge organization, framework summaries
- Tutorial summaries, quick notes
- "Top student notes" style content
- Knowledge sharing requiring authentic feel
## Style Rules
### DO ✓
- Keep content extremely dense
- Use simple symbols (→ * ✓ ✗ !)
- Annotate key points with red pen
- Include correction marks
- Squeeze tiny notes into margins
### DON'T ✗
- Use complex emojis
- Leave too much whitespace
- Make neat, tidy layouts
- Add colorful decorations
- Include cartoon elements