mirror of
https://github.com/JimLiu/baoyu-skills.git
synced 2026-07-13 22:29:48 +08:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a7d6b5c69b | |||
| 6e18725906 | |||
| 7133d54f04 | |||
| 208bb20e98 | |||
| 6edc1b7822 | |||
| e754eae5ae | |||
| cc016f3b16 | |||
| 2188160418 | |||
| 77204d2e4a | |||
| 35eeb62f10 | |||
| 7dfa2b6e6c | |||
| a2eecec26f | |||
| 903e7ad79d | |||
| 13707fa2cf | |||
| b56e503b16 | |||
| 7d03685ade | |||
| b305c386bc | |||
| 240dd7d314 | |||
| b02ceacfd9 | |||
| fdf9007e2c | |||
| e737c4a611 | |||
| 2eec4f3639 |
@@ -6,7 +6,7 @@
|
|||||||
},
|
},
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"description": "Skills shared by Baoyu for improving daily work efficiency",
|
"description": "Skills shared by Baoyu for improving daily work efficiency",
|
||||||
"version": "1.36.0"
|
"version": "1.42.0"
|
||||||
},
|
},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,6 +2,56 @@
|
|||||||
|
|
||||||
English | [中文](./CHANGELOG.zh.md)
|
English | [中文](./CHANGELOG.zh.md)
|
||||||
|
|
||||||
|
## 1.42.0 - 2026-02-28
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- `baoyu-markdown-to-html`: consolidate heritage and warm into single modern theme, add per-theme color defaults (default→blue, grace→purple, simple→green, modern→orange)
|
||||||
|
- `baoyu-post-to-wechat`: add default color preference support in EXTEND.md, add modern theme option to first-time setup
|
||||||
|
|
||||||
|
## 1.41.0 - 2026-02-28
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- `baoyu-markdown-to-html`: rename themes (red→heritage, orange→warm), add 13 named color presets, serif-cjk font family, and per-theme style defaults
|
||||||
|
|
||||||
|
## 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
|
## 1.36.0 - 2026-02-27
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|||||||
@@ -2,6 +2,56 @@
|
|||||||
|
|
||||||
[English](./CHANGELOG.md) | 中文
|
[English](./CHANGELOG.md) | 中文
|
||||||
|
|
||||||
|
## 1.42.0 - 2026-02-28
|
||||||
|
|
||||||
|
### 新功能
|
||||||
|
- `baoyu-markdown-to-html`:合并 heritage 和 warm 为 modern 主题,新增主题默认颜色(default→蓝、grace→紫、simple→绿、modern→橙)
|
||||||
|
- `baoyu-post-to-wechat`:EXTEND.md 新增默认颜色配置,首次设置增加 modern 主题和颜色选择
|
||||||
|
|
||||||
|
## 1.41.0 - 2026-02-28
|
||||||
|
|
||||||
|
### 新功能
|
||||||
|
- `baoyu-markdown-to-html`:重命名主题(red→heritage、orange→warm),新增 13 个颜色预设、serif-cjk 字体、主题级样式默认值
|
||||||
|
|
||||||
|
## 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
|
## 1.36.0 - 2026-02-27
|
||||||
|
|
||||||
### 新功能
|
### 新功能
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ test -f "$HOME/.baoyu-skills/baoyu-danger-gemini-web/EXTEND.md" && echo "user"
|
|||||||
```bash
|
```bash
|
||||||
# Text generation
|
# Text generation
|
||||||
npx -y bun ${SKILL_DIR}/scripts/main.ts "Your prompt"
|
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
|
# Image generation
|
||||||
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "A cute cat" --image cat.png
|
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 |
|
| `--prompt`, `-p` | Prompt text |
|
||||||
| `--promptfiles` | Read prompt from files (concatenated) |
|
| `--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) |
|
| `--image [path]` | Generate image (default: generated.png) |
|
||||||
| `--reference`, `--ref` | Reference images for vision input |
|
| `--reference`, `--ref` | Reference images for vision input |
|
||||||
| `--sessionId` | Session ID for multi-turn conversation |
|
| `--sessionId` | Session ID for multi-turn conversation |
|
||||||
@@ -114,9 +114,10 @@ npx -y bun ${SKILL_DIR}/scripts/main.ts "Hello" --json
|
|||||||
|
|
||||||
| Model | Description |
|
| Model | Description |
|
||||||
|-------|-------------|
|
|-------|-------------|
|
||||||
| `gemini-3-pro` | Default, latest |
|
| `gemini-3-pro` | Default, latest 3.0 Pro |
|
||||||
| `gemini-2.5-pro` | Previous pro |
|
| `gemini-3-flash` | Fast, lightweight 3.0 Flash |
|
||||||
| `gemini-2.5-flash` | Fast, lightweight |
|
| `gemini-3-flash-thinking` | 3.0 Flash with thinking |
|
||||||
|
| `gemini-3.1-pro-preview` | 3.1 Pro preview (empty header, auto-routed) |
|
||||||
|
|
||||||
## Authentication
|
## Authentication
|
||||||
|
|
||||||
|
|||||||
@@ -47,17 +47,22 @@ export class Model {
|
|||||||
static readonly UNSPECIFIED = new Model('unspecified', {}, false);
|
static readonly UNSPECIFIED = new Model('unspecified', {}, false);
|
||||||
static readonly G_3_0_PRO = new Model(
|
static readonly G_3_0_PRO = new Model(
|
||||||
'gemini-3.0-pro',
|
'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,
|
false,
|
||||||
);
|
);
|
||||||
static readonly G_2_5_PRO = new Model(
|
static readonly G_3_0_FLASH = new Model(
|
||||||
'gemini-2.5-pro',
|
'gemini-3.0-flash',
|
||||||
{ 'x-goog-ext-525001261-jspb': '[1,null,null,null,"4af6c7f5da75d65d",null,null,0,[4]]' },
|
{ 'x-goog-ext-525001261-jspb': '[1,null,null,null,"fbb127bbb056c959",null,null,0,[4],null,null,1]' },
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
static readonly G_2_5_FLASH = new Model(
|
static readonly G_3_0_FLASH_THINKING = new Model(
|
||||||
'gemini-2.5-flash',
|
'gemini-3.0-flash-thinking',
|
||||||
{ 'x-goog-ext-525001261-jspb': '[1,null,null,null,"9ec249fc9ad08861",null,null,0,[4]]' },
|
{ '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,
|
false,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -68,12 +73,12 @@ export class Model {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
static from_name(name: string): 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;
|
if (model.model_name === name) return model;
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(
|
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)
|
.map((m) => m.model_name)
|
||||||
.join(', ')}`,
|
.join(', ')}`,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ Multi-turn conversation (agent generates unique sessionId):
|
|||||||
Options:
|
Options:
|
||||||
-p, --prompt <text> Prompt text
|
-p, --prompt <text> Prompt text
|
||||||
--promptfiles <files...> Read prompt from one or more files (concatenated in order)
|
--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
|
--json Output JSON
|
||||||
--image [path] Generate an image and save it (default: ./generated.png)
|
--image [path] Generate an image and save it (default: ./generated.png)
|
||||||
--reference <files...> Reference images for vision input
|
--reference <files...> Reference images for vision input
|
||||||
@@ -227,8 +227,11 @@ function resolveModel(id: string): Model {
|
|||||||
const k = id.trim();
|
const k = id.trim();
|
||||||
if (k === 'gemini-3-pro') return Model.G_3_0_PRO;
|
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-3.0-pro') return Model.G_3_0_PRO;
|
||||||
if (k === 'gemini-2.5-pro') return Model.G_2_5_PRO;
|
if (k === 'gemini-3-flash') return Model.G_3_0_FLASH;
|
||||||
if (k === 'gemini-2.5-flash') return Model.G_2_5_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);
|
return Model.from_name(k);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -170,7 +170,7 @@ coverImage: "https://pbs.twimg.com/media/example.jpg"
|
|||||||
Content...
|
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:
|
When `--download-media` is enabled:
|
||||||
- Images are saved to `imgs/` next to the markdown file
|
- Images are saved to `imgs/` next to the markdown file
|
||||||
|
|||||||
@@ -2,11 +2,12 @@ import fs from "node:fs";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import readline from "node:readline";
|
import readline from "node:readline";
|
||||||
import process from "node:process";
|
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 { fetchXArticle } from "./graphql.js";
|
||||||
import { formatArticleMarkdown } from "./markdown.js";
|
import { formatArticleMarkdown } from "./markdown.js";
|
||||||
import { localizeMarkdownMedia, type LocalizeMarkdownMediaResult } from "./media-localizer.js";
|
import { localizeMarkdownMedia, type LocalizeMarkdownMediaResult } from "./media-localizer.js";
|
||||||
|
import { resolveReferencedTweetsFromArticle } from "./referenced-tweets.js";
|
||||||
import { hasRequiredXCookies, loadXCookies, refreshXCookies } from "./cookies.js";
|
import { hasRequiredXCookies, loadXCookies, refreshXCookies } from "./cookies.js";
|
||||||
import { resolveXToMarkdownConsentPath } from "./paths.js";
|
import { resolveXToMarkdownConsentPath } from "./paths.js";
|
||||||
import { tweetToMarkdown } from "./tweet-to-markdown.js";
|
import { tweetToMarkdown } from "./tweet-to-markdown.js";
|
||||||
@@ -182,36 +183,160 @@ function sanitizeSlug(input: string): string {
|
|||||||
.slice(0, 120);
|
.slice(0, 120);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatBackupTimestamp(date: Date = new Date()): string {
|
function extractContentSlug(markdown: string): string {
|
||||||
const pad2 = (n: number) => String(n).padStart(2, "0");
|
const headingMatch = markdown.match(/^#\s+(.+)$/m);
|
||||||
return `${date.getFullYear()}${pad2(date.getMonth() + 1)}${pad2(date.getDate())}-${pad2(date.getHours())}${pad2(
|
if (headingMatch?.[1]) {
|
||||||
date.getMinutes()
|
return sanitizeSlug(headingMatch[1].slice(0, 60)).toLowerCase();
|
||||||
)}${pad2(date.getSeconds())}`;
|
}
|
||||||
|
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 {
|
try {
|
||||||
if (!fs.existsSync(dir)) return;
|
return fs
|
||||||
const stat = fs.statSync(dir);
|
.readdirSync(dirPath)
|
||||||
if (!stat.isDirectory()) return;
|
.filter((name) => name.toLowerCase().endsWith(".md"))
|
||||||
const backup = `${dir}-backup-${formatBackupTimestamp()}`;
|
.map((name) => path.join(dirPath, name))
|
||||||
await rename(dir, backup);
|
.sort();
|
||||||
log(`[x-to-markdown] Existing directory moved to: ${backup}`);
|
} catch {
|
||||||
} catch (error) {
|
return [];
|
||||||
throw new Error(
|
|
||||||
`Failed to backup existing directory (${dir}): ${error instanceof Error ? error.message : String(error ?? "")}`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveDefaultOutputDir(slug: string): string {
|
function resolveExistingMarkdownPath(
|
||||||
return path.resolve(process.cwd(), "x-to-markdown", slug);
|
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(
|
async function resolveOutputPath(
|
||||||
normalizedUrl: string,
|
normalizedUrl: string,
|
||||||
kind: "tweet" | "article",
|
kind: "tweet" | "article",
|
||||||
argsOutput: string | null,
|
argsOutput: string | null,
|
||||||
|
contentSlug: string,
|
||||||
log: (message: string) => void
|
log: (message: string) => void
|
||||||
): Promise<{ outputDir: string; markdownPath: string; slug: string }> {
|
): Promise<{ outputDir: string; markdownPath: string; slug: string }> {
|
||||||
const articleId = kind === "article" ? parseArticleId(normalizedUrl) : null;
|
const articleId = kind === "article" ? parseArticleId(normalizedUrl) : null;
|
||||||
@@ -222,15 +347,14 @@ async function resolveOutputPath(
|
|||||||
const idPart = articleId ?? tweetId ?? String(Date.now());
|
const idPart = articleId ?? tweetId ?? String(Date.now());
|
||||||
const slug = userSlug ?? idPart;
|
const slug = userSlug ?? idPart;
|
||||||
|
|
||||||
const defaultFileName = kind === "article" ? `${idPart}.md` : `${idPart}.md`;
|
const defaultFileName = `${contentSlug}.md`;
|
||||||
|
|
||||||
if (argsOutput) {
|
if (argsOutput) {
|
||||||
const wantsDir = argsOutput.endsWith("/") || argsOutput.endsWith("\\");
|
const wantsDir = argsOutput.endsWith("/") || argsOutput.endsWith("\\");
|
||||||
const resolved = path.resolve(argsOutput);
|
const resolved = path.resolve(argsOutput);
|
||||||
try {
|
try {
|
||||||
if (wantsDir || (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory())) {
|
if (wantsDir || (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory())) {
|
||||||
const outputDir = path.join(resolved, slug);
|
const outputDir = path.join(resolved, slug, idPart);
|
||||||
await backupDirIfExists(outputDir, log);
|
|
||||||
await mkdir(outputDir, { recursive: true });
|
await mkdir(outputDir, { recursive: true });
|
||||||
return { outputDir, markdownPath: path.join(outputDir, defaultFileName), slug };
|
return { outputDir, markdownPath: path.join(outputDir, defaultFileName), slug };
|
||||||
}
|
}
|
||||||
@@ -243,8 +367,7 @@ async function resolveOutputPath(
|
|||||||
return { outputDir, markdownPath: resolved, slug };
|
return { outputDir, markdownPath: resolved, slug };
|
||||||
}
|
}
|
||||||
|
|
||||||
const outputDir = resolveDefaultOutputDir(slug);
|
const outputDir = path.resolve(process.cwd(), "x-to-markdown", slug, idPart);
|
||||||
await backupDirIfExists(outputDir, log);
|
|
||||||
await mkdir(outputDir, { recursive: true });
|
await mkdir(outputDir, { recursive: true });
|
||||||
return { outputDir, markdownPath: path.join(outputDir, defaultFileName), slug };
|
return { outputDir, markdownPath: path.join(outputDir, defaultFileName), slug };
|
||||||
}
|
}
|
||||||
@@ -355,7 +478,8 @@ async function convertArticleToMarkdown(
|
|||||||
|
|
||||||
log(`[x-to-markdown] Fetching article ${articleId}...`);
|
log(`[x-to-markdown] Fetching article ${articleId}...`);
|
||||||
const article = await fetchXArticle(articleId, cookieMap, false);
|
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 title = typeof (article as any)?.title === "string" ? String((article as any).title).trim() : "";
|
||||||
const meta = formatMetaMarkdown({
|
const meta = formatMetaMarkdown({
|
||||||
@@ -394,13 +518,67 @@ async function main(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const kind = articleId ? ("article" as const) : ("tweet" as const);
|
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 =
|
let markdown =
|
||||||
kind === "article" && articleId
|
kind === "article" && articleId
|
||||||
? await convertArticleToMarkdown(normalizedUrl, articleId, log)
|
? await convertArticleToMarkdown(normalizedUrl, articleId, log)
|
||||||
: await tweetToMarkdown(normalizedUrl, { 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;
|
let mediaResult: LocalizeMarkdownMediaResult | null = null;
|
||||||
|
|
||||||
if (args.downloadMedia) {
|
if (args.downloadMedia) {
|
||||||
|
|||||||
@@ -2,9 +2,22 @@ import type {
|
|||||||
ArticleBlock,
|
ArticleBlock,
|
||||||
ArticleContentState,
|
ArticleContentState,
|
||||||
ArticleEntity,
|
ArticleEntity,
|
||||||
|
ArticleEntityMapEntry,
|
||||||
ArticleMediaInfo,
|
ArticleMediaInfo,
|
||||||
} from "./types.js";
|
} 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 {
|
function coerceArticleEntity(value: unknown): ArticleEntity | null {
|
||||||
if (!value || typeof value !== "object") return null;
|
if (!value || typeof value !== "object") return null;
|
||||||
const candidate = value as ArticleEntity;
|
const candidate = value as ArticleEntity;
|
||||||
@@ -29,6 +42,73 @@ function normalizeCaption(caption?: string): string {
|
|||||||
return trimmed.replace(/\s+/g, " ");
|
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 {
|
function resolveMediaUrl(info?: ArticleMediaInfo): string | undefined {
|
||||||
if (!info) return undefined;
|
if (!info) return undefined;
|
||||||
if (info.original_img_url) return info.original_img_url;
|
if (info.original_img_url) return info.original_img_url;
|
||||||
@@ -79,11 +159,12 @@ function collectMediaUrls(
|
|||||||
function resolveEntityMediaLines(
|
function resolveEntityMediaLines(
|
||||||
entityKey: number | undefined,
|
entityKey: number | undefined,
|
||||||
entityMap: ArticleContentState["entityMap"] | undefined,
|
entityMap: ArticleContentState["entityMap"] | undefined,
|
||||||
|
entityLookup: EntityLookup,
|
||||||
mediaById: Map<string, string>,
|
mediaById: Map<string, string>,
|
||||||
usedUrls: Set<string>
|
usedUrls: Set<string>
|
||||||
): string[] {
|
): string[] {
|
||||||
if (entityKey === undefined || !entityMap) return [];
|
if (entityKey === undefined) return [];
|
||||||
const entry = entityMap[String(entityKey)];
|
const entry = resolveEntityEntry(entityKey, entityMap, entityLookup);
|
||||||
const value = entry?.value;
|
const value = entry?.value;
|
||||||
if (!value) return [];
|
if (!value) return [];
|
||||||
const type = value.type;
|
const type = value.type;
|
||||||
@@ -117,11 +198,142 @@ function resolveEntityMediaLines(
|
|||||||
return lines;
|
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(
|
function renderContentBlocks(
|
||||||
blocks: ArticleBlock[],
|
blocks: ArticleBlock[],
|
||||||
entityMap: ArticleContentState["entityMap"] | undefined,
|
entityMap: ArticleContentState["entityMap"] | undefined,
|
||||||
|
entityLookup: EntityLookup,
|
||||||
mediaById: Map<string, string>,
|
mediaById: Map<string, string>,
|
||||||
usedUrls: Set<string>
|
usedUrls: Set<string>,
|
||||||
|
mediaLinkMap: Map<number, string>,
|
||||||
|
referencedTweets?: Map<string, ReferencedTweetInfo>
|
||||||
): string[] {
|
): string[] {
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
let previousKind: "list" | "quote" | "heading" | "text" | "code" | "media" | null = null;
|
let previousKind: "list" | "quote" | "heading" | "text" | "code" | "media" | null = null;
|
||||||
@@ -150,14 +362,55 @@ function renderContentBlocks(
|
|||||||
const mediaLines: string[] = [];
|
const mediaLines: string[] = [];
|
||||||
for (const range of ranges) {
|
for (const range of ranges) {
|
||||||
if (typeof range?.key !== "number") continue;
|
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;
|
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) {
|
for (const block of blocks) {
|
||||||
const type = typeof block?.type === "string" ? block.type : "unstyled";
|
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 (type === "code-block") {
|
||||||
if (!inCodeBlock) {
|
if (!inCodeBlock) {
|
||||||
@@ -182,10 +435,22 @@ function renderContentBlocks(
|
|||||||
}
|
}
|
||||||
listKind = null;
|
listKind = null;
|
||||||
orderedIndex = 0;
|
orderedIndex = 0;
|
||||||
|
|
||||||
|
const tweetLines = collectTweetLines(block);
|
||||||
|
if (tweetLines.length > 0) {
|
||||||
|
pushBlock(tweetLines, "quote");
|
||||||
|
}
|
||||||
|
|
||||||
const mediaLines = collectMediaLines(block);
|
const mediaLines = collectMediaLines(block);
|
||||||
if (mediaLines.length > 0) {
|
if (mediaLines.length > 0) {
|
||||||
pushBlock(mediaLines, "media");
|
pushBlock(mediaLines, "media");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const linkLines = collectLinkLines(block);
|
||||||
|
if (linkLines.length > 0) {
|
||||||
|
pushBlock(linkLines, "text");
|
||||||
|
}
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,6 +464,7 @@ function renderContentBlocks(
|
|||||||
listKind = "unordered";
|
listKind = "unordered";
|
||||||
orderedIndex = 0;
|
orderedIndex = 0;
|
||||||
pushBlock([`- ${text}`], "list");
|
pushBlock([`- ${text}`], "list");
|
||||||
|
pushTrailingMedia(collectMediaLines(block));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,6 +475,7 @@ function renderContentBlocks(
|
|||||||
listKind = "ordered";
|
listKind = "ordered";
|
||||||
orderedIndex += 1;
|
orderedIndex += 1;
|
||||||
pushBlock([`${orderedIndex}. ${text}`], "list");
|
pushBlock([`${orderedIndex}. ${text}`], "list");
|
||||||
|
pushTrailingMedia(collectMediaLines(block));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,36 +485,43 @@ function renderContentBlocks(
|
|||||||
switch (type) {
|
switch (type) {
|
||||||
case "header-one":
|
case "header-one":
|
||||||
pushBlock([`# ${text}`], "heading");
|
pushBlock([`# ${text}`], "heading");
|
||||||
|
pushTrailingMedia(collectMediaLines(block));
|
||||||
break;
|
break;
|
||||||
case "header-two":
|
case "header-two":
|
||||||
pushBlock([`## ${text}`], "heading");
|
pushBlock([`## ${text}`], "heading");
|
||||||
|
pushTrailingMedia(collectMediaLines(block));
|
||||||
break;
|
break;
|
||||||
case "header-three":
|
case "header-three":
|
||||||
pushBlock([`### ${text}`], "heading");
|
pushBlock([`### ${text}`], "heading");
|
||||||
|
pushTrailingMedia(collectMediaLines(block));
|
||||||
break;
|
break;
|
||||||
case "header-four":
|
case "header-four":
|
||||||
pushBlock([`#### ${text}`], "heading");
|
pushBlock([`#### ${text}`], "heading");
|
||||||
|
pushTrailingMedia(collectMediaLines(block));
|
||||||
break;
|
break;
|
||||||
case "header-five":
|
case "header-five":
|
||||||
pushBlock([`##### ${text}`], "heading");
|
pushBlock([`##### ${text}`], "heading");
|
||||||
|
pushTrailingMedia(collectMediaLines(block));
|
||||||
break;
|
break;
|
||||||
case "header-six":
|
case "header-six":
|
||||||
pushBlock([`###### ${text}`], "heading");
|
pushBlock([`###### ${text}`], "heading");
|
||||||
|
pushTrailingMedia(collectMediaLines(block));
|
||||||
break;
|
break;
|
||||||
case "blockquote": {
|
case "blockquote": {
|
||||||
const quoteLines = text.length > 0 ? text.split("\n") : [""];
|
const quoteLines = text.length > 0 ? text.split("\n") : [""];
|
||||||
pushBlock(quoteLines.map((line) => `> ${line}`), "quote");
|
pushBlock(quoteLines.map((line) => `> ${line}`), "quote");
|
||||||
|
pushTrailingMedia(collectMediaLines(block));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
|
if (/^XIMGPH_\d+$/.test(text.trim())) {
|
||||||
|
pushTrailingMedia(collectMediaLines(block));
|
||||||
|
break;
|
||||||
|
}
|
||||||
pushBlock([text], "text");
|
pushBlock([text], "text");
|
||||||
|
pushTrailingMedia(collectMediaLines(block));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
const trailingMediaLines = collectMediaLines(block);
|
|
||||||
if (trailingMediaLines.length > 0) {
|
|
||||||
pushBlock(trailingMediaLines, "media");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (inCodeBlock) {
|
if (inCodeBlock) {
|
||||||
@@ -262,7 +536,28 @@ export type FormatArticleResult = {
|
|||||||
coverUrl: string | null;
|
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);
|
const candidate = coerceArticleEntity(article);
|
||||||
if (!candidate) {
|
if (!candidate) {
|
||||||
return { markdown: `\`\`\`json\n${JSON.stringify(article, null, 2)}\n\`\`\``, coverUrl: null };
|
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 blocks = candidate.content_state?.blocks;
|
||||||
const entityMap = candidate.content_state?.entityMap;
|
const entityMap = candidate.content_state?.entityMap;
|
||||||
|
const entityLookup = buildEntityLookup(entityMap);
|
||||||
if (Array.isArray(blocks) && blocks.length > 0) {
|
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 (rendered.length > 0) {
|
||||||
if (lines.length > 0) lines.push("");
|
if (lines.length > 0) lines.push("");
|
||||||
lines.push(...rendered);
|
lines.push(...rendered);
|
||||||
|
|||||||
@@ -187,11 +187,35 @@ function buildFileName(kind: MediaKind, index: number, sourceUrl: string, extens
|
|||||||
|
|
||||||
const FRONTMATTER_COVER_RE = /^(coverImage:\s*")(https?:\/\/[^"]+)(")/m;
|
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[] {
|
function collectMarkdownLinkCandidates(markdown: string): MarkdownLinkCandidate[] {
|
||||||
MARKDOWN_LINK_RE.lastIndex = 0;
|
|
||||||
const candidates: MarkdownLinkCandidate[] = [];
|
const candidates: MarkdownLinkCandidate[] = [];
|
||||||
const seen = new Set<string>();
|
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;
|
let match: RegExpExecArray | null;
|
||||||
while ((match = MARKDOWN_LINK_RE.exec(markdown))) {
|
while ((match = MARKDOWN_LINK_RE.exec(markdown))) {
|
||||||
const label = match[1] ?? "";
|
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;
|
return candidates;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,7 +274,8 @@ export async function localizeMarkdownMedia(
|
|||||||
|
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(candidate.url, {
|
const downloadUrl = toHighResUrl(candidate.url);
|
||||||
|
const response = await fetch(downloadUrl, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
redirect: "follow",
|
redirect: "follow",
|
||||||
headers: {
|
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 { hasRequiredXCookies, loadXCookies } from "./cookies.js";
|
||||||
import { fetchTweetThread } from "./thread.js";
|
import { fetchTweetThread } from "./thread.js";
|
||||||
import { formatArticleMarkdown } from "./markdown.js";
|
import { formatArticleMarkdown } from "./markdown.js";
|
||||||
|
import { resolveReferencedTweetsFromArticle } from "./referenced-tweets.js";
|
||||||
import { formatThreadTweetsMarkdown } from "./thread-markdown.js";
|
import { formatThreadTweetsMarkdown } from "./thread-markdown.js";
|
||||||
import { resolveArticleEntityFromTweet } from "./tweet-article.js";
|
import { resolveArticleEntityFromTweet } from "./tweet-article.js";
|
||||||
|
|
||||||
@@ -129,7 +130,8 @@ export async function tweetToMarkdown(
|
|||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
|
|
||||||
if (articleEntity) {
|
if (articleEntity) {
|
||||||
const articleResult = formatArticleMarkdown(articleEntity);
|
const referencedTweets = await resolveReferencedTweetsFromArticle(articleEntity, cookieMap, { log });
|
||||||
|
const articleResult = formatArticleMarkdown(articleEntity, { referencedTweets });
|
||||||
coverImage = articleResult.coverUrl;
|
coverImage = articleResult.coverUrl;
|
||||||
const articleMarkdown = articleResult.markdown.trimEnd();
|
const articleMarkdown = articleResult.markdown.trimEnd();
|
||||||
if (articleMarkdown) {
|
if (articleMarkdown) {
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ export type ArticleEntityMapEntry = {
|
|||||||
caption?: string;
|
caption?: string;
|
||||||
mediaItems?: ArticleEntityMapMediaItem[];
|
mediaItems?: ArticleEntityMapMediaItem[];
|
||||||
url?: string;
|
url?: string;
|
||||||
|
tweetId?: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -110,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`
|
**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>`
|
1. CLI flag: `--model <id>`
|
||||||
2. EXTEND.md: `default_model.replicate`
|
2. EXTEND.md: `default_model.[provider]`
|
||||||
3. Env var: `REPLICATE_IMAGE_MODEL`
|
3. Env var: `<PROVIDER>_IMAGE_MODEL` (e.g., `GOOGLE_IMAGE_MODEL`)
|
||||||
4. Built-in default: `google/nano-banana-pro`
|
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:
|
Supported model formats:
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ Environment variables:
|
|||||||
DASHSCOPE_IMAGE_MODEL Default DashScope model (z-image-turbo)
|
DASHSCOPE_IMAGE_MODEL Default DashScope model (z-image-turbo)
|
||||||
REPLICATE_IMAGE_MODEL Default Replicate model (google/nano-banana-pro)
|
REPLICATE_IMAGE_MODEL Default Replicate model (google/nano-banana-pro)
|
||||||
OPENAI_BASE_URL Custom OpenAI endpoint
|
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
|
GOOGLE_BASE_URL Custom Google endpoint
|
||||||
DASHSCOPE_BASE_URL Custom DashScope endpoint
|
DASHSCOPE_BASE_URL Custom DashScope endpoint
|
||||||
REPLICATE_BASE_URL Custom Replicate endpoint
|
REPLICATE_BASE_URL Custom Replicate endpoint
|
||||||
|
|||||||
@@ -70,6 +70,10 @@ export async function generateImage(
|
|||||||
|
|
||||||
if (!apiKey) throw new Error("OPENAI_API_KEY is required");
|
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);
|
const size = args.size || getOpenAISize(model, args.aspectRatio, args.quality);
|
||||||
|
|
||||||
if (args.referenceImages.length > 0) {
|
if (args.referenceImages.length > 0) {
|
||||||
@@ -84,6 +88,40 @@ export async function generateImage(
|
|||||||
return generateWithOpenAIGenerations(baseURL, apiKey, prompt, model, size, args.quality);
|
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(
|
async function generateWithOpenAIGenerations(
|
||||||
baseURL: string,
|
baseURL: string,
|
||||||
apiKey: string,
|
apiKey: string,
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ test -f "$HOME/.baoyu-skills/baoyu-post-to-wechat/EXTEND.md" && grep -o 'default
|
|||||||
| `default` (Recommended) | 经典主题 - 传统排版,标题居中带底边,二级标题白字彩底 |
|
| `default` (Recommended) | 经典主题 - 传统排版,标题居中带底边,二级标题白字彩底 |
|
||||||
| `grace` | 优雅主题 - 文字阴影,圆角卡片,精致引用块 |
|
| `grace` | 优雅主题 - 文字阴影,圆角卡片,精致引用块 |
|
||||||
| `simple` | 简洁主题 - 现代极简风,不对称圆角,清爽留白 |
|
| `simple` | 简洁主题 - 现代极简风,不对称圆角,清爽留白 |
|
||||||
|
| `modern` | 现代主题 - 大圆角、药丸形标题、宽松行距(搭配 `--color red` 还原传统红金风格) |
|
||||||
|
|
||||||
### Step 2: Convert
|
### Step 2: Convert
|
||||||
|
|
||||||
@@ -113,11 +114,32 @@ npx -y bun ${SKILL_DIR}/scripts/main.ts <markdown_file> [options]
|
|||||||
|
|
||||||
| Option | Description | Default |
|
| Option | Description | Default |
|
||||||
|--------|-------------|---------|
|
|--------|-------------|---------|
|
||||||
| `--theme <name>` | Theme name (default, grace, simple) | default |
|
| `--theme <name>` | Theme name (default, grace, simple, modern) | default |
|
||||||
|
| `--color <name\|hex>` | Primary color: preset name or hex value | theme default |
|
||||||
|
| `--font-family <name>` | Font: sans, serif, serif-cjk, mono, or CSS value | theme default |
|
||||||
|
| `--font-size <N>` | Font size: 14px, 15px, 16px, 17px, 18px | 16px |
|
||||||
| `--title <title>` | Override title from frontmatter | |
|
| `--title <title>` | Override title from frontmatter | |
|
||||||
| `--keep-title` | Keep the first heading in content | false (removed) |
|
| `--keep-title` | Keep the first heading in content | false (removed) |
|
||||||
| `--help` | Show help | |
|
| `--help` | Show help | |
|
||||||
|
|
||||||
|
**Color Presets:**
|
||||||
|
|
||||||
|
| Name | Hex | Label |
|
||||||
|
|------|-----|-------|
|
||||||
|
| blue | #0F4C81 | 经典蓝 |
|
||||||
|
| green | #009874 | 翡翠绿 |
|
||||||
|
| vermilion | #FA5151 | 活力橘 |
|
||||||
|
| yellow | #FECE00 | 柠檬黄 |
|
||||||
|
| purple | #92617E | 薰衣紫 |
|
||||||
|
| sky | #55C9EA | 天空蓝 |
|
||||||
|
| rose | #B76E79 | 玫瑰金 |
|
||||||
|
| olive | #556B2F | 橄榄绿 |
|
||||||
|
| black | #333333 | 石墨黑 |
|
||||||
|
| gray | #A9A9A9 | 雾烟灰 |
|
||||||
|
| pink | #FFB7C5 | 樱花粉 |
|
||||||
|
| red | #A93226 | 中国红 |
|
||||||
|
| orange | #D97757 | 暖橘 (modern default) |
|
||||||
|
|
||||||
**Examples:**
|
**Examples:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -127,6 +149,9 @@ npx -y bun ${SKILL_DIR}/scripts/main.ts article.md
|
|||||||
# With specific theme
|
# With specific theme
|
||||||
npx -y bun ${SKILL_DIR}/scripts/main.ts article.md --theme grace
|
npx -y bun ${SKILL_DIR}/scripts/main.ts article.md --theme grace
|
||||||
|
|
||||||
|
# Theme with custom color
|
||||||
|
npx -y bun ${SKILL_DIR}/scripts/main.ts article.md --theme modern --color red
|
||||||
|
|
||||||
# Keep the first heading in content
|
# Keep the first heading in content
|
||||||
npx -y bun ${SKILL_DIR}/scripts/main.ts article.md --keep-title
|
npx -y bun ${SKILL_DIR}/scripts/main.ts article.md --keep-title
|
||||||
|
|
||||||
@@ -169,6 +194,7 @@ npx -y bun ${SKILL_DIR}/scripts/main.ts article.md --title "My Article"
|
|||||||
| `default` | 经典主题 - 传统排版,标题居中带底边,二级标题白字彩底 |
|
| `default` | 经典主题 - 传统排版,标题居中带底边,二级标题白字彩底 |
|
||||||
| `grace` | 优雅主题 - 文字阴影,圆角卡片,精致引用块 (by @brzhang) |
|
| `grace` | 优雅主题 - 文字阴影,圆角卡片,精致引用块 (by @brzhang) |
|
||||||
| `simple` | 简洁主题 - 现代极简风,不对称圆角,清爽留白 (by @okooo5km) |
|
| `simple` | 简洁主题 - 现代极简风,不对称圆角,清爽留白 (by @okooo5km) |
|
||||||
|
| `modern` | 现代主题 - 大圆角、药丸形标题、宽松行距(搭配 `--color red` 还原传统红金风格) |
|
||||||
|
|
||||||
## Supported Markdown Features
|
## Supported Markdown Features
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
#!/usr/bin/env npx tsx
|
#!/usr/bin/env npx tsx
|
||||||
|
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
|
import { homedir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import frontMatter from "front-matter";
|
import frontMatter from "front-matter";
|
||||||
@@ -40,13 +41,91 @@ const EXTERNAL_THEME_DIR =
|
|||||||
|| path.resolve(path.dirname(EXTERNAL_THEME_CONFIG_PATH), "theme-css");
|
|| path.resolve(path.dirname(EXTERNAL_THEME_CONFIG_PATH), "theme-css");
|
||||||
const FALLBACK_THEMES: ThemeName[] = ["default", "grace", "simple"];
|
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`,
|
||||||
|
"serif-cjk": `"Source Han Serif SC", "Noto Serif CJK SC", "Source Han Serif CN", STSong, SimSun, serif`,
|
||||||
|
mono: `Menlo, Monaco, 'Courier New', monospace`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const FONT_SIZE_OPTIONS = ["14px", "15px", "16px", "17px", "18px"];
|
||||||
|
|
||||||
|
const COLOR_PRESETS: Record<string, string> = {
|
||||||
|
blue: "#0F4C81",
|
||||||
|
green: "#009874",
|
||||||
|
vermilion: "#FA5151",
|
||||||
|
yellow: "#FECE00",
|
||||||
|
purple: "#92617E",
|
||||||
|
sky: "#55C9EA",
|
||||||
|
rose: "#B76E79",
|
||||||
|
olive: "#556B2F",
|
||||||
|
black: "#333333",
|
||||||
|
gray: "#A9A9A9",
|
||||||
|
pink: "#FFB7C5",
|
||||||
|
red: "#A93226",
|
||||||
|
orange: "#D97757",
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
accentColor: string;
|
||||||
|
containerBg: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_STYLE: StyleConfig = {
|
||||||
primaryColor: "#0F4C81",
|
primaryColor: "#0F4C81",
|
||||||
fontFamily:
|
fontFamily: FONT_FAMILY_MAP.sans!,
|
||||||
"-apple-system-font,BlinkMacSystemFont, Helvetica Neue, PingFang SC, Hiragino Sans GB , Microsoft YaHei UI , Microsoft YaHei ,Arial,sans-serif",
|
|
||||||
fontSize: "16px",
|
fontSize: "16px",
|
||||||
foreground: "0 0% 3.9%",
|
foreground: "0 0% 3.9%",
|
||||||
blockquoteBackground: "#f7f7f7",
|
blockquoteBackground: "#f7f7f7",
|
||||||
|
accentColor: "#6B7280",
|
||||||
|
containerBg: "transparent",
|
||||||
|
};
|
||||||
|
|
||||||
|
const THEME_STYLE_DEFAULTS: Record<string, Partial<StyleConfig>> = {
|
||||||
|
default: {
|
||||||
|
primaryColor: COLOR_PRESETS.blue,
|
||||||
|
},
|
||||||
|
grace: {
|
||||||
|
primaryColor: COLOR_PRESETS.purple,
|
||||||
|
},
|
||||||
|
simple: {
|
||||||
|
primaryColor: COLOR_PRESETS.green,
|
||||||
|
},
|
||||||
|
modern: {
|
||||||
|
primaryColor: COLOR_PRESETS.orange,
|
||||||
|
accentColor: "#E4B1A0",
|
||||||
|
containerBg: "rgba(250, 249, 245, 1)",
|
||||||
|
fontFamily: FONT_FAMILY_MAP.sans,
|
||||||
|
fontSize: "15px",
|
||||||
|
blockquoteBackground: "rgba(255, 255, 255, 0.6)",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
Object.entries(COMMON_LANGUAGES).forEach(([name, lang]) => {
|
Object.entries(COMMON_LANGUAGES).forEach(([name, lang]) => {
|
||||||
@@ -490,46 +569,137 @@ function printUsage(): void {
|
|||||||
console.error(
|
console.error(
|
||||||
[
|
[
|
||||||
"Usage:",
|
"Usage:",
|
||||||
" npx tsx src/md/render.ts <markdown_file> [--theme <name>]",
|
" npx tsx src/md/render.ts <markdown_file> [options]",
|
||||||
"",
|
"",
|
||||||
"Options:",
|
"Options:",
|
||||||
` --theme Theme name (${THEME_NAMES.join(", ")})`,
|
` --theme <name> Theme (${THEME_NAMES.join(", ")})`,
|
||||||
|
` --color <name|hex> Primary color: ${Object.keys(COLOR_PRESETS).join(", ")}, or hex`,
|
||||||
|
` --font-family <name> Font: ${Object.keys(FONT_FAMILY_MAP).join(", ")}, 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")
|
].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 resolveColor(value: string): string {
|
||||||
|
return COLOR_PRESETS[value] ?? value;
|
||||||
|
}
|
||||||
|
|
||||||
function parseArgs(argv: string[]): CliOptions | null {
|
function parseArgs(argv: string[]): CliOptions | null {
|
||||||
|
const ext = loadExtendConfig();
|
||||||
|
|
||||||
let inputPath = "";
|
let inputPath = "";
|
||||||
let theme: ThemeName = "default";
|
let theme: ThemeName = ext.default_theme ?? "default";
|
||||||
let keepTitle = false;
|
let keepTitle = ext.keep_title ?? false;
|
||||||
|
let primaryColor: string | undefined = ext.default_color ? resolveColor(ext.default_color) : undefined;
|
||||||
|
let fontFamily: string | undefined = ext.default_font_family ? resolveFontFamily(ext.default_font_family) : undefined;
|
||||||
|
let fontSize: string | undefined = ext.default_font_size ?? undefined;
|
||||||
|
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) {
|
for (let i = 0; i < argv.length; i += 1) {
|
||||||
const arg = argv[i];
|
const arg = argv[i]!;
|
||||||
|
|
||||||
if (!arg.startsWith("--") && !inputPath) {
|
if (!arg.startsWith("--") && !inputPath) {
|
||||||
inputPath = arg;
|
inputPath = arg;
|
||||||
continue;
|
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") {
|
if (arg === "--help" || arg === "-h") {
|
||||||
return null;
|
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 = resolveColor(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}`);
|
console.error(`Unknown argument: ${arg}`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -544,9 +714,8 @@ function parseArgs(argv: string[]): CliOptions | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
inputPath,
|
inputPath, theme, keepTitle, primaryColor, fontFamily, fontSize,
|
||||||
theme,
|
codeTheme, isMacCodeBlock, isShowLineNumber, citeStatus, countStatus, legend,
|
||||||
keepTitle,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -554,6 +723,78 @@ interface CliOptions {
|
|||||||
inputPath: string;
|
inputPath: string;
|
||||||
theme: ThemeName;
|
theme: ThemeName;
|
||||||
keepTitle: boolean;
|
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 {
|
function preprocessCjkEmphasis(markdown: string): string {
|
||||||
@@ -679,14 +920,16 @@ function loadThemeCss(theme: ThemeName): {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildCss(baseCss: string, themeCss: string): string {
|
function buildCss(baseCss: string, themeCss: string, style: StyleConfig = DEFAULT_STYLE): string {
|
||||||
const variables = `
|
const variables = `
|
||||||
:root {
|
:root {
|
||||||
--md-primary-color: ${DEFAULT_STYLE.primaryColor};
|
--md-primary-color: ${style.primaryColor};
|
||||||
--md-font-family: ${DEFAULT_STYLE.fontFamily};
|
--md-font-family: ${style.fontFamily};
|
||||||
--md-font-size: ${DEFAULT_STYLE.fontSize};
|
--md-font-size: ${style.fontSize};
|
||||||
--foreground: ${DEFAULT_STYLE.foreground};
|
--foreground: ${style.foreground};
|
||||||
--blockquote-background: ${DEFAULT_STYLE.blockquoteBackground};
|
--blockquote-background: ${style.blockquoteBackground};
|
||||||
|
--md-accent-color: ${style.accentColor};
|
||||||
|
--md-container-bg: ${style.containerBg};
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@@ -708,13 +951,28 @@ function normalizeThemeCss(css: string): string {
|
|||||||
return stripOutputScope(css);
|
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 {
|
interface HtmlDocumentMeta {
|
||||||
title: string;
|
title: string;
|
||||||
author?: string;
|
author?: string;
|
||||||
description?: 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 = [
|
const lines = [
|
||||||
"<!doctype html>",
|
"<!doctype html>",
|
||||||
"<html>",
|
"<html>",
|
||||||
@@ -729,8 +987,11 @@ function buildHtmlDocument(meta: HtmlDocumentMeta, css: string, html: string): s
|
|||||||
if (meta.description) {
|
if (meta.description) {
|
||||||
lines.push(` <meta name="description" content="${meta.description}" />`);
|
lines.push(` <meta name="description" content="${meta.description}" />`);
|
||||||
}
|
}
|
||||||
|
lines.push(` <style>${css}</style>`);
|
||||||
|
if (codeThemeCss) {
|
||||||
|
lines.push(` <style>${codeThemeCss}</style>`);
|
||||||
|
}
|
||||||
lines.push(
|
lines.push(
|
||||||
` <style>${css}</style>`,
|
|
||||||
"</head>",
|
"</head>",
|
||||||
"<body>",
|
"<body>",
|
||||||
' <div id="output">',
|
' <div id="output">',
|
||||||
@@ -758,34 +1019,38 @@ async function inlineCss(html: string): Promise<string> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeCssText(cssText: string): string {
|
function normalizeCssText(cssText: string, style: StyleConfig = DEFAULT_STYLE): string {
|
||||||
return cssText
|
return cssText
|
||||||
.replace(/var\(--md-primary-color\)/g, DEFAULT_STYLE.primaryColor)
|
.replace(/var\(--md-primary-color\)/g, style.primaryColor)
|
||||||
.replace(/var\(--md-font-family\)/g, DEFAULT_STYLE.fontFamily)
|
.replace(/var\(--md-font-family\)/g, style.fontFamily)
|
||||||
.replace(/var\(--md-font-size\)/g, DEFAULT_STYLE.fontSize)
|
.replace(/var\(--md-font-size\)/g, style.fontSize)
|
||||||
.replace(/var\(--blockquote-background\)/g, DEFAULT_STYLE.blockquoteBackground)
|
.replace(/var\(--blockquote-background\)/g, style.blockquoteBackground)
|
||||||
|
.replace(/var\(--md-accent-color\)/g, style.accentColor)
|
||||||
|
.replace(/var\(--md-container-bg\)/g, style.containerBg)
|
||||||
.replace(/hsl\(var\(--foreground\)\)/g, "#3f3f3f")
|
.replace(/hsl\(var\(--foreground\)\)/g, "#3f3f3f")
|
||||||
.replace(/--md-primary-color:\s*[^;"']+;?/g, "")
|
.replace(/--md-primary-color:\s*[^;"']+;?/g, "")
|
||||||
.replace(/--md-font-family:\s*[^;"']+;?/g, "")
|
.replace(/--md-font-family:\s*[^;"']+;?/g, "")
|
||||||
.replace(/--md-font-size:\s*[^;"']+;?/g, "")
|
.replace(/--md-font-size:\s*[^;"']+;?/g, "")
|
||||||
.replace(/--blockquote-background:\s*[^;"']+;?/g, "")
|
.replace(/--blockquote-background:\s*[^;"']+;?/g, "")
|
||||||
|
.replace(/--md-accent-color:\s*[^;"']+;?/g, "")
|
||||||
|
.replace(/--md-container-bg:\s*[^;"']+;?/g, "")
|
||||||
.replace(/--foreground:\s*[^;"']+;?/g, "");
|
.replace(/--foreground:\s*[^;"']+;?/g, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeInlineCss(html: string): string {
|
function normalizeInlineCss(html: string, style: StyleConfig = DEFAULT_STYLE): string {
|
||||||
let output = html;
|
let output = html;
|
||||||
output = output.replace(
|
output = output.replace(
|
||||||
/<style([^>]*)>([\s\S]*?)<\/style>/gi,
|
/<style([^>]*)>([\s\S]*?)<\/style>/gi,
|
||||||
(_match, attrs: string, cssText: string) =>
|
(_match, attrs: string, cssText: string) =>
|
||||||
`<style${attrs}>${normalizeCssText(cssText)}</style>`
|
`<style${attrs}>${normalizeCssText(cssText, style)}</style>`
|
||||||
);
|
);
|
||||||
output = output.replace(
|
output = output.replace(
|
||||||
/style="([^"]*)"/gi,
|
/style="([^"]*)"/gi,
|
||||||
(_match, cssText: string) => `style="${normalizeCssText(cssText)}"`
|
(_match, cssText: string) => `style="${normalizeCssText(cssText, style)}"`
|
||||||
);
|
);
|
||||||
output = output.replace(
|
output = output.replace(
|
||||||
/style='([^']*)'/gi,
|
/style='([^']*)'/gi,
|
||||||
(_match, cssText: string) => `style='${normalizeCssText(cssText)}'`
|
(_match, cssText: string) => `style='${normalizeCssText(cssText, style)}'`
|
||||||
);
|
);
|
||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
@@ -824,11 +1089,29 @@ async function main(): Promise<void> {
|
|||||||
options.inputPath.replace(/\.md$/i, ".html")
|
options.inputPath.replace(/\.md$/i, ".html")
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const themeDefaults = THEME_STYLE_DEFAULTS[options.theme] ?? {};
|
||||||
|
const style: StyleConfig = {
|
||||||
|
...DEFAULT_STYLE,
|
||||||
|
...themeDefaults,
|
||||||
|
...(options.primaryColor !== undefined ? { primaryColor: options.primaryColor } : {}),
|
||||||
|
...(options.fontFamily !== undefined ? { fontFamily: options.fontFamily } : {}),
|
||||||
|
...(options.fontSize !== undefined ? { fontSize: options.fontSize } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
const { baseCss, themeCss } = loadThemeCss(options.theme);
|
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 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 { yamlData } = renderer.parseFrontMatterAndContent(markdown);
|
||||||
const { html: baseHtml, readingTime: readingTimeResult } = renderMarkdown(
|
const { html: baseHtml, readingTime: readingTimeResult } = renderMarkdown(
|
||||||
markdown,
|
markdown,
|
||||||
@@ -855,8 +1138,8 @@ async function main(): Promise<void> {
|
|||||||
author: stripQuotes(yamlData.author),
|
author: stripQuotes(yamlData.author),
|
||||||
description: stripQuotes(yamlData.description) || stripQuotes(yamlData.summary),
|
description: stripQuotes(yamlData.description) || stripQuotes(yamlData.summary),
|
||||||
};
|
};
|
||||||
const html = buildHtmlDocument(meta, css, content);
|
const html = buildHtmlDocument(meta, css, content, codeThemeCss);
|
||||||
const inlinedHtml = normalizeInlineCss(await inlineCss(html));
|
const inlinedHtml = normalizeInlineCss(await inlineCss(html), style);
|
||||||
const finalHtml = modifyHtmlStructure(inlinedHtml);
|
const finalHtml = modifyHtmlStructure(inlinedHtml);
|
||||||
|
|
||||||
let backupPath = "";
|
let backupPath = "";
|
||||||
|
|||||||
@@ -32,3 +32,8 @@ blockquote {
|
|||||||
#output section > :first-child {
|
#output section > :first-child {
|
||||||
margin-top: 0 !important;
|
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;
|
padding: 0 !important;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
margin: 10px 8px;
|
margin: 10px 8px;
|
||||||
|
box-shadow: inset 0 0 10px rgba(0,0,0,0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ==================== 图片 ==================== */
|
/* ==================== 图片 ==================== */
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
/**
|
||||||
|
* MD 现代主题 (modern)
|
||||||
|
* 大圆角、药丸形标题、宽松行距、现代感
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ==================== 容器样式覆盖 ==================== */
|
||||||
|
section,
|
||||||
|
container {
|
||||||
|
font-family: var(--md-font-family);
|
||||||
|
font-size: var(--md-font-size);
|
||||||
|
line-height: 2;
|
||||||
|
background-color: var(--md-container-bg);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.01);
|
||||||
|
border-radius: 25px;
|
||||||
|
padding: 12px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#output {
|
||||||
|
font-family: var(--md-font-family);
|
||||||
|
font-size: var(--md-font-size);
|
||||||
|
line-height: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 一级标题 ==================== */
|
||||||
|
h1 {
|
||||||
|
display: table;
|
||||||
|
padding: 0.3em 1em;
|
||||||
|
margin: 20px auto;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
background: var(--md-primary-color);
|
||||||
|
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: var(--md-primary-color);
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: bold;
|
||||||
|
border-bottom: 2px solid var(--md-accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 三级标题 ==================== */
|
||||||
|
h3 {
|
||||||
|
margin: 0 8px 10px;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 四级标题 ==================== */
|
||||||
|
h4 {
|
||||||
|
margin: 0 8px 10px;
|
||||||
|
color: var(--md-primary-color);
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 五级标题 ==================== */
|
||||||
|
h5 {
|
||||||
|
display: inline-block;
|
||||||
|
margin: 0 8px 10px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
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: var(--md-primary-color);
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 段落 ==================== */
|
||||||
|
p {
|
||||||
|
margin: 20px 0;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
line-height: 2;
|
||||||
|
letter-spacing: 0px;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 引用块 ==================== */
|
||||||
|
blockquote {
|
||||||
|
font-style: normal;
|
||||||
|
padding: 15px 12px;
|
||||||
|
border-left: 7px solid var(--md-accent-color);
|
||||||
|
border-radius: 10px;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
background-color: var(--blockquote-background);
|
||||||
|
margin: 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
blockquote > p {
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
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: hsl(var(--foreground));
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 分隔线 ==================== */
|
||||||
|
hr {
|
||||||
|
border-style: solid;
|
||||||
|
border-width: 1px 0 0;
|
||||||
|
border-color: var(--md-primary-color);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 强调 ==================== */
|
||||||
|
strong {
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 标记高亮 ==================== */
|
||||||
|
.markup-highlight {
|
||||||
|
background-color: hsl(var(--foreground));
|
||||||
|
padding: 10px;
|
||||||
|
color: var(--md-container-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markup-underline {
|
||||||
|
text-decoration: underline;
|
||||||
|
text-decoration-color: var(--md-accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 链接 ==================== */
|
||||||
|
a {
|
||||||
|
color: var(--md-primary-color);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 表格 ==================== */
|
||||||
|
th {
|
||||||
|
background: color-mix(in srgb, var(--md-primary-color) 10%, transparent);
|
||||||
|
}
|
||||||
@@ -48,7 +48,7 @@ test -f "$HOME/.baoyu-skills/baoyu-post-to-wechat/EXTEND.md" && echo "user"
|
|||||||
│ Not found │ Run first-time setup ([references/config/first-time-setup.md](references/config/first-time-setup.md)) → Save → Continue │
|
│ Not found │ Run first-time setup ([references/config/first-time-setup.md](references/config/first-time-setup.md)) → Save → Continue │
|
||||||
└───────────┴───────────────────────────────────────────────────────────────────────────┘
|
└───────────┴───────────────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
**EXTEND.md Supports**: Default theme | Default publishing method (api/browser) | Default author | Default open-comment switch | Default fans-only-comment switch | Chrome profile path
|
**EXTEND.md Supports**: Default theme | Default color | Default publishing method (api/browser) | Default author | Default open-comment switch | Default fans-only-comment switch | Chrome profile path
|
||||||
|
|
||||||
First-time setup: [references/config/first-time-setup.md](references/config/first-time-setup.md)
|
First-time setup: [references/config/first-time-setup.md](references/config/first-time-setup.md)
|
||||||
|
|
||||||
@@ -64,6 +64,7 @@ First-time setup: [references/config/first-time-setup.md](references/config/firs
|
|||||||
|
|
||||||
```md
|
```md
|
||||||
default_theme: default
|
default_theme: default
|
||||||
|
default_color: blue
|
||||||
default_publish_method: api
|
default_publish_method: api
|
||||||
default_author: 宝玉
|
default_author: 宝玉
|
||||||
need_open_comment: 1
|
need_open_comment: 1
|
||||||
@@ -71,6 +72,10 @@ only_fans_can_comment: 0
|
|||||||
chrome_profile_path: /path/to/chrome/profile
|
chrome_profile_path: /path/to/chrome/profile
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Theme options**: default, grace, simple, modern
|
||||||
|
|
||||||
|
**Color presets**: blue, green, vermilion, yellow, purple, sky, rose, olive, black, gray, pink, red, orange (or hex value)
|
||||||
|
|
||||||
**Value priority**:
|
**Value priority**:
|
||||||
1. CLI arguments
|
1. CLI arguments
|
||||||
2. Frontmatter
|
2. Frontmatter
|
||||||
@@ -134,6 +139,8 @@ Check and load EXTEND.md settings (see Preferences section above).
|
|||||||
**CRITICAL**: If not found, complete first-time setup BEFORE any other steps or questions.
|
**CRITICAL**: If not found, complete first-time setup BEFORE any other steps or questions.
|
||||||
|
|
||||||
Resolve and store these defaults for later steps:
|
Resolve and store these defaults for later steps:
|
||||||
|
- `default_theme` (default `default`)
|
||||||
|
- `default_color` (omit if not set — theme default applies)
|
||||||
- `default_author`
|
- `default_author`
|
||||||
- `need_open_comment` (default `1`)
|
- `need_open_comment` (default `1`)
|
||||||
- `only_fans_can_comment` (default `0`)
|
- `only_fans_can_comment` (default `0`)
|
||||||
@@ -201,13 +208,18 @@ B) Continue - provide HTML file manually
|
|||||||
- EXTEND.md `default_theme` (loaded in Step 0)
|
- EXTEND.md `default_theme` (loaded in Step 0)
|
||||||
- Fallback: `default`
|
- Fallback: `default`
|
||||||
|
|
||||||
2. **Execute conversion** (using the discovered skill), **always pass `--theme`**:
|
2. **Resolve color** (first match wins):
|
||||||
|
- CLI `--color` argument
|
||||||
|
- EXTEND.md `default_color` (loaded in Step 0)
|
||||||
|
- Omit if not set (theme default applies)
|
||||||
|
|
||||||
|
3. **Execute conversion** (using the discovered skill), **always pass `--theme`**:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npx -y bun ${MD_TO_HTML_SKILL_DIR}/scripts/main.ts <markdown_file> --theme <theme>
|
npx -y bun ${MD_TO_HTML_SKILL_DIR}/scripts/main.ts <markdown_file> --theme <theme> [--color <color>]
|
||||||
```
|
```
|
||||||
|
|
||||||
**CRITICAL**: Always include `--theme` parameter. Never omit it, even if using `default`.
|
**CRITICAL**: Always include `--theme` parameter. Never omit it, even if using `default`. Only include `--color` if explicitly set by user or EXTEND.md.
|
||||||
|
|
||||||
3. **Parse JSON output** to get: `htmlPath`, `title`, `author`, `summary`, `contentImages`
|
3. **Parse JSON output** to get: `htmlPath`, `title`, `author`, `summary`, `contentImages`
|
||||||
|
|
||||||
@@ -307,7 +319,7 @@ WeChat Publishing Complete!
|
|||||||
|
|
||||||
Input: [type] - [path]
|
Input: [type] - [path]
|
||||||
Method: API
|
Method: API
|
||||||
Theme: [theme name]
|
Theme: [theme name] [color if set]
|
||||||
|
|
||||||
Article:
|
Article:
|
||||||
• Title: [title]
|
• Title: [title]
|
||||||
@@ -334,7 +346,7 @@ WeChat Publishing Complete!
|
|||||||
|
|
||||||
Input: [type] - [path]
|
Input: [type] - [path]
|
||||||
Method: Browser
|
Method: Browser
|
||||||
Theme: [theme name]
|
Theme: [theme name] [color if set]
|
||||||
|
|
||||||
Article:
|
Article:
|
||||||
• Title: [title]
|
• Title: [title]
|
||||||
|
|||||||
@@ -20,9 +20,9 @@ npx -y bun ./scripts/wechat-article.ts --markdown article.md --author "作者名
|
|||||||
| Parameter | Description |
|
| Parameter | Description |
|
||||||
|-----------|-------------|
|
|-----------|-------------|
|
||||||
| `--markdown <path>` | Markdown file to convert and post |
|
| `--markdown <path>` | Markdown file to convert and post |
|
||||||
| `--theme <name>` | Theme: default, grace, or simple |
|
| `--theme <name>` | Theme: default, grace, simple, modern |
|
||||||
| `--title <text>` | Override title (auto-extracted from markdown) |
|
| `--title <text>` | Override title (auto-extracted from markdown) |
|
||||||
| `--author <name>` | Author name (default: 宝玉) |
|
| `--author <name>` | Author name |
|
||||||
| `--summary <text>` | Article summary |
|
| `--summary <text>` | Article summary |
|
||||||
| `--html <path>` | Pre-rendered HTML file (alternative to markdown) |
|
| `--html <path>` | Pre-rendered HTML file (alternative to markdown) |
|
||||||
| `--profile <dir>` | Chrome profile directory |
|
| `--profile <dir>` | Chrome profile directory |
|
||||||
|
|||||||
@@ -49,14 +49,34 @@ header: "Theme"
|
|||||||
question: "Default theme for article conversion?"
|
question: "Default theme for article conversion?"
|
||||||
options:
|
options:
|
||||||
- label: "default (Recommended)"
|
- label: "default (Recommended)"
|
||||||
description: "Classic layout - centered title with border, white-on-color H2"
|
description: "Classic layout - centered title with border, white-on-color H2 (default: blue)"
|
||||||
- label: "grace"
|
- label: "grace"
|
||||||
description: "Elegant - text shadows, rounded cards, refined blockquotes"
|
description: "Elegant - text shadows, rounded cards, refined blockquotes (default: purple)"
|
||||||
- label: "simple"
|
- label: "simple"
|
||||||
description: "Minimal modern - asymmetric rounded corners, clean whitespace"
|
description: "Minimal modern - asymmetric rounded corners, clean whitespace (default: green)"
|
||||||
|
- label: "modern"
|
||||||
|
description: "Large rounded corners, pill headings, spacious (default: orange)"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Question 2: Default Publishing Method
|
### Question 2: Default Color
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
header: "Color"
|
||||||
|
question: "Default color preset? (theme default if not set)"
|
||||||
|
options:
|
||||||
|
- label: "Theme default (Recommended)"
|
||||||
|
description: "Use the theme's built-in default color"
|
||||||
|
- label: "blue"
|
||||||
|
description: "#0F4C81 经典蓝"
|
||||||
|
- label: "red"
|
||||||
|
description: "#A93226 中国红"
|
||||||
|
- label: "green"
|
||||||
|
description: "#009874 翡翠绿"
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: User can choose "Other" to type any preset name (vermilion, yellow, purple, sky, rose, olive, black, gray, pink, orange) or hex value.
|
||||||
|
|
||||||
|
### Question 3: Default Publishing Method
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
header: "Method"
|
header: "Method"
|
||||||
@@ -68,7 +88,7 @@ options:
|
|||||||
description: "Slow, requires Chrome and login session"
|
description: "Slow, requires Chrome and login session"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Question 3: Default Author
|
### Question 4: Default Author
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
header: "Author"
|
header: "Author"
|
||||||
@@ -80,7 +100,7 @@ options:
|
|||||||
|
|
||||||
Note: User will likely choose "Other" to type their author name.
|
Note: User will likely choose "Other" to type their author name.
|
||||||
|
|
||||||
### Question 4: Open Comments
|
### Question 5: Open Comments
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
header: "Comments"
|
header: "Comments"
|
||||||
@@ -92,7 +112,7 @@ options:
|
|||||||
description: "Disable comments by default"
|
description: "Disable comments by default"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Question 5: Fans-Only Comments
|
### Question 6: Fans-Only Comments
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
header: "Fans only"
|
header: "Fans only"
|
||||||
@@ -104,7 +124,7 @@ options:
|
|||||||
description: "Only followers can comment"
|
description: "Only followers can comment"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Question 6: Save Location
|
### Question 7: Save Location
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
header: "Save"
|
header: "Save"
|
||||||
@@ -133,7 +153,8 @@ options:
|
|||||||
## EXTEND.md Template
|
## EXTEND.md Template
|
||||||
|
|
||||||
```md
|
```md
|
||||||
default_theme: [default/grace/simple]
|
default_theme: [default/grace/simple/modern]
|
||||||
|
default_color: [preset name, hex, or empty for theme default]
|
||||||
default_publish_method: [api/browser]
|
default_publish_method: [api/browser]
|
||||||
default_author: [author name or empty]
|
default_author: [author name or empty]
|
||||||
need_open_comment: [1/0]
|
need_open_comment: [1/0]
|
||||||
|
|||||||
@@ -673,8 +673,8 @@ Options:
|
|||||||
--content <text> Article content (use with --image)
|
--content <text> Article content (use with --image)
|
||||||
--html <path> HTML file to paste (alternative to --content)
|
--html <path> HTML file to paste (alternative to --content)
|
||||||
--markdown <path> Markdown file to convert and post (recommended)
|
--markdown <path> Markdown file to convert and post (recommended)
|
||||||
--theme <name> Theme for markdown (default, grace, simple)
|
--theme <name> Theme for markdown (default, grace, simple, modern)
|
||||||
--author <name> Author name (default: 宝玉)
|
--author <name> Author name
|
||||||
--summary <text> Article summary
|
--summary <text> Article summary
|
||||||
--image <path> Content image, can repeat (only with --content)
|
--image <path> Content image, can repeat (only with --content)
|
||||||
--submit Save as draft
|
--submit Save as draft
|
||||||
|
|||||||
Reference in New Issue
Block a user