mirror of
https://github.com/JimLiu/baoyu-skills.git
synced 2026-07-13 06:19:46 +08:00
Compare commits
54 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ea8ad82786 | |||
| 6fffe252c0 | |||
| 86a84739e8 | |||
| 7f80100b3e | |||
| 61960961ba | |||
| cdd9ee042d | |||
| c742bfa1af | |||
| 0faea4ecaa | |||
| 7c9ec259a1 | |||
| 24a17709a8 | |||
| a3849af0cf | |||
| cc95e6fe05 | |||
| e7d9ed7917 | |||
| 876c6332f6 | |||
| 502d2448b2 | |||
| b9e48d9483 | |||
| b0554f8d0c | |||
| 12f3d95639 | |||
| 809fb29ca9 | |||
| be20bdf0be | |||
| 7c36b2a6a5 | |||
| 7979cb9dde | |||
| e563bbae7b | |||
| c63d1eec69 | |||
| 78d6d23359 | |||
| ec6781a231 | |||
| edf9030675 | |||
| 10e1c426a0 | |||
| dbab83ff82 | |||
| 478e66a93a | |||
| 3a5866eb4b | |||
| c0162bb3af | |||
| e9342a16f8 | |||
| 5cb86d8e51 | |||
| 66ed08f010 | |||
| b179166d28 | |||
| 4805526649 | |||
| 89159bf86e | |||
| b45b5d2c6e | |||
| 62866426c5 | |||
| 28a7db6129 | |||
| 7c5e81fd15 | |||
| 058d25d02c | |||
| fe09ec546f | |||
| cf611a4514 | |||
| 7cf30e969f | |||
| 18b71157db | |||
| 9904c490e4 | |||
| 32ed09128c | |||
| 81e96ee43c | |||
| 5f994589b3 | |||
| 40363194be | |||
| 21a5167b61 | |||
| 4d3957ca06 |
@@ -6,7 +6,7 @@
|
||||
},
|
||||
"metadata": {
|
||||
"description": "Skills shared by Baoyu for improving daily work efficiency",
|
||||
"version": "1.24.3"
|
||||
"version": "1.30.1"
|
||||
},
|
||||
"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"
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
+3
-1
@@ -156,4 +156,6 @@ comic/
|
||||
.idea
|
||||
*.iws
|
||||
*.iml
|
||||
*.ipr
|
||||
*.ipr
|
||||
|
||||
.claude/skills/baoyu-skill-evolution
|
||||
|
||||
+106
@@ -2,6 +2,112 @@
|
||||
|
||||
English | [中文](./CHANGELOG.zh.md)
|
||||
|
||||
## 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
|
||||
|
||||
+106
@@ -2,6 +2,112 @@
|
||||
|
||||
[English](./CHANGELOG.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
|
||||
|
||||
### 文档
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -678,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:
|
||||
|
||||
+60
-2
@@ -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)
|
||||
|
||||
@@ -678,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 frontmatter(title、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` 文件中设置:
|
||||
|
||||
@@ -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
|
||||

|
||||
```
|
||||
|
||||
Alt text: concise description in article's language.
|
||||
|
||||
**6.2 Output Summary**
|
||||
**Update Article**: Insert `` 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,26 +180,18 @@ illustrations/{topic-slug}/
|
||||
|
||||
| Action | Steps |
|
||||
|--------|-------|
|
||||
| **Edit** | **Update prompt file FIRST** → 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 |
|
||||
|
||||
**IMPORTANT**: When updating illustrations, ALWAYS update the prompt file (`prompts/illustration-{slug}.md`) FIRST before regenerating. This ensures:
|
||||
1. Changes are documented and reproducible
|
||||
2. The prompt reflects the new requirements
|
||||
3. Future regeneration uses the correct prompt
|
||||
|
||||
## References
|
||||
|
||||
| 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
|
||||

|
||||
```
|
||||
|
||||
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]
|
||||
```
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
```
|
||||
|
||||
@@ -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`:
|
||||
|
||||
@@ -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
|
||||
@@ -34,6 +34,10 @@ Generate elegant cover images for articles with 5-dimensional customization.
|
||||
# Direct input with options
|
||||
/baoyu-cover-image --palette mono --rendering digital --aspect 1:1 --quick
|
||||
[paste content]
|
||||
|
||||
# With reference images
|
||||
/baoyu-cover-image article.md --ref style-ref.png
|
||||
/baoyu-cover-image article.md --ref ref1.png ref2.png --quick
|
||||
```
|
||||
|
||||
## Options
|
||||
@@ -46,10 +50,12 @@ Generate elegant cover images for articles with 5-dimensional customization.
|
||||
| `--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 |
|
||||
| `--font <name>` | Font style: 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 |
|
||||
| `--ref <files...>` | Reference images for style/composition guidance |
|
||||
|
||||
## Five Dimensions
|
||||
|
||||
@@ -60,6 +66,7 @@ Generate elegant cover images for articles with 5-dimensional customization.
|
||||
| **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 |
|
||||
| **Font** | Typography style, character feel | clean, handwritten, serif, display | clean |
|
||||
|
||||
Dimensions can be freely combined. Auto-selection rules: [references/auto-selection.md](references/auto-selection.md)
|
||||
|
||||
@@ -110,8 +117,8 @@ Rendering definitions: [references/renderings/](references/renderings/)
|
||||
| Text Level | Title | Subtitle | Tags | Use Case |
|
||||
|------------|:-----:|:--------:|:----:|----------|
|
||||
| `none` | - | - | - | Pure visual, no text |
|
||||
| `title-only` | ✓ (≤8 字) | - | - | Simple headline (default) |
|
||||
| `title-subtitle` | ✓ | ✓ (≤15 字) | - | Title + supporting context |
|
||||
| `title-only` | ✓ | - | - | Simple headline (default) |
|
||||
| `title-subtitle` | ✓ | ✓ | - | Title + supporting context |
|
||||
| `text-rich` | ✓ | ✓ | ✓ (2-4) | Information-dense |
|
||||
|
||||
| Mood | Contrast | Saturation | Weight | Use Case |
|
||||
@@ -122,6 +129,17 @@ Rendering definitions: [references/renderings/](references/renderings/)
|
||||
|
||||
Full guides: [references/dimensions/text.md](references/dimensions/text.md) | [references/dimensions/mood.md](references/dimensions/mood.md)
|
||||
|
||||
## Font Gallery
|
||||
|
||||
| Font | Description | Best For |
|
||||
|------|-------------|----------|
|
||||
| `clean` | Modern geometric sans-serif | Tech, professional, minimal |
|
||||
| `handwritten` | Warm hand-lettered style | Personal, friendly, lifestyle |
|
||||
| `serif` | Classic elegant typography | Editorial, academic, luxury |
|
||||
| `display` | Bold decorative headlines | Announcements, entertainment |
|
||||
|
||||
Full guide: [references/dimensions/font.md](references/dimensions/font.md)
|
||||
|
||||
## Style Presets & Compatibility
|
||||
|
||||
- **Style Presets**: `--style X` expands to palette + rendering. See [references/style-presets.md](references/style-presets.md)
|
||||
@@ -142,6 +160,12 @@ Output directory depends on `default_output_dir` preference:
|
||||
```
|
||||
<output-dir>/
|
||||
├── source-{slug}.{ext} # Source files (text, images, etc.)
|
||||
├── refs/ # Reference images (if provided)
|
||||
│ ├── ref-01-{slug}.{ext}
|
||||
│ ├── ref-01-{slug}.md # Description file (optional)
|
||||
│ ├── ref-02-{slug}.{ext}
|
||||
│ ├── ref-02-{slug}.md # Description file (optional)
|
||||
│ └── extracted-style.md # Verbally extracted style (if no file path)
|
||||
├── prompts/cover.md # Generation prompt
|
||||
└── cover.png # Output image
|
||||
```
|
||||
@@ -156,25 +180,45 @@ 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
|
||||
- [ ] Found → load preferences → continue
|
||||
- [ ] Not found → run first-time setup → MUST complete before Step 1
|
||||
- [ ] Step 1: Analyze content + determine output directory
|
||||
- [ ] 1.1 Reference images ⚠️ (if provided)
|
||||
- [ ] File path given → saved to refs/ ✓
|
||||
- [ ] No path → asked user OR extracted verbally
|
||||
- [ ] 1.2 Output directory determined
|
||||
- [ ] Step 2: Confirm options (6 dimensions) ⚠️ REQUIRED unless --quick or all specified
|
||||
- [ ] Step 3: Create prompt
|
||||
- [ ] References in prompt ONLY if files exist in refs/
|
||||
- [ ] Extracted style/palette appended to prompt body (if no file)
|
||||
- [ ] Step 4: Generate image
|
||||
- [ ] 4.1 References verified before generation
|
||||
- [ ] 4.2 Pass refs via --ref if skill supports AND files exist
|
||||
- [ ] Step 5: Completion report
|
||||
```
|
||||
|
||||
### 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
|
||||
│
|
||||
└─ Complete setup → 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) ⚠️
|
||||
### 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 reference images, do NOT ask about dimensions — ONLY complete the preferences setup first.
|
||||
|
||||
Use Bash to check EXTEND.md existence (priority order):
|
||||
|
||||
@@ -189,7 +233,7 @@ 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 |
|
||||
| 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 to Step 1 |
|
||||
|
||||
**Preferences Summary** (when found):
|
||||
|
||||
@@ -202,38 +246,208 @@ Preferences loaded from [project/user]:
|
||||
• 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
|
||||
**EXTEND.md Supports**: Watermark | Preferred type | Preferred palette | Preferred rendering | Preferred text | Preferred mood | Preferred font | Default aspect ratio | Default output directory | Quick mode | Custom palette definitions | Language preference
|
||||
|
||||
Schema: [references/config/preferences-schema.md](references/config/preferences-schema.md)
|
||||
|
||||
### 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.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 `refs/` 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 prompt (NO frontmatter references) |
|
||||
|
||||
**CRITICAL**: Only add `references` to prompt frontmatter if files are ACTUALLY SAVED to `refs/` directory.
|
||||
|
||||
**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
|
||||
|
||||
**If user can't provide path** (extracted verbally):
|
||||
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. Instead, append extracted style/colors directly to prompt text
|
||||
|
||||
**Description File Format** (only when file saved):
|
||||
```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 |
|
||||
|
||||
**Verification** (only for saved files):
|
||||
```
|
||||
Reference Images Saved:
|
||||
- ref-01-{slug}.png ✓ (can use --ref)
|
||||
- ref-02-{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 Save Source Content**
|
||||
- If pasted, save to `source.md` in target directory; if file path, use as-is
|
||||
- **Backup rule**: If `source.md` exists, rename to `source-backup-YYYYMMDD-HHMMSS.md`
|
||||
|
||||
**1.2 Content Analysis**
|
||||
- Extract topic, core message, tone, keywords
|
||||
- Identify visual metaphors
|
||||
- Detect content type
|
||||
|
||||
**1.3 Reference Image Deep Analysis** ⚠️ CRITICAL (if provided in Step 1.0)
|
||||
|
||||
References are high-priority inputs. Perform deep visual analysis to extract **specific, concrete, reproducible** elements — not vague summaries.
|
||||
|
||||
For each reference image, extract ALL of the following:
|
||||
|
||||
| Analysis | Description | Example (good vs bad) |
|
||||
|----------|-------------|----------------------|
|
||||
| **Brand elements** | Logos, wordmarks, specific typography treatments | Good: "Logo uses vertical parallel lines for 'm'" / Bad: "Has a logo" |
|
||||
| **Signature patterns** | Unique decorative motifs, textures, geometric patterns | Good: "Woven intersecting curves forming diamond grid" / Bad: "Has patterns" |
|
||||
| **Color palette** | Exact hex values or close approximations for key colors | Good: "#2D4A3E dark teal, #F5F0E0 cream" / Bad: "Dark and light colors" |
|
||||
| **Layout structure** | Specific spatial arrangement, banner placement, zone splits | Good: "Bottom 30% dark banner with branding" / Bad: "Has a banner" |
|
||||
| **Typography** | Font style, weight, spacing, case, positioning | Good: "Uppercase, wide letter-spacing, handwritten ep number" / 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 a bullet point that can be directly copy-pasted into a prompt as a mandatory instruction.
|
||||
|
||||
**1.4 Language Detection**
|
||||
- Detect source language
|
||||
- Note user's input language
|
||||
- Compare with EXTEND.md preference
|
||||
|
||||
**1.5 Determine Output Directory**
|
||||
- Per File Structure rules
|
||||
- If no `default_output_dir` preference + file path input, include in Step 2 Q4
|
||||
|
||||
### Step 2: Confirm Options ⚠️
|
||||
|
||||
Validate all 5 dimensions + aspect ratio. Full confirmation flow: [references/workflow/confirm-options.md](references/workflow/confirm-options.md)
|
||||
Validate all 6 dimensions + aspect ratio. Full confirmation flow: [references/workflow/confirm-options.md](references/workflow/confirm-options.md)
|
||||
|
||||
**Skip Conditions**:
|
||||
|
||||
| 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
|
||||
|
||||
**Backup rule**: If `prompts/cover.md` exists, rename to `prompts/cover-backup-YYYYMMDD-HHMMSS.md`
|
||||
|
||||
Save to `prompts/cover.md`. Full template: [references/workflow/prompt-template.md](references/workflow/prompt-template.md)
|
||||
|
||||
**CRITICAL - References in YAML Frontmatter**:
|
||||
|
||||
When reference files are saved to `refs/`, **MUST add `references` field in frontmatter**:
|
||||
|
||||
```yaml
|
||||
---
|
||||
type: cover
|
||||
palette: warm
|
||||
rendering: flat-vector
|
||||
references:
|
||||
- ref_id: 01
|
||||
filename: refs/ref-01-podcast-thumbnail.jpg
|
||||
usage: style
|
||||
---
|
||||
```
|
||||
|
||||
| Rule | Action |
|
||||
|------|--------|
|
||||
| Files saved to `refs/` | Add to frontmatter `references` list |
|
||||
| Style extracted verbally (no file) | Omit `references` field, describe in body |
|
||||
| Before writing | Verify: `test -f refs/ref-NN-{slug}.{ext}` |
|
||||
|
||||
**Reference Embedding**:
|
||||
|
||||
| Situation | Frontmatter | Body |
|
||||
|-----------|-------------|------|
|
||||
| Reference file saved to `refs/` | Add to `references` ✓ | **Detailed mandatory style instructions** (see below) |
|
||||
| Style extracted verbally (no file) | Omit `references` | Full style description |
|
||||
| File in frontmatter but doesn't exist | ERROR - fix or remove | — |
|
||||
|
||||
⚠️ **"Brief style note" is NOT sufficient.** When references are provided, the prompt body MUST contain a dedicated `# Reference Style — MUST INCORPORATE` section with:
|
||||
1. Per-reference breakdown of **specific visual elements** extracted in Step 1.3
|
||||
2. Each element prefixed with "MUST" or "REQUIRED"
|
||||
3. An **integration approach** describing exactly how reference elements should be composed into the cover layout (e.g., "Use a split layout: illustration area 65% + dark branded banner 35%")
|
||||
|
||||
### 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
|
||||
**4.1 Backup existing** `cover.png` → `cover-backup-YYYYMMDD-HHMMSS.png` (if regenerating)
|
||||
|
||||
**4.2 Check available image generation skills**; if multiple, ask user preference
|
||||
|
||||
**4.3 Process References** ⚠️ REQUIRED if references in frontmatter
|
||||
|
||||
**Read `references` from prompt frontmatter** and process each entry:
|
||||
|
||||
1. **Parse frontmatter** to get references list:
|
||||
```yaml
|
||||
references:
|
||||
- ref_id: 01
|
||||
filename: refs/ref-01-podcast-thumbnail.jpg
|
||||
usage: style
|
||||
```
|
||||
|
||||
2. **VERIFY each file exists**:
|
||||
```bash
|
||||
test -f refs/ref-NN-{slug}.{ext} && echo "exists" || echo "MISSING"
|
||||
```
|
||||
- If file MISSING → ERROR, fix prompt or remove from references
|
||||
- If file exists → proceed with processing
|
||||
|
||||
3. Process based on `usage` type:
|
||||
|
||||
| Usage | Action | Example |
|
||||
|-------|--------|---------|
|
||||
| `direct` | Add reference path to `--ref` parameter | `--ref refs/ref-01-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..." |
|
||||
|
||||
3. Check image generation skill capability:
|
||||
|
||||
| Skill Supports `--ref` | Action |
|
||||
|------------------------|--------|
|
||||
| Yes (e.g., baoyu-image-gen with Google/OpenAI) | Pass reference images via `--ref` |
|
||||
| No | If usage is `direct`, switch to a ref-capable backend (baoyu-image-gen + Google multimodal or OpenAI GPT Image edits). If unavailable, convert to text description and remove direct refs |
|
||||
|
||||
**Verification**: Before generating, confirm reference processing:
|
||||
```
|
||||
Reference Processing:
|
||||
- ref-01-brand.png: using as direct reference ✓
|
||||
- ref-02-style.png: extracted palette ✓
|
||||
```
|
||||
|
||||
**4.4 Generate**
|
||||
|
||||
1. Call selected skill with prompt file path, output path (`cover.png`), aspect ratio
|
||||
2. If references with `direct` usage: use ref-capable backend and include `--ref` (recommend `baoyu-image-gen --provider google --model gemini-3-pro-image-preview` or `--provider openai --model gpt-image-1.5`)
|
||||
3. On failure: auto-retry once before reporting error
|
||||
|
||||
### Step 5: Completion Report
|
||||
|
||||
@@ -242,13 +456,17 @@ Cover Generated!
|
||||
|
||||
Topic: [topic]
|
||||
Type: [type] | Palette: [palette] | Rendering: [rendering]
|
||||
Text: [text] | Mood: [mood] | Aspect: [ratio]
|
||||
Text: [text] | Mood: [mood] | Font: [font] | Aspect: [ratio]
|
||||
Title: [title text or "visual only"]
|
||||
Language: [lang] | Watermark: [enabled/disabled]
|
||||
References: [N images (direct/style/palette) or "extracted style" or "none"]
|
||||
Location: [directory path]
|
||||
|
||||
Files:
|
||||
✓ source-{slug}.{ext}
|
||||
[✓ refs/ref-01-{slug}.{ext} ... (if references saved)]
|
||||
[✓ refs/ref-01-{slug}.md ... (description files)]
|
||||
[✓ refs/extracted-style.md (if style extracted verbally)]
|
||||
✓ prompts/cover.md
|
||||
✓ cover.png
|
||||
[✓ cover-backup-{timestamp}.png (if regenerated)]
|
||||
@@ -269,22 +487,45 @@ All modifications automatically backup existing `cover.png` before regenerating.
|
||||
|
||||
- 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`
|
||||
- Title: readable, impactful
|
||||
- Two confirmation points: Step 0 (first-time setup) + Step 2 (6 dimensions) - 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
|
||||
|
||||
### Reference Image Priority ⚠️
|
||||
|
||||
When user provides reference images, they are **HIGH PRIORITY** and MUST strongly influence the generated cover:
|
||||
|
||||
- **References override defaults**: If reference images conflict with preferred palette/rendering, the reference visual identity takes precedence in the prompt
|
||||
- **Concrete > abstract**: Extract specific, reproducible visual elements (exact patterns, logo treatments, color hex values, layout structures) — not vague descriptions like "clean style"
|
||||
- **Mandatory language**: Use "MUST", "REQUIRED", "CRITICAL" in the prompt for reference elements — generic "follow the style" is insufficient for image generation models
|
||||
- **Visible in output**: After generation, verify that reference elements are actually present in the cover. If not, strengthen the prompt and regenerate
|
||||
|
||||
### Composition Principles
|
||||
|
||||
- **Generous whitespace**: 40-60% breathing room; avoid cluttered layouts
|
||||
- **Visual anchor**: Main element centered or offset left (reserve right for title)
|
||||
- **Character handling**: Simplified silhouettes or icon-style figures; NO realistic humans
|
||||
- **Icon vocabulary**: Use simple, recognizable symbols (see [references/visual-elements.md](references/visual-elements.md))
|
||||
|
||||
### Title Handling
|
||||
|
||||
- **Source**: Use the exact title provided by user, or extract from source content
|
||||
- **Do NOT invent titles**: Stay faithful to the original
|
||||
- If no title in source and user doesn't provide one, ask user to specify
|
||||
|
||||
## 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/)
|
||||
**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)
|
||||
**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)
|
||||
**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,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);
|
||||
}
|
||||
}
|
||||
+1125
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");
|
||||
}
|
||||
@@ -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,9 +60,12 @@ 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
|
||||
|
||||
@@ -76,12 +81,12 @@ npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "一只可爱的猫" --image ou
|
||||
| `--promptfiles <files...>` | Read prompt from files (concatenated) |
|
||||
| `--image <path>` | Output image path (required) |
|
||||
| `--provider google\|openai\|dashscope` | Force provider (default: google) |
|
||||
| `--model <id>`, `-m` | Model ID |
|
||||
| `--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 |
|
||||
|
||||
@@ -99,13 +104,14 @@ npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "一只可爱的猫" --image ou
|
||||
| `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. Multiple 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
|
||||
|
||||
@@ -155,7 +161,7 @@ Supported: `1:1`, `16:9`, `9:16`, `4:3`, `3:4`, `2.35:1`
|
||||
- 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"
|
||||
---
|
||||
```
|
||||
@@ -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:
|
||||
@@ -20,7 +20,7 @@ Options:
|
||||
--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
|
||||
@@ -37,7 +37,7 @@ Environment variables:
|
||||
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 {
|
||||
@@ -49,7 +49,7 @@ function parseArgs(argv: string[]): CliArgs {
|
||||
model: null,
|
||||
aspectRatio: null,
|
||||
size: null,
|
||||
quality: "2k",
|
||||
quality: null,
|
||||
imageSize: null,
|
||||
referenceImages: [],
|
||||
n: 1,
|
||||
@@ -215,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) {
|
||||
@@ -242,12 +323,26 @@ 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 (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]!;
|
||||
@@ -259,11 +354,34 @@ function detectProvider(args: CliArgs): Provider {
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -283,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) {
|
||||
@@ -295,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;
|
||||
@@ -328,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(
|
||||
{
|
||||
|
||||
@@ -58,7 +58,9 @@ export async function generateImage(
|
||||
if (!apiKey) throw new Error("DASHSCOPE_API_KEY is required");
|
||||
|
||||
if (args.referenceImages.length > 0) {
|
||||
console.error("Warning: Reference images not yet supported with DashScope, ignoring.");
|
||||
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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 | `` |
|
||||
| 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: <,,>
|
||||
*/
|
||||
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
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
+1395
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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'")
|
||||
.replace(/`/g, "`");
|
||||
}
|
||||
|
||||
function buildAddition(): string {
|
||||
return `
|
||||
<style>
|
||||
.preview-wrapper pre::before {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
color: #ccc;
|
||||
text-align: center;
|
||||
font-size: 0.8em;
|
||||
padding: 5px 10px 0;
|
||||
line-height: 15px;
|
||||
height: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
`;
|
||||
}
|
||||
|
||||
function buildFootnoteArray(footnotes: [number, string, string][]): string {
|
||||
return footnotes
|
||||
.map(([index, title, link]) =>
|
||||
link === title
|
||||
? `<code style="font-size: 90%; opacity: 0.6;">[${index}]</code>: <i style="word-break: break-all">${title}</i><br/>`
|
||||
: `<code style="font-size: 90%; opacity: 0.6;">[${index}]</code> ${title}: <i style="word-break: break-all">${link}</i><br/>`
|
||||
)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function transform(legend: string, text: string | null, title: string | null): string {
|
||||
const options = legend.split("-");
|
||||
for (const option of options) {
|
||||
if (option === "alt" && text) {
|
||||
return text;
|
||||
}
|
||||
if (option === "title" && title) {
|
||||
return title;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
const macCodeSvg = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" x="0px" y="0px" width="45px" height="13px" viewBox="0 0 450 130">
|
||||
<ellipse cx="50" cy="65" rx="50" ry="52" stroke="rgb(220,60,54)" stroke-width="2" fill="rgb(237,108,96)" />
|
||||
<ellipse cx="225" cy="65" rx="50" ry="52" stroke="rgb(218,151,33)" stroke-width="2" fill="rgb(247,193,81)" />
|
||||
<ellipse cx="400" cy="65" rx="50" ry="52" stroke="rgb(27,161,37)" stroke-width="2" fill="rgb(100,200,86)" />
|
||||
</svg>
|
||||
`.trim();
|
||||
|
||||
function parseFrontMatterAndContent(markdownText: string): ParseResult {
|
||||
try {
|
||||
const parsed = frontMatter(markdownText);
|
||||
const yamlData = parsed.attributes;
|
||||
const markdownContent = parsed.body;
|
||||
|
||||
const readingTimeResult = readingTime(markdownContent);
|
||||
|
||||
return {
|
||||
yamlData: yamlData as Record<string, any>,
|
||||
markdownContent,
|
||||
readingTime: readingTimeResult,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error parsing front-matter:", error);
|
||||
return {
|
||||
yamlData: {},
|
||||
markdownContent: markdownText,
|
||||
readingTime: readingTime(markdownText),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function initRenderer(opts: IOpts = {}): RendererAPI {
|
||||
const footnotes: [number, string, string][] = [];
|
||||
let footnoteIndex = 0;
|
||||
let codeIndex = 0;
|
||||
const listOrderedStack: boolean[] = [];
|
||||
const listCounters: number[] = [];
|
||||
const isBrowser = typeof window !== "undefined";
|
||||
|
||||
function getOpts(): IOpts {
|
||||
return opts;
|
||||
}
|
||||
|
||||
function styledContent(styleLabel: string, content: string, tagName?: string): string {
|
||||
const tag = tagName ?? styleLabel;
|
||||
const className = `${styleLabel.replace(/_/g, "-")}`;
|
||||
const headingAttr = /^h\d$/.test(tag) ? " data-heading=\"true\"" : "";
|
||||
return `<${tag} class="${className}"${headingAttr}>${content}</${tag}>`;
|
||||
}
|
||||
|
||||
function addFootnote(title: string, link: string): number {
|
||||
const existingFootnote = footnotes.find(([, , existingLink]) => existingLink === link);
|
||||
if (existingFootnote) {
|
||||
return existingFootnote[0];
|
||||
}
|
||||
|
||||
footnotes.push([++footnoteIndex, title, link]);
|
||||
return footnoteIndex;
|
||||
}
|
||||
|
||||
function reset(newOpts: Partial<IOpts>): void {
|
||||
footnotes.length = 0;
|
||||
footnoteIndex = 0;
|
||||
setOptions(newOpts);
|
||||
}
|
||||
|
||||
function setOptions(newOpts: Partial<IOpts>): void {
|
||||
opts = { ...opts, ...newOpts };
|
||||
marked.use(markedAlert());
|
||||
if (isBrowser) {
|
||||
marked.use(MDKatex({ nonStandard: true }, true));
|
||||
}
|
||||
marked.use(markedMarkup());
|
||||
marked.use(markedInfographic({ themeMode: opts.themeMode }));
|
||||
}
|
||||
|
||||
function buildReadingTime(readingTimeResult: ReadTimeResults): string {
|
||||
if (!opts.countStatus) {
|
||||
return "";
|
||||
}
|
||||
if (!readingTimeResult.words) {
|
||||
return "";
|
||||
}
|
||||
return `
|
||||
<blockquote class="md-blockquote">
|
||||
<p class="md-blockquote-p">字数 ${readingTimeResult?.words},阅读大约需 ${Math.ceil(readingTimeResult?.minutes)} 分钟</p>
|
||||
</blockquote>
|
||||
`;
|
||||
}
|
||||
|
||||
const buildFootnotes = () => {
|
||||
if (!footnotes.length) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return (
|
||||
styledContent("h4", "引用链接")
|
||||
+ styledContent("footnotes", buildFootnoteArray(footnotes), "p")
|
||||
);
|
||||
};
|
||||
|
||||
const renderer: RendererObject = {
|
||||
heading({ tokens, depth }: Tokens.Heading) {
|
||||
const text = this.parser.parseInline(tokens);
|
||||
const tag = `h${depth}`;
|
||||
return styledContent(tag, text);
|
||||
},
|
||||
|
||||
paragraph({ tokens }: Tokens.Paragraph): string {
|
||||
const text = this.parser.parseInline(tokens);
|
||||
const isFigureImage = text.includes("<figure") && text.includes("<img");
|
||||
const isEmpty = text.trim() === "";
|
||||
if (isFigureImage || isEmpty) {
|
||||
return text;
|
||||
}
|
||||
return styledContent("p", text);
|
||||
},
|
||||
|
||||
blockquote({ tokens }: Tokens.Blockquote): string {
|
||||
const text = this.parser.parse(tokens);
|
||||
return styledContent("blockquote", text);
|
||||
},
|
||||
|
||||
code({ text, lang = "" }: Tokens.Code): string {
|
||||
if (lang.startsWith("mermaid")) {
|
||||
if (isBrowser) {
|
||||
clearTimeout(codeIndex as any);
|
||||
codeIndex = setTimeout(async () => {
|
||||
const windowRef = typeof window !== "undefined" ? (window as any) : undefined;
|
||||
if (windowRef && windowRef.mermaid) {
|
||||
const mermaid = windowRef.mermaid;
|
||||
await mermaid.run();
|
||||
} else {
|
||||
const mermaid = await import("mermaid");
|
||||
await mermaid.default.run();
|
||||
}
|
||||
}, 0) as any as number;
|
||||
}
|
||||
return `<pre class="mermaid">${text}</pre>`;
|
||||
}
|
||||
const langText = lang.split(" ")[0];
|
||||
const isLanguageRegistered = hljs.getLanguage(langText);
|
||||
const language = isLanguageRegistered ? langText : "plaintext";
|
||||
|
||||
const highlighted = highlightAndFormatCode(
|
||||
text,
|
||||
language,
|
||||
hljs,
|
||||
!!opts.isShowLineNumber
|
||||
);
|
||||
|
||||
const span = `<span class="mac-sign" style="padding: 10px 14px 0;">${macCodeSvg}</span>`;
|
||||
let pendingAttr = "";
|
||||
if (!isLanguageRegistered && langText !== "plaintext") {
|
||||
const escapedText = text.replace(/"/g, """);
|
||||
pendingAttr = ` data-language-pending="${langText}" data-raw-code="${escapedText}" data-show-line-number="${opts.isShowLineNumber}"`;
|
||||
}
|
||||
const code = `<code class="language-${lang}"${pendingAttr}>${highlighted}</code>`;
|
||||
|
||||
return `<pre class="hljs code__pre">${span}${code}</pre>`;
|
||||
},
|
||||
|
||||
codespan({ text }: Tokens.Codespan): string {
|
||||
const escapedText = escapeHtml(text);
|
||||
return styledContent("codespan", escapedText, "code");
|
||||
},
|
||||
|
||||
list({ ordered, items, start = 1 }: Tokens.List) {
|
||||
listOrderedStack.push(ordered);
|
||||
listCounters.push(Number(start));
|
||||
|
||||
const html = items.map((item) => this.listitem(item)).join("");
|
||||
|
||||
listOrderedStack.pop();
|
||||
listCounters.pop();
|
||||
|
||||
return styledContent(ordered ? "ol" : "ul", html);
|
||||
},
|
||||
|
||||
listitem(token: Tokens.ListItem) {
|
||||
const ordered = listOrderedStack[listOrderedStack.length - 1];
|
||||
const idx = listCounters[listCounters.length - 1]!;
|
||||
|
||||
listCounters[listCounters.length - 1] = idx + 1;
|
||||
|
||||
const prefix = ordered ? `${idx}. ` : "• ";
|
||||
|
||||
let content: string;
|
||||
try {
|
||||
content = this.parser.parseInline(token.tokens);
|
||||
} catch {
|
||||
content = this.parser
|
||||
.parse(token.tokens)
|
||||
.replace(/^<p(?:\s[^>]*)?>([\s\S]*?)<\/p>/, "$1");
|
||||
}
|
||||
|
||||
return styledContent("listitem", `${prefix}${content}`, "li");
|
||||
},
|
||||
|
||||
image({ href, title, text }: Tokens.Image): string {
|
||||
const newText = opts.legend ? transform(opts.legend, text, title) : "";
|
||||
const subText = newText ? styledContent("figcaption", newText) : "";
|
||||
const titleAttr = title ? ` title="${title}"` : "";
|
||||
return `<figure><img src="${href}"${titleAttr} alt="${text}"/>${subText}</figure>`;
|
||||
},
|
||||
|
||||
link({ href, title, text, tokens }: Tokens.Link): string {
|
||||
const parsedText = this.parser.parseInline(tokens);
|
||||
if (/^https?:\/\/mp\.weixin\.qq\.com/.test(href)) {
|
||||
return `<a href="${href}" title="${title || text}">${parsedText}</a>`;
|
||||
}
|
||||
if (href === text) {
|
||||
return parsedText;
|
||||
}
|
||||
if (opts.citeStatus) {
|
||||
const ref = addFootnote(title || text, href);
|
||||
return `<a href="${href}" title="${title || text}">${parsedText}<sup>[${ref}]</sup></a>`;
|
||||
}
|
||||
return `<a href="${href}" title="${title || text}">${parsedText}</a>`;
|
||||
},
|
||||
|
||||
strong({ tokens }: Tokens.Strong): string {
|
||||
return styledContent("strong", this.parser.parseInline(tokens));
|
||||
},
|
||||
|
||||
em({ tokens }: Tokens.Em): string {
|
||||
return styledContent("em", this.parser.parseInline(tokens));
|
||||
},
|
||||
|
||||
table({ header, rows }: Tokens.Table): string {
|
||||
const headerRow = header
|
||||
.map((cell) => {
|
||||
const text = this.parser.parseInline(cell.tokens);
|
||||
return styledContent("th", text);
|
||||
})
|
||||
.join("");
|
||||
const body = rows
|
||||
.map((row) => {
|
||||
const rowContent = row.map((cell) => this.tablecell(cell)).join("");
|
||||
return styledContent("tr", rowContent);
|
||||
})
|
||||
.join("");
|
||||
return `
|
||||
<section style="max-width: 100%; overflow: auto">
|
||||
<table class="preview-table">
|
||||
<thead>${headerRow}</thead>
|
||||
<tbody>${body}</tbody>
|
||||
</table>
|
||||
</section>
|
||||
`;
|
||||
},
|
||||
|
||||
tablecell(token: Tokens.TableCell): string {
|
||||
const text = this.parser.parseInline(token.tokens);
|
||||
return styledContent("td", text);
|
||||
},
|
||||
|
||||
hr(_: Tokens.Hr): string {
|
||||
return styledContent("hr", "");
|
||||
},
|
||||
};
|
||||
|
||||
marked.use({ renderer });
|
||||
marked.use(markedMarkup());
|
||||
marked.use(markedToc());
|
||||
marked.use(markedSlider());
|
||||
marked.use(markedAlert({}));
|
||||
if (isBrowser) {
|
||||
marked.use(MDKatex({ nonStandard: true }, true));
|
||||
}
|
||||
marked.use(markedFootnotes());
|
||||
marked.use(
|
||||
markedPlantUML({
|
||||
inlineSvg: isBrowser,
|
||||
})
|
||||
);
|
||||
marked.use(markedInfographic());
|
||||
marked.use(markedRuby());
|
||||
|
||||
return {
|
||||
buildAddition,
|
||||
buildFootnotes,
|
||||
setOptions,
|
||||
reset,
|
||||
parseFrontMatterAndContent,
|
||||
buildReadingTime,
|
||||
createContainer(content: string) {
|
||||
return styledContent("container", content, "section");
|
||||
},
|
||||
getOpts,
|
||||
};
|
||||
}
|
||||
|
||||
function printUsage(): void {
|
||||
console.error(
|
||||
[
|
||||
"Usage:",
|
||||
" npx tsx src/md/render.ts <markdown_file> [--theme <name>]",
|
||||
"",
|
||||
"Options:",
|
||||
` --theme Theme name (${THEME_NAMES.join(", ")})`,
|
||||
].join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): CliOptions | null {
|
||||
let inputPath = "";
|
||||
let theme: ThemeName = "default";
|
||||
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/>,并将空格转换为
|
||||
formatted = formatted.replace(/\r\n/g, `<br/>`).replace(/\n/g, `<br/>`).replace(/(>[^<]+)|(^[^<]+)/g, (str: string) => str.replace(/\s/g, ` `))
|
||||
}
|
||||
else {
|
||||
// 只将空格转换为
|
||||
formatted = formatted.replace(/(>[^<]+)|(^[^<]+)/g, (str: string) => str.replace(/\s/g, ` `))
|
||||
}
|
||||
|
||||
return formatted
|
||||
}
|
||||
|
||||
/**
|
||||
* 高亮代码并格式化(支持行号)
|
||||
* @param text 原始代码文本
|
||||
* @param language 语言名称
|
||||
* @param hljs highlight.js 实例
|
||||
* @param showLineNumber 是否显示行号
|
||||
* @returns 格式化后的 HTML
|
||||
*/
|
||||
export function highlightAndFormatCode(text: string, language: string, hljs: any, showLineNumber: boolean): string {
|
||||
let highlighted = ``
|
||||
|
||||
if (showLineNumber) {
|
||||
const rawLines = text.replace(/\r\n/g, `\n`).split(`\n`)
|
||||
|
||||
const highlightedLines = rawLines.map((lineRaw) => {
|
||||
const lineHtml = hljs.highlight(lineRaw, { language }).value
|
||||
const formatted = formatHighlightedCode(lineHtml, false)
|
||||
return formatted === `` ? ` ` : formatted
|
||||
})
|
||||
|
||||
const lineNumbersHtml = highlightedLines.map((_, idx) => `<section style="padding:0 10px 0 0;line-height:1.75">${idx + 1}</section>`).join(``)
|
||||
const codeInnerHtml = highlightedLines.join(`<br/>`)
|
||||
const codeLinesHtml = `<div style="white-space:pre;min-width:max-content;line-height:1.75">${codeInnerHtml}</div>`
|
||||
const lineNumberColumnStyles = `text-align:right;padding:8px 0;border-right:1px solid rgba(0,0,0,0.04);user-select:none;background:var(--code-bg,transparent);`
|
||||
|
||||
highlighted = `
|
||||
<section style="display:flex;align-items:flex-start;overflow-x:hidden;overflow-y:auto;width:100%;max-width:100%;padding:0;box-sizing:border-box">
|
||||
<section class="line-numbers" style="${lineNumberColumnStyles}">${lineNumbersHtml}</section>
|
||||
<section class="code-scroll" style="flex:1 1 auto;overflow-x:auto;overflow-y:visible;padding:8px;min-width:0;box-sizing:border-box">${codeLinesHtml}</section>
|
||||
</section>
|
||||
`
|
||||
}
|
||||
else {
|
||||
const rawHighlighted = hljs.highlight(text, { language }).value
|
||||
highlighted = formatHighlightedCode(rawHighlighted, true)
|
||||
}
|
||||
|
||||
return highlighted
|
||||
}
|
||||
|
||||
export function highlightCodeBlock(codeBlock: Element, language: string, hljs: any): void {
|
||||
const rawCode = codeBlock.getAttribute(`data-raw-code`)
|
||||
const showLineNumber = codeBlock.getAttribute(`data-show-line-number`) === `true`
|
||||
|
||||
if (!rawCode)
|
||||
return
|
||||
|
||||
const text = rawCode.replace(/"/g, `"`)
|
||||
|
||||
const highlighted = highlightAndFormatCode(text, language, hljs, showLineNumber)
|
||||
|
||||
codeBlock.innerHTML = highlighted
|
||||
codeBlock.removeAttribute(`data-language-pending`)
|
||||
codeBlock.removeAttribute(`data-raw-code`)
|
||||
codeBlock.removeAttribute(`data-show-line-number`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 高亮 DOM 中待处理的代码块
|
||||
* 查找带有 data-language-pending 属性的代码块,动态加载语言后重新高亮
|
||||
* @param hljs highlight.js 实例
|
||||
* @param container 容器元素(可选,默认为 document)
|
||||
*/
|
||||
export function highlightPendingBlocks(hljs: any, container: Document | Element = document): void {
|
||||
const pendingBlocks = container.querySelectorAll(`code[data-language-pending]`)
|
||||
|
||||
pendingBlocks.forEach((codeBlock) => {
|
||||
const language = codeBlock.getAttribute(`data-language-pending`)
|
||||
if (!language)
|
||||
return
|
||||
|
||||
if (hljs.getLanguage(language)) {
|
||||
// 语言已加载,直接高亮
|
||||
highlightCodeBlock(codeBlock, language, hljs)
|
||||
}
|
||||
else {
|
||||
// 动态加载语言后重新高亮
|
||||
loadAndRegisterLanguage(language, hljs).then(() => {
|
||||
highlightCodeBlock(codeBlock, language, hljs)
|
||||
}).catch(() => {
|
||||
// 加载失败,移除标记
|
||||
codeBlock.removeAttribute(`data-language-pending`)
|
||||
codeBlock.removeAttribute(`data-raw-code`)
|
||||
codeBlock.removeAttribute(`data-show-line-number`)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -120,22 +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 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]!;
|
||||
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;
|
||||
@@ -161,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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -115,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}`;
|
||||
|
||||
@@ -128,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: `
|
||||
@@ -176,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 = '';
|
||||
@@ -203,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> {
|
||||
@@ -213,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;
|
||||
@@ -246,6 +315,71 @@ 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, cdpPort } = options;
|
||||
let { contentImages = [] } = options;
|
||||
@@ -273,9 +407,13 @@ 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)`);
|
||||
@@ -381,11 +519,6 @@ 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 }));`);
|
||||
}
|
||||
|
||||
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 }));`);
|
||||
}
|
||||
|
||||
await sleep(500);
|
||||
|
||||
if (effectiveTitle) {
|
||||
@@ -397,15 +530,6 @@ export async function postArticle(options: ArticleOptions): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
if (effectiveSummary) {
|
||||
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] Clicking on editor...');
|
||||
await clickElement(session, '.ProseMirror');
|
||||
await sleep(1000);
|
||||
@@ -416,7 +540,7 @@ 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);
|
||||
@@ -461,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.');
|
||||
}
|
||||
@@ -472,6 +597,7 @@ export async function postArticle(options: ArticleOptions): Promise<void> {
|
||||
await sleep(500);
|
||||
await pasteInEditor(session);
|
||||
await sleep(2000);
|
||||
await removeExtraEmptyLineAfterImage(session);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -494,6 +620,30 @@ export async function postArticle(options: ArticleOptions): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
if (effectiveSummary) {
|
||||
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...');
|
||||
await evaluate(session, `document.querySelector('#js_submit button').click()`);
|
||||
await sleep(3000);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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):
|
||||
@@ -417,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
|
||||
Reference in New Issue
Block a user