Merge pull request #86 from JimLiu/codex/baoyu-md-shared

refactor: share markdown renderer via baoyu-md
This commit is contained in:
Jim Liu 宝玉 2026-03-13 10:45:34 -05:00 committed by GitHub
commit 95970480c8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
427 changed files with 10948 additions and 3816 deletions

View File

@ -27,7 +27,9 @@ Release hooks are configured via `.releaserc.yml`. This repo does not stage a se
`packages/` is the **only** source of truth. Never edit `skills/*/scripts/vendor/` directly.
Current package: `baoyu-chrome-cdp` (Chrome CDP utilities), consumed by 6 skills (`baoyu-danger-gemini-web`, `baoyu-danger-x-to-markdown`, `baoyu-post-to-wechat`, `baoyu-post-to-weibo`, `baoyu-post-to-x`, `baoyu-url-to-markdown`).
Current packages:
- `baoyu-chrome-cdp` (Chrome CDP utilities), consumed by 6 skills (`baoyu-danger-gemini-web`, `baoyu-danger-x-to-markdown`, `baoyu-post-to-wechat`, `baoyu-post-to-weibo`, `baoyu-post-to-x`, `baoyu-url-to-markdown`)
- `baoyu-md` (shared Markdown rendering and placeholder pipeline), consumed by 3 skills (`baoyu-markdown-to-html`, `baoyu-post-to-wechat`, `baoyu-post-to-weibo`)
**How it works**: Sync script copies packages into each consuming skill's `vendor/` directory and rewrites dependency specs to `file:./vendor/<name>`. Vendor copies are committed to git, making skills self-contained.

View File

@ -1,4 +1,11 @@
{
"name": "baoyu-md",
"private": true,
"version": "0.1.0",
"type": "module",
"exports": {
".": "./src/index.ts"
},
"dependencies": {
"fflate": "^0.8.2",
"front-matter": "^4.0.2",

View File

@ -12,7 +12,7 @@ export function printUsage(): void {
console.error(
[
"Usage:",
" npx tsx src/md/render.ts <markdown_file> [options]",
" npx tsx render.ts <markdown_file> [options]",
"",
"Options:",
` --theme <name> Theme (${THEME_NAMES.join(", ")})`,

View File

@ -0,0 +1,105 @@
import { Lexer } from "marked";
export type FrontmatterFields = Record<string, string>;
export function parseFrontmatter(content: string): {
frontmatter: FrontmatterFields;
body: string;
} {
const match = content.match(/^\s*---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
if (!match) {
return { frontmatter: {}, body: content };
}
const frontmatter: FrontmatterFields = {};
const lines = match[1]!.split("\n");
for (const line of lines) {
const colonIdx = line.indexOf(":");
if (colonIdx <= 0) continue;
const key = line.slice(0, colonIdx).trim();
const value = line.slice(colonIdx + 1).trim();
frontmatter[key] = stripWrappingQuotes(value);
}
return { frontmatter, body: match[2]! };
}
export function serializeFrontmatter(frontmatter: FrontmatterFields): string {
const entries = Object.entries(frontmatter);
if (entries.length === 0) return "";
return `---\n${entries.map(([key, value]) => `${key}: ${value}`).join("\n")}\n---\n`;
}
export function stripWrappingQuotes(value: string): string {
if (!value) return value;
const doubleQuoted = value.startsWith('"') && value.endsWith('"');
const singleQuoted = value.startsWith("'") && value.endsWith("'");
const cjkDoubleQuoted = value.startsWith("\u201c") && value.endsWith("\u201d");
const cjkSingleQuoted = value.startsWith("\u2018") && value.endsWith("\u2019");
if (doubleQuoted || singleQuoted || cjkDoubleQuoted || cjkSingleQuoted) {
return value.slice(1, -1).trim();
}
return value.trim();
}
export function toFrontmatterString(value: unknown): string | undefined {
if (typeof value === "string") {
return stripWrappingQuotes(value);
}
if (typeof value === "number" || typeof value === "boolean") {
return String(value);
}
return undefined;
}
export function pickFirstString(
frontmatter: Record<string, unknown>,
keys: string[],
): string | undefined {
for (const key of keys) {
const value = toFrontmatterString(frontmatter[key]);
if (value) return value;
}
return undefined;
}
export function extractTitleFromMarkdown(markdown: string): string {
const tokens = Lexer.lex(markdown, { gfm: true, breaks: true });
for (const token of tokens) {
if (token.type !== "heading" || (token.depth !== 1 && token.depth !== 2)) continue;
return stripWrappingQuotes(token.text);
}
return "";
}
export function extractSummaryFromBody(body: string, maxLen: number): string {
const lines = body.split("\n");
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
if (trimmed.startsWith("#")) continue;
if (trimmed.startsWith("![")) continue;
if (trimmed.startsWith(">")) continue;
if (trimmed.startsWith("-") || trimmed.startsWith("*")) continue;
if (/^\d+\./.test(trimmed)) continue;
if (trimmed.startsWith("```")) continue;
const cleanText = trimmed
.replace(/\*\*(.+?)\*\*/g, "$1")
.replace(/\*(.+?)\*/g, "$1")
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
.replace(/`([^`]+)`/g, "$1");
if (cleanText.length > 20) {
if (cleanText.length <= maxLen) return cleanText;
return `${cleanText.slice(0, maxLen - 3)}...`;
}
}
return "";
}

View File

@ -0,0 +1,206 @@
import fs from "node:fs";
import path from "node:path";
import type { ReadTimeResults } from "reading-time";
import {
COLOR_PRESETS,
DEFAULT_STYLE,
FONT_FAMILY_MAP,
THEME_STYLE_DEFAULTS,
} from "./constants.js";
import {
extractSummaryFromBody,
extractTitleFromMarkdown,
pickFirstString,
stripWrappingQuotes,
} from "./content.js";
import { loadExtendConfig } from "./extend-config.js";
import {
buildCss,
buildHtmlDocument,
inlineCss,
loadCodeThemeCss,
modifyHtmlStructure,
normalizeInlineCss,
removeFirstHeading,
} from "./html-builder.js";
import { initRenderer, postProcessHtml, renderMarkdown } from "./renderer.js";
import { loadThemeCss, normalizeThemeCss } from "./themes.js";
import type { HtmlDocumentMeta, IOpts, StyleConfig, ThemeName } from "./types.js";
export interface RenderMarkdownDocumentOptions {
codeTheme?: string;
countStatus?: boolean;
citeStatus?: boolean;
defaultTitle?: string;
fontFamily?: string;
fontSize?: string;
isMacCodeBlock?: boolean;
isShowLineNumber?: boolean;
keepTitle?: boolean;
legend?: string;
primaryColor?: string;
theme?: ThemeName;
themeMode?: IOpts["themeMode"];
}
export interface RenderMarkdownDocumentResult {
contentHtml: string;
html: string;
meta: HtmlDocumentMeta;
readingTime: ReadTimeResults;
style: StyleConfig;
yamlData: Record<string, unknown>;
}
export function resolveColorToken(value?: string): string | undefined {
if (!value) return undefined;
return COLOR_PRESETS[value] ?? value;
}
export function resolveFontFamilyToken(value?: string): string | undefined {
if (!value) return undefined;
return FONT_FAMILY_MAP[value] ?? value;
}
export function formatTimestamp(date = new Date()): string {
const pad = (value: number) => String(value).padStart(2, "0");
return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(
date.getDate(),
)}${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`;
}
export function buildMarkdownDocumentMeta(
markdown: string,
yamlData: Record<string, unknown>,
defaultTitle = "document",
): HtmlDocumentMeta {
const title = pickFirstString(yamlData, ["title"])
|| extractTitleFromMarkdown(markdown)
|| defaultTitle;
const author = pickFirstString(yamlData, ["author"]);
const description = pickFirstString(yamlData, ["description", "summary"])
|| extractSummaryFromBody(markdown, 120);
return {
title: stripWrappingQuotes(title),
author: author ? stripWrappingQuotes(author) : undefined,
description: description ? stripWrappingQuotes(description) : undefined,
};
}
export function resolveMarkdownStyle(options: RenderMarkdownDocumentOptions = {}): StyleConfig {
const theme = options.theme ?? "default";
const themeDefaults = THEME_STYLE_DEFAULTS[theme] ?? {};
return {
...DEFAULT_STYLE,
...themeDefaults,
...(options.primaryColor !== undefined ? { primaryColor: options.primaryColor } : {}),
...(options.fontFamily !== undefined ? { fontFamily: options.fontFamily } : {}),
...(options.fontSize !== undefined ? { fontSize: options.fontSize } : {}),
};
}
export function resolveRenderOptions(
options: RenderMarkdownDocumentOptions = {},
): RenderMarkdownDocumentOptions {
const extendConfig = loadExtendConfig();
return {
codeTheme: options.codeTheme ?? extendConfig.default_code_theme ?? "github",
countStatus: options.countStatus ?? extendConfig.count ?? false,
citeStatus: options.citeStatus ?? extendConfig.cite ?? false,
defaultTitle: options.defaultTitle,
fontFamily: options.fontFamily ?? resolveFontFamilyToken(extendConfig.default_font_family ?? undefined),
fontSize: options.fontSize ?? extendConfig.default_font_size ?? undefined,
isMacCodeBlock: options.isMacCodeBlock ?? extendConfig.mac_code_block ?? true,
isShowLineNumber: options.isShowLineNumber ?? extendConfig.show_line_number ?? false,
keepTitle: options.keepTitle ?? extendConfig.keep_title ?? false,
legend: options.legend ?? extendConfig.legend ?? "alt",
primaryColor: options.primaryColor ?? resolveColorToken(extendConfig.default_color ?? undefined),
theme: options.theme ?? extendConfig.default_theme ?? "default",
themeMode: options.themeMode,
};
}
export async function renderMarkdownDocument(
markdown: string,
options: RenderMarkdownDocumentOptions = {},
): Promise<RenderMarkdownDocumentResult> {
const resolvedOptions = resolveRenderOptions(options);
const theme = resolvedOptions.theme ?? "default";
const codeTheme = resolvedOptions.codeTheme ?? "github";
const style = resolveMarkdownStyle(resolvedOptions);
const { baseCss, themeCss } = loadThemeCss(theme);
const css = normalizeThemeCss(buildCss(baseCss, themeCss, style));
const codeThemeCss = loadCodeThemeCss(codeTheme);
const renderer = initRenderer({
citeStatus: resolvedOptions.citeStatus ?? false,
countStatus: resolvedOptions.countStatus ?? false,
isMacCodeBlock: resolvedOptions.isMacCodeBlock ?? true,
isShowLineNumber: resolvedOptions.isShowLineNumber ?? false,
legend: resolvedOptions.legend ?? "alt",
themeMode: resolvedOptions.themeMode,
});
const { yamlData, markdownContent, readingTime } = renderer.parseFrontMatterAndContent(markdown);
const { html: baseHtml, readingTime: readingTimeResult } = renderMarkdown(markdown, renderer);
let contentHtml = postProcessHtml(baseHtml, readingTimeResult, renderer);
if (!(resolvedOptions.keepTitle ?? false)) {
contentHtml = removeFirstHeading(contentHtml);
}
const meta = buildMarkdownDocumentMeta(
markdownContent,
yamlData as Record<string, unknown>,
resolvedOptions.defaultTitle,
);
const html = buildHtmlDocument(meta, css, contentHtml, codeThemeCss);
const inlinedHtml = normalizeInlineCss(await inlineCss(html), style);
return {
contentHtml,
html: modifyHtmlStructure(inlinedHtml),
meta,
readingTime,
style,
yamlData: yamlData as Record<string, unknown>,
};
}
export async function renderMarkdownFileToHtml(
inputPath: string,
options: RenderMarkdownDocumentOptions = {},
): Promise<RenderMarkdownDocumentResult & {
backupPath?: string;
outputPath: string;
}> {
const markdown = fs.readFileSync(inputPath, "utf-8");
const outputPath = path.resolve(
path.dirname(inputPath),
`${path.basename(inputPath, path.extname(inputPath))}.html`,
);
const result = await renderMarkdownDocument(markdown, {
...options,
defaultTitle: options.defaultTitle ?? path.basename(outputPath, ".html"),
});
let backupPath: string | undefined;
if (fs.existsSync(outputPath)) {
backupPath = `${outputPath}.bak-${formatTimestamp()}`;
fs.renameSync(outputPath, backupPath);
}
fs.writeFileSync(outputPath, result.html, "utf-8");
return {
...result,
backupPath,
outputPath,
};
}

View File

@ -0,0 +1,156 @@
import { createHash } from "node:crypto";
import fs from "node:fs";
import http from "node:http";
import https from "node:https";
import path from "node:path";
export interface ImagePlaceholder {
originalPath: string;
placeholder: string;
alt?: string;
}
export interface ResolvedImageInfo extends ImagePlaceholder {
localPath: string;
}
export function replaceMarkdownImagesWithPlaceholders(
markdown: string,
placeholderPrefix: string,
): {
images: ImagePlaceholder[];
markdown: string;
} {
const images: ImagePlaceholder[] = [];
let imageCounter = 0;
const rewritten = markdown.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_match, alt, src) => {
const placeholder = `${placeholderPrefix}${++imageCounter}`;
images.push({
alt,
originalPath: src,
placeholder,
});
return placeholder;
});
return { images, markdown: rewritten };
}
export function getImageExtension(urlOrPath: string): string {
const match = urlOrPath.match(/\.(jpg|jpeg|png|gif|webp)(\?|$)/i);
return match ? match[1]!.toLowerCase() : "png";
}
export async function downloadFile(url: string, destPath: string): Promise<void> {
return await new Promise((resolve, reject) => {
const protocol = url.startsWith("https://") ? https : http;
const file = fs.createWriteStream(destPath);
const request = protocol.get(url, { headers: { "User-Agent": "Mozilla/5.0" } }, (response) => {
if (response.statusCode === 301 || response.statusCode === 302) {
const redirectUrl = response.headers.location;
if (redirectUrl) {
file.close();
fs.unlinkSync(destPath);
void downloadFile(redirectUrl, destPath).then(resolve).catch(reject);
return;
}
}
if (response.statusCode !== 200) {
file.close();
fs.unlinkSync(destPath);
reject(new Error(`Failed to download: ${response.statusCode}`));
return;
}
response.pipe(file);
file.on("finish", () => {
file.close();
resolve();
});
});
request.on("error", (error) => {
file.close();
fs.unlink(destPath, () => {});
reject(error);
});
request.setTimeout(30_000, () => {
request.destroy();
reject(new Error("Download timeout"));
});
});
}
export async function resolveImagePath(
imagePath: string,
baseDir: string,
tempDir: string,
logLabel = "baoyu-md",
): Promise<string> {
if (imagePath.startsWith("http://") || imagePath.startsWith("https://")) {
const hash = createHash("md5").update(imagePath).digest("hex").slice(0, 8);
const ext = getImageExtension(imagePath);
const localPath = path.join(tempDir, `remote_${hash}.${ext}`);
if (!fs.existsSync(localPath)) {
console.error(`[${logLabel}] Downloading: ${imagePath}`);
await downloadFile(imagePath, localPath);
}
return localPath;
}
const resolved = path.isAbsolute(imagePath)
? imagePath
: path.resolve(baseDir, imagePath);
return resolveLocalWithFallback(resolved, logLabel);
}
export async function resolveContentImages(
images: ImagePlaceholder[],
baseDir: string,
tempDir: string,
logLabel = "baoyu-md",
): Promise<ResolvedImageInfo[]> {
const resolved: ResolvedImageInfo[] = [];
for (const image of images) {
resolved.push({
...image,
localPath: await resolveImagePath(image.originalPath, baseDir, tempDir, logLabel),
});
}
return resolved;
}
function resolveLocalWithFallback(resolved: string, logLabel: string): string {
if (fs.existsSync(resolved)) {
return resolved;
}
const ext = path.extname(resolved);
const base = ext ? resolved.slice(0, -ext.length) : resolved;
const alternatives = [
`${base}.webp`,
`${base}.jpg`,
`${base}.jpeg`,
`${base}.png`,
`${base}.gif`,
`${base}_original.png`,
`${base}_original.jpg`,
].filter((candidate) => candidate !== resolved);
for (const alternative of alternatives) {
if (!fs.existsSync(alternative)) continue;
console.error(
`[${logLabel}] Image fallback: ${path.basename(resolved)} -> ${path.basename(alternative)}`,
);
return alternative;
}
return resolved;
}

View File

@ -0,0 +1,10 @@
export * from "./cli.js";
export * from "./constants.js";
export * from "./content.js";
export * from "./document.js";
export * from "./extend-config.js";
export * from "./html-builder.js";
export * from "./images.js";
export * from "./renderer.js";
export * from "./themes.js";
export * from "./types.js";

View File

@ -0,0 +1,43 @@
#!/usr/bin/env npx tsx
import path from "node:path";
import { parseArgs, printUsage } from "./cli.js";
import { renderMarkdownFileToHtml } from "./document.js";
async function main(): Promise<void> {
const options = parseArgs(process.argv.slice(2));
if (!options) {
printUsage();
process.exit(1);
}
const inputPath = path.resolve(process.cwd(), options.inputPath);
if (!inputPath.toLowerCase().endsWith(".md")) {
console.error("Input file must end with .md");
process.exit(1);
}
const result = await renderMarkdownFileToHtml(inputPath, {
codeTheme: options.codeTheme,
countStatus: options.countStatus,
citeStatus: options.citeStatus,
fontFamily: options.fontFamily,
fontSize: options.fontSize,
isMacCodeBlock: options.isMacCodeBlock,
isShowLineNumber: options.isShowLineNumber,
keepTitle: options.keepTitle,
legend: options.legend,
primaryColor: options.primaryColor,
theme: options.theme,
});
if (result.backupPath) {
console.log(`Backup created: ${result.backupPath}`);
}
console.log(`HTML written: ${result.outputPath}`);
}
main().catch((error) => {
console.error(error);
process.exit(1);
});

Some files were not shown because too many files have changed in this diff Show More