feat(baoyu-post-to-wechat): add API publishing and external theme support

- Add wechat-api.ts for direct API-based article publishing
- Support plain text, HTML, and markdown inputs with auto-conversion
- Add external theme discovery from MD_THEME_DIR environment variable
- Improve CSS inlining with juice library integration
- Support WeChat API credentials via .baoyu-skills/.env
This commit is contained in:
Jim Liu 宝玉
2026-01-28 21:37:40 -06:00
parent 5f994589b3
commit 81e96ee43c
3 changed files with 893 additions and 36 deletions
+152 -12
View File
@@ -24,11 +24,17 @@ import {
highlightAndFormatCode,
} from "./utils/languages.js";
type ThemeName = "default" | "grace" | "simple";
type ThemeName = string;
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
const THEME_DIR = path.resolve(SCRIPT_DIR, "themes");
const THEME_NAMES: ThemeName[] = ["default", "grace", "simple"];
const EXTERNAL_THEME_CONFIG_PATH =
process.env.MD_THEME_CONFIG_PATH
|| "/Users/jimliu/GitHub/md/packages/shared/src/configs/theme.ts";
const EXTERNAL_THEME_DIR =
process.env.MD_THEME_DIR
|| path.resolve(path.dirname(EXTERNAL_THEME_CONFIG_PATH), "theme-css");
const FALLBACK_THEMES: ThemeName[] = ["default", "grace", "simple"];
const DEFAULT_STYLE = {
primaryColor: "#0F4C81",
@@ -45,6 +51,57 @@ Object.entries(COMMON_LANGUAGES).forEach(([name, lang]) => {
export { hljs };
function stripOutputScope(cssContent: string): string {
let css = cssContent;
css = css.replace(/#output\s*\{/g, "body {");
css = css.replace(/#output\s+/g, "");
css = css.replace(/^#output\s*/gm, "");
return css;
}
function discoverThemesFromDir(dir: string): string[] {
if (!fs.existsSync(dir)) {
return [];
}
return fs
.readdirSync(dir)
.filter((name) => name.endsWith(".css"))
.map((name) => name.replace(/\.css$/i, ""))
.filter((name) => name.toLowerCase() !== "base");
}
function readThemeNamesFromConfig(configPath: string): string[] {
if (!fs.existsSync(configPath)) {
return [];
}
const content = fs.readFileSync(configPath, "utf-8");
const match = content.match(/themeOptionsMap\s*=\s*\{([\s\S]*?)\n\}/);
if (!match) {
return [];
}
return Array.from(match[1].matchAll(/^\s*([a-zA-Z0-9_-]+)\s*:/gm)).map(
(item) => item[1]!
);
}
function resolveThemeNames(): ThemeName[] {
const localThemes = discoverThemesFromDir(THEME_DIR);
const externalThemes = discoverThemesFromDir(EXTERNAL_THEME_DIR);
const configThemes = readThemeNamesFromConfig(EXTERNAL_THEME_CONFIG_PATH);
const combined = new Set<ThemeName>([
...localThemes,
...externalThemes,
...configThemes,
]);
const resolved = Array.from(combined).filter((name) =>
fs.existsSync(path.join(THEME_DIR, `${name}.css`))
|| fs.existsSync(path.join(EXTERNAL_THEME_DIR, `${name}.css`))
);
return resolved.length ? resolved : FALLBACK_THEMES;
}
const THEME_NAMES: ThemeName[] = resolveThemeNames();
marked.setOptions({
breaks: true,
});
@@ -542,15 +599,31 @@ function loadThemeCss(theme: ThemeName): {
baseCss: string;
themeCss: string;
} {
const basePath = path.join(THEME_DIR, "base.css");
const themePath = path.join(THEME_DIR, `${theme}.css`);
const basePathCandidates = [
path.join(THEME_DIR, "base.css"),
path.join(EXTERNAL_THEME_DIR, "base.css"),
];
const themePathCandidates = [
path.join(THEME_DIR, `${theme}.css`),
path.join(EXTERNAL_THEME_DIR, `${theme}.css`),
];
const basePath = basePathCandidates.find((candidate) =>
fs.existsSync(candidate)
);
const themePath = themePathCandidates.find((candidate) =>
fs.existsSync(candidate)
);
if (!fs.existsSync(basePath)) {
throw new Error(`Missing base CSS: ${basePath}`);
if (!basePath) {
throw new Error(
`Missing base CSS. Checked: ${basePathCandidates.join(", ")}`
);
}
if (!fs.existsSync(themePath)) {
throw new Error(`Missing theme CSS: ${themePath}`);
if (!themePath) {
throw new Error(
`Missing theme CSS for "${theme}". Checked: ${themePathCandidates.join(", ")}`
);
}
return {
@@ -584,6 +657,10 @@ body {
return [variables, baseCss, themeCss].join("\n\n");
}
function normalizeThemeCss(css: string): string {
return stripOutputScope(css);
}
function buildHtmlDocument(title: string, css: string, html: string): string {
return [
"<!doctype html>",
@@ -603,7 +680,65 @@ function buildHtmlDocument(title: string, css: string, html: string): string {
].join("\n");
}
function main(): void {
async function inlineCss(html: string): Promise<string> {
try {
const { default: juice } = await import("juice");
return juice(html, {
inlinePseudoElements: true,
preserveImportant: true,
resolveCSSVariables: false,
});
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
throw new Error(
`Missing dependency "juice" for CSS inlining. Install it first (e.g. "bun add juice" or "npm add juice"). Original error: ${detail}`
);
}
}
function normalizeCssText(cssText: string): string {
return cssText
.replace(/var\(--md-primary-color\)/g, DEFAULT_STYLE.primaryColor)
.replace(/var\(--md-font-family\)/g, DEFAULT_STYLE.fontFamily)
.replace(/var\(--md-font-size\)/g, DEFAULT_STYLE.fontSize)
.replace(/var\(--blockquote-background\)/g, DEFAULT_STYLE.blockquoteBackground)
.replace(/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(/--foreground:\s*[^;"']+;?/g, "");
}
function normalizeInlineCss(html: string): string {
let output = html;
output = output.replace(
/<style([^>]*)>([\s\S]*?)<\/style>/gi,
(_match, attrs: string, cssText: string) =>
`<style${attrs}>${normalizeCssText(cssText)}</style>`
);
output = output.replace(
/style="([^"]*)"/gi,
(_match, cssText: string) => `style="${normalizeCssText(cssText)}"`
);
output = output.replace(
/style='([^']*)'/gi,
(_match, cssText: string) => `style='${normalizeCssText(cssText)}'`
);
return output;
}
function modifyHtmlStructure(htmlString: string): string {
let output = htmlString;
const pattern =
/<li([^>]*)>([\s\S]*?)(<ul[\s\S]*?<\/ul>|<ol[\s\S]*?<\/ol>)<\/li>/i;
while (pattern.test(output)) {
output = output.replace(pattern, "<li$1>$2</li>$3");
}
return output;
}
async function main(): Promise<void> {
const options = parseArgs(process.argv.slice(2));
if (!options) {
printUsage();
@@ -624,7 +759,7 @@ function main(): void {
);
const { baseCss, themeCss } = loadThemeCss(options.theme);
const css = buildCss(baseCss, themeCss);
const css = normalizeThemeCss(buildCss(baseCss, themeCss));
const markdown = fs.readFileSync(inputPath, "utf-8");
const renderer = initRenderer({});
@@ -636,6 +771,8 @@ function main(): void {
const title = path.basename(outputPath, ".html");
const html = buildHtmlDocument(title, css, content);
const inlinedHtml = normalizeInlineCss(await inlineCss(html));
const finalHtml = modifyHtmlStructure(inlinedHtml);
let backupPath = "";
if (fs.existsSync(outputPath)) {
@@ -643,7 +780,7 @@ function main(): void {
fs.renameSync(outputPath, backupPath);
}
fs.writeFileSync(outputPath, html, "utf-8");
fs.writeFileSync(outputPath, finalHtml, "utf-8");
if (backupPath) {
console.log(`Backup created: ${backupPath}`);
@@ -651,4 +788,7 @@ function main(): void {
console.log(`HTML written: ${outputPath}`);
}
main();
main().catch((error) => {
console.error(error);
process.exit(1);
});
@@ -0,0 +1,499 @@
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
interface WechatConfig {
appId: string;
appSecret: string;
}
interface AccessTokenResponse {
access_token?: string;
errcode?: number;
errmsg?: string;
}
interface UploadResponse {
media_id: string;
url: string;
errcode?: number;
errmsg?: string;
}
interface PublishResponse {
media_id?: string;
errcode?: number;
errmsg?: string;
}
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 DRAFT_URL = "https://api.weixin.qq.com/cgi-bin/draft/add";
function loadEnvFile(envPath: string): Record<string, string> {
const env: Record<string, string> = {};
if (!fs.existsSync(envPath)) return env;
const content = fs.readFileSync(envPath, "utf-8");
for (const line of content.split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const eqIdx = trimmed.indexOf("=");
if (eqIdx > 0) {
const key = trimmed.slice(0, eqIdx).trim();
let value = trimmed.slice(eqIdx + 1).trim();
if ((value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
env[key] = value;
}
}
return env;
}
function loadConfig(): WechatConfig {
const cwdEnvPath = path.join(process.cwd(), ".baoyu-skills", ".env");
const homeEnvPath = path.join(os.homedir(), ".baoyu-skills", ".env");
const cwdEnv = loadEnvFile(cwdEnvPath);
const homeEnv = loadEnvFile(homeEnvPath);
const appId = process.env.WECHAT_APP_ID || cwdEnv.WECHAT_APP_ID || homeEnv.WECHAT_APP_ID;
const appSecret = process.env.WECHAT_APP_SECRET || cwdEnv.WECHAT_APP_SECRET || homeEnv.WECHAT_APP_SECRET;
if (!appId || !appSecret) {
throw new Error(
"Missing WECHAT_APP_ID or WECHAT_APP_SECRET.\n" +
"Set via environment variables or in .baoyu-skills/.env file."
);
}
return { appId, appSecret };
}
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);
if (!res.ok) {
throw new Error(`Failed to fetch access token: ${res.status}`);
}
const data = await res.json() as AccessTokenResponse;
if (data.errcode) {
throw new Error(`Access token error ${data.errcode}: ${data.errmsg}`);
}
if (!data.access_token) {
throw new Error("No access_token in response");
}
return data.access_token;
}
async function uploadImage(
imagePath: string,
accessToken: string,
baseDir?: string
): Promise<UploadResponse> {
let fileBuffer: Buffer;
let filename: string;
let contentType: string;
if (imagePath.startsWith("http://") || imagePath.startsWith("https://")) {
const response = await fetch(imagePath);
if (!response.ok) {
throw new Error(`Failed to download image: ${imagePath}`);
}
const buffer = await response.arrayBuffer();
if (buffer.byteLength === 0) {
throw new Error(`Remote image is empty: ${imagePath}`);
}
fileBuffer = Buffer.from(buffer);
const urlPath = imagePath.split("?")[0];
filename = path.basename(urlPath) || "image.jpg";
contentType = response.headers.get("content-type") || "image/jpeg";
} else {
const resolvedPath = path.isAbsolute(imagePath)
? imagePath
: path.resolve(baseDir || process.cwd(), imagePath);
if (!fs.existsSync(resolvedPath)) {
throw new Error(`Image not found: ${resolvedPath}`);
}
const stats = fs.statSync(resolvedPath);
if (stats.size === 0) {
throw new Error(`Local image is empty: ${resolvedPath}`);
}
fileBuffer = fs.readFileSync(resolvedPath);
filename = path.basename(resolvedPath);
const ext = path.extname(filename).toLowerCase();
const mimeTypes: Record<string, string> = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".webp": "image/webp",
};
contentType = mimeTypes[ext] || "image/jpeg";
}
const boundary = `----WebKitFormBoundary${Date.now().toString(16)}`;
const header = [
`--${boundary}`,
`Content-Disposition: form-data; name="media"; filename="${filename}"`,
`Content-Type: ${contentType}`,
"",
"",
].join("\r\n");
const footer = `\r\n--${boundary}--\r\n`;
const headerBuffer = Buffer.from(header, "utf-8");
const footerBuffer = Buffer.from(footer, "utf-8");
const body = Buffer.concat([headerBuffer, fileBuffer, footerBuffer]);
const url = `${UPLOAD_URL}?access_token=${accessToken}&type=image`;
const res = await fetch(url, {
method: "POST",
headers: {
"Content-Type": `multipart/form-data; boundary=${boundary}`,
},
body,
});
const data = await res.json() as UploadResponse;
if (data.errcode && data.errcode !== 0) {
throw new Error(`Upload failed ${data.errcode}: ${data.errmsg}`);
}
if (data.url?.startsWith("http://")) {
data.url = data.url.replace(/^http:\/\//i, "https://");
}
return data;
}
async function uploadImagesInHtml(
html: string,
accessToken: string,
baseDir: string
): Promise<{ html: string; firstMediaId: string }> {
const imgRegex = /<img[^>]+src=["']([^"']+)["'][^>]*>/gi;
const matches = [...html.matchAll(imgRegex)];
if (matches.length === 0) {
return { html, firstMediaId: "" };
}
let firstMediaId = "";
let updatedHtml = html;
for (const match of matches) {
const [, src] = match;
if (!src) continue;
if (src.startsWith("https://mmbiz.qpic.cn")) {
if (!firstMediaId) {
firstMediaId = src;
}
continue;
}
console.error(`[wechat-api] Uploading image: ${src}`);
try {
const resp = await uploadImage(src, accessToken, baseDir);
updatedHtml = updatedHtml.replace(src, resp.url);
if (!firstMediaId) {
firstMediaId = resp.media_id;
}
} catch (err) {
console.error(`[wechat-api] Failed to upload ${src}:`, err);
}
}
return { html: updatedHtml, firstMediaId };
}
async function publishToDraft(
title: string,
html: string,
thumbMediaId: string,
accessToken: string
): Promise<PublishResponse> {
const url = `${DRAFT_URL}?access_token=${accessToken}`;
const res = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
articles: [{
title,
content: html,
thumb_media_id: thumbMediaId,
}],
}),
});
const data = await res.json() as PublishResponse;
if (data.errcode && data.errcode !== 0) {
throw new Error(`Publish failed ${data.errcode}: ${data.errmsg}`);
}
return data;
}
function parseFrontmatter(content: string): { frontmatter: Record<string, string>; body: string } {
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
if (!match) return { frontmatter: {}, body: content };
const frontmatter: Record<string, string> = {};
const lines = match[1]!.split("\n");
for (const line of lines) {
const colonIdx = line.indexOf(":");
if (colonIdx > 0) {
const key = line.slice(0, colonIdx).trim();
let value = line.slice(colonIdx + 1).trim();
if ((value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
frontmatter[key] = value;
}
}
return { frontmatter, body: match[2]! };
}
function renderMarkdownToHtml(markdownPath: string, theme: string = "default"): string {
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const renderScript = path.join(__dirname, "md", "render.ts");
const baseDir = path.dirname(markdownPath);
console.error(`[wechat-api] Rendering markdown with theme: ${theme}`);
const result = spawnSync("npx", ["-y", "bun", renderScript, markdownPath, "--theme", theme], {
stdio: ["inherit", "pipe", "pipe"],
cwd: baseDir,
});
if (result.status !== 0) {
const stderr = result.stderr?.toString() || "";
throw new Error(`Render failed: ${stderr}`);
}
const htmlPath = markdownPath.replace(/\.md$/i, ".html");
if (!fs.existsSync(htmlPath)) {
throw new Error(`HTML file not generated: ${htmlPath}`);
}
return htmlPath;
}
function extractHtmlContent(htmlPath: string): string {
const html = fs.readFileSync(htmlPath, "utf-8");
const match = html.match(/<div id="output">([\s\S]*?)<\/div>\s*<\/body>/);
if (match) {
return match[1]!.trim();
}
const bodyMatch = html.match(/<body[^>]*>([\s\S]*?)<\/body>/i);
return bodyMatch ? bodyMatch[1]!.trim() : html;
}
function printUsage(): never {
console.log(`Publish article to WeChat Official Account draft using API
Usage:
npx -y bun wechat-api.ts <file> [options]
Arguments:
file Markdown (.md) or HTML (.html) file
Options:
--title <title> Override title
--theme <name> Theme name for markdown (default, grace, simple). Default: default
--cover <path> Cover image path (local or URL)
--dry-run Parse and render only, don't publish
--help Show this help
Environment Variables:
WECHAT_APP_ID WeChat App ID
WECHAT_APP_SECRET WeChat App Secret
Config File Locations (in priority order):
1. Environment variables
2. <cwd>/.baoyu-skills/.env
3. ~/.baoyu-skills/.env
Example:
npx -y bun wechat-api.ts article.md
npx -y bun wechat-api.ts article.md --theme grace --cover cover.png
npx -y bun wechat-api.ts article.html --title "My Article"
npx -y bun wechat-api.ts article.md --dry-run
`);
process.exit(0);
}
interface CliArgs {
filePath: string;
isHtml: boolean;
title?: string;
theme: string;
cover?: string;
dryRun: boolean;
}
function parseArgs(argv: string[]): CliArgs {
if (argv.length === 0 || argv.includes("--help") || argv.includes("-h")) {
printUsage();
}
const args: CliArgs = {
filePath: "",
isHtml: false,
theme: "default",
dryRun: false,
};
for (let i = 0; i < argv.length; i++) {
const arg = argv[i]!;
if (arg === "--title" && argv[i + 1]) {
args.title = argv[++i];
} else if (arg === "--theme" && argv[i + 1]) {
args.theme = argv[++i]!;
} else if (arg === "--cover" && argv[i + 1]) {
args.cover = argv[++i];
} else if (arg === "--dry-run") {
args.dryRun = true;
} else if (!arg.startsWith("-")) {
args.filePath = arg;
}
}
if (!args.filePath) {
console.error("Error: File path required");
process.exit(1);
}
args.isHtml = args.filePath.toLowerCase().endsWith(".html");
return args;
}
function extractHtmlTitle(html: string): string {
const titleMatch = html.match(/<title>([^<]+)<\/title>/i);
if (titleMatch) return titleMatch[1]!;
const h1Match = html.match(/<h1[^>]*>([^<]+)<\/h1>/i);
if (h1Match) return h1Match[1]!.replace(/<[^>]+>/g, "").trim();
return "";
}
async function main(): Promise<void> {
const args = parseArgs(process.argv.slice(2));
const filePath = path.resolve(args.filePath);
if (!fs.existsSync(filePath)) {
console.error(`Error: File not found: ${filePath}`);
process.exit(1);
}
const baseDir = path.dirname(filePath);
let title = args.title || "";
let htmlPath: string;
let htmlContent: string;
let frontmatter: Record<string, string> = {};
if (args.isHtml) {
htmlPath = filePath;
htmlContent = extractHtmlContent(htmlPath);
if (!title) {
title = extractHtmlTitle(fs.readFileSync(htmlPath, "utf-8"));
}
console.error(`[wechat-api] Using HTML file: ${htmlPath}`);
} else {
const content = fs.readFileSync(filePath, "utf-8");
const parsed = parseFrontmatter(content);
frontmatter = parsed.frontmatter;
const body = parsed.body;
title = title || frontmatter.title || "";
if (!title) {
const h1Match = body.match(/^#\s+(.+)$/m);
if (h1Match) title = h1Match[1]!;
}
console.error(`[wechat-api] Theme: ${args.theme}`);
htmlPath = renderMarkdownToHtml(filePath, args.theme);
console.error(`[wechat-api] HTML generated: ${htmlPath}`);
htmlContent = extractHtmlContent(htmlPath);
}
if (!title) {
console.error("Error: No title found. Provide via --title, frontmatter, or <title> tag.");
process.exit(1);
}
console.error(`[wechat-api] Title: ${title}`);
if (args.dryRun) {
console.log(JSON.stringify({
title,
htmlPath,
contentLength: htmlContent.length,
}, null, 2));
return;
}
const config = loadConfig();
console.error("[wechat-api] Fetching access token...");
const accessToken = await fetchAccessToken(config.appId, config.appSecret);
console.error("[wechat-api] Uploading images...");
const { html: processedHtml, firstMediaId } = await uploadImagesInHtml(
htmlContent,
accessToken,
baseDir
);
htmlContent = processedHtml;
let thumbMediaId = "";
const coverPath = args.cover ||
frontmatter.featureImage ||
frontmatter.coverImage ||
frontmatter.cover ||
frontmatter.image;
if (coverPath) {
console.error(`[wechat-api] Uploading cover: ${coverPath}`);
const coverResp = await uploadImage(coverPath, accessToken, baseDir);
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;
}
}
if (!thumbMediaId) {
console.error("Error: No cover image. Provide via --cover, frontmatter.featureImage, or include an image in content.");
process.exit(1);
}
console.error("[wechat-api] Publishing to draft...");
const result = await publishToDraft(title, htmlContent, thumbMediaId, accessToken);
console.log(JSON.stringify({
success: true,
media_id: result.media_id,
title,
}, null, 2));
console.error(`[wechat-api] Published successfully! media_id: ${result.media_id}`);
}
await main().catch((err) => {
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
});