Compare commits

..

21 Commits

Author SHA1 Message Date
Jim Liu 宝玉 e754eae5ae chore: release v1.40.1 2026-02-28 01:38:40 -06:00
Jim Liu 宝玉 cc016f3b16 feat(baoyu-image-gen): clarify model resolution priority and add model display requirement
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 01:38:10 -06:00
Jim Liu 宝玉 2188160418 chore: release v1.40.0 2026-02-28 01:15:53 -06:00
Jim Liu 宝玉 77204d2e4a feat(baoyu-markdown-to-html): add CLI customization options and EXTEND.md config support
Add --color, --font-family, --font-size, --code-theme, --mac-code-block,
--line-number, --cite, --count, --legend options. Support EXTEND.md for
default preferences. Add code highlight theme fetching and mermaid fix.
2026-02-28 01:15:16 -06:00
Jim Liu 宝玉 35eeb62f10 Merge pull request #57 from zhao-newname/main
feat(baoyu-image-gen): support OpenAI chat completions endpoint for i…
2026-02-28 01:13:19 -06:00
jeff.zhao 7dfa2b6e6c feat(baoyu-image-gen): support OpenAI chat completions endpoint for image generation
Add OPENAI_IMAGE_USE_CHAT env var (true|false) to route image generation
through /chat/completions instead of /images/generations. Extracts base64
image data URLs from the chat response, enabling compatibility with
OpenAI-compatible providers that return images via chat API.
2026-02-28 14:46:05 +08:00
Jim Liu 宝玉 a2eecec26f chore: release v1.39.0 2026-02-28 00:33:53 -06:00
Jim Liu 宝玉 903e7ad79d feat(baoyu-markdown-to-html): add red and orange themes 2026-02-28 00:33:28 -06:00
Jim Liu 宝玉 13707fa2cf chore: release v1.38.0 2026-02-28 00:07:01 -06:00
Jim Liu 宝玉 b56e503b16 feat(baoyu-danger-x-to-markdown): add referenced tweet rendering, media reuse, and hi-res downloads
- Render embedded tweets in articles as blockquotes with author info and text summary
- Reuse existing markdown when --download-media targets already-converted URLs
- Upgrade Twitter image downloads to 4096x4096 resolution
- Improve entity resolution with logical key lookup
- Support trailing media for all block types (headings, lists, blockquotes)
- Change output path to {username}/{tweet-id}/{content-slug}.md for stable paths
2026-02-28 00:06:43 -06:00
Jim Liu 宝玉 7d03685ade chore: release v1.37.1 2026-02-27 18:35:25 -06:00
Jim Liu 宝玉 b305c386bc chore: remove unused Gemini model comparison images
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 18:32:05 -06:00
Jim Liu 宝玉 240dd7d314 Merge pull request #56 from xkcoding/fix/update-gemini-web-model-headers
fix(baoyu-danger-gemini-web): sync model headers with upstream and update model list
2026-02-27 18:30:48 -06:00
Jim Liu 宝玉 b02ceacfd9 chore: release v1.37.0 2026-02-27 18:29:04 -06:00
Jim Liu 宝玉 fdf9007e2c feat(baoyu-danger-x-to-markdown): improve article rendering with inline links and content-based output paths 2026-02-27 18:28:34 -06:00
Jim Liu 宝玉 08cee885d3 chore: release v1.36.0 2026-02-27 10:13:27 -06:00
Jim Liu 宝玉 3bd5fdeb1b feat(baoyu-image-gen): add gemini-3.1-flash-image-preview model and improve first-time setup
- Add gemini-3.1-flash-image-preview to supported Google multimodal models
- Improve preferences loading with blocking first-time setup flow
- Add first-time-setup.md reference for guided configuration
- Update model references in SKILL.md and preferences schema
2026-02-27 10:12:52 -06:00
Yangkai.Shen e737c4a611 docs: add model comparison images for PR #56
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 19:59:42 +08:00
Yangkai.Shen 2eec4f3639 fix(baoyu-danger-gemini-web): sync model headers with upstream and update model list
- Update x-goog-ext-525001261-jspb header format to match upstream
  HanaokaYuzu/Gemini-API (commit 42900f7, 2026-02-03), appending
  `,null,null,1` to fix image generation failures
- Replace deprecated gemini-2.5-pro and gemini-2.5-flash with
  gemini-3.0-flash and gemini-3.0-flash-thinking
- Add gemini-3.1-pro-preview (empty header, server auto-routed)
- Update SKILL.md and CLI help text to reflect new model options

Closes #50

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 19:40:50 +08:00
Jim Liu 宝玉 e5912018f3 Merge pull request #55 from liye71023326/fix/google-proxy-support
fix(baoyu-image-gen): use curl fallback when HTTP proxy is detected
2026-02-26 14:33:55 -06:00
李野 b1f568d03d fix(baoyu-image-gen): use curl fallback for Google API when HTTP proxy is detected
Bun's fetch implementation has a known issue where long-lived connections
through HTTP proxies (e.g., Clash, V2Ray) get their sockets closed
unexpectedly, causing Google image generation requests to fail with
"The socket connection was closed unexpectedly".

This change adds automatic proxy detection and falls back to curl as the
HTTP client when a proxy is configured (via https_proxy, http_proxy,
HTTPS_PROXY, HTTP_PROXY, or ALL_PROXY environment variables). When no
proxy is detected, the original fetch-based implementation is used.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 17:16:15 +08:00
25 changed files with 1787 additions and 169 deletions
+1 -1
View File
@@ -6,7 +6,7 @@
},
"metadata": {
"description": "Skills shared by Baoyu for improving daily work efficiency",
"version": "1.35.0"
"version": "1.40.1"
},
"plugins": [
{
+48
View File
@@ -2,6 +2,54 @@
English | [中文](./CHANGELOG.zh.md)
## 1.40.1 - 2026-02-28
### Features
- `baoyu-image-gen`: clarify model resolution priority (EXTEND.md overrides env vars) and display current model with switch hints during generation
## 1.40.0 - 2026-02-28
### Features
- `baoyu-image-gen`: support OpenAI chat completions endpoint for image generation (by @zhao-newname)
- `baoyu-markdown-to-html`: add CLI customization options (--color, --font-family, --font-size, --code-theme, --mac-code-block, --line-number, --cite, --count, --legend) and EXTEND.md config support
## 1.39.0 - 2026-02-28
### Features
- `baoyu-markdown-to-html`: add red theme (traditional calligraphy style with red-gold palette and serif typography) and orange theme (warm modern style with rounded corners and relaxed line height)
## 1.38.0 - 2026-02-28
### Features
- `baoyu-danger-x-to-markdown`: render embedded tweets in articles as blockquotes with author info and text summary
- `baoyu-danger-x-to-markdown`: reuse existing markdown when `--download-media` targets already-converted URLs
- `baoyu-danger-x-to-markdown`: upgrade Twitter image downloads to 4096x4096 high resolution
### Fixes
- `baoyu-danger-x-to-markdown`: improve entity resolution with logical key lookup for reliable media and link mapping
- `baoyu-danger-x-to-markdown`: support trailing media for all block types (headings, lists, blockquotes)
## 1.37.1 - 2026-02-27
### Fixes
- `baoyu-danger-gemini-web`: sync model headers with upstream and update model list (by @xkcoding)
## 1.37.0 - 2026-02-27
### Features
- `baoyu-danger-x-to-markdown`: add inline link rendering for X article content, mapping LINK/MEDIA entities to markdown links
- `baoyu-danger-x-to-markdown`: use content-based slug in output directory path for meaningful folder names
- `baoyu-danger-x-to-markdown`: add atomic media queue for blocks without direct media references
## 1.36.0 - 2026-02-27
### Features
- `baoyu-image-gen`: add `gemini-3.1-flash-image-preview` model support for Google multimodal image generation
- `baoyu-image-gen`: improve first-time setup with blocking preferences flow and guided configuration
### Fixes
- `baoyu-image-gen`: use curl fallback for Google API when HTTP proxy is detected (by @liye71023326)
## 1.35.0 - 2026-02-24
### Features
+48
View File
@@ -2,6 +2,54 @@
[English](./CHANGELOG.md) | 中文
## 1.40.1 - 2026-02-28
### 新功能
- `baoyu-image-gen`:明确模型解析优先级(EXTEND.md 优先于环境变量),生成图片时显示当前模型及切换方式
## 1.40.0 - 2026-02-28
### 新功能
- `baoyu-image-gen`:支持 OpenAI Chat Completions 端点生成图片 (by @zhao-newname)
- `baoyu-markdown-to-html`:新增 CLI 自定义选项(--color、--font-family、--font-size、--code-theme、--mac-code-block、--line-number、--cite、--count、--legend)及 EXTEND.md 配置支持
## 1.39.0 - 2026-02-28
### 新功能
- `baoyu-markdown-to-html`:新增红色主题(红金配色、宋体排版、传统书法风格)和橙色主题(暖色调现代风、圆角装饰、宽松行距)
## 1.38.0 - 2026-02-28
### 新功能
- `baoyu-danger-x-to-markdown`:支持文章内嵌推文渲染,以引用块形式显示作者信息和推文摘要
- `baoyu-danger-x-to-markdown``--download-media` 复用已转换的 Markdown 文件,跳过重复抓取
- `baoyu-danger-x-to-markdown`:推特图片下载升级至 4096x4096 高分辨率
### 修复
- `baoyu-danger-x-to-markdown`:改进实体解析逻辑,通过逻辑键查找提升媒体和链接映射准确性
- `baoyu-danger-x-to-markdown`:所有区块类型(标题、列表、引用块)支持尾随媒体展示
## 1.37.1 - 2026-02-27
### 修复
- `baoyu-danger-gemini-web`:同步上游模型请求头并更新模型列表 (by @xkcoding)
## 1.37.0 - 2026-02-27
### 新功能
- `baoyu-danger-x-to-markdown`:支持 X 文章内联链接渲染,将 LINK/MEDIA 实体映射为 Markdown 链接
- `baoyu-danger-x-to-markdown`:输出目录使用基于内容的 slug,生成更有意义的文件夹名称
- `baoyu-danger-x-to-markdown`:新增 atomic 媒体队列,支持无直接媒体引用的区块
## 1.36.0 - 2026-02-27
### 新功能
- `baoyu-image-gen`:新增 `gemini-3.1-flash-image-preview` Google 多模态图片生成模型支持
- `baoyu-image-gen`:优化首次使用引导流程,支持阻塞式偏好配置
### 修复
- `baoyu-image-gen`:检测到 HTTP 代理时自动回退使用 curl 调用 Google API (by @liye71023326)
## 1.35.0 - 2026-02-24
### 新功能
+6 -5
View File
@@ -76,7 +76,7 @@ test -f "$HOME/.baoyu-skills/baoyu-danger-gemini-web/EXTEND.md" && echo "user"
```bash
# Text generation
npx -y bun ${SKILL_DIR}/scripts/main.ts "Your prompt"
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "Your prompt" --model gemini-2.5-pro
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "Your prompt" --model gemini-3-flash
# Image generation
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "A cute cat" --image cat.png
@@ -100,7 +100,7 @@ npx -y bun ${SKILL_DIR}/scripts/main.ts "Hello" --json
|--------|-------------|
| `--prompt`, `-p` | Prompt text |
| `--promptfiles` | Read prompt from files (concatenated) |
| `--model`, `-m` | Model: gemini-3-pro (default), gemini-2.5-pro, gemini-2.5-flash |
| `--model`, `-m` | Model: gemini-3-pro (default), gemini-3-flash, gemini-3-flash-thinking, gemini-3.1-pro-preview |
| `--image [path]` | Generate image (default: generated.png) |
| `--reference`, `--ref` | Reference images for vision input |
| `--sessionId` | Session ID for multi-turn conversation |
@@ -114,9 +114,10 @@ npx -y bun ${SKILL_DIR}/scripts/main.ts "Hello" --json
| Model | Description |
|-------|-------------|
| `gemini-3-pro` | Default, latest |
| `gemini-2.5-pro` | Previous pro |
| `gemini-2.5-flash` | Fast, lightweight |
| `gemini-3-pro` | Default, latest 3.0 Pro |
| `gemini-3-flash` | Fast, lightweight 3.0 Flash |
| `gemini-3-flash-thinking` | 3.0 Flash with thinking |
| `gemini-3.1-pro-preview` | 3.1 Pro preview (empty header, auto-routed) |
## Authentication
@@ -47,17 +47,22 @@ export class Model {
static readonly UNSPECIFIED = new Model('unspecified', {}, false);
static readonly G_3_0_PRO = new Model(
'gemini-3.0-pro',
{ 'x-goog-ext-525001261-jspb': '[1,null,null,null,"9d8ca3786ebdfbea",null,null,0,[4]]' },
{ 'x-goog-ext-525001261-jspb': '[1,null,null,null,"9d8ca3786ebdfbea",null,null,0,[4],null,null,1]' },
false,
);
static readonly G_2_5_PRO = new Model(
'gemini-2.5-pro',
{ 'x-goog-ext-525001261-jspb': '[1,null,null,null,"4af6c7f5da75d65d",null,null,0,[4]]' },
static readonly G_3_0_FLASH = new Model(
'gemini-3.0-flash',
{ 'x-goog-ext-525001261-jspb': '[1,null,null,null,"fbb127bbb056c959",null,null,0,[4],null,null,1]' },
false,
);
static readonly G_2_5_FLASH = new Model(
'gemini-2.5-flash',
{ 'x-goog-ext-525001261-jspb': '[1,null,null,null,"9ec249fc9ad08861",null,null,0,[4]]' },
static readonly G_3_0_FLASH_THINKING = new Model(
'gemini-3.0-flash-thinking',
{ 'x-goog-ext-525001261-jspb': '[1,null,null,null,"5bf011840784117a",null,null,0,[4],null,null,1]' },
false,
);
static readonly G_3_1_PRO_PREVIEW = new Model(
'gemini-3.1-pro-preview',
{},
false,
);
@@ -68,12 +73,12 @@ export class Model {
) {}
static from_name(name: string): Model {
for (const model of [Model.UNSPECIFIED, Model.G_3_0_PRO, Model.G_2_5_PRO, Model.G_2_5_FLASH]) {
for (const model of [Model.UNSPECIFIED, Model.G_3_0_PRO, Model.G_3_0_FLASH, Model.G_3_0_FLASH_THINKING, Model.G_3_1_PRO_PREVIEW]) {
if (model.model_name === name) return model;
}
throw new Error(
`Unknown model name: ${name}. Available models: ${[Model.UNSPECIFIED, Model.G_3_0_PRO, Model.G_2_5_PRO, Model.G_2_5_FLASH]
`Unknown model name: ${name}. Available models: ${[Model.UNSPECIFIED, Model.G_3_0_PRO, Model.G_3_0_FLASH, Model.G_3_0_FLASH_THINKING, Model.G_3_1_PRO_PREVIEW]
.map((m) => m.model_name)
.join(', ')}`,
);
@@ -73,7 +73,7 @@ Multi-turn conversation (agent generates unique sessionId):
Options:
-p, --prompt <text> Prompt text
--promptfiles <files...> Read prompt from one or more files (concatenated in order)
-m, --model <id> gemini-3-pro | gemini-2.5-pro | gemini-2.5-flash (default: gemini-3-pro)
-m, --model <id> gemini-3-pro | gemini-3-flash | gemini-3-flash-thinking | gemini-3.1-pro-preview (default: gemini-3-pro)
--json Output JSON
--image [path] Generate an image and save it (default: ./generated.png)
--reference <files...> Reference images for vision input
@@ -227,8 +227,11 @@ function resolveModel(id: string): Model {
const k = id.trim();
if (k === 'gemini-3-pro') return Model.G_3_0_PRO;
if (k === 'gemini-3.0-pro') return Model.G_3_0_PRO;
if (k === 'gemini-2.5-pro') return Model.G_2_5_PRO;
if (k === 'gemini-2.5-flash') return Model.G_2_5_FLASH;
if (k === 'gemini-3-flash') return Model.G_3_0_FLASH;
if (k === 'gemini-3.0-flash') return Model.G_3_0_FLASH;
if (k === 'gemini-3-flash-thinking') return Model.G_3_0_FLASH_THINKING;
if (k === 'gemini-3.0-flash-thinking') return Model.G_3_0_FLASH_THINKING;
if (k === 'gemini-3.1-pro-preview') return Model.G_3_1_PRO_PREVIEW;
return Model.from_name(k);
}
+1 -1
View File
@@ -170,7 +170,7 @@ coverImage: "https://pbs.twimg.com/media/example.jpg"
Content...
```
**File structure**: `x-to-markdown/{username}/{tweet-id}.md`
**File structure**: `x-to-markdown/{username}/{tweet-id}/{content-slug}.md`
When `--download-media` is enabled:
- Images are saved to `imgs/` next to the markdown file
+204 -26
View File
@@ -2,11 +2,12 @@ import fs from "node:fs";
import path from "node:path";
import readline from "node:readline";
import process from "node:process";
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { fetchXArticle } from "./graphql.js";
import { formatArticleMarkdown } from "./markdown.js";
import { localizeMarkdownMedia, type LocalizeMarkdownMediaResult } from "./media-localizer.js";
import { resolveReferencedTweetsFromArticle } from "./referenced-tweets.js";
import { hasRequiredXCookies, loadXCookies, refreshXCookies } from "./cookies.js";
import { resolveXToMarkdownConsentPath } from "./paths.js";
import { tweetToMarkdown } from "./tweet-to-markdown.js";
@@ -182,36 +183,160 @@ function sanitizeSlug(input: string): string {
.slice(0, 120);
}
function formatBackupTimestamp(date: Date = new Date()): string {
const pad2 = (n: number) => String(n).padStart(2, "0");
return `${date.getFullYear()}${pad2(date.getMonth() + 1)}${pad2(date.getDate())}-${pad2(date.getHours())}${pad2(
date.getMinutes()
)}${pad2(date.getSeconds())}`;
function extractContentSlug(markdown: string): string {
const headingMatch = markdown.match(/^#\s+(.+)$/m);
if (headingMatch?.[1]) {
return sanitizeSlug(headingMatch[1].slice(0, 60)).toLowerCase();
}
const lines = markdown.split("\n");
let inFrontmatter = false;
for (const line of lines) {
if (line === "---") {
inFrontmatter = !inFrontmatter;
continue;
}
if (inFrontmatter) continue;
const trimmed = line.trim();
if (trimmed) {
return sanitizeSlug(trimmed.slice(0, 60)).toLowerCase();
}
}
return "untitled";
}
async function backupDirIfExists(dir: string, log: (message: string) => void): Promise<void> {
function resolveSlugAndId(normalizedUrl: string, kind: "tweet" | "article"): { slug: string; idPart: string } {
const articleId = kind === "article" ? parseArticleId(normalizedUrl) : null;
const tweetId = kind === "tweet" ? parseTweetId(normalizedUrl) : null;
const username = kind === "tweet" ? parseTweetUsername(normalizedUrl) : null;
const idPart = articleId ?? tweetId ?? String(Date.now());
const userSlug = username ? sanitizeSlug(username) : null;
const slug = userSlug ?? idPart;
return { slug, idPart };
}
function extractFrontmatterUrls(markdown: string): string[] {
const match = markdown.match(/^---\n([\s\S]*?)\n---/);
if (!match?.[1]) return [];
const lines = match[1].split("\n");
const urls: string[] = [];
for (const line of lines) {
const m = line.match(/^(url|requestedUrl):\s*["']([^"']+)["']\s*$/);
if (m?.[2]) {
urls.push(m[2]);
}
}
return urls;
}
function frontmatterMatchesTarget(
markdown: string,
normalizedUrl: string,
kind: "tweet" | "article"
): boolean {
const urls = extractFrontmatterUrls(markdown);
if (urls.length === 0) return false;
const targetId = kind === "article" ? parseArticleId(normalizedUrl) : parseTweetId(normalizedUrl);
if (!targetId) return false;
for (const url of urls) {
const candidateId = kind === "article" ? parseArticleId(url) : parseTweetId(url);
if (candidateId && candidateId === targetId) {
return true;
}
}
return false;
}
function listMarkdownFiles(dirPath: string): string[] {
try {
if (!fs.existsSync(dir)) return;
const stat = fs.statSync(dir);
if (!stat.isDirectory()) return;
const backup = `${dir}-backup-${formatBackupTimestamp()}`;
await rename(dir, backup);
log(`[x-to-markdown] Existing directory moved to: ${backup}`);
} catch (error) {
throw new Error(
`Failed to backup existing directory (${dir}): ${error instanceof Error ? error.message : String(error ?? "")}`
);
return fs
.readdirSync(dirPath)
.filter((name) => name.toLowerCase().endsWith(".md"))
.map((name) => path.join(dirPath, name))
.sort();
} catch {
return [];
}
}
function resolveDefaultOutputDir(slug: string): string {
return path.resolve(process.cwd(), "x-to-markdown", slug);
function resolveExistingMarkdownPath(
normalizedUrl: string,
kind: "tweet" | "article",
argsOutput: string | null
): string | null {
const { slug, idPart } = resolveSlugAndId(normalizedUrl, kind);
const candidateDirs = new Set<string>();
const candidateFiles = new Set<string>();
if (argsOutput) {
const resolved = path.resolve(argsOutput);
const looksDir = argsOutput.endsWith("/") || argsOutput.endsWith("\\");
try {
if (fs.existsSync(resolved)) {
const stat = fs.statSync(resolved);
if (stat.isFile()) {
candidateFiles.add(resolved);
} else if (stat.isDirectory()) {
candidateDirs.add(path.join(resolved, slug, idPart));
candidateDirs.add(resolved);
}
} else if (looksDir) {
candidateDirs.add(path.join(resolved, slug, idPart));
}
} catch {
// ignore and continue
}
} else {
candidateDirs.add(path.resolve(process.cwd(), "x-to-markdown", slug, idPart));
}
for (const filePath of candidateFiles) {
if (!filePath.toLowerCase().endsWith(".md")) continue;
try {
const markdown = fs.readFileSync(filePath, "utf8");
if (frontmatterMatchesTarget(markdown, normalizedUrl, kind)) {
return filePath;
}
} catch {
// ignore and continue
}
}
for (const dirPath of candidateDirs) {
if (!fs.existsSync(dirPath)) continue;
let stat: fs.Stats;
try {
stat = fs.statSync(dirPath);
} catch {
continue;
}
if (!stat.isDirectory()) continue;
const markdownFiles = listMarkdownFiles(dirPath);
for (const filePath of markdownFiles) {
try {
const markdown = fs.readFileSync(filePath, "utf8");
if (frontmatterMatchesTarget(markdown, normalizedUrl, kind)) {
return filePath;
}
} catch {
// ignore and continue
}
}
}
return null;
}
async function resolveOutputPath(
normalizedUrl: string,
kind: "tweet" | "article",
argsOutput: string | null,
contentSlug: string,
log: (message: string) => void
): Promise<{ outputDir: string; markdownPath: string; slug: string }> {
const articleId = kind === "article" ? parseArticleId(normalizedUrl) : null;
@@ -222,15 +347,14 @@ async function resolveOutputPath(
const idPart = articleId ?? tweetId ?? String(Date.now());
const slug = userSlug ?? idPart;
const defaultFileName = kind === "article" ? `${idPart}.md` : `${idPart}.md`;
const defaultFileName = `${contentSlug}.md`;
if (argsOutput) {
const wantsDir = argsOutput.endsWith("/") || argsOutput.endsWith("\\");
const resolved = path.resolve(argsOutput);
try {
if (wantsDir || (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory())) {
const outputDir = path.join(resolved, slug);
await backupDirIfExists(outputDir, log);
const outputDir = path.join(resolved, slug, idPart);
await mkdir(outputDir, { recursive: true });
return { outputDir, markdownPath: path.join(outputDir, defaultFileName), slug };
}
@@ -243,8 +367,7 @@ async function resolveOutputPath(
return { outputDir, markdownPath: resolved, slug };
}
const outputDir = resolveDefaultOutputDir(slug);
await backupDirIfExists(outputDir, log);
const outputDir = path.resolve(process.cwd(), "x-to-markdown", slug, idPart);
await mkdir(outputDir, { recursive: true });
return { outputDir, markdownPath: path.join(outputDir, defaultFileName), slug };
}
@@ -355,7 +478,8 @@ async function convertArticleToMarkdown(
log(`[x-to-markdown] Fetching article ${articleId}...`);
const article = await fetchXArticle(articleId, cookieMap, false);
const { markdown: body, coverUrl } = formatArticleMarkdown(article);
const referencedTweets = await resolveReferencedTweetsFromArticle(article, cookieMap, { log });
const { markdown: body, coverUrl } = formatArticleMarkdown(article, { referencedTweets });
const title = typeof (article as any)?.title === "string" ? String((article as any).title).trim() : "";
const meta = formatMetaMarkdown({
@@ -394,13 +518,67 @@ async function main(): Promise<void> {
}
const kind = articleId ? ("article" as const) : ("tweet" as const);
const { outputDir, markdownPath, slug } = await resolveOutputPath(normalizedUrl, kind, args.output, log);
if (args.downloadMedia) {
const existingMarkdownPath = resolveExistingMarkdownPath(normalizedUrl, kind, args.output);
if (existingMarkdownPath) {
log(`[x-to-markdown] Reusing existing markdown: ${existingMarkdownPath}`);
const existingMarkdown = await readFile(existingMarkdownPath, "utf8");
const mediaResult = await localizeMarkdownMedia(existingMarkdown, {
markdownPath: existingMarkdownPath,
log,
});
const didLocalize =
mediaResult.downloadedImages > 0 ||
mediaResult.downloadedVideos > 0 ||
mediaResult.markdown !== existingMarkdown;
if (didLocalize) {
await writeFile(existingMarkdownPath, mediaResult.markdown, "utf8");
log(
`[x-to-markdown] Media localized: images=${mediaResult.downloadedImages}, videos=${mediaResult.downloadedVideos}`
);
log(`[x-to-markdown] Saved: ${existingMarkdownPath}`);
const { slug } = resolveSlugAndId(normalizedUrl, kind);
if (args.json) {
console.log(
JSON.stringify(
{
url: articleId ? `https://x.com/i/article/${articleId}` : normalizedUrl,
requestedUrl: normalizedUrl,
type: kind,
slug,
outputDir: path.dirname(existingMarkdownPath),
markdownPath: existingMarkdownPath,
downloadMedia: true,
downloadedImages: mediaResult.downloadedImages,
downloadedVideos: mediaResult.downloadedVideos,
imageDir: mediaResult.imageDir,
videoDir: mediaResult.videoDir,
},
null,
2
)
);
} else {
console.log(existingMarkdownPath);
}
return;
}
log("[x-to-markdown] Existing markdown already localized; rebuilding content to refresh placement.");
}
}
let markdown =
kind === "article" && articleId
? await convertArticleToMarkdown(normalizedUrl, articleId, log)
: await tweetToMarkdown(normalizedUrl, { log });
const contentSlug = extractContentSlug(markdown);
const { outputDir, markdownPath, slug } = await resolveOutputPath(normalizedUrl, kind, args.output, contentSlug, log);
let mediaResult: LocalizeMarkdownMediaResult | null = null;
if (args.downloadMedia) {
@@ -2,9 +2,22 @@ import type {
ArticleBlock,
ArticleContentState,
ArticleEntity,
ArticleEntityMapEntry,
ArticleMediaInfo,
} from "./types.js";
export type ReferencedTweetInfo = {
id: string;
url: string;
authorName?: string;
authorUsername?: string;
text?: string;
};
export type FormatArticleOptions = {
referencedTweets?: Map<string, ReferencedTweetInfo>;
};
function coerceArticleEntity(value: unknown): ArticleEntity | null {
if (!value || typeof value !== "object") return null;
const candidate = value as ArticleEntity;
@@ -29,6 +42,73 @@ function normalizeCaption(caption?: string): string {
return trimmed.replace(/\s+/g, " ");
}
function summarizeTweetText(text?: string): string {
const trimmed = text?.trim();
if (!trimmed) return "";
const normalized = trimmed
.split(/\r?\n+/)
.map((line) => line.trim())
.filter(Boolean)
.join(" ");
if (normalized.length <= 280) return normalized;
return `${normalized.slice(0, 277)}...`;
}
function buildTweetUrl(tweetId?: string, username?: string): string | null {
if (!tweetId) return null;
if (username) {
return `https://x.com/${username}/status/${tweetId}`;
}
return `https://x.com/i/web/status/${tweetId}`;
}
type EntityLookup = {
byIndex: Map<number, ArticleEntityMapEntry>;
byLogicalKey: Map<number, ArticleEntityMapEntry>;
};
function buildEntityLookup(
entityMap: ArticleContentState["entityMap"] | undefined
): EntityLookup {
const lookup: EntityLookup = {
byIndex: new Map<number, ArticleEntityMapEntry>(),
byLogicalKey: new Map<number, ArticleEntityMapEntry>(),
};
if (!entityMap) return lookup;
for (const [idx, entry] of Object.entries(entityMap)) {
const idxNum = Number(idx);
if (Number.isFinite(idxNum)) {
lookup.byIndex.set(idxNum, entry);
}
const logicalKey = parseInt(entry?.key ?? "", 10);
if (Number.isFinite(logicalKey) && !lookup.byLogicalKey.has(logicalKey)) {
lookup.byLogicalKey.set(logicalKey, entry);
}
}
return lookup;
}
function resolveEntityEntry(
entityKey: number | undefined,
entityMap: ArticleContentState["entityMap"] | undefined,
lookup: EntityLookup
): ArticleEntityMapEntry | undefined {
if (entityKey === undefined) return undefined;
const byLogicalKey = lookup.byLogicalKey.get(entityKey);
if (byLogicalKey) return byLogicalKey;
const byIndex = lookup.byIndex.get(entityKey);
if (byIndex) return byIndex;
if (!entityMap) return undefined;
return entityMap[String(entityKey)];
}
function resolveMediaUrl(info?: ArticleMediaInfo): string | undefined {
if (!info) return undefined;
if (info.original_img_url) return info.original_img_url;
@@ -79,11 +159,12 @@ function collectMediaUrls(
function resolveEntityMediaLines(
entityKey: number | undefined,
entityMap: ArticleContentState["entityMap"] | undefined,
entityLookup: EntityLookup,
mediaById: Map<string, string>,
usedUrls: Set<string>
): string[] {
if (entityKey === undefined || !entityMap) return [];
const entry = entityMap[String(entityKey)];
if (entityKey === undefined) return [];
const entry = resolveEntityEntry(entityKey, entityMap, entityLookup);
const value = entry?.value;
if (!value) return [];
const type = value.type;
@@ -117,11 +198,142 @@ function resolveEntityMediaLines(
return lines;
}
function resolveEntityTweetLines(
entityKey: number | undefined,
entityMap: ArticleContentState["entityMap"] | undefined,
entityLookup: EntityLookup,
referencedTweets?: Map<string, ReferencedTweetInfo>
): string[] {
if (entityKey === undefined) return [];
const entry = resolveEntityEntry(entityKey, entityMap, entityLookup);
const value = entry?.value;
if (!value || value.type !== "TWEET") return [];
const tweetId = typeof value.data?.tweetId === "string" ? value.data.tweetId : "";
if (!tweetId) return [];
const referenced = referencedTweets?.get(tweetId);
const url =
referenced?.url ??
buildTweetUrl(tweetId, referenced?.authorUsername) ??
`https://x.com/i/web/status/${tweetId}`;
const authorText =
referenced?.authorName && referenced?.authorUsername
? `${referenced.authorName} (@${referenced.authorUsername})`
: referenced?.authorUsername
? `@${referenced.authorUsername}`
: referenced?.authorName;
const lines: string[] = [];
lines.push(`> 引用推文${authorText ? `${authorText}` : ""}`);
const summary = summarizeTweetText(referenced?.text);
if (summary) {
lines.push(`> ${summary}`);
}
lines.push(`> ${url}`);
return lines;
}
function buildMediaLinkMap(
entityMap: ArticleContentState["entityMap"] | undefined
): Map<number, string> {
const map = new Map<number, string>();
if (!entityMap) return map;
const mediaEntries: { idx: number; key: number }[] = [];
const linkEntries: { key: number; url: string }[] = [];
for (const [idx, entry] of Object.entries(entityMap)) {
const value = entry?.value;
if (!value) continue;
const key = parseInt(entry?.key ?? "", 10);
if (isNaN(key)) continue;
if (value.type === "MEDIA" || value.type === "IMAGE") {
mediaEntries.push({ idx: Number(idx), key });
} else if (value.type === "LINK" && typeof value.data?.url === "string") {
linkEntries.push({ key, url: value.data.url });
}
}
if (mediaEntries.length === 0 || linkEntries.length === 0) return map;
mediaEntries.sort((a, b) => a.key - b.key);
linkEntries.sort((a, b) => a.key - b.key);
const pool = [...linkEntries];
for (const media of mediaEntries) {
if (pool.length === 0) break;
let linkIdx = pool.findIndex((l) => l.key > media.key);
if (linkIdx === -1) linkIdx = 0;
const link = pool.splice(linkIdx, 1)[0]!;
map.set(media.idx, link.url);
map.set(media.key, link.url);
}
return map;
}
function renderInlineLinks(
text: string,
entityRanges: Array<{ key?: number; offset?: number; length?: number }>,
entityMap: ArticleContentState["entityMap"] | undefined,
entityLookup: EntityLookup,
mediaLinkMap: Map<number, string>
): string {
if (!entityMap || entityRanges.length === 0) return text;
const valid = entityRanges.filter(
(r) =>
typeof r.key === "number" &&
typeof r.offset === "number" &&
typeof r.length === "number" &&
r.length > 0
);
if (valid.length === 0) return text;
const sorted = [...valid].sort((a, b) => (b.offset ?? 0) - (a.offset ?? 0));
let result = text;
for (const range of sorted) {
const offset = range.offset!;
const length = range.length!;
const key = range.key!;
const entry = resolveEntityEntry(key, entityMap, entityLookup);
const value = entry?.value;
if (!value) continue;
let url: string | undefined;
if (value.type === "LINK" && typeof value.data?.url === "string") {
url = value.data.url;
} else if (value.type === "MEDIA" || value.type === "IMAGE") {
url = mediaLinkMap.get(key);
}
if (!url) continue;
const linkText = result.slice(offset, offset + length);
result =
result.slice(0, offset) +
`[${linkText}](${url})` +
result.slice(offset + length);
}
return result;
}
function renderContentBlocks(
blocks: ArticleBlock[],
entityMap: ArticleContentState["entityMap"] | undefined,
entityLookup: EntityLookup,
mediaById: Map<string, string>,
usedUrls: Set<string>
usedUrls: Set<string>,
mediaLinkMap: Map<number, string>,
referencedTweets?: Map<string, ReferencedTweetInfo>
): string[] {
const lines: string[] = [];
let previousKind: "list" | "quote" | "heading" | "text" | "code" | "media" | null = null;
@@ -150,14 +362,55 @@ function renderContentBlocks(
const mediaLines: string[] = [];
for (const range of ranges) {
if (typeof range?.key !== "number") continue;
mediaLines.push(...resolveEntityMediaLines(range.key, entityMap, mediaById, usedUrls));
mediaLines.push(
...resolveEntityMediaLines(range.key, entityMap, entityLookup, mediaById, usedUrls)
);
}
return mediaLines;
};
const collectTweetLines = (block: ArticleBlock): string[] => {
const ranges = Array.isArray(block.entityRanges) ? block.entityRanges : [];
const tweetLines: string[] = [];
for (const range of ranges) {
if (typeof range?.key !== "number") continue;
tweetLines.push(
...resolveEntityTweetLines(range.key, entityMap, entityLookup, referencedTweets)
);
}
return tweetLines;
};
const collectLinkLines = (block: ArticleBlock): string[] => {
const ranges = Array.isArray(block.entityRanges) ? block.entityRanges : [];
const linkLines: string[] = [];
for (const range of ranges) {
if (typeof range?.key !== "number") continue;
const entry = resolveEntityEntry(range.key, entityMap, entityLookup);
const value = entry?.value;
if (value?.type !== "LINK") continue;
const url = typeof value.data?.url === "string" ? value.data.url : "";
if (url) {
linkLines.push(url);
}
}
return [...new Set(linkLines)];
};
const pushTrailingMedia = (mediaLines: string[]) => {
if (mediaLines.length > 0) {
pushBlock(mediaLines, "media");
}
};
for (const block of blocks) {
const type = typeof block?.type === "string" ? block.type : "unstyled";
const text = typeof block?.text === "string" ? block.text : "";
const rawText = typeof block?.text === "string" ? block.text : "";
const ranges = Array.isArray(block.entityRanges) ? block.entityRanges : [];
const text =
type !== "atomic" && type !== "code-block"
? renderInlineLinks(rawText, ranges, entityMap, entityLookup, mediaLinkMap)
: rawText;
if (type === "code-block") {
if (!inCodeBlock) {
@@ -182,10 +435,22 @@ function renderContentBlocks(
}
listKind = null;
orderedIndex = 0;
const tweetLines = collectTweetLines(block);
if (tweetLines.length > 0) {
pushBlock(tweetLines, "quote");
}
const mediaLines = collectMediaLines(block);
if (mediaLines.length > 0) {
pushBlock(mediaLines, "media");
}
const linkLines = collectLinkLines(block);
if (linkLines.length > 0) {
pushBlock(linkLines, "text");
}
continue;
}
@@ -199,6 +464,7 @@ function renderContentBlocks(
listKind = "unordered";
orderedIndex = 0;
pushBlock([`- ${text}`], "list");
pushTrailingMedia(collectMediaLines(block));
continue;
}
@@ -209,6 +475,7 @@ function renderContentBlocks(
listKind = "ordered";
orderedIndex += 1;
pushBlock([`${orderedIndex}. ${text}`], "list");
pushTrailingMedia(collectMediaLines(block));
continue;
}
@@ -218,36 +485,43 @@ function renderContentBlocks(
switch (type) {
case "header-one":
pushBlock([`# ${text}`], "heading");
pushTrailingMedia(collectMediaLines(block));
break;
case "header-two":
pushBlock([`## ${text}`], "heading");
pushTrailingMedia(collectMediaLines(block));
break;
case "header-three":
pushBlock([`### ${text}`], "heading");
pushTrailingMedia(collectMediaLines(block));
break;
case "header-four":
pushBlock([`#### ${text}`], "heading");
pushTrailingMedia(collectMediaLines(block));
break;
case "header-five":
pushBlock([`##### ${text}`], "heading");
pushTrailingMedia(collectMediaLines(block));
break;
case "header-six":
pushBlock([`###### ${text}`], "heading");
pushTrailingMedia(collectMediaLines(block));
break;
case "blockquote": {
const quoteLines = text.length > 0 ? text.split("\n") : [""];
pushBlock(quoteLines.map((line) => `> ${line}`), "quote");
pushTrailingMedia(collectMediaLines(block));
break;
}
default:
if (/^XIMGPH_\d+$/.test(text.trim())) {
pushTrailingMedia(collectMediaLines(block));
break;
}
pushBlock([text], "text");
pushTrailingMedia(collectMediaLines(block));
break;
}
const trailingMediaLines = collectMediaLines(block);
if (trailingMediaLines.length > 0) {
pushBlock(trailingMediaLines, "media");
}
}
if (inCodeBlock) {
@@ -262,7 +536,28 @@ export type FormatArticleResult = {
coverUrl: string | null;
};
export function formatArticleMarkdown(article: unknown): FormatArticleResult {
export function extractReferencedTweetIds(article: unknown): string[] {
const candidate = coerceArticleEntity(article);
const entityMap = candidate?.content_state?.entityMap;
if (!entityMap) return [];
const ids: string[] = [];
const seen = new Set<string>();
for (const entry of Object.values(entityMap)) {
const value = entry?.value;
if (value?.type !== "TWEET") continue;
const tweetId = typeof value.data?.tweetId === "string" ? value.data.tweetId : "";
if (!tweetId || seen.has(tweetId)) continue;
seen.add(tweetId);
ids.push(tweetId);
}
return ids;
}
export function formatArticleMarkdown(
article: unknown,
options: FormatArticleOptions = {}
): FormatArticleResult {
const candidate = coerceArticleEntity(article);
if (!candidate) {
return { markdown: `\`\`\`json\n${JSON.stringify(article, null, 2)}\n\`\`\``, coverUrl: null };
@@ -283,8 +578,18 @@ export function formatArticleMarkdown(article: unknown): FormatArticleResult {
const blocks = candidate.content_state?.blocks;
const entityMap = candidate.content_state?.entityMap;
const entityLookup = buildEntityLookup(entityMap);
if (Array.isArray(blocks) && blocks.length > 0) {
const rendered = renderContentBlocks(blocks, entityMap, mediaById, usedUrls);
const mediaLinkMap = buildMediaLinkMap(entityMap);
const rendered = renderContentBlocks(
blocks,
entityMap,
entityLookup,
mediaById,
usedUrls,
mediaLinkMap,
options.referencedTweets
);
if (rendered.length > 0) {
if (lines.length > 0) lines.push("");
lines.push(...rendered);
@@ -187,11 +187,35 @@ function buildFileName(kind: MediaKind, index: number, sourceUrl: string, extens
const FRONTMATTER_COVER_RE = /^(coverImage:\s*")(https?:\/\/[^"]+)(")/m;
function toHighResUrl(rawUrl: string): string {
try {
const parsed = new URL(rawUrl);
if (parsed.hostname !== "pbs.twimg.com") return rawUrl;
const ext = path.posix.extname(parsed.pathname).replace(/^\./, "").toLowerCase();
if (!ext || !IMAGE_EXTENSIONS.has(ext)) return rawUrl;
parsed.pathname = parsed.pathname.replace(new RegExp(`\\.${ext}$`), "");
parsed.searchParams.set("format", ext === "jpeg" ? "jpg" : ext);
parsed.searchParams.set("name", "4096x4096");
return parsed.toString();
} catch {
return rawUrl;
}
}
function collectMarkdownLinkCandidates(markdown: string): MarkdownLinkCandidate[] {
MARKDOWN_LINK_RE.lastIndex = 0;
const candidates: MarkdownLinkCandidate[] = [];
const seen = new Set<string>();
const fmMatch = markdown.match(/^---\n([\s\S]*?)\n---/);
if (fmMatch) {
const coverMatch = fmMatch[1]?.match(FRONTMATTER_COVER_RE);
if (coverMatch?.[2] && !seen.has(coverMatch[2])) {
seen.add(coverMatch[2]);
candidates.push({ url: coverMatch[2], hint: "image" });
}
}
MARKDOWN_LINK_RE.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = MARKDOWN_LINK_RE.exec(markdown))) {
const label = match[1] ?? "";
@@ -204,15 +228,6 @@ function collectMarkdownLinkCandidates(markdown: string): MarkdownLinkCandidate[
});
}
const fmMatch = markdown.match(/^---\n([\s\S]*?)\n---/);
if (fmMatch) {
const coverMatch = fmMatch[1]?.match(FRONTMATTER_COVER_RE);
if (coverMatch?.[2] && !seen.has(coverMatch[2])) {
seen.add(coverMatch[2]);
candidates.push({ url: coverMatch[2], hint: "image" });
}
}
return candidates;
}
@@ -259,7 +274,8 @@ export async function localizeMarkdownMedia(
for (const candidate of candidates) {
try {
const response = await fetch(candidate.url, {
const downloadUrl = toHighResUrl(candidate.url);
const response = await fetch(downloadUrl, {
method: "GET",
redirect: "follow",
headers: {
@@ -0,0 +1,81 @@
import { fetchXTweet } from "./graphql.js";
import {
extractReferencedTweetIds,
type ReferencedTweetInfo,
} from "./markdown.js";
type ResolveReferencedTweetsOptions = {
log?: (message: string) => void;
};
function extractReferencedTweetInfo(tweet: any, fallbackTweetId: string): ReferencedTweetInfo {
const userCore = tweet?.core?.user_results?.result?.core;
const userLegacy = tweet?.core?.user_results?.result?.legacy;
const authorName =
typeof userCore?.name === "string"
? userCore.name
: typeof userLegacy?.name === "string"
? userLegacy.name
: undefined;
const authorUsername =
typeof userCore?.screen_name === "string"
? userCore.screen_name
: typeof userLegacy?.screen_name === "string"
? userLegacy.screen_name
: undefined;
const text =
tweet?.note_tweet?.note_tweet_results?.result?.text ??
tweet?.legacy?.full_text ??
tweet?.legacy?.text ??
undefined;
const tweetId =
typeof tweet?.rest_id === "string" && tweet.rest_id.length > 0
? tweet.rest_id
: fallbackTweetId;
const url = authorUsername
? `https://x.com/${authorUsername}/status/${tweetId}`
: `https://x.com/i/web/status/${tweetId}`;
return {
id: tweetId,
url,
authorName,
authorUsername,
text: typeof text === "string" ? text : undefined,
};
}
export async function resolveReferencedTweetsFromArticle(
article: unknown,
cookieMap: Record<string, string>,
options: ResolveReferencedTweetsOptions = {}
): Promise<Map<string, ReferencedTweetInfo>> {
const log = options.log ?? (() => {});
const ids = extractReferencedTweetIds(article);
const referencedTweets = new Map<string, ReferencedTweetInfo>();
for (const id of ids) {
try {
const tweet = await fetchXTweet(id, cookieMap, false);
const info = extractReferencedTweetInfo(tweet, id);
referencedTweets.set(id, info);
} catch (error) {
log(
`[x-to-markdown] Failed to fetch referenced tweet ${id}: ${
error instanceof Error ? error.message : String(error)
}`
);
referencedTweets.set(id, {
id,
url: `https://x.com/i/web/status/${id}`,
});
}
}
return referencedTweets;
}
@@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url";
import { hasRequiredXCookies, loadXCookies } from "./cookies.js";
import { fetchTweetThread } from "./thread.js";
import { formatArticleMarkdown } from "./markdown.js";
import { resolveReferencedTweetsFromArticle } from "./referenced-tweets.js";
import { formatThreadTweetsMarkdown } from "./thread-markdown.js";
import { resolveArticleEntityFromTweet } from "./tweet-article.js";
@@ -129,7 +130,8 @@ export async function tweetToMarkdown(
const parts: string[] = [];
if (articleEntity) {
const articleResult = formatArticleMarkdown(articleEntity);
const referencedTweets = await resolveReferencedTweetsFromArticle(articleEntity, cookieMap, { log });
const articleResult = formatArticleMarkdown(articleEntity, { referencedTweets });
coverImage = articleResult.coverUrl;
const articleMarkdown = articleResult.markdown.trimEnd();
if (articleMarkdown) {
@@ -40,6 +40,7 @@ export type ArticleEntityMapEntry = {
caption?: string;
mediaItems?: ArticleEntityMapMediaItem[];
url?: string;
tweetId?: string;
};
};
};
+31 -28
View File
@@ -13,33 +13,28 @@ Official API-based image generation. Supports OpenAI, Google, DashScope (阿里
1. `SKILL_DIR` = this SKILL.md file's directory
2. Script path = `${SKILL_DIR}/scripts/main.ts`
## Preferences (EXTEND.md)
## Step 0: Load Preferences ⛔ BLOCKING
Use Bash to check EXTEND.md existence (priority order):
**CRITICAL**: This step MUST complete BEFORE any image generation. Do NOT skip or defer.
Check EXTEND.md existence (priority: project → user):
```bash
# Check project-level first
test -f .baoyu-skills/baoyu-image-gen/EXTEND.md && echo "project"
# Then user-level (cross-platform: $HOME works on macOS/Linux/WSL)
test -f "$HOME/.baoyu-skills/baoyu-image-gen/EXTEND.md" && echo "user"
```
┌──────────────────────────────────────────────────┬───────────────────┐
│ Path │ Location │
├──────────────────────────────────────────────────┼───────────────────┤
│ .baoyu-skills/baoyu-image-gen/EXTEND.md │ Project directory │
├──────────────────────────────────────────────────┼───────────────────┤
│ $HOME/.baoyu-skills/baoyu-image-gen/EXTEND.md │ User home │
└──────────────────────────────────────────────────┴───────────────────┘
| Result | Action |
|--------|--------|
| Found | Load, parse, apply settings. If `default_model.[provider]` is null → ask model only (Flow 2) |
| Not found | ⛔ Run first-time setup ([references/config/first-time-setup.md](references/config/first-time-setup.md)) → Save EXTEND.md → Then continue |
┌───────────┬───────────────────────────────────────────────────────────────────────────┐
│ Result │ Action │
├───────────┼───────────────────────────────────────────────────────────────────────────┤
│ Found │ Read, parse, apply settings │
├───────────┼───────────────────────────────────────────────────────────────────────────┤
│ Not found │ Use defaults │
└───────────┴───────────────────────────────────────────────────────────────────────────┘
**CRITICAL**: If not found, complete the full setup (provider + model + quality + save location) using AskUserQuestion BEFORE generating any images. Generation is BLOCKED until EXTEND.md is created.
| Path | Location |
|------|----------|
| `.baoyu-skills/baoyu-image-gen/EXTEND.md` | Project directory |
| `$HOME/.baoyu-skills/baoyu-image-gen/EXTEND.md` | User home |
**EXTEND.md Supports**: Default provider | Default quality | Default aspect ratio | Default image size | Default models
@@ -87,12 +82,12 @@ npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "A cat" --image out.png --provi
| `--promptfiles <files...>` | Read prompt from files (concatenated) |
| `--image <path>` | Output image path (required) |
| `--provider google\|openai\|dashscope\|replicate` | Force provider (default: google) |
| `--model <id>`, `-m` | Model ID (`--ref` with OpenAI requires GPT Image model, e.g. `gpt-image-1.5`) |
| `--model <id>`, `-m` | Model ID (Google: `gemini-3-pro-image-preview`, `gemini-3.1-flash-image-preview`; OpenAI: `gpt-image-1.5`) |
| `--ar <ratio>` | Aspect ratio (e.g., `16:9`, `1:1`, `4:3`) |
| `--size <WxH>` | Size (e.g., `1024x1024`) |
| `--quality normal\|2k` | Quality preset (default: 2k) |
| `--imageSize 1K\|2K\|4K` | Image size for Google (default: from quality) |
| `--ref <files...>` | Reference images. Supported by Google multimodal and OpenAI edits (GPT Image models). If provider omitted: Google first, then OpenAI |
| `--ref <files...>` | Reference images. Supported by Google multimodal (`gemini-3-pro-image-preview`, `gemini-3-flash-preview`, `gemini-3.1-flash-image-preview`) and OpenAI edits (GPT Image models). If provider omitted: Google first, then OpenAI |
| `--n <count>` | Number of images |
| `--json` | JSON output |
@@ -115,14 +110,22 @@ npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "A cat" --image out.png --provi
**Load Priority**: CLI args > EXTEND.md > env vars > `<cwd>/.baoyu-skills/.env` > `~/.baoyu-skills/.env`
## Replicate Model Configuration
## Model Resolution
When using `--provider replicate`, the model can be configured in the following ways (highest priority first):
Model priority (highest → lowest), applies to all providers:
1. CLI flag: `--model <owner/name>`
2. EXTEND.md: `default_model.replicate`
3. Env var: `REPLICATE_IMAGE_MODEL`
4. Built-in default: `google/nano-banana-pro`
1. CLI flag: `--model <id>`
2. EXTEND.md: `default_model.[provider]`
3. Env var: `<PROVIDER>_IMAGE_MODEL` (e.g., `GOOGLE_IMAGE_MODEL`)
4. Built-in default
**EXTEND.md overrides env vars**. If both EXTEND.md `default_model.google: "gemini-3-pro-image-preview"` and env var `GOOGLE_IMAGE_MODEL=gemini-3.1-flash-image-preview` exist, EXTEND.md wins.
**Agent MUST display model info** before each generation:
- Show: `Using [provider] / [model]`
- Show switch hint: `Switch model: --model <id> | EXTEND.md default_model.[provider] | env <PROVIDER>_IMAGE_MODEL`
### Replicate Models
Supported model formats:
@@ -194,7 +197,7 @@ Supported: `1:1`, `16:9`, `9:16`, `4:3`, `3:4`, `2.35:1`
- Missing API key → error with setup instructions
- Generation failure → auto-retry once
- Invalid aspect ratio → warning, proceed with default
- Reference images with unsupported provider/model → error with fix hint (switch to Google multimodal or OpenAI GPT Image edits)
- Reference images with unsupported provider/model → error with fix hint (switch to Google multimodal: `gemini-3-pro-image-preview`, `gemini-3.1-flash-image-preview`; or OpenAI GPT Image edits)
## Extension Support
@@ -0,0 +1,197 @@
---
name: first-time-setup
description: First-time setup and default model selection flow for baoyu-image-gen
---
# First-Time Setup
## Overview
Triggered when:
1. No EXTEND.md found → full setup (provider + model + preferences)
2. EXTEND.md found but `default_model.[provider]` is null → model selection only
## Setup Flow
```
No EXTEND.md found EXTEND.md found, model null
│ │
▼ ▼
┌─────────────────────┐ ┌──────────────────────┐
│ AskUserQuestion │ │ AskUserQuestion │
│ (full setup) │ │ (model only) │
└─────────────────────┘ └──────────────────────┘
│ │
▼ ▼
┌─────────────────────┐ ┌──────────────────────┐
│ Create EXTEND.md │ │ Update EXTEND.md │
└─────────────────────┘ └──────────────────────┘
│ │
▼ ▼
Continue Continue
```
## Flow 1: No EXTEND.md (Full Setup)
**Language**: Use user's input language or saved language preference.
Use AskUserQuestion with ALL questions in ONE call:
### Question 1: Default Provider
```yaml
header: "Provider"
question: "Default image generation provider?"
options:
- label: "Google (Recommended)"
description: "Gemini multimodal - high quality, reference images, flexible sizes"
- label: "OpenAI"
description: "GPT Image - consistent quality, reliable output"
- label: "DashScope"
description: "Alibaba Cloud - z-image-turbo, good for Chinese content"
- label: "Replicate"
description: "Community models - nano-banana-pro, flexible model selection"
```
### Question 2: Default Google Model
Only show if user selected Google or auto-detect (no explicit provider).
```yaml
header: "Google Model"
question: "Default Google image generation model?"
options:
- label: "gemini-3-pro-image-preview (Recommended)"
description: "Highest quality, best for production use"
- label: "gemini-3.1-flash-image-preview"
description: "Fast generation, good quality, lower cost"
- label: "gemini-3-flash-preview"
description: "Fast generation, balanced quality and speed"
```
### Question 3: Default Quality
```yaml
header: "Quality"
question: "Default image quality?"
options:
- label: "2k (Recommended)"
description: "2048px - covers, illustrations, infographics"
- label: "normal"
description: "1024px - quick previews, drafts"
```
### Question 4: Save Location
```yaml
header: "Save"
question: "Where to save preferences?"
options:
- label: "Project (Recommended)"
description: ".baoyu-skills/ (this project only)"
- label: "User"
description: "~/.baoyu-skills/ (all projects)"
```
### Save Locations
| Choice | Path | Scope |
|--------|------|-------|
| Project | `.baoyu-skills/baoyu-image-gen/EXTEND.md` | Current project |
| User | `$HOME/.baoyu-skills/baoyu-image-gen/EXTEND.md` | All projects |
### EXTEND.md Template
```yaml
---
version: 1
default_provider: [selected provider or null]
default_quality: [selected quality]
default_aspect_ratio: null
default_image_size: null
default_model:
google: [selected google model or null]
openai: null
dashscope: null
replicate: null
---
```
## Flow 2: EXTEND.md Exists, Model Null
When EXTEND.md exists but `default_model.[current_provider]` is null, ask ONLY the model question for the current provider.
### Google Model Selection
```yaml
header: "Google Model"
question: "Choose a default Google image generation model?"
options:
- label: "gemini-3-pro-image-preview (Recommended)"
description: "Highest quality, best for production use"
- label: "gemini-3.1-flash-image-preview"
description: "Fast generation, good quality, lower cost"
- label: "gemini-3-flash-preview"
description: "Fast generation, balanced quality and speed"
```
### OpenAI Model Selection
```yaml
header: "OpenAI Model"
question: "Choose a default OpenAI image generation model?"
options:
- label: "gpt-image-1.5 (Recommended)"
description: "Latest GPT Image model, high quality"
- label: "gpt-image-1"
description: "Previous generation GPT Image model"
```
### DashScope Model Selection
```yaml
header: "DashScope Model"
question: "Choose a default DashScope image generation model?"
options:
- label: "z-image-turbo (Recommended)"
description: "Fast generation, good quality"
- label: "z-image-ultra"
description: "Higher quality, slower generation"
```
### Replicate Model Selection
```yaml
header: "Replicate Model"
question: "Choose a default Replicate image generation model?"
options:
- label: "google/nano-banana-pro (Recommended)"
description: "Google's fast image model on Replicate"
- label: "google/nano-banana"
description: "Google's base image model on Replicate"
```
### Update EXTEND.md
After user selects a model:
1. Read existing EXTEND.md
2. If `default_model:` section exists → update the provider-specific key
3. If `default_model:` section missing → add the full section:
```yaml
default_model:
google: [value or null]
openai: [value or null]
dashscope: [value or null]
replicate: [value or null]
```
Only set the selected provider's model; leave others as their current value or null.
## After Setup
1. Create directory if needed
2. Write/update EXTEND.md with frontmatter
3. Confirm: "Preferences saved to [path]"
4. Continue with image generation
@@ -20,7 +20,7 @@ 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"
google: null # e.g., "gemini-3-pro-image-preview", "gemini-3.1-flash-image-preview"
openai: null # e.g., "gpt-image-1.5"
dashscope: null # e.g., "z-image-turbo"
replicate: null # e.g., "google/nano-banana-pro"
+1
View File
@@ -36,6 +36,7 @@ Environment variables:
DASHSCOPE_IMAGE_MODEL Default DashScope model (z-image-turbo)
REPLICATE_IMAGE_MODEL Default Replicate model (google/nano-banana-pro)
OPENAI_BASE_URL Custom OpenAI endpoint
OPENAI_IMAGE_USE_CHAT Use /chat/completions instead of /images/generations (true|false)
GOOGLE_BASE_URL Custom Google endpoint
DASHSCOPE_BASE_URL Custom DashScope endpoint
REPLICATE_BASE_URL Custom Replicate endpoint
@@ -1,9 +1,17 @@
import path from "node:path";
import { readFile } from "node:fs/promises";
import { execSync } from "node:child_process";
import type { CliArgs } from "../types";
const GOOGLE_MULTIMODAL_MODELS = ["gemini-3-pro-image-preview", "gemini-3-flash-preview"];
const GOOGLE_IMAGEN_MODELS = ["imagen-3.0-generate-002", "imagen-3.0-generate-001"];
const GOOGLE_MULTIMODAL_MODELS = [
"gemini-3-pro-image-preview",
"gemini-3-flash-preview",
"gemini-3.1-flash-image-preview",
];
const GOOGLE_IMAGEN_MODELS = [
"imagen-3.0-generate-002",
"imagen-3.0-generate-001",
];
export function getDefaultModel(): string {
return process.env.GOOGLE_IMAGE_MODEL || "gemini-3-pro-image-preview";
@@ -33,7 +41,8 @@ function getGoogleImageSize(args: CliArgs): "1K" | "2K" | "4K" {
}
function getGoogleBaseUrl(): string {
const base = process.env.GOOGLE_BASE_URL || "https://generativelanguage.googleapis.com";
const base =
process.env.GOOGLE_BASE_URL || "https://generativelanguage.googleapis.com";
return base.replace(/\/+$/g, "");
}
@@ -49,11 +58,46 @@ function toModelPath(model: string): string {
return `models/${modelId}`;
}
async function postGoogleJson<T>(pathname: string, body: unknown): Promise<T> {
const apiKey = getGoogleApiKey();
if (!apiKey) throw new Error("GOOGLE_API_KEY or GEMINI_API_KEY is required");
function getHttpProxy(): string | null {
return (
process.env.https_proxy ||
process.env.HTTPS_PROXY ||
process.env.http_proxy ||
process.env.HTTP_PROXY ||
process.env.ALL_PROXY ||
null
);
}
const res = await fetch(buildGoogleUrl(pathname), {
async function postGoogleJsonViaCurl<T>(
url: string,
apiKey: string,
body: unknown,
): Promise<T> {
const proxy = getHttpProxy();
const bodyStr = JSON.stringify(body);
const proxyArgs = proxy ? `-x "${proxy}"` : "";
const result = execSync(
`curl -s --connect-timeout 30 --max-time 300 ${proxyArgs} "${url}" -H "Content-Type: application/json" -H "x-goog-api-key: ${apiKey}" -d @-`,
{ input: bodyStr, maxBuffer: 100 * 1024 * 1024, timeout: 310000 },
);
const parsed = JSON.parse(result.toString()) as any;
if (parsed.error) {
throw new Error(
`Google API error (${parsed.error.code}): ${parsed.error.message}`,
);
}
return parsed as T;
}
async function postGoogleJsonViaFetch<T>(
url: string,
apiKey: string,
body: unknown,
): Promise<T> {
const res = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
@@ -70,7 +114,30 @@ async function postGoogleJson<T>(pathname: string, body: unknown): Promise<T> {
return (await res.json()) as T;
}
function buildPromptWithAspect(prompt: string, ar: string | null, quality: CliArgs["quality"]): string {
async function postGoogleJson<T>(pathname: string, body: unknown): Promise<T> {
const apiKey = getGoogleApiKey();
if (!apiKey) throw new Error("GOOGLE_API_KEY or GEMINI_API_KEY is required");
const url = buildGoogleUrl(pathname);
const proxy = getHttpProxy();
// When an HTTP proxy is detected, use curl instead of fetch.
// Bun's fetch has a known issue where long-lived connections through
// HTTP proxies get their sockets closed unexpectedly, causing image
// generation requests to fail with "socket connection was closed
// unexpectedly". Using curl as the HTTP client works around this.
if (proxy) {
return postGoogleJsonViaCurl<T>(url, apiKey, body);
}
return postGoogleJsonViaFetch<T>(url, apiKey, body);
}
function buildPromptWithAspect(
prompt: string,
ar: string | null,
quality: CliArgs["quality"],
): string {
let result = prompt;
if (ar) {
result += ` Aspect ratio: ${ar}.`;
@@ -86,7 +153,9 @@ function addAspectRatioToPrompt(prompt: string, ar: string | null): string {
return `${prompt} Aspect ratio: ${ar}.`;
}
async function readImageAsBase64(p: string): Promise<{ data: string; mimeType: string }> {
async function readImageAsBase64(
p: string,
): Promise<{ data: string; mimeType: string }> {
const buf = await readFile(p);
const ext = path.extname(p).toLowerCase();
let mimeType = "image/png";
@@ -97,7 +166,9 @@ async function readImageAsBase64(p: string): Promise<{ data: string; mimeType: s
}
function extractInlineImageData(response: {
candidates?: Array<{ content?: { parts?: Array<{ inlineData?: { data?: string } }> } }>;
candidates?: Array<{
content?: { parts?: Array<{ inlineData?: { data?: string } }> };
}>;
}): string | null {
for (const candidate of response.candidates || []) {
for (const part of candidate.content?.parts || []) {
@@ -112,16 +183,21 @@ function extractPredictedImageData(response: {
predictions?: Array<any>;
generatedImages?: Array<any>;
}): string | null {
const candidates = [...(response.predictions || []), ...(response.generatedImages || [])];
const candidates = [
...(response.predictions || []),
...(response.generatedImages || []),
];
for (const candidate of candidates) {
if (!candidate || typeof candidate !== "object") continue;
if (typeof candidate.imageBytes === "string") return candidate.imageBytes;
if (typeof candidate.bytesBase64Encoded === "string") return candidate.bytesBase64Encoded;
if (typeof candidate.bytesBase64Encoded === "string")
return candidate.bytesBase64Encoded;
if (typeof candidate.data === "string") return candidate.data;
const image = candidate.image;
if (image && typeof image === "object") {
if (typeof image.imageBytes === "string") return image.imageBytes;
if (typeof image.bytesBase64Encoded === "string") return image.bytesBase64Encoded;
if (typeof image.bytesBase64Encoded === "string")
return image.bytesBase64Encoded;
if (typeof image.data === "string") return image.data;
}
}
@@ -131,10 +207,13 @@ function extractPredictedImageData(response: {
async function generateWithGemini(
prompt: string,
model: string,
args: CliArgs
args: CliArgs,
): Promise<Uint8Array> {
const promptWithAspect = addAspectRatioToPrompt(prompt, args.aspectRatio);
const parts: Array<{ text?: string; inlineData?: { data: string; mimeType: string } }> = [];
const parts: Array<{
text?: string;
inlineData?: { data: string; mimeType: string };
}> = [];
for (const refPath of args.referenceImages) {
const { data, mimeType } = await readImageAsBase64(refPath);
parts.push({ inlineData: { data, mimeType } });
@@ -147,7 +226,9 @@ async function generateWithGemini(
console.log("Generating image with Gemini...", imageConfig);
const response = await postGoogleJson<{
candidates?: Array<{ content?: { parts?: Array<{ inlineData?: { data?: string } }> } }>;
candidates?: Array<{
content?: { parts?: Array<{ inlineData?: { data?: string } }> };
}>;
}>(`${toModelPath(model)}:generateContent`, {
contents: [
{
@@ -171,12 +252,18 @@ async function generateWithGemini(
async function generateWithImagen(
prompt: string,
model: string,
args: CliArgs
args: CliArgs,
): Promise<Uint8Array> {
const fullPrompt = buildPromptWithAspect(prompt, args.aspectRatio, args.quality);
const fullPrompt = buildPromptWithAspect(
prompt,
args.aspectRatio,
args.quality,
);
const imageSize = getGoogleImageSize(args);
if (imageSize === "4K") {
console.error("Warning: Imagen models do not support 4K imageSize, using 2K instead.");
console.error(
"Warning: Imagen models do not support 4K imageSize, using 2K instead.",
);
}
const parameters: Record<string, unknown> = {
@@ -212,12 +299,12 @@ async function generateWithImagen(
export async function generateImage(
prompt: string,
model: string,
args: CliArgs
args: CliArgs,
): Promise<Uint8Array> {
if (isGoogleImagen(model)) {
if (args.referenceImages.length > 0) {
throw new Error(
"Reference images are not supported with Imagen models. Use gemini-3-pro-image-preview or gemini-3-flash-preview."
"Reference images are not supported with Imagen models. Use gemini-3-pro-image-preview, gemini-3-flash-preview, or gemini-3.1-flash-image-preview.",
);
}
return generateWithImagen(prompt, model, args);
@@ -225,7 +312,7 @@ export async function generateImage(
if (!isGoogleMultimodal(model) && args.referenceImages.length > 0) {
throw new Error(
"Reference images are only supported with Gemini multimodal models. Use gemini-3-pro-image-preview or gemini-3-flash-preview."
"Reference images are only supported with Gemini multimodal models. Use gemini-3-pro-image-preview, gemini-3-flash-preview, or gemini-3.1-flash-image-preview.",
);
}
@@ -70,6 +70,10 @@ export async function generateImage(
if (!apiKey) throw new Error("OPENAI_API_KEY is required");
if (process.env.OPENAI_IMAGE_USE_CHAT === "true") {
return generateWithChatCompletions(baseURL, apiKey, prompt, model);
}
const size = args.size || getOpenAISize(model, args.aspectRatio, args.quality);
if (args.referenceImages.length > 0) {
@@ -84,6 +88,40 @@ export async function generateImage(
return generateWithOpenAIGenerations(baseURL, apiKey, prompt, model, size, args.quality);
}
async function generateWithChatCompletions(
baseURL: string,
apiKey: string,
prompt: string,
model: string
): Promise<Uint8Array> {
const res = await fetch(`${baseURL}/chat/completions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model,
messages: [{ role: "user", content: prompt }],
}),
});
if (!res.ok) {
const err = await res.text();
throw new Error(`OpenAI API error: ${err}`);
}
const result = (await res.json()) as { choices: Array<{ message: { content: string } }> };
const content = result.choices[0]?.message?.content ?? "";
const match = content.match(/data:image\/[^;]+;base64,([A-Za-z0-9+/=]+)/);
if (match) {
return Uint8Array.from(Buffer.from(match[1]!, "base64"));
}
throw new Error("No image found in chat completions response");
}
async function generateWithOpenAIGenerations(
baseURL: string,
apiKey: string,
+5 -1
View File
@@ -92,6 +92,8 @@ test -f "$HOME/.baoyu-skills/baoyu-post-to-wechat/EXTEND.md" && grep -o 'default
| `default` (Recommended) | 经典主题 - 传统排版,标题居中带底边,二级标题白字彩底 |
| `grace` | 优雅主题 - 文字阴影,圆角卡片,精致引用块 |
| `simple` | 简洁主题 - 现代极简风,不对称圆角,清爽留白 |
| `red` | 红色主题 - 红金配色,宋体排版,传统书法风格 |
| `orange` | 橙色主题 - 暖色调现代风,宽松行距,圆角装饰 |
### Step 2: Convert
@@ -113,7 +115,7 @@ npx -y bun ${SKILL_DIR}/scripts/main.ts <markdown_file> [options]
| Option | Description | Default |
|--------|-------------|---------|
| `--theme <name>` | Theme name (default, grace, simple) | default |
| `--theme <name>` | Theme name (default, grace, simple, red, orange) | default |
| `--title <title>` | Override title from frontmatter | |
| `--keep-title` | Keep the first heading in content | false (removed) |
| `--help` | Show help | |
@@ -169,6 +171,8 @@ npx -y bun ${SKILL_DIR}/scripts/main.ts article.md --title "My Article"
| `default` | 经典主题 - 传统排版,标题居中带底边,二级标题白字彩底 |
| `grace` | 优雅主题 - 文字阴影,圆角卡片,精致引用块 (by @brzhang) |
| `simple` | 简洁主题 - 现代极简风,不对称圆角,清爽留白 (by @okooo5km) |
| `red` | 红色主题 - 红金配色,宋体排版,传统书法风格 |
| `orange` | 橙色主题 - 暖色调现代风,宽松行距,圆角装饰 |
## Supported Markdown Features
@@ -1,6 +1,7 @@
#!/usr/bin/env npx tsx
import fs from "node:fs";
import { homedir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import frontMatter from "front-matter";
@@ -40,10 +41,47 @@ const EXTERNAL_THEME_DIR =
|| path.resolve(path.dirname(EXTERNAL_THEME_CONFIG_PATH), "theme-css");
const FALLBACK_THEMES: ThemeName[] = ["default", "grace", "simple"];
const DEFAULT_STYLE = {
const FONT_FAMILY_MAP: Record<string, string> = {
sans: `-apple-system-font,BlinkMacSystemFont, Helvetica Neue, PingFang SC, Hiragino Sans GB , Microsoft YaHei UI , Microsoft YaHei ,Arial,sans-serif`,
serif: `Optima-Regular, Optima, PingFangSC-light, PingFangTC-light, 'PingFang SC', Cambria, Cochin, Georgia, Times, 'Times New Roman', serif`,
mono: `Menlo, Monaco, 'Courier New', monospace`,
};
const FONT_SIZE_OPTIONS = ["14px", "15px", "16px", "17px", "18px"];
const CODE_BLOCK_THEMES = [
"1c-light", "a11y-dark", "a11y-light", "agate", "an-old-hope",
"androidstudio", "arduino-light", "arta", "ascetic",
"atom-one-dark-reasonable", "atom-one-dark", "atom-one-light",
"brown-paper", "codepen-embed", "color-brewer", "dark", "default",
"devibeans", "docco", "far", "felipec", "foundation",
"github-dark-dimmed", "github-dark", "github", "gml", "googlecode",
"gradient-dark", "gradient-light", "grayscale", "hybrid", "idea",
"intellij-light", "ir-black", "isbl-editor-dark", "isbl-editor-light",
"kimbie-dark", "kimbie-light", "lightfair", "lioshi", "magula",
"mono-blue", "monokai-sublime", "monokai", "night-owl", "nnfx-dark",
"nnfx-light", "nord", "obsidian", "panda-syntax-dark",
"panda-syntax-light", "paraiso-dark", "paraiso-light", "pojoaque",
"purebasic", "qtcreator-dark", "qtcreator-light", "rainbow", "routeros",
"school-book", "shades-of-purple", "srcery", "stackoverflow-dark",
"stackoverflow-light", "sunburst", "tokyo-night-dark", "tokyo-night-light",
"tomorrow-night-blue", "tomorrow-night-bright", "vs", "vs2015", "xcode",
"xt256",
];
const HLJS_CDN_BASE = "https://cdn-doocs.oss-cn-shenzhen.aliyuncs.com/npm/highlightjs/11.11.1";
interface StyleConfig {
primaryColor: string;
fontFamily: string;
fontSize: string;
foreground: string;
blockquoteBackground: string;
}
const DEFAULT_STYLE: StyleConfig = {
primaryColor: "#0F4C81",
fontFamily:
"-apple-system-font,BlinkMacSystemFont, Helvetica Neue, PingFang SC, Hiragino Sans GB , Microsoft YaHei UI , Microsoft YaHei ,Arial,sans-serif",
fontFamily: FONT_FAMILY_MAP.sans!,
fontSize: "16px",
foreground: "0 0% 3.9%",
blockquoteBackground: "#f7f7f7",
@@ -490,46 +528,133 @@ function printUsage(): void {
console.error(
[
"Usage:",
" npx tsx src/md/render.ts <markdown_file> [--theme <name>]",
" npx tsx src/md/render.ts <markdown_file> [options]",
"",
"Options:",
` --theme Theme name (${THEME_NAMES.join(", ")})`,
` --theme <name> Theme (${THEME_NAMES.join(", ")})`,
` --color <hex> Primary color (default: ${DEFAULT_STYLE.primaryColor})`,
` --font-family <name> Font: sans, serif, mono, or CSS value`,
` --font-size <N> Font size: ${FONT_SIZE_OPTIONS.join(", ")} (default: 16px)`,
` --code-theme <name> Code highlight theme (default: github)`,
` --mac-code-block Show Mac-style code block header`,
` --line-number Show line numbers in code blocks`,
` --cite Enable footnote citations`,
` --count Show reading time / word count`,
` --legend <value> Image caption: title-alt, alt-title, title, alt, none`,
` --keep-title Keep the first heading in output`,
].join("\n")
);
}
function parseArgValue(argv: string[], i: number, flag: string): string | null {
const arg = argv[i]!;
if (arg.includes("=")) {
return arg.slice(flag.length + 1);
}
const next = argv[i + 1];
return next ?? null;
}
function resolveFontFamily(value: string): string {
return FONT_FAMILY_MAP[value] ?? value;
}
function parseArgs(argv: string[]): CliOptions | null {
const ext = loadExtendConfig();
let inputPath = "";
let theme: ThemeName = "default";
let keepTitle = false;
let theme: ThemeName = ext.default_theme ?? "default";
let keepTitle = ext.keep_title ?? false;
let primaryColor = ext.default_color ?? DEFAULT_STYLE.primaryColor;
let fontFamily = ext.default_font_family ? resolveFontFamily(ext.default_font_family) : DEFAULT_STYLE.fontFamily;
let fontSize = ext.default_font_size ?? DEFAULT_STYLE.fontSize;
let codeTheme = ext.default_code_theme ?? "github";
let isMacCodeBlock = ext.mac_code_block ?? true;
let isShowLineNumber = ext.show_line_number ?? false;
let citeStatus = ext.cite ?? false;
let countStatus = ext.count ?? false;
let legend = ext.legend ?? "alt";
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
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;
}
if (arg === "--keep-title") { keepTitle = true; continue; }
if (arg === "--mac-code-block") { isMacCodeBlock = true; continue; }
if (arg === "--no-mac-code-block") { isMacCodeBlock = false; continue; }
if (arg === "--line-number") { isShowLineNumber = true; continue; }
if (arg === "--cite") { citeStatus = true; continue; }
if (arg === "--count") { countStatus = true; continue; }
if (arg === "--theme" || arg.startsWith("--theme=")) {
const val = parseArgValue(argv, i, "--theme");
if (!val) { console.error("Missing value for --theme"); return null; }
theme = val as ThemeName;
if (!arg.includes("=")) i += 1;
continue;
}
if (arg === "--color" || arg.startsWith("--color=")) {
const val = parseArgValue(argv, i, "--color");
if (!val) { console.error("Missing value for --color"); return null; }
primaryColor = val;
if (!arg.includes("=")) i += 1;
continue;
}
if (arg === "--font-family" || arg.startsWith("--font-family=")) {
const val = parseArgValue(argv, i, "--font-family");
if (!val) { console.error("Missing value for --font-family"); return null; }
fontFamily = resolveFontFamily(val);
if (!arg.includes("=")) i += 1;
continue;
}
if (arg === "--font-size" || arg.startsWith("--font-size=")) {
const val = parseArgValue(argv, i, "--font-size");
if (!val) { console.error("Missing value for --font-size"); return null; }
fontSize = val.endsWith("px") ? val : `${val}px`;
if (!FONT_SIZE_OPTIONS.includes(fontSize)) {
console.error(`Invalid font size: ${fontSize}. Valid: ${FONT_SIZE_OPTIONS.join(", ")}`);
return null;
}
if (!arg.includes("=")) i += 1;
continue;
}
if (arg === "--code-theme" || arg.startsWith("--code-theme=")) {
const val = parseArgValue(argv, i, "--code-theme");
if (!val) { console.error("Missing value for --code-theme"); return null; }
codeTheme = val;
if (!CODE_BLOCK_THEMES.includes(codeTheme)) {
console.error(`Unknown code theme: ${codeTheme}`);
return null;
}
if (!arg.includes("=")) i += 1;
continue;
}
if (arg === "--legend" || arg.startsWith("--legend=")) {
const val = parseArgValue(argv, i, "--legend");
if (!val) { console.error("Missing value for --legend"); return null; }
const valid = ["title-alt", "alt-title", "title", "alt", "none"];
if (!valid.includes(val)) {
console.error(`Invalid legend: ${val}. Valid: ${valid.join(", ")}`);
return null;
}
legend = val;
if (!arg.includes("=")) i += 1;
continue;
}
console.error(`Unknown argument: ${arg}`);
return null;
}
@@ -544,9 +669,8 @@ function parseArgs(argv: string[]): CliOptions | null {
}
return {
inputPath,
theme,
keepTitle,
inputPath, theme, keepTitle, primaryColor, fontFamily, fontSize,
codeTheme, isMacCodeBlock, isShowLineNumber, citeStatus, countStatus, legend,
};
}
@@ -554,6 +678,78 @@ interface CliOptions {
inputPath: string;
theme: ThemeName;
keepTitle: boolean;
primaryColor: string;
fontFamily: string;
fontSize: string;
codeTheme: string;
isMacCodeBlock: boolean;
isShowLineNumber: boolean;
citeStatus: boolean;
countStatus: boolean;
legend: string;
}
interface ExtendConfig {
default_theme: string | null;
default_color: string | null;
default_font_family: string | null;
default_font_size: string | null;
default_code_theme: string | null;
mac_code_block: boolean | null;
show_line_number: boolean | null;
cite: boolean | null;
count: boolean | null;
legend: string | null;
keep_title: boolean | null;
}
function extractYamlFrontMatter(content: string): string | null {
const match = content.match(/^---\s*\n([\s\S]*?)\n---\s*$/m);
return match ? match[1]! : null;
}
function parseExtendYaml(yaml: string): Partial<ExtendConfig> {
const config: Partial<ExtendConfig> = {};
for (const line of yaml.split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const colonIdx = trimmed.indexOf(":");
if (colonIdx < 0) continue;
const key = trimmed.slice(0, colonIdx).trim();
let value = trimmed.slice(colonIdx + 1).trim().replace(/^['"]|['"]$/g, "");
if (value === "null" || value === "") continue;
if (key === "default_theme") config.default_theme = value;
else if (key === "default_color") config.default_color = value;
else if (key === "default_font_family") config.default_font_family = value;
else if (key === "default_font_size") config.default_font_size = value.endsWith("px") ? value : `${value}px`;
else if (key === "default_code_theme") config.default_code_theme = value;
else if (key === "mac_code_block") config.mac_code_block = value === "true";
else if (key === "show_line_number") config.show_line_number = value === "true";
else if (key === "cite") config.cite = value === "true";
else if (key === "count") config.count = value === "true";
else if (key === "legend") config.legend = value;
else if (key === "keep_title") config.keep_title = value === "true";
}
return config;
}
function loadExtendConfig(): Partial<ExtendConfig> {
const paths = [
path.join(process.cwd(), ".baoyu-skills", "baoyu-markdown-to-html", "EXTEND.md"),
path.join(homedir(), ".baoyu-skills", "baoyu-markdown-to-html", "EXTEND.md"),
];
for (const p of paths) {
try {
const content = fs.readFileSync(p, "utf-8");
const yaml = extractYamlFrontMatter(content);
if (!yaml) continue;
return parseExtendYaml(yaml);
} catch {
continue;
}
}
return {};
}
function preprocessCjkEmphasis(markdown: string): string {
@@ -679,14 +875,14 @@ function loadThemeCss(theme: ThemeName): {
};
}
function buildCss(baseCss: string, themeCss: string): string {
function buildCss(baseCss: string, themeCss: string, style: StyleConfig = DEFAULT_STYLE): 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};
--md-primary-color: ${style.primaryColor};
--md-font-family: ${style.fontFamily};
--md-font-size: ${style.fontSize};
--foreground: ${style.foreground};
--blockquote-background: ${style.blockquoteBackground};
}
body {
@@ -708,13 +904,28 @@ function normalizeThemeCss(css: string): string {
return stripOutputScope(css);
}
async function fetchCodeThemeCss(themeName: string): Promise<string> {
const url = `${HLJS_CDN_BASE}/styles/${themeName}.min.css`;
try {
const res = await fetch(url);
if (!res.ok) {
console.error(`Failed to fetch code theme CSS: ${res.status} ${url}`);
return "";
}
return await res.text();
} catch (error) {
console.error(`Failed to fetch code theme CSS from ${url}:`, error);
return "";
}
}
interface HtmlDocumentMeta {
title: string;
author?: string;
description?: string;
}
function buildHtmlDocument(meta: HtmlDocumentMeta, css: string, html: string): string {
function buildHtmlDocument(meta: HtmlDocumentMeta, css: string, html: string, codeThemeCss?: string): string {
const lines = [
"<!doctype html>",
"<html>",
@@ -729,8 +940,11 @@ function buildHtmlDocument(meta: HtmlDocumentMeta, css: string, html: string): s
if (meta.description) {
lines.push(` <meta name="description" content="${meta.description}" />`);
}
lines.push(` <style>${css}</style>`);
if (codeThemeCss) {
lines.push(` <style>${codeThemeCss}</style>`);
}
lines.push(
` <style>${css}</style>`,
"</head>",
"<body>",
' <div id="output">',
@@ -758,12 +972,12 @@ async function inlineCss(html: string): Promise<string> {
}
}
function normalizeCssText(cssText: string): string {
function normalizeCssText(cssText: string, style: StyleConfig = DEFAULT_STYLE): 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(/var\(--md-primary-color\)/g, style.primaryColor)
.replace(/var\(--md-font-family\)/g, style.fontFamily)
.replace(/var\(--md-font-size\)/g, style.fontSize)
.replace(/var\(--blockquote-background\)/g, style.blockquoteBackground)
.replace(/hsl\(var\(--foreground\)\)/g, "#3f3f3f")
.replace(/--md-primary-color:\s*[^;"']+;?/g, "")
.replace(/--md-font-family:\s*[^;"']+;?/g, "")
@@ -772,20 +986,20 @@ function normalizeCssText(cssText: string): string {
.replace(/--foreground:\s*[^;"']+;?/g, "");
}
function normalizeInlineCss(html: string): string {
function normalizeInlineCss(html: string, style: StyleConfig = DEFAULT_STYLE): string {
let output = html;
output = output.replace(
/<style([^>]*)>([\s\S]*?)<\/style>/gi,
(_match, attrs: string, cssText: string) =>
`<style${attrs}>${normalizeCssText(cssText)}</style>`
`<style${attrs}>${normalizeCssText(cssText, style)}</style>`
);
output = output.replace(
/style="([^"]*)"/gi,
(_match, cssText: string) => `style="${normalizeCssText(cssText)}"`
(_match, cssText: string) => `style="${normalizeCssText(cssText, style)}"`
);
output = output.replace(
/style='([^']*)'/gi,
(_match, cssText: string) => `style='${normalizeCssText(cssText)}'`
(_match, cssText: string) => `style='${normalizeCssText(cssText, style)}'`
);
return output;
}
@@ -824,11 +1038,27 @@ async function main(): Promise<void> {
options.inputPath.replace(/\.md$/i, ".html")
);
const style: StyleConfig = {
...DEFAULT_STYLE,
primaryColor: options.primaryColor,
fontFamily: options.fontFamily,
fontSize: options.fontSize,
};
const { baseCss, themeCss } = loadThemeCss(options.theme);
const css = normalizeThemeCss(buildCss(baseCss, themeCss));
const css = normalizeThemeCss(buildCss(baseCss, themeCss, style));
const codeThemeCss = await fetchCodeThemeCss(options.codeTheme);
const markdown = fs.readFileSync(inputPath, "utf-8");
const renderer = initRenderer({});
const renderer = initRenderer({
legend: options.legend,
citeStatus: options.citeStatus,
countStatus: options.countStatus,
isMacCodeBlock: options.isMacCodeBlock,
isShowLineNumber: options.isShowLineNumber,
});
const { yamlData } = renderer.parseFrontMatterAndContent(markdown);
const { html: baseHtml, readingTime: readingTimeResult } = renderMarkdown(
markdown,
@@ -855,8 +1085,8 @@ async function main(): Promise<void> {
author: stripQuotes(yamlData.author),
description: stripQuotes(yamlData.description) || stripQuotes(yamlData.summary),
};
const html = buildHtmlDocument(meta, css, content);
const inlinedHtml = normalizeInlineCss(await inlineCss(html));
const html = buildHtmlDocument(meta, css, content, codeThemeCss);
const inlinedHtml = normalizeInlineCss(await inlineCss(html), style);
const finalHtml = modifyHtmlStructure(inlinedHtml);
let backupPath = "";
@@ -32,3 +32,8 @@ blockquote {
#output section > :first-child {
margin-top: 0 !important;
}
.mermaid-diagram .nodeLabel p {
color: unset !important;
letter-spacing: unset !important;
}
@@ -269,6 +269,7 @@ pre.code__pre,
padding: 0 !important;
line-height: 1.5;
margin: 10px 8px;
box-shadow: inset 0 0 10px rgba(0,0,0,0.05);
}
/* ==================== 图片 ==================== */
@@ -0,0 +1,181 @@
/**
* MD 橙色主题
* 暖色调现代风,橙色主色,宽松行距
*/
/* ==================== 容器样式覆盖 ==================== */
section,
container {
font-family: PingFang SC, system-ui, -apple-system, BlinkMacSystemFont, Helvetica Neue, Hiragino Sans GB, Microsoft YaHei UI, Microsoft YaHei, Arial, sans-serif;
font-size: 15px;
line-height: 2;
background-color: rgba(250, 249, 245, 1);
border: 1px solid rgba(255, 255, 255, 0.01);
border-radius: 25px;
padding: 12px 12px;
}
#output {
font-family: PingFang SC, system-ui, -apple-system, BlinkMacSystemFont, Helvetica Neue, Hiragino Sans GB, Microsoft YaHei UI, Microsoft YaHei, Arial, sans-serif;
font-size: 15px;
line-height: 2;
}
/* ==================== 一级标题 ==================== */
h1 {
display: table;
padding: 0.3em 1em;
margin: 20px auto;
color: #3B3B38;
background: #D97757;
border-radius: 15px;
font-size: 28px;
font-weight: bold;
text-align: center;
}
/* ==================== 二级标题 ==================== */
h2 {
display: block;
padding: 0.2em 0;
padding-bottom: 0;
margin: 0 auto 20px;
width: 100%;
color: #D97757;
font-size: 20px;
font-weight: bold;
border-bottom: 2px solid #E4B1A0;
}
/* ==================== 三级标题 ==================== */
h3 {
margin: 0 8px 10px;
color: #3B3B38;
font-size: 18px;
font-weight: bold;
}
/* ==================== 四级标题 ==================== */
h4 {
margin: 0 8px 10px;
color: #D97757;
font-size: 16px;
font-weight: bold;
}
/* ==================== 五级标题 ==================== */
h5 {
display: inline-block;
margin: 0 8px 10px;
padding: 4px 10px;
color: #3B3B38;
background: rgba(255, 255, 255, 0.7);
border: 1px solid rgb(189, 224, 254);
border-radius: 20px;
font-size: 16px;
font-weight: 500;
}
/* ==================== 六级标题 ==================== */
h6 {
margin: 0 8px 10px;
color: #D97757;
font-size: 16px;
font-weight: bold;
}
/* ==================== 段落 ==================== */
p {
margin: 20px 0;
color: #3B3B38;
line-height: 2;
letter-spacing: 0px;
font-size: 15px;
}
/* ==================== 引用块 ==================== */
blockquote {
font-style: normal;
padding: 15px 12px;
border-left: 7px solid rgba(228, 177, 160, 1);
border-radius: 10px;
color: #3B3B38;
background-color: rgba(255, 255, 255, 0.6);
margin: 12px 0;
}
blockquote > p {
color: #3B3B38;
font-size: 14px;
margin: 0;
}
/* ==================== 代码块 ==================== */
pre.code__pre,
.hljs.code__pre {
border-radius: 10px;
box-shadow: inset 0 0 10px rgba(0, 0, 0, 0.05);
}
/* ==================== 图片 ==================== */
img {
border-radius: 10px;
margin: 5px auto;
}
/* ==================== 列表 ==================== */
ol {
padding-left: 1em;
margin: 15px 0;
line-height: 2;
}
ul {
list-style: none;
padding-left: 0;
margin: 15px 0;
line-height: 2;
}
li {
margin: 0.2em 0;
color: #3B3B38;
font-size: 15px;
}
/* ==================== 分隔线 ==================== */
hr {
border-style: solid;
border-width: 1px 0 0;
border-color: #D97757;
margin: 0;
}
/* ==================== 强调 ==================== */
strong {
color: #3B3B38;
font-weight: bold;
}
/* ==================== 标记高亮 ==================== */
.markup-highlight {
background-color: #3B3B3B;
padding: 10px;
color: #FAF9F5;
}
.markup-underline {
text-decoration: underline;
text-decoration-color: #E4B1A0;
}
/* ==================== 链接 ==================== */
a {
color: #D97757;
text-decoration: none;
}
/* ==================== 表格 ==================== */
th {
background: rgba(217, 119, 87, 0.1);
}
@@ -0,0 +1,183 @@
/**
* MD 红色主题
* 传统书法风格,红金配色,宋体排版
*/
/* ==================== 容器字体覆盖 ==================== */
section,
container {
font-family: "Source Han Serif SC", "Noto Serif CJK SC", "Source Han Serif CN", STSong, SimSun, serif;
font-size: 16px;
line-height: 1.8;
background-color: rgba(255, 251, 240, 1);
border: 4px solid rgba(169, 50, 38, 1);
border-radius: 12px;
padding: 6px 12px;
}
#output {
font-family: "Source Han Serif SC", "Noto Serif CJK SC", "Source Han Serif CN", STSong, SimSun, serif;
font-size: 16px;
line-height: 1.8;
}
/* ==================== 一级标题 ==================== */
h1 {
display: table;
padding: 0 1em;
border-bottom: 2px solid #A93226;
margin: 30px auto 20px;
color: #A93226;
font-size: 24px;
font-weight: bold;
text-align: center;
}
/* ==================== 二级标题 ==================== */
h2 {
display: block;
width: fit-content;
padding: 6px 20px;
margin: 30px auto 20px;
color: #FFFFFF;
background-color: #A93226;
font-size: 18px;
font-weight: bold;
text-align: center;
border-radius: 8px;
box-shadow: rgba(0, 0, 0, 0.1) 0px 4px 6px;
line-height: 1.6em;
}
/* ==================== 三级标题 ==================== */
h3 {
padding-left: 8px;
border-left: 3px solid #A93226;
margin: 20px 8px 10px 0;
color: #A93226;
font-size: 17px;
font-weight: normal;
line-height: 1.2;
}
/* ==================== 四级标题 ==================== */
h4 {
margin: 10px 8px;
color: #D4AC0D;
font-size: 16px;
font-weight: normal;
}
/* ==================== 五级标题 ==================== */
h5 {
margin: 10px 8px;
color: #797D7F;
font-size: 15px;
font-weight: normal;
}
/* ==================== 六级标题 ==================== */
h6 {
margin: 10px 8px;
color: #797D7F;
font-size: 14px;
font-weight: normal;
}
/* ==================== 段落 ==================== */
p {
margin: 16px 0;
letter-spacing: 0.5px;
color: #2C2C2C;
line-height: 1.8;
}
/* ==================== 引用块 ==================== */
blockquote {
font-style: normal;
padding: 15px 12px;
border-left: 2px solid rgba(169, 50, 38, 0.8);
border-radius: 4px;
color: #5D4037;
background-color: rgba(253, 237, 236, 1);
margin: 12px 0;
}
blockquote > p {
color: #5D4037;
font-size: 14px;
margin: 0;
}
/* ==================== 代码块 ==================== */
pre.code__pre,
.hljs.code__pre {
font-size: 13px;
border: 1px solid #D4AC0D;
box-shadow: inset 0 0 10px rgba(0, 0, 0, 0.05);
}
/* ==================== 图片 ==================== */
img {
border-radius: 4px;
border: 1px solid #D4AC0D;
margin: 20px auto;
padding: 5px;
}
/* ==================== 列表 ==================== */
ol {
padding-left: 1em;
margin: 15px 0;
line-height: 1.6;
}
ul {
list-style: none;
padding-left: 0;
margin: 15px 0;
line-height: 1.6;
}
li {
margin: 0.2em 0;
color: #2C2C2C;
font-size: 16px;
}
/* ==================== 分隔线 ==================== */
hr {
border-style: solid;
border-width: 2px 0 0;
border-color: #D4AC0D;
margin: 40px 0;
}
/* ==================== 强调 ==================== */
strong {
color: #A93226;
font-weight: bold;
}
/* ==================== 标记高亮 ==================== */
.markup-highlight {
background-color: #FADBD8;
padding: 5px;
color: #A93226;
}
.markup-underline {
text-decoration: underline;
text-decoration-color: #D4AC0D;
}
/* ==================== 链接 ==================== */
a {
color: #A93226;
text-decoration: none;
}
/* ==================== 表格 ==================== */
th {
background: rgba(169, 50, 38, 0.1);
}