Loading source
Pulling the file list, source metadata, and syntax-aware rendering for this listing.
Source from repo
Converts Markdown to styled HTML with WeChat-compatible themes, code highlighting, math, PlantUML, and footnotes.
Files
Skill
Size
Entrypoint
Format
Open file
Syntax-highlighted preview of this file as included in the skill package.
scripts/main.ts
1import fs from "node:fs";2import os from "node:os";3import path from "node:path";4import process from "node:process";56import {7COLOR_PRESETS,8FONT_FAMILY_MAP,9FONT_SIZE_OPTIONS,10THEME_NAMES,11extractSummaryFromBody,12extractTitleFromMarkdown,13formatTimestamp,14parseArgs,15parseFrontmatter,16preprocessMermaidInMarkdown,17renderMarkdownDocument,18replaceMarkdownImagesWithPlaceholders,19resolveContentImages,20serializeFrontmatter,21stripWrappingQuotes,22} from "baoyu-md";23import type { CliOptions } from "baoyu-md";24import { closeRenderer, renderMermaidToPng } from "baoyu-chrome-cdp/mermaid";2526interface ImageInfo {27placeholder: string;28localPath: string;29originalPath: string;30alt?: string;31}3233interface MermaidImageInfo {34hash: string;35localPath: string;36cached: boolean;37}3839interface ParsedResult {40title: string;41author: string;42summary: string;43htmlPath: string;44backupPath?: string;45contentImages: ImageInfo[];46mermaidImages: MermaidImageInfo[];47}4849interface MermaidCliOptions {50enabled?: boolean;51theme?: string;52scale?: number;53background?: string;54minWidth?: number;55}5657type ConvertMarkdownOptions = Partial<Omit<CliOptions, "inputPath">> & {58title?: string;59mermaid?: MermaidCliOptions;60};6162function escapeHtmlAttribute(value: string): string {63return value64.replace(/&/g, "&")65.replace(/"/g, """)66.replace(/</g, "<")67.replace(/>/g, ">");68}6970export async function convertMarkdown(71markdownPath: string,72options?: ConvertMarkdownOptions,73): Promise<ParsedResult> {74const baseDir = path.dirname(markdownPath);75const content = fs.readFileSync(markdownPath, "utf-8");76const theme = options?.theme;77const keepTitle = options?.keepTitle ?? false;78const citeStatus = options?.citeStatus ?? false;7980const { frontmatter, body } = parseFrontmatter(content);8182let title = stripWrappingQuotes(options?.title ?? "")83|| stripWrappingQuotes(frontmatter.title ?? "")84|| extractTitleFromMarkdown(body);85if (!title) {86title = path.basename(markdownPath, path.extname(markdownPath));87}8889const author = stripWrappingQuotes(frontmatter.author ?? "");90let summary = stripWrappingQuotes(frontmatter.description ?? "")91|| stripWrappingQuotes(frontmatter.summary ?? "");92if (!summary) {93summary = extractSummaryFromBody(body, 120);94}9596const effectiveFrontmatter = options?.title97? { ...frontmatter, title }98: frontmatter;99100const mermaidEnabled = options?.mermaid?.enabled !== false;101const mermaidMinWidth = options?.mermaid?.minWidth ?? 860;102const { markdown: mermaidProcessedBody, images: mermaidImages } =103await preprocessMermaidInMarkdown(body, {104baseDir,105renderFn: renderMermaidToPng,106enabled: mermaidEnabled,107theme: options?.mermaid?.theme,108scale: options?.mermaid?.scale,109background: options?.mermaid?.background,110minWidth: mermaidMinWidth,111onError: (error, block) => {112const message = error instanceof Error ? error.message : String(error);113console.error(114`[markdown-to-html] mermaid render failed (${block.code.slice(0, 40).replace(/\s+/g, " ")}…): ${message}`,115);116},117});118119if (mermaidImages.length > 0) {120const fresh = mermaidImages.filter((image) => !image.cached).length;121console.error(122`[markdown-to-html] mermaid: ${mermaidImages.length} block(s), ${fresh} rendered, ${mermaidImages.length - fresh} cached`,123);124}125126const { images, markdown: rewrittenBody } = replaceMarkdownImagesWithPlaceholders(127mermaidProcessedBody,128"MDTOHTMLIMGPH_",129);130const rewrittenMarkdown = `${serializeFrontmatter(effectiveFrontmatter)}${rewrittenBody}`;131132console.error(133`[markdown-to-html] Rendering with theme: ${theme ?? "default"}, keepTitle: ${keepTitle}, citeStatus: ${citeStatus}`,134);135136const { html } = await renderMarkdownDocument(rewrittenMarkdown, {137codeTheme: options?.codeTheme,138countStatus: options?.countStatus,139citeStatus,140defaultTitle: title,141fontFamily: options?.fontFamily,142fontSize: options?.fontSize,143isMacCodeBlock: options?.isMacCodeBlock,144isShowLineNumber: options?.isShowLineNumber,145keepTitle,146legend: options?.legend,147primaryColor: options?.primaryColor,148theme,149});150151const finalHtmlPath = markdownPath.replace(/\.md$/i, ".html");152let backupPath: string | undefined;153154if (fs.existsSync(finalHtmlPath)) {155backupPath = `${finalHtmlPath}.bak-${formatTimestamp()}`;156console.error(`[markdown-to-html] Backing up existing file to: ${backupPath}`);157fs.renameSync(finalHtmlPath, backupPath);158}159160fs.writeFileSync(finalHtmlPath, html, "utf-8");161162const hasRemoteImages = images.some((image) =>163image.originalPath.startsWith("http://") || image.originalPath.startsWith("https://"),164);165const tempDir = hasRemoteImages166? fs.mkdtempSync(path.join(os.tmpdir(), "markdown-to-html-"))167: baseDir;168const contentImages = await resolveContentImages(images, baseDir, tempDir, "markdown-to-html");169170let finalContent = fs.readFileSync(finalHtmlPath, "utf-8");171for (const image of contentImages) {172const altAttr = image.alt !== undefined173? ` alt="${escapeHtmlAttribute(image.alt)}"`174: "";175const imgTag = `<img src="${escapeHtmlAttribute(image.originalPath)}" `176+ `data-local-path="${escapeHtmlAttribute(image.localPath)}"${altAttr} `177+ `style="display: block; width: 100%; margin: 1.5em auto;">`;178finalContent = finalContent.replace(image.placeholder, imgTag);179}180fs.writeFileSync(finalHtmlPath, finalContent, "utf-8");181182console.error(`[markdown-to-html] HTML saved to: ${finalHtmlPath}`);183184return {185title,186author,187summary,188htmlPath: finalHtmlPath,189backupPath,190contentImages,191mermaidImages: mermaidImages.map((image) => ({192hash: image.hash,193localPath: image.localPath,194cached: image.cached,195})),196};197}198199function printUsage(exitCode = 0): never {200const colorNames = Object.keys(COLOR_PRESETS).join(", ");201const fontFamilyNames = Object.keys(FONT_FAMILY_MAP).join(", ");202203console.log(`Convert Markdown to styled HTML204205Usage:206npx -y bun main.ts <markdown_file> [options]207208Options:209--title <title> Override title210--theme <name> Theme name (${THEME_NAMES.join(", ")}). Default: default211--color <name|hex> Primary color: ${colorNames}212--font-family <name> Font: ${fontFamilyNames}, or CSS value213--font-size <N> Font size: ${FONT_SIZE_OPTIONS.join(", ")} (default: 16px)214--code-theme <name> Code highlight theme (default: github)215--mac-code-block Show Mac-style code block header216--no-mac-code-block Hide Mac-style code block header217--line-number Show line numbers in code blocks218--cite Convert ordinary external links to bottom citations. Default: off219--count Show reading time / word count220--legend <value> Image caption: title-alt, alt-title, title, alt, none221--keep-title Keep the first heading in content. Default: false (removed)222--mermaid-theme <name> Mermaid theme: default, forest, dark, neutral. Default: default223--mermaid-scale <N> Mermaid render scale: 1, 1.5, 2, 3. Default: 2224--mermaid-width <N> Mermaid target display width in CSS px. Default: 860225--mermaid-bg <value> Mermaid background: white, transparent, or #hex. Default: white226--no-mermaid Skip Mermaid rendering; emit <pre class="mermaid"> fallback227--help Show this help228229Output:230HTML file saved to same directory as input markdown file.231Example: article.md -> article.html232233If HTML file already exists, it will be backed up first:234article.html -> article.html.bak-YYYYMMDDHHMMSS235236Output JSON format:237{238"title": "Article Title",239"htmlPath": "/path/to/article.html",240"backupPath": "/path/to/article.html.bak-20260128180000",241"contentImages": [...]242}243244Example:245npx -y bun main.ts article.md246npx -y bun main.ts article.md --theme grace247npx -y bun main.ts article.md --theme modern --color red248npx -y bun main.ts article.md --cite249`);250process.exit(exitCode);251}252253function parseArgValue(argv: string[], i: number, flag: string): string | null {254const arg = argv[i]!;255if (arg.includes("=")) {256return arg.slice(flag.length + 1);257}258const next = argv[i + 1];259return next ?? null;260}261262function extractTitleArg(argv: string[]): { renderArgs: string[]; title?: string } {263let title: string | undefined;264const renderArgs: string[] = [];265266for (let i = 0; i < argv.length; i += 1) {267const arg = argv[i]!;268if (arg === "--title" || arg.startsWith("--title=")) {269const value = parseArgValue(argv, i, "--title");270if (!value) {271console.error("Missing value for --title");272printUsage(1);273}274title = value;275if (!arg.includes("=")) {276i += 1;277}278continue;279}280renderArgs.push(arg);281}282283return { renderArgs, title };284}285286const VALID_MERMAID_THEMES = new Set(["default", "forest", "dark", "neutral", "base"]);287288function extractMermaidArgs(argv: string[]): { renderArgs: string[]; mermaid: MermaidCliOptions } {289const mermaid: MermaidCliOptions = {};290const renderArgs: string[] = [];291292for (let i = 0; i < argv.length; i += 1) {293const arg = argv[i]!;294if (arg === "--no-mermaid") {295mermaid.enabled = false;296continue;297}298if (arg === "--mermaid-theme" || arg.startsWith("--mermaid-theme=")) {299const value = parseArgValue(argv, i, "--mermaid-theme");300if (!value) {301console.error("Missing value for --mermaid-theme");302printUsage(1);303}304if (!VALID_MERMAID_THEMES.has(value)) {305console.error(`Invalid --mermaid-theme: ${value} (choose one of ${[...VALID_MERMAID_THEMES].join(", ")})`);306printUsage(1);307}308mermaid.theme = value;309if (!arg.includes("=")) i += 1;310continue;311}312if (arg === "--mermaid-scale" || arg.startsWith("--mermaid-scale=")) {313const value = parseArgValue(argv, i, "--mermaid-scale");314const parsed = Number.parseFloat(value ?? "");315if (!value || !Number.isFinite(parsed) || parsed <= 0 || parsed > 4) {316console.error(`Invalid --mermaid-scale: ${value} (expect a positive number ≤ 4)`);317printUsage(1);318}319mermaid.scale = parsed;320if (!arg.includes("=")) i += 1;321continue;322}323if (arg === "--mermaid-width" || arg.startsWith("--mermaid-width=")) {324const value = parseArgValue(argv, i, "--mermaid-width");325const parsed = Number.parseInt(value ?? "", 10);326if (!value || !Number.isFinite(parsed) || parsed <= 0) {327console.error(`Invalid --mermaid-width: ${value} (expect a positive integer)`);328printUsage(1);329}330mermaid.minWidth = parsed;331if (!arg.includes("=")) i += 1;332continue;333}334if (arg === "--mermaid-bg" || arg.startsWith("--mermaid-bg=")) {335const value = parseArgValue(argv, i, "--mermaid-bg");336if (!value) {337console.error("Missing value for --mermaid-bg");338printUsage(1);339}340mermaid.background = value;341if (!arg.includes("=")) i += 1;342continue;343}344renderArgs.push(arg);345}346347return { renderArgs, mermaid };348}349350async function main(): Promise<void> {351const args = process.argv.slice(2);352if (args.length === 0 || args.includes("--help") || args.includes("-h")) {353printUsage(0);354}355356const { renderArgs: afterTitle, title } = extractTitleArg(args);357const { renderArgs, mermaid } = extractMermaidArgs(afterTitle);358const options = parseArgs(renderArgs);359if (!options) {360printUsage(1);361}362363const markdownPath = path.resolve(process.cwd(), options.inputPath);364if (!markdownPath.toLowerCase().endsWith(".md")) {365console.error("Input file must end with .md");366process.exit(1);367}368369if (!fs.existsSync(markdownPath)) {370console.error(`Error: File not found: ${markdownPath}`);371process.exit(1);372}373374const result = await convertMarkdown(markdownPath, { ...options, title, mermaid });375console.log(JSON.stringify(result, null, 2));376}377378try {379await main();380} catch (error) {381console.error(`Error: ${error instanceof Error ? error.message : String(error)}`);382process.exitCode = 1;383} finally {384await closeRenderer();385}386