mirror of
https://github.com/JimLiu/baoyu-skills.git
synced 2026-07-12 05:51:44 +08:00
Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 10e1c426a0 | |||
| dbab83ff82 | |||
| 478e66a93a | |||
| 3a5866eb4b | |||
| c0162bb3af | |||
| e9342a16f8 | |||
| 5cb86d8e51 | |||
| 66ed08f010 | |||
| b179166d28 | |||
| 4805526649 | |||
| 89159bf86e | |||
| b45b5d2c6e | |||
| 62866426c5 | |||
| 28a7db6129 | |||
| 7c5e81fd15 | |||
| 058d25d02c | |||
| fe09ec546f | |||
| cf611a4514 | |||
| 7cf30e969f | |||
| 18b71157db | |||
| 9904c490e4 | |||
| 32ed09128c | |||
| 81e96ee43c | |||
| 5f994589b3 | |||
| 40363194be |
@@ -6,7 +6,7 @@
|
|||||||
},
|
},
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"description": "Skills shared by Baoyu for improving daily work efficiency",
|
"description": "Skills shared by Baoyu for improving daily work efficiency",
|
||||||
"version": "1.24.4"
|
"version": "1.27.0"
|
||||||
},
|
},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
{
|
{
|
||||||
@@ -43,7 +43,9 @@
|
|||||||
"skills": [
|
"skills": [
|
||||||
"./skills/baoyu-danger-x-to-markdown",
|
"./skills/baoyu-danger-x-to-markdown",
|
||||||
"./skills/baoyu-compress-image",
|
"./skills/baoyu-compress-image",
|
||||||
"./skills/baoyu-url-to-markdown"
|
"./skills/baoyu-url-to-markdown",
|
||||||
|
"./skills/baoyu-format-markdown",
|
||||||
|
"./skills/baoyu-markdown-to-html"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -2,6 +2,50 @@
|
|||||||
|
|
||||||
English | [中文](./CHANGELOG.zh.md)
|
English | [中文](./CHANGELOG.zh.md)
|
||||||
|
|
||||||
|
## 1.26.1 - 2026-01-29
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- `baoyu-article-illustrator`, `baoyu-comic`, `baoyu-cover-image`, `baoyu-infographic`, `baoyu-slide-deck`, `baoyu-xhs-images`: add backup rules for existing files—automatically renames source, prompt, and image files with timestamp suffix before overwriting.
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
- `baoyu-xhs-images`: remove `notebook` style (10 styles remaining).
|
||||||
|
|
||||||
|
## 1.26.0 - 2026-01-29
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- `baoyu-xhs-images`: add `notebook` style (hand-drawn infographic with watercolor rendering and Morandi palette) and `study-notes` style (realistic handwritten photo aesthetic).
|
||||||
|
- `baoyu-xhs-images`: add `mindmap` (center radial) and `quadrant` (four-section grid) layouts.
|
||||||
|
|
||||||
|
## 1.25.4 - 2026-01-29
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
- `baoyu-markdown-to-html`: generate proper `<img>` tags with `data-local-path` attribute instead of text placeholders.
|
||||||
|
- `baoyu-post-to-wechat`: fix API publishing to read image paths from `data-local-path` attribute; fix title/cover extraction from corresponding `.md` frontmatter when publishing HTML files.
|
||||||
|
- `baoyu-post-to-wechat`: fix CLI argument parsing to handle unknown parameters gracefully; add `--summary` parameter support.
|
||||||
|
- `baoyu-post-to-wechat`: fix browser publishing to convert `<img>` tags back to text placeholders before paste.
|
||||||
|
|
||||||
|
## 1.25.3 - 2026-01-28
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- `baoyu-format-markdown`: add content type detection with user confirmation for markdown files; add CJK punctuation handling to move paired punctuation outside emphasis markers.
|
||||||
|
|
||||||
|
## 1.25.2 - 2026-01-28
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
- `baoyu-post-to-wechat`: add WeChat API credentials configuration guide to README.
|
||||||
|
|
||||||
|
## 1.25.1 - 2026-01-28
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- `baoyu-markdown-to-html`: add pre-check step for CJK content to suggest formatting with `baoyu-format-markdown` before conversion.
|
||||||
|
|
||||||
|
## 1.25.0 - 2026-01-28
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- `baoyu-format-markdown`: add markdown formatter skill with frontmatter, typography, and CJK spacing support.
|
||||||
|
- `baoyu-markdown-to-html`: add markdown to HTML converter with WeChat-compatible themes, code highlighting, math, PlantUML, and alerts.
|
||||||
|
- `baoyu-post-to-wechat`: add API-based publishing method and external theme support.
|
||||||
|
|
||||||
## 1.24.4 - 2026-01-28
|
## 1.24.4 - 2026-01-28
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
|
|||||||
@@ -2,6 +2,50 @@
|
|||||||
|
|
||||||
[English](./CHANGELOG.md) | 中文
|
[English](./CHANGELOG.md) | 中文
|
||||||
|
|
||||||
|
## 1.26.1 - 2026-01-29
|
||||||
|
|
||||||
|
### 新功能
|
||||||
|
- `baoyu-article-illustrator`、`baoyu-comic`、`baoyu-cover-image`、`baoyu-infographic`、`baoyu-slide-deck`、`baoyu-xhs-images`:新增文件备份规则,覆盖前自动将现有源文件、提示词和图片重命名为带时间戳后缀的备份文件。
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
- `baoyu-xhs-images`:移除 `notebook` 风格(保留 10 种风格)。
|
||||||
|
|
||||||
|
## 1.26.0 - 2026-01-29
|
||||||
|
|
||||||
|
### 新功能
|
||||||
|
- `baoyu-xhs-images`:新增 `notebook` 风格(水彩渲染手绘信息图 + 莫兰迪配色)和 `study-notes` 风格(真实手写照片美学)。
|
||||||
|
- `baoyu-xhs-images`:新增 `mindmap`(中心发散式)和 `quadrant`(四象限)布局。
|
||||||
|
|
||||||
|
## 1.25.4 - 2026-01-29
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
- `baoyu-markdown-to-html`:生成带 `data-local-path` 属性的 `<img>` 标签,而非纯文本占位符。
|
||||||
|
- `baoyu-post-to-wechat`:修复 API 发布时从 `data-local-path` 属性读取图片路径;修复发布 HTML 文件时从对应 `.md` 的 frontmatter 提取标题和封面图。
|
||||||
|
- `baoyu-post-to-wechat`:修复命令行参数解析,正确跳过未知参数;新增 `--summary` 参数支持。
|
||||||
|
- `baoyu-post-to-wechat`:修复浏览器发布模式,粘贴前将 `<img>` 标签转换回文本占位符。
|
||||||
|
|
||||||
|
## 1.25.3 - 2026-01-28
|
||||||
|
|
||||||
|
### 新功能
|
||||||
|
- `baoyu-format-markdown`:新增内容类型检测,对已有 markdown 格式的文件提供用户确认选项;新增 CJK 配对标点处理,将括号、引号等标点移出加粗标记外。
|
||||||
|
|
||||||
|
## 1.25.2 - 2026-01-28
|
||||||
|
|
||||||
|
### 文档
|
||||||
|
- `baoyu-post-to-wechat`:README 新增微信公众号 API 凭证配置说明。
|
||||||
|
|
||||||
|
## 1.25.1 - 2026-01-28
|
||||||
|
|
||||||
|
### 新功能
|
||||||
|
- `baoyu-markdown-to-html`:新增中文内容预检查,建议在转换前使用 `baoyu-format-markdown` 格式化以修复加粗标点问题。
|
||||||
|
|
||||||
|
## 1.25.0 - 2026-01-28
|
||||||
|
|
||||||
|
### 新功能
|
||||||
|
- `baoyu-format-markdown`:新增 markdown 格式化技能,支持 frontmatter、排版优化和中英文空格处理。
|
||||||
|
- `baoyu-markdown-to-html`:新增 markdown 转 HTML 技能,支持微信兼容主题、代码高亮、数学公式、PlantUML 和 alerts。
|
||||||
|
- `baoyu-post-to-wechat`:新增 API 发布方式和外部主题支持。
|
||||||
|
|
||||||
## 1.24.4 - 2026-01-28
|
## 1.24.4 - 2026-01-28
|
||||||
|
|
||||||
### 修复
|
### 修复
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ Simply tell Claude Code:
|
|||||||
|--------|-------------|--------|
|
|--------|-------------|--------|
|
||||||
| **content-skills** | Content generation and publishing | [xhs-images](#baoyu-xhs-images), [infographic](#baoyu-infographic), [cover-image](#baoyu-cover-image), [slide-deck](#baoyu-slide-deck), [comic](#baoyu-comic), [article-illustrator](#baoyu-article-illustrator), [post-to-x](#baoyu-post-to-x), [post-to-wechat](#baoyu-post-to-wechat) |
|
| **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) |
|
| **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
|
## Update Skills
|
||||||
|
|
||||||
@@ -535,7 +535,28 @@ Post content to WeChat Official Account (微信公众号). Two modes available:
|
|||||||
/baoyu-post-to-wechat 文章 --html article.html
|
/baoyu-post-to-wechat 文章 --html article.html
|
||||||
```
|
```
|
||||||
|
|
||||||
Prerequisites: Google Chrome installed. First run requires QR code login (session preserved).
|
**Publishing Methods**:
|
||||||
|
|
||||||
|
| Method | Speed | Requirements |
|
||||||
|
|--------|-------|--------------|
|
||||||
|
| API (Recommended) | Fast | API credentials |
|
||||||
|
| Browser | Slow | Chrome, login session |
|
||||||
|
|
||||||
|
**API Configuration** (for faster publishing):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Add to .baoyu-skills/.env (project-level) or ~/.baoyu-skills/.env (user-level)
|
||||||
|
WECHAT_APP_ID=your_app_id
|
||||||
|
WECHAT_APP_SECRET=your_app_secret
|
||||||
|
```
|
||||||
|
|
||||||
|
To obtain credentials:
|
||||||
|
1. Visit https://developers.weixin.qq.com/platform/
|
||||||
|
2. Go to: 我的业务 → 公众号 → 开发密钥
|
||||||
|
3. Create development key and copy AppID/AppSecret
|
||||||
|
4. Add your machine's IP to the whitelist
|
||||||
|
|
||||||
|
**Browser Method** (no API setup needed): Requires Google Chrome. First run opens browser for QR code login (session preserved).
|
||||||
|
|
||||||
### AI Generation Skills
|
### AI Generation Skills
|
||||||
|
|
||||||
@@ -678,6 +699,43 @@ Compress images to reduce file size while maintaining quality.
|
|||||||
/baoyu-compress-image path/to/images/ --quality 80
|
/baoyu-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
|
## Environment Configuration
|
||||||
|
|
||||||
Some skills require API keys or custom configuration. Environment variables can be set in `.env` files:
|
Some skills require API keys or custom configuration. Environment variables can be set in `.env` files:
|
||||||
|
|||||||
+60
-2
@@ -55,7 +55,7 @@ npx skills add jimliu/baoyu-skills
|
|||||||
|------|------|----------|
|
|------|------|----------|
|
||||||
| **content-skills** | 内容生成和发布 | [xhs-images](#baoyu-xhs-images), [infographic](#baoyu-infographic), [cover-image](#baoyu-cover-image), [slide-deck](#baoyu-slide-deck), [comic](#baoyu-comic), [article-illustrator](#baoyu-article-illustrator), [post-to-x](#baoyu-post-to-x), [post-to-wechat](#baoyu-post-to-wechat) |
|
| **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) |
|
| **ai-generation-skills** | AI 生成后端 | [image-gen](#baoyu-image-gen), [danger-gemini-web](#baoyu-danger-gemini-web) |
|
||||||
| **utility-skills** | 内容处理工具 | [url-to-markdown](#baoyu-url-to-markdown), [danger-x-to-markdown](#baoyu-danger-x-to-markdown), [compress-image](#baoyu-compress-image) |
|
| **utility-skills** | 内容处理工具 | [url-to-markdown](#baoyu-url-to-markdown), [danger-x-to-markdown](#baoyu-danger-x-to-markdown), [compress-image](#baoyu-compress-image), [format-markdown](#baoyu-format-markdown) |
|
||||||
|
|
||||||
## 更新技能
|
## 更新技能
|
||||||
|
|
||||||
@@ -535,7 +535,28 @@ npx skills add jimliu/baoyu-skills
|
|||||||
/baoyu-post-to-wechat 文章 --html article.html
|
/baoyu-post-to-wechat 文章 --html article.html
|
||||||
```
|
```
|
||||||
|
|
||||||
前置要求:已安装 Google Chrome,首次运行需扫码登录(登录状态会保存)
|
**发布方式**:
|
||||||
|
|
||||||
|
| 方式 | 速度 | 要求 |
|
||||||
|
|------|------|------|
|
||||||
|
| API(推荐) | 快 | API 凭证 |
|
||||||
|
| 浏览器 | 慢 | Chrome,登录会话 |
|
||||||
|
|
||||||
|
**API 配置**(更快的发布方式):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 添加到 .baoyu-skills/.env(项目级)或 ~/.baoyu-skills/.env(用户级)
|
||||||
|
WECHAT_APP_ID=你的AppID
|
||||||
|
WECHAT_APP_SECRET=你的AppSecret
|
||||||
|
```
|
||||||
|
|
||||||
|
获取凭证方法:
|
||||||
|
1. 访问 https://developers.weixin.qq.com/platform/
|
||||||
|
2. 进入:我的业务 → 公众号 → 开发密钥
|
||||||
|
3. 添加开发密钥,复制 AppID 和 AppSecret
|
||||||
|
4. 将你操作的机器 IP 加入白名单
|
||||||
|
|
||||||
|
**浏览器方式**(无需 API 配置):需已安装 Google Chrome,首次运行需扫码登录(登录状态会保存)
|
||||||
|
|
||||||
### AI 生成技能 (AI Generation Skills)
|
### AI 生成技能 (AI Generation Skills)
|
||||||
|
|
||||||
@@ -678,6 +699,43 @@ AI 驱动的生成后端。
|
|||||||
/baoyu-compress-image path/to/images/ --quality 80
|
/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` 文件中设置:
|
部分技能需要 API 密钥或自定义配置。环境变量可以在 `.env` 文件中设置:
|
||||||
|
|||||||
@@ -78,6 +78,8 @@ Progress:
|
|||||||
| File path | Ask user (1.2) | → 1.2 |
|
| File path | Ask user (1.2) | → 1.2 |
|
||||||
| Pasted content | `illustrations/{topic-slug}/` | → 1.4 |
|
| Pasted content | `illustrations/{topic-slug}/` | → 1.4 |
|
||||||
|
|
||||||
|
**Backup rule for pasted content**: If `source.md` exists in target directory, rename to `source-backup-YYYYMMDD-HHMMSS.md` before saving.
|
||||||
|
|
||||||
**1.2 Determine Output Directory** (file path input only)
|
**1.2 Determine Output Directory** (file path input only)
|
||||||
|
|
||||||
Check `default_output_dir` in preferences:
|
Check `default_output_dir` in preferences:
|
||||||
@@ -236,6 +238,7 @@ image_count: 4
|
|||||||
**5.1 Create Prompts**
|
**5.1 Create Prompts**
|
||||||
|
|
||||||
Follow [references/prompt-construction.md](references/prompt-construction.md). Save to `prompts/illustration-{slug}.md`.
|
Follow [references/prompt-construction.md](references/prompt-construction.md). Save to `prompts/illustration-{slug}.md`.
|
||||||
|
- **Backup rule**: If prompt file exists, rename to `prompts/illustration-{slug}-backup-YYYYMMDD-HHMMSS.md`
|
||||||
|
|
||||||
**5.2 Select Generation Skill**
|
**5.2 Select Generation Skill**
|
||||||
|
|
||||||
@@ -247,7 +250,9 @@ Add: `Include a subtle watermark "[content]" at [position].`
|
|||||||
|
|
||||||
**5.4 Generate**
|
**5.4 Generate**
|
||||||
|
|
||||||
1. Generate sequentially
|
1. For each illustration:
|
||||||
|
- **Backup rule**: If image file exists, rename to `NN-{type}-{slug}-backup-YYYYMMDD-HHMMSS.png`
|
||||||
|
- Generate image
|
||||||
2. After each: "Generated X/N"
|
2. After each: "Generated X/N"
|
||||||
3. On failure: retry once, then log and continue
|
3. On failure: retry once, then log and continue
|
||||||
|
|
||||||
|
|||||||
@@ -194,6 +194,7 @@ Input → Preferences → Analyze → [Check Existing?] → [Confirm: Style + Re
|
|||||||
**Character reference is MANDATORY for visual consistency.**
|
**Character reference is MANDATORY for visual consistency.**
|
||||||
|
|
||||||
**7.1 Generate character sheet first**:
|
**7.1 Generate character sheet first**:
|
||||||
|
- **Backup rule**: If `characters/characters.png` exists, rename to `characters/characters-backup-YYYYMMDD-HHMMSS.png`
|
||||||
```bash
|
```bash
|
||||||
# Use Reference Sheet Prompt from characters/characters.md
|
# Use Reference Sheet Prompt from characters/characters.md
|
||||||
npx -y bun ${SKILL_DIR}/../baoyu-image-gen/scripts/main.ts \
|
npx -y bun ${SKILL_DIR}/../baoyu-image-gen/scripts/main.ts \
|
||||||
@@ -214,6 +215,10 @@ Compress to reduce token usage when used as reference image:
|
|||||||
| Supports `--ref` | Pass `characters/characters.png` with EVERY page |
|
| Supports `--ref` | Pass `characters/characters.png` with EVERY page |
|
||||||
| No `--ref` support | Prepend character descriptions to EVERY prompt file |
|
| No `--ref` support | Prepend character descriptions to EVERY prompt file |
|
||||||
|
|
||||||
|
**Backup rules for page generation**:
|
||||||
|
- If prompt file exists: rename to `prompts/NN-{cover|page}-[slug]-backup-YYYYMMDD-HHMMSS.md`
|
||||||
|
- If image file exists: rename to `NN-{cover|page}-[slug]-backup-YYYYMMDD-HHMMSS.png`
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Example: ALWAYS include --ref for consistency
|
# Example: ALWAYS include --ref for consistency
|
||||||
npx -y bun ${SKILL_DIR}/../baoyu-image-gen/scripts/main.ts \
|
npx -y bun ${SKILL_DIR}/../baoyu-image-gen/scripts/main.ts \
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ Read source content, save it if needed, and perform deep analysis.
|
|||||||
1. **Save source content** (if not already a file):
|
1. **Save source content** (if not already a file):
|
||||||
- If user provides a file path: use as-is
|
- If user provides a file path: use as-is
|
||||||
- If user pastes content: save to `source.md` in target directory
|
- If user pastes content: save to `source.md` in target directory
|
||||||
|
- **Backup rule**: If `source.md` exists, rename to `source-backup-YYYYMMDD-HHMMSS.md`
|
||||||
2. Read source content
|
2. Read source content
|
||||||
3. **Deep analysis** following `analysis-framework.md`:
|
3. **Deep analysis** following `analysis-framework.md`:
|
||||||
- Target audience identification
|
- Target audience identification
|
||||||
@@ -329,6 +330,7 @@ Create image generation prompts for all pages.
|
|||||||
1. Create prompt following art style + tone guidelines
|
1. Create prompt following art style + tone guidelines
|
||||||
2. Include character visual descriptions for consistency
|
2. Include character visual descriptions for consistency
|
||||||
3. Save to `prompts/NN-{cover|page}-[slug].md`
|
3. Save to `prompts/NN-{cover|page}-[slug].md`
|
||||||
|
- **Backup rule**: If prompt file exists, rename to `prompts/NN-{cover|page}-[slug]-backup-YYYYMMDD-HHMMSS.md`
|
||||||
|
|
||||||
**Prompt File Format**:
|
**Prompt File Format**:
|
||||||
```markdown
|
```markdown
|
||||||
@@ -405,8 +407,9 @@ With confirmed prompts from Step 5/6:
|
|||||||
### 7.1 Generate Character Reference Sheet (first)
|
### 7.1 Generate Character Reference Sheet (first)
|
||||||
|
|
||||||
1. Use Reference Sheet Prompt from `characters/characters.md`
|
1. Use Reference Sheet Prompt from `characters/characters.md`
|
||||||
2. Generate → `characters/characters.png`
|
2. **Backup rule**: If `characters/characters.png` exists, rename to `characters/characters-backup-YYYYMMDD-HHMMSS.png`
|
||||||
3. This ensures visual consistency for all subsequent pages
|
3. Generate → `characters/characters.png`
|
||||||
|
4. This ensures visual consistency for all subsequent pages
|
||||||
|
|
||||||
### 7.2 Generate Comic Pages
|
### 7.2 Generate Comic Pages
|
||||||
|
|
||||||
@@ -453,9 +456,10 @@ When skill does NOT support reference images, create combined prompt files:
|
|||||||
|
|
||||||
**For each page (cover + pages)**:
|
**For each page (cover + pages)**:
|
||||||
1. Read prompt from `prompts/NN-{cover|page}-[slug].md`
|
1. Read prompt from `prompts/NN-{cover|page}-[slug].md`
|
||||||
2. Generate image using Strategy A or B (based on skill capability)
|
2. **Backup rule**: If image file exists, rename to `NN-{cover|page}-[slug]-backup-YYYYMMDD-HHMMSS.png`
|
||||||
3. Save to `NN-{cover|page}-[slug].png`
|
3. Generate image using Strategy A or B (based on skill capability)
|
||||||
4. Report progress after each generation: "Generated X/N: [page title]"
|
4. Save to `NN-{cover|page}-[slug].png`
|
||||||
|
5. Report progress after each generation: "Generated X/N: [page title]"
|
||||||
|
|
||||||
**Session Management**:
|
**Session Management**:
|
||||||
If image generation skill supports `--sessionId`:
|
If image generation skill supports `--sessionId`:
|
||||||
|
|||||||
@@ -209,6 +209,7 @@ Schema: [references/config/preferences-schema.md](references/config/preferences-
|
|||||||
### Step 1: Analyze Content
|
### Step 1: Analyze Content
|
||||||
|
|
||||||
1. **Save source content** (if pasted, save to `source.md` in target directory; if file path, use as-is)
|
1. **Save source content** (if pasted, save to `source.md` in target directory; if file path, use as-is)
|
||||||
|
- **Backup rule**: If `source.md` exists, rename to `source-backup-YYYYMMDD-HHMMSS.md`
|
||||||
2. **Content analysis**: Extract topic, core message, tone, keywords; identify visual metaphors; detect content type
|
2. **Content analysis**: Extract topic, core message, tone, keywords; identify visual metaphors; detect content type
|
||||||
3. **Language detection**: Detect source language, note user's input language, compare with EXTEND.md preference
|
3. **Language detection**: Detect source language, note user's input language, compare with EXTEND.md preference
|
||||||
4. **Determine output directory** per File Structure rules. If no `default_output_dir` preference + file path input, include in Step 2 Q4
|
4. **Determine output directory** per File Structure rules. If no `default_output_dir` preference + file path input, include in Step 2 Q4
|
||||||
@@ -226,6 +227,8 @@ Validate all 5 dimensions + aspect ratio. Full confirmation flow: [references/wo
|
|||||||
|
|
||||||
### Step 3: Create Prompt
|
### Step 3: Create Prompt
|
||||||
|
|
||||||
|
**Backup rule**: If `prompts/cover.md` exists, rename to `prompts/cover-backup-YYYYMMDD-HHMMSS.md`
|
||||||
|
|
||||||
Save to `prompts/cover.md`. Full template: [references/workflow/prompt-template.md](references/workflow/prompt-template.md)
|
Save to `prompts/cover.md`. Full template: [references/workflow/prompt-template.md](references/workflow/prompt-template.md)
|
||||||
|
|
||||||
### Step 4: Generate Image
|
### Step 4: Generate Image
|
||||||
|
|||||||
@@ -0,0 +1,298 @@
|
|||||||
|
---
|
||||||
|
name: baoyu-format-markdown
|
||||||
|
description: Formats plain text or markdown files with frontmatter, titles, summaries, headings, bold, lists, and code blocks. Use when user asks to "format markdown", "beautify article", "add formatting", or improve article layout. Outputs to {filename}-formatted.md.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Markdown Formatter
|
||||||
|
|
||||||
|
Transforms plain text or markdown files into well-structured markdown with proper frontmatter, formatting, and typography.
|
||||||
|
|
||||||
|
## Script Directory
|
||||||
|
|
||||||
|
Scripts in `scripts/` subdirectory. Replace `${SKILL_DIR}` with this SKILL.md's directory path.
|
||||||
|
|
||||||
|
| Script | Purpose |
|
||||||
|
|--------|---------|
|
||||||
|
| `scripts/main.ts` | Main entry point with CLI options (uses remark-cjk-friendly for CJK emphasis) |
|
||||||
|
| `scripts/quotes.ts` | Replace ASCII quotes with fullwidth quotes |
|
||||||
|
| `scripts/autocorrect.ts` | Add CJK/English spacing via autocorrect |
|
||||||
|
|
||||||
|
## Preferences (EXTEND.md)
|
||||||
|
|
||||||
|
Use Bash to check EXTEND.md existence (priority order):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check project-level first
|
||||||
|
test -f .baoyu-skills/baoyu-format-markdown/EXTEND.md && echo "project"
|
||||||
|
|
||||||
|
# Then user-level (cross-platform: $HOME works on macOS/Linux/WSL)
|
||||||
|
test -f "$HOME/.baoyu-skills/baoyu-format-markdown/EXTEND.md" && echo "user"
|
||||||
|
```
|
||||||
|
|
||||||
|
┌──────────────────────────────────────────────────────────┬───────────────────┐
|
||||||
|
│ Path │ Location │
|
||||||
|
├──────────────────────────────────────────────────────────┼───────────────────┤
|
||||||
|
│ .baoyu-skills/baoyu-format-markdown/EXTEND.md │ Project directory │
|
||||||
|
├──────────────────────────────────────────────────────────┼───────────────────┤
|
||||||
|
│ $HOME/.baoyu-skills/baoyu-format-markdown/EXTEND.md │ User home │
|
||||||
|
└──────────────────────────────────────────────────────────┴───────────────────┘
|
||||||
|
|
||||||
|
┌───────────┬───────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Result │ Action │
|
||||||
|
├───────────┼───────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ Found │ Read, parse, apply settings │
|
||||||
|
├───────────┼───────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ Not found │ Use defaults │
|
||||||
|
└───────────┴───────────────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
**EXTEND.md Supports**: Default formatting options | Summary length preferences
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
Claude performs content analysis and formatting (Steps 1-6), then runs the script for typography fixes (Step 7).
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
### Step 1: Read Source File
|
||||||
|
|
||||||
|
Read the user-specified markdown or plain text file.
|
||||||
|
|
||||||
|
### Step 1.5: Detect Content Type & Confirm
|
||||||
|
|
||||||
|
**Content Type Detection:**
|
||||||
|
|
||||||
|
| Indicator | Classification |
|
||||||
|
|-----------|----------------|
|
||||||
|
| Has `---` YAML frontmatter | Markdown |
|
||||||
|
| Has `#`, `##`, `###` headings | Markdown |
|
||||||
|
| Has `**bold**`, `*italic*` | Markdown |
|
||||||
|
| Has `- ` or `1. ` lists | Markdown |
|
||||||
|
| Has ``` code blocks | Markdown |
|
||||||
|
| Has `> ` blockquotes | Markdown |
|
||||||
|
| None of above | Plain text |
|
||||||
|
|
||||||
|
**Decision Flow:**
|
||||||
|
|
||||||
|
┌─────────────────┬────────────────────────────────────────────────┐
|
||||||
|
│ Content Type │ Action │
|
||||||
|
├─────────────────┼────────────────────────────────────────────────┤
|
||||||
|
│ Plain text │ Proceed to Step 2 (format to markdown) │
|
||||||
|
├─────────────────┼────────────────────────────────────────────────┤
|
||||||
|
│ Markdown │ Use AskUserQuestion to confirm optimization │
|
||||||
|
└─────────────────┴────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
**If Markdown detected, ask user:**
|
||||||
|
|
||||||
|
```
|
||||||
|
Detected existing markdown formatting. What would you like to do?
|
||||||
|
|
||||||
|
1. Optimize formatting (Recommended)
|
||||||
|
- Add/improve frontmatter, headings, bold, lists
|
||||||
|
- Run typography script (spacing, emphasis fixes)
|
||||||
|
- Output: {filename}-formatted.md
|
||||||
|
|
||||||
|
2. Keep original formatting
|
||||||
|
- Preserve existing markdown structure
|
||||||
|
- Run typography script (spacing, emphasis fixes)
|
||||||
|
- Output: {filename}-formatted.md
|
||||||
|
|
||||||
|
3. Typography fixes only
|
||||||
|
- Run typography script on original file in-place
|
||||||
|
- No copy created, modifies original file directly
|
||||||
|
```
|
||||||
|
|
||||||
|
**Based on user choice:**
|
||||||
|
- **Optimize**: Continue to Step 2-8 (full workflow)
|
||||||
|
- **Keep original**: Skip Steps 2-5, copy file → Step 6-8 (run script on copy)
|
||||||
|
- **Typography only**: Skip Steps 2-6, run Step 7 on original file directly
|
||||||
|
|
||||||
|
### Step 2: Analyze Content Structure
|
||||||
|
|
||||||
|
Identify:
|
||||||
|
- Existing title (H1 `#`)
|
||||||
|
- Paragraph separations
|
||||||
|
- Keywords suitable for **bold**
|
||||||
|
- Parallel content convertible to lists
|
||||||
|
- Code snippets
|
||||||
|
- Quotations
|
||||||
|
|
||||||
|
### Step 3: Check/Create Frontmatter
|
||||||
|
|
||||||
|
Check for YAML frontmatter (`---` block). Create if missing.
|
||||||
|
|
||||||
|
**Meta field handling:**
|
||||||
|
|
||||||
|
| Field | Processing |
|
||||||
|
|-------|------------|
|
||||||
|
| `title` | See Step 4 |
|
||||||
|
| `slug` | Infer from file path (e.g., `posts/2026/01/10/vibe-coding/` → `vibe-coding`) or generate from title |
|
||||||
|
| `summary` | Generate engaging summary (100-150 characters) |
|
||||||
|
| `featureImage` | Check if `imgs/cover.png` exists in same directory; if so, use relative path |
|
||||||
|
|
||||||
|
### Step 4: Title Handling
|
||||||
|
|
||||||
|
**Logic:**
|
||||||
|
1. If frontmatter already has `title` → use it, no H1 in body
|
||||||
|
2. If first line is H1 → extract to frontmatter `title`, remove H1 from body
|
||||||
|
3. If neither exists → generate candidate titles
|
||||||
|
|
||||||
|
**Title generation flow:**
|
||||||
|
|
||||||
|
1. Generate 3 candidate titles based on content
|
||||||
|
2. Use `AskUserQuestion` tool:
|
||||||
|
|
||||||
|
```
|
||||||
|
Select a title:
|
||||||
|
|
||||||
|
1. [Title A] (Recommended)
|
||||||
|
2. [Title B]
|
||||||
|
3. [Title C]
|
||||||
|
```
|
||||||
|
|
||||||
|
3. If no selection within a few seconds, use recommended (option 1)
|
||||||
|
|
||||||
|
**Title principles:**
|
||||||
|
- Concise, max 20 characters
|
||||||
|
- Captures core message
|
||||||
|
- Engaging, sparks reading interest
|
||||||
|
- Accurate, avoids clickbait
|
||||||
|
|
||||||
|
**Important:** Once title is in frontmatter, body should NOT have H1 (avoid duplication)
|
||||||
|
|
||||||
|
### Step 5: Format Processing
|
||||||
|
|
||||||
|
**Formatting rules:**
|
||||||
|
|
||||||
|
| Element | Format |
|
||||||
|
|---------|--------|
|
||||||
|
| Titles | Use `#`, `##`, `###` hierarchy |
|
||||||
|
| Key points | Use `**bold**` |
|
||||||
|
| Parallel items | Convert to `-` unordered or `1.` ordered lists |
|
||||||
|
| Code/commands | Use `` `inline` `` or ` ```block``` ` |
|
||||||
|
| Quotes/sayings | Use `>` blockquote |
|
||||||
|
| Separators | Use `---` where appropriate |
|
||||||
|
|
||||||
|
**Formatting principles:**
|
||||||
|
- Preserve original content and viewpoints
|
||||||
|
- Add formatting only, do not modify text
|
||||||
|
- Formatting serves readability
|
||||||
|
- Avoid over-formatting
|
||||||
|
|
||||||
|
### Step 6: Save Formatted File
|
||||||
|
|
||||||
|
Save as `{original-filename}-formatted.md`
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- `final.md` → `final-formatted.md`
|
||||||
|
- `draft-v1.md` → `draft-v1-formatted.md`
|
||||||
|
|
||||||
|
**If user chose "Keep original formatting" (from Step 1.5):**
|
||||||
|
- Copy original file to `{filename}-formatted.md` without modifications
|
||||||
|
- Proceed to Step 7 for typography fixes only
|
||||||
|
|
||||||
|
**Backup existing file:**
|
||||||
|
|
||||||
|
If `{filename}-formatted.md` already exists, backup before overwriting:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check if formatted file exists
|
||||||
|
if [ -f "{filename}-formatted.md" ]; then
|
||||||
|
# Backup with timestamp
|
||||||
|
mv "{filename}-formatted.md" "{filename}-formatted.backup-$(date +%Y%m%d-%H%M%S).md"
|
||||||
|
fi
|
||||||
|
```
|
||||||
|
|
||||||
|
Example:
|
||||||
|
- `final-formatted.md` exists → backup to `final-formatted.backup-20260128-143052.md`
|
||||||
|
|
||||||
|
### Step 7: Execute Text Formatting Script
|
||||||
|
|
||||||
|
After saving, **must** run the formatting script:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx -y bun ${SKILL_DIR}/scripts/main.ts {output-file-path} [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Script Options:**
|
||||||
|
|
||||||
|
| Option | Short | Description | Default |
|
||||||
|
|--------|-------|-------------|---------|
|
||||||
|
| `--quotes` | `-q` | Replace ASCII quotes with fullwidth quotes `"..."` | false |
|
||||||
|
| `--no-quotes` | | Do not replace quotes | |
|
||||||
|
| `--spacing` | `-s` | Add CJK/English spacing via autocorrect | true |
|
||||||
|
| `--no-spacing` | | Do not add CJK/English spacing | |
|
||||||
|
| `--emphasis` | `-e` | Fix CJK emphasis punctuation issues | true |
|
||||||
|
| `--no-emphasis` | | Do not fix CJK emphasis issues | |
|
||||||
|
| `--help` | `-h` | Show help message | |
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Default: spacing + emphasis enabled, quotes disabled
|
||||||
|
npx -y bun ${SKILL_DIR}/scripts/main.ts article.md
|
||||||
|
|
||||||
|
# Enable all features including quote replacement
|
||||||
|
npx -y bun ${SKILL_DIR}/scripts/main.ts article.md --quotes
|
||||||
|
|
||||||
|
# Only fix emphasis issues, skip spacing
|
||||||
|
npx -y bun ${SKILL_DIR}/scripts/main.ts article.md --no-spacing
|
||||||
|
|
||||||
|
# Disable all processing except frontmatter formatting
|
||||||
|
npx -y bun ${SKILL_DIR}/scripts/main.ts article.md --no-spacing --no-emphasis
|
||||||
|
```
|
||||||
|
|
||||||
|
**Script performs (based on options):**
|
||||||
|
1. Fix CJK emphasis/bold punctuation issues (default: enabled)
|
||||||
|
2. Add CJK/English mixed text spacing via autocorrect (default: enabled)
|
||||||
|
3. Replace ASCII quotes `"..."` with fullwidth quotes `"..."` (default: disabled)
|
||||||
|
4. Format frontmatter YAML (always enabled)
|
||||||
|
|
||||||
|
### Step 8: Display Results
|
||||||
|
|
||||||
|
```
|
||||||
|
**Formatting complete**
|
||||||
|
|
||||||
|
File: posts/2026/01/09/example/final-formatted.md
|
||||||
|
|
||||||
|
Changes:
|
||||||
|
- Added title: [title content]
|
||||||
|
- Added X bold markers
|
||||||
|
- Added X lists
|
||||||
|
- Added X code blocks
|
||||||
|
```
|
||||||
|
|
||||||
|
## Formatting Example
|
||||||
|
|
||||||
|
**Before:**
|
||||||
|
```
|
||||||
|
This is plain text. First point is efficiency improvement. Second point is cost reduction. Third point is experience optimization. Use npm install to install dependencies.
|
||||||
|
```
|
||||||
|
|
||||||
|
**After:**
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
title: Three Core Advantages
|
||||||
|
slug: three-core-advantages
|
||||||
|
summary: Discover the three key benefits that drive success in modern projects.
|
||||||
|
---
|
||||||
|
|
||||||
|
This is plain text.
|
||||||
|
|
||||||
|
**Main advantages:**
|
||||||
|
- Efficiency improvement
|
||||||
|
- Cost reduction
|
||||||
|
- Experience optimization
|
||||||
|
|
||||||
|
Use `npm install` to install dependencies.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Preserve original writing style and tone
|
||||||
|
- Specify correct language for code blocks (e.g., `python`, `javascript`)
|
||||||
|
- Maintain CJK/English spacing standards
|
||||||
|
- Do not add content not present in original
|
||||||
|
|
||||||
|
## Extension Support
|
||||||
|
|
||||||
|
Custom configurations via EXTEND.md. See **Preferences** section for paths and supported options.
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { execSync } from "child_process";
|
||||||
|
|
||||||
|
export function applyAutocorrect(filePath: string): boolean {
|
||||||
|
try {
|
||||||
|
execSync(`npx autocorrect-node --fix "${filePath}"`, { stdio: "inherit" });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
{
|
||||||
|
"lockfileVersion": 1,
|
||||||
|
"workspaces": {
|
||||||
|
"": {
|
||||||
|
"dependencies": {
|
||||||
|
"remark-frontmatter": "^5.0.0",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
|
"remark-parse": "^11.0.0",
|
||||||
|
"remark-stringify": "^11.0.0",
|
||||||
|
"unified": "^11.0.5",
|
||||||
|
"unist-util-visit": "^5.1.0",
|
||||||
|
"yaml": "^2.8.2",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"packages": {
|
||||||
|
"@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
|
||||||
|
|
||||||
|
"@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
|
||||||
|
|
||||||
|
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
|
||||||
|
|
||||||
|
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
|
||||||
|
|
||||||
|
"bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
|
||||||
|
|
||||||
|
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
|
||||||
|
|
||||||
|
"character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="],
|
||||||
|
|
||||||
|
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||||
|
|
||||||
|
"decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="],
|
||||||
|
|
||||||
|
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
|
||||||
|
|
||||||
|
"devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
|
||||||
|
|
||||||
|
"escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
|
||||||
|
|
||||||
|
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
|
||||||
|
|
||||||
|
"fault": ["fault@2.0.1", "", { "dependencies": { "format": "^0.2.0" } }, "sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ=="],
|
||||||
|
|
||||||
|
"format": ["format@0.2.2", "", {}, "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww=="],
|
||||||
|
|
||||||
|
"is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
|
||||||
|
|
||||||
|
"longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="],
|
||||||
|
|
||||||
|
"markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="],
|
||||||
|
|
||||||
|
"mdast-util-find-and-replace": ["mdast-util-find-and-replace@3.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="],
|
||||||
|
|
||||||
|
"mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA=="],
|
||||||
|
|
||||||
|
"mdast-util-frontmatter": ["mdast-util-frontmatter@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "escape-string-regexp": "^5.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "micromark-extension-frontmatter": "^2.0.0" } }, "sha512-LRqI9+wdgC25P0URIJY9vwocIzCcksduHQ9OF2joxQoyTNVduwLAFUzjoopuRJbJAReaKrNQKAZKL3uCMugWJA=="],
|
||||||
|
|
||||||
|
"mdast-util-gfm": ["mdast-util-gfm@3.1.0", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-gfm-autolink-literal": "^2.0.0", "mdast-util-gfm-footnote": "^2.0.0", "mdast-util-gfm-strikethrough": "^2.0.0", "mdast-util-gfm-table": "^2.0.0", "mdast-util-gfm-task-list-item": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ=="],
|
||||||
|
|
||||||
|
"mdast-util-gfm-autolink-literal": ["mdast-util-gfm-autolink-literal@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "ccount": "^2.0.0", "devlop": "^1.0.0", "mdast-util-find-and-replace": "^3.0.0", "micromark-util-character": "^2.0.0" } }, "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ=="],
|
||||||
|
|
||||||
|
"mdast-util-gfm-footnote": ["mdast-util-gfm-footnote@2.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0" } }, "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ=="],
|
||||||
|
|
||||||
|
"mdast-util-gfm-strikethrough": ["mdast-util-gfm-strikethrough@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg=="],
|
||||||
|
|
||||||
|
"mdast-util-gfm-table": ["mdast-util-gfm-table@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "markdown-table": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg=="],
|
||||||
|
|
||||||
|
"mdast-util-gfm-task-list-item": ["mdast-util-gfm-task-list-item@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ=="],
|
||||||
|
|
||||||
|
"mdast-util-phrasing": ["mdast-util-phrasing@4.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "unist-util-is": "^6.0.0" } }, "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w=="],
|
||||||
|
|
||||||
|
"mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA=="],
|
||||||
|
|
||||||
|
"mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="],
|
||||||
|
|
||||||
|
"micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="],
|
||||||
|
|
||||||
|
"micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="],
|
||||||
|
|
||||||
|
"micromark-extension-frontmatter": ["micromark-extension-frontmatter@2.0.0", "", { "dependencies": { "fault": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-C4AkuM3dA58cgZha7zVnuVxBhDsbttIMiytjgsM2XbHAB2faRVaHRle40558FBN+DJcrLNCoqG5mlrpdU4cRtg=="],
|
||||||
|
|
||||||
|
"micromark-extension-gfm": ["micromark-extension-gfm@3.0.0", "", { "dependencies": { "micromark-extension-gfm-autolink-literal": "^2.0.0", "micromark-extension-gfm-footnote": "^2.0.0", "micromark-extension-gfm-strikethrough": "^2.0.0", "micromark-extension-gfm-table": "^2.0.0", "micromark-extension-gfm-tagfilter": "^2.0.0", "micromark-extension-gfm-task-list-item": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w=="],
|
||||||
|
|
||||||
|
"micromark-extension-gfm-autolink-literal": ["micromark-extension-gfm-autolink-literal@2.1.0", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw=="],
|
||||||
|
|
||||||
|
"micromark-extension-gfm-footnote": ["micromark-extension-gfm-footnote@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw=="],
|
||||||
|
|
||||||
|
"micromark-extension-gfm-strikethrough": ["micromark-extension-gfm-strikethrough@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw=="],
|
||||||
|
|
||||||
|
"micromark-extension-gfm-table": ["micromark-extension-gfm-table@2.1.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg=="],
|
||||||
|
|
||||||
|
"micromark-extension-gfm-tagfilter": ["micromark-extension-gfm-tagfilter@2.0.0", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg=="],
|
||||||
|
|
||||||
|
"micromark-extension-gfm-task-list-item": ["micromark-extension-gfm-task-list-item@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw=="],
|
||||||
|
|
||||||
|
"micromark-factory-destination": ["micromark-factory-destination@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="],
|
||||||
|
|
||||||
|
"micromark-factory-label": ["micromark-factory-label@2.0.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg=="],
|
||||||
|
|
||||||
|
"micromark-factory-space": ["micromark-factory-space@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg=="],
|
||||||
|
|
||||||
|
"micromark-factory-title": ["micromark-factory-title@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw=="],
|
||||||
|
|
||||||
|
"micromark-factory-whitespace": ["micromark-factory-whitespace@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ=="],
|
||||||
|
|
||||||
|
"micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="],
|
||||||
|
|
||||||
|
"micromark-util-chunked": ["micromark-util-chunked@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA=="],
|
||||||
|
|
||||||
|
"micromark-util-classify-character": ["micromark-util-classify-character@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q=="],
|
||||||
|
|
||||||
|
"micromark-util-combine-extensions": ["micromark-util-combine-extensions@2.0.1", "", { "dependencies": { "micromark-util-chunked": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg=="],
|
||||||
|
|
||||||
|
"micromark-util-decode-numeric-character-reference": ["micromark-util-decode-numeric-character-reference@2.0.2", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw=="],
|
||||||
|
|
||||||
|
"micromark-util-decode-string": ["micromark-util-decode-string@2.0.1", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ=="],
|
||||||
|
|
||||||
|
"micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="],
|
||||||
|
|
||||||
|
"micromark-util-html-tag-name": ["micromark-util-html-tag-name@2.0.1", "", {}, "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA=="],
|
||||||
|
|
||||||
|
"micromark-util-normalize-identifier": ["micromark-util-normalize-identifier@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q=="],
|
||||||
|
|
||||||
|
"micromark-util-resolve-all": ["micromark-util-resolve-all@2.0.1", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg=="],
|
||||||
|
|
||||||
|
"micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="],
|
||||||
|
|
||||||
|
"micromark-util-subtokenize": ["micromark-util-subtokenize@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA=="],
|
||||||
|
|
||||||
|
"micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="],
|
||||||
|
|
||||||
|
"micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="],
|
||||||
|
|
||||||
|
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||||
|
|
||||||
|
"remark-frontmatter": ["remark-frontmatter@5.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-frontmatter": "^2.0.0", "micromark-extension-frontmatter": "^2.0.0", "unified": "^11.0.0" } }, "sha512-XTFYvNASMe5iPN0719nPrdItC9aU0ssC4v14mH1BCi1u0n1gAocqcujWUrByftZTbLhRtiKRyjYTSIOcr69UVQ=="],
|
||||||
|
|
||||||
|
"remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="],
|
||||||
|
|
||||||
|
"remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="],
|
||||||
|
|
||||||
|
"remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="],
|
||||||
|
|
||||||
|
"trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="],
|
||||||
|
|
||||||
|
"unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="],
|
||||||
|
|
||||||
|
"unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="],
|
||||||
|
|
||||||
|
"unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="],
|
||||||
|
|
||||||
|
"unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="],
|
||||||
|
|
||||||
|
"unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="],
|
||||||
|
|
||||||
|
"vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
|
||||||
|
|
||||||
|
"vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
|
||||||
|
|
||||||
|
"yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="],
|
||||||
|
|
||||||
|
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import { readFileSync, writeFileSync } from "fs";
|
||||||
|
import { unified } from "unified";
|
||||||
|
import remarkParse from "remark-parse";
|
||||||
|
import remarkCjkFriendly from "remark-cjk-friendly";
|
||||||
|
import remarkGfm from "remark-gfm";
|
||||||
|
import remarkFrontmatter from "remark-frontmatter";
|
||||||
|
import remarkStringify from "remark-stringify";
|
||||||
|
import { visit } from "unist-util-visit";
|
||||||
|
import YAML from "yaml";
|
||||||
|
import { replaceQuotes } from "./quotes";
|
||||||
|
import { applyAutocorrect } from "./autocorrect";
|
||||||
|
|
||||||
|
export interface FormatOptions {
|
||||||
|
quotes?: boolean;
|
||||||
|
spacing?: boolean;
|
||||||
|
emphasis?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FormatResult {
|
||||||
|
success: boolean;
|
||||||
|
filePath: string;
|
||||||
|
quotesFixed: boolean;
|
||||||
|
spacingApplied: boolean;
|
||||||
|
emphasisFixed: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_OPTIONS: Required<FormatOptions> = {
|
||||||
|
quotes: false,
|
||||||
|
spacing: true,
|
||||||
|
emphasis: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
function decodeHtmlEntities(text: string): string {
|
||||||
|
return text.replace(/&#x([0-9A-Fa-f]+);/g, (_, hex) =>
|
||||||
|
String.fromCodePoint(parseInt(hex, 16))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFrontmatter(value: string): string | null {
|
||||||
|
try {
|
||||||
|
const doc = YAML.parseDocument(value);
|
||||||
|
return doc.toString({ lineWidth: 0 }).trimEnd();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMarkdownContent(
|
||||||
|
content: string,
|
||||||
|
options: Required<FormatOptions>
|
||||||
|
): string {
|
||||||
|
const processor = unified()
|
||||||
|
.use(remarkParse)
|
||||||
|
.use(options.emphasis ? remarkCjkFriendly : [])
|
||||||
|
.use(remarkGfm)
|
||||||
|
.use(remarkFrontmatter, ["yaml"])
|
||||||
|
.use(remarkStringify, {
|
||||||
|
wrap: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const tree = processor.parse(content);
|
||||||
|
|
||||||
|
visit(tree, (node) => {
|
||||||
|
if (node.type === "text" && options.quotes) {
|
||||||
|
const textNode = node as { value: string };
|
||||||
|
textNode.value = replaceQuotes(textNode.value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (node.type === "yaml") {
|
||||||
|
const yamlNode = node as { value: string };
|
||||||
|
const formatted = formatFrontmatter(yamlNode.value);
|
||||||
|
if (formatted !== null) {
|
||||||
|
yamlNode.value = formatted;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = processor.stringify(tree);
|
||||||
|
if (options.emphasis) {
|
||||||
|
result = decodeHtmlEntities(result);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatMarkdown(
|
||||||
|
filePath: string,
|
||||||
|
options?: FormatOptions
|
||||||
|
): FormatResult {
|
||||||
|
const opts: Required<FormatOptions> = { ...DEFAULT_OPTIONS, ...options };
|
||||||
|
|
||||||
|
const result: FormatResult = {
|
||||||
|
success: false,
|
||||||
|
filePath,
|
||||||
|
quotesFixed: false,
|
||||||
|
spacingApplied: false,
|
||||||
|
emphasisFixed: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = readFileSync(filePath, "utf-8");
|
||||||
|
const formattedContent = formatMarkdownContent(content, opts);
|
||||||
|
|
||||||
|
result.quotesFixed = opts.quotes;
|
||||||
|
result.emphasisFixed = opts.emphasis;
|
||||||
|
|
||||||
|
writeFileSync(filePath, formattedContent, "utf-8");
|
||||||
|
|
||||||
|
if (opts.spacing) {
|
||||||
|
result.spacingApplied = applyAutocorrect(filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
result.success = true;
|
||||||
|
console.log(`✓ Formatted: ${filePath}`);
|
||||||
|
} catch (error) {
|
||||||
|
result.error = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error(`✗ Format failed: ${result.error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs(args: string[]): { filePath: string; options: FormatOptions } {
|
||||||
|
const options: FormatOptions = {};
|
||||||
|
let filePath = "";
|
||||||
|
|
||||||
|
for (let i = 0; i < args.length; i++) {
|
||||||
|
const arg = args[i];
|
||||||
|
|
||||||
|
if (arg === "--quotes" || arg === "-q") {
|
||||||
|
options.quotes = true;
|
||||||
|
} else if (arg === "--no-quotes") {
|
||||||
|
options.quotes = false;
|
||||||
|
} else if (arg === "--spacing" || arg === "-s") {
|
||||||
|
options.spacing = true;
|
||||||
|
} else if (arg === "--no-spacing") {
|
||||||
|
options.spacing = false;
|
||||||
|
} else if (arg === "--emphasis" || arg === "-e") {
|
||||||
|
options.emphasis = true;
|
||||||
|
} else if (arg === "--no-emphasis") {
|
||||||
|
options.emphasis = false;
|
||||||
|
} else if (arg === "--help" || arg === "-h") {
|
||||||
|
console.log(`Usage: npx -y bun scripts/main.ts <file.md> [options]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-q, --quotes Replace ASCII quotes with fullwidth quotes (default: false)
|
||||||
|
--no-quotes Do not replace quotes
|
||||||
|
-s, --spacing Add CJK/English spacing via autocorrect (default: true)
|
||||||
|
--no-spacing Do not add CJK/English spacing
|
||||||
|
-e, --emphasis Fix CJK emphasis punctuation issues (default: true)
|
||||||
|
--no-emphasis Do not fix CJK emphasis issues
|
||||||
|
-h, --help Show this help message`);
|
||||||
|
process.exit(0);
|
||||||
|
} else if (!arg.startsWith("-")) {
|
||||||
|
filePath = arg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { filePath, options };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||||
|
const { filePath, options } = parseArgs(process.argv.slice(2));
|
||||||
|
|
||||||
|
if (!filePath) {
|
||||||
|
console.error("Usage: npx -y bun scripts/main.ts <file.md> [options]");
|
||||||
|
console.error("Use --help for more information.");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = formatMarkdown(filePath, options);
|
||||||
|
if (!result.success) {
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
+1125
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"remark-cjk-friendly": "^1.1.0",
|
||||||
|
"remark-frontmatter": "^5.0.0",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
|
"remark-parse": "^11.0.0",
|
||||||
|
"remark-stringify": "^11.0.0",
|
||||||
|
"unified": "^11.0.5",
|
||||||
|
"unist-util-visit": "^5.1.0",
|
||||||
|
"yaml": "^2.8.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export function replaceQuotes(content: string): string {
|
||||||
|
return content
|
||||||
|
.replace(/"([^"]+)"/g, "\u201c$1\u201d")
|
||||||
|
.replace(/「([^」]+)」/g, "\u201c$1\u201d");
|
||||||
|
}
|
||||||
@@ -153,10 +153,12 @@ Schema: `references/config/preferences-schema.md`
|
|||||||
**1.2 Analyze Content → `analysis.md`**
|
**1.2 Analyze Content → `analysis.md`**
|
||||||
|
|
||||||
1. Save source content (file path or paste → `source.md`)
|
1. Save source content (file path or paste → `source.md`)
|
||||||
|
- **Backup rule**: If `source.md` exists, rename to `source-backup-YYYYMMDD-HHMMSS.md`
|
||||||
2. Analyze: topic, data type, complexity, tone, audience
|
2. Analyze: topic, data type, complexity, tone, audience
|
||||||
3. Detect source language and user language
|
3. Detect source language and user language
|
||||||
4. Extract design instructions from user input
|
4. Extract design instructions from user input
|
||||||
5. Save analysis
|
5. Save analysis
|
||||||
|
- **Backup rule**: If `analysis.md` exists, rename to `analysis-backup-YYYYMMDD-HHMMSS.md`
|
||||||
|
|
||||||
See `references/analysis-framework.md` for detailed format.
|
See `references/analysis-framework.md` for detailed format.
|
||||||
|
|
||||||
@@ -182,13 +184,20 @@ Recommend 3-5 layout×style combinations based on:
|
|||||||
|
|
||||||
### Step 4: Confirm Options
|
### Step 4: Confirm Options
|
||||||
|
|
||||||
Present all options in single confirmation:
|
Use **single AskUserQuestion call** with multiple questions to confirm all options together:
|
||||||
1. **Combination** (always): 3+ options with rationale
|
|
||||||
2. **Aspect** (always): landscape/portrait/square
|
| Question | When | Options |
|
||||||
3. **Language** (only if source ≠ user language): which language for text
|
|----------|------|---------|
|
||||||
|
| **Combination** | Always | 3+ layout×style combos with rationale |
|
||||||
|
| **Aspect** | Always | landscape (16:9), portrait (9:16), square (1:1) |
|
||||||
|
| **Language** | Only if source ≠ user language | Language for text content |
|
||||||
|
|
||||||
|
**Important**: Do NOT split into separate AskUserQuestion calls. Combine all applicable questions into one call.
|
||||||
|
|
||||||
### Step 5: Generate Prompt → `prompts/infographic.md`
|
### Step 5: Generate Prompt → `prompts/infographic.md`
|
||||||
|
|
||||||
|
**Backup rule**: If `prompts/infographic.md` exists, rename to `prompts/infographic-backup-YYYYMMDD-HHMMSS.md`
|
||||||
|
|
||||||
Combine:
|
Combine:
|
||||||
1. Layout definition from `references/layouts/<layout>.md`
|
1. Layout definition from `references/layouts/<layout>.md`
|
||||||
2. Style definition from `references/styles/<style>.md`
|
2. Style definition from `references/styles/<style>.md`
|
||||||
@@ -199,8 +208,10 @@ Combine:
|
|||||||
### Step 6: Generate Image
|
### Step 6: Generate Image
|
||||||
|
|
||||||
1. Select available image generation skill (ask user if multiple)
|
1. Select available image generation skill (ask user if multiple)
|
||||||
2. Call with prompt file and output path
|
2. **Check for existing file**: Before generating, check if `infographic.png` exists
|
||||||
3. On failure, auto-retry once
|
- If exists: Rename to `infographic-backup-YYYYMMDD-HHMMSS.png`
|
||||||
|
3. Call with prompt file and output path
|
||||||
|
4. On failure, auto-retry once
|
||||||
|
|
||||||
### Step 7: Output Summary
|
### Step 7: Output Summary
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
---
|
||||||
|
name: baoyu-markdown-to-html
|
||||||
|
description: Converts Markdown to styled HTML with WeChat-compatible themes. Supports code highlighting, math, PlantUML, footnotes, alerts, and infographics. Use when user asks for "markdown to html", "convert md to html", "md转html", or needs styled HTML output from markdown.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Markdown to HTML Converter
|
||||||
|
|
||||||
|
Converts Markdown files to beautifully styled HTML with inline CSS, optimized for WeChat Official Account and other platforms.
|
||||||
|
|
||||||
|
## Script Directory
|
||||||
|
|
||||||
|
**Agent Execution**: Determine this SKILL.md directory as `SKILL_DIR`, then use `${SKILL_DIR}/scripts/<name>.ts`.
|
||||||
|
|
||||||
|
| Script | Purpose |
|
||||||
|
|--------|---------|
|
||||||
|
| `scripts/main.ts` | Main entry point |
|
||||||
|
|
||||||
|
## Preferences (EXTEND.md)
|
||||||
|
|
||||||
|
Use Bash to check EXTEND.md existence (priority order):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check project-level first
|
||||||
|
test -f .baoyu-skills/baoyu-markdown-to-html/EXTEND.md && echo "project"
|
||||||
|
|
||||||
|
# Then user-level (cross-platform: $HOME works on macOS/Linux/WSL)
|
||||||
|
test -f "$HOME/.baoyu-skills/baoyu-markdown-to-html/EXTEND.md" && echo "user"
|
||||||
|
```
|
||||||
|
|
||||||
|
┌──────────────────────────────────────────────────────────────┬───────────────────┐
|
||||||
|
│ Path │ Location │
|
||||||
|
├──────────────────────────────────────────────────────────────┼───────────────────┤
|
||||||
|
│ .baoyu-skills/baoyu-markdown-to-html/EXTEND.md │ Project directory │
|
||||||
|
├──────────────────────────────────────────────────────────────┼───────────────────┤
|
||||||
|
│ $HOME/.baoyu-skills/baoyu-markdown-to-html/EXTEND.md │ User home │
|
||||||
|
└──────────────────────────────────────────────────────────────┴───────────────────┘
|
||||||
|
|
||||||
|
┌───────────┬───────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Result │ Action │
|
||||||
|
├───────────┼───────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ Found │ Read, parse, apply settings │
|
||||||
|
├───────────┼───────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ Not found │ Use defaults │
|
||||||
|
└───────────┴───────────────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
**EXTEND.md Supports**: Default theme | Custom CSS variables | Code block style
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
### Step 0: Pre-check (Chinese Content)
|
||||||
|
|
||||||
|
**Condition**: Only execute if input file contains Chinese text.
|
||||||
|
|
||||||
|
**Detection**:
|
||||||
|
1. Read input markdown file
|
||||||
|
2. Check if content contains CJK characters (Chinese/Japanese/Korean)
|
||||||
|
3. If no CJK content → skip to Step 1
|
||||||
|
|
||||||
|
**Format Suggestion**:
|
||||||
|
|
||||||
|
If CJK content detected AND `baoyu-format-markdown` skill is available:
|
||||||
|
|
||||||
|
Use `AskUserQuestion` to ask whether to format first. Formatting can fix:
|
||||||
|
- Bold markers with punctuation inside causing `**` parse failures
|
||||||
|
- CJK/English spacing issues
|
||||||
|
|
||||||
|
**If user agrees**: Invoke `baoyu-format-markdown` skill to format the file, then use formatted file as input.
|
||||||
|
|
||||||
|
**If user declines**: Continue with original file.
|
||||||
|
|
||||||
|
### Step 1: Confirm Theme
|
||||||
|
|
||||||
|
Before converting, use AskUserQuestion to confirm the theme (unless user already specified):
|
||||||
|
|
||||||
|
| Theme | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| `default` (Recommended) | 经典主题 - 传统排版,标题居中带底边,二级标题白字彩底 |
|
||||||
|
| `grace` | 优雅主题 - 文字阴影,圆角卡片,精致引用块 |
|
||||||
|
| `simple` | 简洁主题 - 现代极简风,不对称圆角,清爽留白 |
|
||||||
|
|
||||||
|
### Step 2: Convert
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx -y bun ${SKILL_DIR}/scripts/main.ts <markdown_file> --theme <theme>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Report Result
|
||||||
|
|
||||||
|
Display the output path from JSON result. If backup was created, mention it.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx -y bun ${SKILL_DIR}/scripts/main.ts <markdown_file> [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Options:**
|
||||||
|
|
||||||
|
| Option | Description | Default |
|
||||||
|
|--------|-------------|---------|
|
||||||
|
| `--theme <name>` | Theme name (default, grace, simple) | default |
|
||||||
|
| `--title <title>` | Override title from frontmatter | |
|
||||||
|
| `--keep-title` | Keep the first heading in content | false (removed) |
|
||||||
|
| `--help` | Show help | |
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Basic conversion (uses default theme, removes first heading)
|
||||||
|
npx -y bun ${SKILL_DIR}/scripts/main.ts article.md
|
||||||
|
|
||||||
|
# With specific theme
|
||||||
|
npx -y bun ${SKILL_DIR}/scripts/main.ts article.md --theme grace
|
||||||
|
|
||||||
|
# Keep the first heading in content
|
||||||
|
npx -y bun ${SKILL_DIR}/scripts/main.ts article.md --keep-title
|
||||||
|
|
||||||
|
# Override title
|
||||||
|
npx -y bun ${SKILL_DIR}/scripts/main.ts article.md --title "My Article"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Output
|
||||||
|
|
||||||
|
**File location**: Same directory as input markdown file.
|
||||||
|
- Input: `/path/to/article.md`
|
||||||
|
- Output: `/path/to/article.html`
|
||||||
|
|
||||||
|
**Conflict handling**: If HTML file already exists, it will be backed up first:
|
||||||
|
- Backup: `/path/to/article.html.bak-YYYYMMDDHHMMSS`
|
||||||
|
|
||||||
|
**JSON output to stdout:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"title": "Article Title",
|
||||||
|
"author": "Author Name",
|
||||||
|
"summary": "Article summary...",
|
||||||
|
"htmlPath": "/path/to/article.html",
|
||||||
|
"backupPath": "/path/to/article.html.bak-20260128180000",
|
||||||
|
"contentImages": [
|
||||||
|
{
|
||||||
|
"placeholder": "MDTOHTMLIMGPH_1",
|
||||||
|
"localPath": "/path/to/img.png",
|
||||||
|
"originalPath": "imgs/image.png"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Themes
|
||||||
|
|
||||||
|
| Theme | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| `default` | 经典主题 - 传统排版,标题居中带底边,二级标题白字彩底 |
|
||||||
|
| `grace` | 优雅主题 - 文字阴影,圆角卡片,精致引用块 (by @brzhang) |
|
||||||
|
| `simple` | 简洁主题 - 现代极简风,不对称圆角,清爽留白 (by @okooo5km) |
|
||||||
|
|
||||||
|
## Supported Markdown Features
|
||||||
|
|
||||||
|
| Feature | Syntax |
|
||||||
|
|---------|--------|
|
||||||
|
| Headings | `# H1` to `###### H6` |
|
||||||
|
| Bold/Italic | `**bold**`, `*italic*` |
|
||||||
|
| Code blocks | ` ```lang ` with syntax highlighting |
|
||||||
|
| Inline code | `` `code` `` |
|
||||||
|
| Tables | GitHub-flavored markdown tables |
|
||||||
|
| Images | `` |
|
||||||
|
| Links | `[text](url)` with footnote references |
|
||||||
|
| Blockquotes | `> quote` |
|
||||||
|
| Lists | `-` unordered, `1.` ordered |
|
||||||
|
| Alerts | `> [!NOTE]`, `> [!WARNING]`, etc. |
|
||||||
|
| Footnotes | `[^1]` references |
|
||||||
|
| Ruby text | `{base|annotation}` |
|
||||||
|
| Mermaid | ` ```mermaid ` diagrams |
|
||||||
|
| PlantUML | ` ```plantuml ` diagrams |
|
||||||
|
|
||||||
|
## Frontmatter
|
||||||
|
|
||||||
|
Supports YAML frontmatter for metadata:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
title: Article Title
|
||||||
|
author: Author Name
|
||||||
|
description: Article summary
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
If no title is found, extracts from first H1/H2 heading or uses filename.
|
||||||
|
|
||||||
|
## Extension Support
|
||||||
|
|
||||||
|
Custom configurations via EXTEND.md. See **Preferences** section for paths and supported options.
|
||||||
@@ -0,0 +1,322 @@
|
|||||||
|
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);
|
||||||
|
|
||||||
|
const contentImages: ImageInfo[] = [];
|
||||||
|
for (const img of images) {
|
||||||
|
const localPath = await resolveImagePath(img.src, baseDir, tempDir);
|
||||||
|
contentImages.push({
|
||||||
|
placeholder: img.placeholder,
|
||||||
|
localPath,
|
||||||
|
originalPath: img.src,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let htmlContent = fs.readFileSync(finalHtmlPath, 'utf-8');
|
||||||
|
for (const img of contentImages) {
|
||||||
|
const imgTag = `<img src="${img.placeholder}" data-local-path="${img.localPath}" style="display: block; width: 100%; margin: 1.5em auto;">`;
|
||||||
|
htmlContent = htmlContent.replace(img.placeholder, imgTag);
|
||||||
|
}
|
||||||
|
fs.writeFileSync(finalHtmlPath, htmlContent, 'utf-8');
|
||||||
|
|
||||||
|
console.error(`[markdown-to-html] HTML saved to: ${finalHtmlPath}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
author,
|
||||||
|
summary,
|
||||||
|
htmlPath: finalHtmlPath,
|
||||||
|
backupPath,
|
||||||
|
contentImages,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function printUsage(): never {
|
||||||
|
console.log(`Convert Markdown to styled HTML
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
npx -y bun main.ts <markdown_file> [options]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--title <title> Override title
|
||||||
|
--theme <name> Theme name (default, grace, simple). Default: default
|
||||||
|
--keep-title Keep the first heading in content. Default: false (removed)
|
||||||
|
--help Show this help
|
||||||
|
|
||||||
|
Output:
|
||||||
|
HTML file saved to same directory as input markdown file.
|
||||||
|
Example: article.md -> article.html
|
||||||
|
|
||||||
|
If HTML file already exists, it will be backed up first:
|
||||||
|
article.html -> article.html.bak-YYYYMMDDHHMMSS
|
||||||
|
|
||||||
|
Output JSON format:
|
||||||
|
{
|
||||||
|
"title": "Article Title",
|
||||||
|
"htmlPath": "/path/to/article.html",
|
||||||
|
"backupPath": "/path/to/article.html.bak-20260128180000",
|
||||||
|
"contentImages": [...]
|
||||||
|
}
|
||||||
|
|
||||||
|
Example:
|
||||||
|
npx -y bun main.ts article.md
|
||||||
|
npx -y bun main.ts article.md --theme grace
|
||||||
|
`);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
|
||||||
|
printUsage();
|
||||||
|
}
|
||||||
|
|
||||||
|
let markdownPath: string | undefined;
|
||||||
|
let title: string | undefined;
|
||||||
|
let theme: string | undefined;
|
||||||
|
let keepTitle = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < args.length; i++) {
|
||||||
|
const arg = args[i]!;
|
||||||
|
if (arg === '--title' && args[i + 1]) {
|
||||||
|
title = args[++i];
|
||||||
|
} else if (arg === '--theme' && args[i + 1]) {
|
||||||
|
theme = args[++i];
|
||||||
|
} else if (arg === '--keep-title') {
|
||||||
|
keepTitle = true;
|
||||||
|
} else if (!arg.startsWith('-')) {
|
||||||
|
markdownPath = arg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!markdownPath) {
|
||||||
|
console.error('Error: Markdown file path is required');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(markdownPath)) {
|
||||||
|
console.error(`Error: File not found: ${markdownPath}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await convertMarkdown(markdownPath, { title, theme, keepTitle });
|
||||||
|
console.log(JSON.stringify(result, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
await main().catch((err) => {
|
||||||
|
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
This directory contains code adapted from the doocs/md project.
|
||||||
|
|
||||||
|
Original project: https://github.com/doocs/md
|
||||||
|
License: WTFPL (Do What The Fuck You Want To Public License)
|
||||||
|
|
||||||
|
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
|
||||||
|
Version 2, December 2004
|
||||||
|
|
||||||
|
Copyright (C) 2025 Doocs <admin@doocs.org>
|
||||||
|
|
||||||
|
Everyone is permitted to copy and distribute verbatim or modified
|
||||||
|
copies of this license document, and changing it is allowed as long
|
||||||
|
as the name is changed.
|
||||||
|
|
||||||
|
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
|
||||||
|
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
|
||||||
|
|
||||||
|
0. You just DO WHAT THE FUCK YOU WANT TO.
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
import type { MarkedExtension, Tokens } from 'marked'
|
||||||
|
|
||||||
|
export interface AlertOptions {
|
||||||
|
className?: string
|
||||||
|
variants?: AlertVariantItem[]
|
||||||
|
withoutStyle?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AlertVariantItem {
|
||||||
|
type: string
|
||||||
|
icon: string
|
||||||
|
title?: string
|
||||||
|
titleClassName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function ucfirst(str: string) {
|
||||||
|
return str.slice(0, 1).toUpperCase() + str.slice(1).toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* https://github.com/bent10/marked-extensions/tree/main/packages/alert
|
||||||
|
* To support theme, we need to modify the source code.
|
||||||
|
* A [marked](https://marked.js.org/) extension to support [GFM alerts](https://github.com/orgs/community/discussions/16925).
|
||||||
|
*/
|
||||||
|
export function markedAlert(options: AlertOptions = {}): MarkedExtension {
|
||||||
|
const { className = `markdown-alert`, variants = [], withoutStyle = false } = options
|
||||||
|
const resolvedVariants = resolveVariants(variants)
|
||||||
|
|
||||||
|
// 提取公共的元数据构建逻辑
|
||||||
|
function buildMeta(variantType: string, matchedVariant: AlertVariantItem, fromContainer = false) {
|
||||||
|
return {
|
||||||
|
className,
|
||||||
|
variant: variantType,
|
||||||
|
icon: matchedVariant.icon,
|
||||||
|
title: matchedVariant.title ?? ucfirst(variantType),
|
||||||
|
titleClassName: `${className}-title`,
|
||||||
|
fromContainer,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提取公共的渲染逻辑
|
||||||
|
function renderAlert(token: any) {
|
||||||
|
const { meta, tokens = [] } = token
|
||||||
|
// @ts-expect-error marked renderer context has parser property
|
||||||
|
const text = this.parser.parse(tokens)
|
||||||
|
// 新主题系统:使用 CSS 选择器而非内联样式
|
||||||
|
let tmpl = `<blockquote class="${meta.className} ${meta.className}-${meta.variant}">\n`
|
||||||
|
tmpl += `<p class="${meta.titleClassName} alert-title-${meta.variant}">`
|
||||||
|
if (!withoutStyle) {
|
||||||
|
// 给 SVG 添加 class,通过 CSS 控制颜色
|
||||||
|
tmpl += meta.icon.replace(
|
||||||
|
`<svg`,
|
||||||
|
`<svg class="alert-icon-${meta.variant}"`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
tmpl += meta.title
|
||||||
|
tmpl += `</p>\n`
|
||||||
|
tmpl += text
|
||||||
|
tmpl += `</blockquote>\n`
|
||||||
|
|
||||||
|
return tmpl
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
walkTokens(token) {
|
||||||
|
if (token.type !== `blockquote`)
|
||||||
|
return
|
||||||
|
|
||||||
|
const matchedVariant = resolvedVariants.find(({ type }) =>
|
||||||
|
new RegExp(createSyntaxPattern(type), `i`).test(token.text),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (matchedVariant) {
|
||||||
|
const { type: variantType } = matchedVariant
|
||||||
|
const typeRegexp = new RegExp(createSyntaxPattern(variantType), `i`)
|
||||||
|
|
||||||
|
Object.assign(token, {
|
||||||
|
type: `alert`,
|
||||||
|
meta: buildMeta(variantType, matchedVariant),
|
||||||
|
})
|
||||||
|
|
||||||
|
const firstLine = token.tokens?.[0] as Tokens.Paragraph
|
||||||
|
const firstLineText = firstLine.raw?.replace(typeRegexp, ``).trim()
|
||||||
|
|
||||||
|
if (firstLineText) {
|
||||||
|
const patternToken = firstLine.tokens[0] as Tokens.Text
|
||||||
|
|
||||||
|
Object.assign(patternToken, {
|
||||||
|
raw: patternToken.raw.replace(typeRegexp, ``),
|
||||||
|
text: patternToken.text.replace(typeRegexp, ``),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (firstLine.tokens[1]?.type === `br`) {
|
||||||
|
firstLine.tokens.splice(1, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
token.tokens?.shift()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
extensions: [
|
||||||
|
{
|
||||||
|
name: `alert`,
|
||||||
|
level: `block`,
|
||||||
|
renderer: renderAlert,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: `alertContainer`,
|
||||||
|
level: `block`,
|
||||||
|
start(src) {
|
||||||
|
return src.match(/^:::/)?.index
|
||||||
|
},
|
||||||
|
tokenizer(src, _tokens) {
|
||||||
|
// eslint-disable-next-line regexp/no-super-linear-backtracking
|
||||||
|
const match = /^:::\s*(\w+)\s*\n([\s\S]*?)\n:::/.exec(src)
|
||||||
|
|
||||||
|
if (match) {
|
||||||
|
const [raw, variant, content] = match
|
||||||
|
const matchedVariant = resolvedVariants.find(v => v.type === variant)
|
||||||
|
if (!matchedVariant)
|
||||||
|
return
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: `alert`,
|
||||||
|
raw,
|
||||||
|
text: content.trim(),
|
||||||
|
tokens: this.lexer.blockTokens(content.trim()),
|
||||||
|
meta: buildMeta(variant, matchedVariant, true),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
renderer: renderAlert,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The default configuration for alert variants.
|
||||||
|
*/
|
||||||
|
const defaultAlertVariant: AlertVariantItem[] = [
|
||||||
|
{
|
||||||
|
type: `note`,
|
||||||
|
icon: `<svg class="octicon octicon-info" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.5 7.75A.75.75 0 0 1 7.25 7h1a.75.75 0 0 1 .75.75v2.75h.25a.75.75 0 0 1 0 1.5h-2a.75.75 0 0 1 0-1.5h.25v-2h-.25a.75.75 0 0 1-.75-.75ZM8 6a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z"></path></svg>`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: `info`,
|
||||||
|
icon: `<svg class="octicon octicon-info" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.5 7.75A.75.75 0 0 1 7.25 7h1a.75.75 0 0 1 .75.75v2.75h.25a.75.75 0 0 1 0 1.5h-2a.75.75 0 0 1 0-1.5h.25v-2h-.25a.75.75 0 0 1-.75-.75ZM8 6a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z"></path></svg>`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: `tip`,
|
||||||
|
icon: `<svg class="octicon octicon-light-bulb" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M8 1.5c-2.363 0-4 1.69-4 3.75 0 .984.424 1.625.984 2.304l.214.253c.223.264.47.556.673.848.284.411.537.896.621 1.49a.75.75 0 0 1-1.484.211c-.04-.282-.163-.547-.37-.847a8.456 8.456 0 0 0-.542-.68c-.084-.1-.173-.205-.268-.32C3.201 7.75 2.5 6.766 2.5 5.25 2.5 2.31 4.863 0 8 0s5.5 2.31 5.5 5.25c0 1.516-.701 2.5-1.328 3.259-.095.115-.184.22-.268.319-.207.245-.383.453-.541.681-.208.3-.33.565-.37.847a.751.751 0 0 1-1.485-.212c.084-.593.337-1.078.621-1.489.203-.292.45-.584.673-.848.075-.088.147-.173.213-.253.561-.679.985-1.32.985-2.304 0-2.06-1.637-3.75-4-3.75ZM5.75 12h4.5a.75.75 0 0 1 0 1.5h-4.5a.75.75 0 0 1 0-1.5ZM6 15.25a.75.75 0 0 1 .75-.75h2.5a.75.75 0 0 1 0 1.5h-2.5a.75.75 0 0 1-.75-.75Z"></path></svg>`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: `important`,
|
||||||
|
icon: `<svg class="octicon octicon-report" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M0 1.75C0 .784.784 0 1.75 0h12.5C15.216 0 16 .784 16 1.75v9.5A1.75 1.75 0 0 1 14.25 13H8.06l-2.573 2.573A1.458 1.458 0 0 1 3 14.543V13H1.75A1.75 1.75 0 0 1 0 11.25Zm1.75-.25a.25.25 0 0 0-.25.25v9.5c0 .138.112.25.25.25h2a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h6.5a.25.25 0 0 0 .25-.25v-9.5a.25.25 0 0 0-.25-.25Zm7 2.25v2.5a.75.75 0 0 1-1.5 0v-2.5a.75.75 0 0 1 1.5 0ZM9 9a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z"></path></svg>`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: `warning`,
|
||||||
|
icon: `<svg class="octicon octicon-alert" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M6.457 1.047c.659-1.234 2.427-1.234 3.086 0l6.082 11.378A1.75 1.75 0 0 1 14.082 15H1.918a1.75 1.75 0 0 1-1.543-2.575Zm1.763.707a.25.25 0 0 0-.44 0L1.698 13.132a.25.25 0 0 0 .22.368h12.164a.25.25 0 0 0 .22-.368Zm.53 3.996v2.5a.75.75 0 0 1-1.5 0v-2.5a.75.75 0 0 1 1.5 0ZM9 11a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z"></path></svg>`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: `caution`,
|
||||||
|
icon: `<svg class="octicon octicon-stop" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M4.47.22A.749.749 0 0 1 5 0h6c.199 0 .389.079.53.22l4.25 4.25c.141.14.22.331.22.53v6a.749.749 0 0 1-.22.53l-4.25 4.25A.749.749 0 0 1 11 16H5a.749.749 0 0 1-.53-.22L.22 11.53A.749.749 0 0 1 0 11V5c0-.199.079-.389.22-.53Zm.84 1.28L1.5 5.31v5.38l3.81 3.81h5.38l3.81-3.81V5.31L10.69 1.5ZM8 4a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5A.75.75 0 0 1 8 4Zm0 8a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z"></path></svg>`,
|
||||||
|
},
|
||||||
|
// Obsidian-style callouts
|
||||||
|
{
|
||||||
|
type: `abstract`,
|
||||||
|
title: `Abstract`,
|
||||||
|
icon: `<svg class="octicon octicon-clipboard" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M5.75 1a.75.75 0 0 0-.75.75v.5c0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75v-.5a.75.75 0 0 0-.75-.75Zm4.5-1.5a2.25 2.25 0 0 1 2.122 1.5H13a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V3a2 2 0 0 1 2-2h.628A2.25 2.25 0 0 1 5.75-.5ZM3.5 3v10a.5.5 0 0 0 .5.5h8a.5.5 0 0 0 .5-.5V3a.5.5 0 0 0-.5-.5h-8a.5.5 0 0 0-.5.5Z"></path></svg>`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: `summary`,
|
||||||
|
title: `Summary`,
|
||||||
|
icon: `<svg class="octicon octicon-clipboard" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M5.75 1a.75.75 0 0 0-.75.75v.5c0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75v-.5a.75.75 0 0 0-.75-.75Zm4.5-1.5a2.25 2.25 0 0 1 2.122 1.5H13a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V3a2 2 0 0 1 2-2h.628A2.25 2.25 0 0 1 5.75-.5ZM3.5 3v10a.5.5 0 0 0 .5.5h8a.5.5 0 0 0 .5-.5V3a.5.5 0 0 0-.5-.5h-8a.5.5 0 0 0-.5.5Z"></path></svg>`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: `tldr`,
|
||||||
|
title: `TL;DR`,
|
||||||
|
icon: `<svg class="octicon octicon-clipboard" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M5.75 1a.75.75 0 0 0-.75.75v.5c0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75v-.5a.75.75 0 0 0-.75-.75Zm4.5-1.5a2.25 2.25 0 0 1 2.122 1.5H13a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V3a2 2 0 0 1 2-2h.628A2.25 2.25 0 0 1 5.75-.5ZM3.5 3v10a.5.5 0 0 0 .5.5h8a.5.5 0 0 0 .5-.5V3a.5.5 0 0 0-.5-.5h-8a.5.5 0 0 0-.5.5Z"></path></svg>`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: `todo`,
|
||||||
|
title: `Todo`,
|
||||||
|
icon: `<svg class="octicon octicon-checklist" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M2.5 1.75v11.5c0 .138.112.25.25.25h3.17a.75.75 0 0 1 0 1.5H2.75A1.75 1.75 0 0 1 1 13.25V1.75C1 .784 1.784 0 2.75 0h8.5C12.216 0 13 .784 13 1.75v7.736a.75.75 0 0 1-1.5 0V1.75a.25.25 0 0 0-.25-.25h-8.5a.25.25 0 0 0-.25.25Zm10.97 8.72a.75.75 0 0 1 1.06 0l2 2a.75.75 0 0 1-1.06 1.06l-1.22-1.22v4.94a.75.75 0 0 1-1.5 0v-4.94l-1.22 1.22a.75.75 0 0 1-1.06-1.06Z"></path></svg>`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: `success`,
|
||||||
|
title: `Success`,
|
||||||
|
icon: `<svg class="octicon octicon-check-circle" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M8 16A8 8 0 1 1 8 0a8 8 0 0 1 0 16Zm3.78-9.72a.751.751 0 0 0-.018-1.042.751.751 0 0 0-1.042-.018L6.75 9.19 5.28 7.72a.751.751 0 0 0-1.042.018.751.751 0 0 0-.018 1.042l2 2a.75.75 0 0 0 1.06 0Z"></path></svg>`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: `done`,
|
||||||
|
title: `Done`,
|
||||||
|
icon: `<svg class="octicon octicon-check-circle" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M8 16A8 8 0 1 1 8 0a8 8 0 0 1 0 16Zm3.78-9.72a.751.751 0 0 0-.018-1.042.751.751 0 0 0-1.042-.018L6.75 9.19 5.28 7.72a.751.751 0 0 0-1.042.018.751.751 0 0 0-.018 1.042l2 2a.75.75 0 0 0 1.06 0Z"></path></svg>`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: `question`,
|
||||||
|
title: `Question`,
|
||||||
|
icon: `<svg class="octicon octicon-question" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.92 6.085h.001a.749.749 0 1 1-1.342-.67c.169-.339.436-.701.849-.977C6.845 4.16 7.369 4 8 4a2.756 2.756 0 0 1 1.637.525c.503.377.863.965.863 1.725 0 .448-.115.83-.329 1.15-.205.307-.47.513-.692.662-.109.072-.22.138-.313.195l-.006.004a6.24 6.24 0 0 0-.26.16.952.952 0 0 0-.276.245.75.75 0 0 1-1.248-.832c.184-.264.42-.489.692-.661.103-.067.207-.132.313-.195l.007-.004c.1-.061.182-.11.258-.161a.969.969 0 0 0 .277-.245C8.96 6.514 9 6.427 9 6.25a.612.612 0 0 0-.262-.525A1.27 1.27 0 0 0 8 5.5c-.369 0-.595.09-.74.187a1.01 1.01 0 0 0-.34.398ZM9 11a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z"></path></svg>`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: `help`,
|
||||||
|
title: `Help`,
|
||||||
|
icon: `<svg class="octicon octicon-question" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.92 6.085h.001a.749.749 0 1 1-1.342-.67c.169-.339.436-.701.849-.977C6.845 4.16 7.369 4 8 4a2.756 2.756 0 0 1 1.637.525c.503.377.863.965.863 1.725 0 .448-.115.83-.329 1.15-.205.307-.47.513-.692.662-.109.072-.22.138-.313.195l-.006.004a6.24 6.24 0 0 0-.26.16.952.952 0 0 0-.276.245.75.75 0 0 1-1.248-.832c.184-.264.42-.489.692-.661.103-.067.207-.132.313-.195l.007-.004c.1-.061.182-.11.258-.161a.969.969 0 0 0 .277-.245C8.96 6.514 9 6.427 9 6.25a.612.612 0 0 0-.262-.525A1.27 1.27 0 0 0 8 5.5c-.369 0-.595.09-.74.187a1.01 1.01 0 0 0-.34.398ZM9 11a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z"></path></svg>`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: `faq`,
|
||||||
|
title: `FAQ`,
|
||||||
|
icon: `<svg class="octicon octicon-question" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.92 6.085h.001a.749.749 0 1 1-1.342-.67c.169-.339.436-.701.849-.977C6.845 4.16 7.369 4 8 4a2.756 2.756 0 0 1 1.637.525c.503.377.863.965.863 1.725 0 .448-.115.83-.329 1.15-.205.307-.47.513-.692.662-.109.072-.22.138-.313.195l-.006.004a6.24 6.24 0 0 0-.26.16.952.952 0 0 0-.276.245.75.75 0 0 1-1.248-.832c.184-.264.42-.489.692-.661.103-.067.207-.132.313-.195l.007-.004c.1-.061.182-.11.258-.161a.969.969 0 0 0 .277-.245C8.96 6.514 9 6.427 9 6.25a.612.612 0 0 0-.262-.525A1.27 1.27 0 0 0 8 5.5c-.369 0-.595.09-.74.187a1.01 1.01 0 0 0-.34.398ZM9 11a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z"></path></svg>`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: `failure`,
|
||||||
|
title: `Failure`,
|
||||||
|
icon: `<svg class="octicon octicon-x-circle" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M2.343 13.657A8 8 0 1 1 13.658 2.343 8 8 0 0 1 2.343 13.657ZM6.03 4.97a.751.751 0 0 0-1.042.018.751.751 0 0 0-.018 1.042L6.94 8 4.97 9.97a.749.749 0 0 0 .326 1.275.749.749 0 0 0 .734-.215L8 9.06l1.97 1.97a.749.749 0 0 0 1.275-.326.749.749 0 0 0-.215-.734L9.06 8l1.97-1.97a.749.749 0 0 0-.326-1.275.749.749 0 0 0-.734.215L8 6.94Z"></path></svg>`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: `fail`,
|
||||||
|
title: `Fail`,
|
||||||
|
icon: `<svg class="octicon octicon-x-circle" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M2.343 13.657A8 8 0 1 1 13.658 2.343 8 8 0 0 1 2.343 13.657ZM6.03 4.97a.751.751 0 0 0-1.042.018.751.751 0 0 0-.018 1.042L6.94 8 4.97 9.97a.749.749 0 0 0 .326 1.275.749.749 0 0 0 .734-.215L8 9.06l1.97 1.97a.749.749 0 0 0 1.275-.326.749.749 0 0 0-.215-.734L9.06 8l1.97-1.97a.749.749 0 0 0-.326-1.275.749.749 0 0 0-.734.215L8 6.94Z"></path></svg>`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: `missing`,
|
||||||
|
title: `Missing`,
|
||||||
|
icon: `<svg class="octicon octicon-x-circle" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M2.343 13.657A8 8 0 1 1 13.658 2.343 8 8 0 0 1 2.343 13.657ZM6.03 4.97a.751.751 0 0 0-1.042.018.751.751 0 0 0-.018 1.042L6.94 8 4.97 9.97a.749.749 0 0 0 .326 1.275.749.749 0 0 0 .734-.215L8 9.06l1.97 1.97a.749.749 0 0 0 1.275-.326.749.749 0 0 0-.215-.734L9.06 8l1.97-1.97a.749.749 0 0 0-.326-1.275.749.749 0 0 0-.734.215L8 6.94Z"></path></svg>`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: `danger`,
|
||||||
|
title: `Danger`,
|
||||||
|
icon: `<svg class="octicon octicon-zap" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M9.504.43a1.516 1.516 0 0 1 2.437 1.713L10.415 5.5h2.123c1.57 0 2.346 1.909 1.22 3.004l-7.34 7.142a1.249 1.249 0 0 1-.871.354h-.302a1.25 1.25 0 0 1-1.157-1.723L5.633 10.5H3.462c-1.57 0-2.346-1.909-1.22-3.004ZM9.98 1.873a.016.016 0 0 0-.016.006L2.252 9.021a.75.75 0 0 0 .488 1.212h3.838a.75.75 0 0 1 .694 1.034L5.545 15.02a.016.016 0 0 0 .003.017.017.017 0 0 0 .018.004h.302a.016.016 0 0 0 .012-.005l7.34-7.142a.75.75 0 0 0-.488-1.212h-3.838a.75.75 0 0 1-.694-1.034l1.527-3.628a.016.016 0 0 0-.003-.017.017.017 0 0 0-.018-.004h-.302Z"></path></svg>`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: `error`,
|
||||||
|
title: `Error`,
|
||||||
|
icon: `<svg class="octicon octicon-zap" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M9.504.43a1.516 1.516 0 0 1 2.437 1.713L10.415 5.5h2.123c1.57 0 2.346 1.909 1.22 3.004l-7.34 7.142a1.249 1.249 0 0 1-.871.354h-.302a1.25 1.25 0 0 1-1.157-1.723L5.633 10.5H3.462c-1.57 0-2.346-1.909-1.22-3.004ZM9.98 1.873a.016.016 0 0 0-.016.006L2.252 9.021a.75.75 0 0 0 .488 1.212h3.838a.75.75 0 0 1 .694 1.034L5.545 15.02a.016.016 0 0 0 .003.017.017.017 0 0 0 .018.004h.302a.016.016 0 0 0 .012-.005l7.34-7.142a.75.75 0 0 0-.488-1.212h-3.838a.75.75 0 0 1-.694-1.034l1.527-3.628a.016.016 0 0 0-.003-.017.017.017 0 0 0-.018-.004h-.302Z"></path></svg>`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: `bug`,
|
||||||
|
title: `Bug`,
|
||||||
|
icon: `<svg class="octicon octicon-bug" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M4.72.22a.75.75 0 0 1 1.06 0l1 .999a3.488 3.488 0 0 1 2.441 0l.999-1a.748.748 0 0 1 1.265.332.75.75 0 0 1-.205.729l-.775.776c.616.63.995 1.493.995 2.444v.327c0 .1-.009.197-.025.292l.727.726a.75.75 0 1 1-1.06 1.06l-.727-.727a2.17 2.17 0 0 1-.292.026H9.25V7.5a.75.75 0 0 1-1.5 0V6.125H6.875a2.17 2.17 0 0 1-.292-.026l-.727.727a.75.75 0 1 1-1.06-1.06l.727-.726a2.17 2.17 0 0 1-.025-.292V4.5c0-.951.379-1.814.995-2.444l-.775-.776a.75.75 0 0 1 0-1.06Zm6.437 6.003A.608.608 0 0 0 11 6.072v-.026a3.999 3.999 0 0 0-.11-.936 2.488 2.488 0 0 0-5.78 0 3.992 3.992 0 0 0-.11.936v.026c0 .05.008.098.02.147h4.937a.612.612 0 0 0 .2-.02ZM2.25 7.5a.75.75 0 0 0 0 1.5h.5v1.25a4.75 4.75 0 0 0 2.478 4.166l.247.137a.75.75 0 1 0 .722-1.313l-.246-.137A3.25 3.25 0 0 1 4.25 10.25V9h7.5v1.25a3.25 3.25 0 0 1-1.701 2.853l-.246.137a.75.75 0 1 0 .722 1.313l.247-.137A4.75 4.75 0 0 0 13.25 10.25V9h.5a.75.75 0 0 0 0-1.5Z"></path></svg>`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: `example`,
|
||||||
|
title: `Example`,
|
||||||
|
icon: `<svg class="octicon octicon-list-unordered" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M5.75 2.5h8.5a.75.75 0 0 1 0 1.5h-8.5a.75.75 0 0 1 0-1.5Zm0 5h8.5a.75.75 0 0 1 0 1.5h-8.5a.75.75 0 0 1 0-1.5Zm0 5h8.5a.75.75 0 0 1 0 1.5h-8.5a.75.75 0 0 1 0-1.5ZM2 14a1 1 0 1 1 0-2 1 1 0 0 1 0 2Zm1-6a1 1 0 1 1-2 0 1 1 0 0 1 2 0ZM2 4a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z"></path></svg>`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: `quote`,
|
||||||
|
title: `Quote`,
|
||||||
|
icon: `<svg class="octicon octicon-quote" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M1.75 2h12.5c.966 0 1.75.784 1.75 1.75v8.5A1.75 1.75 0 0 1 14.25 14H1.75A1.75 1.75 0 0 1 0 12.25v-8.5C0 2.784.784 2 1.75 2ZM1.5 12.25c0 .138.112.25.25.25h12.5a.25.25 0 0 0 .25-.25v-8.5a.25.25 0 0 0-.25-.25H1.75a.25.25 0 0 0-.25.25ZM4 5.25a.75.75 0 0 1 .75-.75h6.5a.75.75 0 0 1 0 1.5h-6.5A.75.75 0 0 1 4 5.25Zm0 4a.75.75 0 0 1 .75-.75h6.5a.75.75 0 0 1 0 1.5h-6.5a.75.75 0 0 1-.75-.75Z"></path></svg>`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: `cite`,
|
||||||
|
title: `Cite`,
|
||||||
|
icon: `<svg class="octicon octicon-quote" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M1.75 2h12.5c.966 0 1.75.784 1.75 1.75v8.5A1.75 1.75 0 0 1 14.25 14H1.75A1.75 1.75 0 0 1 0 12.25v-8.5C0 2.784.784 2 1.75 2ZM1.5 12.25c0 .138.112.25.25.25h12.5a.25.25 0 0 0 .25-.25v-8.5a.25.25 0 0 0-.25-.25H1.75a.25.25 0 0 0-.25.25ZM4 5.25a.75.75 0 0 1 .75-.75h6.5a.75.75 0 0 1 0 1.5h-6.5A.75.75 0 0 1 4 5.25Zm0 4a.75.75 0 0 1 .75-.75h6.5a.75.75 0 0 1 0 1.5h-6.5a.75.75 0 0 1-.75-.75Z"></path></svg>`,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the variants configuration, combining the provided variants with
|
||||||
|
* the default variants.
|
||||||
|
*/
|
||||||
|
export function resolveVariants(variants: AlertVariantItem[]) {
|
||||||
|
if (!variants.length)
|
||||||
|
return defaultAlertVariant
|
||||||
|
|
||||||
|
return Object.values(
|
||||||
|
[...defaultAlertVariant, ...variants].reduce(
|
||||||
|
(map, item) => {
|
||||||
|
map[item.type] = item
|
||||||
|
return map
|
||||||
|
},
|
||||||
|
{} as { [key: string]: AlertVariantItem },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns regex pattern to match alert syntax.
|
||||||
|
*/
|
||||||
|
export function createSyntaxPattern(type: string) {
|
||||||
|
return `^(?:\\[!${type}])\\s*?\n*`
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import type { MarkedExtension, Tokens } from 'marked'
|
||||||
|
/**
|
||||||
|
* A marked extension to support footnotes syntax.
|
||||||
|
* Syntax:
|
||||||
|
* This is a footnote reference[^1][^2].
|
||||||
|
*
|
||||||
|
* [^1]: .....
|
||||||
|
* [^2]: .....
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface MapContent {
|
||||||
|
index: number
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
const fnMap = new Map<string, MapContent>()
|
||||||
|
|
||||||
|
export function markedFootnotes(): MarkedExtension {
|
||||||
|
return {
|
||||||
|
extensions: [
|
||||||
|
{
|
||||||
|
name: `footnoteDef`,
|
||||||
|
level: `block`,
|
||||||
|
start(src: string) {
|
||||||
|
fnMap.clear()
|
||||||
|
return src.match(/^\[\^/)?.index
|
||||||
|
},
|
||||||
|
tokenizer(src: string) {
|
||||||
|
const match = src.match(/^\[\^(.*)\]:(.*)/)
|
||||||
|
if (match) {
|
||||||
|
const [raw, fnId, text] = match
|
||||||
|
const index = fnMap.size + 1
|
||||||
|
fnMap.set(fnId, { index, text })
|
||||||
|
return {
|
||||||
|
type: `footnoteDef`,
|
||||||
|
raw,
|
||||||
|
fnId,
|
||||||
|
index,
|
||||||
|
text,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
},
|
||||||
|
renderer(token: Tokens.Generic) {
|
||||||
|
const { index, text, fnId } = token
|
||||||
|
const fnInner = `
|
||||||
|
<code>${index}.</code>
|
||||||
|
<span>${text}</span>
|
||||||
|
<a id="fnDef-${fnId}" href="#fnRef-${fnId}" style="color: var(--md-primary-color);">\u21A9\uFE0E</a>
|
||||||
|
<br>`
|
||||||
|
if (index === 1) {
|
||||||
|
return `
|
||||||
|
<p style="font-size: 80%;margin: 0.5em 8px;word-break:break-all;">${fnInner}`
|
||||||
|
}
|
||||||
|
if (index === fnMap.size) {
|
||||||
|
return `${fnInner}</p>`
|
||||||
|
}
|
||||||
|
return fnInner
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: `footnoteRef`,
|
||||||
|
level: `inline`,
|
||||||
|
start(src: string) {
|
||||||
|
return src.match(/\[\^/)?.index
|
||||||
|
},
|
||||||
|
tokenizer(src: string) {
|
||||||
|
const match = src.match(/^\[\^(.*?)\]/)
|
||||||
|
if (match) {
|
||||||
|
const [raw, fnId] = match
|
||||||
|
if (fnMap.has(fnId)) {
|
||||||
|
return {
|
||||||
|
type: `footnoteRef`,
|
||||||
|
raw,
|
||||||
|
fnId,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
renderer(token: Tokens.Generic) {
|
||||||
|
const { fnId } = token
|
||||||
|
const { index } = fnMap.get(fnId) as MapContent
|
||||||
|
return `<sup style="color: var(--md-primary-color);">
|
||||||
|
<a href="#fnDef-${fnId}" id="fnRef-${fnId}">\[${index}\]</a>
|
||||||
|
</sup>`
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
// Markdown 扩展导出
|
||||||
|
export * from './alert.js'
|
||||||
|
export * from './footnotes.js'
|
||||||
|
export * from './infographic.js'
|
||||||
|
export * from './katex.js'
|
||||||
|
export * from './markup.js'
|
||||||
|
export * from './plantuml.js'
|
||||||
|
export * from './ruby.js'
|
||||||
|
export * from './slider.js'
|
||||||
|
export * from './toc.js'
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import type { MarkedExtension } from 'marked'
|
||||||
|
|
||||||
|
interface InfographicOptions {
|
||||||
|
themeMode?: 'dark' | 'light'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderInfographic(containerId: string, code: string, options?: InfographicOptions) {
|
||||||
|
if (typeof window === 'undefined')
|
||||||
|
return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { Infographic, setDefaultFont, setFontExtendFactor, exportToSVG } = await import('@antv/infographic')
|
||||||
|
|
||||||
|
setFontExtendFactor(1.1)
|
||||||
|
setDefaultFont('-apple-system-font, "system-ui", "Helvetica Neue", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei UI", "Microsoft YaHei", Arial, sans-serif')
|
||||||
|
|
||||||
|
const findContainer = (retries = 5, delay = 100) => {
|
||||||
|
const container = document.getElementById(containerId)
|
||||||
|
if (container) {
|
||||||
|
const isDark = options?.themeMode === 'dark'
|
||||||
|
|
||||||
|
// 从 CSS 变量中读取主题颜色
|
||||||
|
const root = document.documentElement
|
||||||
|
const computedStyle = getComputedStyle(root)
|
||||||
|
const primaryColor = computedStyle.getPropertyValue('--md-primary-color').trim()
|
||||||
|
const backgroundColor = computedStyle.getPropertyValue('--background').trim()
|
||||||
|
|
||||||
|
// 转换 HSL 格式
|
||||||
|
const toHSLString = (variant: string) => {
|
||||||
|
const vars = variant.split(' ')
|
||||||
|
if (vars.length === 3)
|
||||||
|
return `hsl(${vars.join(', ')})`
|
||||||
|
if (vars.length === 4)
|
||||||
|
return `hsla(${vars.join(', ')})`
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const instance = new Infographic({
|
||||||
|
container,
|
||||||
|
svg: {
|
||||||
|
style: {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
background: isDark ? '#000' : 'transparent',
|
||||||
|
},
|
||||||
|
background: false,
|
||||||
|
},
|
||||||
|
theme: isDark ? 'dark' : 'default',
|
||||||
|
themeConfig: {
|
||||||
|
colorPrimary: primaryColor || undefined,
|
||||||
|
colorBg: toHSLString(backgroundColor) || undefined,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
instance.on('loaded', ({ node }) => {
|
||||||
|
exportToSVG(node, { removeIds: true }).then((svg) => {
|
||||||
|
container.replaceChildren(svg)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
instance.render(code)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (retries > 0) {
|
||||||
|
setTimeout(() => findContainer(retries - 1, delay), delay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
findContainer()
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error('Failed to render Infographic:', error)
|
||||||
|
const container = document.getElementById(containerId)
|
||||||
|
if (container) {
|
||||||
|
container.innerHTML = `<div style="color: red; padding: 10px; border: 1px solid red;">Infographic 渲染失败: ${error instanceof Error ? error.message : String(error)}</div>`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markedInfographic(options?: InfographicOptions): MarkedExtension {
|
||||||
|
const className = 'infographic-diagram'
|
||||||
|
|
||||||
|
return {
|
||||||
|
extensions: [
|
||||||
|
{
|
||||||
|
name: 'infographic',
|
||||||
|
level: 'block',
|
||||||
|
start(src: string) {
|
||||||
|
return src.match(/^```infographic/m)?.index
|
||||||
|
},
|
||||||
|
tokenizer(src: string) {
|
||||||
|
const match = /^```infographic\r?\n([\s\S]*?)\r?\n```/.exec(src)
|
||||||
|
if (match) {
|
||||||
|
return {
|
||||||
|
type: 'infographic',
|
||||||
|
raw: match[0],
|
||||||
|
text: match[1].trim(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
renderer(token: any) {
|
||||||
|
const id = `infographic-${Math.random().toString(36).slice(2, 11)}`
|
||||||
|
const code = token.text
|
||||||
|
|
||||||
|
renderInfographic(id, code, options)
|
||||||
|
|
||||||
|
return `<div id="${id}" class="${className}" style="width: 100%;">正在加载 Infographic...</div>`
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
walkTokens(token: any) {
|
||||||
|
if (token.type === 'code' && token.lang === 'infographic') {
|
||||||
|
token.type = 'infographic'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import type { MarkedExtension } from 'marked'
|
||||||
|
|
||||||
|
export interface MarkedKatexOptions {
|
||||||
|
nonStandard?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const inlineRule = /^(\${1,2})(?!\$)((?:\\.|[^\\\n])*?(?:\\.|[^\\\n$]))\1(?=[\s?!.,:?!。,:]|$)/
|
||||||
|
const inlineRuleNonStandard = /^(\${1,2})(?!\$)((?:\\.|[^\\\n])*?(?:\\.|[^\\\n$]))\1/ // Non-standard, even if there are no spaces before and after $ or $$, try to parse
|
||||||
|
|
||||||
|
const blockRule = /^\s{0,3}(\${1,2})[ \t]*\n([\s\S]+?)\n\s{0,3}\1[ \t]*(?:\n|$)/
|
||||||
|
|
||||||
|
// LaTeX style rules for \( ... \) and \[ ... \]
|
||||||
|
const inlineLatexRule = /^\\\(([^\\]*(?:\\.[^\\]*)*?)\\\)/
|
||||||
|
const blockLatexRule = /^\\\[([^\\]*(?:\\.[^\\]*)*?)\\\]/
|
||||||
|
|
||||||
|
function createRenderer(display: boolean, withStyle: boolean = true) {
|
||||||
|
return (token: any) => {
|
||||||
|
// @ts-expect-error MathJax is a global variable
|
||||||
|
window.MathJax.texReset()
|
||||||
|
// @ts-expect-error MathJax is a global variable
|
||||||
|
const mjxContainer = window.MathJax.tex2svg(token.text, { display })
|
||||||
|
const svg = mjxContainer.firstChild
|
||||||
|
const width = svg.style[`min-width`] || svg.getAttribute(`width`)
|
||||||
|
svg.removeAttribute(`width`)
|
||||||
|
|
||||||
|
// 行内公式对齐 https://groups.google.com/g/mathjax-users/c/zThKffrrCvE?pli=1
|
||||||
|
// 直接覆盖 style 会覆盖 MathJax 的样式,需要手动设置
|
||||||
|
// svg.style = `max-width: 300vw !important; display: initial; flex-shrink: 0;`
|
||||||
|
|
||||||
|
if (withStyle) {
|
||||||
|
svg.style.display = `initial`
|
||||||
|
svg.style.setProperty(`max-width`, `300vw`, `important`)
|
||||||
|
svg.style.flexShrink = `0`
|
||||||
|
svg.style.width = width
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!display) {
|
||||||
|
// 新主题系统:使用 class 而非内联样式
|
||||||
|
return `<span class="katex-inline">${svg.outerHTML}</span>`
|
||||||
|
}
|
||||||
|
|
||||||
|
return `<section class="katex-block">${svg.outerHTML}</section>`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function inlineKatex(options: MarkedKatexOptions | undefined, renderer: any) {
|
||||||
|
const nonStandard = options && options.nonStandard
|
||||||
|
const ruleReg = nonStandard ? inlineRuleNonStandard : inlineRule
|
||||||
|
return {
|
||||||
|
name: `inlineKatex`,
|
||||||
|
level: `inline`,
|
||||||
|
start(src: string) {
|
||||||
|
let index
|
||||||
|
let indexSrc = src
|
||||||
|
|
||||||
|
while (indexSrc) {
|
||||||
|
index = indexSrc.indexOf(`$`)
|
||||||
|
if (index === -1) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const f = nonStandard ? index > -1 : index === 0 || indexSrc.charAt(index - 1) === ` `
|
||||||
|
if (f) {
|
||||||
|
const possibleKatex = indexSrc.substring(index)
|
||||||
|
|
||||||
|
if (possibleKatex.match(ruleReg)) {
|
||||||
|
return index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
indexSrc = indexSrc.substring(index + 1).replace(/^\$+/, ``)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tokenizer(src: string) {
|
||||||
|
const match = src.match(ruleReg)
|
||||||
|
if (match) {
|
||||||
|
return {
|
||||||
|
type: `inlineKatex`,
|
||||||
|
raw: match[0],
|
||||||
|
text: match[2].trim(),
|
||||||
|
displayMode: match[1].length === 2,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
renderer,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function blockKatex(_options: MarkedKatexOptions | undefined, renderer: any) {
|
||||||
|
return {
|
||||||
|
name: `blockKatex`,
|
||||||
|
level: `block`,
|
||||||
|
tokenizer(src: string) {
|
||||||
|
const match = src.match(blockRule)
|
||||||
|
if (match) {
|
||||||
|
return {
|
||||||
|
type: `blockKatex`,
|
||||||
|
raw: match[0],
|
||||||
|
text: match[2].trim(),
|
||||||
|
displayMode: match[1].length === 2,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
renderer,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function inlineLatexKatex(_options: MarkedKatexOptions | undefined, renderer: any) {
|
||||||
|
return {
|
||||||
|
name: `inlineLatexKatex`,
|
||||||
|
level: `inline`,
|
||||||
|
start(src: string) {
|
||||||
|
const index = src.indexOf(`\\(`)
|
||||||
|
return index !== -1 ? index : undefined
|
||||||
|
},
|
||||||
|
tokenizer(src: string) {
|
||||||
|
const match = src.match(inlineLatexRule)
|
||||||
|
if (match) {
|
||||||
|
return {
|
||||||
|
type: `inlineLatexKatex`,
|
||||||
|
raw: match[0],
|
||||||
|
text: match[1].trim(),
|
||||||
|
displayMode: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
renderer,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function blockLatexKatex(_options: MarkedKatexOptions | undefined, renderer: any) {
|
||||||
|
return {
|
||||||
|
name: `blockLatexKatex`,
|
||||||
|
level: `block`,
|
||||||
|
start(src: string) {
|
||||||
|
const index = src.indexOf(`\\[`)
|
||||||
|
return index !== -1 ? index : undefined
|
||||||
|
},
|
||||||
|
tokenizer(src: string) {
|
||||||
|
const match = src.match(blockLatexRule)
|
||||||
|
if (match) {
|
||||||
|
return {
|
||||||
|
type: `blockLatexKatex`,
|
||||||
|
raw: match[0],
|
||||||
|
text: match[1].trim(),
|
||||||
|
displayMode: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
renderer,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MDKatex(options: MarkedKatexOptions | undefined, withStyle: boolean = true): MarkedExtension {
|
||||||
|
return {
|
||||||
|
extensions: [
|
||||||
|
inlineKatex(options, createRenderer(false, withStyle)),
|
||||||
|
blockKatex(options, createRenderer(true, withStyle)),
|
||||||
|
inlineLatexKatex(options, createRenderer(false, withStyle)),
|
||||||
|
blockLatexKatex(options, createRenderer(true, withStyle)),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import type { MarkedExtension } from 'marked'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 扩展标记语法:
|
||||||
|
* - 高亮: ==文本==
|
||||||
|
* - 下划线: ++文本++
|
||||||
|
* - 波浪线: ~文本~
|
||||||
|
*/
|
||||||
|
export function markedMarkup(): MarkedExtension {
|
||||||
|
return {
|
||||||
|
extensions: [
|
||||||
|
// 高亮语法 ==文本==
|
||||||
|
{
|
||||||
|
name: `markup_highlight`,
|
||||||
|
level: `inline`,
|
||||||
|
start(src: string) {
|
||||||
|
return src.match(/==(?!=)/)?.index
|
||||||
|
},
|
||||||
|
tokenizer(src: string) {
|
||||||
|
const rule = /^==((?:[^=]|=(?!=))+)==/
|
||||||
|
const match = rule.exec(src)
|
||||||
|
if (match) {
|
||||||
|
return {
|
||||||
|
type: `markup_highlight`,
|
||||||
|
raw: match[0],
|
||||||
|
text: match[1],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
renderer(token: any) {
|
||||||
|
// 新主题系统:使用 class 而非内联样式
|
||||||
|
return `<span class="markup-highlight">${token.text}</span>`
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// 下划线语法 ++文本++
|
||||||
|
{
|
||||||
|
name: `markup_underline`,
|
||||||
|
level: `inline`,
|
||||||
|
start(src: string) {
|
||||||
|
return src.match(/\+\+(?!\+)/)?.index
|
||||||
|
},
|
||||||
|
tokenizer(src: string) {
|
||||||
|
const rule = /^\+\+((?:[^+]|\+(?!\+))+)\+\+/
|
||||||
|
const match = rule.exec(src)
|
||||||
|
if (match) {
|
||||||
|
return {
|
||||||
|
type: `markup_underline`,
|
||||||
|
raw: match[0],
|
||||||
|
text: match[1],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
renderer(token: any) {
|
||||||
|
// 新主题系统:使用 class 而非内联样式
|
||||||
|
return `<span class="markup-underline">${token.text}</span>`
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// 波浪线语法 ~文本~
|
||||||
|
{
|
||||||
|
name: `markup_wavyline`,
|
||||||
|
level: `inline`,
|
||||||
|
start(src: string) {
|
||||||
|
// 查找单个 ~ 但不是连续的 ~~
|
||||||
|
return src.match(/~(?!~)/)?.index
|
||||||
|
},
|
||||||
|
tokenizer(src: string) {
|
||||||
|
// 匹配 ~文本~ 但确保不是 ~~文本~~
|
||||||
|
const rule = /^~([^~\n]+)~(?!~)/
|
||||||
|
const match = rule.exec(src)
|
||||||
|
if (match) {
|
||||||
|
return {
|
||||||
|
type: `markup_wavyline`,
|
||||||
|
raw: match[0],
|
||||||
|
text: match[1],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
renderer(token: any) {
|
||||||
|
// 新主题系统:使用 class 而非内联样式
|
||||||
|
return `<span class="markup-wavyline">${token.text}</span>`
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
import type { MarkedExtension, Tokens } from 'marked'
|
||||||
|
import { deflateSync } from 'fflate'
|
||||||
|
|
||||||
|
export interface PlantUMLOptions {
|
||||||
|
/**
|
||||||
|
* PlantUML 服务器地址
|
||||||
|
* @default 'https://www.plantuml.com/plantuml'
|
||||||
|
*/
|
||||||
|
serverUrl?: string
|
||||||
|
/**
|
||||||
|
* 渲染格式
|
||||||
|
* @default 'svg'
|
||||||
|
*/
|
||||||
|
format?: `svg` | `png`
|
||||||
|
/**
|
||||||
|
* CSS 类名
|
||||||
|
* @default 'plantuml-diagram'
|
||||||
|
*/
|
||||||
|
className?: string
|
||||||
|
/**
|
||||||
|
* 是否内嵌SVG内容(用于微信公众号等不支持外链图片的环境)
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
inlineSvg?: boolean
|
||||||
|
/**
|
||||||
|
* 自定义样式
|
||||||
|
*/
|
||||||
|
styles?: {
|
||||||
|
container?: Record<string, string | number>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PlantUML 专用的 6-bit 编码函数
|
||||||
|
* 基于官方文档 https://plantuml.com/text-encoding
|
||||||
|
*/
|
||||||
|
function encode6bit(b: number): string {
|
||||||
|
if (b < 10) {
|
||||||
|
return String.fromCharCode(48 + b)
|
||||||
|
}
|
||||||
|
b -= 10
|
||||||
|
if (b < 26) {
|
||||||
|
return String.fromCharCode(65 + b)
|
||||||
|
}
|
||||||
|
b -= 26
|
||||||
|
if (b < 26) {
|
||||||
|
return String.fromCharCode(97 + b)
|
||||||
|
}
|
||||||
|
b -= 26
|
||||||
|
if (b === 0) {
|
||||||
|
return `-`
|
||||||
|
}
|
||||||
|
if (b === 1) {
|
||||||
|
return `_`
|
||||||
|
}
|
||||||
|
return `?`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 3 个字节附加到编码字符串中
|
||||||
|
* 基于官方文档 https://plantuml.com/text-encoding
|
||||||
|
*/
|
||||||
|
function append3bytes(b1: number, b2: number, b3: number): string {
|
||||||
|
const c1 = b1 >> 2
|
||||||
|
const c2 = ((b1 & 0x3) << 4) | (b2 >> 4)
|
||||||
|
const c3 = ((b2 & 0xF) << 2) | (b3 >> 6)
|
||||||
|
const c4 = b3 & 0x3F
|
||||||
|
let r = ``
|
||||||
|
r += encode6bit(c1 & 0x3F)
|
||||||
|
r += encode6bit(c2 & 0x3F)
|
||||||
|
r += encode6bit(c3 & 0x3F)
|
||||||
|
r += encode6bit(c4 & 0x3F)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PlantUML 专用的 base64 编码函数
|
||||||
|
* 基于官方文档 https://plantuml.com/text-encoding
|
||||||
|
*/
|
||||||
|
function encode64(data: string): string {
|
||||||
|
let r = ``
|
||||||
|
for (let i = 0; i < data.length; i += 3) {
|
||||||
|
if (i + 2 === data.length) {
|
||||||
|
r += append3bytes(data.charCodeAt(i), data.charCodeAt(i + 1), 0)
|
||||||
|
}
|
||||||
|
else if (i + 1 === data.length) {
|
||||||
|
r += append3bytes(data.charCodeAt(i), 0, 0)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
r += append3bytes(data.charCodeAt(i), data.charCodeAt(i + 1), data.charCodeAt(i + 2))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用 fflate 库进行 Deflate 压缩
|
||||||
|
* 按照官方规范进行压缩
|
||||||
|
*/
|
||||||
|
function performDeflate(input: string): string {
|
||||||
|
try {
|
||||||
|
// 将字符串转换为字节数组
|
||||||
|
const inputBytes = new TextEncoder().encode(input)
|
||||||
|
|
||||||
|
// 使用 fflate 进行 deflate 压缩(最高压缩级别 9)
|
||||||
|
const compressed = deflateSync(inputBytes, { level: 9 })
|
||||||
|
|
||||||
|
// 将压缩后的字节数组转换为二进制字符串
|
||||||
|
return String.fromCharCode(...compressed)
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.warn(`Deflate compression failed:`, error)
|
||||||
|
// 如果压缩失败,返回原始输入
|
||||||
|
return input
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 编码 PlantUML 代码为服务器可识别的格式
|
||||||
|
* 按照官方规范:UTF-8 编码 -> Deflate 压缩 -> PlantUML Base64 编码
|
||||||
|
*/
|
||||||
|
function encodePlantUML(plantumlCode: string): string {
|
||||||
|
try {
|
||||||
|
// 步骤 1 & 2: UTF-8 编码 + Deflate 压缩
|
||||||
|
const deflated = performDeflate(plantumlCode)
|
||||||
|
|
||||||
|
// 步骤 3: PlantUML 专用的 base64 编码
|
||||||
|
return encode64(deflated)
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
// 如果编码失败,回退到简单方案
|
||||||
|
console.warn(`PlantUML encoding failed, using fallback:`, error)
|
||||||
|
const utf8Bytes = new TextEncoder().encode(plantumlCode)
|
||||||
|
const base64 = btoa(String.fromCharCode(...utf8Bytes))
|
||||||
|
return `~1${base64.replace(/\+/g, `-`).replace(/\//g, `_`).replace(/=/g, ``)}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成 PlantUML 图片 URL
|
||||||
|
*/
|
||||||
|
function generatePlantUMLUrl(code: string, options: Required<PlantUMLOptions>): string {
|
||||||
|
const encoded = encodePlantUML(code)
|
||||||
|
const formatPath = options.format === `svg` ? `svg` : `png`
|
||||||
|
return `${options.serverUrl}/${formatPath}/${encoded}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 渲染 PlantUML 图表
|
||||||
|
*/
|
||||||
|
function renderPlantUMLDiagram(token: Tokens.Code, options: Required<PlantUMLOptions>): string {
|
||||||
|
const { text: code } = token
|
||||||
|
|
||||||
|
// 检查代码是否包含 PlantUML 标记
|
||||||
|
const finalCode = (!code.trim().includes(`@start`) || !code.trim().includes(`@end`))
|
||||||
|
? `@startuml\n${code.trim()}\n@enduml`
|
||||||
|
: code
|
||||||
|
|
||||||
|
const imageUrl = generatePlantUMLUrl(finalCode, options)
|
||||||
|
|
||||||
|
// 如果启用了内嵌SVG且格式是SVG
|
||||||
|
if (options.inlineSvg && options.format === `svg`) {
|
||||||
|
// 由于marked是同步的,我们需要返回一个占位符,然后异步替换
|
||||||
|
const placeholder = `plantuml-placeholder-${Math.random().toString(36).slice(2, 11)}`
|
||||||
|
|
||||||
|
// 异步获取SVG内容并替换
|
||||||
|
fetchSvgContent(imageUrl).then((svgContent) => {
|
||||||
|
const placeholderElement = document.querySelector(`[data-placeholder="${placeholder}"]`)
|
||||||
|
if (placeholderElement) {
|
||||||
|
placeholderElement.outerHTML = createPlantUMLHTML(imageUrl, options, svgContent)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const containerStyles = options.styles.container
|
||||||
|
? Object.entries(options.styles.container)
|
||||||
|
.map(([key, value]) => `${key.replace(/([A-Z])/g, `-$1`).toLowerCase()}: ${value}`)
|
||||||
|
.join(`; `)
|
||||||
|
: ``
|
||||||
|
|
||||||
|
return `<div class="${options.className}" style="${containerStyles}" data-placeholder="${placeholder}">
|
||||||
|
<div style="color: #666; font-style: italic;">正在加载PlantUML图表...</div>
|
||||||
|
</div>`
|
||||||
|
}
|
||||||
|
|
||||||
|
return createPlantUMLHTML(imageUrl, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取SVG内容
|
||||||
|
*/
|
||||||
|
async function fetchSvgContent(svgUrl: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(svgUrl)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}`)
|
||||||
|
}
|
||||||
|
const svgContent = await response.text()
|
||||||
|
// 移除SVG根元素的固定尺寸,使其响应式
|
||||||
|
return svgContent
|
||||||
|
// 移除width和height属性
|
||||||
|
.replace(/(<svg[^>]*)\swidth="[^"]*"/g, `$1`)
|
||||||
|
.replace(/(<svg[^>]*)\sheight="[^"]*"/g, `$1`)
|
||||||
|
// 移除style中的width和height
|
||||||
|
.replace(/(<svg[^>]*style="[^"]*?)width:[^;]*;?/g, `$1`)
|
||||||
|
.replace(/(<svg[^>]*style="[^"]*?)height:[^;]*;?/g, `$1`)
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.warn(`Failed to fetch SVG content from ${svgUrl}:`, error)
|
||||||
|
return `<div style="color: #666; font-style: italic;">PlantUML图表加载失败</div>`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建 PlantUML HTML 元素
|
||||||
|
*/
|
||||||
|
function createPlantUMLHTML(imageUrl: string, options: Required<PlantUMLOptions>, svgContent?: string): string {
|
||||||
|
const containerStyles = options.styles.container
|
||||||
|
? Object.entries(options.styles.container)
|
||||||
|
.map(([key, value]) => `${key.replace(/([A-Z])/g, `-$1`).toLowerCase()}: ${value}`)
|
||||||
|
.join(`; `)
|
||||||
|
: ``
|
||||||
|
|
||||||
|
// 如果有SVG内容,直接嵌入
|
||||||
|
if (svgContent) {
|
||||||
|
return `<div class="${options.className}" style="${containerStyles}">
|
||||||
|
${svgContent}
|
||||||
|
</div>`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 否则使用图片链接
|
||||||
|
return `<div class="${options.className}" style="${containerStyles}">
|
||||||
|
<img src="${imageUrl}" alt="PlantUML Diagram" style="max-width: 100%; height: auto;" />
|
||||||
|
</div>`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PlantUML marked 扩展
|
||||||
|
*/
|
||||||
|
export function markedPlantUML(options: PlantUMLOptions = {}): MarkedExtension {
|
||||||
|
const resolvedOptions: Required<PlantUMLOptions> = {
|
||||||
|
serverUrl: options.serverUrl || `https://www.plantuml.com/plantuml`,
|
||||||
|
format: options.format || `svg`,
|
||||||
|
className: options.className || `plantuml-diagram`,
|
||||||
|
inlineSvg: options.inlineSvg || false,
|
||||||
|
styles: {
|
||||||
|
container: {
|
||||||
|
textAlign: `center`,
|
||||||
|
margin: `16px 8px`,
|
||||||
|
overflowX: `auto`,
|
||||||
|
...options.styles?.container,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
extensions: [
|
||||||
|
{
|
||||||
|
name: `plantuml`,
|
||||||
|
level: `block`,
|
||||||
|
start(src: string) {
|
||||||
|
// 匹配 ```plantuml 代码块
|
||||||
|
return src.match(/^```plantuml/m)?.index
|
||||||
|
},
|
||||||
|
tokenizer(src: string) {
|
||||||
|
// 匹配完整的 plantuml 代码块
|
||||||
|
const match = /^```plantuml\r?\n([\s\S]*?)\r?\n```/.exec(src)
|
||||||
|
|
||||||
|
if (match) {
|
||||||
|
const [raw, code] = match
|
||||||
|
return {
|
||||||
|
type: `plantuml`,
|
||||||
|
raw,
|
||||||
|
text: code.trim(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
renderer(token: any) {
|
||||||
|
return renderPlantUMLDiagram(token, resolvedOptions)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
walkTokens(token: any) {
|
||||||
|
// 处理现有的代码块,如果语言是 plantuml 就转换类型
|
||||||
|
if (token.type === `code` && token.lang === `plantuml`) {
|
||||||
|
token.type = `plantuml`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import type { MarkedExtension } from 'marked'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注音/拼音标注扩展
|
||||||
|
* https://talk.commonmark.org/t/proper-ruby-text-rb-syntax-support-in-markdown/2279
|
||||||
|
* https://www.w3.org/TR/ruby/
|
||||||
|
*
|
||||||
|
* 支持的格式:
|
||||||
|
* 1. [文字]{注音}
|
||||||
|
* 2. [文字]^(注音)
|
||||||
|
*
|
||||||
|
* 分隔符:
|
||||||
|
* - `・` (中点)
|
||||||
|
* - `.` (全角句点)
|
||||||
|
* - `。` (中文句号)
|
||||||
|
* - `-` (英文减号)
|
||||||
|
*/
|
||||||
|
export function markedRuby(): MarkedExtension {
|
||||||
|
return {
|
||||||
|
extensions: [
|
||||||
|
{
|
||||||
|
name: `ruby`,
|
||||||
|
level: `inline`,
|
||||||
|
start(src: string) {
|
||||||
|
// 匹配以 [ 开头的格式
|
||||||
|
return src.match(/\[/)?.index
|
||||||
|
},
|
||||||
|
tokenizer(src: string) {
|
||||||
|
// 1. [文字]{注音}
|
||||||
|
const rule1 = /^\[([^\]]+)\]\{([^}]+)\}/
|
||||||
|
let match = rule1.exec(src)
|
||||||
|
if (match) {
|
||||||
|
return {
|
||||||
|
type: `ruby`,
|
||||||
|
raw: match[0],
|
||||||
|
text: match[1].trim(),
|
||||||
|
ruby: match[2].trim(),
|
||||||
|
format: `basic`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. [文字]^(注音)
|
||||||
|
const rule2 = /^\[([^\]]+)\]\^\(([^)]+)\)/
|
||||||
|
match = rule2.exec(src)
|
||||||
|
if (match) {
|
||||||
|
return {
|
||||||
|
type: `ruby`,
|
||||||
|
raw: match[0],
|
||||||
|
text: match[1].trim(),
|
||||||
|
ruby: match[2].trim(),
|
||||||
|
format: `basic-hat`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
},
|
||||||
|
renderer(token: any) {
|
||||||
|
const { text, ruby, format } = token
|
||||||
|
|
||||||
|
// 检查是否有分隔符
|
||||||
|
const separatorRegex = /[・.。-]/g
|
||||||
|
const hasSeparators = separatorRegex.test(ruby)
|
||||||
|
|
||||||
|
if (hasSeparators) {
|
||||||
|
// 分割注音部分
|
||||||
|
const rubyParts = ruby.split(separatorRegex).filter((part: string) => part.trim() !== ``)
|
||||||
|
|
||||||
|
const textChars = text.split(``)
|
||||||
|
const result = []
|
||||||
|
|
||||||
|
if (textChars.length >= rubyParts.length) {
|
||||||
|
// 文字字符数量 >= 注音部分数量
|
||||||
|
// 按注音部分数量分割文字
|
||||||
|
let currentIndex = 0
|
||||||
|
|
||||||
|
for (let i = 0; i < rubyParts.length; i++) {
|
||||||
|
const rubyPart = rubyParts[i]
|
||||||
|
const remainingChars = textChars.length - currentIndex
|
||||||
|
const remainingParts = rubyParts.length - i
|
||||||
|
|
||||||
|
// 计算当前部分应该包含多少个字符,默认为 1
|
||||||
|
let charCount = 1
|
||||||
|
if (remainingParts === 1) {
|
||||||
|
// 最后一个部分,包含所有剩余字符
|
||||||
|
charCount = remainingChars
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提取当前部分的文字
|
||||||
|
const currentText = textChars.slice(currentIndex, currentIndex + charCount).join(``)
|
||||||
|
|
||||||
|
result.push(`<ruby data-text="${currentText}" data-ruby="${rubyPart}" data-format="${format}">${currentText}<rp>(</rp><rt>${rubyPart}</rt><rp>)</rp></ruby>`)
|
||||||
|
|
||||||
|
currentIndex += charCount
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理剩余的字符
|
||||||
|
if (currentIndex < textChars.length) {
|
||||||
|
result.push(textChars.slice(currentIndex).join(``))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// 文字字符数量 < 注音部分数量
|
||||||
|
// 每个字符对应一个注音部分,多余的注音被忽略
|
||||||
|
for (let i = 0; i < textChars.length; i++) {
|
||||||
|
const char = textChars[i]
|
||||||
|
const rubyPart = rubyParts[i] || ``
|
||||||
|
|
||||||
|
if (rubyPart) {
|
||||||
|
result.push(`<ruby data-text="${char}" data-ruby="${rubyPart}" data-format="${format}">${char}<rp>(</rp><rt>${rubyPart}</rt><rp>)</rp></ruby>`)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
result.push(char)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.join(``)
|
||||||
|
}
|
||||||
|
|
||||||
|
return `<ruby data-text="${text}" data-ruby="${ruby}" data-format="${format}">${text}<rp>(</rp><rt>${ruby}</rt><rp>)</rp></ruby>`
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import type { MarkedExtension, Tokens } from 'marked'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A marked extension to support horizontal sliding images.
|
||||||
|
* Syntax: <,,>
|
||||||
|
*/
|
||||||
|
export function markedSlider(): MarkedExtension {
|
||||||
|
return {
|
||||||
|
extensions: [
|
||||||
|
{
|
||||||
|
name: `horizontalSlider`,
|
||||||
|
level: `block`,
|
||||||
|
start(src: string) {
|
||||||
|
return src.match(/^<!\[/)?.index
|
||||||
|
},
|
||||||
|
tokenizer(src: string) {
|
||||||
|
const rule = /^<(!\[.*?\]\(.*?\)(?:,!\[.*?\]\(.*?\))*)>/
|
||||||
|
const match = src.match(rule)
|
||||||
|
if (match) {
|
||||||
|
return {
|
||||||
|
type: `horizontalSlider`,
|
||||||
|
raw: match[0],
|
||||||
|
text: match[1],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
},
|
||||||
|
renderer(token: Tokens.Generic) {
|
||||||
|
const { text } = token
|
||||||
|
const imageMatches = text.match(/!\[(.*?)\]\((.*?)\)/g) || []
|
||||||
|
|
||||||
|
if (imageMatches.length === 0) {
|
||||||
|
return ``
|
||||||
|
}
|
||||||
|
|
||||||
|
const images = imageMatches.map((img: string) => {
|
||||||
|
const altMatch = img.match(/!\[(.*?)\]/) || []
|
||||||
|
const srcMatch = img.match(/\]\((.*?)\)/) || []
|
||||||
|
const alt = altMatch[1] || ``
|
||||||
|
const src = srcMatch[1] || ``
|
||||||
|
|
||||||
|
// 新主题系统:不再需要内联样式
|
||||||
|
return { src, alt }
|
||||||
|
})
|
||||||
|
|
||||||
|
// 使用微信公众号兼容的滑动容器布局
|
||||||
|
// 使用微信支持的section标签和特殊样式组合
|
||||||
|
|
||||||
|
return `
|
||||||
|
<section style="box-sizing: border-box; font-size: 16px;">
|
||||||
|
<section data-role="outer" style="font-family: 微软雅黑; font-size: 16px;">
|
||||||
|
<section data-role="paragraph" style="margin: 0px auto; box-sizing: border-box; width: 100%;">
|
||||||
|
<section style="margin: 0px auto; text-align: center;">
|
||||||
|
<section style="display: inline-block; width: 100%;">
|
||||||
|
<!-- 微信公众号支持的滑动图片容器 -->
|
||||||
|
<section style="overflow-x: scroll; -webkit-overflow-scrolling: touch; white-space: nowrap; width: 100%; text-align: center;">
|
||||||
|
${images.map((img: { src: string, alt: string }, _index: number) => `<section style="display: inline-block; width: 100%; margin-right: 0; vertical-align: top;">
|
||||||
|
<img src="${img.src}" alt="${img.alt}" title="${img.alt}" style="width: 100%; height: auto; border-radius: 4px; vertical-align: top;"/>
|
||||||
|
<p style="margin-top: 5px; font-size: 14px; color: #666; text-align: center; white-space: normal;">${img.alt}</p>
|
||||||
|
</section>`).join(``)}
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
<p style="font-size: 14px; color: #999; text-align: center; margin-top: 5px;"><<< 左右滑动看更多 >>></p>
|
||||||
|
</section>
|
||||||
|
`
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import type { MarkedExtension } from 'marked'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* marked 插件:支持 [TOC] 语法,自动生成嵌套目录
|
||||||
|
*/
|
||||||
|
export function markedToc(): MarkedExtension {
|
||||||
|
let headings: { text: string, depth: number, index: number }[] = []
|
||||||
|
|
||||||
|
let firstToken = true
|
||||||
|
|
||||||
|
return {
|
||||||
|
walkTokens(token) {
|
||||||
|
if (firstToken) {
|
||||||
|
headings = []
|
||||||
|
firstToken = false
|
||||||
|
}
|
||||||
|
if (token.type === `heading`) {
|
||||||
|
const text = token.text || ``
|
||||||
|
const depth = token.depth || 1
|
||||||
|
const index = headings.length
|
||||||
|
headings.push({ text, depth, index })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
extensions: [
|
||||||
|
{
|
||||||
|
name: `toc`,
|
||||||
|
level: `block`,
|
||||||
|
start(src) {
|
||||||
|
// 只匹配独立一行的 [TOC],避免误伤
|
||||||
|
const match = src.match(/^\s*\[TOC\]\s*$/m)
|
||||||
|
return match ? match.index : undefined
|
||||||
|
},
|
||||||
|
tokenizer(src) {
|
||||||
|
const match = /^\[TOC\]/.exec(src)
|
||||||
|
if (match) {
|
||||||
|
return {
|
||||||
|
type: `toc`,
|
||||||
|
raw: match[0],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
renderer() {
|
||||||
|
if (!headings.length)
|
||||||
|
return ``
|
||||||
|
let html = `<nav class="markdown-toc"><ul class="toc-ul toc-level-1 pl-4 border-l ml-2">`
|
||||||
|
let lastDepth = 1
|
||||||
|
headings.forEach(({ text, depth, index }) => {
|
||||||
|
if (depth > lastDepth) {
|
||||||
|
for (let i = lastDepth + 1; i <= depth; i++) {
|
||||||
|
html += `<ul class="toc-ul toc-level-${i} pl-4 border-l ml-2">`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (depth < lastDepth) {
|
||||||
|
for (let i = lastDepth; i > depth; i--) {
|
||||||
|
html += `</ul>`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
html += `<li class="toc-li toc-level-${depth} mb-1"><a class="text-gray-700 hover:text-blue-600 underline transition-colors" href="#${index}">${text}</a></li>`
|
||||||
|
lastDepth = depth
|
||||||
|
})
|
||||||
|
|
||||||
|
for (let i = lastDepth; i > 1; i--) {
|
||||||
|
html += `</ul>`
|
||||||
|
}
|
||||||
|
|
||||||
|
html += `</ul></nav>`
|
||||||
|
|
||||||
|
firstToken = true
|
||||||
|
return html
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
+1395
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"fflate": "^0.8.2",
|
||||||
|
"front-matter": "^4.0.2",
|
||||||
|
"highlight.js": "^11.11.1",
|
||||||
|
"juice": "^11.0.1",
|
||||||
|
"marked": "^15.0.6",
|
||||||
|
"reading-time": "^1.5.0",
|
||||||
|
"remark-cjk-friendly": "^1.1.0",
|
||||||
|
"remark-parse": "^11.0.0",
|
||||||
|
"remark-stringify": "^11.0.0",
|
||||||
|
"unified": "^11.0.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,848 @@
|
|||||||
|
#!/usr/bin/env npx tsx
|
||||||
|
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import frontMatter from "front-matter";
|
||||||
|
import hljs from "highlight.js/lib/core";
|
||||||
|
import { marked, type RendererObject, type Tokens } from "marked";
|
||||||
|
import readingTime, { type ReadTimeResults } from "reading-time";
|
||||||
|
import { unified } from "unified";
|
||||||
|
import remarkParse from "remark-parse";
|
||||||
|
import remarkCjkFriendly from "remark-cjk-friendly";
|
||||||
|
import remarkStringify from "remark-stringify";
|
||||||
|
|
||||||
|
import {
|
||||||
|
markedAlert,
|
||||||
|
markedFootnotes,
|
||||||
|
markedInfographic,
|
||||||
|
markedMarkup,
|
||||||
|
markedPlantUML,
|
||||||
|
markedRuby,
|
||||||
|
markedSlider,
|
||||||
|
markedToc,
|
||||||
|
MDKatex,
|
||||||
|
} from "./extensions/index.js";
|
||||||
|
import {
|
||||||
|
COMMON_LANGUAGES,
|
||||||
|
highlightAndFormatCode,
|
||||||
|
} from "./utils/languages.js";
|
||||||
|
|
||||||
|
type ThemeName = string;
|
||||||
|
|
||||||
|
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const THEME_DIR = path.resolve(SCRIPT_DIR, "themes");
|
||||||
|
const EXTERNAL_THEME_CONFIG_PATH =
|
||||||
|
process.env.MD_THEME_CONFIG_PATH
|
||||||
|
|| "/Users/jimliu/GitHub/md/packages/shared/src/configs/theme.ts";
|
||||||
|
const EXTERNAL_THEME_DIR =
|
||||||
|
process.env.MD_THEME_DIR
|
||||||
|
|| path.resolve(path.dirname(EXTERNAL_THEME_CONFIG_PATH), "theme-css");
|
||||||
|
const FALLBACK_THEMES: ThemeName[] = ["default", "grace", "simple"];
|
||||||
|
|
||||||
|
const DEFAULT_STYLE = {
|
||||||
|
primaryColor: "#0F4C81",
|
||||||
|
fontFamily:
|
||||||
|
"-apple-system-font,BlinkMacSystemFont, Helvetica Neue, PingFang SC, Hiragino Sans GB , Microsoft YaHei UI , Microsoft YaHei ,Arial,sans-serif",
|
||||||
|
fontSize: "16px",
|
||||||
|
foreground: "0 0% 3.9%",
|
||||||
|
blockquoteBackground: "#f7f7f7",
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.entries(COMMON_LANGUAGES).forEach(([name, lang]) => {
|
||||||
|
hljs.registerLanguage(name, lang);
|
||||||
|
});
|
||||||
|
|
||||||
|
export { hljs };
|
||||||
|
|
||||||
|
function stripOutputScope(cssContent: string): string {
|
||||||
|
let css = cssContent;
|
||||||
|
css = css.replace(/#output\s*\{/g, "body {");
|
||||||
|
css = css.replace(/#output\s+/g, "");
|
||||||
|
css = css.replace(/^#output\s*/gm, "");
|
||||||
|
return css;
|
||||||
|
}
|
||||||
|
|
||||||
|
function discoverThemesFromDir(dir: string): string[] {
|
||||||
|
if (!fs.existsSync(dir)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return fs
|
||||||
|
.readdirSync(dir)
|
||||||
|
.filter((name) => name.endsWith(".css"))
|
||||||
|
.map((name) => name.replace(/\.css$/i, ""))
|
||||||
|
.filter((name) => name.toLowerCase() !== "base");
|
||||||
|
}
|
||||||
|
|
||||||
|
function readThemeNamesFromConfig(configPath: string): string[] {
|
||||||
|
if (!fs.existsSync(configPath)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const content = fs.readFileSync(configPath, "utf-8");
|
||||||
|
const match = content.match(/themeOptionsMap\s*=\s*\{([\s\S]*?)\n\}/);
|
||||||
|
if (!match) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return Array.from(match[1].matchAll(/^\s*([a-zA-Z0-9_-]+)\s*:/gm)).map(
|
||||||
|
(item) => item[1]!
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveThemeNames(): ThemeName[] {
|
||||||
|
const localThemes = discoverThemesFromDir(THEME_DIR);
|
||||||
|
const externalThemes = discoverThemesFromDir(EXTERNAL_THEME_DIR);
|
||||||
|
const configThemes = readThemeNamesFromConfig(EXTERNAL_THEME_CONFIG_PATH);
|
||||||
|
const combined = new Set<ThemeName>([
|
||||||
|
...localThemes,
|
||||||
|
...externalThemes,
|
||||||
|
...configThemes,
|
||||||
|
]);
|
||||||
|
const resolved = Array.from(combined).filter((name) =>
|
||||||
|
fs.existsSync(path.join(THEME_DIR, `${name}.css`))
|
||||||
|
|| fs.existsSync(path.join(EXTERNAL_THEME_DIR, `${name}.css`))
|
||||||
|
);
|
||||||
|
return resolved.length ? resolved : FALLBACK_THEMES;
|
||||||
|
}
|
||||||
|
|
||||||
|
const THEME_NAMES: ThemeName[] = resolveThemeNames();
|
||||||
|
|
||||||
|
marked.setOptions({
|
||||||
|
breaks: true,
|
||||||
|
});
|
||||||
|
marked.use(markedSlider());
|
||||||
|
|
||||||
|
interface IOpts {
|
||||||
|
legend?: string;
|
||||||
|
citeStatus?: boolean;
|
||||||
|
countStatus?: boolean;
|
||||||
|
isMacCodeBlock?: boolean;
|
||||||
|
isShowLineNumber?: boolean;
|
||||||
|
themeMode?: "light" | "dark";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RendererAPI {
|
||||||
|
reset: (newOpts: Partial<IOpts>) => void;
|
||||||
|
setOptions: (newOpts: Partial<IOpts>) => void;
|
||||||
|
getOpts: () => IOpts;
|
||||||
|
parseFrontMatterAndContent: (markdown: string) => {
|
||||||
|
yamlData: Record<string, any>;
|
||||||
|
markdownContent: string;
|
||||||
|
readingTime: ReadTimeResults;
|
||||||
|
};
|
||||||
|
buildReadingTime: (reading: ReadTimeResults) => string;
|
||||||
|
buildFootnotes: () => string;
|
||||||
|
buildAddition: () => string;
|
||||||
|
createContainer: (html: string) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ParseResult {
|
||||||
|
yamlData: Record<string, any>;
|
||||||
|
markdownContent: string;
|
||||||
|
readingTime: ReadTimeResults;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text: string): string {
|
||||||
|
return text
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/`/g, "`");
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAddition(): string {
|
||||||
|
return `
|
||||||
|
<style>
|
||||||
|
.preview-wrapper pre::before {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
color: #ccc;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.8em;
|
||||||
|
padding: 5px 10px 0;
|
||||||
|
line-height: 15px;
|
||||||
|
height: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFootnoteArray(footnotes: [number, string, string][]): string {
|
||||||
|
return footnotes
|
||||||
|
.map(([index, title, link]) =>
|
||||||
|
link === title
|
||||||
|
? `<code style="font-size: 90%; opacity: 0.6;">[${index}]</code>: <i style="word-break: break-all">${title}</i><br/>`
|
||||||
|
: `<code style="font-size: 90%; opacity: 0.6;">[${index}]</code> ${title}: <i style="word-break: break-all">${link}</i><br/>`
|
||||||
|
)
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function transform(legend: string, text: string | null, title: string | null): string {
|
||||||
|
const options = legend.split("-");
|
||||||
|
for (const option of options) {
|
||||||
|
if (option === "alt" && text) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
if (option === "title" && title) {
|
||||||
|
return title;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const macCodeSvg = `
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" x="0px" y="0px" width="45px" height="13px" viewBox="0 0 450 130">
|
||||||
|
<ellipse cx="50" cy="65" rx="50" ry="52" stroke="rgb(220,60,54)" stroke-width="2" fill="rgb(237,108,96)" />
|
||||||
|
<ellipse cx="225" cy="65" rx="50" ry="52" stroke="rgb(218,151,33)" stroke-width="2" fill="rgb(247,193,81)" />
|
||||||
|
<ellipse cx="400" cy="65" rx="50" ry="52" stroke="rgb(27,161,37)" stroke-width="2" fill="rgb(100,200,86)" />
|
||||||
|
</svg>
|
||||||
|
`.trim();
|
||||||
|
|
||||||
|
function parseFrontMatterAndContent(markdownText: string): ParseResult {
|
||||||
|
try {
|
||||||
|
const parsed = frontMatter(markdownText);
|
||||||
|
const yamlData = parsed.attributes;
|
||||||
|
const markdownContent = parsed.body;
|
||||||
|
|
||||||
|
const readingTimeResult = readingTime(markdownContent);
|
||||||
|
|
||||||
|
return {
|
||||||
|
yamlData: yamlData as Record<string, any>,
|
||||||
|
markdownContent,
|
||||||
|
readingTime: readingTimeResult,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error parsing front-matter:", error);
|
||||||
|
return {
|
||||||
|
yamlData: {},
|
||||||
|
markdownContent: markdownText,
|
||||||
|
readingTime: readingTime(markdownText),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initRenderer(opts: IOpts = {}): RendererAPI {
|
||||||
|
const footnotes: [number, string, string][] = [];
|
||||||
|
let footnoteIndex = 0;
|
||||||
|
let codeIndex = 0;
|
||||||
|
const listOrderedStack: boolean[] = [];
|
||||||
|
const listCounters: number[] = [];
|
||||||
|
const isBrowser = typeof window !== "undefined";
|
||||||
|
|
||||||
|
function getOpts(): IOpts {
|
||||||
|
return opts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function styledContent(styleLabel: string, content: string, tagName?: string): string {
|
||||||
|
const tag = tagName ?? styleLabel;
|
||||||
|
const className = `${styleLabel.replace(/_/g, "-")}`;
|
||||||
|
const headingAttr = /^h\d$/.test(tag) ? " data-heading=\"true\"" : "";
|
||||||
|
return `<${tag} class="${className}"${headingAttr}>${content}</${tag}>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addFootnote(title: string, link: string): number {
|
||||||
|
const existingFootnote = footnotes.find(([, , existingLink]) => existingLink === link);
|
||||||
|
if (existingFootnote) {
|
||||||
|
return existingFootnote[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
footnotes.push([++footnoteIndex, title, link]);
|
||||||
|
return footnoteIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset(newOpts: Partial<IOpts>): void {
|
||||||
|
footnotes.length = 0;
|
||||||
|
footnoteIndex = 0;
|
||||||
|
setOptions(newOpts);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setOptions(newOpts: Partial<IOpts>): void {
|
||||||
|
opts = { ...opts, ...newOpts };
|
||||||
|
marked.use(markedAlert());
|
||||||
|
if (isBrowser) {
|
||||||
|
marked.use(MDKatex({ nonStandard: true }, true));
|
||||||
|
}
|
||||||
|
marked.use(markedMarkup());
|
||||||
|
marked.use(markedInfographic({ themeMode: opts.themeMode }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildReadingTime(readingTimeResult: ReadTimeResults): string {
|
||||||
|
if (!opts.countStatus) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
if (!readingTimeResult.words) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return `
|
||||||
|
<blockquote class="md-blockquote">
|
||||||
|
<p class="md-blockquote-p">字数 ${readingTimeResult?.words},阅读大约需 ${Math.ceil(readingTimeResult?.minutes)} 分钟</p>
|
||||||
|
</blockquote>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildFootnotes = () => {
|
||||||
|
if (!footnotes.length) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
styledContent("h4", "引用链接")
|
||||||
|
+ styledContent("footnotes", buildFootnoteArray(footnotes), "p")
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderer: RendererObject = {
|
||||||
|
heading({ tokens, depth }: Tokens.Heading) {
|
||||||
|
const text = this.parser.parseInline(tokens);
|
||||||
|
const tag = `h${depth}`;
|
||||||
|
return styledContent(tag, text);
|
||||||
|
},
|
||||||
|
|
||||||
|
paragraph({ tokens }: Tokens.Paragraph): string {
|
||||||
|
const text = this.parser.parseInline(tokens);
|
||||||
|
const isFigureImage = text.includes("<figure") && text.includes("<img");
|
||||||
|
const isEmpty = text.trim() === "";
|
||||||
|
if (isFigureImage || isEmpty) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
return styledContent("p", text);
|
||||||
|
},
|
||||||
|
|
||||||
|
blockquote({ tokens }: Tokens.Blockquote): string {
|
||||||
|
const text = this.parser.parse(tokens);
|
||||||
|
return styledContent("blockquote", text);
|
||||||
|
},
|
||||||
|
|
||||||
|
code({ text, lang = "" }: Tokens.Code): string {
|
||||||
|
if (lang.startsWith("mermaid")) {
|
||||||
|
if (isBrowser) {
|
||||||
|
clearTimeout(codeIndex as any);
|
||||||
|
codeIndex = setTimeout(async () => {
|
||||||
|
const windowRef = typeof window !== "undefined" ? (window as any) : undefined;
|
||||||
|
if (windowRef && windowRef.mermaid) {
|
||||||
|
const mermaid = windowRef.mermaid;
|
||||||
|
await mermaid.run();
|
||||||
|
} else {
|
||||||
|
const mermaid = await import("mermaid");
|
||||||
|
await mermaid.default.run();
|
||||||
|
}
|
||||||
|
}, 0) as any as number;
|
||||||
|
}
|
||||||
|
return `<pre class="mermaid">${text}</pre>`;
|
||||||
|
}
|
||||||
|
const langText = lang.split(" ")[0];
|
||||||
|
const isLanguageRegistered = hljs.getLanguage(langText);
|
||||||
|
const language = isLanguageRegistered ? langText : "plaintext";
|
||||||
|
|
||||||
|
const highlighted = highlightAndFormatCode(
|
||||||
|
text,
|
||||||
|
language,
|
||||||
|
hljs,
|
||||||
|
!!opts.isShowLineNumber
|
||||||
|
);
|
||||||
|
|
||||||
|
const span = `<span class="mac-sign" style="padding: 10px 14px 0;">${macCodeSvg}</span>`;
|
||||||
|
let pendingAttr = "";
|
||||||
|
if (!isLanguageRegistered && langText !== "plaintext") {
|
||||||
|
const escapedText = text.replace(/"/g, """);
|
||||||
|
pendingAttr = ` data-language-pending="${langText}" data-raw-code="${escapedText}" data-show-line-number="${opts.isShowLineNumber}"`;
|
||||||
|
}
|
||||||
|
const code = `<code class="language-${lang}"${pendingAttr}>${highlighted}</code>`;
|
||||||
|
|
||||||
|
return `<pre class="hljs code__pre">${span}${code}</pre>`;
|
||||||
|
},
|
||||||
|
|
||||||
|
codespan({ text }: Tokens.Codespan): string {
|
||||||
|
const escapedText = escapeHtml(text);
|
||||||
|
return styledContent("codespan", escapedText, "code");
|
||||||
|
},
|
||||||
|
|
||||||
|
list({ ordered, items, start = 1 }: Tokens.List) {
|
||||||
|
listOrderedStack.push(ordered);
|
||||||
|
listCounters.push(Number(start));
|
||||||
|
|
||||||
|
const html = items.map((item) => this.listitem(item)).join("");
|
||||||
|
|
||||||
|
listOrderedStack.pop();
|
||||||
|
listCounters.pop();
|
||||||
|
|
||||||
|
return styledContent(ordered ? "ol" : "ul", html);
|
||||||
|
},
|
||||||
|
|
||||||
|
listitem(token: Tokens.ListItem) {
|
||||||
|
const ordered = listOrderedStack[listOrderedStack.length - 1];
|
||||||
|
const idx = listCounters[listCounters.length - 1]!;
|
||||||
|
|
||||||
|
listCounters[listCounters.length - 1] = idx + 1;
|
||||||
|
|
||||||
|
const prefix = ordered ? `${idx}. ` : "• ";
|
||||||
|
|
||||||
|
let content: string;
|
||||||
|
try {
|
||||||
|
content = this.parser.parseInline(token.tokens);
|
||||||
|
} catch {
|
||||||
|
content = this.parser
|
||||||
|
.parse(token.tokens)
|
||||||
|
.replace(/^<p(?:\s[^>]*)?>([\s\S]*?)<\/p>/, "$1");
|
||||||
|
}
|
||||||
|
|
||||||
|
return styledContent("listitem", `${prefix}${content}`, "li");
|
||||||
|
},
|
||||||
|
|
||||||
|
image({ href, title, text }: Tokens.Image): string {
|
||||||
|
const newText = opts.legend ? transform(opts.legend, text, title) : "";
|
||||||
|
const subText = newText ? styledContent("figcaption", newText) : "";
|
||||||
|
const titleAttr = title ? ` title="${title}"` : "";
|
||||||
|
return `<figure><img src="${href}"${titleAttr} alt="${text}"/>${subText}</figure>`;
|
||||||
|
},
|
||||||
|
|
||||||
|
link({ href, title, text, tokens }: Tokens.Link): string {
|
||||||
|
const parsedText = this.parser.parseInline(tokens);
|
||||||
|
if (/^https?:\/\/mp\.weixin\.qq\.com/.test(href)) {
|
||||||
|
return `<a href="${href}" title="${title || text}">${parsedText}</a>`;
|
||||||
|
}
|
||||||
|
if (href === text) {
|
||||||
|
return parsedText;
|
||||||
|
}
|
||||||
|
if (opts.citeStatus) {
|
||||||
|
const ref = addFootnote(title || text, href);
|
||||||
|
return `<a href="${href}" title="${title || text}">${parsedText}<sup>[${ref}]</sup></a>`;
|
||||||
|
}
|
||||||
|
return `<a href="${href}" title="${title || text}">${parsedText}</a>`;
|
||||||
|
},
|
||||||
|
|
||||||
|
strong({ tokens }: Tokens.Strong): string {
|
||||||
|
return styledContent("strong", this.parser.parseInline(tokens));
|
||||||
|
},
|
||||||
|
|
||||||
|
em({ tokens }: Tokens.Em): string {
|
||||||
|
return styledContent("em", this.parser.parseInline(tokens));
|
||||||
|
},
|
||||||
|
|
||||||
|
table({ header, rows }: Tokens.Table): string {
|
||||||
|
const headerRow = header
|
||||||
|
.map((cell) => {
|
||||||
|
const text = this.parser.parseInline(cell.tokens);
|
||||||
|
return styledContent("th", text);
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
const body = rows
|
||||||
|
.map((row) => {
|
||||||
|
const rowContent = row.map((cell) => this.tablecell(cell)).join("");
|
||||||
|
return styledContent("tr", rowContent);
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
return `
|
||||||
|
<section style="max-width: 100%; overflow: auto">
|
||||||
|
<table class="preview-table">
|
||||||
|
<thead>${headerRow}</thead>
|
||||||
|
<tbody>${body}</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
`;
|
||||||
|
},
|
||||||
|
|
||||||
|
tablecell(token: Tokens.TableCell): string {
|
||||||
|
const text = this.parser.parseInline(token.tokens);
|
||||||
|
return styledContent("td", text);
|
||||||
|
},
|
||||||
|
|
||||||
|
hr(_: Tokens.Hr): string {
|
||||||
|
return styledContent("hr", "");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
marked.use({ renderer });
|
||||||
|
marked.use(markedMarkup());
|
||||||
|
marked.use(markedToc());
|
||||||
|
marked.use(markedSlider());
|
||||||
|
marked.use(markedAlert({}));
|
||||||
|
if (isBrowser) {
|
||||||
|
marked.use(MDKatex({ nonStandard: true }, true));
|
||||||
|
}
|
||||||
|
marked.use(markedFootnotes());
|
||||||
|
marked.use(
|
||||||
|
markedPlantUML({
|
||||||
|
inlineSvg: isBrowser,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
marked.use(markedInfographic());
|
||||||
|
marked.use(markedRuby());
|
||||||
|
|
||||||
|
return {
|
||||||
|
buildAddition,
|
||||||
|
buildFootnotes,
|
||||||
|
setOptions,
|
||||||
|
reset,
|
||||||
|
parseFrontMatterAndContent,
|
||||||
|
buildReadingTime,
|
||||||
|
createContainer(content: string) {
|
||||||
|
return styledContent("container", content, "section");
|
||||||
|
},
|
||||||
|
getOpts,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function printUsage(): void {
|
||||||
|
console.error(
|
||||||
|
[
|
||||||
|
"Usage:",
|
||||||
|
" npx tsx src/md/render.ts <markdown_file> [--theme <name>]",
|
||||||
|
"",
|
||||||
|
"Options:",
|
||||||
|
` --theme Theme name (${THEME_NAMES.join(", ")})`,
|
||||||
|
].join("\n")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs(argv: string[]): CliOptions | null {
|
||||||
|
let inputPath = "";
|
||||||
|
let theme: ThemeName = "default";
|
||||||
|
let keepTitle = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < argv.length; i += 1) {
|
||||||
|
const arg = argv[i];
|
||||||
|
if (!arg.startsWith("--") && !inputPath) {
|
||||||
|
inputPath = arg;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (arg === "--theme") {
|
||||||
|
theme = (argv[i + 1] || "") as ThemeName;
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (arg.startsWith("--theme=")) {
|
||||||
|
theme = arg.slice("--theme=".length) as ThemeName;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (arg === "--keep-title") {
|
||||||
|
keepTitle = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (arg === "--help" || arg === "-h") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error(`Unknown argument: ${arg}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!inputPath) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!THEME_NAMES.includes(theme)) {
|
||||||
|
console.error(`Unknown theme: ${theme}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
inputPath,
|
||||||
|
theme,
|
||||||
|
keepTitle,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CliOptions {
|
||||||
|
inputPath: string;
|
||||||
|
theme: ThemeName;
|
||||||
|
keepTitle: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function preprocessCjkEmphasis(markdown: string): string {
|
||||||
|
const processor = unified()
|
||||||
|
.use(remarkParse)
|
||||||
|
.use(remarkCjkFriendly);
|
||||||
|
const tree = processor.parse(markdown);
|
||||||
|
const visit = (node: any, parent?: any, index?: number) => {
|
||||||
|
if (node.children) {
|
||||||
|
for (let i = 0; i < node.children.length; i++) {
|
||||||
|
visit(node.children[i], node, i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (node.type === "strong" && parent && typeof index === "number") {
|
||||||
|
const text = extractText(node);
|
||||||
|
parent.children[index] = { type: "html", value: `<strong>${text}</strong>` };
|
||||||
|
}
|
||||||
|
if (node.type === "emphasis" && parent && typeof index === "number") {
|
||||||
|
const text = extractText(node);
|
||||||
|
parent.children[index] = { type: "html", value: `<em>${text}</em>` };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const extractText = (node: any): string => {
|
||||||
|
if (node.type === "text") return node.value;
|
||||||
|
if (node.children) return node.children.map(extractText).join("");
|
||||||
|
return "";
|
||||||
|
};
|
||||||
|
visit(tree);
|
||||||
|
const stringify = unified().use(remarkStringify);
|
||||||
|
let result = stringify.stringify(tree);
|
||||||
|
result = result.replace(/&#x([0-9A-Fa-f]+);/g, (_, hex) =>
|
||||||
|
String.fromCodePoint(parseInt(hex, 16))
|
||||||
|
);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMarkdown(raw: string, renderer: RendererAPI): {
|
||||||
|
html: string;
|
||||||
|
readingTime: ReadTimeResults;
|
||||||
|
} {
|
||||||
|
const preprocessed = preprocessCjkEmphasis(raw);
|
||||||
|
const { markdownContent, readingTime: readingTimeResult } =
|
||||||
|
renderer.parseFrontMatterAndContent(preprocessed);
|
||||||
|
|
||||||
|
const html = marked.parse(markdownContent) as string;
|
||||||
|
|
||||||
|
return { html, readingTime: readingTimeResult };
|
||||||
|
}
|
||||||
|
|
||||||
|
function postProcessHtml(
|
||||||
|
baseHtml: string,
|
||||||
|
reading: ReadTimeResults,
|
||||||
|
renderer: RendererAPI
|
||||||
|
): string {
|
||||||
|
let html = baseHtml;
|
||||||
|
html = renderer.buildReadingTime(reading) + html;
|
||||||
|
html += renderer.buildFootnotes();
|
||||||
|
html += renderer.buildAddition();
|
||||||
|
html += `
|
||||||
|
<style>
|
||||||
|
.hljs.code__pre > .mac-sign {
|
||||||
|
display: ${renderer.getOpts().isMacCodeBlock ? "flex" : "none"};
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
`;
|
||||||
|
html += `
|
||||||
|
<style>
|
||||||
|
h2 strong {
|
||||||
|
color: inherit !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
`;
|
||||||
|
return renderer.createContainer(html);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimestamp(date = new Date()): string {
|
||||||
|
const pad = (value: number) => String(value).padStart(2, "0");
|
||||||
|
return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(
|
||||||
|
date.getDate()
|
||||||
|
)}${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureMarkdownPath(inputPath: string): void {
|
||||||
|
if (!inputPath.toLowerCase().endsWith(".md")) {
|
||||||
|
throw new Error("Input file must end with .md");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadThemeCss(theme: ThemeName): {
|
||||||
|
baseCss: string;
|
||||||
|
themeCss: string;
|
||||||
|
} {
|
||||||
|
const basePathCandidates = [
|
||||||
|
path.join(THEME_DIR, "base.css"),
|
||||||
|
path.join(EXTERNAL_THEME_DIR, "base.css"),
|
||||||
|
];
|
||||||
|
const themePathCandidates = [
|
||||||
|
path.join(THEME_DIR, `${theme}.css`),
|
||||||
|
path.join(EXTERNAL_THEME_DIR, `${theme}.css`),
|
||||||
|
];
|
||||||
|
const basePath = basePathCandidates.find((candidate) =>
|
||||||
|
fs.existsSync(candidate)
|
||||||
|
);
|
||||||
|
const themePath = themePathCandidates.find((candidate) =>
|
||||||
|
fs.existsSync(candidate)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!basePath) {
|
||||||
|
throw new Error(
|
||||||
|
`Missing base CSS. Checked: ${basePathCandidates.join(", ")}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!themePath) {
|
||||||
|
throw new Error(
|
||||||
|
`Missing theme CSS for "${theme}". Checked: ${themePathCandidates.join(", ")}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
baseCss: fs.readFileSync(basePath, "utf-8"),
|
||||||
|
themeCss: fs.readFileSync(themePath, "utf-8"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCss(baseCss: string, themeCss: string): string {
|
||||||
|
const variables = `
|
||||||
|
:root {
|
||||||
|
--md-primary-color: ${DEFAULT_STYLE.primaryColor};
|
||||||
|
--md-font-family: ${DEFAULT_STYLE.fontFamily};
|
||||||
|
--md-font-size: ${DEFAULT_STYLE.fontSize};
|
||||||
|
--foreground: ${DEFAULT_STYLE.foreground};
|
||||||
|
--blockquote-background: ${DEFAULT_STYLE.blockquoteBackground};
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 24px;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
#output {
|
||||||
|
max-width: 860px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
`.trim();
|
||||||
|
|
||||||
|
return [variables, baseCss, themeCss].join("\n\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeThemeCss(css: string): string {
|
||||||
|
return stripOutputScope(css);
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
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
|
# 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
|
## Script Directory
|
||||||
|
|
||||||
**Agent Execution**: Determine this SKILL.md directory as `SKILL_DIR`, then use `${SKILL_DIR}/scripts/<name>.ts`.
|
**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 |
|
| Script | Purpose |
|
||||||
|--------|---------|
|
|--------|---------|
|
||||||
| `scripts/wechat-browser.ts` | Image-text posts (图文) |
|
| `scripts/wechat-browser.ts` | Image-text posts (图文) |
|
||||||
| `scripts/wechat-article.ts` | Article posting (文章) |
|
| `scripts/wechat-article.ts` | Article posting via browser (文章) |
|
||||||
| `scripts/md-to-wechat.ts` | Markdown → WeChat HTML |
|
| `scripts/wechat-api.ts` | Article posting via API (文章) |
|
||||||
|
|
||||||
## Preferences (EXTEND.md)
|
## Preferences (EXTEND.md)
|
||||||
|
|
||||||
@@ -43,38 +47,229 @@ test -f "$HOME/.baoyu-skills/baoyu-post-to-wechat/EXTEND.md" && echo "user"
|
|||||||
│ Not found │ Use defaults │
|
│ 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
|
```bash
|
||||||
npx -y bun ${SKILL_DIR}/scripts/wechat-browser.ts --markdown article.md --images ./images/
|
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
|
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 |
|
| Theme | Description |
|
||||||
|-------|-------------|
|
|-------|-------------|
|
||||||
| `default` | 经典主题 - 传统排版,标题居中带底边,二级标题白字彩底 |
|
| `default` | 经典主题 - 传统排版,标题居中带底边,二级标题白字彩底 |
|
||||||
| `grace` | 优雅主题 - 文字阴影,圆角卡片,精致引用块 (by @brzhang) |
|
| `grace` | 优雅主题 - 文字阴影,圆角卡片,精致引用块 |
|
||||||
| `simple` | 简洁主题 - 现代极简风,不对称圆角,清爽留白 (by @okooo5km) |
|
| `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
|
```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
|
```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
|
## Detailed References
|
||||||
@@ -86,24 +281,47 @@ npx -y bun ${SKILL_DIR}/scripts/wechat-article.ts --markdown article.md --theme
|
|||||||
|
|
||||||
## Feature Comparison
|
## Feature Comparison
|
||||||
|
|
||||||
| Feature | Image-Text | Article |
|
| Feature | Image-Text | Article (API) | Article (Browser) |
|
||||||
|---------|------------|---------|
|
|---------|------------|---------------|-------------------|
|
||||||
| Multiple images | ✓ (up to 9) | ✓ (inline) |
|
| Plain text input | ✗ | ✓ | ✓ |
|
||||||
| Markdown support | Title/content extraction | Full formatting |
|
| HTML input | ✗ | ✓ | ✓ |
|
||||||
| Auto compression | ✓ (title: 20, content: 1000 chars) | ✗ |
|
| Markdown input | Title/content | ✓ (via skill) | ✓ (via skill) |
|
||||||
| Themes | ✗ | ✓ (default, grace, simple) |
|
| Multiple images | ✓ (up to 9) | ✓ (inline) | ✓ (inline) |
|
||||||
|
| Themes | ✗ | ✓ | ✓ |
|
||||||
|
| Auto-generate metadata | ✗ | ✓ | ✓ |
|
||||||
|
| Requires Chrome | ✓ | ✗ | ✓ |
|
||||||
|
| Requires API credentials | ✗ | ✓ | ✗ |
|
||||||
|
| Speed | Medium | Fast | Slow |
|
||||||
|
|
||||||
## Prerequisites
|
## 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
|
- Google Chrome
|
||||||
- First run: log in to WeChat Official Account (session preserved)
|
- 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
|
## Troubleshooting
|
||||||
|
|
||||||
| Issue | Solution |
|
| 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 |
|
| 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 |
|
| Paste fails | Check system clipboard permissions |
|
||||||
|
|
||||||
## Extension Support
|
## Extension Support
|
||||||
|
|||||||
@@ -24,11 +24,17 @@ import {
|
|||||||
highlightAndFormatCode,
|
highlightAndFormatCode,
|
||||||
} from "./utils/languages.js";
|
} from "./utils/languages.js";
|
||||||
|
|
||||||
type ThemeName = "default" | "grace" | "simple";
|
type ThemeName = string;
|
||||||
|
|
||||||
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const THEME_DIR = path.resolve(SCRIPT_DIR, "themes");
|
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 = {
|
const DEFAULT_STYLE = {
|
||||||
primaryColor: "#0F4C81",
|
primaryColor: "#0F4C81",
|
||||||
@@ -45,6 +51,57 @@ Object.entries(COMMON_LANGUAGES).forEach(([name, lang]) => {
|
|||||||
|
|
||||||
export { hljs };
|
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({
|
marked.setOptions({
|
||||||
breaks: true,
|
breaks: true,
|
||||||
});
|
});
|
||||||
@@ -542,15 +599,31 @@ function loadThemeCss(theme: ThemeName): {
|
|||||||
baseCss: string;
|
baseCss: string;
|
||||||
themeCss: string;
|
themeCss: string;
|
||||||
} {
|
} {
|
||||||
const basePath = path.join(THEME_DIR, "base.css");
|
const basePathCandidates = [
|
||||||
const themePath = path.join(THEME_DIR, `${theme}.css`);
|
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)) {
|
if (!basePath) {
|
||||||
throw new Error(`Missing base CSS: ${basePath}`);
|
throw new Error(
|
||||||
|
`Missing base CSS. Checked: ${basePathCandidates.join(", ")}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!fs.existsSync(themePath)) {
|
if (!themePath) {
|
||||||
throw new Error(`Missing theme CSS: ${themePath}`);
|
throw new Error(
|
||||||
|
`Missing theme CSS for "${theme}". Checked: ${themePathCandidates.join(", ")}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -584,6 +657,10 @@ body {
|
|||||||
return [variables, baseCss, themeCss].join("\n\n");
|
return [variables, baseCss, themeCss].join("\n\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeThemeCss(css: string): string {
|
||||||
|
return stripOutputScope(css);
|
||||||
|
}
|
||||||
|
|
||||||
function buildHtmlDocument(title: string, css: string, html: string): string {
|
function buildHtmlDocument(title: string, css: string, html: string): string {
|
||||||
return [
|
return [
|
||||||
"<!doctype html>",
|
"<!doctype html>",
|
||||||
@@ -603,7 +680,65 @@ function buildHtmlDocument(title: string, css: string, html: string): string {
|
|||||||
].join("\n");
|
].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));
|
const options = parseArgs(process.argv.slice(2));
|
||||||
if (!options) {
|
if (!options) {
|
||||||
printUsage();
|
printUsage();
|
||||||
@@ -624,7 +759,7 @@ function main(): void {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const { baseCss, themeCss } = loadThemeCss(options.theme);
|
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 markdown = fs.readFileSync(inputPath, "utf-8");
|
||||||
|
|
||||||
const renderer = initRenderer({});
|
const renderer = initRenderer({});
|
||||||
@@ -636,6 +771,8 @@ function main(): void {
|
|||||||
|
|
||||||
const title = path.basename(outputPath, ".html");
|
const title = path.basename(outputPath, ".html");
|
||||||
const html = buildHtmlDocument(title, css, content);
|
const html = buildHtmlDocument(title, css, content);
|
||||||
|
const inlinedHtml = normalizeInlineCss(await inlineCss(html));
|
||||||
|
const finalHtml = modifyHtmlStructure(inlinedHtml);
|
||||||
|
|
||||||
let backupPath = "";
|
let backupPath = "";
|
||||||
if (fs.existsSync(outputPath)) {
|
if (fs.existsSync(outputPath)) {
|
||||||
@@ -643,7 +780,7 @@ function main(): void {
|
|||||||
fs.renameSync(outputPath, backupPath);
|
fs.renameSync(outputPath, backupPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
fs.writeFileSync(outputPath, html, "utf-8");
|
fs.writeFileSync(outputPath, finalHtml, "utf-8");
|
||||||
|
|
||||||
if (backupPath) {
|
if (backupPath) {
|
||||||
console.log(`Backup created: ${backupPath}`);
|
console.log(`Backup created: ${backupPath}`);
|
||||||
@@ -651,4 +788,7 @@ function main(): void {
|
|||||||
console.log(`HTML written: ${outputPath}`);
|
console.log(`HTML written: ${outputPath}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
main();
|
main().catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,520 @@
|
|||||||
|
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[^>]*\ssrc=["']([^"']+)["'][^>]*>/gi;
|
||||||
|
const matches = [...html.matchAll(imgRegex)];
|
||||||
|
|
||||||
|
if (matches.length === 0) {
|
||||||
|
return { html, firstMediaId: "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
let firstMediaId = "";
|
||||||
|
let updatedHtml = html;
|
||||||
|
|
||||||
|
for (const match of matches) {
|
||||||
|
const [fullTag, src] = match;
|
||||||
|
if (!src) continue;
|
||||||
|
|
||||||
|
if (src.startsWith("https://mmbiz.qpic.cn")) {
|
||||||
|
if (!firstMediaId) {
|
||||||
|
firstMediaId = src;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const localPathMatch = fullTag.match(/data-local-path=["']([^"']+)["']/);
|
||||||
|
const imagePath = localPathMatch ? localPathMatch[1]! : src;
|
||||||
|
|
||||||
|
console.error(`[wechat-api] Uploading image: ${imagePath}`);
|
||||||
|
try {
|
||||||
|
const resp = await uploadImage(imagePath, accessToken, baseDir);
|
||||||
|
const newTag = fullTag
|
||||||
|
.replace(/\ssrc=["'][^"']+["']/, ` src="${resp.url}"`)
|
||||||
|
.replace(/\sdata-local-path=["'][^"']+["']/, "");
|
||||||
|
updatedHtml = updatedHtml.replace(fullTag, newTag);
|
||||||
|
if (!firstMediaId) {
|
||||||
|
firstMediaId = resp.media_id;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[wechat-api] Failed to upload ${imagePath}:`, 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
|
||||||
|
--summary <text> Article summary (for future use)
|
||||||
|
--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;
|
||||||
|
summary?: string;
|
||||||
|
theme: string;
|
||||||
|
cover?: string;
|
||||||
|
dryRun: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs(argv: string[]): CliArgs {
|
||||||
|
if (argv.length === 0 || argv.includes("--help") || argv.includes("-h")) {
|
||||||
|
printUsage();
|
||||||
|
}
|
||||||
|
|
||||||
|
const args: CliArgs = {
|
||||||
|
filePath: "",
|
||||||
|
isHtml: false,
|
||||||
|
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 === "--summary" && argv[i + 1]) {
|
||||||
|
args.summary = argv[++i];
|
||||||
|
} else if (arg === "--theme" && argv[i + 1]) {
|
||||||
|
args.theme = argv[++i]!;
|
||||||
|
} else if (arg === "--cover" && argv[i + 1]) {
|
||||||
|
args.cover = argv[++i];
|
||||||
|
} else if (arg === "--dry-run") {
|
||||||
|
args.dryRun = true;
|
||||||
|
} else if (arg.startsWith("--") && argv[i + 1] && !argv[i + 1]!.startsWith("-")) {
|
||||||
|
i++;
|
||||||
|
} else if (!arg.startsWith("-")) {
|
||||||
|
args.filePath = arg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!args.filePath) {
|
||||||
|
console.error("Error: File path required");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
args.isHtml = args.filePath.toLowerCase().endsWith(".html");
|
||||||
|
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractHtmlTitle(html: string): string {
|
||||||
|
const titleMatch = html.match(/<title>([^<]+)<\/title>/i);
|
||||||
|
if (titleMatch) return titleMatch[1]!;
|
||||||
|
const h1Match = html.match(/<h1[^>]*>([^<]+)<\/h1>/i);
|
||||||
|
if (h1Match) return h1Match[1]!.replace(/<[^>]+>/g, "").trim();
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const args = parseArgs(process.argv.slice(2));
|
||||||
|
|
||||||
|
const filePath = path.resolve(args.filePath);
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
console.error(`Error: File not found: ${filePath}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseDir = path.dirname(filePath);
|
||||||
|
let title = args.title || "";
|
||||||
|
let htmlPath: string;
|
||||||
|
let htmlContent: string;
|
||||||
|
let frontmatter: Record<string, string> = {};
|
||||||
|
|
||||||
|
if (args.isHtml) {
|
||||||
|
htmlPath = filePath;
|
||||||
|
htmlContent = extractHtmlContent(htmlPath);
|
||||||
|
const mdPath = filePath.replace(/\.html$/i, ".md");
|
||||||
|
if (fs.existsSync(mdPath)) {
|
||||||
|
const mdContent = fs.readFileSync(mdPath, "utf-8");
|
||||||
|
const parsed = parseFrontmatter(mdContent);
|
||||||
|
frontmatter = parsed.frontmatter;
|
||||||
|
if (!title && frontmatter.title) {
|
||||||
|
title = frontmatter.title;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!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);
|
||||||
|
});
|
||||||
@@ -115,7 +115,7 @@ async function sendPaste(cdp?: CdpConnection, sessionId?: string): Promise<void>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function copyHtmlFromBrowser(cdp: CdpConnection, htmlFilePath: string): Promise<void> {
|
async function copyHtmlFromBrowser(cdp: CdpConnection, htmlFilePath: string, contentImages: ImageInfo[] = []): Promise<void> {
|
||||||
const absolutePath = path.isAbsolute(htmlFilePath) ? htmlFilePath : path.resolve(process.cwd(), htmlFilePath);
|
const absolutePath = path.isAbsolute(htmlFilePath) ? htmlFilePath : path.resolve(process.cwd(), htmlFilePath);
|
||||||
const fileUrl = `file://${absolutePath}`;
|
const fileUrl = `file://${absolutePath}`;
|
||||||
|
|
||||||
@@ -128,6 +128,28 @@ async function copyHtmlFromBrowser(cdp: CdpConnection, htmlFilePath: string): Pr
|
|||||||
await cdp.send('Runtime.enable', {}, { sessionId });
|
await cdp.send('Runtime.enable', {}, { sessionId });
|
||||||
await sleep(2000);
|
await sleep(2000);
|
||||||
|
|
||||||
|
if (contentImages.length > 0) {
|
||||||
|
console.log('[wechat] Replacing img tags with placeholders for browser paste...');
|
||||||
|
const replacements = contentImages.map(img => ({ placeholder: img.placeholder, localPath: img.localPath }));
|
||||||
|
await cdp.send<{ result: { value: unknown } }>('Runtime.evaluate', {
|
||||||
|
expression: `
|
||||||
|
(function() {
|
||||||
|
const replacements = ${JSON.stringify(replacements)};
|
||||||
|
for (const r of replacements) {
|
||||||
|
const imgs = document.querySelectorAll('img[src="' + r.placeholder + '"], img[data-local-path="' + r.localPath + '"]');
|
||||||
|
for (const img of imgs) {
|
||||||
|
const text = document.createTextNode(r.placeholder);
|
||||||
|
img.parentNode.replaceChild(text, img);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
})()
|
||||||
|
`,
|
||||||
|
returnByValue: true,
|
||||||
|
}, { sessionId });
|
||||||
|
await sleep(500);
|
||||||
|
}
|
||||||
|
|
||||||
console.log('[wechat] Selecting #output content...');
|
console.log('[wechat] Selecting #output content...');
|
||||||
await cdp.send<{ result: { value: unknown } }>('Runtime.evaluate', {
|
await cdp.send<{ result: { value: unknown } }>('Runtime.evaluate', {
|
||||||
expression: `
|
expression: `
|
||||||
@@ -176,7 +198,7 @@ async function parseMarkdownWithPlaceholders(markdownPath: string, theme?: strin
|
|||||||
return JSON.parse(output);
|
return JSON.parse(output);
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseHtmlMeta(htmlPath: string): { title: string; author: string; summary: string } {
|
function parseHtmlMeta(htmlPath: string): { title: string; author: string; summary: string; contentImages: ImageInfo[] } {
|
||||||
const content = fs.readFileSync(htmlPath, 'utf-8');
|
const content = fs.readFileSync(htmlPath, 'utf-8');
|
||||||
|
|
||||||
let title = '';
|
let title = '';
|
||||||
@@ -203,7 +225,45 @@ function parseHtmlMeta(htmlPath: string): { title: string; author: string; summa
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { title, author, summary };
|
const mdPath = htmlPath.replace(/\.html$/i, '.md');
|
||||||
|
if (fs.existsSync(mdPath)) {
|
||||||
|
const mdContent = fs.readFileSync(mdPath, 'utf-8');
|
||||||
|
const fmMatch = mdContent.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
||||||
|
if (fmMatch) {
|
||||||
|
const lines = fmMatch[1]!.split('\n');
|
||||||
|
for (const line of lines) {
|
||||||
|
const colonIdx = line.indexOf(':');
|
||||||
|
if (colonIdx > 0) {
|
||||||
|
const key = line.slice(0, colonIdx).trim();
|
||||||
|
let value = line.slice(colonIdx + 1).trim();
|
||||||
|
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
||||||
|
value = value.slice(1, -1);
|
||||||
|
}
|
||||||
|
if (key === 'title' && !title) title = value;
|
||||||
|
if (key === 'author' && !author) author = value;
|
||||||
|
if ((key === 'description' || key === 'summary') && !summary) summary = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentImages: ImageInfo[] = [];
|
||||||
|
const imgRegex = /<img[^>]*\ssrc=["']([^"']+)["'][^>]*>/gi;
|
||||||
|
const matches = [...content.matchAll(imgRegex)];
|
||||||
|
for (const match of matches) {
|
||||||
|
const [fullTag, src] = match;
|
||||||
|
if (!src || src.startsWith('http')) continue;
|
||||||
|
const localPathMatch = fullTag.match(/data-local-path=["']([^"']+)["']/);
|
||||||
|
if (localPathMatch) {
|
||||||
|
contentImages.push({
|
||||||
|
placeholder: src,
|
||||||
|
localPath: localPathMatch[1]!,
|
||||||
|
originalPath: src,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { title, author, summary, contentImages };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function selectAndReplacePlaceholder(session: ChromeSession, placeholder: string): Promise<boolean> {
|
async function selectAndReplacePlaceholder(session: ChromeSession, placeholder: string): Promise<boolean> {
|
||||||
@@ -273,9 +333,13 @@ export async function postArticle(options: ArticleOptions): Promise<void> {
|
|||||||
effectiveAuthor = effectiveAuthor || meta.author;
|
effectiveAuthor = effectiveAuthor || meta.author;
|
||||||
effectiveSummary = effectiveSummary || meta.summary;
|
effectiveSummary = effectiveSummary || meta.summary;
|
||||||
effectiveHtmlFile = htmlFile;
|
effectiveHtmlFile = htmlFile;
|
||||||
|
if (meta.contentImages.length > 0) {
|
||||||
|
contentImages = meta.contentImages;
|
||||||
|
}
|
||||||
console.log(`[wechat] Title: ${effectiveTitle || '(empty)'}`);
|
console.log(`[wechat] Title: ${effectiveTitle || '(empty)'}`);
|
||||||
console.log(`[wechat] Author: ${effectiveAuthor || '(empty)'}`);
|
console.log(`[wechat] Author: ${effectiveAuthor || '(empty)'}`);
|
||||||
console.log(`[wechat] Summary: ${effectiveSummary || '(empty)'}`);
|
console.log(`[wechat] Summary: ${effectiveSummary || '(empty)'}`);
|
||||||
|
console.log(`[wechat] Found ${contentImages.length} images to insert`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (effectiveTitle && effectiveTitle.length > 64) throw new Error(`Title too long: ${effectiveTitle.length} chars (max 64)`);
|
if (effectiveTitle && effectiveTitle.length > 64) throw new Error(`Title too long: ${effectiveTitle.length} chars (max 64)`);
|
||||||
@@ -416,7 +480,7 @@ export async function postArticle(options: ArticleOptions): Promise<void> {
|
|||||||
|
|
||||||
if (effectiveHtmlFile && fs.existsSync(effectiveHtmlFile)) {
|
if (effectiveHtmlFile && fs.existsSync(effectiveHtmlFile)) {
|
||||||
console.log(`[wechat] Copying HTML content from: ${effectiveHtmlFile}`);
|
console.log(`[wechat] Copying HTML content from: ${effectiveHtmlFile}`);
|
||||||
await copyHtmlFromBrowser(cdp, effectiveHtmlFile);
|
await copyHtmlFromBrowser(cdp, effectiveHtmlFile, contentImages);
|
||||||
await sleep(500);
|
await sleep(500);
|
||||||
console.log('[wechat] Pasting into editor...');
|
console.log('[wechat] Pasting into editor...');
|
||||||
await pasteFromClipboardInEditor(session);
|
await pasteFromClipboardInEditor(session);
|
||||||
|
|||||||
@@ -226,6 +226,7 @@ Schema: `references/config/preferences-schema.md`
|
|||||||
**1.2 Analyze Content**
|
**1.2 Analyze Content**
|
||||||
|
|
||||||
1. Save source content (if pasted, save as `source.md`)
|
1. Save source content (if pasted, save as `source.md`)
|
||||||
|
- **Backup rule**: If `source.md` exists, rename to `source-backup-YYYYMMDD-HHMMSS.md`
|
||||||
2. Follow `references/analysis-framework.md` for content analysis
|
2. Follow `references/analysis-framework.md` for content analysis
|
||||||
3. Analyze content signals for style recommendations
|
3. Analyze content signals for style recommendations
|
||||||
4. Detect source language
|
4. Detect source language
|
||||||
@@ -483,6 +484,7 @@ options:
|
|||||||
- Add slide-specific content
|
- Add slide-specific content
|
||||||
- If `Layout:` specified, include layout guidance from `references/layouts.md`
|
- If `Layout:` specified, include layout guidance from `references/layouts.md`
|
||||||
3. Save to `prompts/` directory
|
3. Save to `prompts/` directory
|
||||||
|
- **Backup rule**: If prompt file exists, rename to `prompts/NN-slide-{slug}-backup-YYYYMMDD-HHMMSS.md`
|
||||||
|
|
||||||
**After generation**:
|
**After generation**:
|
||||||
- If `--prompts-only`, stop here and output prompt summary
|
- If `--prompts-only`, stop here and output prompt summary
|
||||||
@@ -539,7 +541,9 @@ options:
|
|||||||
**Standard flow**:
|
**Standard flow**:
|
||||||
1. Select available image generation skill
|
1. Select available image generation skill
|
||||||
2. Generate session ID: `slides-{topic-slug}-{timestamp}`
|
2. Generate session ID: `slides-{topic-slug}-{timestamp}`
|
||||||
3. Generate each slide sequentially with same session ID
|
3. For each slide:
|
||||||
|
- **Backup rule**: If image file exists, rename to `NN-slide-{slug}-backup-YYYYMMDD-HHMMSS.png`
|
||||||
|
- Generate image sequentially with same session ID
|
||||||
4. Report progress: "Generated X/N" (in user's language)
|
4. Report progress: "Generated X/N" (in user's language)
|
||||||
5. Auto-retry once on failure before reporting error
|
5. Auto-retry once on failure before reporting error
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: baoyu-xhs-images
|
name: baoyu-xhs-images
|
||||||
description: Generates Xiaohongshu (Little Red Book) infographic series with 9 visual styles and 6 layouts. Breaks content into 1-10 cartoon-style images optimized for XHS engagement. Use when user mentions "小红书图片", "XHS images", "RedNote infographics", "小红书种草", or wants social media infographics for Chinese platforms.
|
description: Generates Xiaohongshu (Little Red Book) infographic series with 10 visual styles and 8 layouts. Breaks content into 1-10 cartoon-style images optimized for XHS engagement. Use when user mentions "小红书图片", "XHS images", "RedNote infographics", "小红书种草", or wants social media infographics for Chinese platforms.
|
||||||
---
|
---
|
||||||
|
|
||||||
# Xiaohongshu Infographic Series Generator
|
# Xiaohongshu Infographic Series Generator
|
||||||
@@ -42,8 +42,8 @@ Break down complex content into eye-catching infographic series for Xiaohongshu
|
|||||||
|
|
||||||
| Dimension | Controls | Options |
|
| Dimension | Controls | Options |
|
||||||
|-----------|----------|---------|
|
|-----------|----------|---------|
|
||||||
| **Style** | Visual aesthetics: colors, lines, decorations | cute, fresh, warm, bold, minimal, retro, pop, notion, chalkboard |
|
| **Style** | Visual aesthetics: colors, lines, decorations | cute, fresh, warm, bold, minimal, retro, pop, notion, chalkboard, study-notes |
|
||||||
| **Layout** | Information structure: density, arrangement | sparse, balanced, dense, list, comparison, flow |
|
| **Layout** | Information structure: density, arrangement | sparse, balanced, dense, list, comparison, flow, mindmap, quadrant |
|
||||||
|
|
||||||
Style × Layout can be freely combined. Example: `--style notion --layout dense` creates an intellectual-looking knowledge card with high information density.
|
Style × Layout can be freely combined. Example: `--style notion --layout dense` creates an intellectual-looking knowledge card with high information density.
|
||||||
|
|
||||||
@@ -60,6 +60,7 @@ Style × Layout can be freely combined. Example: `--style notion --layout dense`
|
|||||||
| `pop` | Vibrant, energetic, eye-catching |
|
| `pop` | Vibrant, energetic, eye-catching |
|
||||||
| `notion` | Minimalist hand-drawn line art, intellectual |
|
| `notion` | Minimalist hand-drawn line art, intellectual |
|
||||||
| `chalkboard` | Colorful chalk on black board, educational |
|
| `chalkboard` | Colorful chalk on black board, educational |
|
||||||
|
| `study-notes` | Realistic handwritten photo style, blue pen + red annotations + yellow highlighter |
|
||||||
|
|
||||||
Detailed style definitions: `references/presets/<style>.md`
|
Detailed style definitions: `references/presets/<style>.md`
|
||||||
|
|
||||||
@@ -73,6 +74,8 @@ Detailed style definitions: `references/presets/<style>.md`
|
|||||||
| `list` | Enumeration and ranking format (4-7 items) |
|
| `list` | Enumeration and ranking format (4-7 items) |
|
||||||
| `comparison` | Side-by-side contrast layout |
|
| `comparison` | Side-by-side contrast layout |
|
||||||
| `flow` | Process and timeline layout (3-6 steps) |
|
| `flow` | Process and timeline layout (3-6 steps) |
|
||||||
|
| `mindmap` | Center radial mind map layout (4-8 branches) |
|
||||||
|
| `quadrant` | Four-quadrant / circular section layout |
|
||||||
|
|
||||||
Detailed layout definitions: `references/elements/canvas.md`
|
Detailed layout definitions: `references/elements/canvas.md`
|
||||||
|
|
||||||
@@ -89,6 +92,7 @@ Detailed layout definitions: `references/elements/canvas.md`
|
|||||||
| Fun, exciting, wow, amazing | `pop` | sparse/list |
|
| Fun, exciting, wow, amazing | `pop` | sparse/list |
|
||||||
| Knowledge, concept, productivity, SaaS | `notion` | dense/list |
|
| Knowledge, concept, productivity, SaaS | `notion` | dense/list |
|
||||||
| Education, tutorial, learning, teaching, classroom | `chalkboard` | balanced/dense |
|
| Education, tutorial, learning, teaching, classroom | `chalkboard` | balanced/dense |
|
||||||
|
| Notes, handwritten, study guide, knowledge, realistic, photo | `study-notes` | dense/list/mindmap |
|
||||||
|
|
||||||
## Outline Strategies
|
## Outline Strategies
|
||||||
|
|
||||||
@@ -227,6 +231,7 @@ Read source content, save it if needed, and perform deep analysis.
|
|||||||
1. **Save source content** (if not already a file):
|
1. **Save source content** (if not already a file):
|
||||||
- If user provides a file path: use as-is
|
- If user provides a file path: use as-is
|
||||||
- If user pastes content: save to `source.md` in target directory
|
- If user pastes content: save to `source.md` in target directory
|
||||||
|
- **Backup rule**: If `source.md` exists, rename to `source-backup-YYYYMMDD-HHMMSS.md`
|
||||||
2. Read source content
|
2. Read source content
|
||||||
3. **Deep analysis** following `references/workflows/analysis-framework.md`:
|
3. **Deep analysis** following `references/workflows/analysis-framework.md`:
|
||||||
- Content type classification (种草/干货/测评/教程/避坑...)
|
- Content type classification (种草/干货/测评/教程/避坑...)
|
||||||
@@ -353,7 +358,9 @@ With confirmed outline + style + layout:
|
|||||||
|
|
||||||
**For each image (cover + content + ending)**:
|
**For each image (cover + content + ending)**:
|
||||||
1. Save prompt to `prompts/NN-{type}-[slug].md` (in user's preferred language)
|
1. Save prompt to `prompts/NN-{type}-[slug].md` (in user's preferred language)
|
||||||
|
- **Backup rule**: If prompt file exists, rename to `prompts/NN-{type}-[slug]-backup-YYYYMMDD-HHMMSS.md`
|
||||||
2. Generate image using confirmed style and layout
|
2. Generate image using confirmed style and layout
|
||||||
|
- **Backup rule**: If image file exists, rename to `NN-{type}-[slug]-backup-YYYYMMDD-HHMMSS.png`
|
||||||
3. Report progress after each generation
|
3. Report progress after each generation
|
||||||
|
|
||||||
**Watermark Application** (if enabled in preferences):
|
**Watermark Application** (if enabled in preferences):
|
||||||
@@ -417,17 +424,18 @@ Files:
|
|||||||
|
|
||||||
**Style × Layout Matrix** (✓✓ = highly recommended, ✓ = works well):
|
**Style × Layout Matrix** (✓✓ = highly recommended, ✓ = works well):
|
||||||
|
|
||||||
| | sparse | balanced | dense | list | comparison | flow |
|
| | sparse | balanced | dense | list | comparison | flow | mindmap | quadrant |
|
||||||
|---|:---:|:---:|:---:|:---:|:---:|:---:|
|
|---|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|
|
||||||
| cute | ✓✓ | ✓✓ | ✓ | ✓✓ | ✓ | ✓ |
|
| cute | ✓✓ | ✓✓ | ✓ | ✓✓ | ✓ | ✓ | ✓ | ✓ |
|
||||||
| fresh | ✓✓ | ✓✓ | ✓ | ✓ | ✓ | ✓✓ |
|
| fresh | ✓✓ | ✓✓ | ✓ | ✓ | ✓ | ✓✓ | ✓ | ✓ |
|
||||||
| warm | ✓✓ | ✓✓ | ✓ | ✓ | ✓✓ | ✓ |
|
| warm | ✓✓ | ✓✓ | ✓ | ✓ | ✓✓ | ✓ | ✓ | ✓ |
|
||||||
| bold | ✓✓ | ✓ | ✓ | ✓✓ | ✓✓ | ✓ |
|
| bold | ✓✓ | ✓ | ✓ | ✓✓ | ✓✓ | ✓ | ✓ | ✓✓ |
|
||||||
| minimal | ✓✓ | ✓✓ | ✓✓ | ✓ | ✓ | ✓ |
|
| minimal | ✓✓ | ✓✓ | ✓✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||||
| retro | ✓✓ | ✓✓ | ✓ | ✓✓ | ✓ | ✓ |
|
| retro | ✓✓ | ✓✓ | ✓ | ✓✓ | ✓ | ✓ | ✓ | ✓ |
|
||||||
| pop | ✓✓ | ✓✓ | ✓ | ✓✓ | ✓✓ | ✓ |
|
| pop | ✓✓ | ✓✓ | ✓ | ✓✓ | ✓✓ | ✓ | ✓ | ✓ |
|
||||||
| notion | ✓✓ | ✓✓ | ✓✓ | ✓✓ | ✓✓ | ✓✓ |
|
| notion | ✓✓ | ✓✓ | ✓✓ | ✓✓ | ✓✓ | ✓✓ | ✓✓ | ✓✓ |
|
||||||
| chalkboard | ✓✓ | ✓✓ | ✓✓ | ✓✓ | ✓ | ✓✓ |
|
| chalkboard | ✓✓ | ✓✓ | ✓✓ | ✓✓ | ✓ | ✓✓ | ✓✓ | ✓ |
|
||||||
|
| study-notes | ✗ | ✓ | ✓✓ | ✓✓ | ✓ | ✓ | ✓✓ | ✓ |
|
||||||
|
|
||||||
## References
|
## References
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ Avoid placing critical content in these areas:
|
|||||||
| list | Vertical enumeration | 4-7 | Rankings, checklists, step guides |
|
| list | Vertical enumeration | 4-7 | Rankings, checklists, step guides |
|
||||||
| comparison | Left vs Right | 2 sections | Before/after, pros/cons |
|
| comparison | Left vs Right | 2 sections | Before/after, pros/cons |
|
||||||
| flow | Connected nodes | 3-6 steps | Processes, timelines, workflows |
|
| flow | Connected nodes | 3-6 steps | Processes, timelines, workflows |
|
||||||
|
| mindmap | Center radial | 4-8 branches | Concept maps, brainstorming, topic overview |
|
||||||
|
| quadrant | 4-section grid | 4 sections | SWOT analysis, priority matrix, classification |
|
||||||
|
|
||||||
## Layout by Position
|
## Layout by Position
|
||||||
|
|
||||||
@@ -106,3 +108,15 @@ For multi-element compositions:
|
|||||||
- Directional flow (top→bottom or left→right)
|
- Directional flow (top→bottom or left→right)
|
||||||
- Connected nodes with arrows
|
- Connected nodes with arrows
|
||||||
- Clear progression indicators
|
- Clear progression indicators
|
||||||
|
|
||||||
|
### Mindmap Layout
|
||||||
|
- Central topic node
|
||||||
|
- Radial branches outward
|
||||||
|
- Hierarchical sub-branches
|
||||||
|
- Organic curved connections
|
||||||
|
|
||||||
|
### Quadrant Layout
|
||||||
|
- 4-section grid (2×2)
|
||||||
|
- Clear axis labels
|
||||||
|
- Each quadrant with distinct content
|
||||||
|
- Optional circular variant for cycles
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
---
|
||||||
|
name: study-notes
|
||||||
|
category: realistic
|
||||||
|
---
|
||||||
|
|
||||||
|
# Study Notes Style
|
||||||
|
|
||||||
|
Realistic handwritten photo aesthetic - student notes style, dense and messy but readable.
|
||||||
|
|
||||||
|
## Element Combination
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
canvas:
|
||||||
|
ratio: portrait-3-4
|
||||||
|
grid: single
|
||||||
|
|
||||||
|
image_effects:
|
||||||
|
cutout: none
|
||||||
|
stroke: none
|
||||||
|
filter: natural-photo
|
||||||
|
|
||||||
|
typography:
|
||||||
|
decorated: none
|
||||||
|
tags: none
|
||||||
|
direction: horizontal
|
||||||
|
|
||||||
|
decorations:
|
||||||
|
emphasis: circle-mark | underline | checkmark | cross | star-simple
|
||||||
|
background: lined-paper-white
|
||||||
|
doodles: arrows-simple | margin-notes | corrections | explanatory-diagrams
|
||||||
|
frames: none
|
||||||
|
```
|
||||||
|
|
||||||
|
## Color Palette (Three-Color Annotation System)
|
||||||
|
|
||||||
|
| Role | Colors | Hex |
|
||||||
|
|------|--------|-----|
|
||||||
|
| Primary | Blue ballpoint, Black ink | #1E3A5F, #1A1A1A |
|
||||||
|
| Highlights | Yellow highlighter | #FFFF00 (50% opacity) |
|
||||||
|
| Accents | Red pen (circles, underlines) | #CC0000 |
|
||||||
|
| Background | White lined paper | #FFFFFF |
|
||||||
|
|
||||||
|
## Visual Elements
|
||||||
|
|
||||||
|
- Realistic photo perspective: top-down view of study desk
|
||||||
|
- Hand holding blue ballpoint pen, actively underlining
|
||||||
|
- Extremely dense handwritten content, filling entire page
|
||||||
|
- Red pen annotations: circles, underlines, stars, boxes
|
||||||
|
- Yellow highlighter marking key terms
|
||||||
|
- Correction marks, cramped notes squeezed into margins
|
||||||
|
- Simple hand-drawn symbols: → * ✓ ✗ !
|
||||||
|
- Varying pen pressure creating lighter and darker strokes
|
||||||
|
|
||||||
|
## Typography
|
||||||
|
|
||||||
|
- Authentic student handwriting
|
||||||
|
- Messy but readable, clear structure maintained
|
||||||
|
- Varying font sizes (large titles, small body, tiny margin notes)
|
||||||
|
- CJK optimized
|
||||||
|
|
||||||
|
## Content Structure
|
||||||
|
|
||||||
|
Three-section layout:
|
||||||
|
|
||||||
|
### Top Section
|
||||||
|
- Core topic (circled multiple times in red)
|
||||||
|
- First section title + 3-4 key points
|
||||||
|
- Arrow connections, red underlines
|
||||||
|
|
||||||
|
### Middle Section
|
||||||
|
- Second section title (red pen box)
|
||||||
|
- Numbered steps ①②③
|
||||||
|
- Specific methods and supplementary notes
|
||||||
|
|
||||||
|
### Bottom Section
|
||||||
|
- Third section title (red star)
|
||||||
|
- Time points / key metrics
|
||||||
|
- Key quotes / core tips (tiny corner notes)
|
||||||
|
|
||||||
|
## Best Layout Pairings
|
||||||
|
|
||||||
|
| Layout | Compatibility | Use Case |
|
||||||
|
|--------|---------------|----------|
|
||||||
|
| sparse | ✗ | Not suitable - style requires dense content |
|
||||||
|
| balanced | ✓ | When content is lighter |
|
||||||
|
| dense | ✓✓ | Best fit - knowledge notes, summaries |
|
||||||
|
| list | ✓✓ | Step checklists, rankings |
|
||||||
|
| comparison | ✓ | Comparative analysis |
|
||||||
|
| flow | ✓ | Process flows |
|
||||||
|
| mindmap | ✓✓ | Mind map notes |
|
||||||
|
| quadrant | ✓ | Quadrant analysis |
|
||||||
|
|
||||||
|
## Best For
|
||||||
|
|
||||||
|
- Study guides, exam notes
|
||||||
|
- Knowledge organization, framework summaries
|
||||||
|
- Tutorial summaries, quick notes
|
||||||
|
- "Top student notes" style content
|
||||||
|
- Knowledge sharing requiring authentic feel
|
||||||
|
|
||||||
|
## Style Rules
|
||||||
|
|
||||||
|
### DO ✓
|
||||||
|
- Keep content extremely dense
|
||||||
|
- Use simple symbols (→ * ✓ ✗ !)
|
||||||
|
- Annotate key points with red pen
|
||||||
|
- Include correction marks
|
||||||
|
- Squeeze tiny notes into margins
|
||||||
|
|
||||||
|
### DON'T ✗
|
||||||
|
- Use complex emojis
|
||||||
|
- Leave too much whitespace
|
||||||
|
- Make neat, tidy layouts
|
||||||
|
- Add colorful decorations
|
||||||
|
- Include cartoon elements
|
||||||
Reference in New Issue
Block a user