mirror of
https://github.com/JimLiu/baoyu-skills.git
synced 2026-07-12 22:09:48 +08:00
Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7cf30e969f | |||
| 18b71157db | |||
| 9904c490e4 | |||
| 32ed09128c | |||
| 81e96ee43c | |||
| 5f994589b3 | |||
| 40363194be | |||
| 21a5167b61 | |||
| 4d3957ca06 | |||
| 013c72fce7 | |||
| 40fadcb1f6 | |||
| 499a1ee478 | |||
| fa89eaf2f7 | |||
| 7964e20bc3 | |||
| 45c109ce88 | |||
| 74c08def06 | |||
| 907c8ab852 | |||
| 64945f3341 | |||
| 050d37c344 | |||
| f9f168fc1f | |||
| b1af3a3e45 | |||
| 0727296592 | |||
| 0e571b72fb | |||
| 366630f8c3 | |||
| 6bfafe0ec5 |
@@ -6,7 +6,7 @@
|
||||
},
|
||||
"metadata": {
|
||||
"description": "Skills shared by Baoyu for improving daily work efficiency",
|
||||
"version": "1.23.0"
|
||||
"version": "1.25.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"
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -114,11 +114,17 @@ Display version change: `1.2.3 → 1.3.0`
|
||||
For each detected changelog file:
|
||||
|
||||
1. **Identify language** from filename suffix
|
||||
2. **Generate content in that language**:
|
||||
2. **Detect third-party contributors**:
|
||||
- Check merge commits: `git log ${LAST_TAG}..HEAD --merges --pretty=format:"%H %s"`
|
||||
- For each merged PR, identify the PR author via `gh pr view <number> --json author --jq '.author.login'`
|
||||
- Compare against repo owner (`gh repo view --json owner --jq '.owner.login'`)
|
||||
- If PR author ≠ repo owner → third-party contributor
|
||||
3. **Generate content in that language**:
|
||||
- Section titles in target language
|
||||
- Change descriptions written naturally in target language (not translated)
|
||||
- Date format: YYYY-MM-DD (universal)
|
||||
3. **Insert at file head** (preserve existing content)
|
||||
- **Third-party contributions**: Append contributor attribution `(by @username)` to the changelog entry
|
||||
4. **Insert at file head** (preserve existing content)
|
||||
|
||||
**Section Title Translations** (built-in):
|
||||
|
||||
@@ -138,6 +144,7 @@ For each detected changelog file:
|
||||
|
||||
### Features
|
||||
- Description of new feature
|
||||
- Description of third-party contribution (by @username)
|
||||
|
||||
### Fixes
|
||||
- Description of fix
|
||||
@@ -148,6 +155,12 @@ For each detected changelog file:
|
||||
|
||||
Only include sections that have changes. Omit empty sections.
|
||||
|
||||
**Third-Party Attribution Rules**:
|
||||
- Only add `(by @username)` for contributors who are NOT the repo owner
|
||||
- Use GitHub username with `@` prefix
|
||||
- Place at the end of the changelog entry line
|
||||
- Apply to all languages consistently (always use `(by @username)` format, not translated)
|
||||
|
||||
**Multi-language Example**:
|
||||
|
||||
English (CHANGELOG.md):
|
||||
@@ -155,7 +168,7 @@ English (CHANGELOG.md):
|
||||
## 1.3.0 - 2026-01-22
|
||||
|
||||
### Features
|
||||
- Add user authentication module
|
||||
- Add user authentication module (by @contributor1)
|
||||
- Support OAuth2 login
|
||||
|
||||
### Fixes
|
||||
@@ -167,7 +180,7 @@ Chinese (CHANGELOG.zh.md):
|
||||
## 1.3.0 - 2026-01-22
|
||||
|
||||
### 新功能
|
||||
- 新增用户认证模块
|
||||
- 新增用户认证模块 (by @contributor1)
|
||||
- 支持 OAuth2 登录
|
||||
|
||||
### 修复
|
||||
@@ -179,7 +192,7 @@ Japanese (CHANGELOG.ja.md):
|
||||
## 1.3.0 - 2026-01-22
|
||||
|
||||
### 新機能
|
||||
- ユーザー認証モジュールを追加
|
||||
- ユーザー認証モジュールを追加 (by @contributor1)
|
||||
- OAuth2 ログインをサポート
|
||||
|
||||
### 修正
|
||||
|
||||
@@ -152,3 +152,8 @@ slide-deck/
|
||||
infographic/
|
||||
illustrations/
|
||||
comic/
|
||||
### IntelliJ IDEA ###
|
||||
.idea
|
||||
*.iws
|
||||
*.iml
|
||||
*.ipr
|
||||
+59
-4
@@ -2,6 +2,58 @@
|
||||
|
||||
English | [中文](./CHANGELOG.zh.md)
|
||||
|
||||
## 1.25.1 - 2026-01-28
|
||||
|
||||
### Features
|
||||
- `baoyu-markdown-to-html`: add pre-check step for CJK content to suggest formatting with `baoyu-format-markdown` before conversion.
|
||||
|
||||
## 1.25.0 - 2026-01-28
|
||||
|
||||
### Features
|
||||
- `baoyu-format-markdown`: add markdown formatter skill with frontmatter, typography, and CJK spacing support.
|
||||
- `baoyu-markdown-to-html`: add markdown to HTML converter with WeChat-compatible themes, code highlighting, math, PlantUML, and alerts.
|
||||
- `baoyu-post-to-wechat`: add API-based publishing method and external theme support.
|
||||
|
||||
## 1.24.4 - 2026-01-28
|
||||
|
||||
### Fixes
|
||||
- `baoyu-post-to-x`: fix Apply button click for cover image modal; add retry logic and wait for modal close.
|
||||
|
||||
## 1.24.3 - 2026-01-28
|
||||
|
||||
### Documentation
|
||||
- Emphasize updating prompt files before regenerating images in modification workflows (article-illustrator, slide-deck, xhs-images, cover-image, comic).
|
||||
|
||||
## 1.24.2 - 2026-01-28
|
||||
|
||||
### Refactor
|
||||
- `baoyu-image-gen`: default to sequential generation; parallel available on request.
|
||||
|
||||
## 1.24.1 - 2026-01-28
|
||||
|
||||
### Features
|
||||
- `baoyu-image-gen`: add Aliyun Tongyi Wanxiang (DashScope) text-to-image model support (by @JianJang2017).
|
||||
|
||||
### Documentation
|
||||
- Add Aliyun text-to-image model configuration to README.
|
||||
|
||||
## 1.24.0 - 2026-01-27
|
||||
|
||||
### Features
|
||||
- `baoyu-post-to-wechat`: reuse existing Chrome browser instead of requiring all windows closed (by @AliceLJY).
|
||||
|
||||
### Fixes
|
||||
- `baoyu-post-to-wechat`: improves title extraction to support h1/h2 headings; adds summary auto-fill and content verification after paste/type; supports flexible HTML meta tag attribute ordering.
|
||||
|
||||
### Documentation
|
||||
- `release-skills`: adds third-party contributor attribution rules to changelog workflow.
|
||||
- Backfills missing third-party contributor attributions across historical changelog entries.
|
||||
|
||||
## 1.23.1 - 2026-01-27
|
||||
|
||||
### Fixes
|
||||
- `baoyu-compress-image`: rename original file as `_original` backup instead of deleting after compression.
|
||||
|
||||
## 1.23.0 - 2026-01-26
|
||||
|
||||
### Refactor
|
||||
@@ -20,7 +72,7 @@ English | [中文](./CHANGELOG.zh.md)
|
||||
## 1.21.4 - 2026-01-25
|
||||
|
||||
### Fixes
|
||||
- `baoyu-post-to-wechat`: adds Windows compatibility—uses `fileURLToPath` for correct path resolution, replaces system-dependent copy/paste tools (osascript/xdotool) with CDP keyboard events for cross-platform support.
|
||||
- `baoyu-post-to-wechat`: adds Windows compatibility—uses `fileURLToPath` for correct path resolution, replaces system-dependent copy/paste tools (osascript/xdotool) with CDP keyboard events for cross-platform support (by @JadeLiang003).
|
||||
- `baoyu-post-to-wechat`: fixes regressions from Windows compatibility PR—corrects broken `-fixed` filename references, restores frontmatter quote stripping, restores `--title` CLI parameter, fixes summary extraction to skip headings/quotes/lists, fixes argument parsing for single-dash flags, removes debug logs.
|
||||
- `baoyu-article-illustrator`, `baoyu-cover-image`, `baoyu-xhs-images`: removes opacity option from watermark configuration.
|
||||
|
||||
@@ -170,7 +222,7 @@ English | [中文](./CHANGELOG.zh.md)
|
||||
## 1.14.0 - 2026-01-22
|
||||
|
||||
### Fixes
|
||||
- `baoyu-post-to-x`: improves video ready detection for more reliable video posting.
|
||||
- `baoyu-post-to-x`: improves video ready detection for more reliable video posting (by @fkysly).
|
||||
|
||||
### Documentation
|
||||
- `baoyu-slide-deck`: comprehensive SKILL.md enhancement—adds slide count guidance (recommended 8-25, max 30), audience guidelines table with audience-specific principles, style selection principles with content-type recommendations, layout selection tips with common mistakes to avoid, visual hierarchy principles, content density guidelines (McKinsey-style high-density principles), color selection guide, typography principles with font recommendations (English and Chinese fonts with multilingual pairing), and visual elements reference (backgrounds, typography treatments, geometric accents).
|
||||
@@ -185,6 +237,9 @@ English | [中文](./CHANGELOG.zh.md)
|
||||
|
||||
## 1.12.0 - 2026-01-21
|
||||
|
||||
### Features
|
||||
- `baoyu-post-to-x`: adds quote tweet support (by @threehotpot-bot).
|
||||
|
||||
### Refactor
|
||||
- `baoyu-post-to-x`: extracts shared utilities to `x-utils.ts`—consolidates Chrome detection, CDP connection, clipboard operations, and helper functions from `x-article.ts`, `x-browser.ts`, `x-quote.ts`, and `x-video.ts` into a single reusable module.
|
||||
|
||||
@@ -200,7 +255,7 @@ English | [中文](./CHANGELOG.zh.md)
|
||||
## 1.10.0 - 2026-01-21
|
||||
|
||||
### Features
|
||||
- `baoyu-post-to-x`: adds video posting support—new `x-video.ts` script for posting text with video files (MP4, MOV, WebM). Supports preview mode and handles video processing timeouts.
|
||||
- `baoyu-post-to-x`: adds video posting support—new `x-video.ts` script for posting text with video files (MP4, MOV, WebM). Supports preview mode and handles video processing timeouts (by @fkysly).
|
||||
|
||||
## 1.9.0 - 2026-01-20
|
||||
|
||||
@@ -256,7 +311,7 @@ English | [中文](./CHANGELOG.zh.md)
|
||||
## 1.4.1 - 2026-01-18
|
||||
|
||||
### Fixes
|
||||
- `baoyu-post-to-x`: supports multi-language UI selectors for X Articles (contributed by [@ianchenx](https://github.com/ianchenx)).
|
||||
- `baoyu-post-to-x`: supports multi-language UI selectors for X Articles (by @ianchenx).
|
||||
|
||||
## 1.4.0 - 2026-01-18
|
||||
|
||||
|
||||
+59
-4
@@ -2,6 +2,58 @@
|
||||
|
||||
[English](./CHANGELOG.md) | 中文
|
||||
|
||||
## 1.25.1 - 2026-01-28
|
||||
|
||||
### 新功能
|
||||
- `baoyu-markdown-to-html`:新增中文内容预检查,建议在转换前使用 `baoyu-format-markdown` 格式化以修复加粗标点问题。
|
||||
|
||||
## 1.25.0 - 2026-01-28
|
||||
|
||||
### 新功能
|
||||
- `baoyu-format-markdown`:新增 markdown 格式化技能,支持 frontmatter、排版优化和中英文空格处理。
|
||||
- `baoyu-markdown-to-html`:新增 markdown 转 HTML 技能,支持微信兼容主题、代码高亮、数学公式、PlantUML 和 alerts。
|
||||
- `baoyu-post-to-wechat`:新增 API 发布方式和外部主题支持。
|
||||
|
||||
## 1.24.4 - 2026-01-28
|
||||
|
||||
### 修复
|
||||
- `baoyu-post-to-x`:修复封面图上传后 Apply 按钮点击问题;增加重试逻辑并等待弹窗关闭后再继续。
|
||||
|
||||
## 1.24.3 - 2026-01-28
|
||||
|
||||
### 文档
|
||||
- 在修改工作流中强调先更新提示词文件再生成图片(article-illustrator、slide-deck、xhs-images、cover-image、comic)。
|
||||
|
||||
## 1.24.2 - 2026-01-28
|
||||
|
||||
### 重构
|
||||
- `baoyu-image-gen`:默认改为顺序生成图片;并行生成需明确请求。
|
||||
|
||||
## 1.24.1 - 2026-01-28
|
||||
|
||||
### 新功能
|
||||
- `baoyu-image-gen`:新增阿里云通义万象(DashScope)文生图模型支持 (by @JianJang2017)。
|
||||
|
||||
### 文档
|
||||
- README 中新增阿里云文生图模型配置说明。
|
||||
|
||||
## 1.24.0 - 2026-01-27
|
||||
|
||||
### 新功能
|
||||
- `baoyu-post-to-wechat`:复用已打开的 Chrome 浏览器,无需关闭所有窗口 (by @AliceLJY)。
|
||||
|
||||
### 修复
|
||||
- `baoyu-post-to-wechat`:改进标题提取,支持 h1/h2 标题;新增摘要自动填充和粘贴/输入后内容验证;支持 HTML meta 标签属性顺序灵活匹配。
|
||||
|
||||
### 文档
|
||||
- `release-skills`:在发布流程中新增第三方贡献者署名规则。
|
||||
- 补全历史 changelog 中缺失的第三方贡献者署名。
|
||||
|
||||
## 1.23.1 - 2026-01-27
|
||||
|
||||
### 修复
|
||||
- `baoyu-compress-image`:压缩后将原始文件重命名为 `_original` 备份,不再删除。
|
||||
|
||||
## 1.23.0 - 2026-01-26
|
||||
|
||||
### 重构
|
||||
@@ -20,7 +72,7 @@
|
||||
## 1.21.4 - 2026-01-25
|
||||
|
||||
### 修复
|
||||
- `baoyu-post-to-wechat`:新增 Windows 兼容性——使用 `fileURLToPath` 正确解析路径,将系统依赖的复制粘贴工具(osascript/xdotool)替换为 CDP 键盘事件,实现跨平台支持。
|
||||
- `baoyu-post-to-wechat`:新增 Windows 兼容性——使用 `fileURLToPath` 正确解析路径,将系统依赖的复制粘贴工具(osascript/xdotool)替换为 CDP 键盘事件,实现跨平台支持 (by @JadeLiang003)。
|
||||
- `baoyu-post-to-wechat`:修复 Windows 兼容性 PR 引入的回退问题——修正错误的 `-fixed` 文件名引用、恢复 frontmatter 引号剥离、恢复 `--title` CLI 参数、修复摘要提取逻辑以正确跳过标题/引用/列表、修复单横线参数解析、移除调试日志。
|
||||
- `baoyu-article-illustrator`、`baoyu-cover-image`、`baoyu-xhs-images`:移除水印配置中的透明度选项。
|
||||
|
||||
@@ -170,7 +222,7 @@
|
||||
## 1.14.0 - 2026-01-22
|
||||
|
||||
### 修复
|
||||
- `baoyu-post-to-x`:改进视频就绪检测,提升视频发布稳定性。
|
||||
- `baoyu-post-to-x`:改进视频就绪检测,提升视频发布稳定性 (by @fkysly)。
|
||||
|
||||
### 文档
|
||||
- `baoyu-slide-deck`:SKILL.md 全面增强——新增幻灯片数量指南(推荐 8-25 张,最多 30 张)、受众指南表格及各受众特定原则、风格选择原则与内容类型推荐、布局选择技巧与常见错误提示、视觉层次原则、内容密度指南(麦肯锡风格高密度原则)、配色选择指南、字体排版原则与字体推荐(中英文字体及多语言搭配方案)、视觉元素参考(背景处理、字体处理、几何装饰)。
|
||||
@@ -185,6 +237,9 @@
|
||||
|
||||
## 1.12.0 - 2026-01-21
|
||||
|
||||
### 新功能
|
||||
- `baoyu-post-to-x`:新增引用推文(Quote Tweet)支持 (by @threehotpot-bot)。
|
||||
|
||||
### 重构
|
||||
- `baoyu-post-to-x`:提取公共工具函数到 `x-utils.ts`——将 `x-article.ts`、`x-browser.ts`、`x-quote.ts`、`x-video.ts` 中重复的 Chrome 检测、CDP 连接、剪贴板操作等功能整合为统一的可复用模块。
|
||||
|
||||
@@ -200,7 +255,7 @@
|
||||
## 1.10.0 - 2026-01-21
|
||||
|
||||
### 新功能
|
||||
- `baoyu-post-to-x`:新增视频发布支持——新增 `x-video.ts` 脚本,支持发布带视频的推文(MP4、MOV、WebM 格式)。支持预览模式,自动处理视频上传等待。
|
||||
- `baoyu-post-to-x`:新增视频发布支持——新增 `x-video.ts` 脚本,支持发布带视频的推文(MP4、MOV、WebM 格式)。支持预览模式,自动处理视频上传等待 (by @fkysly)。
|
||||
|
||||
## 1.9.0 - 2026-01-20
|
||||
|
||||
@@ -256,7 +311,7 @@
|
||||
## 1.4.1 - 2026-01-18
|
||||
|
||||
### 修复
|
||||
- `baoyu-post-to-x`:支持 X Articles 多语言 UI 选择器(感谢 [@ianchenx](https://github.com/ianchenx) 贡献)。
|
||||
- `baoyu-post-to-x`:支持 X Articles 多语言 UI 选择器 (by @ianchenx)。
|
||||
|
||||
## 1.4.0 - 2026-01-18
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -543,7 +543,7 @@ AI-powered generation backends.
|
||||
|
||||
#### baoyu-image-gen
|
||||
|
||||
AI SDK-based image generation using official OpenAI and Google APIs. Supports text-to-image, reference images, aspect ratios, and quality presets.
|
||||
AI SDK-based image generation using official OpenAI, Google and DashScope (Aliyun Tongyi Wanxiang) APIs. Supports text-to-image, reference images, aspect ratios, and quality presets.
|
||||
|
||||
```bash
|
||||
# Basic generation (auto-detect provider)
|
||||
@@ -558,6 +558,9 @@ AI SDK-based image generation using official OpenAI and Google APIs. Supports te
|
||||
# Specific provider
|
||||
/baoyu-image-gen --prompt "A cat" --image cat.png --provider openai
|
||||
|
||||
# DashScope (Aliyun Tongyi Wanxiang)
|
||||
/baoyu-image-gen --prompt "一只可爱的猫" --image cat.png --provider dashscope
|
||||
|
||||
# With reference images (Google multimodal only)
|
||||
/baoyu-image-gen --prompt "Make it blue" --image out.png --ref source.png
|
||||
```
|
||||
@@ -568,7 +571,7 @@ AI SDK-based image generation using official OpenAI and Google APIs. Supports te
|
||||
| `--prompt`, `-p` | Prompt text |
|
||||
| `--promptfiles` | Read prompt from files (concatenated) |
|
||||
| `--image` | Output image path (required) |
|
||||
| `--provider` | `google` or `openai` (default: google) |
|
||||
| `--provider` | `google`, `openai` or `dashscope` (default: google) |
|
||||
| `--model`, `-m` | Model ID |
|
||||
| `--ar` | Aspect ratio (e.g., `16:9`, `1:1`, `4:3`) |
|
||||
| `--size` | Size (e.g., `1024x1024`) |
|
||||
@@ -580,15 +583,18 @@ AI SDK-based image generation using official OpenAI and Google APIs. Supports te
|
||||
|----------|-------------|---------|
|
||||
| `OPENAI_API_KEY` | OpenAI API key | - |
|
||||
| `GOOGLE_API_KEY` | Google API key | - |
|
||||
| `DASHSCOPE_API_KEY` | DashScope API key (Aliyun) | - |
|
||||
| `OPENAI_IMAGE_MODEL` | OpenAI model | `gpt-image-1.5` |
|
||||
| `GOOGLE_IMAGE_MODEL` | Google model | `gemini-3-pro-image-preview` |
|
||||
| `DASHSCOPE_IMAGE_MODEL` | DashScope model | `z-image-turbo` |
|
||||
| `OPENAI_BASE_URL` | Custom OpenAI endpoint | - |
|
||||
| `GOOGLE_BASE_URL` | Custom Google endpoint | - |
|
||||
| `DASHSCOPE_BASE_URL` | Custom DashScope endpoint | - |
|
||||
|
||||
**Provider Auto-Selection**:
|
||||
1. If `--provider` specified → use it
|
||||
2. If only one API key available → use that provider
|
||||
3. If both available → default to Google
|
||||
3. If multiple available → default to Google
|
||||
|
||||
#### baoyu-danger-gemini-web
|
||||
|
||||
@@ -672,6 +678,43 @@ Compress images to reduce file size while maintaining quality.
|
||||
/baoyu-compress-image path/to/images/ --quality 80
|
||||
```
|
||||
|
||||
#### baoyu-format-markdown
|
||||
|
||||
Format plain text or markdown files with proper frontmatter, titles, summaries, headings, bold, lists, and code blocks.
|
||||
|
||||
```bash
|
||||
# Format a markdown file
|
||||
/baoyu-format-markdown path/to/article.md
|
||||
|
||||
# Format with specific output
|
||||
/baoyu-format-markdown path/to/draft.md
|
||||
```
|
||||
|
||||
**Workflow**:
|
||||
1. Read source file and analyze content structure
|
||||
2. Check/create YAML frontmatter (title, slug, summary, featureImage)
|
||||
3. Handle title: use existing, extract from H1, or generate candidates
|
||||
4. Apply formatting: headings, bold, lists, code blocks, quotes
|
||||
5. Save to `{filename}-formatted.md`
|
||||
6. Run typography script: ASCII→fullwidth quotes, CJK spacing, autocorrect
|
||||
|
||||
**Frontmatter Fields**:
|
||||
| Field | Processing |
|
||||
|-------|------------|
|
||||
| `title` | Use existing, extract H1, or generate candidates |
|
||||
| `slug` | Infer from file path or generate from title |
|
||||
| `summary` | Generate engaging summary (100-150 chars) |
|
||||
| `featureImage` | Check for `imgs/cover.png` in same directory |
|
||||
|
||||
**Formatting Rules**:
|
||||
| Element | Format |
|
||||
|---------|--------|
|
||||
| Titles | `#`, `##`, `###` hierarchy |
|
||||
| Key points | `**bold**` |
|
||||
| Parallel items | `-` unordered or `1.` ordered lists |
|
||||
| Code/commands | `` `inline` `` or ` ```block``` ` |
|
||||
| Quotes | `>` blockquote |
|
||||
|
||||
## Environment Configuration
|
||||
|
||||
Some skills require API keys or custom configuration. Environment variables can be set in `.env` files:
|
||||
@@ -699,6 +742,11 @@ OPENAI_IMAGE_MODEL=gpt-image-1.5
|
||||
GOOGLE_API_KEY=xxx
|
||||
GOOGLE_IMAGE_MODEL=gemini-3-pro-image-preview
|
||||
# GOOGLE_BASE_URL=https://generativelanguage.googleapis.com/v1beta
|
||||
|
||||
# DashScope (Aliyun Tongyi Wanxiang)
|
||||
DASHSCOPE_API_KEY=sk-xxx
|
||||
DASHSCOPE_IMAGE_MODEL=z-image-turbo
|
||||
# DASHSCOPE_BASE_URL=https://dashscope.aliyuncs.com/api/v1
|
||||
EOF
|
||||
```
|
||||
|
||||
|
||||
+52
-4
@@ -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) |
|
||||
|
||||
## 更新技能
|
||||
|
||||
@@ -543,7 +543,7 @@ AI 驱动的生成后端。
|
||||
|
||||
#### baoyu-image-gen
|
||||
|
||||
基于 AI SDK 的图像生成,使用官方 OpenAI 和 Google API。支持文生图、参考图、宽高比和质量预设。
|
||||
基于 AI SDK 的图像生成,使用官方 OpenAI、Google 和 DashScope(阿里通义万相)API。支持文生图、参考图、宽高比和质量预设。
|
||||
|
||||
```bash
|
||||
# 基础生成(自动检测服务商)
|
||||
@@ -558,6 +558,9 @@ AI 驱动的生成后端。
|
||||
# 指定服务商
|
||||
/baoyu-image-gen --prompt "一只猫" --image cat.png --provider openai
|
||||
|
||||
# DashScope(阿里通义万相)
|
||||
/baoyu-image-gen --prompt "一只可爱的猫" --image cat.png --provider dashscope
|
||||
|
||||
# 带参考图(仅 Google 多模态支持)
|
||||
/baoyu-image-gen --prompt "把它变成蓝色" --image out.png --ref source.png
|
||||
```
|
||||
@@ -568,7 +571,7 @@ AI 驱动的生成后端。
|
||||
| `--prompt`, `-p` | 提示词文本 |
|
||||
| `--promptfiles` | 从文件读取提示词(多文件拼接) |
|
||||
| `--image` | 输出图片路径(必需) |
|
||||
| `--provider` | `google` 或 `openai`(默认:google) |
|
||||
| `--provider` | `google`、`openai` 或 `dashscope`(默认:google) |
|
||||
| `--model`, `-m` | 模型 ID |
|
||||
| `--ar` | 宽高比(如 `16:9`、`1:1`、`4:3`) |
|
||||
| `--size` | 尺寸(如 `1024x1024`) |
|
||||
@@ -580,15 +583,18 @@ AI 驱动的生成后端。
|
||||
|------|------|--------|
|
||||
| `OPENAI_API_KEY` | OpenAI API 密钥 | - |
|
||||
| `GOOGLE_API_KEY` | Google API 密钥 | - |
|
||||
| `DASHSCOPE_API_KEY` | DashScope API 密钥(阿里云) | - |
|
||||
| `OPENAI_IMAGE_MODEL` | OpenAI 模型 | `gpt-image-1.5` |
|
||||
| `GOOGLE_IMAGE_MODEL` | Google 模型 | `gemini-3-pro-image-preview` |
|
||||
| `DASHSCOPE_IMAGE_MODEL` | DashScope 模型 | `z-image-turbo` |
|
||||
| `OPENAI_BASE_URL` | 自定义 OpenAI 端点 | - |
|
||||
| `GOOGLE_BASE_URL` | 自定义 Google 端点 | - |
|
||||
| `DASHSCOPE_BASE_URL` | 自定义 DashScope 端点 | - |
|
||||
|
||||
**服务商自动选择**:
|
||||
1. 如果指定了 `--provider` → 使用指定的
|
||||
2. 如果只有一个 API 密钥 → 使用对应服务商
|
||||
3. 如果两个都有 → 默认使用 Google
|
||||
3. 如果多个可用 → 默认使用 Google
|
||||
|
||||
#### baoyu-danger-gemini-web
|
||||
|
||||
@@ -672,6 +678,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` 文件中设置:
|
||||
@@ -699,6 +742,11 @@ OPENAI_IMAGE_MODEL=gpt-image-1.5
|
||||
GOOGLE_API_KEY=xxx
|
||||
GOOGLE_IMAGE_MODEL=gemini-3-pro-image-preview
|
||||
# GOOGLE_BASE_URL=https://generativelanguage.googleapis.com/v1beta
|
||||
|
||||
# DashScope(阿里通义万相)
|
||||
DASHSCOPE_API_KEY=sk-xxx
|
||||
DASHSCOPE_IMAGE_MODEL=z-image-turbo
|
||||
# DASHSCOPE_BASE_URL=https://dashscope.aliyuncs.com/api/v1
|
||||
EOF
|
||||
```
|
||||
|
||||
|
||||
@@ -303,10 +303,15 @@ illustrations/{topic-slug}/
|
||||
|
||||
| Action | Steps |
|
||||
|--------|-------|
|
||||
| **Edit** | Update prompt → Regenerate → Update reference |
|
||||
| **Edit** | **Update prompt file FIRST** → Regenerate → Update reference |
|
||||
| **Add** | Identify position → Create prompt → Generate → Update outline → Insert |
|
||||
| **Delete** | Delete files → Remove reference → Update outline |
|
||||
|
||||
**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 |
|
||||
|
||||
@@ -259,6 +259,16 @@ Schema: [references/config/preferences-schema.md](references/config/preferences-
|
||||
- [config/first-time-setup.md](references/config/first-time-setup.md) - First-time setup
|
||||
- [config/watermark-guide.md](references/config/watermark-guide.md) - Watermark configuration
|
||||
|
||||
## Page Modification
|
||||
|
||||
| Action | Steps |
|
||||
|--------|-------|
|
||||
| **Edit** | **Update prompt file FIRST** → `--regenerate N` → Regenerate PDF |
|
||||
| **Add** | Create prompt at position → Generate with character ref → Renumber subsequent → Update storyboard → Regenerate PDF |
|
||||
| **Delete** | Remove files → Renumber subsequent → Update storyboard → Regenerate PDF |
|
||||
|
||||
**IMPORTANT**: When updating pages, ALWAYS update the prompt file (`prompts/NN-{cover|page}-[slug].md`) FIRST before regenerating. This ensures changes are documented and reproducible.
|
||||
|
||||
## Notes
|
||||
|
||||
- Image generation: 10-30 seconds per page
|
||||
|
||||
@@ -147,7 +147,9 @@ async function processFile(
|
||||
const outputSize = statSync(tempOutput).size;
|
||||
|
||||
if (!opts.keep && absInput !== output) {
|
||||
unlinkSync(absInput);
|
||||
const ext = extname(absInput);
|
||||
const base = absInput.slice(0, -ext.length);
|
||||
renameSync(absInput, `${base}_original${ext}`);
|
||||
}
|
||||
renameSync(tempOutput, output);
|
||||
|
||||
|
||||
@@ -258,8 +258,10 @@ Files:
|
||||
|
||||
| Action | Steps |
|
||||
|--------|-------|
|
||||
| **Regenerate** | Backup existing → Update prompt → Regenerate with same settings |
|
||||
| **Change dimension** | Backup existing → Confirm new value → Update prompt → Regenerate |
|
||||
| **Regenerate** | Backup existing → **Update prompt file FIRST** → Regenerate with same settings |
|
||||
| **Change dimension** | Backup existing → Confirm new value → **Update prompt file FIRST** → Regenerate |
|
||||
|
||||
**IMPORTANT**: When regenerating, ALWAYS update the prompt file (`prompts/cover.md`) FIRST before regenerating. This ensures changes are documented and reproducible.
|
||||
|
||||
All modifications automatically backup existing `cover.png` before regenerating.
|
||||
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
---
|
||||
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 |
|
||||
| `scripts/cjk-emphasis.ts` | Fix CJK emphasis/bold punctuation issues |
|
||||
| `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 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`
|
||||
|
||||
**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,187 @@
|
||||
const CJK_PUNCT_COMMON = "。.,、?!:;";
|
||||
const CJK_OPENING_PUNCT = "(〔〖〘〚「『〈《【\u201C\u2018";
|
||||
const CJK_CLOSING_PUNCT = ")〕〗〙〛」』〉》】\u201D\u2019" + CJK_PUNCT_COMMON;
|
||||
const CJK_SCRIPTS =
|
||||
"\\p{Script=Han}\\p{Script=Hiragana}\\p{Script=Katakana}\\p{Script=Hangul}";
|
||||
|
||||
export const CJK_CLOSING_PUNCT_RE = new RegExp(`[${CJK_CLOSING_PUNCT}]`);
|
||||
export const CJK_OPENING_PUNCT_RE = new RegExp(`^[${CJK_OPENING_PUNCT}]`);
|
||||
export const CJK_CHAR_RE = new RegExp(`[${CJK_SCRIPTS}]`, "u");
|
||||
|
||||
const PUNCT_OR_SYMBOL_RE = /[\p{P}\p{S}]/u;
|
||||
const WORD_CHAR_RE = /[\p{L}\p{N}]/u;
|
||||
|
||||
function findInlineCodeRanges(text: string): Array<[number, number]> {
|
||||
const ranges: Array<[number, number]> = [];
|
||||
let i = 0;
|
||||
while (i < text.length) {
|
||||
if (text[i] !== "`") {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
let run = 1;
|
||||
while (i + run < text.length && text[i + run] === "`") {
|
||||
run += 1;
|
||||
}
|
||||
|
||||
const start = i;
|
||||
let j = i + run;
|
||||
let found = false;
|
||||
while (j < text.length) {
|
||||
if (text[j] !== "`") {
|
||||
j += 1;
|
||||
continue;
|
||||
}
|
||||
let closeRun = 1;
|
||||
while (j + closeRun < text.length && text[j + closeRun] === "`") {
|
||||
closeRun += 1;
|
||||
}
|
||||
if (closeRun === run) {
|
||||
ranges.push([start, j + closeRun - 1]);
|
||||
i = j + closeRun;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
j += closeRun;
|
||||
}
|
||||
|
||||
if (!found) {
|
||||
i = start + run;
|
||||
}
|
||||
}
|
||||
return ranges;
|
||||
}
|
||||
|
||||
function isEscaped(text: string, pos: number): boolean {
|
||||
let count = 0;
|
||||
for (let i = pos - 1; i >= 0 && text[i] === "\\"; i -= 1) {
|
||||
count += 1;
|
||||
}
|
||||
return count % 2 === 1;
|
||||
}
|
||||
|
||||
function isWhitespaceChar(ch: string | undefined): boolean {
|
||||
return !ch || /\s/u.test(ch);
|
||||
}
|
||||
|
||||
function isPunctuationOrSymbol(ch: string | undefined): boolean {
|
||||
return !!ch && PUNCT_OR_SYMBOL_RE.test(ch);
|
||||
}
|
||||
|
||||
function fixCjkEmphasisSpacingInBlock(block: string): string {
|
||||
const codeRanges = findInlineCodeRanges(block);
|
||||
let rangeIndex = 0;
|
||||
|
||||
const delimiters: Array<{
|
||||
pos: number;
|
||||
canOpen: boolean;
|
||||
canClose: boolean;
|
||||
}> = [];
|
||||
let cursor = 0;
|
||||
while (cursor < block.length - 1) {
|
||||
if (rangeIndex < codeRanges.length && cursor >= codeRanges[rangeIndex][0]) {
|
||||
if (cursor <= codeRanges[rangeIndex][1]) {
|
||||
cursor = codeRanges[rangeIndex][1] + 1;
|
||||
continue;
|
||||
}
|
||||
rangeIndex += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
block[cursor] === "*" &&
|
||||
block[cursor + 1] === "*" &&
|
||||
block[cursor - 1] !== "*" &&
|
||||
block[cursor + 2] !== "*" &&
|
||||
!isEscaped(block, cursor)
|
||||
) {
|
||||
const before = block[cursor - 1];
|
||||
const after = block[cursor + 2];
|
||||
const beforeIsSpace = isWhitespaceChar(before);
|
||||
const afterIsSpace = isWhitespaceChar(after);
|
||||
const beforeIsPunct = isPunctuationOrSymbol(before);
|
||||
const afterIsPunct = isPunctuationOrSymbol(after);
|
||||
const leftFlanking =
|
||||
!afterIsSpace && (!afterIsPunct || beforeIsSpace || beforeIsPunct);
|
||||
const rightFlanking =
|
||||
!beforeIsSpace && (!beforeIsPunct || afterIsSpace || afterIsPunct);
|
||||
const cjkPunctBefore = !!before && CJK_CLOSING_PUNCT_RE.test(before);
|
||||
const wordAfter = !!after && WORD_CHAR_RE.test(after);
|
||||
|
||||
delimiters.push({
|
||||
pos: cursor,
|
||||
canOpen: leftFlanking,
|
||||
canClose: rightFlanking || (cjkPunctBefore && wordAfter),
|
||||
});
|
||||
cursor += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
cursor += 1;
|
||||
}
|
||||
|
||||
const stack: Array<{ pos: number }> = [];
|
||||
const pairs: Array<{ open: number; close: number }> = [];
|
||||
for (const delimiter of delimiters) {
|
||||
if (delimiter.canClose) {
|
||||
let openerIndex = -1;
|
||||
for (let j = stack.length - 1; j >= 0; j -= 1) {
|
||||
openerIndex = j;
|
||||
break;
|
||||
}
|
||||
if (openerIndex !== -1) {
|
||||
const opener = stack.splice(openerIndex, 1)[0];
|
||||
pairs.push({ open: opener.pos, close: delimiter.pos });
|
||||
}
|
||||
}
|
||||
if (delimiter.canOpen) {
|
||||
stack.push({ pos: delimiter.pos });
|
||||
}
|
||||
}
|
||||
|
||||
if (pairs.length === 0) return block;
|
||||
|
||||
const insertPositions = new Set<number>();
|
||||
for (const pair of pairs) {
|
||||
const insideLast = block[pair.close - 1];
|
||||
const afterClose = block[pair.close + 2];
|
||||
if (!afterClose) continue;
|
||||
if (
|
||||
CJK_CLOSING_PUNCT_RE.test(insideLast) &&
|
||||
WORD_CHAR_RE.test(afterClose)
|
||||
) {
|
||||
insertPositions.add(pair.close + 2);
|
||||
}
|
||||
}
|
||||
|
||||
if (insertPositions.size === 0) return block;
|
||||
|
||||
let result = "";
|
||||
for (let idx = 0; idx < block.length; idx += 1) {
|
||||
if (insertPositions.has(idx)) {
|
||||
result += " ";
|
||||
}
|
||||
result += block[idx];
|
||||
}
|
||||
if (insertPositions.has(block.length)) {
|
||||
result += " ";
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function fixCjkEmphasisSpacing(content: string): string {
|
||||
const parts = content.split(/(^```[\s\S]*?^```|^~~~[\s\S]*?^~~~)/m);
|
||||
return parts
|
||||
.map((part, i) => {
|
||||
if (i % 2 === 1) return part;
|
||||
const blocks = part.split(/(\n\s*\n+)/);
|
||||
return blocks
|
||||
.map((block, index) => {
|
||||
if (index % 2 === 1) return block;
|
||||
return fixCjkEmphasisSpacingInBlock(block);
|
||||
})
|
||||
.join("");
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import { readFileSync, writeFileSync } from "fs";
|
||||
import { unified } from "unified";
|
||||
import remarkParse from "remark-parse";
|
||||
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 {
|
||||
fixCjkEmphasisSpacing,
|
||||
CJK_CLOSING_PUNCT_RE,
|
||||
CJK_OPENING_PUNCT_RE,
|
||||
CJK_CHAR_RE,
|
||||
} from "./cjk-emphasis";
|
||||
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 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 {
|
||||
if (options.emphasis) {
|
||||
content = fixCjkEmphasisSpacing(content);
|
||||
}
|
||||
|
||||
const processor = unified()
|
||||
.use(remarkParse)
|
||||
.use(remarkGfm)
|
||||
.use(remarkFrontmatter, ["yaml"])
|
||||
.use(remarkStringify, {
|
||||
wrap: false,
|
||||
});
|
||||
|
||||
const tree = processor.parse(content);
|
||||
|
||||
visit(tree, (node, _index, parent) => {
|
||||
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;
|
||||
}
|
||||
if (
|
||||
options.emphasis &&
|
||||
(node.type === "strong" ||
|
||||
node.type === "emphasis" ||
|
||||
node.type === "delete") &&
|
||||
parent
|
||||
) {
|
||||
const siblings = (parent as { children: typeof node[] }).children;
|
||||
const idx = siblings.indexOf(node);
|
||||
const children = (node as { children: typeof node[] }).children;
|
||||
if (!children || children.length === 0) return;
|
||||
|
||||
const lastChild = children[children.length - 1];
|
||||
if (lastChild.type === "text") {
|
||||
const lastText = (lastChild as { value: string }).value;
|
||||
if (
|
||||
CJK_CLOSING_PUNCT_RE.test(lastText.slice(-1)) &&
|
||||
idx + 1 < siblings.length
|
||||
) {
|
||||
const nextSib = siblings[idx + 1];
|
||||
if (nextSib.type === "text") {
|
||||
const nextText = (nextSib as { value: string }).value;
|
||||
if (CJK_CHAR_RE.test(nextText.charAt(0))) {
|
||||
(nextSib as { value: string }).value = " " + nextText;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const firstChild = children[0];
|
||||
if (firstChild.type === "text") {
|
||||
const firstText = (firstChild as { value: string }).value;
|
||||
if (CJK_OPENING_PUNCT_RE.test(firstText) && idx > 0) {
|
||||
const prevSib = siblings[idx - 1];
|
||||
if (prevSib.type === "text") {
|
||||
const prevText = (prevSib as { value: string }).value;
|
||||
if (CJK_CHAR_RE.test(prevText.charAt(prevText.length - 1))) {
|
||||
(prevSib as { value: string }).value = prevText + " ";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let result = processor.stringify(tree);
|
||||
if (options.emphasis) {
|
||||
result = fixCjkEmphasisSpacing(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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export function replaceQuotes(content: string): string {
|
||||
return content
|
||||
.replace(/"([^"]+)"/g, "\u201c$1\u201d")
|
||||
.replace(/「([^」]+)」/g, "\u201c$1\u201d");
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
---
|
||||
name: baoyu-image-gen
|
||||
description: AI image generation with OpenAI and Google APIs. Supports text-to-image, reference images, aspect ratios, and parallel generation (recommended 4 concurrent subagents). Use when user asks to generate, create, or draw images.
|
||||
description: AI image generation with OpenAI, Google and DashScope APIs. Supports text-to-image, reference images, aspect ratios. Sequential by default; parallel generation available on request. Use when user asks to generate, create, or draw images.
|
||||
---
|
||||
|
||||
# Image Generation (AI SDK)
|
||||
|
||||
Official API-based image generation. Supports OpenAI and Google providers.
|
||||
Official API-based image generation. Supports OpenAI, Google and DashScope (阿里通义万象) providers.
|
||||
|
||||
## Script Directory
|
||||
|
||||
@@ -63,6 +63,9 @@ npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "Make blue" --image out.png --r
|
||||
|
||||
# Specific provider
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "A cat" --image out.png --provider openai
|
||||
|
||||
# DashScope (阿里通义万象)
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "一只可爱的猫" --image out.png --provider dashscope
|
||||
```
|
||||
|
||||
## Options
|
||||
@@ -72,7 +75,7 @@ npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "A cat" --image out.png --provi
|
||||
| `--prompt <text>`, `-p` | Prompt text |
|
||||
| `--promptfiles <files...>` | Read prompt from files (concatenated) |
|
||||
| `--image <path>` | Output image path (required) |
|
||||
| `--provider google\|openai` | Force provider (default: google) |
|
||||
| `--provider google\|openai\|dashscope` | Force provider (default: google) |
|
||||
| `--model <id>`, `-m` | Model ID |
|
||||
| `--ar <ratio>` | Aspect ratio (e.g., `16:9`, `1:1`, `4:3`) |
|
||||
| `--size <WxH>` | Size (e.g., `1024x1024`) |
|
||||
@@ -88,10 +91,13 @@ npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "A cat" --image out.png --provi
|
||||
|----------|-------------|
|
||||
| `OPENAI_API_KEY` | OpenAI API key |
|
||||
| `GOOGLE_API_KEY` | Google API key |
|
||||
| `DASHSCOPE_API_KEY` | DashScope API key (阿里云) |
|
||||
| `OPENAI_IMAGE_MODEL` | OpenAI model override |
|
||||
| `GOOGLE_IMAGE_MODEL` | Google model override |
|
||||
| `DASHSCOPE_IMAGE_MODEL` | DashScope model override (default: z-image-turbo) |
|
||||
| `OPENAI_BASE_URL` | Custom OpenAI endpoint |
|
||||
| `GOOGLE_BASE_URL` | Custom Google endpoint |
|
||||
| `DASHSCOPE_BASE_URL` | Custom DashScope endpoint |
|
||||
|
||||
**Load Priority**: CLI args > env vars > `<cwd>/.baoyu-skills/.env` > `~/.baoyu-skills/.env`
|
||||
|
||||
@@ -99,7 +105,7 @@ npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "A cat" --image out.png --provi
|
||||
|
||||
1. `--provider` specified → use it
|
||||
2. Only one API key available → use that provider
|
||||
3. Both available → default to Google
|
||||
3. Multiple available → default to Google
|
||||
|
||||
## Quality Presets
|
||||
|
||||
@@ -118,25 +124,32 @@ Supported: `1:1`, `16:9`, `9:16`, `4:3`, `3:4`, `2.35:1`
|
||||
- Google Imagen: uses `aspectRatio` parameter
|
||||
- OpenAI: maps to closest supported size
|
||||
|
||||
## Parallel Generation
|
||||
## Generation Mode
|
||||
|
||||
Supports concurrent image generation via background subagents for batch operations.
|
||||
**Default**: Sequential generation (one image at a time). This ensures stable output and easier debugging.
|
||||
|
||||
**Parallel Generation**: Only use when user explicitly requests parallel/concurrent generation.
|
||||
|
||||
| Mode | When to Use |
|
||||
|------|-------------|
|
||||
| Sequential (default) | Normal usage, single images, small batches |
|
||||
| Parallel | User explicitly requests, large batches (10+) |
|
||||
|
||||
**Parallel Settings** (when requested):
|
||||
|
||||
| Setting | Value |
|
||||
|---------|-------|
|
||||
| Recommended concurrency | 4 subagents |
|
||||
| Max concurrency | 8 subagents |
|
||||
| Use case | Batch generation (slides, comics, infographics) |
|
||||
| Use case | Large batch generation when user requests parallel |
|
||||
|
||||
**Agent Implementation**:
|
||||
**Agent Implementation** (parallel mode only):
|
||||
```
|
||||
# Launch multiple generations in parallel using Task tool
|
||||
# Each Task runs as background subagent with run_in_background=true
|
||||
# Collect results via TaskOutput when all complete
|
||||
```
|
||||
|
||||
**Best Practice**: When generating 4+ images, spawn background subagents (recommended 4 concurrent) instead of sequential execution.
|
||||
|
||||
## Error Handling
|
||||
|
||||
- Missing API key → error with setup instructions
|
||||
|
||||
@@ -14,7 +14,7 @@ Options:
|
||||
-p, --prompt <text> Prompt text
|
||||
--promptfiles <files...> Read prompt from files (concatenated)
|
||||
--image <path> Output image path (required)
|
||||
--provider google|openai Force provider (auto-detect by default)
|
||||
--provider google|openai|dashscope Force provider (auto-detect by default)
|
||||
-m, --model <id> Model ID
|
||||
--ar <ratio> Aspect ratio (e.g., 16:9, 1:1, 4:3)
|
||||
--size <WxH> Size (e.g., 1024x1024)
|
||||
@@ -29,10 +29,13 @@ Environment variables:
|
||||
OPENAI_API_KEY OpenAI API key
|
||||
GOOGLE_API_KEY Google API key
|
||||
GEMINI_API_KEY Gemini API key (alias for GOOGLE_API_KEY)
|
||||
DASHSCOPE_API_KEY DashScope API key (阿里云通义万象)
|
||||
OPENAI_IMAGE_MODEL Default OpenAI model (gpt-image-1.5)
|
||||
GOOGLE_IMAGE_MODEL Default Google model (gemini-3-pro-image-preview)
|
||||
DASHSCOPE_IMAGE_MODEL Default DashScope model (z-image-turbo)
|
||||
OPENAI_BASE_URL Custom OpenAI endpoint
|
||||
GOOGLE_BASE_URL Custom Google endpoint
|
||||
DASHSCOPE_BASE_URL Custom DashScope endpoint
|
||||
|
||||
Env file load order: CLI args > process.env > <cwd>/.baoyu-skills/.env > ~/.baoyu-skills/.env`);
|
||||
}
|
||||
@@ -105,7 +108,7 @@ function parseArgs(argv: string[]): CliArgs {
|
||||
|
||||
if (a === "--provider") {
|
||||
const v = argv[++i];
|
||||
if (v !== "google" && v !== "openai") throw new Error(`Invalid provider: ${v}`);
|
||||
if (v !== "google" && v !== "openai" && v !== "dashscope") throw new Error(`Invalid provider: ${v}`);
|
||||
out.provider = v;
|
||||
continue;
|
||||
}
|
||||
@@ -243,13 +246,15 @@ function detectProvider(args: CliArgs): Provider {
|
||||
|
||||
const hasGoogle = !!(process.env.GOOGLE_API_KEY || process.env.GEMINI_API_KEY);
|
||||
const hasOpenai = !!process.env.OPENAI_API_KEY;
|
||||
const hasDashscope = !!process.env.DASHSCOPE_API_KEY;
|
||||
|
||||
if (hasGoogle && !hasOpenai) return "google";
|
||||
if (hasOpenai && !hasGoogle) return "openai";
|
||||
if (hasGoogle && hasOpenai) return "google";
|
||||
const available = [hasGoogle && "google", hasOpenai && "openai", hasDashscope && "dashscope"].filter(Boolean) as Provider[];
|
||||
|
||||
if (available.length === 1) return available[0]!;
|
||||
if (available.length > 1) return available[0]!;
|
||||
|
||||
throw new Error(
|
||||
"No API key found. Set GOOGLE_API_KEY, GEMINI_API_KEY, or OPENAI_API_KEY.\n" +
|
||||
"No API key found. Set GOOGLE_API_KEY, GEMINI_API_KEY, OPENAI_API_KEY, or DASHSCOPE_API_KEY.\n" +
|
||||
"Create ~/.baoyu-skills/.env or <cwd>/.baoyu-skills/.env with your keys."
|
||||
);
|
||||
}
|
||||
@@ -263,6 +268,9 @@ async function loadProviderModule(provider: Provider): Promise<ProviderModule> {
|
||||
if (provider === "google") {
|
||||
return (await import("./providers/google")) as ProviderModule;
|
||||
}
|
||||
if (provider === "dashscope") {
|
||||
return (await import("./providers/dashscope")) as ProviderModule;
|
||||
}
|
||||
return (await import("./providers/openai")) as ProviderModule;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import type { CliArgs } from "../types";
|
||||
|
||||
export function getDefaultModel(): string {
|
||||
return process.env.DASHSCOPE_IMAGE_MODEL || "z-image-turbo";
|
||||
}
|
||||
|
||||
function getApiKey(): string | null {
|
||||
return process.env.DASHSCOPE_API_KEY || null;
|
||||
}
|
||||
|
||||
function getBaseUrl(): string {
|
||||
const base = process.env.DASHSCOPE_BASE_URL || "https://dashscope.aliyuncs.com";
|
||||
return base.replace(/\/+$/g, "");
|
||||
}
|
||||
|
||||
function parseAspectRatio(ar: string): { width: number; height: number } | null {
|
||||
const match = ar.match(/^(\d+(?:\.\d+)?):(\d+(?:\.\d+)?)$/);
|
||||
if (!match) return null;
|
||||
const w = parseFloat(match[1]!);
|
||||
const h = parseFloat(match[2]!);
|
||||
if (w <= 0 || h <= 0) return null;
|
||||
return { width: w, height: h };
|
||||
}
|
||||
|
||||
function getSizeFromAspectRatio(ar: string | null, quality: CliArgs["quality"]): string {
|
||||
const baseSize = quality === "2k" ? 1440 : 1024;
|
||||
|
||||
if (!ar) return `${baseSize}*${baseSize}`;
|
||||
|
||||
const parsed = parseAspectRatio(ar);
|
||||
if (!parsed) return `${baseSize}*${baseSize}`;
|
||||
|
||||
const ratio = parsed.width / parsed.height;
|
||||
|
||||
if (Math.abs(ratio - 1) < 0.1) {
|
||||
return `${baseSize}*${baseSize}`;
|
||||
}
|
||||
|
||||
if (ratio > 1) {
|
||||
const w = Math.round(baseSize * ratio);
|
||||
return `${w}*${baseSize}`;
|
||||
}
|
||||
|
||||
const h = Math.round(baseSize / ratio);
|
||||
return `${baseSize}*${h}`;
|
||||
}
|
||||
|
||||
function normalizeSize(size: string): string {
|
||||
return size.replace("x", "*");
|
||||
}
|
||||
|
||||
export async function generateImage(
|
||||
prompt: string,
|
||||
model: string,
|
||||
args: CliArgs
|
||||
): Promise<Uint8Array> {
|
||||
const apiKey = getApiKey();
|
||||
if (!apiKey) throw new Error("DASHSCOPE_API_KEY is required");
|
||||
|
||||
if (args.referenceImages.length > 0) {
|
||||
console.error("Warning: Reference images not yet supported with DashScope, ignoring.");
|
||||
}
|
||||
|
||||
const size = args.size ? normalizeSize(args.size) : getSizeFromAspectRatio(args.aspectRatio, args.quality);
|
||||
const url = `${getBaseUrl()}/api/v1/services/aigc/multimodal-generation/generation`;
|
||||
|
||||
const body = {
|
||||
model,
|
||||
input: {
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [{ text: prompt }],
|
||||
},
|
||||
],
|
||||
},
|
||||
parameters: {
|
||||
prompt_extend: false,
|
||||
size,
|
||||
},
|
||||
};
|
||||
|
||||
console.log(`Generating image with DashScope (${model})...`, { size });
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
throw new Error(`DashScope API error (${res.status}): ${err}`);
|
||||
}
|
||||
|
||||
const result = await res.json() as {
|
||||
output?: {
|
||||
result_image?: string;
|
||||
choices?: Array<{
|
||||
message?: {
|
||||
content?: Array<{ image?: string }>;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
let imageData: string | null = null;
|
||||
|
||||
if (result.output?.result_image) {
|
||||
imageData = result.output.result_image;
|
||||
} else if (result.output?.choices?.[0]?.message?.content) {
|
||||
const content = result.output.choices[0].message.content;
|
||||
for (const item of content) {
|
||||
if (item.image) {
|
||||
imageData = item.image;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!imageData) {
|
||||
console.error("Response:", JSON.stringify(result, null, 2));
|
||||
throw new Error("No image in response");
|
||||
}
|
||||
|
||||
if (imageData.startsWith("http://") || imageData.startsWith("https://")) {
|
||||
const imgRes = await fetch(imageData);
|
||||
if (!imgRes.ok) throw new Error("Failed to download image");
|
||||
const buf = await imgRes.arrayBuffer();
|
||||
return new Uint8Array(buf);
|
||||
}
|
||||
|
||||
return Uint8Array.from(Buffer.from(imageData, "base64"));
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
export type Provider = "google" | "openai";
|
||||
export type Provider = "google" | "openai" | "dashscope";
|
||||
export type Quality = "normal" | "2k";
|
||||
|
||||
export type CliArgs = {
|
||||
|
||||
@@ -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,314 @@
|
||||
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);
|
||||
|
||||
let title = options?.title ?? 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 = frontmatter.author || '';
|
||||
let summary = frontmatter.description || 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);
|
||||
console.error(`[markdown-to-html] HTML saved to: ${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,
|
||||
});
|
||||
}
|
||||
|
||||
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
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,809 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import frontMatter from "front-matter";
|
||||
import hljs from "highlight.js/lib/core";
|
||||
import { marked, type RendererObject, type Tokens } from "marked";
|
||||
import readingTime, { type ReadTimeResults } from "reading-time";
|
||||
|
||||
import {
|
||||
markedAlert,
|
||||
markedFootnotes,
|
||||
markedInfographic,
|
||||
markedMarkup,
|
||||
markedPlantUML,
|
||||
markedRuby,
|
||||
markedSlider,
|
||||
markedToc,
|
||||
MDKatex,
|
||||
} from "./extensions/index.js";
|
||||
import {
|
||||
COMMON_LANGUAGES,
|
||||
highlightAndFormatCode,
|
||||
} from "./utils/languages.js";
|
||||
|
||||
type ThemeName = 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 renderMarkdown(raw: string, renderer: RendererAPI): {
|
||||
html: string;
|
||||
readingTime: ReadTimeResults;
|
||||
} {
|
||||
const { markdownContent, readingTime: readingTimeResult } =
|
||||
renderer.parseFrontMatterAndContent(raw);
|
||||
|
||||
const html = marked.parse(markdownContent) as string;
|
||||
|
||||
return { html, readingTime: readingTimeResult };
|
||||
}
|
||||
|
||||
function postProcessHtml(
|
||||
baseHtml: string,
|
||||
reading: ReadTimeResults,
|
||||
renderer: RendererAPI
|
||||
): string {
|
||||
let html = baseHtml;
|
||||
html = renderer.buildReadingTime(reading) + html;
|
||||
html += renderer.buildFootnotes();
|
||||
html += renderer.buildAddition();
|
||||
html += `
|
||||
<style>
|
||||
.hljs.code__pre > .mac-sign {
|
||||
display: ${renderer.getOpts().isMacCodeBlock ? "flex" : "none"};
|
||||
}
|
||||
</style>
|
||||
`;
|
||||
html += `
|
||||
<style>
|
||||
h2 strong {
|
||||
color: inherit !important;
|
||||
}
|
||||
</style>
|
||||
`;
|
||||
return renderer.createContainer(html);
|
||||
}
|
||||
|
||||
function formatTimestamp(date = new Date()): string {
|
||||
const pad = (value: number) => String(value).padStart(2, "0");
|
||||
return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(
|
||||
date.getDate()
|
||||
)}${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`;
|
||||
}
|
||||
|
||||
function ensureMarkdownPath(inputPath: string): void {
|
||||
if (!inputPath.toLowerCase().endsWith(".md")) {
|
||||
throw new Error("Input file must end with .md");
|
||||
}
|
||||
}
|
||||
|
||||
function loadThemeCss(theme: ThemeName): {
|
||||
baseCss: string;
|
||||
themeCss: string;
|
||||
} {
|
||||
const 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);
|
||||
}
|
||||
|
||||
function buildHtmlDocument(title: string, css: string, html: string): string {
|
||||
return [
|
||||
"<!doctype html>",
|
||||
"<html>",
|
||||
"<head>",
|
||||
' <meta charset="utf-8" />',
|
||||
' <meta name="viewport" content="width=device-width, initial-scale=1" />',
|
||||
` <title>${title}</title>`,
|
||||
` <style>${css}</style>`,
|
||||
"</head>",
|
||||
"<body>",
|
||||
' <div id="output">',
|
||||
html,
|
||||
" </div>",
|
||||
"</body>",
|
||||
"</html>",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
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 { html: baseHtml, readingTime: readingTimeResult } = renderMarkdown(
|
||||
markdown,
|
||||
renderer
|
||||
);
|
||||
let content = postProcessHtml(baseHtml, readingTimeResult, renderer);
|
||||
if (!options.keepTitle) {
|
||||
content = removeFirstHeading(content);
|
||||
}
|
||||
|
||||
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)) {
|
||||
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
|
||||
|
||||
@@ -171,6 +171,37 @@ export interface ChromeSession {
|
||||
targetId: string;
|
||||
}
|
||||
|
||||
export async function tryConnectExisting(port: number): Promise<CdpConnection | null> {
|
||||
try {
|
||||
const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(`http://127.0.0.1:${port}/json/version`);
|
||||
if (version.webSocketDebuggerUrl) {
|
||||
const cdp = await CdpConnection.connect(version.webSocketDebuggerUrl, 5_000);
|
||||
return cdp;
|
||||
}
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function findExistingChromeDebugPort(): Promise<number | null> {
|
||||
if (process.platform !== 'darwin' && process.platform !== 'linux') return null;
|
||||
try {
|
||||
const { execSync } = await import('node:child_process');
|
||||
const cmd = process.platform === 'darwin'
|
||||
? `lsof -nP -iTCP -sTCP:LISTEN 2>/dev/null | grep -i 'google\\|chrome' | awk '{print $9}' | sed 's/.*://'`
|
||||
: `ss -tlnp 2>/dev/null | grep -i chrome | awk '{print $4}' | sed 's/.*://'`;
|
||||
const output = execSync(cmd, { encoding: 'utf-8', timeout: 5_000 }).trim();
|
||||
if (!output) return null;
|
||||
const ports = output.split('\n').map(p => parseInt(p, 10)).filter(p => !isNaN(p) && p > 0);
|
||||
for (const port of ports) {
|
||||
try {
|
||||
const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(`http://127.0.0.1:${port}/json/version`);
|
||||
if (version.webSocketDebuggerUrl) return port;
|
||||
} catch {}
|
||||
}
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function launchChrome(url: string, profileDir?: string): Promise<{ cdp: CdpConnection; chrome: ReturnType<typeof spawn> }> {
|
||||
const chromePath = findChromeExecutable();
|
||||
if (!chromePath) throw new Error('Chrome not found. Set WECHAT_BROWSER_CHROME_PATH env var.');
|
||||
|
||||
@@ -121,8 +121,14 @@ export async function convertMarkdown(markdownPath: string, options?: { title?:
|
||||
|
||||
let title = options?.title ?? frontmatter.title ?? '';
|
||||
if (!title) {
|
||||
const h1Match = body.match(/^#\s+(.+)$/m);
|
||||
if (h1Match) title = h1Match[1]!;
|
||||
const lines = body.split('\n');
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
const headingMatch = trimmed.match(/^#{1,2}\s+(.+)$/);
|
||||
if (headingMatch) title = headingMatch[1]!;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!title) title = path.basename(markdownPath, path.extname(markdownPath));
|
||||
const author = frontmatter.author || '';
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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,499 @@
|
||||
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;
|
||||
}
|
||||
|
||||
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 }> {
|
||||
const imgRegex = /<img[^>]+src=["']([^"']+)["'][^>]*>/gi;
|
||||
const matches = [...html.matchAll(imgRegex)];
|
||||
|
||||
if (matches.length === 0) {
|
||||
return { html, firstMediaId: "" };
|
||||
}
|
||||
|
||||
let firstMediaId = "";
|
||||
let updatedHtml = html;
|
||||
|
||||
for (const match of matches) {
|
||||
const [, src] = match;
|
||||
if (!src) continue;
|
||||
|
||||
if (src.startsWith("https://mmbiz.qpic.cn")) {
|
||||
if (!firstMediaId) {
|
||||
firstMediaId = src;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
console.error(`[wechat-api] Uploading image: ${src}`);
|
||||
try {
|
||||
const resp = await uploadImage(src, accessToken, baseDir);
|
||||
updatedHtml = updatedHtml.replace(src, resp.url);
|
||||
if (!firstMediaId) {
|
||||
firstMediaId = resp.media_id;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[wechat-api] Failed to upload ${src}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
return { html: updatedHtml, firstMediaId };
|
||||
}
|
||||
|
||||
async function publishToDraft(
|
||||
title: string,
|
||||
html: string,
|
||||
thumbMediaId: string,
|
||||
accessToken: string
|
||||
): Promise<PublishResponse> {
|
||||
const url = `${DRAFT_URL}?access_token=${accessToken}`;
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
articles: [{
|
||||
title,
|
||||
content: html,
|
||||
thumb_media_id: thumbMediaId,
|
||||
}],
|
||||
}),
|
||||
});
|
||||
|
||||
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:
|
||||
--title <title> Override title
|
||||
--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
|
||||
|
||||
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.html --title "My Article"
|
||||
npx -y bun wechat-api.ts article.md --dry-run
|
||||
`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
interface CliArgs {
|
||||
filePath: string;
|
||||
isHtml: boolean;
|
||||
title?: 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,
|
||||
theme: "default",
|
||||
dryRun: false,
|
||||
};
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i]!;
|
||||
if (arg === "--title" && argv[i + 1]) {
|
||||
args.title = 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("-")) {
|
||||
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 htmlPath: string;
|
||||
let htmlContent: string;
|
||||
let frontmatter: Record<string, string> = {};
|
||||
|
||||
if (args.isHtml) {
|
||||
htmlPath = filePath;
|
||||
htmlContent = extractHtmlContent(htmlPath);
|
||||
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]!;
|
||||
}
|
||||
|
||||
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 (args.dryRun) {
|
||||
console.log(JSON.stringify({
|
||||
title,
|
||||
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 } = 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 (!thumbMediaId) {
|
||||
console.error("Error: No cover image. Provide via --cover, frontmatter.featureImage, or include an image in content.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.error("[wechat-api] Publishing to draft...");
|
||||
const result = await publishToDraft(title, htmlContent, thumbMediaId, accessToken);
|
||||
|
||||
console.log(JSON.stringify({
|
||||
success: true,
|
||||
media_id: result.media_id,
|
||||
title,
|
||||
}, null, 2));
|
||||
|
||||
console.error(`[wechat-api] Published successfully! media_id: ${result.media_id}`);
|
||||
}
|
||||
|
||||
await main().catch((err) => {
|
||||
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import process from 'node:process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { launchChrome, getPageSession, waitForNewTab, clickElement, typeText, evaluate, sleep, type ChromeSession, type CdpConnection } from './cdp.ts';
|
||||
import { launchChrome, tryConnectExisting, findExistingChromeDebugPort, getPageSession, waitForNewTab, clickElement, typeText, evaluate, sleep, type ChromeSession, type CdpConnection } from './cdp.ts';
|
||||
|
||||
const WECHAT_URL = 'https://mp.weixin.qq.com/';
|
||||
|
||||
@@ -25,6 +25,7 @@ interface ArticleOptions {
|
||||
contentImages?: ImageInfo[];
|
||||
submit?: boolean;
|
||||
profileDir?: string;
|
||||
cdpPort?: number;
|
||||
}
|
||||
|
||||
async function waitForLogin(session: ChromeSession, timeoutMs = 120_000): Promise<boolean> {
|
||||
@@ -37,6 +38,16 @@ async function waitForLogin(session: ChromeSession, timeoutMs = 120_000): Promis
|
||||
return false;
|
||||
}
|
||||
|
||||
async function waitForElement(session: ChromeSession, selector: string, timeoutMs = 10_000): Promise<boolean> {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
const found = await evaluate<boolean>(session, `!!document.querySelector('${selector}')`);
|
||||
if (found) return true;
|
||||
await sleep(500);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function clickMenuByText(session: ChromeSession, text: string): Promise<void> {
|
||||
console.log(`[wechat] Clicking "${text}" menu...`);
|
||||
const posResult = await session.cdp.send<{ result: { value: string } }>('Runtime.evaluate', {
|
||||
@@ -173,11 +184,13 @@ function parseHtmlMeta(htmlPath: string): { title: string; author: string; summa
|
||||
if (titleMatch) title = titleMatch[1]!;
|
||||
|
||||
let author = '';
|
||||
const authorMatch = content.match(/<meta\s+name=["']author["']\s+content=["']([^"']+)["']/i);
|
||||
const authorMatch = content.match(/<meta\s+name=["']author["']\s+content=["']([^"']+)["']/i)
|
||||
|| content.match(/<meta\s+content=["']([^"']+)["']\s+name=["']author["']/i);
|
||||
if (authorMatch) author = authorMatch[1]!;
|
||||
|
||||
let summary = '';
|
||||
const descMatch = content.match(/<meta\s+name=["']description["']\s+content=["']([^"']+)["']/i);
|
||||
const descMatch = content.match(/<meta\s+name=["']description["']\s+content=["']([^"']+)["']/i)
|
||||
|| content.match(/<meta\s+content=["']([^"']+)["']\s+name=["']description["']/i);
|
||||
if (descMatch) summary = descMatch[1]!;
|
||||
|
||||
if (!summary) {
|
||||
@@ -234,7 +247,7 @@ async function pressDeleteKey(session: ChromeSession): Promise<void> {
|
||||
}
|
||||
|
||||
export async function postArticle(options: ArticleOptions): Promise<void> {
|
||||
const { title, content, htmlFile, markdownFile, theme, author, summary, images = [], submit = false, profileDir } = options;
|
||||
const { title, content, htmlFile, markdownFile, theme, author, summary, images = [], submit = false, profileDir, cdpPort } = options;
|
||||
let { contentImages = [] } = options;
|
||||
let effectiveTitle = title || '';
|
||||
let effectiveAuthor = author || '';
|
||||
@@ -268,16 +281,67 @@ export async function postArticle(options: ArticleOptions): Promise<void> {
|
||||
if (effectiveTitle && effectiveTitle.length > 64) throw new Error(`Title too long: ${effectiveTitle.length} chars (max 64)`);
|
||||
if (!content && !effectiveHtmlFile) throw new Error('Either --content, --html, or --markdown is required');
|
||||
|
||||
const { cdp, chrome } = await launchChrome(WECHAT_URL, profileDir);
|
||||
let cdp: CdpConnection;
|
||||
let chrome: ReturnType<typeof import('node:child_process').spawn> | null = null;
|
||||
|
||||
// Try connecting to existing Chrome: explicit port > auto-detect > launch new
|
||||
const portToTry = cdpPort ?? await findExistingChromeDebugPort();
|
||||
if (portToTry) {
|
||||
const existing = await tryConnectExisting(portToTry);
|
||||
if (existing) {
|
||||
console.log(`[cdp] Connected to existing Chrome on port ${portToTry}`);
|
||||
cdp = existing;
|
||||
} else {
|
||||
console.log(`[cdp] Port ${portToTry} not available, launching new Chrome...`);
|
||||
const launched = await launchChrome(WECHAT_URL, profileDir);
|
||||
cdp = launched.cdp;
|
||||
chrome = launched.chrome;
|
||||
}
|
||||
} else {
|
||||
const launched = await launchChrome(WECHAT_URL, profileDir);
|
||||
cdp = launched.cdp;
|
||||
chrome = launched.chrome;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('[wechat] Waiting for page load...');
|
||||
await sleep(3000);
|
||||
|
||||
let session = await getPageSession(cdp, 'mp.weixin.qq.com');
|
||||
let session: ChromeSession;
|
||||
if (!chrome) {
|
||||
// Reusing existing Chrome: find an already-logged-in tab (has token in URL)
|
||||
const allTargets = await cdp.send<{ targetInfos: Array<{ targetId: string; url: string; type: string }> }>('Target.getTargets');
|
||||
const loggedInTab = allTargets.targetInfos.find(t => t.type === 'page' && t.url.includes('mp.weixin.qq.com') && t.url.includes('token='));
|
||||
const wechatTab = loggedInTab || allTargets.targetInfos.find(t => t.type === 'page' && t.url.includes('mp.weixin.qq.com'));
|
||||
|
||||
if (wechatTab) {
|
||||
console.log(`[wechat] Reusing existing tab: ${wechatTab.url.substring(0, 80)}...`);
|
||||
const { sessionId: reuseSid } = await cdp.send<{ sessionId: string }>('Target.attachToTarget', { targetId: wechatTab.targetId, flatten: true });
|
||||
await cdp.send('Page.enable', {}, { sessionId: reuseSid });
|
||||
await cdp.send('Runtime.enable', {}, { sessionId: reuseSid });
|
||||
await cdp.send('DOM.enable', {}, { sessionId: reuseSid });
|
||||
session = { cdp, sessionId: reuseSid, targetId: wechatTab.targetId };
|
||||
|
||||
// Navigate to home if not already there
|
||||
const currentUrl = await evaluate<string>(session, 'window.location.href');
|
||||
if (!currentUrl.includes('/cgi-bin/home')) {
|
||||
console.log('[wechat] Navigating to home...');
|
||||
await evaluate(session, `window.location.href = '${WECHAT_URL}cgi-bin/home?t=home/index'`);
|
||||
await sleep(5000);
|
||||
}
|
||||
} else {
|
||||
// No WeChat tab found, create one
|
||||
console.log('[wechat] No WeChat tab found, opening...');
|
||||
await cdp.send('Target.createTarget', { url: WECHAT_URL });
|
||||
await sleep(5000);
|
||||
session = await getPageSession(cdp, 'mp.weixin.qq.com');
|
||||
}
|
||||
} else {
|
||||
session = await getPageSession(cdp, 'mp.weixin.qq.com');
|
||||
}
|
||||
|
||||
const url = await evaluate<string>(session, 'window.location.href');
|
||||
if (!url.includes('/cgi-bin/home')) {
|
||||
if (!url.includes('/cgi-bin/')) {
|
||||
console.log('[wechat] Not logged in. Please scan QR code...');
|
||||
const loggedIn = await waitForLogin(session);
|
||||
if (!loggedIn) throw new Error('Login timeout');
|
||||
@@ -285,6 +349,10 @@ export async function postArticle(options: ArticleOptions): Promise<void> {
|
||||
console.log('[wechat] Logged in.');
|
||||
await sleep(2000);
|
||||
|
||||
// Wait for menu to be ready
|
||||
const menuReady = await waitForElement(session, '.new-creation__menu', 20_000);
|
||||
if (!menuReady) throw new Error('Home page menu did not load');
|
||||
|
||||
const targets = await cdp.send<{ targetInfos: Array<{ targetId: string; url: string; type: string }> }>('Target.getTargets');
|
||||
const initialIds = new Set(targets.targetInfos.map(t => t.targetId));
|
||||
|
||||
@@ -313,6 +381,31 @@ 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) {
|
||||
const actualTitle = await evaluate<string>(session, `document.querySelector('#title')?.value || ''`);
|
||||
if (actualTitle === effectiveTitle) {
|
||||
console.log('[wechat] Title verified OK.');
|
||||
} else {
|
||||
console.warn(`[wechat] Title verification failed. Expected: "${effectiveTitle}", got: "${actualTitle}"`);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -329,6 +422,20 @@ export async function postArticle(options: ArticleOptions): Promise<void> {
|
||||
await pasteFromClipboardInEditor(session);
|
||||
await sleep(3000);
|
||||
|
||||
const editorHasContent = await evaluate<boolean>(session, `
|
||||
(function() {
|
||||
const editor = document.querySelector('.ProseMirror');
|
||||
if (!editor) return false;
|
||||
const text = editor.innerText?.trim() || '';
|
||||
return text.length > 0;
|
||||
})()
|
||||
`);
|
||||
if (editorHasContent) {
|
||||
console.log('[wechat] Body content verified OK.');
|
||||
} else {
|
||||
console.warn('[wechat] Body content verification failed: editor appears empty after paste.');
|
||||
}
|
||||
|
||||
if (contentImages.length > 0) {
|
||||
console.log(`[wechat] Inserting ${contentImages.length} images...`);
|
||||
for (let i = 0; i < contentImages.length; i++) {
|
||||
@@ -371,11 +478,20 @@ export async function postArticle(options: ArticleOptions): Promise<void> {
|
||||
console.log('[wechat] Typing content...');
|
||||
await typeText(session, content);
|
||||
await sleep(1000);
|
||||
}
|
||||
|
||||
if (effectiveSummary) {
|
||||
console.log(`[wechat] Filling summary: ${effectiveSummary}`);
|
||||
await evaluate(session, `document.querySelector('#js_description').value = ${JSON.stringify(effectiveSummary)}; document.querySelector('#js_description').dispatchEvent(new Event('input', { bubbles: true }));`);
|
||||
const editorHasContent = await evaluate<boolean>(session, `
|
||||
(function() {
|
||||
const editor = document.querySelector('.ProseMirror');
|
||||
if (!editor) return false;
|
||||
const text = editor.innerText?.trim() || '';
|
||||
return text.length > 0;
|
||||
})()
|
||||
`);
|
||||
if (editorHasContent) {
|
||||
console.log('[wechat] Body content verified OK.');
|
||||
} else {
|
||||
console.warn('[wechat] Body content verification failed: editor appears empty after typing.');
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[wechat] Saving as draft...');
|
||||
@@ -413,6 +529,7 @@ Options:
|
||||
--image <path> Content image, can repeat (only with --content)
|
||||
--submit Save as draft
|
||||
--profile <dir> Chrome profile directory
|
||||
--cdp-port <port> Connect to existing Chrome debug port instead of launching new instance
|
||||
|
||||
Examples:
|
||||
npx -y bun wechat-article.ts --markdown article.md
|
||||
@@ -442,6 +559,7 @@ async function main(): Promise<void> {
|
||||
let summary: string | undefined;
|
||||
let submit = false;
|
||||
let profileDir: string | undefined;
|
||||
let cdpPort: number | undefined;
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i]!;
|
||||
@@ -455,15 +573,18 @@ async function main(): Promise<void> {
|
||||
else if (arg === '--image' && args[i + 1]) images.push(args[++i]!);
|
||||
else if (arg === '--submit') submit = true;
|
||||
else if (arg === '--profile' && args[i + 1]) profileDir = args[++i];
|
||||
else if (arg === '--cdp-port' && args[i + 1]) cdpPort = parseInt(args[++i]!, 10);
|
||||
}
|
||||
|
||||
if (!markdownFile && !htmlFile && !title) { console.error('Error: --title is required (or use --markdown/--html)'); process.exit(1); }
|
||||
if (!markdownFile && !htmlFile && !content) { console.error('Error: --content, --html, or --markdown is required'); process.exit(1); }
|
||||
|
||||
await postArticle({ title: title || '', content, htmlFile, markdownFile, theme, author, summary, images, submit, profileDir });
|
||||
await postArticle({ title: title || '', content, htmlFile, markdownFile, theme, author, summary, images, submit, profileDir, cdpPort });
|
||||
}
|
||||
|
||||
await main().catch((err) => {
|
||||
await main().then(() => {
|
||||
process.exit(0);
|
||||
}).catch((err) => {
|
||||
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -229,11 +229,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...');
|
||||
}
|
||||
|
||||
@@ -626,16 +626,18 @@ Flow:
|
||||
|
||||
| Action | Command | Manual Steps |
|
||||
|--------|---------|--------------|
|
||||
| **Edit** | `--regenerate N` | Update prompt → Regenerate image → Regenerate PDF |
|
||||
| **Edit** | `--regenerate N` | **Update prompt file FIRST** → Regenerate image → Regenerate PDF |
|
||||
| **Add** | Manual | Create prompt → Generate image → Renumber subsequent → Update outline → Regenerate PDF |
|
||||
| **Delete** | Manual | Remove files → Renumber subsequent → Update outline → Regenerate PDF |
|
||||
|
||||
### Edit Single Slide
|
||||
|
||||
1. Update prompt in `prompts/NN-slide-{slug}.md`
|
||||
1. **Update prompt file FIRST** in `prompts/NN-slide-{slug}.md`
|
||||
2. Run: `/baoyu-slide-deck <dir> --regenerate N`
|
||||
3. Or manually regenerate image + PDF
|
||||
|
||||
**IMPORTANT**: When updating slides, ALWAYS update the prompt file (`prompts/NN-slide-{slug}.md`) FIRST before regenerating. This ensures changes are documented and reproducible.
|
||||
|
||||
### Add New Slide
|
||||
|
||||
1. Create prompt at position: `prompts/NN-slide-{new-slug}.md`
|
||||
|
||||
@@ -403,10 +403,12 @@ Files:
|
||||
|
||||
| Action | Steps |
|
||||
|--------|-------|
|
||||
| **Edit** | Update prompt → Regenerate with same session ID |
|
||||
| **Edit** | **Update prompt file FIRST** → Regenerate with same session ID |
|
||||
| **Add** | Specify position → Create prompt → Generate → Renumber subsequent files (NN+1) → Update outline |
|
||||
| **Delete** | Remove files → Renumber subsequent (NN-1) → Update outline |
|
||||
|
||||
**IMPORTANT**: When updating images, ALWAYS update the prompt file (`prompts/NN-{type}-[slug].md`) FIRST before regenerating. This ensures changes are documented and reproducible.
|
||||
|
||||
## Content Breakdown Principles
|
||||
|
||||
1. **Cover (Image 1)**: Hook + visual impact → `sparse` layout
|
||||
|
||||
Reference in New Issue
Block a user