Compare commits

...

13 Commits

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

Closes #50

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 19:40:50 +08:00
21 changed files with 1296 additions and 114 deletions
+1 -1
View File
@@ -6,7 +6,7 @@
},
"metadata": {
"description": "Skills shared by Baoyu for improving daily work efficiency",
"version": "1.37.0"
"version": "1.40.0"
},
"plugins": [
{
+27
View File
@@ -2,6 +2,33 @@
English | [中文](./CHANGELOG.zh.md)
## 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
+27
View File
@@ -2,6 +2,33 @@
[English](./CHANGELOG.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
### 新功能
+6 -5
View File
@@ -76,7 +76,7 @@ test -f "$HOME/.baoyu-skills/baoyu-danger-gemini-web/EXTEND.md" && echo "user"
```bash
# Text generation
npx -y bun ${SKILL_DIR}/scripts/main.ts "Your prompt"
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "Your prompt" --model gemini-2.5-pro
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "Your prompt" --model gemini-3-flash
# Image generation
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "A cute cat" --image cat.png
@@ -100,7 +100,7 @@ npx -y bun ${SKILL_DIR}/scripts/main.ts "Hello" --json
|--------|-------------|
| `--prompt`, `-p` | Prompt text |
| `--promptfiles` | Read prompt from files (concatenated) |
| `--model`, `-m` | Model: gemini-3-pro (default), gemini-2.5-pro, gemini-2.5-flash |
| `--model`, `-m` | Model: gemini-3-pro (default), gemini-3-flash, gemini-3-flash-thinking, gemini-3.1-pro-preview |
| `--image [path]` | Generate image (default: generated.png) |
| `--reference`, `--ref` | Reference images for vision input |
| `--sessionId` | Session ID for multi-turn conversation |
@@ -114,9 +114,10 @@ npx -y bun ${SKILL_DIR}/scripts/main.ts "Hello" --json
| Model | Description |
|-------|-------------|
| `gemini-3-pro` | Default, latest |
| `gemini-2.5-pro` | Previous pro |
| `gemini-2.5-flash` | Fast, lightweight |
| `gemini-3-pro` | Default, latest 3.0 Pro |
| `gemini-3-flash` | Fast, lightweight 3.0 Flash |
| `gemini-3-flash-thinking` | 3.0 Flash with thinking |
| `gemini-3.1-pro-preview` | 3.1 Pro preview (empty header, auto-routed) |
## Authentication
@@ -47,17 +47,22 @@ export class Model {
static readonly UNSPECIFIED = new Model('unspecified', {}, false);
static readonly G_3_0_PRO = new Model(
'gemini-3.0-pro',
{ 'x-goog-ext-525001261-jspb': '[1,null,null,null,"9d8ca3786ebdfbea",null,null,0,[4]]' },
{ 'x-goog-ext-525001261-jspb': '[1,null,null,null,"9d8ca3786ebdfbea",null,null,0,[4],null,null,1]' },
false,
);
static readonly G_2_5_PRO = new Model(
'gemini-2.5-pro',
{ 'x-goog-ext-525001261-jspb': '[1,null,null,null,"4af6c7f5da75d65d",null,null,0,[4]]' },
static readonly G_3_0_FLASH = new Model(
'gemini-3.0-flash',
{ 'x-goog-ext-525001261-jspb': '[1,null,null,null,"fbb127bbb056c959",null,null,0,[4],null,null,1]' },
false,
);
static readonly G_2_5_FLASH = new Model(
'gemini-2.5-flash',
{ 'x-goog-ext-525001261-jspb': '[1,null,null,null,"9ec249fc9ad08861",null,null,0,[4]]' },
static readonly G_3_0_FLASH_THINKING = new Model(
'gemini-3.0-flash-thinking',
{ 'x-goog-ext-525001261-jspb': '[1,null,null,null,"5bf011840784117a",null,null,0,[4],null,null,1]' },
false,
);
static readonly G_3_1_PRO_PREVIEW = new Model(
'gemini-3.1-pro-preview',
{},
false,
);
@@ -68,12 +73,12 @@ export class Model {
) {}
static from_name(name: string): Model {
for (const model of [Model.UNSPECIFIED, Model.G_3_0_PRO, Model.G_2_5_PRO, Model.G_2_5_FLASH]) {
for (const model of [Model.UNSPECIFIED, Model.G_3_0_PRO, Model.G_3_0_FLASH, Model.G_3_0_FLASH_THINKING, Model.G_3_1_PRO_PREVIEW]) {
if (model.model_name === name) return model;
}
throw new Error(
`Unknown model name: ${name}. Available models: ${[Model.UNSPECIFIED, Model.G_3_0_PRO, Model.G_2_5_PRO, Model.G_2_5_FLASH]
`Unknown model name: ${name}. Available models: ${[Model.UNSPECIFIED, Model.G_3_0_PRO, Model.G_3_0_FLASH, Model.G_3_0_FLASH_THINKING, Model.G_3_1_PRO_PREVIEW]
.map((m) => m.model_name)
.join(', ')}`,
);
@@ -73,7 +73,7 @@ Multi-turn conversation (agent generates unique sessionId):
Options:
-p, --prompt <text> Prompt text
--promptfiles <files...> Read prompt from one or more files (concatenated in order)
-m, --model <id> gemini-3-pro | gemini-2.5-pro | gemini-2.5-flash (default: gemini-3-pro)
-m, --model <id> gemini-3-pro | gemini-3-flash | gemini-3-flash-thinking | gemini-3.1-pro-preview (default: gemini-3-pro)
--json Output JSON
--image [path] Generate an image and save it (default: ./generated.png)
--reference <files...> Reference images for vision input
@@ -227,8 +227,11 @@ function resolveModel(id: string): Model {
const k = id.trim();
if (k === 'gemini-3-pro') return Model.G_3_0_PRO;
if (k === 'gemini-3.0-pro') return Model.G_3_0_PRO;
if (k === 'gemini-2.5-pro') return Model.G_2_5_PRO;
if (k === 'gemini-2.5-flash') return Model.G_2_5_FLASH;
if (k === 'gemini-3-flash') return Model.G_3_0_FLASH;
if (k === 'gemini-3.0-flash') return Model.G_3_0_FLASH;
if (k === 'gemini-3-flash-thinking') return Model.G_3_0_FLASH_THINKING;
if (k === 'gemini-3.0-flash-thinking') return Model.G_3_0_FLASH_THINKING;
if (k === 'gemini-3.1-pro-preview') return Model.G_3_1_PRO_PREVIEW;
return Model.from_name(k);
}
+1 -1
View File
@@ -170,7 +170,7 @@ coverImage: "https://pbs.twimg.com/media/example.jpg"
Content...
```
**File structure**: `x-to-markdown/{username}/{tweet-id}.md`
**File structure**: `x-to-markdown/{username}/{tweet-id}/{content-slug}.md`
When `--download-media` is enabled:
- Images are saved to `imgs/` next to the markdown file
@@ -7,6 +7,7 @@ import { mkdir, readFile, writeFile } from "node:fs/promises";
import { fetchXArticle } from "./graphql.js";
import { formatArticleMarkdown } from "./markdown.js";
import { localizeMarkdownMedia, type LocalizeMarkdownMediaResult } from "./media-localizer.js";
import { resolveReferencedTweetsFromArticle } from "./referenced-tweets.js";
import { hasRequiredXCookies, loadXCookies, refreshXCookies } from "./cookies.js";
import { resolveXToMarkdownConsentPath } from "./paths.js";
import { tweetToMarkdown } from "./tweet-to-markdown.js";
@@ -203,6 +204,134 @@ function extractContentSlug(markdown: string): string {
return "untitled";
}
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 {
return fs
.readdirSync(dirPath)
.filter((name) => name.toLowerCase().endsWith(".md"))
.map((name) => path.join(dirPath, name))
.sort();
} catch {
return [];
}
}
function resolveExistingMarkdownPath(
normalizedUrl: string,
kind: "tweet" | "article",
argsOutput: string | null
): string | null {
const { slug, idPart } = resolveSlugAndId(normalizedUrl, kind);
const candidateDirs = new Set<string>();
const candidateFiles = new Set<string>();
if (argsOutput) {
const resolved = path.resolve(argsOutput);
const looksDir = argsOutput.endsWith("/") || argsOutput.endsWith("\\");
try {
if (fs.existsSync(resolved)) {
const stat = fs.statSync(resolved);
if (stat.isFile()) {
candidateFiles.add(resolved);
} else if (stat.isDirectory()) {
candidateDirs.add(path.join(resolved, slug, idPart));
candidateDirs.add(resolved);
}
} else if (looksDir) {
candidateDirs.add(path.join(resolved, slug, idPart));
}
} catch {
// ignore and continue
}
} else {
candidateDirs.add(path.resolve(process.cwd(), "x-to-markdown", slug, idPart));
}
for (const filePath of candidateFiles) {
if (!filePath.toLowerCase().endsWith(".md")) continue;
try {
const markdown = fs.readFileSync(filePath, "utf8");
if (frontmatterMatchesTarget(markdown, normalizedUrl, kind)) {
return filePath;
}
} catch {
// ignore and continue
}
}
for (const dirPath of candidateDirs) {
if (!fs.existsSync(dirPath)) continue;
let stat: fs.Stats;
try {
stat = fs.statSync(dirPath);
} catch {
continue;
}
if (!stat.isDirectory()) continue;
const markdownFiles = listMarkdownFiles(dirPath);
for (const filePath of markdownFiles) {
try {
const markdown = fs.readFileSync(filePath, "utf8");
if (frontmatterMatchesTarget(markdown, normalizedUrl, kind)) {
return filePath;
}
} catch {
// ignore and continue
}
}
}
return null;
}
async function resolveOutputPath(
normalizedUrl: string,
kind: "tweet" | "article",
@@ -218,14 +347,14 @@ async function resolveOutputPath(
const idPart = articleId ?? tweetId ?? String(Date.now());
const slug = userSlug ?? idPart;
const defaultFileName = `${idPart}.md`;
const defaultFileName = `${contentSlug}.md`;
if (argsOutput) {
const wantsDir = argsOutput.endsWith("/") || argsOutput.endsWith("\\");
const resolved = path.resolve(argsOutput);
try {
if (wantsDir || (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory())) {
const outputDir = path.join(resolved, slug, contentSlug);
const outputDir = path.join(resolved, slug, idPart);
await mkdir(outputDir, { recursive: true });
return { outputDir, markdownPath: path.join(outputDir, defaultFileName), slug };
}
@@ -238,7 +367,7 @@ async function resolveOutputPath(
return { outputDir, markdownPath: resolved, slug };
}
const outputDir = path.resolve(process.cwd(), "x-to-markdown", slug, contentSlug);
const outputDir = path.resolve(process.cwd(), "x-to-markdown", slug, idPart);
await mkdir(outputDir, { recursive: true });
return { outputDir, markdownPath: path.join(outputDir, defaultFileName), slug };
}
@@ -349,7 +478,8 @@ async function convertArticleToMarkdown(
log(`[x-to-markdown] Fetching article ${articleId}...`);
const article = await fetchXArticle(articleId, cookieMap, false);
const { markdown: body, coverUrl } = formatArticleMarkdown(article);
const referencedTweets = await resolveReferencedTweetsFromArticle(article, cookieMap, { log });
const { markdown: body, coverUrl } = formatArticleMarkdown(article, { referencedTweets });
const title = typeof (article as any)?.title === "string" ? String((article as any).title).trim() : "";
const meta = formatMetaMarkdown({
@@ -389,6 +519,58 @@ async function main(): Promise<void> {
const kind = articleId ? ("article" as const) : ("tweet" as const);
if (args.downloadMedia) {
const existingMarkdownPath = resolveExistingMarkdownPath(normalizedUrl, kind, args.output);
if (existingMarkdownPath) {
log(`[x-to-markdown] Reusing existing markdown: ${existingMarkdownPath}`);
const existingMarkdown = await readFile(existingMarkdownPath, "utf8");
const mediaResult = await localizeMarkdownMedia(existingMarkdown, {
markdownPath: existingMarkdownPath,
log,
});
const didLocalize =
mediaResult.downloadedImages > 0 ||
mediaResult.downloadedVideos > 0 ||
mediaResult.markdown !== existingMarkdown;
if (didLocalize) {
await writeFile(existingMarkdownPath, mediaResult.markdown, "utf8");
log(
`[x-to-markdown] Media localized: images=${mediaResult.downloadedImages}, videos=${mediaResult.downloadedVideos}`
);
log(`[x-to-markdown] Saved: ${existingMarkdownPath}`);
const { slug } = resolveSlugAndId(normalizedUrl, kind);
if (args.json) {
console.log(
JSON.stringify(
{
url: articleId ? `https://x.com/i/article/${articleId}` : normalizedUrl,
requestedUrl: normalizedUrl,
type: kind,
slug,
outputDir: path.dirname(existingMarkdownPath),
markdownPath: existingMarkdownPath,
downloadMedia: true,
downloadedImages: mediaResult.downloadedImages,
downloadedVideos: mediaResult.downloadedVideos,
imageDir: mediaResult.imageDir,
videoDir: mediaResult.videoDir,
},
null,
2
)
);
} else {
console.log(existingMarkdownPath);
}
return;
}
log("[x-to-markdown] Existing markdown already localized; rebuilding content to refresh placement.");
}
}
let markdown =
kind === "article" && articleId
? await convertArticleToMarkdown(normalizedUrl, articleId, log)
@@ -2,9 +2,22 @@ import type {
ArticleBlock,
ArticleContentState,
ArticleEntity,
ArticleEntityMapEntry,
ArticleMediaInfo,
} from "./types.js";
export type ReferencedTweetInfo = {
id: string;
url: string;
authorName?: string;
authorUsername?: string;
text?: string;
};
export type FormatArticleOptions = {
referencedTweets?: Map<string, ReferencedTweetInfo>;
};
function coerceArticleEntity(value: unknown): ArticleEntity | null {
if (!value || typeof value !== "object") return null;
const candidate = value as ArticleEntity;
@@ -29,6 +42,73 @@ function normalizeCaption(caption?: string): string {
return trimmed.replace(/\s+/g, " ");
}
function summarizeTweetText(text?: string): string {
const trimmed = text?.trim();
if (!trimmed) return "";
const normalized = trimmed
.split(/\r?\n+/)
.map((line) => line.trim())
.filter(Boolean)
.join(" ");
if (normalized.length <= 280) return normalized;
return `${normalized.slice(0, 277)}...`;
}
function buildTweetUrl(tweetId?: string, username?: string): string | null {
if (!tweetId) return null;
if (username) {
return `https://x.com/${username}/status/${tweetId}`;
}
return `https://x.com/i/web/status/${tweetId}`;
}
type EntityLookup = {
byIndex: Map<number, ArticleEntityMapEntry>;
byLogicalKey: Map<number, ArticleEntityMapEntry>;
};
function buildEntityLookup(
entityMap: ArticleContentState["entityMap"] | undefined
): EntityLookup {
const lookup: EntityLookup = {
byIndex: new Map<number, ArticleEntityMapEntry>(),
byLogicalKey: new Map<number, ArticleEntityMapEntry>(),
};
if (!entityMap) return lookup;
for (const [idx, entry] of Object.entries(entityMap)) {
const idxNum = Number(idx);
if (Number.isFinite(idxNum)) {
lookup.byIndex.set(idxNum, entry);
}
const logicalKey = parseInt(entry?.key ?? "", 10);
if (Number.isFinite(logicalKey) && !lookup.byLogicalKey.has(logicalKey)) {
lookup.byLogicalKey.set(logicalKey, entry);
}
}
return lookup;
}
function resolveEntityEntry(
entityKey: number | undefined,
entityMap: ArticleContentState["entityMap"] | undefined,
lookup: EntityLookup
): ArticleEntityMapEntry | undefined {
if (entityKey === undefined) return undefined;
const byLogicalKey = lookup.byLogicalKey.get(entityKey);
if (byLogicalKey) return byLogicalKey;
const byIndex = lookup.byIndex.get(entityKey);
if (byIndex) return byIndex;
if (!entityMap) return undefined;
return entityMap[String(entityKey)];
}
function resolveMediaUrl(info?: ArticleMediaInfo): string | undefined {
if (!info) return undefined;
if (info.original_img_url) return info.original_img_url;
@@ -79,11 +159,12 @@ function collectMediaUrls(
function resolveEntityMediaLines(
entityKey: number | undefined,
entityMap: ArticleContentState["entityMap"] | undefined,
entityLookup: EntityLookup,
mediaById: Map<string, string>,
usedUrls: Set<string>
): string[] {
if (entityKey === undefined || !entityMap) return [];
const entry = entityMap[String(entityKey)];
if (entityKey === undefined) return [];
const entry = resolveEntityEntry(entityKey, entityMap, entityLookup);
const value = entry?.value;
if (!value) return [];
const type = value.type;
@@ -117,6 +198,45 @@ function resolveEntityMediaLines(
return lines;
}
function resolveEntityTweetLines(
entityKey: number | undefined,
entityMap: ArticleContentState["entityMap"] | undefined,
entityLookup: EntityLookup,
referencedTweets?: Map<string, ReferencedTweetInfo>
): string[] {
if (entityKey === undefined) return [];
const entry = resolveEntityEntry(entityKey, entityMap, entityLookup);
const value = entry?.value;
if (!value || value.type !== "TWEET") return [];
const tweetId = typeof value.data?.tweetId === "string" ? value.data.tweetId : "";
if (!tweetId) return [];
const referenced = referencedTweets?.get(tweetId);
const url =
referenced?.url ??
buildTweetUrl(tweetId, referenced?.authorUsername) ??
`https://x.com/i/web/status/${tweetId}`;
const authorText =
referenced?.authorName && referenced?.authorUsername
? `${referenced.authorName} (@${referenced.authorUsername})`
: referenced?.authorUsername
? `@${referenced.authorUsername}`
: referenced?.authorName;
const lines: string[] = [];
lines.push(`> 引用推文${authorText ? `${authorText}` : ""}`);
const summary = summarizeTweetText(referenced?.text);
if (summary) {
lines.push(`> ${summary}`);
}
lines.push(`> ${url}`);
return lines;
}
function buildMediaLinkMap(
entityMap: ArticleContentState["entityMap"] | undefined
): Map<number, string> {
@@ -151,6 +271,7 @@ function buildMediaLinkMap(
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;
@@ -160,6 +281,7 @@ 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;
@@ -181,7 +303,7 @@ function renderInlineLinks(
const length = range.length!;
const key = range.key!;
const entry = entityMap[String(key)];
const entry = resolveEntityEntry(key, entityMap, entityLookup);
const value = entry?.value;
if (!value) continue;
@@ -204,27 +326,14 @@ function renderInlineLinks(
return result;
}
function buildAtomicMediaQueue(
article: ArticleEntity,
usedUrls: Set<string>
): string[] {
const queue: string[] = [];
for (const entity of article.media_entities ?? []) {
const url = resolveMediaUrl(entity?.media_info);
if (url && !usedUrls.has(url)) {
queue.push(url);
}
}
return queue;
}
function renderContentBlocks(
blocks: ArticleBlock[],
entityMap: ArticleContentState["entityMap"] | undefined,
entityLookup: EntityLookup,
mediaById: Map<string, string>,
usedUrls: Set<string>,
atomicMediaQueue: string[],
mediaLinkMap: Map<number, string>
mediaLinkMap: Map<number, string>,
referencedTweets?: Map<string, ReferencedTweetInfo>
): string[] {
const lines: string[] = [];
let previousKind: "list" | "quote" | "heading" | "text" | "code" | "media" | null = null;
@@ -253,18 +362,54 @@ function renderContentBlocks(
const mediaLines: string[] = [];
for (const range of ranges) {
if (typeof range?.key !== "number") continue;
mediaLines.push(...resolveEntityMediaLines(range.key, entityMap, mediaById, usedUrls));
mediaLines.push(
...resolveEntityMediaLines(range.key, entityMap, entityLookup, mediaById, usedUrls)
);
}
return mediaLines;
};
const collectTweetLines = (block: ArticleBlock): string[] => {
const ranges = Array.isArray(block.entityRanges) ? block.entityRanges : [];
const tweetLines: string[] = [];
for (const range of ranges) {
if (typeof range?.key !== "number") continue;
tweetLines.push(
...resolveEntityTweetLines(range.key, entityMap, entityLookup, referencedTweets)
);
}
return tweetLines;
};
const collectLinkLines = (block: ArticleBlock): string[] => {
const ranges = Array.isArray(block.entityRanges) ? block.entityRanges : [];
const linkLines: string[] = [];
for (const range of ranges) {
if (typeof range?.key !== "number") continue;
const entry = resolveEntityEntry(range.key, entityMap, entityLookup);
const value = entry?.value;
if (value?.type !== "LINK") continue;
const url = typeof value.data?.url === "string" ? value.data.url : "";
if (url) {
linkLines.push(url);
}
}
return [...new Set(linkLines)];
};
const pushTrailingMedia = (mediaLines: string[]) => {
if (mediaLines.length > 0) {
pushBlock(mediaLines, "media");
}
};
for (const block of blocks) {
const type = typeof block?.type === "string" ? block.type : "unstyled";
const 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, mediaLinkMap)
? renderInlineLinks(rawText, ranges, entityMap, entityLookup, mediaLinkMap)
: rawText;
if (type === "code-block") {
@@ -290,16 +435,22 @@ function renderContentBlocks(
}
listKind = null;
orderedIndex = 0;
const tweetLines = collectTweetLines(block);
if (tweetLines.length > 0) {
pushBlock(tweetLines, "quote");
}
const mediaLines = collectMediaLines(block);
if (mediaLines.length > 0) {
pushBlock(mediaLines, "media");
} else if (atomicMediaQueue.length > 0) {
const url = atomicMediaQueue.shift()!;
if (!usedUrls.has(url)) {
usedUrls.add(url);
pushBlock([`![](${url})`], "media");
}
}
const linkLines = collectLinkLines(block);
if (linkLines.length > 0) {
pushBlock(linkLines, "text");
}
continue;
}
@@ -313,6 +464,7 @@ function renderContentBlocks(
listKind = "unordered";
orderedIndex = 0;
pushBlock([`- ${text}`], "list");
pushTrailingMedia(collectMediaLines(block));
continue;
}
@@ -323,6 +475,7 @@ function renderContentBlocks(
listKind = "ordered";
orderedIndex += 1;
pushBlock([`${orderedIndex}. ${text}`], "list");
pushTrailingMedia(collectMediaLines(block));
continue;
}
@@ -332,29 +485,41 @@ function renderContentBlocks(
switch (type) {
case "header-one":
pushBlock([`# ${text}`], "heading");
pushTrailingMedia(collectMediaLines(block));
break;
case "header-two":
pushBlock([`## ${text}`], "heading");
pushTrailingMedia(collectMediaLines(block));
break;
case "header-three":
pushBlock([`### ${text}`], "heading");
pushTrailingMedia(collectMediaLines(block));
break;
case "header-four":
pushBlock([`#### ${text}`], "heading");
pushTrailingMedia(collectMediaLines(block));
break;
case "header-five":
pushBlock([`##### ${text}`], "heading");
pushTrailingMedia(collectMediaLines(block));
break;
case "header-six":
pushBlock([`###### ${text}`], "heading");
pushTrailingMedia(collectMediaLines(block));
break;
case "blockquote": {
const quoteLines = text.length > 0 ? text.split("\n") : [""];
pushBlock(quoteLines.map((line) => `> ${line}`), "quote");
pushTrailingMedia(collectMediaLines(block));
break;
}
default:
if (/^XIMGPH_\d+$/.test(text.trim())) {
pushTrailingMedia(collectMediaLines(block));
break;
}
pushBlock([text], "text");
pushTrailingMedia(collectMediaLines(block));
break;
}
}
@@ -371,7 +536,28 @@ export type FormatArticleResult = {
coverUrl: string | null;
};
export function formatArticleMarkdown(article: unknown): FormatArticleResult {
export function extractReferencedTweetIds(article: unknown): string[] {
const candidate = coerceArticleEntity(article);
const entityMap = candidate?.content_state?.entityMap;
if (!entityMap) return [];
const ids: string[] = [];
const seen = new Set<string>();
for (const entry of Object.values(entityMap)) {
const value = entry?.value;
if (value?.type !== "TWEET") continue;
const tweetId = typeof value.data?.tweetId === "string" ? value.data.tweetId : "";
if (!tweetId || seen.has(tweetId)) continue;
seen.add(tweetId);
ids.push(tweetId);
}
return ids;
}
export function formatArticleMarkdown(
article: unknown,
options: FormatArticleOptions = {}
): FormatArticleResult {
const candidate = coerceArticleEntity(article);
if (!candidate) {
return { markdown: `\`\`\`json\n${JSON.stringify(article, null, 2)}\n\`\`\``, coverUrl: null };
@@ -392,10 +578,18 @@ export function formatArticleMarkdown(article: unknown): FormatArticleResult {
const blocks = candidate.content_state?.blocks;
const entityMap = candidate.content_state?.entityMap;
const entityLookup = buildEntityLookup(entityMap);
if (Array.isArray(blocks) && blocks.length > 0) {
const atomicMediaQueue = buildAtomicMediaQueue(candidate, usedUrls);
const mediaLinkMap = buildMediaLinkMap(entityMap);
const rendered = renderContentBlocks(blocks, entityMap, mediaById, usedUrls, atomicMediaQueue, mediaLinkMap);
const rendered = renderContentBlocks(
blocks,
entityMap,
entityLookup,
mediaById,
usedUrls,
mediaLinkMap,
options.referencedTweets
);
if (rendered.length > 0) {
if (lines.length > 0) lines.push("");
lines.push(...rendered);
@@ -187,11 +187,35 @@ function buildFileName(kind: MediaKind, index: number, sourceUrl: string, extens
const FRONTMATTER_COVER_RE = /^(coverImage:\s*")(https?:\/\/[^"]+)(")/m;
function toHighResUrl(rawUrl: string): string {
try {
const parsed = new URL(rawUrl);
if (parsed.hostname !== "pbs.twimg.com") return rawUrl;
const ext = path.posix.extname(parsed.pathname).replace(/^\./, "").toLowerCase();
if (!ext || !IMAGE_EXTENSIONS.has(ext)) return rawUrl;
parsed.pathname = parsed.pathname.replace(new RegExp(`\\.${ext}$`), "");
parsed.searchParams.set("format", ext === "jpeg" ? "jpg" : ext);
parsed.searchParams.set("name", "4096x4096");
return parsed.toString();
} catch {
return rawUrl;
}
}
function collectMarkdownLinkCandidates(markdown: string): MarkdownLinkCandidate[] {
MARKDOWN_LINK_RE.lastIndex = 0;
const candidates: MarkdownLinkCandidate[] = [];
const seen = new Set<string>();
const fmMatch = markdown.match(/^---\n([\s\S]*?)\n---/);
if (fmMatch) {
const coverMatch = fmMatch[1]?.match(FRONTMATTER_COVER_RE);
if (coverMatch?.[2] && !seen.has(coverMatch[2])) {
seen.add(coverMatch[2]);
candidates.push({ url: coverMatch[2], hint: "image" });
}
}
MARKDOWN_LINK_RE.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = MARKDOWN_LINK_RE.exec(markdown))) {
const label = match[1] ?? "";
@@ -204,15 +228,6 @@ function collectMarkdownLinkCandidates(markdown: string): MarkdownLinkCandidate[
});
}
const fmMatch = markdown.match(/^---\n([\s\S]*?)\n---/);
if (fmMatch) {
const coverMatch = fmMatch[1]?.match(FRONTMATTER_COVER_RE);
if (coverMatch?.[2] && !seen.has(coverMatch[2])) {
seen.add(coverMatch[2]);
candidates.push({ url: coverMatch[2], hint: "image" });
}
}
return candidates;
}
@@ -259,7 +274,8 @@ export async function localizeMarkdownMedia(
for (const candidate of candidates) {
try {
const response = await fetch(candidate.url, {
const downloadUrl = toHighResUrl(candidate.url);
const response = await fetch(downloadUrl, {
method: "GET",
redirect: "follow",
headers: {
@@ -0,0 +1,81 @@
import { fetchXTweet } from "./graphql.js";
import {
extractReferencedTweetIds,
type ReferencedTweetInfo,
} from "./markdown.js";
type ResolveReferencedTweetsOptions = {
log?: (message: string) => void;
};
function extractReferencedTweetInfo(tweet: any, fallbackTweetId: string): ReferencedTweetInfo {
const userCore = tweet?.core?.user_results?.result?.core;
const userLegacy = tweet?.core?.user_results?.result?.legacy;
const authorName =
typeof userCore?.name === "string"
? userCore.name
: typeof userLegacy?.name === "string"
? userLegacy.name
: undefined;
const authorUsername =
typeof userCore?.screen_name === "string"
? userCore.screen_name
: typeof userLegacy?.screen_name === "string"
? userLegacy.screen_name
: undefined;
const text =
tweet?.note_tweet?.note_tweet_results?.result?.text ??
tweet?.legacy?.full_text ??
tweet?.legacy?.text ??
undefined;
const tweetId =
typeof tweet?.rest_id === "string" && tweet.rest_id.length > 0
? tweet.rest_id
: fallbackTweetId;
const url = authorUsername
? `https://x.com/${authorUsername}/status/${tweetId}`
: `https://x.com/i/web/status/${tweetId}`;
return {
id: tweetId,
url,
authorName,
authorUsername,
text: typeof text === "string" ? text : undefined,
};
}
export async function resolveReferencedTweetsFromArticle(
article: unknown,
cookieMap: Record<string, string>,
options: ResolveReferencedTweetsOptions = {}
): Promise<Map<string, ReferencedTweetInfo>> {
const log = options.log ?? (() => {});
const ids = extractReferencedTweetIds(article);
const referencedTweets = new Map<string, ReferencedTweetInfo>();
for (const id of ids) {
try {
const tweet = await fetchXTweet(id, cookieMap, false);
const info = extractReferencedTweetInfo(tweet, id);
referencedTweets.set(id, info);
} catch (error) {
log(
`[x-to-markdown] Failed to fetch referenced tweet ${id}: ${
error instanceof Error ? error.message : String(error)
}`
);
referencedTweets.set(id, {
id,
url: `https://x.com/i/web/status/${id}`,
});
}
}
return referencedTweets;
}
@@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url";
import { hasRequiredXCookies, loadXCookies } from "./cookies.js";
import { fetchTweetThread } from "./thread.js";
import { formatArticleMarkdown } from "./markdown.js";
import { resolveReferencedTweetsFromArticle } from "./referenced-tweets.js";
import { formatThreadTweetsMarkdown } from "./thread-markdown.js";
import { resolveArticleEntityFromTweet } from "./tweet-article.js";
@@ -129,7 +130,8 @@ export async function tweetToMarkdown(
const parts: string[] = [];
if (articleEntity) {
const articleResult = formatArticleMarkdown(articleEntity);
const referencedTweets = await resolveReferencedTweetsFromArticle(articleEntity, cookieMap, { log });
const articleResult = formatArticleMarkdown(articleEntity, { referencedTweets });
coverImage = articleResult.coverUrl;
const articleMarkdown = articleResult.markdown.trimEnd();
if (articleMarkdown) {
@@ -40,6 +40,7 @@ export type ArticleEntityMapEntry = {
caption?: string;
mediaItems?: ArticleEntityMapMediaItem[];
url?: string;
tweetId?: string;
};
};
};
+1
View File
@@ -36,6 +36,7 @@ Environment variables:
DASHSCOPE_IMAGE_MODEL Default DashScope model (z-image-turbo)
REPLICATE_IMAGE_MODEL Default Replicate model (google/nano-banana-pro)
OPENAI_BASE_URL Custom OpenAI endpoint
OPENAI_IMAGE_USE_CHAT Use /chat/completions instead of /images/generations (true|false)
GOOGLE_BASE_URL Custom Google endpoint
DASHSCOPE_BASE_URL Custom DashScope endpoint
REPLICATE_BASE_URL Custom Replicate endpoint
@@ -70,6 +70,10 @@ export async function generateImage(
if (!apiKey) throw new Error("OPENAI_API_KEY is required");
if (process.env.OPENAI_IMAGE_USE_CHAT === "true") {
return generateWithChatCompletions(baseURL, apiKey, prompt, model);
}
const size = args.size || getOpenAISize(model, args.aspectRatio, args.quality);
if (args.referenceImages.length > 0) {
@@ -84,6 +88,40 @@ export async function generateImage(
return generateWithOpenAIGenerations(baseURL, apiKey, prompt, model, size, args.quality);
}
async function generateWithChatCompletions(
baseURL: string,
apiKey: string,
prompt: string,
model: string
): Promise<Uint8Array> {
const res = await fetch(`${baseURL}/chat/completions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model,
messages: [{ role: "user", content: prompt }],
}),
});
if (!res.ok) {
const err = await res.text();
throw new Error(`OpenAI API error: ${err}`);
}
const result = (await res.json()) as { choices: Array<{ message: { content: string } }> };
const content = result.choices[0]?.message?.content ?? "";
const match = content.match(/data:image\/[^;]+;base64,([A-Za-z0-9+/=]+)/);
if (match) {
return Uint8Array.from(Buffer.from(match[1]!, "base64"));
}
throw new Error("No image found in chat completions response");
}
async function generateWithOpenAIGenerations(
baseURL: string,
apiKey: string,
+5 -1
View File
@@ -92,6 +92,8 @@ test -f "$HOME/.baoyu-skills/baoyu-post-to-wechat/EXTEND.md" && grep -o 'default
| `default` (Recommended) | 经典主题 - 传统排版,标题居中带底边,二级标题白字彩底 |
| `grace` | 优雅主题 - 文字阴影,圆角卡片,精致引用块 |
| `simple` | 简洁主题 - 现代极简风,不对称圆角,清爽留白 |
| `red` | 红色主题 - 红金配色,宋体排版,传统书法风格 |
| `orange` | 橙色主题 - 暖色调现代风,宽松行距,圆角装饰 |
### Step 2: Convert
@@ -113,7 +115,7 @@ npx -y bun ${SKILL_DIR}/scripts/main.ts <markdown_file> [options]
| Option | Description | Default |
|--------|-------------|---------|
| `--theme <name>` | Theme name (default, grace, simple) | default |
| `--theme <name>` | Theme name (default, grace, simple, red, orange) | default |
| `--title <title>` | Override title from frontmatter | |
| `--keep-title` | Keep the first heading in content | false (removed) |
| `--help` | Show help | |
@@ -169,6 +171,8 @@ npx -y bun ${SKILL_DIR}/scripts/main.ts article.md --title "My Article"
| `default` | 经典主题 - 传统排版,标题居中带底边,二级标题白字彩底 |
| `grace` | 优雅主题 - 文字阴影,圆角卡片,精致引用块 (by @brzhang) |
| `simple` | 简洁主题 - 现代极简风,不对称圆角,清爽留白 (by @okooo5km) |
| `red` | 红色主题 - 红金配色,宋体排版,传统书法风格 |
| `orange` | 橙色主题 - 暖色调现代风,宽松行距,圆角装饰 |
## Supported Markdown Features
@@ -1,6 +1,7 @@
#!/usr/bin/env npx tsx
import fs from "node:fs";
import { homedir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import frontMatter from "front-matter";
@@ -40,10 +41,47 @@ const EXTERNAL_THEME_DIR =
|| path.resolve(path.dirname(EXTERNAL_THEME_CONFIG_PATH), "theme-css");
const FALLBACK_THEMES: ThemeName[] = ["default", "grace", "simple"];
const DEFAULT_STYLE = {
const FONT_FAMILY_MAP: Record<string, string> = {
sans: `-apple-system-font,BlinkMacSystemFont, Helvetica Neue, PingFang SC, Hiragino Sans GB , Microsoft YaHei UI , Microsoft YaHei ,Arial,sans-serif`,
serif: `Optima-Regular, Optima, PingFangSC-light, PingFangTC-light, 'PingFang SC', Cambria, Cochin, Georgia, Times, 'Times New Roman', serif`,
mono: `Menlo, Monaco, 'Courier New', monospace`,
};
const FONT_SIZE_OPTIONS = ["14px", "15px", "16px", "17px", "18px"];
const CODE_BLOCK_THEMES = [
"1c-light", "a11y-dark", "a11y-light", "agate", "an-old-hope",
"androidstudio", "arduino-light", "arta", "ascetic",
"atom-one-dark-reasonable", "atom-one-dark", "atom-one-light",
"brown-paper", "codepen-embed", "color-brewer", "dark", "default",
"devibeans", "docco", "far", "felipec", "foundation",
"github-dark-dimmed", "github-dark", "github", "gml", "googlecode",
"gradient-dark", "gradient-light", "grayscale", "hybrid", "idea",
"intellij-light", "ir-black", "isbl-editor-dark", "isbl-editor-light",
"kimbie-dark", "kimbie-light", "lightfair", "lioshi", "magula",
"mono-blue", "monokai-sublime", "monokai", "night-owl", "nnfx-dark",
"nnfx-light", "nord", "obsidian", "panda-syntax-dark",
"panda-syntax-light", "paraiso-dark", "paraiso-light", "pojoaque",
"purebasic", "qtcreator-dark", "qtcreator-light", "rainbow", "routeros",
"school-book", "shades-of-purple", "srcery", "stackoverflow-dark",
"stackoverflow-light", "sunburst", "tokyo-night-dark", "tokyo-night-light",
"tomorrow-night-blue", "tomorrow-night-bright", "vs", "vs2015", "xcode",
"xt256",
];
const HLJS_CDN_BASE = "https://cdn-doocs.oss-cn-shenzhen.aliyuncs.com/npm/highlightjs/11.11.1";
interface StyleConfig {
primaryColor: string;
fontFamily: string;
fontSize: string;
foreground: string;
blockquoteBackground: string;
}
const DEFAULT_STYLE: StyleConfig = {
primaryColor: "#0F4C81",
fontFamily:
"-apple-system-font,BlinkMacSystemFont, Helvetica Neue, PingFang SC, Hiragino Sans GB , Microsoft YaHei UI , Microsoft YaHei ,Arial,sans-serif",
fontFamily: FONT_FAMILY_MAP.sans!,
fontSize: "16px",
foreground: "0 0% 3.9%",
blockquoteBackground: "#f7f7f7",
@@ -490,46 +528,133 @@ function printUsage(): void {
console.error(
[
"Usage:",
" npx tsx src/md/render.ts <markdown_file> [--theme <name>]",
" npx tsx src/md/render.ts <markdown_file> [options]",
"",
"Options:",
` --theme Theme name (${THEME_NAMES.join(", ")})`,
` --theme <name> Theme (${THEME_NAMES.join(", ")})`,
` --color <hex> Primary color (default: ${DEFAULT_STYLE.primaryColor})`,
` --font-family <name> Font: sans, serif, mono, or CSS value`,
` --font-size <N> Font size: ${FONT_SIZE_OPTIONS.join(", ")} (default: 16px)`,
` --code-theme <name> Code highlight theme (default: github)`,
` --mac-code-block Show Mac-style code block header`,
` --line-number Show line numbers in code blocks`,
` --cite Enable footnote citations`,
` --count Show reading time / word count`,
` --legend <value> Image caption: title-alt, alt-title, title, alt, none`,
` --keep-title Keep the first heading in output`,
].join("\n")
);
}
function parseArgValue(argv: string[], i: number, flag: string): string | null {
const arg = argv[i]!;
if (arg.includes("=")) {
return arg.slice(flag.length + 1);
}
const next = argv[i + 1];
return next ?? null;
}
function resolveFontFamily(value: string): string {
return FONT_FAMILY_MAP[value] ?? value;
}
function parseArgs(argv: string[]): CliOptions | null {
const ext = loadExtendConfig();
let inputPath = "";
let theme: ThemeName = "default";
let keepTitle = false;
let theme: ThemeName = ext.default_theme ?? "default";
let keepTitle = ext.keep_title ?? false;
let primaryColor = ext.default_color ?? DEFAULT_STYLE.primaryColor;
let fontFamily = ext.default_font_family ? resolveFontFamily(ext.default_font_family) : DEFAULT_STYLE.fontFamily;
let fontSize = ext.default_font_size ?? DEFAULT_STYLE.fontSize;
let codeTheme = ext.default_code_theme ?? "github";
let isMacCodeBlock = ext.mac_code_block ?? true;
let isShowLineNumber = ext.show_line_number ?? false;
let citeStatus = ext.cite ?? false;
let countStatus = ext.count ?? false;
let legend = ext.legend ?? "alt";
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
const arg = argv[i]!;
if (!arg.startsWith("--") && !inputPath) {
inputPath = arg;
continue;
}
if (arg === "--theme") {
theme = (argv[i + 1] || "") as ThemeName;
i += 1;
continue;
}
if (arg.startsWith("--theme=")) {
theme = arg.slice("--theme=".length) as ThemeName;
continue;
}
if (arg === "--keep-title") {
keepTitle = true;
continue;
}
if (arg === "--help" || arg === "-h") {
return null;
}
if (arg === "--keep-title") { keepTitle = true; continue; }
if (arg === "--mac-code-block") { isMacCodeBlock = true; continue; }
if (arg === "--no-mac-code-block") { isMacCodeBlock = false; continue; }
if (arg === "--line-number") { isShowLineNumber = true; continue; }
if (arg === "--cite") { citeStatus = true; continue; }
if (arg === "--count") { countStatus = true; continue; }
if (arg === "--theme" || arg.startsWith("--theme=")) {
const val = parseArgValue(argv, i, "--theme");
if (!val) { console.error("Missing value for --theme"); return null; }
theme = val as ThemeName;
if (!arg.includes("=")) i += 1;
continue;
}
if (arg === "--color" || arg.startsWith("--color=")) {
const val = parseArgValue(argv, i, "--color");
if (!val) { console.error("Missing value for --color"); return null; }
primaryColor = val;
if (!arg.includes("=")) i += 1;
continue;
}
if (arg === "--font-family" || arg.startsWith("--font-family=")) {
const val = parseArgValue(argv, i, "--font-family");
if (!val) { console.error("Missing value for --font-family"); return null; }
fontFamily = resolveFontFamily(val);
if (!arg.includes("=")) i += 1;
continue;
}
if (arg === "--font-size" || arg.startsWith("--font-size=")) {
const val = parseArgValue(argv, i, "--font-size");
if (!val) { console.error("Missing value for --font-size"); return null; }
fontSize = val.endsWith("px") ? val : `${val}px`;
if (!FONT_SIZE_OPTIONS.includes(fontSize)) {
console.error(`Invalid font size: ${fontSize}. Valid: ${FONT_SIZE_OPTIONS.join(", ")}`);
return null;
}
if (!arg.includes("=")) i += 1;
continue;
}
if (arg === "--code-theme" || arg.startsWith("--code-theme=")) {
const val = parseArgValue(argv, i, "--code-theme");
if (!val) { console.error("Missing value for --code-theme"); return null; }
codeTheme = val;
if (!CODE_BLOCK_THEMES.includes(codeTheme)) {
console.error(`Unknown code theme: ${codeTheme}`);
return null;
}
if (!arg.includes("=")) i += 1;
continue;
}
if (arg === "--legend" || arg.startsWith("--legend=")) {
const val = parseArgValue(argv, i, "--legend");
if (!val) { console.error("Missing value for --legend"); return null; }
const valid = ["title-alt", "alt-title", "title", "alt", "none"];
if (!valid.includes(val)) {
console.error(`Invalid legend: ${val}. Valid: ${valid.join(", ")}`);
return null;
}
legend = val;
if (!arg.includes("=")) i += 1;
continue;
}
console.error(`Unknown argument: ${arg}`);
return null;
}
@@ -544,9 +669,8 @@ function parseArgs(argv: string[]): CliOptions | null {
}
return {
inputPath,
theme,
keepTitle,
inputPath, theme, keepTitle, primaryColor, fontFamily, fontSize,
codeTheme, isMacCodeBlock, isShowLineNumber, citeStatus, countStatus, legend,
};
}
@@ -554,6 +678,78 @@ interface CliOptions {
inputPath: string;
theme: ThemeName;
keepTitle: boolean;
primaryColor: string;
fontFamily: string;
fontSize: string;
codeTheme: string;
isMacCodeBlock: boolean;
isShowLineNumber: boolean;
citeStatus: boolean;
countStatus: boolean;
legend: string;
}
interface ExtendConfig {
default_theme: string | null;
default_color: string | null;
default_font_family: string | null;
default_font_size: string | null;
default_code_theme: string | null;
mac_code_block: boolean | null;
show_line_number: boolean | null;
cite: boolean | null;
count: boolean | null;
legend: string | null;
keep_title: boolean | null;
}
function extractYamlFrontMatter(content: string): string | null {
const match = content.match(/^---\s*\n([\s\S]*?)\n---\s*$/m);
return match ? match[1]! : null;
}
function parseExtendYaml(yaml: string): Partial<ExtendConfig> {
const config: Partial<ExtendConfig> = {};
for (const line of yaml.split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const colonIdx = trimmed.indexOf(":");
if (colonIdx < 0) continue;
const key = trimmed.slice(0, colonIdx).trim();
let value = trimmed.slice(colonIdx + 1).trim().replace(/^['"]|['"]$/g, "");
if (value === "null" || value === "") continue;
if (key === "default_theme") config.default_theme = value;
else if (key === "default_color") config.default_color = value;
else if (key === "default_font_family") config.default_font_family = value;
else if (key === "default_font_size") config.default_font_size = value.endsWith("px") ? value : `${value}px`;
else if (key === "default_code_theme") config.default_code_theme = value;
else if (key === "mac_code_block") config.mac_code_block = value === "true";
else if (key === "show_line_number") config.show_line_number = value === "true";
else if (key === "cite") config.cite = value === "true";
else if (key === "count") config.count = value === "true";
else if (key === "legend") config.legend = value;
else if (key === "keep_title") config.keep_title = value === "true";
}
return config;
}
function loadExtendConfig(): Partial<ExtendConfig> {
const paths = [
path.join(process.cwd(), ".baoyu-skills", "baoyu-markdown-to-html", "EXTEND.md"),
path.join(homedir(), ".baoyu-skills", "baoyu-markdown-to-html", "EXTEND.md"),
];
for (const p of paths) {
try {
const content = fs.readFileSync(p, "utf-8");
const yaml = extractYamlFrontMatter(content);
if (!yaml) continue;
return parseExtendYaml(yaml);
} catch {
continue;
}
}
return {};
}
function preprocessCjkEmphasis(markdown: string): string {
@@ -679,14 +875,14 @@ function loadThemeCss(theme: ThemeName): {
};
}
function buildCss(baseCss: string, themeCss: string): string {
function buildCss(baseCss: string, themeCss: string, style: StyleConfig = DEFAULT_STYLE): string {
const variables = `
:root {
--md-primary-color: ${DEFAULT_STYLE.primaryColor};
--md-font-family: ${DEFAULT_STYLE.fontFamily};
--md-font-size: ${DEFAULT_STYLE.fontSize};
--foreground: ${DEFAULT_STYLE.foreground};
--blockquote-background: ${DEFAULT_STYLE.blockquoteBackground};
--md-primary-color: ${style.primaryColor};
--md-font-family: ${style.fontFamily};
--md-font-size: ${style.fontSize};
--foreground: ${style.foreground};
--blockquote-background: ${style.blockquoteBackground};
}
body {
@@ -708,13 +904,28 @@ function normalizeThemeCss(css: string): string {
return stripOutputScope(css);
}
async function fetchCodeThemeCss(themeName: string): Promise<string> {
const url = `${HLJS_CDN_BASE}/styles/${themeName}.min.css`;
try {
const res = await fetch(url);
if (!res.ok) {
console.error(`Failed to fetch code theme CSS: ${res.status} ${url}`);
return "";
}
return await res.text();
} catch (error) {
console.error(`Failed to fetch code theme CSS from ${url}:`, error);
return "";
}
}
interface HtmlDocumentMeta {
title: string;
author?: string;
description?: string;
}
function buildHtmlDocument(meta: HtmlDocumentMeta, css: string, html: string): string {
function buildHtmlDocument(meta: HtmlDocumentMeta, css: string, html: string, codeThemeCss?: string): string {
const lines = [
"<!doctype html>",
"<html>",
@@ -729,8 +940,11 @@ function buildHtmlDocument(meta: HtmlDocumentMeta, css: string, html: string): s
if (meta.description) {
lines.push(` <meta name="description" content="${meta.description}" />`);
}
lines.push(` <style>${css}</style>`);
if (codeThemeCss) {
lines.push(` <style>${codeThemeCss}</style>`);
}
lines.push(
` <style>${css}</style>`,
"</head>",
"<body>",
' <div id="output">',
@@ -758,12 +972,12 @@ async function inlineCss(html: string): Promise<string> {
}
}
function normalizeCssText(cssText: string): string {
function normalizeCssText(cssText: string, style: StyleConfig = DEFAULT_STYLE): string {
return cssText
.replace(/var\(--md-primary-color\)/g, DEFAULT_STYLE.primaryColor)
.replace(/var\(--md-font-family\)/g, DEFAULT_STYLE.fontFamily)
.replace(/var\(--md-font-size\)/g, DEFAULT_STYLE.fontSize)
.replace(/var\(--blockquote-background\)/g, DEFAULT_STYLE.blockquoteBackground)
.replace(/var\(--md-primary-color\)/g, style.primaryColor)
.replace(/var\(--md-font-family\)/g, style.fontFamily)
.replace(/var\(--md-font-size\)/g, style.fontSize)
.replace(/var\(--blockquote-background\)/g, style.blockquoteBackground)
.replace(/hsl\(var\(--foreground\)\)/g, "#3f3f3f")
.replace(/--md-primary-color:\s*[^;"']+;?/g, "")
.replace(/--md-font-family:\s*[^;"']+;?/g, "")
@@ -772,20 +986,20 @@ function normalizeCssText(cssText: string): string {
.replace(/--foreground:\s*[^;"']+;?/g, "");
}
function normalizeInlineCss(html: string): string {
function normalizeInlineCss(html: string, style: StyleConfig = DEFAULT_STYLE): string {
let output = html;
output = output.replace(
/<style([^>]*)>([\s\S]*?)<\/style>/gi,
(_match, attrs: string, cssText: string) =>
`<style${attrs}>${normalizeCssText(cssText)}</style>`
`<style${attrs}>${normalizeCssText(cssText, style)}</style>`
);
output = output.replace(
/style="([^"]*)"/gi,
(_match, cssText: string) => `style="${normalizeCssText(cssText)}"`
(_match, cssText: string) => `style="${normalizeCssText(cssText, style)}"`
);
output = output.replace(
/style='([^']*)'/gi,
(_match, cssText: string) => `style='${normalizeCssText(cssText)}'`
(_match, cssText: string) => `style='${normalizeCssText(cssText, style)}'`
);
return output;
}
@@ -824,11 +1038,27 @@ async function main(): Promise<void> {
options.inputPath.replace(/\.md$/i, ".html")
);
const style: StyleConfig = {
...DEFAULT_STYLE,
primaryColor: options.primaryColor,
fontFamily: options.fontFamily,
fontSize: options.fontSize,
};
const { baseCss, themeCss } = loadThemeCss(options.theme);
const css = normalizeThemeCss(buildCss(baseCss, themeCss));
const css = normalizeThemeCss(buildCss(baseCss, themeCss, style));
const codeThemeCss = await fetchCodeThemeCss(options.codeTheme);
const markdown = fs.readFileSync(inputPath, "utf-8");
const renderer = initRenderer({});
const renderer = initRenderer({
legend: options.legend,
citeStatus: options.citeStatus,
countStatus: options.countStatus,
isMacCodeBlock: options.isMacCodeBlock,
isShowLineNumber: options.isShowLineNumber,
});
const { yamlData } = renderer.parseFrontMatterAndContent(markdown);
const { html: baseHtml, readingTime: readingTimeResult } = renderMarkdown(
markdown,
@@ -855,8 +1085,8 @@ async function main(): Promise<void> {
author: stripQuotes(yamlData.author),
description: stripQuotes(yamlData.description) || stripQuotes(yamlData.summary),
};
const html = buildHtmlDocument(meta, css, content);
const inlinedHtml = normalizeInlineCss(await inlineCss(html));
const html = buildHtmlDocument(meta, css, content, codeThemeCss);
const inlinedHtml = normalizeInlineCss(await inlineCss(html), style);
const finalHtml = modifyHtmlStructure(inlinedHtml);
let backupPath = "";
@@ -32,3 +32,8 @@ blockquote {
#output section > :first-child {
margin-top: 0 !important;
}
.mermaid-diagram .nodeLabel p {
color: unset !important;
letter-spacing: unset !important;
}
@@ -269,6 +269,7 @@ pre.code__pre,
padding: 0 !important;
line-height: 1.5;
margin: 10px 8px;
box-shadow: inset 0 0 10px rgba(0,0,0,0.05);
}
/* ==================== 图片 ==================== */
@@ -0,0 +1,181 @@
/**
* MD 橙色主题
* 暖色调现代风,橙色主色,宽松行距
*/
/* ==================== 容器样式覆盖 ==================== */
section,
container {
font-family: PingFang SC, system-ui, -apple-system, BlinkMacSystemFont, Helvetica Neue, Hiragino Sans GB, Microsoft YaHei UI, Microsoft YaHei, Arial, sans-serif;
font-size: 15px;
line-height: 2;
background-color: rgba(250, 249, 245, 1);
border: 1px solid rgba(255, 255, 255, 0.01);
border-radius: 25px;
padding: 12px 12px;
}
#output {
font-family: PingFang SC, system-ui, -apple-system, BlinkMacSystemFont, Helvetica Neue, Hiragino Sans GB, Microsoft YaHei UI, Microsoft YaHei, Arial, sans-serif;
font-size: 15px;
line-height: 2;
}
/* ==================== 一级标题 ==================== */
h1 {
display: table;
padding: 0.3em 1em;
margin: 20px auto;
color: #3B3B38;
background: #D97757;
border-radius: 15px;
font-size: 28px;
font-weight: bold;
text-align: center;
}
/* ==================== 二级标题 ==================== */
h2 {
display: block;
padding: 0.2em 0;
padding-bottom: 0;
margin: 0 auto 20px;
width: 100%;
color: #D97757;
font-size: 20px;
font-weight: bold;
border-bottom: 2px solid #E4B1A0;
}
/* ==================== 三级标题 ==================== */
h3 {
margin: 0 8px 10px;
color: #3B3B38;
font-size: 18px;
font-weight: bold;
}
/* ==================== 四级标题 ==================== */
h4 {
margin: 0 8px 10px;
color: #D97757;
font-size: 16px;
font-weight: bold;
}
/* ==================== 五级标题 ==================== */
h5 {
display: inline-block;
margin: 0 8px 10px;
padding: 4px 10px;
color: #3B3B38;
background: rgba(255, 255, 255, 0.7);
border: 1px solid rgb(189, 224, 254);
border-radius: 20px;
font-size: 16px;
font-weight: 500;
}
/* ==================== 六级标题 ==================== */
h6 {
margin: 0 8px 10px;
color: #D97757;
font-size: 16px;
font-weight: bold;
}
/* ==================== 段落 ==================== */
p {
margin: 20px 0;
color: #3B3B38;
line-height: 2;
letter-spacing: 0px;
font-size: 15px;
}
/* ==================== 引用块 ==================== */
blockquote {
font-style: normal;
padding: 15px 12px;
border-left: 7px solid rgba(228, 177, 160, 1);
border-radius: 10px;
color: #3B3B38;
background-color: rgba(255, 255, 255, 0.6);
margin: 12px 0;
}
blockquote > p {
color: #3B3B38;
font-size: 14px;
margin: 0;
}
/* ==================== 代码块 ==================== */
pre.code__pre,
.hljs.code__pre {
border-radius: 10px;
box-shadow: inset 0 0 10px rgba(0, 0, 0, 0.05);
}
/* ==================== 图片 ==================== */
img {
border-radius: 10px;
margin: 5px auto;
}
/* ==================== 列表 ==================== */
ol {
padding-left: 1em;
margin: 15px 0;
line-height: 2;
}
ul {
list-style: none;
padding-left: 0;
margin: 15px 0;
line-height: 2;
}
li {
margin: 0.2em 0;
color: #3B3B38;
font-size: 15px;
}
/* ==================== 分隔线 ==================== */
hr {
border-style: solid;
border-width: 1px 0 0;
border-color: #D97757;
margin: 0;
}
/* ==================== 强调 ==================== */
strong {
color: #3B3B38;
font-weight: bold;
}
/* ==================== 标记高亮 ==================== */
.markup-highlight {
background-color: #3B3B3B;
padding: 10px;
color: #FAF9F5;
}
.markup-underline {
text-decoration: underline;
text-decoration-color: #E4B1A0;
}
/* ==================== 链接 ==================== */
a {
color: #D97757;
text-decoration: none;
}
/* ==================== 表格 ==================== */
th {
background: rgba(217, 119, 87, 0.1);
}
@@ -0,0 +1,183 @@
/**
* MD 红色主题
* 传统书法风格,红金配色,宋体排版
*/
/* ==================== 容器字体覆盖 ==================== */
section,
container {
font-family: "Source Han Serif SC", "Noto Serif CJK SC", "Source Han Serif CN", STSong, SimSun, serif;
font-size: 16px;
line-height: 1.8;
background-color: rgba(255, 251, 240, 1);
border: 4px solid rgba(169, 50, 38, 1);
border-radius: 12px;
padding: 6px 12px;
}
#output {
font-family: "Source Han Serif SC", "Noto Serif CJK SC", "Source Han Serif CN", STSong, SimSun, serif;
font-size: 16px;
line-height: 1.8;
}
/* ==================== 一级标题 ==================== */
h1 {
display: table;
padding: 0 1em;
border-bottom: 2px solid #A93226;
margin: 30px auto 20px;
color: #A93226;
font-size: 24px;
font-weight: bold;
text-align: center;
}
/* ==================== 二级标题 ==================== */
h2 {
display: block;
width: fit-content;
padding: 6px 20px;
margin: 30px auto 20px;
color: #FFFFFF;
background-color: #A93226;
font-size: 18px;
font-weight: bold;
text-align: center;
border-radius: 8px;
box-shadow: rgba(0, 0, 0, 0.1) 0px 4px 6px;
line-height: 1.6em;
}
/* ==================== 三级标题 ==================== */
h3 {
padding-left: 8px;
border-left: 3px solid #A93226;
margin: 20px 8px 10px 0;
color: #A93226;
font-size: 17px;
font-weight: normal;
line-height: 1.2;
}
/* ==================== 四级标题 ==================== */
h4 {
margin: 10px 8px;
color: #D4AC0D;
font-size: 16px;
font-weight: normal;
}
/* ==================== 五级标题 ==================== */
h5 {
margin: 10px 8px;
color: #797D7F;
font-size: 15px;
font-weight: normal;
}
/* ==================== 六级标题 ==================== */
h6 {
margin: 10px 8px;
color: #797D7F;
font-size: 14px;
font-weight: normal;
}
/* ==================== 段落 ==================== */
p {
margin: 16px 0;
letter-spacing: 0.5px;
color: #2C2C2C;
line-height: 1.8;
}
/* ==================== 引用块 ==================== */
blockquote {
font-style: normal;
padding: 15px 12px;
border-left: 2px solid rgba(169, 50, 38, 0.8);
border-radius: 4px;
color: #5D4037;
background-color: rgba(253, 237, 236, 1);
margin: 12px 0;
}
blockquote > p {
color: #5D4037;
font-size: 14px;
margin: 0;
}
/* ==================== 代码块 ==================== */
pre.code__pre,
.hljs.code__pre {
font-size: 13px;
border: 1px solid #D4AC0D;
box-shadow: inset 0 0 10px rgba(0, 0, 0, 0.05);
}
/* ==================== 图片 ==================== */
img {
border-radius: 4px;
border: 1px solid #D4AC0D;
margin: 20px auto;
padding: 5px;
}
/* ==================== 列表 ==================== */
ol {
padding-left: 1em;
margin: 15px 0;
line-height: 1.6;
}
ul {
list-style: none;
padding-left: 0;
margin: 15px 0;
line-height: 1.6;
}
li {
margin: 0.2em 0;
color: #2C2C2C;
font-size: 16px;
}
/* ==================== 分隔线 ==================== */
hr {
border-style: solid;
border-width: 2px 0 0;
border-color: #D4AC0D;
margin: 40px 0;
}
/* ==================== 强调 ==================== */
strong {
color: #A93226;
font-weight: bold;
}
/* ==================== 标记高亮 ==================== */
.markup-highlight {
background-color: #FADBD8;
padding: 5px;
color: #A93226;
}
.markup-underline {
text-decoration: underline;
text-decoration-color: #D4AC0D;
}
/* ==================== 链接 ==================== */
a {
color: #A93226;
text-decoration: none;
}
/* ==================== 表格 ==================== */
th {
background: rgba(169, 50, 38, 0.1);
}