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 https from 'node:https';4import os from 'node:os';5import path from 'node:path';6import process from 'node:process';7import { createHash } from 'node:crypto';8import { pathToFileURL } from 'node:url';910import frontMatter from 'front-matter';11import hljs from 'highlight.js/lib/common';12import { Lexer, Marked, type RendererObject, type Tokens } from 'marked';13import { unified } from 'unified';14import remarkCjkFriendly from 'remark-cjk-friendly';15import remarkParse from 'remark-parse';16import remarkStringify from 'remark-stringify';1718interface ImageInfo {19placeholder: string;20localPath: string;21originalPath: string;22blockIndex: number;23}2425interface ParsedMarkdown {26title: string;27coverImage: string | null;28contentImages: ImageInfo[];29html: string;30totalBlocks: number;31}3233type FrontmatterFields = Record<string, unknown>;3435function parseFrontmatter(content: string): { frontmatter: FrontmatterFields; body: string } {36try {37const parsed = frontMatter<FrontmatterFields>(content);38return {39frontmatter: parsed.attributes ?? {},40body: parsed.body,41};42} catch {43return { frontmatter: {}, body: content };44}45}4647function stripWrappingQuotes(value: string): string {48if (!value) return value;49const doubleQuoted = value.startsWith('"') && value.endsWith('"');50const singleQuoted = value.startsWith("'") && value.endsWith("'");51const cjkDoubleQuoted = value.startsWith('\u201c') && value.endsWith('\u201d');52const cjkSingleQuoted = value.startsWith('\u2018') && value.endsWith('\u2019');53if (doubleQuoted || singleQuoted || cjkDoubleQuoted || cjkSingleQuoted) {54return value.slice(1, -1).trim();55}56return value.trim();57}5859function toFrontmatterString(value: unknown): string | undefined {60if (typeof value === 'string') {61return stripWrappingQuotes(value);62}63if (typeof value === 'number' || typeof value === 'boolean') {64return String(value);65}66return undefined;67}6869function pickFirstString(frontmatter: FrontmatterFields, keys: string[]): string | undefined {70for (const key of keys) {71const value = toFrontmatterString(frontmatter[key]);72if (value) return value;73}74return undefined;75}7677function findCoverImageNearMarkdown(baseDir: string): string | null {78const candidateDirs = [baseDir, path.join(baseDir, 'imgs')];79const coverPattern = /^cover\.(png|jpe?g|webp)$/i;8081for (const dir of candidateDirs) {82try {83if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) {84continue;85}8687const match = fs.readdirSync(dir).find((entry) => coverPattern.test(entry));88if (match) {89return path.join(dir, match);90}91} catch {92continue;93}94}9596return null;97}9899function extractTitleFromMarkdown(markdown: string): string {100const tokens = Lexer.lex(markdown, { gfm: true, breaks: true });101for (const token of tokens) {102if (token.type === 'heading' && token.depth === 1) {103return stripWrappingQuotes(token.text);104}105}106return '';107}108109function downloadFile(url: string, destPath: string, maxRedirects = 5): Promise<void> {110return new Promise((resolve, reject) => {111if (!url.startsWith('https://')) {112reject(new Error(`Refusing non-HTTPS download: ${url}`));113return;114}115if (maxRedirects <= 0) {116reject(new Error('Too many redirects'));117return;118}119const file = fs.createWriteStream(destPath);120121const request = https.get(url, { headers: { 'User-Agent': 'Mozilla/5.0' } }, (response) => {122if (response.statusCode === 301 || response.statusCode === 302) {123const redirectUrl = response.headers.location;124if (redirectUrl) {125file.close();126fs.unlinkSync(destPath);127downloadFile(redirectUrl, destPath, maxRedirects - 1).then(resolve).catch(reject);128return;129}130}131132if (response.statusCode !== 200) {133file.close();134fs.unlinkSync(destPath);135reject(new Error(`Failed to download: ${response.statusCode}`));136return;137}138139response.pipe(file);140file.on('finish', () => {141file.close();142resolve();143});144});145146request.on('error', (err) => {147file.close();148fs.unlink(destPath, () => {});149reject(err);150});151152request.setTimeout(30000, () => {153request.destroy();154reject(new Error('Download timeout'));155});156});157}158159function getImageExtension(urlOrPath: string): string {160const match = urlOrPath.match(/\.(jpg|jpeg|png|gif|webp)(\?|$)/i);161return match ? match[1]!.toLowerCase() : 'png';162}163164async function resolveImagePath(imagePath: string, baseDir: string, tempDir: string): Promise<string> {165if (imagePath.startsWith('http://')) {166console.error(`[md-to-html] Skipping non-HTTPS image: ${imagePath}`);167return '';168}169if (imagePath.startsWith('https://')) {170const hash = createHash('md5').update(imagePath).digest('hex').slice(0, 8);171const ext = getImageExtension(imagePath);172const localPath = path.join(tempDir, `remote_${hash}.${ext}`);173174if (!fs.existsSync(localPath)) {175console.error(`[md-to-html] Downloading: ${imagePath}`);176await downloadFile(imagePath, localPath);177}178return localPath;179}180181if (path.isAbsolute(imagePath)) {182return imagePath;183}184185return path.resolve(baseDir, imagePath);186}187188function escapeHtml(text: string): string {189return text190.replace(/&/g, '&')191.replace(/</g, '<')192.replace(/>/g, '>')193.replace(/"/g, '"')194.replace(/'/g, ''');195}196197function highlightCode(code: string, lang: string): string {198try {199if (lang && hljs.getLanguage(lang)) {200return hljs.highlight(code, { language: lang, ignoreIllegals: true }).value;201}202return hljs.highlightAuto(code).value;203} catch {204return escapeHtml(code);205}206}207208function preprocessCjkMarkdown(markdown: string): string {209try {210const processor = unified()211.use(remarkParse)212.use(remarkCjkFriendly)213.use(remarkStringify);214215const result = String(processor.processSync(markdown));216return result.replace(/&#x([0-9A-Fa-f]+);/g, (_, hex: string) => String.fromCodePoint(parseInt(hex, 16)));217} catch {218return markdown;219}220}221222function convertMarkdownToHtml(markdown: string, imageCallback: (src: string, alt: string) => string): { html: string; totalBlocks: number } {223const preprocessedMarkdown = preprocessCjkMarkdown(markdown);224const blockTokens = Lexer.lex(preprocessedMarkdown, { gfm: true, breaks: true });225226const renderer: RendererObject = {227heading({ depth, tokens }: Tokens.Heading): string {228if (depth === 1) {229return '';230}231return `<h2>${this.parser.parseInline(tokens)}</h2>`;232},233234paragraph({ tokens }: Tokens.Paragraph): string {235const text = this.parser.parseInline(tokens).trim();236if (!text) return '';237return `<p>${text}</p>`;238},239240blockquote({ tokens }: Tokens.Blockquote): string {241return `<blockquote>${this.parser.parse(tokens)}</blockquote>`;242},243244code({ text, lang = '' }: Tokens.Code): string {245const language = lang.split(/\s+/)[0]!.toLowerCase();246const source = text.replace(/\n$/, '');247const highlighted = highlightCode(source, language).replace(/\n/g, '<br>');248const label = language ? `<strong>[${escapeHtml(language)}]</strong><br>` : '';249return `<blockquote>${label}${highlighted}</blockquote>`;250},251252image({ href, text }: Tokens.Image): string {253if (!href) return '';254return imageCallback(href, text ?? '');255},256257link({ href, title, tokens, text }: Tokens.Link): string {258const label = tokens?.length ? this.parser.parseInline(tokens) : escapeHtml(text || href || '');259if (!href) return label;260261const titleAttr = title ? ` title="${escapeHtml(title)}"` : '';262return `<a href="${escapeHtml(href)}"${titleAttr} rel="noopener noreferrer nofollow">${label}</a>`;263},264};265266const parser = new Marked({267gfm: true,268breaks: true,269});270parser.use({ renderer });271272const rendered = parser.parse(preprocessedMarkdown);273if (typeof rendered !== 'string') {274throw new Error('Unexpected async markdown parse result');275}276277const totalBlocks = blockTokens.filter((token) => {278if (token.type === 'space') return false;279if (token.type === 'heading' && token.depth === 1) return false;280return true;281}).length;282283return {284html: rendered,285totalBlocks,286};287}288289export async function parseMarkdown(290markdownPath: string,291options?: { coverImage?: string; title?: string; tempDir?: string },292): Promise<ParsedMarkdown> {293const content = fs.readFileSync(markdownPath, 'utf-8');294const baseDir = path.dirname(markdownPath);295const tempDir = options?.tempDir ?? path.join(os.tmpdir(), 'x-article-images');296297await mkdir(tempDir, { recursive: true });298299const { frontmatter, body } = parseFrontmatter(content);300301let title = stripWrappingQuotes(options?.title ?? '') || pickFirstString(frontmatter, ['title']) || '';302if (!title) {303title = extractTitleFromMarkdown(body);304}305if (!title) {306title = path.basename(markdownPath, path.extname(markdownPath));307}308309let coverImagePath = stripWrappingQuotes(options?.coverImage ?? '') || pickFirstString(frontmatter, [310'cover_image',311'coverImage',312'cover',313'image',314'featureImage',315'feature_image',316]) || null;317if (!coverImagePath) {318coverImagePath = findCoverImageNearMarkdown(baseDir);319}320321const images: Array<{ src: string; alt: string; blockIndex: number }> = [];322let imageCounter = 0;323324const { html, totalBlocks } = convertMarkdownToHtml(body, (src, alt) => {325const placeholder = `XIMGPH_${++imageCounter}`;326images.push({ src, alt, blockIndex: -1 });327return placeholder;328});329330const htmlLines = html.split('\n');331for (let i = 0; i < images.length; i++) {332const placeholder = `XIMGPH_${i + 1}`;333for (let lineIndex = 0; lineIndex < htmlLines.length; lineIndex++) {334const regex = new RegExp(`\\b${placeholder}\\b`);335if (regex.test(htmlLines[lineIndex]!)) {336images[i]!.blockIndex = lineIndex;337break;338}339}340}341342const contentImages: ImageInfo[] = [];343let firstImageAsCover: string | null = null;344345for (let i = 0; i < images.length; i++) {346const img = images[i]!;347const localPath = await resolveImagePath(img.src, baseDir, tempDir);348349if (i === 0 && !coverImagePath) {350firstImageAsCover = localPath;351}352353contentImages.push({354placeholder: `XIMGPH_${i + 1}`,355localPath,356originalPath: img.src,357blockIndex: img.blockIndex,358});359}360361const finalHtml = html.replace(/\n{3,}/g, '\n\n').trim();362363let resolvedCoverImage: string | null = null;364if (coverImagePath) {365resolvedCoverImage = await resolveImagePath(coverImagePath, baseDir, tempDir);366} else if (firstImageAsCover) {367resolvedCoverImage = firstImageAsCover;368}369370return {371title,372coverImage: resolvedCoverImage,373contentImages,374html: finalHtml,375totalBlocks,376};377}378379function printUsage(): never {380console.log(`Convert Markdown to HTML for X Article publishing381382Usage:383npx -y bun md-to-html.ts <markdown_file> [options]384385Options:386--title <title> Override title from frontmatter387--cover <image> Override cover image from frontmatter388--output <json|html> Output format (default: json)389--html-only Output only the HTML content390--save-html <path> Save HTML to file391392Frontmatter fields:393title: Article title (or use first H1)394cover_image: Cover image path or URL395cover: Alias for cover_image396image: Alias for cover_image397398Example:399npx -y bun md-to-html.ts article.md --output json400npx -y bun md-to-html.ts article.md --html-only > /tmp/article.html401npx -y bun md-to-html.ts article.md --save-html /tmp/article.html402`);403process.exit(0);404}405406async function main(): Promise<void> {407const args = process.argv.slice(2);408if (args.length === 0 || args.includes('--help') || args.includes('-h')) {409printUsage();410}411412let markdownPath: string | undefined;413let title: string | undefined;414let coverImage: string | undefined;415let outputFormat: 'json' | 'html' = 'json';416let htmlOnly = false;417let saveHtmlPath: string | undefined;418419for (let i = 0; i < args.length; i++) {420const arg = args[i]!;421if (arg === '--title' && args[i + 1]) {422title = args[++i];423} else if (arg === '--cover' && args[i + 1]) {424coverImage = args[++i];425} else if (arg === '--output' && args[i + 1]) {426outputFormat = args[++i] as 'json' | 'html';427} else if (arg === '--html-only') {428htmlOnly = true;429} else if (arg === '--save-html' && args[i + 1]) {430saveHtmlPath = args[++i];431} else if (!arg.startsWith('-')) {432markdownPath = arg;433}434}435436if (!markdownPath) {437console.error('Error: Markdown file path required');438process.exit(1);439}440441if (!fs.existsSync(markdownPath)) {442console.error(`Error: File not found: ${markdownPath}`);443process.exit(1);444}445446const result = await parseMarkdown(markdownPath, { title, coverImage });447448if (saveHtmlPath) {449await writeFile(saveHtmlPath, result.html, 'utf-8');450console.error(`[md-to-html] HTML saved to: ${saveHtmlPath}`);451}452453if (htmlOnly) {454console.log(result.html);455} else if (outputFormat === 'html') {456console.log(result.html);457} else {458console.log(JSON.stringify(result, null, 2));459}460}461462if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {463await main().catch((err) => {464console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);465process.exit(1);466});467}468