Compare commits

...

11 Commits

Author SHA1 Message Date
Jim Liu 宝玉 7f80100b3e chore: release v1.30.0 2026-02-06 14:56:00 -06:00
Jim Liu 宝玉 61960961ba feat(baoyu-cover-image): add font dimension with 4 styles
Add 6th dimension for typography control:
- clean: modern geometric sans-serif
- handwritten: warm hand-lettered style
- serif: classic elegant typography
- display: bold decorative headlines

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

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 10:04:34 +08:00
Jim Liu 宝玉 cc95e6fe05 chore: release v1.28.4 2026-02-03 11:49:41 -06:00
Jim Liu 宝玉 e7d9ed7917 fix(baoyu-post-to-wechat): remove extra empty lines after image paste and fix summary timing
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 11:49:13 -06:00
Jim Liu 宝玉 876c6332f6 feat(baoyu-markdown-to-html): add HTML meta tags and quote stripping for frontmatter
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 11:49:07 -06:00
20 changed files with 1424 additions and 228 deletions
+1 -1
View File
@@ -6,7 +6,7 @@
},
"metadata": {
"description": "Skills shared by Baoyu for improving daily work efficiency",
"version": "1.28.3"
"version": "1.30.0"
},
"plugins": [
{
+22
View File
@@ -2,6 +2,28 @@
English | [中文](./CHANGELOG.zh.md)
## 1.30.0 - 2026-02-06
### Features
- `baoyu-cover-image`: add font dimension with 4 typography styles (clean, handwritten, serif, display); includes auto-selection rules, compatibility matrix, and `warm-flat` style preset.
## 1.29.0 - 2026-02-06
### Features
- `baoyu-image-gen`: add EXTEND.md configuration support, including schema documentation and runtime preference loading in scripts (by @kingdomad).
### Fixes
- `baoyu-post-to-wechat`: fix duplicated title and ordered-list numbering in WeChat article publishing (by @NantesCheval).
- `baoyu-url-to-markdown`: replace regex-only conversion with multi-strategy content extraction and Turndown conversion; improve noise filtering for Substack-style pages.
## 1.28.4 - 2026-02-03
### Features
- `baoyu-markdown-to-html`: add author and description meta tags to generated HTML from YAML frontmatter; strip quotes from frontmatter values (supports both English and Chinese quotation marks).
### Fixes
- `baoyu-post-to-wechat`: remove extra empty lines after image paste; fix summary field timing to fill after content paste (prevents being overwritten).
## 1.28.3 - 2026-02-03
### Fixes
+22
View File
@@ -2,6 +2,28 @@
[English](./CHANGELOG.md) | 中文
## 1.30.0 - 2026-02-06
### 新功能
- `baoyu-cover-image`:新增字体维度,支持 4 种字体风格(clean、handwritten、serif、display);包含自动选择规则、兼容性矩阵和 `warm-flat` 风格预设。
## 1.29.0 - 2026-02-06
### 新功能
- `baoyu-image-gen`:新增 EXTEND.md 配置支持,补充配置 schema 文档并在脚本运行时读取偏好设置 (by @kingdomad)。
### 修复
- `baoyu-post-to-wechat`:修复公众号文章发布时标题和有序列表编号重复问题 (by @NantesCheval)。
- `baoyu-url-to-markdown`:将正则转换升级为多策略正文抽取 + Turndown 转换,提升 Substack 类页面的噪声过滤能力。
## 1.28.4 - 2026-02-03
### 新功能
- `baoyu-markdown-to-html`:从 YAML frontmatter 生成 author 和 description meta 标签;自动去除 frontmatter 值两端的引号(支持中英文引号)。
### 修复
- `baoyu-post-to-wechat`:移除图片粘贴后产生的多余空行;修复摘要填充时机,改为内容粘贴后填写(避免被覆盖)。
## 1.28.3 - 2026-02-03
### 修复
+22 -9
View File
@@ -50,6 +50,7 @@ Generate elegant cover images for articles with 5-dimensional customization.
| `--style <name>` | Preset shorthand (expands to palette + rendering, see [Style Presets](references/style-presets.md)) |
| `--text <level>` | Text density: none, title-only, title-subtitle, text-rich |
| `--mood <level>` | Emotional intensity: subtle, balanced, bold |
| `--font <name>` | Font style: clean, handwritten, serif, display |
| `--aspect <ratio>` | 16:9 (default), 2.35:1, 4:3, 3:2, 1:1, 3:4 |
| `--lang <code>` | Title language (en, zh, ja, etc.) |
| `--no-title` | Alias for `--text none` |
@@ -65,6 +66,7 @@ Generate elegant cover images for articles with 5-dimensional customization.
| **Rendering** | Line quality, texture, depth, element style | flat-vector, hand-drawn, painterly, digital, pixel, chalk | auto |
| **Text** | Text density, information hierarchy | none, title-only, title-subtitle, text-rich | title-only |
| **Mood** | Emotional intensity, visual weight | subtle, balanced, bold | balanced |
| **Font** | Typography style, character feel | clean, handwritten, serif, display | clean |
Dimensions can be freely combined. Auto-selection rules: [references/auto-selection.md](references/auto-selection.md)
@@ -127,6 +129,17 @@ Rendering definitions: [references/renderings/](references/renderings/)
Full guides: [references/dimensions/text.md](references/dimensions/text.md) | [references/dimensions/mood.md](references/dimensions/mood.md)
## Font Gallery
| Font | Description | Best For |
|------|-------------|----------|
| `clean` | Modern geometric sans-serif | Tech, professional, minimal |
| `handwritten` | Warm hand-lettered style | Personal, friendly, lifestyle |
| `serif` | Classic elegant typography | Editorial, academic, luxury |
| `display` | Bold decorative headlines | Announcements, entertainment |
Full guide: [references/dimensions/font.md](references/dimensions/font.md)
## Style Presets & Compatibility
- **Style Presets**: `--style X` expands to palette + rendering. See [references/style-presets.md](references/style-presets.md)
@@ -175,7 +188,7 @@ Cover Image Progress:
- [ ] File path given → saved to refs/ ✓
- [ ] No path → asked user OR extracted verbally
- [ ] 1.2 Output directory determined
- [ ] Step 2: Confirm options (5 dimensions) ⚠️ REQUIRED unless --quick or all specified
- [ ] Step 2: Confirm options (6 dimensions) ⚠️ REQUIRED unless --quick or all specified
- [ ] Step 3: Create prompt
- [ ] References in prompt ONLY if files exist in refs/
- [ ] Extracted style/palette appended to prompt body (if no file)
@@ -196,7 +209,7 @@ Input → [Step 0: Preferences] ─┬─ Found → Continue
┌───────────────────────────────────────────────────────────────────────────┘
Analyze + Save Refs → [Output Dir ⚠️] → [Confirm: 5 Dimensions] → Prompt → Generate → Complete
Analyze + Save Refs → [Output Dir ⚠️] → [Confirm: 6 Dimensions] → Prompt → Generate → Complete
(skip if --quick or all specified)
```
@@ -233,7 +246,7 @@ Preferences loaded from [project/user]:
• Quick mode: [enabled/disabled] | Language: [value or "auto"]
```
**EXTEND.md Supports**: Watermark | Preferred type | Preferred palette | Preferred rendering | Preferred text | Preferred mood | Default aspect ratio | Default output directory | Quick mode | Custom palette definitions | Language preference
**EXTEND.md Supports**: Watermark | Preferred type | Preferred palette | Preferred rendering | Preferred text | Preferred mood | Preferred font | Default aspect ratio | Default output directory | Quick mode | Custom palette definitions | Language preference
Schema: [references/config/preferences-schema.md](references/config/preferences-schema.md)
@@ -326,14 +339,14 @@ For each reference image:
### Step 2: Confirm Options ⚠️
Validate all 5 dimensions + aspect ratio. Full confirmation flow: [references/workflow/confirm-options.md](references/workflow/confirm-options.md)
Validate all 6 dimensions + aspect ratio. Full confirmation flow: [references/workflow/confirm-options.md](references/workflow/confirm-options.md)
**Skip Conditions**:
| Condition | Skipped | Still Asked |
|-----------|---------|-------------|
| `--quick` or `quick_mode: true` | 5 dimensions | Aspect ratio (unless `--aspect`) |
| All 5 + `--aspect` specified | All | None |
| `--quick` or `quick_mode: true` | 6 dimensions | Aspect ratio (unless `--aspect`) |
| All 6 + `--aspect` specified | All | None |
### Step 3: Create Prompt
@@ -431,7 +444,7 @@ Cover Generated!
Topic: [topic]
Type: [type] | Palette: [palette] | Rendering: [rendering]
Text: [text] | Mood: [mood] | Aspect: [ratio]
Text: [text] | Mood: [mood] | Font: [font] | Aspect: [ratio]
Title: [title text or "visual only"]
Language: [lang] | Watermark: [enabled/disabled]
References: [N images (direct/style/palette) or "extracted style" or "none"]
@@ -463,7 +476,7 @@ All modifications automatically backup existing `cover.png` before regenerating.
- Cover must be readable at small preview sizes
- Visual metaphors > literal representations
- Title: readable, impactful
- Two confirmation points: Step 0 (first-time setup) + Step 2 (options) - skip Step 2 with `--quick`
- Two confirmation points: Step 0 (first-time setup) + Step 2 (6 dimensions) - skip Step 2 with `--quick`
- Use confirmed language for title text
- Maintain watermark consistency if enabled
- Check compatibility matrices when selecting combinations
@@ -485,7 +498,7 @@ All modifications automatically backup existing `cover.png` before regenerating.
## References
**Dimensions**: [text.md](references/dimensions/text.md) | [mood.md](references/dimensions/mood.md)
**Dimensions**: [text.md](references/dimensions/text.md) | [mood.md](references/dimensions/mood.md) | [font.md](references/dimensions/font.md)
**Palettes**: [references/palettes/](references/palettes/)
**Renderings**: [references/renderings/](references/renderings/)
**Auto-Selection**: [references/auto-selection.md](references/auto-selection.md)
@@ -58,3 +58,14 @@ Default: `title-only`
| Launch, announcement, promotion, event, gaming, entertainment | `bold` |
Default: `balanced`
## Auto Font Selection
| Signals | Font |
|---------|------|
| Personal, lifestyle, human, warm, friendly, story | `handwritten` |
| Technical, professional, clean, modern, minimal, data | `clean` |
| Editorial, academic, luxury, classic, literary | `serif` |
| Announcement, entertainment, promotion, bold, event, gaming | `display` |
Default: `clean`
@@ -48,3 +48,12 @@
| metaphor | ✓✓ | ✓✓ | ✓ |
| scene | ✓✓ | ✓✓ | ✓ |
| minimal | ✓✓ | ✓✓ | ✗ |
## Font × Rendering
| | flat-vector | hand-drawn | painterly | digital | pixel | chalk |
|---|:---:|:---:|:---:|:---:|:---:|:---:|
| clean | ✓✓ | ✗ | ✗ | ✓✓ | ✓ | ✗ |
| handwritten | ✓ | ✓✓ | ✓✓ | ✓ | ✗ | ✓✓ |
| serif | ✓ | ✗ | ✓ | ✓✓ | ✗ | ✗ |
| display | ✓✓ | ✓ | ✓ | ✓✓ | ✓✓ | ✓ |
@@ -0,0 +1,163 @@
---
name: font-dimension
description: Typography style dimension for cover images
---
# Font Dimension
Controls typography style and character feel.
## Values
| Font | Visual Style | Line Quality | Character |
|------|--------------|--------------|-----------|
| `clean` | Geometric sans-serif | Sharp, uniform | Modern, precise, neutral |
| `handwritten` | Hand-lettered, brush | Organic, varied | Warm, personal, friendly |
| `serif` | Classic serifs, elegant | Refined, structured | Editorial, authoritative |
| `display` | Bold, decorative | Heavy, expressive | Attention-grabbing, playful |
## Detail
### clean
Modern, universal typography with neutral character.
**Characteristics**:
- Geometric sans-serif letterforms
- Sharp, uniform line weight
- Clean edges, no flourishes
- High readability at all sizes
- Minimal personality, maximum clarity
**Use Cases**:
- Technical documentation
- Professional/corporate content
- Minimal design approaches
- Data-driven articles
- Modern brand aesthetics
**Prompt Hints**:
- Use clean geometric sans-serif typography
- Modern, minimal letterforms
- Sharp edges, uniform stroke weight
- High contrast against background
### handwritten
Warm, organic typography with personal character.
**Characteristics**:
- Hand-lettered or brush style
- Organic, varied line weight
- Natural imperfections
- Approachable, human feel
- Casual yet intentional
**Use Cases**:
- Personal stories
- Lifestyle content
- Wellness and self-improvement
- Creative tutorials
- Friendly brand voices
**Prompt Hints**:
- Use warm hand-lettered typography with organic brush strokes
- Friendly, personal feel
- Natural variation in stroke weight
- Approachable, human character
### serif
Classic, elegant typography with editorial authority.
**Characteristics**:
- Traditional serif letterforms
- Refined, structured strokes
- Elegant proportions
- Timeless sophistication
- Formal, trustworthy feel
**Use Cases**:
- Editorial content
- Academic articles
- Luxury brand content
- Historical topics
- Literary pieces
**Prompt Hints**:
- Use elegant serif typography with refined letterforms
- Classic, editorial character
- Structured, proportional spacing
- Authoritative, sophisticated feel
### display
Bold, decorative typography for maximum impact.
**Characteristics**:
- Heavy, expressive letterforms
- Decorative elements
- Strong visual presence
- Playful or dramatic character
- Designed for headlines
**Use Cases**:
- Announcements
- Entertainment content
- Promotional materials
- Event marketing
- Gaming topics
**Prompt Hints**:
- Use bold decorative display typography
- Heavy, expressive headlines
- Strong visual impact
- Attention-grabbing character
## Default
`clean` — Universal, pairs well with most rendering styles.
## Rendering Compatibility
| Font × Rendering | flat-vector | hand-drawn | painterly | digital | pixel | chalk |
|------------------|:-----------:|:----------:|:---------:|:-------:|:-----:|:-----:|
| clean | ✓✓ | ✗ | ✗ | ✓✓ | ✓ | ✗ |
| handwritten | ✓ | ✓✓ | ✓✓ | ✓ | ✗ | ✓✓ |
| serif | ✓ | ✗ | ✓ | ✓✓ | ✗ | ✗ |
| display | ✓✓ | ✓ | ✓ | ✓✓ | ✓✓ | ✓ |
✓✓ = highly recommended | ✓ = compatible | ✗ = not recommended
## Type Compatibility
| Font × Type | hero | conceptual | typography | metaphor | scene | minimal |
|-------------|:----:|:----------:|:----------:|:--------:|:-----:|:-------:|
| clean | ✓ | ✓✓ | ✓✓ | ✓ | ✗ | ✓✓ |
| handwritten | ✓✓ | ✓ | ✓ | ✓✓ | ✓✓ | ✓ |
| serif | ✓ | ✓ | ✓✓ | ✓ | ✓ | ✓ |
| display | ✓✓ | ✓ | ✓✓ | ✓ | ✓ | ✗ |
## Palette Interaction
Font style adapts to palette characteristics:
| Palette Category | clean | handwritten | serif | display |
|------------------|-------|-------------|-------|---------|
| Warm (warm, earth, pastel) | Softer weight | Natural fit | Warm tones | Playful energy |
| Cool (cool, mono, elegant) | Perfect match | Contrast | Classic pairing | Bold statement |
| Dark (dark, vivid) | High contrast | Glow effects | Dramatic | Maximum impact |
| Vintage (retro) | Modern contrast | Nostalgic fit | Period-appropriate | Retro headlines |
## Auto Selection
When `--font` is omitted, select based on signals:
| Signals | Font |
|---------|------|
| Personal, lifestyle, human, warm, friendly, story | `handwritten` |
| Technical, professional, clean, modern, minimal, data | `clean` |
| Editorial, academic, luxury, classic, literary | `serif` |
| Announcement, entertainment, promotion, bold, event, gaming | `display` |
Default: `clean`
@@ -22,6 +22,7 @@
| `vector-illustration` | `retro` | `flat-vector` |
| `vintage` | `retro` | `hand-drawn` |
| `warm` | `warm` | `hand-drawn` |
| `warm-flat` | `warm` | `flat-vector` |
| `watercolor` | `earth` | `painterly` |
## Override Examples
@@ -2,22 +2,22 @@
## Purpose
Validate all 5 dimensions + aspect ratio.
Validate all 6 dimensions + aspect ratio.
## Skip Conditions
| Condition | Skipped Questions | Still Asked |
|-----------|-------------------|-------------|
| `--quick` flag | Type, Palette, Rendering, Text, Mood | **Aspect Ratio** (unless `--aspect` specified) |
| All 5 dimensions + `--aspect` specified | All | None |
| `quick_mode: true` in EXTEND.md | Type, Palette, Rendering, Text, Mood | **Aspect Ratio** (unless `--aspect` specified) |
| Otherwise | None | All 6 questions |
| `--quick` flag | Type, Palette, Rendering, Text, Mood, Font | **Aspect Ratio** (unless `--aspect` specified) |
| All 6 dimensions + `--aspect` specified | All | None |
| `quick_mode: true` in EXTEND.md | Type, Palette, Rendering, Text, Mood, Font | **Aspect Ratio** (unless `--aspect` specified) |
| Otherwise | None | All 7 questions |
**Important**: Aspect ratio is ALWAYS asked unless explicitly specified via `--aspect` CLI flag. User presets in EXTEND.md are shown as recommended option, not auto-selected.
## Quick Mode Output
When skipping 5 dimensions:
When skipping 6 dimensions:
```
Quick Mode: Auto-selected dimensions
@@ -26,8 +26,9 @@ Quick Mode: Auto-selected dimensions
• Rendering: [rendering] ([reason])
• Text: [text] ([reason])
• Mood: [mood] ([reason])
• Font: [font] ([reason])
[Then ask Question 6: Aspect Ratio]
[Then ask Question 7: Aspect Ratio]
```
## Confirmation Flow
@@ -91,7 +92,26 @@ options:
description: "Polished, precise, subtle gradients"
```
### Q4: Other Settings (skip if all remaining dimensions already specified)
### Q4: Font (skip if `--font`)
```yaml
header: "Font"
question: "Which font style?"
multiSelect: false
options:
- label: "[auto-recommended font] (Recommended)"
description: "[reason based on content signals]"
- label: "clean"
description: "Modern geometric sans-serif - tech, professional"
- label: "handwritten"
description: "Warm hand-lettered - personal, friendly"
- label: "serif"
description: "Classic elegant - editorial, luxury"
- label: "display"
description: "Bold decorative - announcements, entertainment"
```
### Q5: Other Settings (skip if all remaining dimensions already specified)
Combine remaining settings into one question. Include: Output Dir (if no preference + file path input), Text, Mood, Aspect. Show auto-selected values as recommended option. User can accept all or type adjustments via "Other".
@@ -26,6 +26,7 @@ Cover theme: [2-3 words visual interpretation]
Type: [confirmed type]
Palette: [confirmed palette]
Rendering: [confirmed rendering]
Font: [confirmed font]
Text level: [confirmed text level]
Mood: [confirmed mood]
Aspect ratio: [confirmed ratio]
@@ -44,6 +45,13 @@ Language: [confirmed language]
- balanced: "Use medium contrast, normal saturation, balanced visual weight"
- bold: "Use high contrast, vivid saturated colors, heavy visual weight, dynamic energy"
# Font Application
[Based on font style:]
- clean: "Use clean geometric sans-serif typography. Modern, minimal letterforms."
- handwritten: "Use warm hand-lettered typography with organic brush strokes. Friendly, personal feel."
- serif: "Use elegant serif typography with refined letterforms. Classic, editorial character."
- display: "Use bold decorative display typography. Heavy, expressive headlines."
# Composition
Type composition:
- [Type-specific layout and structure]
+4 -2
View File
@@ -41,7 +41,9 @@ test -f "$HOME/.baoyu-skills/baoyu-image-gen/EXTEND.md" && echo "user"
│ Not found │ Use defaults │
└───────────┴───────────────────────────────────────────────────────────────────────────┘
**EXTEND.md Supports**: Default provider | Default quality | Default aspect ratio
**EXTEND.md Supports**: Default provider | Default quality | Default aspect ratio | Default image size | Default models
Schema: `references/config/preferences-schema.md`
## Usage
@@ -99,7 +101,7 @@ npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "一只可爱的猫" --image ou
| `GOOGLE_BASE_URL` | Custom Google endpoint |
| `DASHSCOPE_BASE_URL` | Custom DashScope endpoint |
**Load Priority**: CLI args > env vars > `<cwd>/.baoyu-skills/.env` > `~/.baoyu-skills/.env`
**Load Priority**: CLI args > EXTEND.md > env vars > `<cwd>/.baoyu-skills/.env` > `~/.baoyu-skills/.env`
## Provider Selection
@@ -0,0 +1,66 @@
---
name: preferences-schema
description: EXTEND.md YAML schema for baoyu-image-gen user preferences
---
# Preferences Schema
## Full Schema
```yaml
---
version: 1
default_provider: null # google|openai|dashscope|null (null = auto-detect)
default_quality: null # normal|2k|null (null = use default: 2k)
default_aspect_ratio: null # "16:9"|"1:1"|"4:3"|"3:4"|"2.35:1"|null
default_image_size: null # 1K|2K|4K|null (Google only, overrides quality)
default_model:
google: null # e.g., "gemini-3-pro-image-preview"
openai: null # e.g., "gpt-image-1.5"
dashscope: null # e.g., "z-image-turbo"
---
```
## Field Reference
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `version` | int | 1 | Schema version |
| `default_provider` | string\|null | null | Default provider (null = auto-detect) |
| `default_quality` | string\|null | null | Default quality (null = 2k) |
| `default_aspect_ratio` | string\|null | null | Default aspect ratio |
| `default_image_size` | string\|null | null | Google image size (overrides quality) |
| `default_model.google` | string\|null | null | Google default model |
| `default_model.openai` | string\|null | null | OpenAI default model |
| `default_model.dashscope` | string\|null | null | DashScope default model |
## Examples
**Minimal**:
```yaml
---
version: 1
default_provider: google
default_quality: 2k
---
```
**Full**:
```yaml
---
version: 1
default_provider: google
default_quality: 2k
default_aspect_ratio: "16:9"
default_image_size: 2K
default_model:
google: "gemini-3-pro-image-preview"
openai: "gpt-image-1.5"
dashscope: "z-image-turbo"
---
```
+104 -11
View File
@@ -2,7 +2,7 @@ import path from "node:path";
import process from "node:process";
import { homedir } from "node:os";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import type { CliArgs, Provider } from "./types";
import type { CliArgs, Provider, ExtendConfig } from "./types";
function printUsage(): void {
console.log(`Usage:
@@ -37,7 +37,7 @@ Environment variables:
GOOGLE_BASE_URL Custom Google endpoint
DASHSCOPE_BASE_URL Custom DashScope endpoint
Env file load order: CLI args > process.env > <cwd>/.baoyu-skills/.env > ~/.baoyu-skills/.env`);
Env file load order: CLI args > EXTEND.md > process.env > <cwd>/.baoyu-skills/.env > ~/.baoyu-skills/.env`);
}
function parseArgs(argv: string[]): CliArgs {
@@ -49,7 +49,7 @@ function parseArgs(argv: string[]): CliArgs {
model: null,
aspectRatio: null,
size: null,
quality: "2k",
quality: null,
imageSize: null,
referenceImages: [],
n: 1,
@@ -215,6 +215,87 @@ async function loadEnv(): Promise<void> {
}
}
function extractYamlFrontMatter(content: string): string | null {
const match = content.match(/^---\s*\n([\s\S]*?)\n---\s*$/m);
return match ? match[1] : null;
}
function parseSimpleYaml(yaml: string): Partial<ExtendConfig> {
const config: Partial<ExtendConfig> = {};
const lines = yaml.split("\n");
let currentKey: string | null = null;
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
if (trimmed.includes(":") && !trimmed.startsWith("-")) {
const colonIdx = trimmed.indexOf(":");
const key = trimmed.slice(0, colonIdx).trim();
let value = trimmed.slice(colonIdx + 1).trim();
if (value === "null" || value === "") {
value = "null";
}
if (key === "version") {
config.version = value === "null" ? 1 : parseInt(value, 10);
} else if (key === "default_provider") {
config.default_provider = value === "null" ? null : (value as Provider);
} else if (key === "default_quality") {
config.default_quality = value === "null" ? null : (value as "normal" | "2k");
} else if (key === "default_aspect_ratio") {
const cleaned = value.replace(/['"]/g, "");
config.default_aspect_ratio = cleaned === "null" ? null : cleaned;
} else if (key === "default_image_size") {
config.default_image_size = value === "null" ? null : (value as "1K" | "2K" | "4K");
} else if (key === "default_model") {
config.default_model = { google: null, openai: null, dashscope: null };
currentKey = "default_model";
} else if (currentKey === "default_model" && (key === "google" || key === "openai" || key === "dashscope")) {
const cleaned = value.replace(/['"]/g, "");
config.default_model![key] = cleaned === "null" ? null : cleaned;
}
}
}
return config;
}
async function loadExtendConfig(): Promise<Partial<ExtendConfig>> {
const home = homedir();
const cwd = process.cwd();
const paths = [
path.join(cwd, ".baoyu-skills", "baoyu-image-gen", "EXTEND.md"),
path.join(home, ".baoyu-skills", "baoyu-image-gen", "EXTEND.md"),
];
for (const p of paths) {
try {
const content = await readFile(p, "utf8");
const yaml = extractYamlFrontMatter(content);
if (!yaml) continue;
return parseSimpleYaml(yaml);
} catch {
continue;
}
}
return {};
}
function mergeConfig(args: CliArgs, extend: Partial<ExtendConfig>): CliArgs {
return {
...args,
provider: args.provider ?? extend.default_provider ?? null,
quality: args.quality ?? extend.default_quality ?? null,
aspectRatio: args.aspectRatio ?? extend.default_aspect_ratio ?? null,
imageSize: args.imageSize ?? extend.default_image_size ?? null,
};
}
async function readPromptFromFiles(files: string[]): Promise<string> {
const parts: string[] = [];
for (const f of files) {
@@ -283,9 +364,13 @@ async function main(): Promise<void> {
}
await loadEnv();
const extendConfig = await loadExtendConfig();
const mergedArgs = mergeConfig(args, extendConfig);
let prompt: string | null = args.prompt;
if (!prompt && args.promptFiles.length > 0) prompt = await readPromptFromFiles(args.promptFiles);
if (!mergedArgs.quality) mergedArgs.quality = "2k";
let prompt: string | null = mergedArgs.prompt;
if (!prompt && mergedArgs.promptFiles.length > 0) prompt = await readPromptFromFiles(mergedArgs.promptFiles);
if (!prompt) prompt = await readPromptFromStdin();
if (!prompt) {
@@ -295,24 +380,32 @@ async function main(): Promise<void> {
return;
}
if (!args.imagePath) {
if (!mergedArgs.imagePath) {
console.error("Error: --image is required");
printUsage();
process.exitCode = 1;
return;
}
const provider = detectProvider(args);
const provider = detectProvider(mergedArgs);
const providerModule = await loadProviderModule(provider);
const model = args.model || providerModule.getDefaultModel();
const outputPath = normalizeOutputImagePath(args.imagePath);
let model = mergedArgs.model;
if (!model && extendConfig.default_model) {
if (provider === "google") model = extendConfig.default_model.google ?? null;
if (provider === "openai") model = extendConfig.default_model.openai ?? null;
if (provider === "dashscope") model = extendConfig.default_model.dashscope ?? null;
}
model = model || providerModule.getDefaultModel();
const outputPath = normalizeOutputImagePath(mergedArgs.imagePath);
let imageData: Uint8Array;
let retried = false;
while (true) {
try {
imageData = await providerModule.generateImage(prompt, model, args);
imageData = await providerModule.generateImage(prompt, model, mergedArgs);
break;
} catch (e) {
if (!retried) {
@@ -328,7 +421,7 @@ async function main(): Promise<void> {
await mkdir(dir, { recursive: true });
await writeFile(outputPath, imageData);
if (args.json) {
if (mergedArgs.json) {
console.log(
JSON.stringify(
{
+14 -1
View File
@@ -9,10 +9,23 @@ export type CliArgs = {
model: string | null;
aspectRatio: string | null;
size: string | null;
quality: Quality;
quality: Quality | null;
imageSize: string | null;
referenceImages: string[];
n: number;
json: boolean;
help: boolean;
};
export type ExtendConfig = {
version: number;
default_provider: Provider | null;
default_quality: Quality | null;
default_aspect_ratio: string | null;
default_image_size: "1K" | "2K" | "4K" | null;
default_model: {
google: string | null;
openai: string | null;
dashscope: string | null;
};
};
+14 -3
View File
@@ -126,7 +126,18 @@ export async function convertMarkdown(markdownPath: string, options?: { title?:
const { frontmatter, body } = parseFrontmatter(content);
let title = options?.title ?? frontmatter.title ?? '';
const stripQuotes = (s?: string): string => {
if (!s) return '';
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
return s.slice(1, -1);
}
if ((s.startsWith('\u201c') && s.endsWith('\u201d')) || (s.startsWith('\u2018') && s.endsWith('\u2019'))) {
return s.slice(1, -1);
}
return s;
};
let title = options?.title ?? stripQuotes(frontmatter.title) ?? '';
if (!title) {
const lines = body.split('\n');
for (const line of lines) {
@@ -138,8 +149,8 @@ export async function convertMarkdown(markdownPath: string, options?: { title?:
}
}
if (!title) title = path.basename(markdownPath, path.extname(markdownPath));
const author = frontmatter.author || '';
let summary = frontmatter.description || frontmatter.summary || '';
const author = stripQuotes(frontmatter.author);
let summary = stripQuotes(frontmatter.description) || stripQuotes(frontmatter.summary);
if (!summary) {
const lines = body.split('\n');
@@ -708,14 +708,28 @@ function normalizeThemeCss(css: string): string {
return stripOutputScope(css);
}
function buildHtmlDocument(title: string, css: string, html: string): string {
return [
interface HtmlDocumentMeta {
title: string;
author?: string;
description?: string;
}
function buildHtmlDocument(meta: HtmlDocumentMeta, css: string, html: string): string {
const lines = [
"<!doctype html>",
"<html>",
"<head>",
' <meta charset="utf-8" />',
' <meta name="viewport" content="width=device-width, initial-scale=1" />',
` <title>${title}</title>`,
` <title>${meta.title}</title>`,
];
if (meta.author) {
lines.push(` <meta name="author" content="${meta.author}" />`);
}
if (meta.description) {
lines.push(` <meta name="description" content="${meta.description}" />`);
}
lines.push(
` <style>${css}</style>`,
"</head>",
"<body>",
@@ -723,8 +737,9 @@ function buildHtmlDocument(title: string, css: string, html: string): string {
html,
" </div>",
"</body>",
"</html>",
].join("\n");
"</html>"
);
return lines.join("\n");
}
async function inlineCss(html: string): Promise<string> {
@@ -814,6 +829,7 @@ async function main(): Promise<void> {
const markdown = fs.readFileSync(inputPath, "utf-8");
const renderer = initRenderer({});
const { yamlData } = renderer.parseFrontMatterAndContent(markdown);
const { html: baseHtml, readingTime: readingTimeResult } = renderMarkdown(
markdown,
renderer
@@ -823,8 +839,23 @@ async function main(): Promise<void> {
content = removeFirstHeading(content);
}
const title = path.basename(outputPath, ".html");
const html = buildHtmlDocument(title, css, content);
const stripQuotes = (s?: string): string | undefined => {
if (!s) return s;
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
return s.slice(1, -1);
}
if ((s.startsWith('\u201c') && s.endsWith('\u201d')) || (s.startsWith('\u2018') && s.endsWith('\u2019'))) {
return s.slice(1, -1);
}
return s;
};
const meta: HtmlDocumentMeta = {
title: stripQuotes(yamlData.title) || path.basename(outputPath, ".html"),
author: stripQuotes(yamlData.author),
description: stripQuotes(yamlData.description) || stripQuotes(yamlData.summary),
};
const html = buildHtmlDocument(meta, css, content);
const inlinedHtml = normalizeInlineCss(await inlineCss(html));
const finalHtml = modifyHtmlStructure(inlinedHtml);
@@ -120,22 +120,28 @@ export async function convertMarkdown(markdownPath: string, options?: { title?:
const { frontmatter, body } = parseFrontmatter(content);
let title = options?.title ?? frontmatter.title ?? '';
let bodyWithoutTitle = body;
if (!title) {
const lines = body.split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
const headingMatch = trimmed.match(/^#{1,2}\s+(.+)$/);
if (headingMatch) title = headingMatch[1]!;
if (headingMatch) {
title = headingMatch[1]!;
bodyWithoutTitle = body.replace(/^#{1,2}\s+.+\r?\n?/, '');
}
break;
}
} else {
bodyWithoutTitle = body.replace(/^#{1,2}\s+.+\r?\n?/, '');
}
if (!title) title = path.basename(markdownPath, path.extname(markdownPath));
const author = frontmatter.author || '';
let summary = frontmatter.description || frontmatter.summary || '';
if (!summary) {
const lines = body.split('\n');
const lines = bodyWithoutTitle.split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
@@ -161,7 +167,7 @@ export async function convertMarkdown(markdownPath: string, options?: { title?:
const images: Array<{ src: string; placeholder: string }> = [];
let imageCounter = 0;
const modifiedBody = body.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (match, alt, src) => {
const modifiedBody = bodyWithoutTitle.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (match, alt, src) => {
const placeholder = `WECHATIMGPH_${++imageCounter}`;
images.push({ src, placeholder });
return placeholder;
@@ -374,7 +374,7 @@ export function initRenderer(opts: IOpts = {}): RendererAPI {
listCounters[listCounters.length - 1] = idx + 1;
const prefix = ordered ? `${idx}. ` : "• ";
const prefix = ordered ? "" : "• ";
let content: string;
try {
@@ -315,6 +315,71 @@ async function pressDeleteKey(session: ChromeSession): Promise<void> {
await session.cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Backspace', code: 'Backspace', windowsVirtualKeyCode: 8 }, { sessionId: session.sessionId });
}
async function removeExtraEmptyLineAfterImage(session: ChromeSession): Promise<boolean> {
const removed = await evaluate<boolean>(session, `
(function() {
const editor = document.querySelector('.ProseMirror');
if (!editor) return false;
const sel = window.getSelection();
if (!sel || sel.rangeCount === 0) return false;
let node = sel.anchorNode;
if (!node) return false;
let element = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement;
if (!element || !editor.contains(element)) return false;
const isEmptyParagraph = (el) => {
if (!el || el.tagName !== 'P') return false;
const text = (el.textContent || '').trim();
if (text.length > 0) return false;
return el.querySelectorAll('img, figure, video, iframe').length === 0;
};
const hasImage = (el) => {
if (!el) return false;
return !!el.querySelector('img, figure img, picture img');
};
const placeCursorAfter = (el) => {
if (!el) return;
const range = document.createRange();
range.setStartAfter(el);
range.collapse(true);
sel.removeAllRanges();
sel.addRange(range);
};
// Case 1: caret is inside an empty paragraph right after an image block.
const emptyPara = element.closest('p');
if (emptyPara && editor.contains(emptyPara) && isEmptyParagraph(emptyPara)) {
const prev = emptyPara.previousElementSibling;
if (prev && hasImage(prev)) {
emptyPara.remove();
placeCursorAfter(prev);
return true;
}
}
// Case 2: caret is on the image block itself; remove the next empty paragraph.
const imageBlock = element.closest('figure, p');
if (imageBlock && editor.contains(imageBlock) && hasImage(imageBlock)) {
const next = imageBlock.nextElementSibling;
if (next && isEmptyParagraph(next)) {
next.remove();
placeCursorAfter(imageBlock);
return true;
}
}
return false;
})()
`);
if (removed) console.log('[wechat] Removed extra empty line after image.');
return removed;
}
export async function postArticle(options: ArticleOptions): Promise<void> {
const { title, content, htmlFile, markdownFile, theme, author, summary, images = [], submit = false, profileDir, cdpPort } = options;
let { contentImages = [] } = options;
@@ -454,11 +519,6 @@ export async function postArticle(options: ArticleOptions): Promise<void> {
await evaluate(session, `document.querySelector('#author').value = ${JSON.stringify(effectiveAuthor)}; document.querySelector('#author').dispatchEvent(new Event('input', { bubbles: true }));`);
}
if (effectiveSummary) {
console.log(`[wechat] Filling summary: ${effectiveSummary}`);
await evaluate(session, `document.querySelector('#js_description').value = ${JSON.stringify(effectiveSummary)}; document.querySelector('#js_description').dispatchEvent(new Event('input', { bubbles: true }));`);
}
await sleep(500);
if (effectiveTitle) {
@@ -470,15 +530,6 @@ export async function postArticle(options: ArticleOptions): Promise<void> {
}
}
if (effectiveSummary) {
const actualSummary = await evaluate<string>(session, `document.querySelector('#js_description')?.value || ''`);
if (actualSummary === effectiveSummary) {
console.log('[wechat] Summary verified OK.');
} else {
console.warn(`[wechat] Summary verification failed. Expected: "${effectiveSummary}", got: "${actualSummary}"`);
}
}
console.log('[wechat] Clicking on editor...');
await clickElement(session, '.ProseMirror');
await sleep(1000);
@@ -534,6 +585,7 @@ export async function postArticle(options: ArticleOptions): Promise<void> {
console.log('[wechat] Pasting image...');
await pasteFromClipboardInEditor(session);
await sleep(3000);
await removeExtraEmptyLineAfterImage(session);
}
console.log('[wechat] All images inserted.');
}
@@ -545,6 +597,7 @@ export async function postArticle(options: ArticleOptions): Promise<void> {
await sleep(500);
await pasteInEditor(session);
await sleep(2000);
await removeExtraEmptyLineAfterImage(session);
}
}
@@ -567,6 +620,30 @@ export async function postArticle(options: ArticleOptions): Promise<void> {
}
}
if (effectiveSummary) {
console.log(`[wechat] Filling summary (after content paste): ${effectiveSummary}`);
await evaluate(session, `
(function() {
const el = document.querySelector('#js_description');
if (!el) return;
el.focus();
el.select();
el.value = ${JSON.stringify(effectiveSummary)};
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
el.dispatchEvent(new Event('blur', { bubbles: true }));
})()
`);
await sleep(500);
const actualSummary = await evaluate<string>(session, `document.querySelector('#js_description')?.value || ''`);
if (actualSummary === effectiveSummary) {
console.log('[wechat] Summary verified OK.');
} else {
console.warn(`[wechat] Summary verification failed. Expected: "${effectiveSummary}", got: "${actualSummary}"`);
}
}
console.log('[wechat] Saving as draft...');
await evaluate(session, `document.querySelector('#js_submit button').click()`);
await sleep(3000);
File diff suppressed because it is too large Load Diff