Loading source
Pulling the file list, source metadata, and syntax-aware rendering for this listing.
Source from repo
Post text, images, videos, and long-form articles to X (Twitter) via real Chrome browser automation.
Files
Skill
Size
Entrypoint
Format
Open file
Syntax-highlighted preview of this file as included in the skill package.
scripts/md-to-html.ts
1import fs from 'node:fs';2import { mkdir, writeFile } from 'node:fs/promises';3import os from 'node:os';4import path from 'node:path';5import process from 'node:process';6import { pathToFileURL } from 'node:url';78import frontMatter from 'front-matter';9import hljs from 'highlight.js/lib/common';10import { Lexer, Marked, type RendererObject, type Tokens } from 'marked';11import { unified } from 'unified';12import remarkCjkFriendly from 'remark-cjk-friendly';13import remarkParse from 'remark-parse';14import remarkStringify from 'remark-stringify';1516import {17preprocessMermaidInMarkdown,18replaceMarkdownImagesWithPlaceholders,19resolveImagePath,20} from 'baoyu-md';21import { closeRenderer, renderMermaidToPng } from 'baoyu-chrome-cdp/mermaid';2223interface ImageInfo {24placeholder: string;25localPath: string;26originalPath: string;27blockIndex: number;28alt?: string;29}3031interface ParsedMarkdown {32title: string;33coverImage: string | null;34contentImages: ImageInfo[];35html: string;36totalBlocks: number;37}3839type FrontmatterFields = Record<string, unknown>;4041function parseFrontmatter(content: string): { frontmatter: FrontmatterFields; body: string } {42try {43const parsed = frontMatter<FrontmatterFields>(content);44return {45frontmatter: parsed.attributes ?? {},46body: parsed.body,47};48} catch {49return { frontmatter: {}, body: content };50}51}5253function stripWrappingQuotes(value: string): string {54if (!value) return value;55const doubleQuoted = value.startsWith('"') && value.endsWith('"');56const singleQuoted = value.startsWith("'") && value.endsWith("'");57const cjkDoubleQuoted = value.startsWith('\u201c') && value.endsWith('\u201d');58const cjkSingleQuoted = value.startsWith('\u2018') && value.endsWith('\u2019');59if (doubleQuoted || singleQuoted || cjkDoubleQuoted || cjkSingleQuoted) {60return value.slice(1, -1).trim();61}62return value.trim();63}6465function toFrontmatterString(value: unknown): string | undefined {66if (typeof value === 'string') {67return stripWrappingQuotes(value);68}69if (typeof value === 'number' || typeof value === 'boolean') {70return String(value);71}72return undefined;73}7475function pickFirstString(frontmatter: FrontmatterFields, keys: string[]): string | undefined {76for (const key of keys) {77const value = toFrontmatterString(frontmatter[key]);78if (value) return value;79}80return undefined;81}8283function findCoverImageNearMarkdown(baseDir: string): string | null {84const candidateDirs = [baseDir, path.join(baseDir, 'imgs')];85const coverPattern = /^cover\.(png|jpe?g|webp)$/i;8687for (const dir of candidateDirs) {88try {89if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) {90continue;91}9293const match = fs.readdirSync(dir).find((entry) => coverPattern.test(entry));94if (match) {95return path.join(dir, match);96}97} catch {98continue;99}100}101102return null;103}104105function extractTitleFromMarkdown(markdown: string): string {106const tokens = Lexer.lex(markdown, { gfm: true, breaks: true });107for (const token of tokens) {108if (token.type === 'heading' && token.depth === 1) {109return stripWrappingQuotes(token.text);110}111}112return '';113}114115function escapeHtml(text: string): string {116return text117.replace(/&/g, '&')118.replace(/</g, '<')119.replace(/>/g, '>')120.replace(/"/g, '"')121.replace(/'/g, ''');122}123124function escapeRegExp(value: string): string {125return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');126}127128function highlightCode(code: string, lang: string): string {129try {130if (lang && hljs.getLanguage(lang)) {131return hljs.highlight(code, { language: lang, ignoreIllegals: true }).value;132}133return hljs.highlightAuto(code).value;134} catch {135return escapeHtml(code);136}137}138139function preprocessCjkMarkdown(markdown: string): string {140try {141const processor = unified()142.use(remarkParse)143.use(remarkCjkFriendly)144.use(remarkStringify);145146const result = String(processor.processSync(markdown));147return result.replace(/&#x([0-9A-Fa-f]+);/g, (_, hex: string) => String.fromCodePoint(parseInt(hex, 16)));148} catch {149return markdown;150}151}152153function convertMarkdownToHtml(markdown: string): { html: string; totalBlocks: number } {154const preprocessedMarkdown = preprocessCjkMarkdown(markdown);155const blockTokens = Lexer.lex(preprocessedMarkdown, { gfm: true, breaks: true });156157const renderer: RendererObject = {158heading({ depth, tokens }: Tokens.Heading): string {159if (depth === 1) {160return '';161}162return `<h2>${this.parser.parseInline(tokens)}</h2>`;163},164165paragraph({ tokens }: Tokens.Paragraph): string {166const text = this.parser.parseInline(tokens).trim();167if (!text) return '';168return `<p>${text}</p>`;169},170171blockquote({ tokens }: Tokens.Blockquote): string {172return `<blockquote>${this.parser.parse(tokens)}</blockquote>`;173},174175code({ text, lang = '' }: Tokens.Code): string {176const language = lang.split(/\s+/)[0]!.toLowerCase();177const source = text.replace(/\n$/, '');178const highlighted = highlightCode(source, language).replace(/\n/g, '<br>');179const label = language ? `<strong>[${escapeHtml(language)}]</strong><br>` : '';180return `<blockquote>${label}${highlighted}</blockquote>`;181},182183image({ href, text }: Tokens.Image): string {184if (!href) return '';185return escapeHtml(text ?? '');186},187188link({ href, title, tokens, text }: Tokens.Link): string {189const label = tokens?.length ? this.parser.parseInline(tokens) : escapeHtml(text || href || '');190if (!href) return label;191192const titleAttr = title ? ` title="${escapeHtml(title)}"` : '';193return `<a href="${escapeHtml(href)}"${titleAttr} rel="noopener noreferrer nofollow">${label}</a>`;194},195};196197const parser = new Marked({198gfm: true,199breaks: true,200});201parser.use({ renderer });202203const rendered = parser.parse(preprocessedMarkdown);204if (typeof rendered !== 'string') {205throw new Error('Unexpected async markdown parse result');206}207208const totalBlocks = blockTokens.filter((token) => {209if (token.type === 'space') return false;210if (token.type === 'heading' && token.depth === 1) return false;211return true;212}).length;213214return {215html: rendered,216totalBlocks,217};218}219220export async function parseMarkdown(221markdownPath: string,222options?: { coverImage?: string; title?: string; tempDir?: string },223): Promise<ParsedMarkdown> {224const content = fs.readFileSync(markdownPath, 'utf-8');225const baseDir = path.dirname(markdownPath);226const tempDir = options?.tempDir ?? path.join(os.tmpdir(), 'x-article-images');227228await mkdir(tempDir, { recursive: true });229230const { frontmatter, body } = parseFrontmatter(content);231232let title = stripWrappingQuotes(options?.title ?? '') || pickFirstString(frontmatter, ['title']) || '';233if (!title) {234title = extractTitleFromMarkdown(body);235}236if (!title) {237title = path.basename(markdownPath, path.extname(markdownPath));238}239240let coverImagePath = stripWrappingQuotes(options?.coverImage ?? '') || pickFirstString(frontmatter, [241'cover_image',242'coverImage',243'cover',244'image',245'featureImage',246'feature_image',247]) || null;248if (!coverImagePath) {249coverImagePath = findCoverImageNearMarkdown(baseDir);250}251252const { markdown: mermaidProcessedBody, images: mermaidImages } =253await preprocessMermaidInMarkdown(body, {254baseDir,255renderFn: renderMermaidToPng,256onError: (error, block) => {257const message = error instanceof Error ? error.message : String(error);258console.error(259`[md-to-html] mermaid render failed (${block.code.slice(0, 40).replace(/\s+/g, ' ')}…): ${message}`,260);261},262});263264if (mermaidImages.length > 0) {265const fresh = mermaidImages.filter((image) => !image.cached).length;266console.error(267`[md-to-html] mermaid: ${mermaidImages.length} block(s), ${fresh} rendered, ${mermaidImages.length - fresh} cached`,268);269}270271const { images, markdown: rewrittenBody } = replaceMarkdownImagesWithPlaceholders(272mermaidProcessedBody,273'XIMGPH_',274);275const { html, totalBlocks } = convertMarkdownToHtml(rewrittenBody);276277const htmlLines = html.split('\n');278const imageBlockIndexes = new Map<string, number>();279for (let i = 0; i < images.length; i++) {280const placeholder = images[i]!.placeholder;281for (let lineIndex = 0; lineIndex < htmlLines.length; lineIndex++) {282const regex = new RegExp(`\\b${escapeRegExp(placeholder)}\\b`);283if (regex.test(htmlLines[lineIndex]!)) {284imageBlockIndexes.set(placeholder, lineIndex);285break;286}287}288}289290const contentImages: ImageInfo[] = [];291let firstImageAsCover: string | null = null;292293for (let i = 0; i < images.length; i++) {294const img = images[i]!;295const localPath = await resolveImagePath(img.originalPath, baseDir, tempDir, 'md-to-html');296297if (i === 0 && !coverImagePath) {298firstImageAsCover = localPath;299}300301contentImages.push({302placeholder: img.placeholder,303localPath,304originalPath: img.originalPath,305alt: img.alt,306blockIndex: imageBlockIndexes.get(img.placeholder) ?? -1,307});308}309310const finalHtml = html.replace(/\n{3,}/g, '\n\n').trim();311312let resolvedCoverImage: string | null = null;313if (coverImagePath) {314resolvedCoverImage = await resolveImagePath(coverImagePath, baseDir, tempDir, 'md-to-html');315} else if (firstImageAsCover) {316resolvedCoverImage = firstImageAsCover;317}318319return {320title,321coverImage: resolvedCoverImage,322contentImages,323html: finalHtml,324totalBlocks,325};326}327328function printUsage(): never {329console.log(`Convert Markdown to HTML for X Article publishing330331Usage:332npx -y bun md-to-html.ts <markdown_file> [options]333334Options:335--title <title> Override title from frontmatter336--cover <image> Override cover image from frontmatter337--output <json|html> Output format (default: json)338--html-only Output only the HTML content339--save-html <path> Save HTML to file340341Frontmatter fields:342title: Article title (or use first H1)343cover_image: Cover image path or URL344cover: Alias for cover_image345image: Alias for cover_image346347Example:348npx -y bun md-to-html.ts article.md --output json349npx -y bun md-to-html.ts article.md --html-only > /tmp/article.html350npx -y bun md-to-html.ts article.md --save-html /tmp/article.html351`);352process.exit(0);353}354355async function main(): Promise<void> {356const args = process.argv.slice(2);357if (args.length === 0 || args.includes('--help') || args.includes('-h')) {358printUsage();359}360361let markdownPath: string | undefined;362let title: string | undefined;363let coverImage: string | undefined;364let outputFormat: 'json' | 'html' = 'json';365let htmlOnly = false;366let saveHtmlPath: string | undefined;367368for (let i = 0; i < args.length; i++) {369const arg = args[i]!;370if (arg === '--title' && args[i + 1]) {371title = args[++i];372} else if (arg === '--cover' && args[i + 1]) {373coverImage = args[++i];374} else if (arg === '--output' && args[i + 1]) {375outputFormat = args[++i] as 'json' | 'html';376} else if (arg === '--html-only') {377htmlOnly = true;378} else if (arg === '--save-html' && args[i + 1]) {379saveHtmlPath = args[++i];380} else if (!arg.startsWith('-')) {381markdownPath = arg;382}383}384385if (!markdownPath) {386console.error('Error: Markdown file path required');387process.exit(1);388}389390if (!fs.existsSync(markdownPath)) {391console.error(`Error: File not found: ${markdownPath}`);392process.exit(1);393}394395const result = await parseMarkdown(markdownPath, { title, coverImage });396397if (saveHtmlPath) {398await writeFile(saveHtmlPath, result.html, 'utf-8');399console.error(`[md-to-html] HTML saved to: ${saveHtmlPath}`);400}401402if (htmlOnly) {403console.log(result.html);404} else if (outputFormat === 'html') {405console.log(result.html);406} else {407console.log(JSON.stringify(result, null, 2));408}409}410411if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {412try {413await main();414} catch (err) {415console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);416process.exitCode = 1;417} finally {418await closeRenderer();419}420}421