Compare commits

...

25 Commits

Author SHA1 Message Date
Jim Liu 宝玉 603cabaef4 chore: release v1.74.1 2026-03-21 00:03:51 -05:00
Jim Liu 宝玉 7d12526e90 fix(baoyu-image-gen): broaden OpenRouter model detection and aspect ratio validation 2026-03-21 00:03:20 -05:00
Jim Liu 宝玉 e7f9764a49 Merge pull request #101 from cwandev/fix/openrouter
fix(baoyu-image-gen): align `OpenRouter` image generation with current API
2026-03-20 23:32:26 -05:00
Jim Liu 宝玉 e55f91b0ea chore: release v1.74.0 2026-03-20 23:17:14 -05:00
Jim Liu 宝玉 fe3b3d9125 feat(baoyu-markdown-to-html): pass through all rendering options from CLI
- CLI now supports --color, --font-family, --font-size, --code-theme, --mac-code-block, --line-number, --count, --legend
- convertMarkdown accepts full CliOptions instead of limited subset
- Dynamic help text showing available theme/color/font options
2026-03-20 23:17:10 -05:00
Jim Liu 宝玉 105339cf3f fix(baoyu-md): fix CSS custom property regex for quoted values and add theme layering
- Remove quotes from CSS custom property regex character class so values containing quotes are fully stripped
- grace/simple themes now layer default CSS before their own rules
- Add tests for quoted property stripping and theme layering
2026-03-20 23:17:04 -05:00
Jim Liu 宝玉 dcfd9033ae chore: release v1.73.3 2026-03-20 18:46:01 -05:00
Jim Liu 宝玉 eb416d174c fix(baoyu-post-to-wechat): prevent placeholder regex from matching longer numbered variants 2026-03-20 18:45:05 -05:00
Jim Liu 宝玉 83afacb00e chore: release v1.73.2 2026-03-20 14:46:06 -05:00
Jim Liu 宝玉 0e6bfbcabd refactor: 提取图片处理模块,本地转换不支持的格式而非回退到material接口
将WeChat正文图片的格式转换和压缩逻辑提取到独立的wechat-image-processor模块,
使用jimp和@jsquash/webp在本地将WebP/BMP/GIF等不支持的格式转换为JPEG/PNG并
压缩到1MB以内,避免依赖material接口的回退策略。同时简化了news类型封面兜底逻辑。
2026-03-20 14:43:49 -05:00
Jim Liu 宝玉 7b67ff44b8 Merge pull request #100 from AICreator-Wind/fix-body-image-upload
fix: 正文图片上传使用 media/uploadimg 接口
2026-03-20 14:00:14 -05:00
浪不能停 79c289ca92 fix: 处理WebP等不支持的格式并优化回退逻辑
- 添加WebP、BMP、TIFF等格式到不支持列表
- 对于需要回退到material接口的情况,同时调用两个接口获取URL和media_id
- 提取uploadToWechat函数避免代码重复
2026-03-20 18:18:01 +08:00
浪不能停 fc5ad4b762 fix: 处理media/uploadimg接口的文件格式和大小限制
- 添加文件大小和格式检查常量
- GIF图片或大于1MB的图片自动回退使用material接口
- 确保向后兼容现有工作流
2026-03-20 14:25:48 +08:00
浪不能停 747977416d fix: 修复newspic类型图片上传和news类型封面兜底逻辑
- uploadImagesInHtml 函数添加 articleType 参数支持
- 为 newspic 类型正文图片额外调用 material 接口收集 media_id
- 返回 firstImageSource 用于 news 类型封面兜底逻辑
- 恢复 news 类型没有显式封面时使用第一张正文图作为封面的逻辑
2026-03-20 13:57:59 +08:00
cwandev e43eec260a fix(baoyu-image-gen): narrow OpenRouter Gemini ratios 2026-03-19 22:09:23 +08:00
cwandev 96ef6e2251 fix(baoyu-image-gen): harden OpenRouter image support 2026-03-19 21:59:41 +08:00
cwandev efb7a1917a fix(baoyu-image-gen): require OpenRouter image parameters 2026-03-19 21:26:19 +08:00
cwandev 1af984a64f fix(baoyu-image-gen): align OpenRouter image generation with current API 2026-03-19 17:36:03 +08:00
Jim Liu 宝玉 60363fc2df chore: release v1.73.1 2026-03-18 22:00:27 -05:00
Jim Liu 宝玉 28ec1053f6 refactor(baoyu-danger-x-to-markdown): migrate tests from bun:test to node:test 2026-03-18 21:59:49 -05:00
Jim Liu 宝玉 5a6afd576f chore: release v1.73.0 2026-03-18 21:56:14 -05:00
Jim Liu 宝玉 ebc74a10ad feat(baoyu-danger-x-to-markdown): add video media support for X articles 2026-03-18 21:54:21 -05:00
浪不能停 e79a42fd94 fix: 正文图片上传使用 media/uploadimg 接口
- 区分正文图片和封面图片的上传接口
- 正文图片使用 media/uploadimg (返回 URL)
- 封面图片使用 material/add_material (返回 media_id)
- 添加 uploadType 参数支持两种上传方式
- 优化错误提示,告知用户 news 类型需要封面图
2026-03-18 17:23:48 +08:00
Jim Liu 宝玉 ea84f21439 chore: release v1.72.0 2026-03-18 02:25:15 -05:00
Jim Liu 宝玉 0b9e51d6cc feat(baoyu-danger-x-to-markdown): add MARKDOWN entity support for X articles 2026-03-18 02:24:15 -05:00
33 changed files with 1699 additions and 195 deletions
+1 -1
View File
@@ -6,7 +6,7 @@
},
"metadata": {
"description": "Skills shared by Baoyu for improving daily work efficiency",
"version": "1.71.0"
"version": "1.74.1"
},
"plugins": [
{
+42
View File
@@ -2,6 +2,48 @@
English | [中文](./CHANGELOG.zh.md)
## 1.74.1 - 2026-03-21
### Fixes
- `baoyu-image-gen`: align OpenRouter image generation with current API, harden image support, and narrow Gemini aspect ratios (by @cwandev)
- `baoyu-image-gen`: broaden OpenRouter model detection and aspect ratio validation
## 1.74.0 - 2026-03-20
### Features
- `baoyu-markdown-to-html`: CLI now supports all rendering options — color, font-family, font-size, code-theme, mac-code-block, line-number, count, legend
### Fixes
- `baoyu-markdown-to-html`: fix CSS custom property regex to handle quoted values; grace/simple themes now layer default CSS
## 1.73.3 - 2026-03-20
### Fixes
- `baoyu-post-to-wechat`: fix placeholder replacement to avoid shorter placeholders matching longer numbered variants
## 1.73.2 - 2026-03-20
### Fixes
- `baoyu-post-to-wechat`: fix body image upload to correctly use media/uploadimg API with format and size validation (by @AICreator-Wind)
### Refactor
- `baoyu-post-to-wechat`: extract image processor module for local format conversion (WebP/BMP/GIF → JPEG/PNG) instead of material API fallback
## 1.73.1 - 2026-03-18
### Refactor
- `baoyu-danger-x-to-markdown`: migrate tests from bun:test to node:test
## 1.73.0 - 2026-03-18
### Features
- `baoyu-danger-x-to-markdown`: add video media support for X articles with poster image and video link rendering
## 1.72.0 - 2026-03-18
### Features
- `baoyu-danger-x-to-markdown`: add MARKDOWN entity support for rendering embedded markdown/code blocks in X articles
## 1.71.0 - 2026-03-17
### Features
+42
View File
@@ -2,6 +2,48 @@
[English](./CHANGELOG.md) | 中文
## 1.74.1 - 2026-03-21
### 修复
- `baoyu-image-gen`:对齐 OpenRouter 图像生成与当前 API,增强图像支持,收窄 Gemini 宽高比范围 (by @cwandev)
- `baoyu-image-gen`:扩展 OpenRouter 模型检测和宽高比验证
## 1.74.0 - 2026-03-20
### 新功能
- `baoyu-markdown-to-html`:CLI 支持全部渲染选项 — color、font-family、font-size、code-theme、mac-code-block、line-number、count、legend
### 修复
- `baoyu-markdown-to-html`:修复 CSS 自定义属性正则无法处理带引号值的问题;grace/simple 主题现在会叠加 default 主题 CSS
## 1.73.3 - 2026-03-20
### 修复
- `baoyu-post-to-wechat`:修复占位符替换时短占位符错误匹配更长编号变体的问题
## 1.73.2 - 2026-03-20
### 修复
- `baoyu-post-to-wechat`:修复正文图片上传,正确使用 media/uploadimg 接口并处理格式和大小限制 (by @AICreator-Wind)
### 重构
- `baoyu-post-to-wechat`:提取图片处理模块,本地转换不支持的格式(WebP/BMP/GIF → JPEG/PNG)而非回退到 material 接口
## 1.73.1 - 2026-03-18
### 重构
- `baoyu-danger-x-to-markdown`:测试从 bun:test 迁移至 node:test
## 1.73.0 - 2026-03-18
### 新功能
- `baoyu-danger-x-to-markdown`:支持 X 文章中的视频媒体,渲染封面图和视频链接
## 1.72.0 - 2026-03-18
### 新功能
- `baoyu-danger-x-to-markdown`:支持渲染 X 文章中嵌入的 MARKDOWN 实体(代码块等)
## 1.71.0 - 2026-03-17
### 新功能
+1 -1
View File
@@ -1,6 +1,6 @@
# CLAUDE.md
Claude Code marketplace plugin providing AI-powered content generation skills. Version: **1.71.0**.
Claude Code marketplace plugin providing AI-powered content generation skills. Version: **1.74.0**.
## Architecture
+34
View File
@@ -9,12 +9,26 @@ import { COLOR_PRESETS, FONT_FAMILY_MAP } from "./constants.ts";
import {
buildMarkdownDocumentMeta,
formatTimestamp,
renderMarkdownDocument,
resolveColorToken,
resolveFontFamilyToken,
resolveMarkdownStyle,
resolveRenderOptions,
} from "./document.ts";
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, `\\$&`);
}
function findInlineStyle(html: string, tagName: string, text: string): string {
const pattern = new RegExp(
`<${tagName}[^>]*style="([^"]*)"[^>]*>${escapeRegExp(text)}</${tagName}>`,
);
const match = html.match(pattern);
assert.ok(match, `Expected inline style for <${tagName}>${text}</${tagName}>`);
return match![1]!;
}
function useCwd(t: TestContext, cwd: string): void {
const previous = process.cwd();
process.chdir(cwd);
@@ -138,3 +152,23 @@ keep_title: true
assert.equal(explicit.fontSize, "18px");
assert.equal(explicit.keepTitle, false);
});
test("renderMarkdownDocument layers default rules into grace theme before CSS inlining", async () => {
const { html } = await renderMarkdownDocument(
`## Section\n\nParagraph with **bold** text.`,
{ keepTitle: true, theme: "grace" },
);
const h2Style = findInlineStyle(html, "h2", "Section");
assert.match(h2Style, /background: #92617E/);
assert.match(h2Style, /box-shadow: 0 4px 6px rgba\(0, 0, 0, 0\.1\)/);
const pMatch = html.match(/<p[^>]*style="([^"]*)"[^>]*>/);
assert.ok(pMatch, "Expected inline style on <p> tag");
assert.match(pMatch![1]!, /color:/);
const strongPattern = /<strong[^>]*style="([^"]*)"[^>]*>bold<\/strong>/;
const strongMatch = html.match(strongPattern);
assert.ok(strongMatch, "Expected inline style for <strong>bold</strong>");
assert.match(strongMatch![1]!, /font-weight:/);
});
@@ -59,6 +59,17 @@ test("normalizeCssText and normalizeInlineCss replace variables and strip declar
assert.doesNotMatch(normalizedHtml, /var\(--md-primary-color\)/);
});
test("normalizeInlineCss removes quoted custom property values without leaving fragments behind", () => {
const normalizedHtml = normalizeInlineCss(
`<html style="--md-font-family: Menlo, Monaco, 'Courier New', monospace; color: var(--md-primary-color)"></html>`,
DEFAULT_STYLE,
);
assert.match(normalizedHtml, /style=" color: #0F4C81"/);
assert.doesNotMatch(normalizedHtml, /Courier New/);
assert.doesNotMatch(normalizedHtml, /--md-font-family/);
});
test("HTML structure helpers hoist nested lists and remove the first heading", () => {
const nestedList = `<ul><li>Parent<ul><li>Child</li></ul></li></ul>`;
assert.equal(
+7 -7
View File
@@ -100,13 +100,13 @@ export function normalizeCssText(cssText: string, style: StyleConfig = DEFAULT_S
.replace(/var\(--md-accent-color\)/g, style.accentColor)
.replace(/var\(--md-container-bg\)/g, style.containerBg)
.replace(/hsl\(var\(--foreground\)\)/g, "#3f3f3f")
.replace(/--md-primary-color:\s*[^;"']+;?/g, "")
.replace(/--md-font-family:\s*[^;"']+;?/g, "")
.replace(/--md-font-size:\s*[^;"']+;?/g, "")
.replace(/--blockquote-background:\s*[^;"']+;?/g, "")
.replace(/--md-accent-color:\s*[^;"']+;?/g, "")
.replace(/--md-container-bg:\s*[^;"']+;?/g, "")
.replace(/--foreground:\s*[^;"']+;?/g, "");
.replace(/--md-primary-color:\s*[^;]+;?/g, "")
.replace(/--md-font-family:\s*[^;]+;?/g, "")
.replace(/--md-font-size:\s*[^;]+;?/g, "")
.replace(/--blockquote-background:\s*[^;]+;?/g, "")
.replace(/--md-accent-color:\s*[^;]+;?/g, "")
.replace(/--md-container-bg:\s*[^;]+;?/g, "")
.replace(/--foreground:\s*[^;]+;?/g, "");
}
export function normalizeInlineCss(html: string, style: StyleConfig = DEFAULT_STYLE): string {
+12 -1
View File
@@ -6,6 +6,7 @@ import type { ThemeName } from "./types.js";
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
export const THEME_DIR = path.resolve(SCRIPT_DIR, "themes");
const FALLBACK_THEMES: ThemeName[] = ["default", "grace", "simple"];
const THEMES_EXTENDING_DEFAULT = new Set<ThemeName>(["grace", "simple"]);
function stripOutputScope(cssContent: string): string {
let css = cssContent;
@@ -41,6 +42,7 @@ export function loadThemeCss(theme: ThemeName): {
themeCss: string;
} {
const basePath = path.join(THEME_DIR, "base.css");
const defaultThemePath = path.join(THEME_DIR, "default.css");
const themePath = path.join(THEME_DIR, `${theme}.css`);
if (!fs.existsSync(basePath)) {
@@ -51,9 +53,18 @@ export function loadThemeCss(theme: ThemeName): {
throw new Error(`Missing theme CSS for "${theme}": ${themePath}`);
}
const layeredThemeCss: string[] = [];
if (theme !== "default" && THEMES_EXTENDING_DEFAULT.has(theme)) {
if (!fs.existsSync(defaultThemePath)) {
throw new Error(`Missing default theme CSS: ${defaultThemePath}`);
}
layeredThemeCss.push(fs.readFileSync(defaultThemePath, "utf-8"));
}
layeredThemeCss.push(fs.readFileSync(themePath, "utf-8"));
return {
baseCss: fs.readFileSync(basePath, "utf-8"),
themeCss: fs.readFileSync(themePath, "utf-8"),
themeCss: layeredThemeCss.join("\n"),
};
}
@@ -0,0 +1,179 @@
import assert from "node:assert/strict";
import test from "node:test";
import { formatArticleMarkdown } from "./markdown.js";
test("formatArticleMarkdown renders MARKDOWN entities from atomic blocks", () => {
const article = {
title: "Atomic Markdown Example",
content_state: {
blocks: [
{
type: "unstyled",
text: "Before the snippet.",
entityRanges: [],
},
{
type: "atomic",
text: " ",
entityRanges: [{ key: 0, offset: 0, length: 1 }],
},
{
type: "unstyled",
text: "After the snippet.",
entityRanges: [],
},
],
entityMap: {
"0": {
key: "5",
value: {
type: "MARKDOWN",
mutability: "Mutable",
data: {
markdown: "```python\nprint('hello from x article')\n```\n",
},
},
},
},
},
};
const { markdown } = formatArticleMarkdown(article);
assert.ok(markdown.includes("Before the snippet."));
assert.ok(markdown.includes("```python\nprint('hello from x article')\n```"));
assert.ok(markdown.includes("After the snippet."));
assert.strictEqual(markdown, `# Atomic Markdown Example
Before the snippet.
\`\`\`python
print('hello from x article')
\`\`\`
After the snippet.`);
});
test("formatArticleMarkdown renders article video media as poster plus video link", () => {
const posterUrl = "https://pbs.twimg.com/amplify_video_thumb/123/img/poster.jpg";
const videoUrl = "https://video.twimg.com/amplify_video/123/vid/avc1/720x720/demo.mp4?tag=21";
const article = {
title: "Video Example",
content_state: {
blocks: [
{
type: "unstyled",
text: "Intro text.",
entityRanges: [],
},
{
type: "atomic",
text: " ",
entityRanges: [{ key: 0, offset: 0, length: 1 }],
},
],
entityMap: {
"0": {
key: "0",
value: {
type: "MEDIA",
mutability: "Immutable",
data: {
caption: "Demo reel",
mediaItems: [{ mediaId: "vid-1" }],
},
},
},
},
},
media_entities: [
{
media_id: "vid-1",
media_info: {
__typename: "ApiVideo",
preview_image: {
original_img_url: posterUrl,
},
variants: [
{
content_type: "video/mp4",
bit_rate: 256000,
url: videoUrl,
},
],
},
},
],
};
const { markdown } = formatArticleMarkdown(article);
assert.ok(markdown.includes("Intro text."));
assert.ok(markdown.includes(`![Demo reel](${posterUrl})`));
assert.ok(markdown.includes(`[video](${videoUrl})`));
assert.ok(!markdown.includes(`![Demo reel](${videoUrl})`));
assert.ok(!markdown.includes("## Media"));
});
test("formatArticleMarkdown renders unused article videos in trailing media section", () => {
const posterUrl = "https://pbs.twimg.com/amplify_video_thumb/456/img/poster.jpg";
const videoUrl = "https://video.twimg.com/amplify_video/456/vid/avc1/1080x1080/demo.mp4?tag=21";
const article = {
title: "Trailing Media Example",
plain_text: "Body text.",
media_entities: [
{
media_id: "vid-2",
media_info: {
__typename: "ApiVideo",
preview_image: {
original_img_url: posterUrl,
},
variants: [
{
content_type: "video/mp4",
bit_rate: 832000,
url: videoUrl,
},
],
},
},
],
};
const { markdown, coverUrl } = formatArticleMarkdown(article);
assert.strictEqual(coverUrl, null);
assert.ok(markdown.includes("## Media"));
assert.ok(markdown.includes(`![video](${posterUrl})`));
assert.ok(markdown.includes(`[video](${videoUrl})`));
});
test("formatArticleMarkdown keeps coverUrl as preview image for video cover media", () => {
const posterUrl = "https://pbs.twimg.com/amplify_video_thumb/789/img/poster.jpg";
const videoUrl = "https://video.twimg.com/amplify_video/789/vid/avc1/720x720/demo.mp4?tag=21";
const article = {
title: "Video Cover Example",
plain_text: "Body text.",
cover_media: {
media_info: {
__typename: "ApiVideo",
preview_image: {
original_img_url: posterUrl,
},
variants: [
{
content_type: "video/mp4",
bit_rate: 1280000,
url: videoUrl,
},
],
},
},
};
const { coverUrl } = formatArticleMarkdown(article);
assert.strictEqual(coverUrl, posterUrl);
});
@@ -18,6 +18,17 @@ export type FormatArticleOptions = {
referencedTweets?: Map<string, ReferencedTweetInfo>;
};
type ResolvedMediaAsset =
| {
kind: "image";
url: string;
}
| {
kind: "video";
url: string;
posterUrl?: string;
};
function coerceArticleEntity(value: unknown): ArticleEntity | null {
if (!value || typeof value !== "object") return null;
const candidate = value as ArticleEntity;
@@ -109,58 +120,127 @@ function resolveEntityEntry(
return entityMap[String(entityKey)];
}
function resolveMediaUrl(info?: ArticleMediaInfo): string | undefined {
function resolveVideoUrl(info?: ArticleMediaInfo): string | undefined {
if (!info) return undefined;
if (info.original_img_url) return info.original_img_url;
if (info.preview_image?.original_img_url) return info.preview_image.original_img_url;
const variants = info.variants ?? [];
const mp4 = variants
.filter((variant) => variant?.content_type?.includes("video"))
.sort((a, b) => (b.bit_rate ?? 0) - (a.bit_rate ?? 0))[0];
return mp4?.url ?? variants[0]?.url;
return mp4?.url ?? variants.find((variant) => typeof variant?.url === "string")?.url;
}
function buildMediaById(article: ArticleEntity): Map<string, string> {
const map = new Map<string, string>();
function resolveMediaAsset(info?: ArticleMediaInfo): ResolvedMediaAsset | undefined {
if (!info) return undefined;
const posterUrl = info.preview_image?.original_img_url ?? info.original_img_url;
const videoUrl = resolveVideoUrl(info);
if (videoUrl) {
return {
kind: "video",
url: videoUrl,
posterUrl,
};
}
const imageUrl = info.original_img_url ?? info.preview_image?.original_img_url;
if (imageUrl) {
return {
kind: "image",
url: imageUrl,
};
}
return undefined;
}
function resolveFallbackMediaAsset(rawUrl?: string): ResolvedMediaAsset | undefined {
if (!rawUrl) return undefined;
if (/^https:\/\/video\.twimg\.com\//i.test(rawUrl) || /\.(mp4|m4v|mov|webm)(?:$|[?#])/i.test(rawUrl)) {
return {
kind: "video",
url: rawUrl,
};
}
return {
kind: "image",
url: rawUrl,
};
}
function resolveCoverUrl(info?: ArticleMediaInfo): string | undefined {
if (!info) return undefined;
return info.original_img_url ?? info.preview_image?.original_img_url;
}
function buildMediaIdentity(asset: ResolvedMediaAsset): string {
return asset.kind === "video"
? `video:${asset.url}:${asset.posterUrl ?? ""}`
: `image:${asset.url}`;
}
function renderMediaLines(
asset: ResolvedMediaAsset,
altText: string,
usedUrls: Set<string>
): string[] {
if (asset.kind === "video") {
const lines: string[] = [];
if (asset.posterUrl && !usedUrls.has(asset.posterUrl)) {
usedUrls.add(asset.posterUrl);
lines.push(`![${altText || "video"}](${asset.posterUrl})`);
}
if (!usedUrls.has(asset.url)) {
usedUrls.add(asset.url);
lines.push(`[video](${asset.url})`);
}
return lines;
}
if (usedUrls.has(asset.url)) {
return [];
}
usedUrls.add(asset.url);
return [`![${altText}](${asset.url})`];
}
function buildMediaById(article: ArticleEntity): Map<string, ResolvedMediaAsset> {
const map = new Map<string, ResolvedMediaAsset>();
for (const entity of article.media_entities ?? []) {
if (!entity?.media_id) continue;
const url = resolveMediaUrl(entity.media_info);
if (url) {
map.set(entity.media_id, url);
const asset = resolveMediaAsset(entity.media_info);
if (asset) {
map.set(entity.media_id, asset);
}
}
return map;
}
function collectMediaUrls(
article: ArticleEntity,
usedUrls: Set<string>,
excludeUrl?: string
): string[] {
const urls: string[] = [];
const addUrl = (url?: string) => {
if (!url) return;
if (excludeUrl && url === excludeUrl) {
usedUrls.add(url);
return;
}
if (usedUrls.has(url)) return;
usedUrls.add(url);
urls.push(url);
function collectMediaAssets(article: ArticleEntity): ResolvedMediaAsset[] {
const assets: ResolvedMediaAsset[] = [];
const seen = new Set<string>();
const addAsset = (asset?: ResolvedMediaAsset) => {
if (!asset) return;
const identity = buildMediaIdentity(asset);
if (seen.has(identity)) return;
seen.add(identity);
assets.push(asset);
};
for (const entity of article.media_entities ?? []) {
addUrl(resolveMediaUrl(entity?.media_info));
addAsset(resolveMediaAsset(entity?.media_info));
}
return urls;
return assets;
}
function resolveEntityMediaLines(
entityKey: number | undefined,
entityMap: ArticleContentState["entityMap"] | undefined,
entityLookup: EntityLookup,
mediaById: Map<string, string>,
mediaById: Map<string, ResolvedMediaAsset>,
usedUrls: Set<string>
): string[] {
if (entityKey === undefined) return [];
@@ -182,17 +262,16 @@ function resolveEntityMediaLines(
: typeof item?.media_id === "string"
? item.media_id
: undefined;
const url = mediaId ? mediaById.get(mediaId) : undefined;
if (url && !usedUrls.has(url)) {
usedUrls.add(url);
lines.push(`![${altText}](${url})`);
const asset = mediaId ? mediaById.get(mediaId) : undefined;
if (asset) {
lines.push(...renderMediaLines(asset, altText, usedUrls));
}
}
const fallbackUrl = typeof value.data?.url === "string" ? value.data.url : undefined;
if (fallbackUrl && !usedUrls.has(fallbackUrl)) {
usedUrls.add(fallbackUrl);
lines.push(`![${altText}](${fallbackUrl})`);
const fallbackAsset = resolveFallbackMediaAsset(fallbackUrl);
if (fallbackAsset) {
lines.push(...renderMediaLines(fallbackAsset, altText, usedUrls));
}
return lines;
@@ -237,6 +316,22 @@ function resolveEntityTweetLines(
return lines;
}
function resolveEntityMarkdownLines(
entityKey: number | undefined,
entityMap: ArticleContentState["entityMap"] | undefined,
entityLookup: EntityLookup
): string[] {
if (entityKey === undefined) return [];
const entry = resolveEntityEntry(entityKey, entityMap, entityLookup);
const value = entry?.value;
if (!value || value.type !== "MARKDOWN") return [];
const markdown = typeof value.data?.markdown === "string" ? value.data.markdown : "";
const normalized = markdown.replace(/\r\n/g, "\n").trimEnd();
if (!normalized) return [];
return normalized.split("\n");
}
function buildMediaLinkMap(
entityMap: ArticleContentState["entityMap"] | undefined
): Map<number, string> {
@@ -330,7 +425,7 @@ function renderContentBlocks(
blocks: ArticleBlock[],
entityMap: ArticleContentState["entityMap"] | undefined,
entityLookup: EntityLookup,
mediaById: Map<string, string>,
mediaById: Map<string, ResolvedMediaAsset>,
usedUrls: Set<string>,
mediaLinkMap: Map<number, string>,
referencedTweets?: Map<string, ReferencedTweetInfo>
@@ -397,6 +492,16 @@ function renderContentBlocks(
return [...new Set(linkLines)];
};
const collectMarkdownLines = (block: ArticleBlock): string[] => {
const ranges = Array.isArray(block.entityRanges) ? block.entityRanges : [];
const markdownLines: string[] = [];
for (const range of ranges) {
if (typeof range?.key !== "number") continue;
markdownLines.push(...resolveEntityMarkdownLines(range.key, entityMap, entityLookup));
}
return markdownLines;
};
const pushTrailingMedia = (mediaLines: string[]) => {
if (mediaLines.length > 0) {
pushBlock(mediaLines, "media");
@@ -441,6 +546,11 @@ function renderContentBlocks(
pushBlock(tweetLines, "quote");
}
const markdownLines = collectMarkdownLines(block);
if (markdownLines.length > 0) {
pushBlock(markdownLines, "text");
}
const mediaLines = collectMediaLines(block);
if (mediaLines.length > 0) {
pushBlock(mediaLines, "media");
@@ -571,7 +681,7 @@ export function formatArticleMarkdown(
lines.push(`# ${title}`);
}
const coverUrl = resolveMediaUrl(candidate.cover_media?.media_info) ?? null;
const coverUrl = resolveCoverUrl(candidate.cover_media?.media_info) ?? null;
if (coverUrl) {
usedUrls.add(coverUrl);
}
@@ -602,12 +712,13 @@ export function formatArticleMarkdown(
lines.push(candidate.preview_text.trim());
}
const mediaUrls = collectMediaUrls(candidate, usedUrls, coverUrl ?? undefined);
if (mediaUrls.length > 0) {
const trailingMediaLines: string[] = [];
for (const asset of collectMediaAssets(candidate)) {
trailingMediaLines.push(...renderMediaLines(asset, "", usedUrls));
}
if (trailingMediaLines.length > 0) {
lines.push("", "## Media", "");
for (const url of mediaUrls) {
lines.push(`![](${url})`);
}
lines.push(...trailingMediaLines);
}
return { markdown: lines.join("\n").trimEnd(), coverUrl };
@@ -202,6 +202,13 @@ function toHighResUrl(rawUrl: string): string {
}
}
function isPlausibleMediaUrl(rawUrl: string): boolean {
const ext = resolveExtensionFromUrl(rawUrl);
if (ext && (IMAGE_EXTENSIONS.has(ext) || VIDEO_EXTENSIONS.has(ext))) return true;
if (resolveKindFromHostname(rawUrl) !== undefined) return true;
return false;
}
function collectMarkdownLinkCandidates(markdown: string): MarkdownLinkCandidate[] {
const candidates: MarkdownLinkCandidate[] = [];
const seen = new Set<string>();
@@ -221,10 +228,12 @@ function collectMarkdownLinkCandidates(markdown: string): MarkdownLinkCandidate[
const label = match[1] ?? "";
const rawUrl = match[3] ?? "";
if (!rawUrl || seen.has(rawUrl)) continue;
const isImage = label.startsWith("![");
if (!isImage && !isPlausibleMediaUrl(rawUrl)) continue;
seen.add(rawUrl);
candidates.push({
url: rawUrl,
hint: label.startsWith("![") ? "image" : "unknown",
hint: isImage ? "image" : "unknown",
});
}
@@ -38,6 +38,7 @@ export type ArticleEntityMapEntry = {
mutability?: string;
data?: {
caption?: string;
markdown?: string;
mediaItems?: ArticleEntityMapMediaItem[];
url?: string;
tweetId?: string;
@@ -0,0 +1,168 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { CliArgs } from "../types.ts";
import {
buildContent,
buildRequestBody,
extractImageFromResponse,
getAspectRatio,
getImageSize,
validateArgs,
} from "./openrouter.ts";
const GEMINI_MODEL = "google/gemini-3.1-flash-image-preview";
const GEMINI_25_MODEL = "google/gemini-2.5-flash-image";
const GPT_5_IMAGE_MODEL = "openai/gpt-5-image";
const OPENROUTER_AUTO_MODEL = "openrouter/auto";
const FLUX_MODEL = "black-forest-labs/flux.2-pro";
function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
return {
prompt: null,
promptFiles: [],
imagePath: null,
provider: null,
model: null,
aspectRatio: null,
size: null,
quality: null,
imageSize: null,
referenceImages: [],
n: 1,
batchFile: null,
jobs: null,
json: false,
help: false,
...overrides,
};
}
test("OpenRouter request body uses image_config and string content for text-only prompts", () => {
const args = makeArgs({ aspectRatio: "16:9", quality: "2k" });
const body = buildRequestBody("hello", GEMINI_MODEL, args, []);
assert.deepEqual(body.image_config, {
image_size: "2K",
aspect_ratio: "16:9",
});
assert.deepEqual(body.provider, {
require_parameters: true,
});
assert.deepEqual(body.modalities, ["image", "text"]);
assert.equal(body.stream, false);
assert.equal(body.messages[0].content, "hello");
});
test("OpenRouter request body keeps text+image modalities for current text+image models", () => {
for (const model of [GEMINI_MODEL, GEMINI_25_MODEL, GPT_5_IMAGE_MODEL, OPENROUTER_AUTO_MODEL]) {
const body = buildRequestBody("hello", model, makeArgs({ quality: "2k" }), []);
assert.deepEqual(body.image_config, {
image_size: "2K",
});
assert.deepEqual(body.provider, {
require_parameters: true,
});
assert.deepEqual(body.modalities, ["image", "text"]);
assert.equal(body.messages[0].content, "hello");
}
});
test("OpenRouter request body uses image-only modalities for image-only models under CLI defaults", () => {
const body = buildRequestBody("hello", FLUX_MODEL, makeArgs({ quality: "2k" }), []);
assert.deepEqual(body.image_config, {
image_size: "2K",
});
assert.deepEqual(body.provider, {
require_parameters: true,
});
assert.deepEqual(body.modalities, ["image"]);
assert.equal(body.stream, false);
assert.equal(body.messages[0].content, "hello");
});
test("OpenRouter helper omits image_config when no size or quality is passed", () => {
const body = buildRequestBody("hello", FLUX_MODEL, makeArgs(), []);
assert.equal(body.image_config, undefined);
assert.equal(body.provider, undefined);
assert.deepEqual(body.modalities, ["image"]);
assert.equal(body.stream, false);
assert.equal(body.messages[0].content, "hello");
});
test("OpenRouter request body keeps multimodal array content when references are provided", () => {
const content = buildContent("hello", ["data:image/png;base64,abc"]);
assert.ok(Array.isArray(content));
assert.deepEqual(content[0], { type: "text", text: "hello" });
assert.deepEqual(content[1], {
type: "image_url",
image_url: { url: "data:image/png;base64,abc" },
});
});
test("OpenRouter size and aspect helpers infer supported values", () => {
assert.equal(getImageSize(makeArgs()), null);
assert.equal(getImageSize(makeArgs({ quality: "normal" })), "1K");
assert.equal(getImageSize(makeArgs({ size: "2048x1024" })), "2K");
assert.equal(getAspectRatio(GEMINI_MODEL, makeArgs({ size: "1600x900" })), "16:9");
assert.equal(getAspectRatio(GEMINI_MODEL, makeArgs({ size: "1024x4096" })), "1:4");
assert.equal(getAspectRatio(GEMINI_25_MODEL, makeArgs({ size: "1600x900" })), "16:9");
assert.equal(getAspectRatio(FLUX_MODEL, makeArgs({ size: "1024x4096" })), null);
});
test("OpenRouter validates explicit aspect ratios and inferred size ratios against model support", () => {
assert.doesNotThrow(() =>
validateArgs(GEMINI_MODEL, makeArgs({ aspectRatio: "1:4" })),
);
assert.doesNotThrow(() =>
validateArgs(GEMINI_MODEL, makeArgs({ size: "1024x4096" })),
);
assert.throws(
() => validateArgs(GEMINI_25_MODEL, makeArgs({ aspectRatio: "1:4" })),
/does not support aspect ratio 1:4/,
);
assert.throws(
() => validateArgs(FLUX_MODEL, makeArgs({ aspectRatio: "1:4" })),
/does not support aspect ratio 1:4/,
);
assert.throws(
() => validateArgs(GEMINI_MODEL, makeArgs({ size: "2048x1024" })),
/does not support size 2048x1024 \(aspect ratio 2:1\)/,
);
});
test("OpenRouter response extraction supports inline image data and finish_reason errors", async () => {
const bytes = await extractImageFromResponse({
choices: [
{
message: {
images: [
{
image_url: {
url: `data:image/png;base64,${Buffer.from("hello").toString("base64")}`,
},
},
],
},
},
],
});
assert.equal(Buffer.from(bytes).toString("utf8"), "hello");
await assert.rejects(
() =>
extractImageFromResponse({
choices: [
{
finish_reason: "error",
native_finish_reason: "MALFORMED_FUNCTION_CALL",
message: { content: null },
},
],
}),
/finish_reason=MALFORMED_FUNCTION_CALL/,
);
});
@@ -3,6 +3,19 @@ import { readFile } from "node:fs/promises";
import type { CliArgs } from "../types";
const DEFAULT_MODEL = "google/gemini-3.1-flash-image-preview";
const COMMON_ASPECT_RATIOS = [
"1:1",
"2:3",
"3:2",
"3:4",
"4:3",
"4:5",
"5:4",
"9:16",
"16:9",
"21:9",
];
const GEMINI_EXTENDED_ASPECT_RATIOS = ["1:4", "4:1", "1:8", "8:1"];
type OpenRouterImageEntry = {
image_url?: string | { url?: string | null } | null;
@@ -18,9 +31,11 @@ type OpenRouterMessagePart = {
type OpenRouterResponse = {
choices?: Array<{
finish_reason?: string | null;
native_finish_reason?: string | null;
message?: {
images?: OpenRouterImageEntry[];
content?: string | OpenRouterMessagePart[];
content?: string | OpenRouterMessagePart[] | null;
};
}>;
};
@@ -29,6 +44,36 @@ export function getDefaultModel(): string {
return process.env.OPENROUTER_IMAGE_MODEL || DEFAULT_MODEL;
}
function normalizeModelId(model: string): string {
return model.trim().toLowerCase().split(":")[0]!;
}
function isTextAndImageModel(model: string): boolean {
const normalized = normalizeModelId(model);
if (normalized === "openrouter/auto") {
return true;
}
if (normalized.startsWith("google/gemini-") && normalized.includes("image")) {
return true;
}
if (normalized.startsWith("openai/gpt-") && normalized.includes("image")) {
return true;
}
return false;
}
function getSupportedAspectRatios(model: string): Set<string> {
const normalized = normalizeModelId(model);
if (normalized !== "google/gemini-3.1-flash-image-preview") {
return new Set(COMMON_ASPECT_RATIOS);
}
return new Set([...COMMON_ASPECT_RATIOS, ...GEMINI_EXTENDED_ASPECT_RATIOS]);
}
function getApiKey(): string | null {
return process.env.OPENROUTER_API_KEY || null;
}
@@ -103,17 +148,50 @@ function inferImageSize(size: string | null): "1K" | "2K" | "4K" | null {
return "4K";
}
function getImageSize(args: CliArgs): "1K" | "2K" | "4K" {
export function getImageSize(args: CliArgs): "1K" | "2K" | "4K" | null {
if (args.imageSize) return args.imageSize as "1K" | "2K" | "4K";
const inferredFromSize = inferImageSize(args.size);
if (inferredFromSize) return inferredFromSize;
return args.quality === "normal" ? "1K" : "2K";
if (args.quality === "normal") return "1K";
if (args.quality === "2k") return "2K";
return null;
}
function getAspectRatio(args: CliArgs): string | null {
return args.aspectRatio || inferAspectRatio(args.size);
export function getAspectRatio(model: string, args: CliArgs): string | null {
if (args.aspectRatio) return args.aspectRatio;
const inferred = inferAspectRatio(args.size);
if (!inferred || !getSupportedAspectRatios(model).has(inferred)) {
return null;
}
return inferred;
}
function getModalities(model: string): string[] {
return isTextAndImageModel(model) ? ["image", "text"] : ["image"];
}
export function validateArgs(model: string, args: CliArgs): void {
const requestedAspectRatio = args.aspectRatio || inferAspectRatio(args.size);
if (!requestedAspectRatio) {
return;
}
const supported = getSupportedAspectRatios(model);
if (supported.has(requestedAspectRatio)) {
return;
}
const requestedValue = args.aspectRatio
? `aspect ratio ${requestedAspectRatio}`
: `size ${args.size} (aspect ratio ${requestedAspectRatio})`;
throw new Error(
`OpenRouter model ${model} does not support ${requestedValue}. Supported values: ${Array.from(supported).join(", ")}`
);
}
function getMimeType(filename: string): string {
@@ -129,7 +207,14 @@ async function readImageAsDataUrl(filePath: string): Promise<string> {
return `data:${getMimeType(filePath)};base64,${bytes.toString("base64")}`;
}
function buildContent(prompt: string, referenceImages: string[]): Array<Record<string, unknown>> {
export function buildContent(
prompt: string,
referenceImages: string[],
): string | Array<Record<string, unknown>> {
if (referenceImages.length === 0) {
return prompt;
}
const content: Array<Record<string, unknown>> = [{ type: "text", text: prompt }];
for (const imageUrl of referenceImages) {
@@ -171,8 +256,9 @@ async function downloadImage(value: string): Promise<Uint8Array> {
return Uint8Array.from(Buffer.from(value, "base64"));
}
async function extractImageFromResponse(result: OpenRouterResponse): Promise<Uint8Array> {
const message = result.choices?.[0]?.message;
export async function extractImageFromResponse(result: OpenRouterResponse): Promise<Uint8Array> {
const choice = result.choices?.[0];
const message = choice?.message;
for (const image of message?.images ?? []) {
const imageUrl = extractImageUrl(image);
@@ -194,7 +280,52 @@ async function extractImageFromResponse(result: OpenRouterResponse): Promise<Uin
if (inline) return inline;
}
throw new Error("No image in OpenRouter response");
const finishReason =
choice?.native_finish_reason || choice?.finish_reason || "unknown";
throw new Error(
`No image in OpenRouter response (finish_reason=${finishReason})`,
);
}
export function buildRequestBody(
prompt: string,
model: string,
args: CliArgs,
referenceImages: string[],
): Record<string, unknown> {
validateArgs(model, args);
const imageConfig: Record<string, string> = {};
const imageSize = getImageSize(args);
if (imageSize) {
imageConfig.image_size = imageSize;
}
const aspectRatio = getAspectRatio(model, args);
if (aspectRatio) {
imageConfig.aspect_ratio = aspectRatio;
}
const body: Record<string, unknown> = {
messages: [
{
role: "user",
content: buildContent(prompt, referenceImages),
},
],
modalities: getModalities(model),
stream: false,
};
if (Object.keys(imageConfig).length > 0) {
body.image_config = imageConfig;
body.provider = {
require_parameters: true,
};
}
return body;
}
export async function generateImage(
@@ -212,32 +343,15 @@ export async function generateImage(
referenceImages.push(await readImageAsDataUrl(refPath));
}
const imageGenerationOptions: Record<string, string> = {
size: getImageSize(args),
};
const aspectRatio = getAspectRatio(args);
if (aspectRatio) {
imageGenerationOptions.aspect_ratio = aspectRatio;
}
const body = {
model,
messages: [
{
role: "user",
content: buildContent(prompt, referenceImages),
},
],
modalities: ["image", "text"],
max_tokens: 256,
imageGenerationOptions,
providerPreferences: {
require_parameters: true,
},
...buildRequestBody(prompt, model, args, referenceImages),
};
console.log(`Generating image with OpenRouter (${model})...`, imageGenerationOptions);
console.log(
`Generating image with OpenRouter (${model})...`,
(body.image_config as Record<string, string>),
);
const response = await fetch(`${getBaseUrl()}/chat/completions`, {
method: "POST",
+1 -1
View File
@@ -1,6 +1,6 @@
---
name: baoyu-markdown-to-html
description: Converts Markdown to styled HTML with WeChat-compatible themes. Supports code highlighting, math, PlantUML, footnotes, alerts, infographics, and optional bottom citations for external links. Use when user asks for "markdown to html", "convert md to html", "mdhtml", "微信外链转底部引用", or needs styled HTML output from markdown.
description: Converts Markdown to styled HTML with WeChat-compatible themes. Supports code highlighting, math, PlantUML, footnotes, alerts, infographics, and optional bottom citations for external links. Use when user asks for "markdown to html", "convert md to html", "mdhtml", "微信外链转底部引用", or needs styled HTML output from markdown.
version: 1.56.1
metadata:
openclaw:
@@ -0,0 +1,53 @@
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
const SCRIPT_PATH = path.join(SCRIPT_DIR, "main.ts");
async function makeTempDir(prefix: string): Promise<string> {
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
}
test("CLI forwards wrapper title and vendor render options", async () => {
const root = await makeTempDir("baoyu-markdown-to-html-cli-");
const markdownPath = path.join(root, "article.md");
await fs.writeFile(markdownPath, "## Section\n\nParagraph with **bold** text.\n", "utf-8");
const { stdout } = await execFileAsync(
"bun",
[
SCRIPT_PATH,
markdownPath,
"--theme", "grace",
"--color", "red",
"--font-family", "mono",
"--font-size", "18",
"--keep-title",
"--title", "Overridden",
],
{ cwd: SCRIPT_DIR },
);
const result = JSON.parse(stdout.trim()) as {
htmlPath: string;
title: string;
};
assert.equal(result.title, "Overridden");
const html = await fs.readFile(result.htmlPath, "utf-8");
assert.match(html, /<title>Overridden<\/title>/);
assert.match(html, /<h2[^>]*style="[^"]*background: #A93226/);
assert.match(html, /<strong[^>]*style="[^"]*color: #A93226/);
assert.match(
html,
/<body[^>]*style="[^"]*font-family: Menlo, Monaco, 'Courier New', monospace;[^"]*font-size: 18px/,
);
});
+87 -33
View File
@@ -4,16 +4,22 @@ import path from "node:path";
import process from "node:process";
import {
COLOR_PRESETS,
FONT_FAMILY_MAP,
FONT_SIZE_OPTIONS,
THEME_NAMES,
extractSummaryFromBody,
extractTitleFromMarkdown,
formatTimestamp,
parseArgs,
parseFrontmatter,
renderMarkdownDocument,
replaceMarkdownImagesWithPlaceholders,
resolveContentImages,
serializeFrontmatter,
stripWrappingQuotes,
} from "baoyu-md";
} from "./vendor/baoyu-md/src/index.ts";
import type { CliOptions } from "./vendor/baoyu-md/src/types.ts";
interface ImageInfo {
placeholder: string;
@@ -30,9 +36,13 @@ interface ParsedResult {
contentImages: ImageInfo[];
}
type ConvertMarkdownOptions = Partial<Omit<CliOptions, "inputPath">> & {
title?: string;
};
export async function convertMarkdown(
markdownPath: string,
options?: { title?: string; theme?: string; keepTitle?: boolean; citeStatus?: boolean },
options?: ConvertMarkdownOptions,
): Promise<ParsedResult> {
const baseDir = path.dirname(markdownPath);
const content = fs.readFileSync(markdownPath, "utf-8");
@@ -56,20 +66,32 @@ export async function convertMarkdown(
summary = extractSummaryFromBody(body, 120);
}
const effectiveFrontmatter = options?.title
? { ...frontmatter, title }
: frontmatter;
const { images, markdown: rewrittenBody } = replaceMarkdownImagesWithPlaceholders(
body,
"MDTOHTMLIMGPH_",
);
const rewrittenMarkdown = `${serializeFrontmatter(frontmatter)}${rewrittenBody}`;
const rewrittenMarkdown = `${serializeFrontmatter(effectiveFrontmatter)}${rewrittenBody}`;
console.error(
`[markdown-to-html] Rendering with theme: ${theme ?? "default"}, keepTitle: ${keepTitle}, citeStatus: ${citeStatus}`,
);
const { html } = await renderMarkdownDocument(rewrittenMarkdown, {
codeTheme: options?.codeTheme,
countStatus: options?.countStatus,
citeStatus,
defaultTitle: title,
fontFamily: options?.fontFamily,
fontSize: options?.fontSize,
isMacCodeBlock: options?.isMacCodeBlock,
isShowLineNumber: options?.isShowLineNumber,
keepTitle,
legend: options?.legend,
primaryColor: options?.primaryColor,
theme,
});
@@ -111,18 +133,30 @@ export async function convertMarkdown(
};
}
function printUsage(): never {
function printUsage(exitCode = 0): never {
const colorNames = Object.keys(COLOR_PRESETS).join(", ");
const fontFamilyNames = Object.keys(FONT_FAMILY_MAP).join(", ");
console.log(`Convert Markdown to styled HTML
Usage:
npx -y bun main.ts <markdown_file> [options]
Options:
--title <title> Override title
--theme <name> Theme name (default, grace, simple). Default: default
--cite Convert ordinary external links to bottom citations. Default: off
--keep-title Keep the first heading in content. Default: false (removed)
--help Show this help
--title <title> Override title
--theme <name> Theme name (${THEME_NAMES.join(", ")}). Default: default
--color <name|hex> Primary color: ${colorNames}
--font-family <name> Font: ${fontFamilyNames}, 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
--no-mac-code-block Hide Mac-style code block header
--line-number Show line numbers in code blocks
--cite Convert ordinary external links to bottom citations. Default: off
--count Show reading time / word count
--legend <value> Image caption: title-alt, alt-title, title, alt, none
--keep-title Keep the first heading in content. Default: false (removed)
--help Show this help
Output:
HTML file saved to same directory as input markdown file.
@@ -142,40 +176,60 @@ Output JSON format:
Example:
npx -y bun main.ts article.md
npx -y bun main.ts article.md --theme grace
npx -y bun main.ts article.md --theme modern --color red
npx -y bun main.ts article.md --cite
`);
process.exit(0);
process.exit(exitCode);
}
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 extractTitleArg(argv: string[]): { renderArgs: string[]; title?: string } {
let title: string | undefined;
const renderArgs: string[] = [];
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i]!;
if (arg === "--title" || arg.startsWith("--title=")) {
const value = parseArgValue(argv, i, "--title");
if (!value) {
console.error("Missing value for --title");
printUsage(1);
}
title = value;
if (!arg.includes("=")) {
i += 1;
}
continue;
}
renderArgs.push(arg);
}
return { renderArgs, title };
}
async function main(): Promise<void> {
const args = process.argv.slice(2);
if (args.length === 0 || args.includes("--help") || args.includes("-h")) {
printUsage();
printUsage(0);
}
let markdownPath: string | undefined;
let title: string | undefined;
let theme: string | undefined;
let citeStatus = false;
let keepTitle = false;
for (let i = 0; i < args.length; i++) {
const arg = args[i]!;
if (arg === "--title" && args[i + 1]) {
title = args[++i];
} else if (arg === "--theme" && args[i + 1]) {
theme = args[++i];
} else if (arg === "--cite") {
citeStatus = true;
} else if (arg === "--keep-title") {
keepTitle = true;
} else if (!arg.startsWith("-")) {
markdownPath = arg;
}
const { renderArgs, title } = extractTitleArg(args);
const options = parseArgs(renderArgs);
if (!options) {
printUsage(1);
}
if (!markdownPath) {
console.error("Error: Markdown file path is required");
const markdownPath = path.resolve(process.cwd(), options.inputPath);
if (!markdownPath.toLowerCase().endsWith(".md")) {
console.error("Input file must end with .md");
process.exit(1);
}
@@ -184,7 +238,7 @@ async function main(): Promise<void> {
process.exit(1);
}
const result = await convertMarkdown(markdownPath, { title, theme, keepTitle, citeStatus });
const result = await convertMarkdown(markdownPath, { ...options, title });
console.log(JSON.stringify(result, null, 2));
}
@@ -9,12 +9,26 @@ import { COLOR_PRESETS, FONT_FAMILY_MAP } from "./constants.ts";
import {
buildMarkdownDocumentMeta,
formatTimestamp,
renderMarkdownDocument,
resolveColorToken,
resolveFontFamilyToken,
resolveMarkdownStyle,
resolveRenderOptions,
} from "./document.ts";
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, `\\$&`);
}
function findInlineStyle(html: string, tagName: string, text: string): string {
const pattern = new RegExp(
`<${tagName}[^>]*style="([^"]*)"[^>]*>${escapeRegExp(text)}</${tagName}>`,
);
const match = html.match(pattern);
assert.ok(match, `Expected inline style for <${tagName}>${text}</${tagName}>`);
return match![1]!;
}
function useCwd(t: TestContext, cwd: string): void {
const previous = process.cwd();
process.chdir(cwd);
@@ -138,3 +152,23 @@ keep_title: true
assert.equal(explicit.fontSize, "18px");
assert.equal(explicit.keepTitle, false);
});
test("renderMarkdownDocument layers default rules into grace theme before CSS inlining", async () => {
const { html } = await renderMarkdownDocument(
`## Section\n\nParagraph with **bold** text.`,
{ keepTitle: true, theme: "grace" },
);
const h2Style = findInlineStyle(html, "h2", "Section");
assert.match(h2Style, /background: #92617E/);
assert.match(h2Style, /box-shadow: 0 4px 6px rgba\(0, 0, 0, 0\.1\)/);
const pMatch = html.match(/<p[^>]*style="([^"]*)"[^>]*>/);
assert.ok(pMatch, "Expected inline style on <p> tag");
assert.match(pMatch![1]!, /color:/);
const strongPattern = /<strong[^>]*style="([^"]*)"[^>]*>bold<\/strong>/;
const strongMatch = html.match(strongPattern);
assert.ok(strongMatch, "Expected inline style for <strong>bold</strong>");
assert.match(strongMatch![1]!, /font-weight:/);
});
@@ -59,6 +59,17 @@ test("normalizeCssText and normalizeInlineCss replace variables and strip declar
assert.doesNotMatch(normalizedHtml, /var\(--md-primary-color\)/);
});
test("normalizeInlineCss removes quoted custom property values without leaving fragments behind", () => {
const normalizedHtml = normalizeInlineCss(
`<html style="--md-font-family: Menlo, Monaco, 'Courier New', monospace; color: var(--md-primary-color)"></html>`,
DEFAULT_STYLE,
);
assert.match(normalizedHtml, /style=" color: #0F4C81"/);
assert.doesNotMatch(normalizedHtml, /Courier New/);
assert.doesNotMatch(normalizedHtml, /--md-font-family/);
});
test("HTML structure helpers hoist nested lists and remove the first heading", () => {
const nestedList = `<ul><li>Parent<ul><li>Child</li></ul></li></ul>`;
assert.equal(
@@ -100,13 +100,13 @@ export function normalizeCssText(cssText: string, style: StyleConfig = DEFAULT_S
.replace(/var\(--md-accent-color\)/g, style.accentColor)
.replace(/var\(--md-container-bg\)/g, style.containerBg)
.replace(/hsl\(var\(--foreground\)\)/g, "#3f3f3f")
.replace(/--md-primary-color:\s*[^;"']+;?/g, "")
.replace(/--md-font-family:\s*[^;"']+;?/g, "")
.replace(/--md-font-size:\s*[^;"']+;?/g, "")
.replace(/--blockquote-background:\s*[^;"']+;?/g, "")
.replace(/--md-accent-color:\s*[^;"']+;?/g, "")
.replace(/--md-container-bg:\s*[^;"']+;?/g, "")
.replace(/--foreground:\s*[^;"']+;?/g, "");
.replace(/--md-primary-color:\s*[^;]+;?/g, "")
.replace(/--md-font-family:\s*[^;]+;?/g, "")
.replace(/--md-font-size:\s*[^;]+;?/g, "")
.replace(/--blockquote-background:\s*[^;]+;?/g, "")
.replace(/--md-accent-color:\s*[^;]+;?/g, "")
.replace(/--md-container-bg:\s*[^;]+;?/g, "")
.replace(/--foreground:\s*[^;]+;?/g, "");
}
export function normalizeInlineCss(html: string, style: StyleConfig = DEFAULT_STYLE): string {
@@ -6,6 +6,7 @@ import type { ThemeName } from "./types.js";
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
export const THEME_DIR = path.resolve(SCRIPT_DIR, "themes");
const FALLBACK_THEMES: ThemeName[] = ["default", "grace", "simple"];
const THEMES_EXTENDING_DEFAULT = new Set<ThemeName>(["grace", "simple"]);
function stripOutputScope(cssContent: string): string {
let css = cssContent;
@@ -41,6 +42,7 @@ export function loadThemeCss(theme: ThemeName): {
themeCss: string;
} {
const basePath = path.join(THEME_DIR, "base.css");
const defaultThemePath = path.join(THEME_DIR, "default.css");
const themePath = path.join(THEME_DIR, `${theme}.css`);
if (!fs.existsSync(basePath)) {
@@ -51,9 +53,18 @@ export function loadThemeCss(theme: ThemeName): {
throw new Error(`Missing theme CSS for "${theme}": ${themePath}`);
}
const layeredThemeCss: string[] = [];
if (theme !== "default" && THEMES_EXTENDING_DEFAULT.has(theme)) {
if (!fs.existsSync(defaultThemePath)) {
throw new Error(`Missing default theme CSS: ${defaultThemePath}`);
}
layeredThemeCss.push(fs.readFileSync(defaultThemePath, "utf-8"));
}
layeredThemeCss.push(fs.readFileSync(themePath, "utf-8"));
return {
baseCss: fs.readFileSync(basePath, "utf-8"),
themeCss: fs.readFileSync(themePath, "utf-8"),
themeCss: layeredThemeCss.join("\n"),
};
}
+147 -1
View File
@@ -4,32 +4,108 @@
"": {
"name": "baoyu-post-to-wechat-scripts",
"dependencies": {
"@jsquash/webp": "^1.5.0",
"baoyu-chrome-cdp": "file:./vendor/baoyu-chrome-cdp",
"baoyu-md": "file:./vendor/baoyu-md",
"jimp": "^1.6.0",
},
},
},
"packages": {
"@jimp/core": ["@jimp/core@1.6.0", "", { "dependencies": { "@jimp/file-ops": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "await-to-js": "^3.0.0", "exif-parser": "^0.1.12", "file-type": "^16.0.0", "mime": "3" } }, "sha512-EQQlKU3s9QfdJqiSrZWNTxBs3rKXgO2W+GxNXDtwchF3a4IqxDheFX1ti+Env9hdJXDiYLp2jTRjlxhPthsk8w=="],
"@jimp/diff": ["@jimp/diff@1.6.0", "", { "dependencies": { "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "pixelmatch": "^5.3.0" } }, "sha512-+yUAQ5gvRC5D1WHYxjBHZI7JBRusGGSLf8AmPRPCenTzh4PA+wZ1xv2+cYqQwTfQHU5tXYOhA0xDytfHUf1Zyw=="],
"@jimp/file-ops": ["@jimp/file-ops@1.6.0", "", {}, "sha512-Dx/bVDmgnRe1AlniRpCKrGRm5YvGmUwbDzt+MAkgmLGf+jvBT75hmMEZ003n9HQI/aPnm/YKnXjg/hOpzNCpHQ=="],
"@jimp/js-bmp": ["@jimp/js-bmp@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "bmp-ts": "^1.0.9" } }, "sha512-FU6Q5PC/e3yzLyBDXupR3SnL3htU7S3KEs4e6rjDP6gNEOXRFsWs6YD3hXuXd50jd8ummy+q2WSwuGkr8wi+Gw=="],
"@jimp/js-gif": ["@jimp/js-gif@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "gifwrap": "^0.10.1", "omggif": "^1.0.10" } }, "sha512-N9CZPHOrJTsAUoWkWZstLPpwT5AwJ0wge+47+ix3++SdSL/H2QzyMqxbcDYNFe4MoI5MIhATfb0/dl/wmX221g=="],
"@jimp/js-jpeg": ["@jimp/js-jpeg@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "jpeg-js": "^0.4.4" } }, "sha512-6vgFDqeusblf5Pok6B2DUiMXplH8RhIKAryj1yn+007SIAQ0khM1Uptxmpku/0MfbClx2r7pnJv9gWpAEJdMVA=="],
"@jimp/js-png": ["@jimp/js-png@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "pngjs": "^7.0.0" } }, "sha512-AbQHScy3hDDgMRNfG0tPjL88AV6qKAILGReIa3ATpW5QFjBKpisvUaOqhzJ7Reic1oawx3Riyv152gaPfqsBVg=="],
"@jimp/js-tiff": ["@jimp/js-tiff@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "utif2": "^4.1.0" } }, "sha512-zhReR8/7KO+adijj3h0ZQUOiun3mXUv79zYEAKvE0O+rP7EhgtKvWJOZfRzdZSNv0Pu1rKtgM72qgtwe2tFvyw=="],
"@jimp/plugin-blit": ["@jimp/plugin-blit@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-M+uRWl1csi7qilnSK8uxK4RJMSuVeBiO1AY0+7APnfUbQNZm6hCe0CCFv1Iyw1D/Dhb8ph8fQgm5mwM0eSxgVA=="],
"@jimp/plugin-blur": ["@jimp/plugin-blur@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/utils": "1.6.0" } }, "sha512-zrM7iic1OTwUCb0g/rN5y+UnmdEsT3IfuCXCJJNs8SZzP0MkZ1eTvuwK9ZidCuMo4+J3xkzCidRwYXB5CyGZTw=="],
"@jimp/plugin-circle": ["@jimp/plugin-circle@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-xt1Gp+LtdMKAXfDp3HNaG30SPZW6AQ7dtAtTnoRKorRi+5yCJjKqXRgkewS5bvj8DEh87Ko1ydJfzqS3P2tdWw=="],
"@jimp/plugin-color": ["@jimp/plugin-color@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "tinycolor2": "^1.6.0", "zod": "^3.23.8" } }, "sha512-J5q8IVCpkBsxIXM+45XOXTrsyfblyMZg3a9eAo0P7VPH4+CrvyNQwaYatbAIamSIN1YzxmO3DkIZXzRjFSz1SA=="],
"@jimp/plugin-contain": ["@jimp/plugin-contain@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/plugin-blit": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-oN/n+Vdq/Qg9bB4yOBOxtY9IPAtEfES8J1n9Ddx+XhGBYT1/QTU/JYkGaAkIGoPnyYvmLEDqMz2SGihqlpqfzQ=="],
"@jimp/plugin-cover": ["@jimp/plugin-cover@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/plugin-crop": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-Iow0h6yqSC269YUJ8HC3Q/MpCi2V55sMlbkkTTx4zPvd8mWZlC0ykrNDeAy9IJegrQ7v5E99rJwmQu25lygKLA=="],
"@jimp/plugin-crop": ["@jimp/plugin-crop@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-KqZkEhvs+21USdySCUDI+GFa393eDIzbi1smBqkUPTE+pRwSWMAf01D5OC3ZWB+xZsNla93BDS9iCkLHA8wang=="],
"@jimp/plugin-displace": ["@jimp/plugin-displace@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-4Y10X9qwr5F+Bo5ME356XSACEF55485j5nGdiyJ9hYzjQP9nGgxNJaZ4SAOqpd+k5sFaIeD7SQ0Occ26uIng5Q=="],
"@jimp/plugin-dither": ["@jimp/plugin-dither@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0" } }, "sha512-600d1RxY0pKwgyU0tgMahLNKsqEcxGdbgXadCiVCoGd6V6glyCvkNrnnwC0n5aJ56Htkj88PToSdF88tNVZEEQ=="],
"@jimp/plugin-fisheye": ["@jimp/plugin-fisheye@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-E5QHKWSCBFtpgZarlmN3Q6+rTQxjirFqo44ohoTjzYVrDI6B6beXNnPIThJgPr0Y9GwfzgyarKvQuQuqCnnfbA=="],
"@jimp/plugin-flip": ["@jimp/plugin-flip@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-/+rJVDuBIVOgwoyVkBjUFHtP+wmW0r+r5OQ2GpatQofToPVbJw1DdYWXlwviSx7hvixTWLKVgRWQ5Dw862emDg=="],
"@jimp/plugin-hash": ["@jimp/plugin-hash@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/js-bmp": "1.6.0", "@jimp/js-jpeg": "1.6.0", "@jimp/js-png": "1.6.0", "@jimp/js-tiff": "1.6.0", "@jimp/plugin-color": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "any-base": "^1.1.0" } }, "sha512-wWzl0kTpDJgYVbZdajTf+4NBSKvmI3bRI8q6EH9CVeIHps9VWVsUvEyb7rpbcwVLWYuzDtP2R0lTT6WeBNQH9Q=="],
"@jimp/plugin-mask": ["@jimp/plugin-mask@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-Cwy7ExSJMZszvkad8NV8o/Z92X2kFUFM8mcDAhNVxU0Q6tA0op2UKRJY51eoK8r6eds/qak3FQkXakvNabdLnA=="],
"@jimp/plugin-print": ["@jimp/plugin-print@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/js-jpeg": "1.6.0", "@jimp/js-png": "1.6.0", "@jimp/plugin-blit": "1.6.0", "@jimp/types": "1.6.0", "parse-bmfont-ascii": "^1.0.6", "parse-bmfont-binary": "^1.0.6", "parse-bmfont-xml": "^1.1.6", "simple-xml-to-json": "^1.2.2", "zod": "^3.23.8" } }, "sha512-zarTIJi8fjoGMSI/M3Xh5yY9T65p03XJmPsuNet19K/Q7mwRU6EV2pfj+28++2PV2NJ+htDF5uecAlnGyxFN2A=="],
"@jimp/plugin-quantize": ["@jimp/plugin-quantize@1.6.0", "", { "dependencies": { "image-q": "^4.0.0", "zod": "^3.23.8" } }, "sha512-EmzZ/s9StYQwbpG6rUGBCisc3f64JIhSH+ncTJd+iFGtGo0YvSeMdAd+zqgiHpfZoOL54dNavZNjF4otK+mvlg=="],
"@jimp/plugin-resize": ["@jimp/plugin-resize@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-uSUD1mqXN9i1SGSz5ov3keRZ7S9L32/mAQG08wUwZiEi5FpbV0K8A8l1zkazAIZi9IJzLlTauRNU41Mi8IF9fA=="],
"@jimp/plugin-rotate": ["@jimp/plugin-rotate@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/plugin-crop": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-JagdjBLnUZGSG4xjCLkIpQOZZ3Mjbg8aGCCi4G69qR+OjNpOeGI7N2EQlfK/WE8BEHOW5vdjSyglNqcYbQBWRw=="],
"@jimp/plugin-threshold": ["@jimp/plugin-threshold@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/plugin-color": "1.6.0", "@jimp/plugin-hash": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-M59m5dzLoHOVWdM41O8z9SyySzcDn43xHseOH0HavjsfQsT56GGCC4QzU1banJidbUrePhzoEdS42uFE8Fei8w=="],
"@jimp/types": ["@jimp/types@1.6.0", "", { "dependencies": { "zod": "^3.23.8" } }, "sha512-7UfRsiKo5GZTAATxm2qQ7jqmUXP0DxTArztllTcYdyw6Xi5oT4RaoXynVtCD4UyLK5gJgkZJcwonoijrhYFKfg=="],
"@jimp/utils": ["@jimp/utils@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "tinycolor2": "^1.6.0" } }, "sha512-gqFTGEosKbOkYF/WFj26jMHOI5OH2jeP1MmC/zbK6BF6VJBf8rIC5898dPfSzZEbSA0wbbV5slbntWVc5PKLFA=="],
"@jsquash/webp": ["@jsquash/webp@1.5.0", "", { "dependencies": { "wasm-feature-detect": "^1.2.11" } }, "sha512-KggLoj2MnRSfIqTeKe1EmbljTX2vuV7mh79k89PCL1pyqiDULcPM1L47twxXt0hkb68F70bXiL31MxsuoZtKFw=="],
"@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="],
"@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
"@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
"@types/node": ["@types/node@16.9.1", "", {}, "sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g=="],
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
"abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
"ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="],
"any-base": ["any-base@1.1.0", "", {}, "sha512-uMgjozySS8adZZYePpaWs8cxB9/kdzmpX6SgJZ+wbz1K5eYk5QMYDVJaZKhxyIHUdnnJkfR7SVgStgH7LkGUyg=="],
"argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
"await-to-js": ["await-to-js@3.0.0", "", {}, "sha512-zJAaP9zxTcvTHRlejau3ZOY4V7SRpiByf3/dxx2uyKxxor19tpmpV2QRsTKikckwhaPmr2dVpxxMr7jOCYVp5g=="],
"bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
"baoyu-chrome-cdp": ["baoyu-chrome-cdp@file:vendor/baoyu-chrome-cdp", {}],
"baoyu-md": ["baoyu-md@file:vendor/baoyu-md", { "dependencies": { "fflate": "^0.8.2", "front-matter": "^4.0.2", "highlight.js": "^11.11.1", "juice": "^11.0.1", "marked": "^15.0.6", "reading-time": "^1.5.0", "remark-cjk-friendly": "^1.1.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.5" } }],
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
"bmp-ts": ["bmp-ts@1.0.9", "", {}, "sha512-cTEHk2jLrPyi+12M3dhpEbnnPOsaZuq7C45ylbbQIiWgDFZq4UVYPEY5mlqjvsj/6gJv9qX5sa+ebDzLXT28Vw=="],
"boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="],
"buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="],
"character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="],
"cheerio": ["cheerio@1.0.0", "", { "dependencies": { "cheerio-select": "^2.1.0", "dom-serializer": "^2.0.0", "domhandler": "^5.0.3", "domutils": "^3.1.0", "encoding-sniffer": "^0.2.0", "htmlparser2": "^9.1.0", "parse5": "^7.1.2", "parse5-htmlparser2-tree-adapter": "^7.0.0", "parse5-parser-stream": "^7.1.2", "undici": "^6.19.5", "whatwg-mimetype": "^4.0.0" } }, "sha512-quS9HgjQpdaXOvsZz82Oz7uxtXiy6UIsIQcpBj7HRw2M63Skasm9qlDocAM7jNuaxdhpPU7c4kJN+gA5MCu4ww=="],
@@ -66,22 +142,40 @@
"esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="],
"event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="],
"events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="],
"exif-parser": ["exif-parser@0.1.12", "", {}, "sha512-c2bQfLNbMzLPmzQuOr8fy0csy84WmwnER81W88DzTp9CYNPJ6yzOj2EZAh9pywYpqHnshVLHQJ8WzldAyfY+Iw=="],
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
"fflate": ["fflate@0.8.2", "", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="],
"file-type": ["file-type@16.5.4", "", { "dependencies": { "readable-web-to-node-stream": "^3.0.0", "strtok3": "^6.2.4", "token-types": "^4.1.1" } }, "sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw=="],
"front-matter": ["front-matter@4.0.2", "", { "dependencies": { "js-yaml": "^3.13.1" } }, "sha512-I8ZuJ/qG92NWX8i5x1Y8qyj3vizhXS31OxjKDu3LKP+7/qBgfIKValiZIEwoVoJKUHlhWtYrktkxV1XsX+pPlg=="],
"get-east-asian-width": ["get-east-asian-width@1.5.0", "", {}, "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA=="],
"gifwrap": ["gifwrap@0.10.1", "", { "dependencies": { "image-q": "^4.0.0", "omggif": "^1.0.10" } }, "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw=="],
"highlight.js": ["highlight.js@11.11.1", "", {}, "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w=="],
"htmlparser2": ["htmlparser2@9.1.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.1.0", "entities": "^4.5.0" } }, "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ=="],
"iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
"image-q": ["image-q@4.0.0", "", { "dependencies": { "@types/node": "16.9.1" } }, "sha512-PfJGVgIfKQJuq3s0tTDOKtztksibuUEbJQIYT3by6wctQo+Rdlh7ef4evJ5NCdxY4CfMbvFkocEwbl4BF8RlJw=="],
"is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
"jimp": ["jimp@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/diff": "1.6.0", "@jimp/js-bmp": "1.6.0", "@jimp/js-gif": "1.6.0", "@jimp/js-jpeg": "1.6.0", "@jimp/js-png": "1.6.0", "@jimp/js-tiff": "1.6.0", "@jimp/plugin-blit": "1.6.0", "@jimp/plugin-blur": "1.6.0", "@jimp/plugin-circle": "1.6.0", "@jimp/plugin-color": "1.6.0", "@jimp/plugin-contain": "1.6.0", "@jimp/plugin-cover": "1.6.0", "@jimp/plugin-crop": "1.6.0", "@jimp/plugin-displace": "1.6.0", "@jimp/plugin-dither": "1.6.0", "@jimp/plugin-fisheye": "1.6.0", "@jimp/plugin-flip": "1.6.0", "@jimp/plugin-hash": "1.6.0", "@jimp/plugin-mask": "1.6.0", "@jimp/plugin-print": "1.6.0", "@jimp/plugin-quantize": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/plugin-rotate": "1.6.0", "@jimp/plugin-threshold": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0" } }, "sha512-YcwCHw1kiqEeI5xRpDlPPBGL2EOpBKLwO4yIBJcXWHPj5PnA5urGq0jbyhM5KoNpypQ6VboSoxc9D8HyfvngSg=="],
"jpeg-js": ["jpeg-js@0.4.4", "", {}, "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg=="],
"js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="],
"juice": ["juice@11.1.1", "", { "dependencies": { "cheerio": "1.0.0", "commander": "^12.1.0", "entities": "^7.0.0", "mensch": "^0.3.4", "slick": "^1.12.2", "web-resource-inliner": "^8.0.0" }, "bin": { "juice": "bin/juice" } }, "sha512-4SBfZqKcc6DrIS+5b/WiGoWaZsdUPBH+e6SbRlNjJpaIRtfoBhYReAtobIEW6mcLeFFDXLBJMuZwkJLkBJjs2w=="],
@@ -146,18 +240,40 @@
"micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="],
"mime": ["mime@2.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg=="],
"mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="],
"omggif": ["omggif@1.0.10", "", {}, "sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw=="],
"pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
"parse-bmfont-ascii": ["parse-bmfont-ascii@1.0.6", "", {}, "sha512-U4RrVsUFCleIOBsIGYOMKjn9PavsGOXxbvYGtMOEfnId0SVNsgehXh1DxUdVPLoxd5mvcEtvmKs2Mmf0Mpa1ZA=="],
"parse-bmfont-binary": ["parse-bmfont-binary@1.0.6", "", {}, "sha512-GxmsRea0wdGdYthjuUeWTMWPqm2+FAd4GI8vCvhgJsFnoGhTrLhXDDupwTo7rXVAgaLIGoVHDZS9p/5XbSqeWA=="],
"parse-bmfont-xml": ["parse-bmfont-xml@1.1.6", "", { "dependencies": { "xml-parse-from-string": "^1.0.0", "xml2js": "^0.5.0" } }, "sha512-0cEliVMZEhrFDwMh4SxIyVJpqYoOWDJ9P895tFuS+XuNzI5UBmBk5U5O4KuJdTnZpSBI4LFA2+ZiJaiwfSwlMA=="],
"parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
"parse5-htmlparser2-tree-adapter": ["parse5-htmlparser2-tree-adapter@7.1.0", "", { "dependencies": { "domhandler": "^5.0.3", "parse5": "^7.0.0" } }, "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g=="],
"parse5-parser-stream": ["parse5-parser-stream@7.1.2", "", { "dependencies": { "parse5": "^7.0.0" } }, "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow=="],
"peek-readable": ["peek-readable@4.1.0", "", {}, "sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg=="],
"pixelmatch": ["pixelmatch@5.3.0", "", { "dependencies": { "pngjs": "^6.0.0" }, "bin": { "pixelmatch": "bin/pixelmatch" } }, "sha512-o8mkY4E/+LNUf6LzX96ht6k6CEDi65k9G2rjMtBe9Oo+VPKSvl+0GKHuH/AlG+GA5LPG/i5hrekkxUc3s2HU+Q=="],
"pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="],
"process": ["process@0.11.10", "", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="],
"readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="],
"readable-web-to-node-stream": ["readable-web-to-node-stream@3.0.4", "", { "dependencies": { "readable-stream": "^4.7.0" } }, "sha512-9nX56alTf5bwXQ3ZDipHJhusu9NTQJ/CVPtb/XHAJCXihZeitfJvIRS4GqQ/mfIoOE3IelHMrpayVrosdHBuLw=="],
"reading-time": ["reading-time@1.5.0", "", {}, "sha512-onYyVhBNr4CmAxFsKS7bz+uTLRakypIe4R+5A824vBSkQy/hB3fZepoVEf8OVAxzLvK+H/jm9TzpI3ETSm64Kg=="],
"remark-cjk-friendly": ["remark-cjk-friendly@1.2.3", "", { "dependencies": { "micromark-extension-cjk-friendly": "1.2.3" }, "peerDependencies": { "@types/mdast": "^4.0.0", "unified": "^11.0.0" }, "optionalPeers": ["@types/mdast"] }, "sha512-UvAgxwlNk+l9Oqgl/9MWK2eWRS7zgBW/nXX9AthV7nd/3lNejF138E7Xbmk9Zs4WjTJGs721r7fAEc7tNFoH7g=="],
@@ -166,12 +282,26 @@
"remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="],
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"sax": ["sax@1.6.0", "", {}, "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA=="],
"simple-xml-to-json": ["simple-xml-to-json@1.2.4", "", {}, "sha512-3MY16e0ocMHL7N1ufpdObURGyX+lCo0T/A+y6VCwosLdH1HSda4QZl1Sdt/O+2qWp48WFi26XEp5rF0LoaL0Dg=="],
"slick": ["slick@1.12.2", "", {}, "sha512-4qdtOGcBjral6YIBCWJ0ljFSKNLz9KkhbWtuGvUyRowl1kxfuE1x/Z/aJcaiilpb3do9bl5K7/1h9XC5wWpY/A=="],
"sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="],
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
"strtok3": ["strtok3@6.3.0", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "peek-readable": "^4.1.0" } }, "sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw=="],
"tinycolor2": ["tinycolor2@1.6.0", "", {}, "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw=="],
"token-types": ["token-types@4.2.1", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ=="],
"trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="],
"undici": ["undici@6.24.0", "", {}, "sha512-lVLNosgqo5EkGqh5XUDhGfsMSoO8K0BAN0TyJLvwNRSl4xWGZlCVYsAIpa/OpA3TvmnM01GWcoKmc3ZWo5wKKA=="],
@@ -186,18 +316,30 @@
"unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="],
"utif2": ["utif2@4.1.0", "", { "dependencies": { "pako": "^1.0.11" } }, "sha512-+oknB9FHrJ7oW7A2WZYajOcv4FcDR4CfoGB0dPNfxbi4GO05RRnFmt5oa23+9w32EanrYcSJWspUiJkLMs+37w=="],
"valid-data-url": ["valid-data-url@3.0.1", "", {}, "sha512-jOWVmzVceKlVVdwjNSenT4PbGghU0SBIizAev8ofZVgivk/TVHXSbNL8LP6M3spZvkR9/QolkyJavGSX5Cs0UA=="],
"vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
"vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
"wasm-feature-detect": ["wasm-feature-detect@1.8.0", "", {}, "sha512-zksaLKM2fVlnB5jQQDqKXXwYHLQUVH9es+5TOOHwGOVJOCeRBCiPjwSg+3tN2AdTCzjgli4jijCH290kXb/zWQ=="],
"web-resource-inliner": ["web-resource-inliner@8.0.0", "", { "dependencies": { "ansi-colors": "^4.1.1", "escape-goat": "^3.0.0", "htmlparser2": "^9.1.0", "mime": "^2.4.6", "valid-data-url": "^3.0.0" } }, "sha512-Ezr98sqXW/+OCGoUEXuOKVR+oVFlSdn1tIySEEJdiSAw4IjrW8hQkwARSSBJTSB5Us5dnytDgL0ZDliAYBhaNA=="],
"whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="],
"whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="],
"xml-parse-from-string": ["xml-parse-from-string@1.0.1", "", {}, "sha512-ErcKwJTF54uRzzNMXq2X5sMIy88zJvfN2DmdoQvy7PAFJ+tPRU6ydWuOKNMyfmOjdyBQTFREi60s0Y0SyI0G0g=="],
"xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="],
"xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="],
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
"dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
@@ -205,5 +347,9 @@
"htmlparser2/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
"pixelmatch/pngjs": ["pngjs@6.0.0", "", {}, "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg=="],
"web-resource-inliner/mime": ["mime@2.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg=="],
}
}
@@ -3,7 +3,9 @@
"private": true,
"type": "module",
"dependencies": {
"@jsquash/webp": "^1.5.0",
"baoyu-chrome-cdp": "file:./vendor/baoyu-chrome-cdp",
"baoyu-md": "file:./vendor/baoyu-md"
"baoyu-md": "file:./vendor/baoyu-md",
"jimp": "^1.6.0"
}
}
@@ -9,12 +9,26 @@ import { COLOR_PRESETS, FONT_FAMILY_MAP } from "./constants.ts";
import {
buildMarkdownDocumentMeta,
formatTimestamp,
renderMarkdownDocument,
resolveColorToken,
resolveFontFamilyToken,
resolveMarkdownStyle,
resolveRenderOptions,
} from "./document.ts";
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, `\\$&`);
}
function findInlineStyle(html: string, tagName: string, text: string): string {
const pattern = new RegExp(
`<${tagName}[^>]*style="([^"]*)"[^>]*>${escapeRegExp(text)}</${tagName}>`,
);
const match = html.match(pattern);
assert.ok(match, `Expected inline style for <${tagName}>${text}</${tagName}>`);
return match![1]!;
}
function useCwd(t: TestContext, cwd: string): void {
const previous = process.cwd();
process.chdir(cwd);
@@ -138,3 +152,23 @@ keep_title: true
assert.equal(explicit.fontSize, "18px");
assert.equal(explicit.keepTitle, false);
});
test("renderMarkdownDocument layers default rules into grace theme before CSS inlining", async () => {
const { html } = await renderMarkdownDocument(
`## Section\n\nParagraph with **bold** text.`,
{ keepTitle: true, theme: "grace" },
);
const h2Style = findInlineStyle(html, "h2", "Section");
assert.match(h2Style, /background: #92617E/);
assert.match(h2Style, /box-shadow: 0 4px 6px rgba\(0, 0, 0, 0\.1\)/);
const pMatch = html.match(/<p[^>]*style="([^"]*)"[^>]*>/);
assert.ok(pMatch, "Expected inline style on <p> tag");
assert.match(pMatch![1]!, /color:/);
const strongPattern = /<strong[^>]*style="([^"]*)"[^>]*>bold<\/strong>/;
const strongMatch = html.match(strongPattern);
assert.ok(strongMatch, "Expected inline style for <strong>bold</strong>");
assert.match(strongMatch![1]!, /font-weight:/);
});
@@ -59,6 +59,17 @@ test("normalizeCssText and normalizeInlineCss replace variables and strip declar
assert.doesNotMatch(normalizedHtml, /var\(--md-primary-color\)/);
});
test("normalizeInlineCss removes quoted custom property values without leaving fragments behind", () => {
const normalizedHtml = normalizeInlineCss(
`<html style="--md-font-family: Menlo, Monaco, 'Courier New', monospace; color: var(--md-primary-color)"></html>`,
DEFAULT_STYLE,
);
assert.match(normalizedHtml, /style=" color: #0F4C81"/);
assert.doesNotMatch(normalizedHtml, /Courier New/);
assert.doesNotMatch(normalizedHtml, /--md-font-family/);
});
test("HTML structure helpers hoist nested lists and remove the first heading", () => {
const nestedList = `<ul><li>Parent<ul><li>Child</li></ul></li></ul>`;
assert.equal(
@@ -100,13 +100,13 @@ export function normalizeCssText(cssText: string, style: StyleConfig = DEFAULT_S
.replace(/var\(--md-accent-color\)/g, style.accentColor)
.replace(/var\(--md-container-bg\)/g, style.containerBg)
.replace(/hsl\(var\(--foreground\)\)/g, "#3f3f3f")
.replace(/--md-primary-color:\s*[^;"']+;?/g, "")
.replace(/--md-font-family:\s*[^;"']+;?/g, "")
.replace(/--md-font-size:\s*[^;"']+;?/g, "")
.replace(/--blockquote-background:\s*[^;"']+;?/g, "")
.replace(/--md-accent-color:\s*[^;"']+;?/g, "")
.replace(/--md-container-bg:\s*[^;"']+;?/g, "")
.replace(/--foreground:\s*[^;"']+;?/g, "");
.replace(/--md-primary-color:\s*[^;]+;?/g, "")
.replace(/--md-font-family:\s*[^;]+;?/g, "")
.replace(/--md-font-size:\s*[^;]+;?/g, "")
.replace(/--blockquote-background:\s*[^;]+;?/g, "")
.replace(/--md-accent-color:\s*[^;]+;?/g, "")
.replace(/--md-container-bg:\s*[^;]+;?/g, "")
.replace(/--foreground:\s*[^;]+;?/g, "");
}
export function normalizeInlineCss(html: string, style: StyleConfig = DEFAULT_STYLE): string {
@@ -6,6 +6,7 @@ import type { ThemeName } from "./types.js";
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
export const THEME_DIR = path.resolve(SCRIPT_DIR, "themes");
const FALLBACK_THEMES: ThemeName[] = ["default", "grace", "simple"];
const THEMES_EXTENDING_DEFAULT = new Set<ThemeName>(["grace", "simple"]);
function stripOutputScope(cssContent: string): string {
let css = cssContent;
@@ -41,6 +42,7 @@ export function loadThemeCss(theme: ThemeName): {
themeCss: string;
} {
const basePath = path.join(THEME_DIR, "base.css");
const defaultThemePath = path.join(THEME_DIR, "default.css");
const themePath = path.join(THEME_DIR, `${theme}.css`);
if (!fs.existsSync(basePath)) {
@@ -51,9 +53,18 @@ export function loadThemeCss(theme: ThemeName): {
throw new Error(`Missing theme CSS for "${theme}": ${themePath}`);
}
const layeredThemeCss: string[] = [];
if (theme !== "default" && THEMES_EXTENDING_DEFAULT.has(theme)) {
if (!fs.existsSync(defaultThemePath)) {
throw new Error(`Missing default theme CSS: ${defaultThemePath}`);
}
layeredThemeCss.push(fs.readFileSync(defaultThemePath, "utf-8"));
}
layeredThemeCss.push(fs.readFileSync(themePath, "utf-8"));
return {
baseCss: fs.readFileSync(basePath, "utf-8"),
themeCss: fs.readFileSync(themePath, "utf-8"),
themeCss: layeredThemeCss.join("\n"),
};
}
+159 -52
View File
@@ -3,6 +3,11 @@ import path from "node:path";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { loadWechatExtendConfig, resolveAccount, loadCredentials } from "./wechat-extend-config.ts";
import {
type WechatUploadAsset,
prepareWechatBodyImageUpload,
needsWechatBodyImageProcessing,
} from "./wechat-image-processor.ts";
interface AccessTokenResponse {
access_token?: string;
@@ -52,10 +57,10 @@ interface ArticleOptions {
}
const TOKEN_URL = "https://api.weixin.qq.com/cgi-bin/token";
const UPLOAD_URL = "https://api.weixin.qq.com/cgi-bin/material/add_material";
const UPLOAD_BODY_IMG_URL = "https://api.weixin.qq.com/cgi-bin/media/uploadimg";
const UPLOAD_MATERIAL_URL = "https://api.weixin.qq.com/cgi-bin/material/add_material";
const DRAFT_URL = "https://api.weixin.qq.com/cgi-bin/draft/add";
async function fetchAccessToken(appId: string, appSecret: string): Promise<string> {
const url = `${TOKEN_URL}?grant_type=client_credential&appid=${appId}&secret=${appSecret}`;
const res = await fetch(url);
@@ -72,14 +77,20 @@ async function fetchAccessToken(appId: string, appSecret: string): Promise<strin
return data.access_token;
}
async function uploadImage(
function toHttpsUrl(url: string | undefined): string {
if (!url) return "";
return url.startsWith("http://") ? url.replace(/^http:\/\//i, "https://") : url;
}
async function loadUploadAsset(
imagePath: string,
accessToken: string,
baseDir?: string
): Promise<UploadResponse> {
baseDir?: string,
): Promise<WechatUploadAsset> {
let fileBuffer: Buffer;
let filename: string;
let contentType: string;
let fileSize = 0;
let fileExt = "";
if (imagePath.startsWith("http://") || imagePath.startsWith("https://")) {
const response = await fetch(imagePath);
@@ -91,8 +102,10 @@ async function uploadImage(
throw new Error(`Remote image is empty: ${imagePath}`);
}
fileBuffer = Buffer.from(buffer);
fileSize = buffer.byteLength;
const urlPath = imagePath.split("?")[0];
filename = path.basename(urlPath) || "image.jpg";
fileExt = path.extname(filename).toLowerCase();
contentType = response.headers.get("content-type") || "image/jpeg";
} else {
const resolvedPath = path.isAbsolute(imagePath)
@@ -106,19 +119,85 @@ async function uploadImage(
if (stats.size === 0) {
throw new Error(`Local image is empty: ${resolvedPath}`);
}
fileSize = stats.size;
fileBuffer = fs.readFileSync(resolvedPath);
filename = path.basename(resolvedPath);
const ext = path.extname(filename).toLowerCase();
fileExt = path.extname(filename).toLowerCase();
const mimeTypes: Record<string, string> = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".webp": "image/webp",
".bmp": "image/bmp",
".tiff": "image/tiff",
".tif": "image/tiff",
".svg": "image/svg+xml",
".ico": "image/x-icon",
};
contentType = mimeTypes[ext] || "image/jpeg";
contentType = mimeTypes[fileExt] || "image/jpeg";
}
return {
buffer: fileBuffer,
filename,
contentType,
fileExt,
fileSize,
};
}
async function uploadImage(
imagePath: string,
accessToken: string,
baseDir?: string,
uploadType: "body" | "material" = "body"
): Promise<UploadResponse> {
const asset = await loadUploadAsset(imagePath, baseDir);
let uploadAsset = asset;
if (uploadType === "body" && needsWechatBodyImageProcessing(asset)) {
const prepared = await prepareWechatBodyImageUpload(asset);
uploadAsset = {
...asset,
buffer: prepared.buffer,
filename: prepared.filename,
contentType: prepared.contentType,
fileExt: path.extname(prepared.filename).toLowerCase(),
fileSize: prepared.buffer.length,
};
const note = prepared.processingNotes.join(", ");
console.error(`[wechat-api] Processed ${asset.filename} for body upload: ${note}`);
}
const result = await uploadToWechat(
uploadAsset.buffer,
uploadAsset.filename,
uploadAsset.contentType,
accessToken,
uploadType,
);
// media/uploadimg 接口只返回 URLmaterial/add_material 返回 media_id
if (uploadType === "body") {
return {
url: toHttpsUrl(result.url),
media_id: "",
} as UploadResponse;
} else {
result.url = toHttpsUrl(result.url);
return result;
}
}
// 实际的微信上传函数
async function uploadToWechat(
fileBuffer: Buffer,
filename: string,
contentType: string,
accessToken: string,
uploadType: "body" | "material"
): Promise<UploadResponse> {
const boundary = `----WebKitFormBoundary${Date.now().toString(16)}`;
const header = [
`--${boundary}`,
@@ -133,7 +212,8 @@ async function uploadImage(
const footerBuffer = Buffer.from(footer, "utf-8");
const body = Buffer.concat([headerBuffer, fileBuffer, footerBuffer]);
const url = `${UPLOAD_URL}?access_token=${accessToken}&type=image`;
const uploadUrl = uploadType === "body" ? UPLOAD_BODY_IMG_URL : UPLOAD_MATERIAL_URL;
const url = `${uploadUrl}?type=image&access_token=${accessToken}`;
const res = await fetch(url, {
method: "POST",
headers: {
@@ -147,10 +227,6 @@ async function uploadImage(
throw new Error(`Upload failed ${data.errcode}: ${data.errmsg}`);
}
if (data.url?.startsWith("http://")) {
data.url = data.url.replace(/^http:\/\//i, "https://");
}
return data;
}
@@ -159,17 +235,19 @@ async function uploadImagesInHtml(
accessToken: string,
baseDir: string,
contentImages: ImageInfo[] = [],
): Promise<{ html: string; firstMediaId: string; allMediaIds: string[] }> {
articleType: ArticleType = "news",
collectNewsCoverFallback: boolean = false,
): Promise<{ html: string; firstCoverMediaId: string; imageMediaIds: string[] }> {
const imgRegex = /<img[^>]*\ssrc=["']([^"']+)["'][^>]*>/gi;
const matches = [...html.matchAll(imgRegex)];
if (matches.length === 0 && contentImages.length === 0) {
return { html, firstMediaId: "", allMediaIds: [] };
return { html, firstCoverMediaId: "", imageMediaIds: [] };
}
let firstMediaId = "";
let firstCoverMediaId = "";
let updatedHtml = html;
const allMediaIds: string[] = [];
const imageMediaIds: string[] = [];
const uploadedBySource = new Map<string, UploadResponse>();
for (const match of matches) {
@@ -177,8 +255,13 @@ async function uploadImagesInHtml(
if (!src) continue;
if (src.startsWith("https://mmbiz.qpic.cn")) {
if (!firstMediaId) {
firstMediaId = src;
if (collectNewsCoverFallback && !firstCoverMediaId) {
try {
const coverResp = await uploadImage(src, accessToken, baseDir, "material");
firstCoverMediaId = coverResp.media_id;
} catch (err) {
console.error(`[wechat-api] Failed to reuse existing WeChat image as cover: ${src}`, err);
}
}
continue;
}
@@ -186,20 +269,31 @@ async function uploadImagesInHtml(
const localPathMatch = fullTag.match(/data-local-path=["']([^"']+)["']/);
const imagePath = localPathMatch ? localPathMatch[1]! : src;
console.error(`[wechat-api] Uploading image: ${imagePath}`);
console.error(`[wechat-api] Uploading body image: ${imagePath}`);
try {
let resp = uploadedBySource.get(imagePath);
if (!resp) {
resp = await uploadImage(imagePath, accessToken, baseDir);
// 正文图片使用 media/uploadimg 接口获取 URL
resp = await uploadImage(imagePath, accessToken, baseDir, "body");
uploadedBySource.set(imagePath, resp);
}
const newTag = fullTag
.replace(/\ssrc=["'][^"']+["']/, ` src="${resp.url}"`)
.replace(/\sdata-local-path=["'][^"']+["']/, "");
updatedHtml = updatedHtml.replace(fullTag, newTag);
allMediaIds.push(resp.media_id);
if (!firstMediaId) {
firstMediaId = resp.media_id;
const shouldUploadMaterial = articleType === "newspic" || (collectNewsCoverFallback && !firstCoverMediaId);
if (shouldUploadMaterial) {
let materialResp = uploadedBySource.get(`${imagePath}:material`);
if (!materialResp) {
materialResp = await uploadImage(imagePath, accessToken, baseDir, "material");
uploadedBySource.set(`${imagePath}:material`, materialResp);
}
if (articleType === "newspic" && materialResp.media_id) {
imageMediaIds.push(materialResp.media_id);
}
if (collectNewsCoverFallback && !firstCoverMediaId && materialResp.media_id) {
firstCoverMediaId = materialResp.media_id;
}
}
} catch (err) {
console.error(`[wechat-api] Failed to upload ${imagePath}:`, err);
@@ -210,27 +304,38 @@ async function uploadImagesInHtml(
if (!updatedHtml.includes(image.placeholder)) continue;
const imagePath = image.localPath || image.originalPath;
console.error(`[wechat-api] Uploading placeholder image: ${imagePath}`);
console.error(`[wechat-api] Uploading body image: ${imagePath}`);
try {
let resp = uploadedBySource.get(imagePath);
if (!resp) {
resp = await uploadImage(imagePath, accessToken, baseDir);
// 正文图片使用 media/uploadimg 接口获取 URL
resp = await uploadImage(imagePath, accessToken, baseDir, "body");
uploadedBySource.set(imagePath, resp);
}
const replacementTag = `<img src="${resp.url}" style="display: block; width: 100%; margin: 1.5em auto;">`;
updatedHtml = replaceAllPlaceholders(updatedHtml, image.placeholder, replacementTag);
allMediaIds.push(resp.media_id);
if (!firstMediaId) {
firstMediaId = resp.media_id;
const shouldUploadMaterial = articleType === "newspic" || (collectNewsCoverFallback && !firstCoverMediaId);
if (shouldUploadMaterial) {
let materialResp = uploadedBySource.get(`${imagePath}:material`);
if (!materialResp) {
materialResp = await uploadImage(imagePath, accessToken, baseDir, "material");
uploadedBySource.set(`${imagePath}:material`, materialResp);
}
if (articleType === "newspic" && materialResp.media_id) {
imageMediaIds.push(materialResp.media_id);
}
if (collectNewsCoverFallback && !firstCoverMediaId && materialResp.media_id) {
firstCoverMediaId = materialResp.media_id;
}
}
} catch (err) {
console.error(`[wechat-api] Failed to upload placeholder ${image.placeholder}:`, err);
}
}
return { html: updatedHtml, firstMediaId, allMediaIds };
return { html: updatedHtml, firstCoverMediaId, imageMediaIds };
}
async function publishToDraft(
@@ -345,7 +450,7 @@ function renderMarkdownWithPlaceholders(
function replaceAllPlaceholders(html: string, placeholder: string, replacement: string): string {
const escapedPlaceholder = placeholder.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return html.replace(new RegExp(escapedPlaceholder, "g"), replacement);
return html.replace(new RegExp(escapedPlaceholder + "(?!\\d)", "g"), replacement);
}
function extractHtmlContent(htmlPath: string): string {
@@ -592,16 +697,6 @@ async function main(): Promise<void> {
console.error("[wechat-api] Fetching access token...");
const accessToken = await fetchAccessToken(creds.appId, creds.appSecret);
console.error("[wechat-api] Uploading images...");
const { html: processedHtml, firstMediaId, allMediaIds } = await uploadImagesInHtml(
htmlContent,
accessToken,
baseDir,
contentImages,
);
htmlContent = processedHtml;
let thumbMediaId = "";
const rawCoverPath = args.cover ||
frontmatter.coverImage ||
frontmatter.featureImage ||
@@ -610,19 +705,31 @@ async function main(): Promise<void> {
const coverPath = rawCoverPath && !path.isAbsolute(rawCoverPath) && args.cover
? path.resolve(process.cwd(), rawCoverPath)
: rawCoverPath;
const needNewsCoverFallback = args.articleType === "news" && !coverPath;
console.error("[wechat-api] Uploading body images...");
const { html: processedHtml, firstCoverMediaId, imageMediaIds } = await uploadImagesInHtml(
htmlContent,
accessToken,
baseDir,
contentImages,
args.articleType,
needNewsCoverFallback,
);
htmlContent = processedHtml;
let thumbMediaId = "";
if (coverPath) {
console.error(`[wechat-api] Uploading cover: ${coverPath}`);
const coverResp = await uploadImage(coverPath, accessToken, baseDir);
// 封面图片使用 material/add_material 接口
const coverResp = await uploadImage(coverPath, accessToken, baseDir, "material");
thumbMediaId = coverResp.media_id;
} else if (firstMediaId) {
if (firstMediaId.startsWith("https://")) {
console.error(`[wechat-api] Uploading first image as cover: ${firstMediaId}`);
const coverResp = await uploadImage(firstMediaId, accessToken, baseDir);
thumbMediaId = coverResp.media_id;
} else {
thumbMediaId = firstMediaId;
}
console.error(`[wechat-api] Cover uploaded successfully, media_id: ${thumbMediaId}`);
} else if (firstCoverMediaId && args.articleType === "news") {
// news 类型没有封面时,使用第一张正文图的 media_id 作为封面(兜底逻辑)
thumbMediaId = firstCoverMediaId;
console.error(`[wechat-api] Using first body image as cover (fallback), media_id: ${thumbMediaId}`);
}
if (args.articleType === "news" && !thumbMediaId) {
@@ -630,7 +737,7 @@ async function main(): Promise<void> {
process.exit(1);
}
if (args.articleType === "newspic" && allMediaIds.length === 0) {
if (args.articleType === "newspic" && imageMediaIds.length === 0) {
console.error("Error: newspic requires at least one image in content.");
process.exit(1);
}
@@ -643,7 +750,7 @@ async function main(): Promise<void> {
content: htmlContent,
thumbMediaId,
articleType: args.articleType,
imageMediaIds: args.articleType === "newspic" ? allMediaIds : undefined,
imageMediaIds: args.articleType === "newspic" ? imageMediaIds : undefined,
needOpenComment: resolved.need_open_comment,
onlyFansCanComment: resolved.only_fans_can_comment,
}, accessToken);
@@ -0,0 +1,252 @@
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { Jimp, JimpMime } from "jimp";
import decodeWebp, { init as initWebpDecode } from "@jsquash/webp/decode.js";
export interface WechatUploadAsset {
buffer: Buffer;
filename: string;
contentType: string;
fileExt: string;
fileSize: number;
}
export interface PreparedWechatUploadAsset {
buffer: Buffer;
filename: string;
contentType: string;
wasProcessed: boolean;
processingNotes: string[];
}
export const WECHAT_BODY_IMAGE_MAX_SIZE = 1024 * 1024; // 1MB
export const WECHAT_BODY_IMAGE_UNSUPPORTED_FORMATS = new Set([
".gif",
".webp",
".bmp",
".tiff",
".tif",
".svg",
".ico",
]);
const BODY_UPLOAD_ALLOWED_MIME_TYPES = new Set([
JimpMime.jpeg,
JimpMime.png,
]);
const MIME_TO_EXT: Record<string, string> = {
"image/jpeg": ".jpg",
"image/png": ".png",
"image/gif": ".gif",
"image/webp": ".webp",
"image/bmp": ".bmp",
"image/x-ms-bmp": ".bmp",
"image/tiff": ".tiff",
"image/svg+xml": ".svg",
"image/x-icon": ".ico",
"image/vnd.microsoft.icon": ".ico",
};
const JPEG_QUALITY_STEPS = [82, 74, 66, 58, 50, 42, 34];
const MAX_WIDTH_STEPS = [2560, 2048, 1600, 1280, 1024, 800, 640, 480];
let webpDecoderReady: Promise<void> | undefined;
type JimpImage = Awaited<ReturnType<typeof Jimp.read>>;
function normalizeMimeType(contentType: string): string {
return contentType.split(";")[0]!.trim().toLowerCase();
}
function extFromMimeType(contentType: string): string {
return MIME_TO_EXT[normalizeMimeType(contentType)] || "";
}
function ensureFileExt(asset: WechatUploadAsset): string {
return asset.fileExt || extFromMimeType(asset.contentType);
}
function basenameWithoutExt(filename: string): string {
const base = path.basename(filename, path.extname(filename));
return base || "image";
}
function renameWithExt(filename: string, ext: string): string {
return `${basenameWithoutExt(filename)}${ext}`;
}
export function needsWechatBodyImageProcessing(asset: WechatUploadAsset): boolean {
if (asset.fileSize > WECHAT_BODY_IMAGE_MAX_SIZE) {
return true;
}
const normalizedMimeType = normalizeMimeType(asset.contentType);
if (BODY_UPLOAD_ALLOWED_MIME_TYPES.has(normalizedMimeType)) {
return false;
}
const fileExt = ensureFileExt(asset);
return WECHAT_BODY_IMAGE_UNSUPPORTED_FORMATS.has(fileExt) || !fileExt;
}
async function ensureWebpDecoder(): Promise<void> {
if (!webpDecoderReady) {
webpDecoderReady = (async () => {
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const wasmPath = path.resolve(__dirname, "node_modules/@jsquash/webp/codec/dec/webp_dec.wasm");
const wasmModule = await WebAssembly.compile(await fs.readFile(wasmPath));
await initWebpDecode(wasmModule, {});
})();
}
await webpDecoderReady;
}
async function loadImageForProcessing(asset: WechatUploadAsset): Promise<JimpImage> {
const fileExt = ensureFileExt(asset);
const normalizedMimeType = normalizeMimeType(asset.contentType);
if (fileExt === ".webp" || normalizedMimeType === "image/webp") {
await ensureWebpDecoder();
const decoded = await decodeWebp(asset.buffer);
return new Jimp({
data: Buffer.from(decoded.data.buffer, decoded.data.byteOffset, decoded.data.byteLength),
width: decoded.width,
height: decoded.height,
});
}
if (fileExt === ".svg" || fileExt === ".ico") {
throw new Error(`Cannot convert ${fileExt} image for WeChat body upload; provide a PNG or JPG instead.`);
}
return Jimp.read(asset.buffer);
}
function imageHasTransparency(image: JimpImage): boolean {
const { data } = image.bitmap;
for (let i = 3; i < data.length; i += 4) {
if (data[i] !== 255) {
return true;
}
}
return false;
}
function buildCandidateWidths(width: number): number[] {
const candidates = new Set<number>([width]);
for (const maxWidth of MAX_WIDTH_STEPS) {
if (width > maxWidth) {
candidates.add(maxWidth);
}
}
return [...candidates].sort((a, b) => b - a);
}
function resizeToWidth(image: JimpImage, width: number): JimpImage {
const cloned = image.clone();
if (width < image.bitmap.width) {
cloned.resize({ w: width });
}
return cloned;
}
function flattenOnWhite(image: JimpImage): JimpImage {
const flattened = new Jimp({
width: image.bitmap.width,
height: image.bitmap.height,
color: 0xffffffff,
});
flattened.composite(image, 0, 0);
return flattened;
}
async function encodePng(image: JimpImage): Promise<Buffer> {
return image.getBuffer(JimpMime.png);
}
async function encodeJpeg(image: JimpImage, quality: number): Promise<Buffer> {
const jpegSource = imageHasTransparency(image) ? flattenOnWhite(image) : image;
return jpegSource.getBuffer(JimpMime.jpeg, { quality });
}
function buildProcessingNotes(asset: WechatUploadAsset): string[] {
const notes: string[] = [];
const fileExt = ensureFileExt(asset);
if (fileExt && WECHAT_BODY_IMAGE_UNSUPPORTED_FORMATS.has(fileExt)) {
notes.push(`converted unsupported ${fileExt} source`);
}
if (asset.fileSize > WECHAT_BODY_IMAGE_MAX_SIZE) {
notes.push(`compressed ${(asset.fileSize / 1024 / 1024).toFixed(2)}MB source below 1MB`);
}
if (notes.length === 0) {
notes.push("re-encoded for WeChat body upload");
}
return notes;
}
export async function prepareWechatBodyImageUpload(
asset: WechatUploadAsset,
): Promise<PreparedWechatUploadAsset> {
if (!needsWechatBodyImageProcessing(asset)) {
return {
buffer: asset.buffer,
filename: asset.filename,
contentType: asset.contentType,
wasProcessed: false,
processingNotes: [],
};
}
const image = await loadImageForProcessing(asset);
const widths = buildCandidateWidths(image.bitmap.width);
const preferPng = imageHasTransparency(image) || ensureFileExt(asset) === ".png";
const processingNotes = buildProcessingNotes(asset);
for (const width of widths) {
const resized = resizeToWidth(image, width);
if (preferPng) {
const pngBuffer = await encodePng(resized);
if (pngBuffer.length <= WECHAT_BODY_IMAGE_MAX_SIZE) {
return {
buffer: pngBuffer,
filename: renameWithExt(asset.filename, ".png"),
contentType: JimpMime.png,
wasProcessed: true,
processingNotes: width < image.bitmap.width
? [...processingNotes, `resized to ${width}px wide`]
: processingNotes,
};
}
}
for (const quality of JPEG_QUALITY_STEPS) {
const jpegBuffer = await encodeJpeg(resized, quality);
if (jpegBuffer.length <= WECHAT_BODY_IMAGE_MAX_SIZE) {
const notes = [...processingNotes, `encoded as JPEG (${quality} quality)`];
if (width < image.bitmap.width) {
notes.push(`resized to ${width}px wide`);
}
return {
buffer: jpegBuffer,
filename: renameWithExt(asset.filename, ".jpg"),
contentType: JimpMime.jpeg,
wasProcessed: true,
processingNotes: notes,
};
}
}
}
throw new Error(`Unable to reduce ${asset.filename} below 1MB for WeChat body upload.`);
}
@@ -9,12 +9,26 @@ import { COLOR_PRESETS, FONT_FAMILY_MAP } from "./constants.ts";
import {
buildMarkdownDocumentMeta,
formatTimestamp,
renderMarkdownDocument,
resolveColorToken,
resolveFontFamilyToken,
resolveMarkdownStyle,
resolveRenderOptions,
} from "./document.ts";
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, `\\$&`);
}
function findInlineStyle(html: string, tagName: string, text: string): string {
const pattern = new RegExp(
`<${tagName}[^>]*style="([^"]*)"[^>]*>${escapeRegExp(text)}</${tagName}>`,
);
const match = html.match(pattern);
assert.ok(match, `Expected inline style for <${tagName}>${text}</${tagName}>`);
return match![1]!;
}
function useCwd(t: TestContext, cwd: string): void {
const previous = process.cwd();
process.chdir(cwd);
@@ -138,3 +152,23 @@ keep_title: true
assert.equal(explicit.fontSize, "18px");
assert.equal(explicit.keepTitle, false);
});
test("renderMarkdownDocument layers default rules into grace theme before CSS inlining", async () => {
const { html } = await renderMarkdownDocument(
`## Section\n\nParagraph with **bold** text.`,
{ keepTitle: true, theme: "grace" },
);
const h2Style = findInlineStyle(html, "h2", "Section");
assert.match(h2Style, /background: #92617E/);
assert.match(h2Style, /box-shadow: 0 4px 6px rgba\(0, 0, 0, 0\.1\)/);
const pMatch = html.match(/<p[^>]*style="([^"]*)"[^>]*>/);
assert.ok(pMatch, "Expected inline style on <p> tag");
assert.match(pMatch![1]!, /color:/);
const strongPattern = /<strong[^>]*style="([^"]*)"[^>]*>bold<\/strong>/;
const strongMatch = html.match(strongPattern);
assert.ok(strongMatch, "Expected inline style for <strong>bold</strong>");
assert.match(strongMatch![1]!, /font-weight:/);
});
@@ -59,6 +59,17 @@ test("normalizeCssText and normalizeInlineCss replace variables and strip declar
assert.doesNotMatch(normalizedHtml, /var\(--md-primary-color\)/);
});
test("normalizeInlineCss removes quoted custom property values without leaving fragments behind", () => {
const normalizedHtml = normalizeInlineCss(
`<html style="--md-font-family: Menlo, Monaco, 'Courier New', monospace; color: var(--md-primary-color)"></html>`,
DEFAULT_STYLE,
);
assert.match(normalizedHtml, /style=" color: #0F4C81"/);
assert.doesNotMatch(normalizedHtml, /Courier New/);
assert.doesNotMatch(normalizedHtml, /--md-font-family/);
});
test("HTML structure helpers hoist nested lists and remove the first heading", () => {
const nestedList = `<ul><li>Parent<ul><li>Child</li></ul></li></ul>`;
assert.equal(
@@ -100,13 +100,13 @@ export function normalizeCssText(cssText: string, style: StyleConfig = DEFAULT_S
.replace(/var\(--md-accent-color\)/g, style.accentColor)
.replace(/var\(--md-container-bg\)/g, style.containerBg)
.replace(/hsl\(var\(--foreground\)\)/g, "#3f3f3f")
.replace(/--md-primary-color:\s*[^;"']+;?/g, "")
.replace(/--md-font-family:\s*[^;"']+;?/g, "")
.replace(/--md-font-size:\s*[^;"']+;?/g, "")
.replace(/--blockquote-background:\s*[^;"']+;?/g, "")
.replace(/--md-accent-color:\s*[^;"']+;?/g, "")
.replace(/--md-container-bg:\s*[^;"']+;?/g, "")
.replace(/--foreground:\s*[^;"']+;?/g, "");
.replace(/--md-primary-color:\s*[^;]+;?/g, "")
.replace(/--md-font-family:\s*[^;]+;?/g, "")
.replace(/--md-font-size:\s*[^;]+;?/g, "")
.replace(/--blockquote-background:\s*[^;]+;?/g, "")
.replace(/--md-accent-color:\s*[^;]+;?/g, "")
.replace(/--md-container-bg:\s*[^;]+;?/g, "")
.replace(/--foreground:\s*[^;]+;?/g, "");
}
export function normalizeInlineCss(html: string, style: StyleConfig = DEFAULT_STYLE): string {
@@ -6,6 +6,7 @@ import type { ThemeName } from "./types.js";
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
export const THEME_DIR = path.resolve(SCRIPT_DIR, "themes");
const FALLBACK_THEMES: ThemeName[] = ["default", "grace", "simple"];
const THEMES_EXTENDING_DEFAULT = new Set<ThemeName>(["grace", "simple"]);
function stripOutputScope(cssContent: string): string {
let css = cssContent;
@@ -41,6 +42,7 @@ export function loadThemeCss(theme: ThemeName): {
themeCss: string;
} {
const basePath = path.join(THEME_DIR, "base.css");
const defaultThemePath = path.join(THEME_DIR, "default.css");
const themePath = path.join(THEME_DIR, `${theme}.css`);
if (!fs.existsSync(basePath)) {
@@ -51,9 +53,18 @@ export function loadThemeCss(theme: ThemeName): {
throw new Error(`Missing theme CSS for "${theme}": ${themePath}`);
}
const layeredThemeCss: string[] = [];
if (theme !== "default" && THEMES_EXTENDING_DEFAULT.has(theme)) {
if (!fs.existsSync(defaultThemePath)) {
throw new Error(`Missing default theme CSS: ${defaultThemePath}`);
}
layeredThemeCss.push(fs.readFileSync(defaultThemePath, "utf-8"));
}
layeredThemeCss.push(fs.readFileSync(themePath, "utf-8"));
return {
baseCss: fs.readFileSync(basePath, "utf-8"),
themeCss: fs.readFileSync(themePath, "utf-8"),
themeCss: layeredThemeCss.join("\n"),
};
}