Now liveThe Skillselion MCP - thousands of ranked skills, loaded into your agent mid-task. No install.Get it →
tuziapi avatar

Tuzi Markdown To Html

  • 156 installs
  • 37 repo stars
  • Updated April 24, 2026
  • tuziapi/tuzi-skills

Use tuzi-markdown-to-html for development tasks

About

tuzi-markdown-to-html: A skill skill for development. This skill provides functionality for development workflows.

  • tuzi-markdown-to-html

Tuzi Markdown To Html by the numbers

  • 156 all-time installs (skills.sh)
  • +1 installs in the week ending Aug 5, 2026 (Skillselion tracking)
  • Ranked #2,434 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
  • Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/tuziapi/tuzi-skills --skill tuzi-markdown-to-html

Add your badge

Show developers this skill is listed on Skillselion. Paste this into your README.

Listed on Skillselion
Installs156
repo stars37
Last updatedApril 24, 2026
Repositorytuziapi/tuzi-skills

What it does

Use tuzi-markdown-to-html for development tasks

Files

scripts/main.tsTypeScriptGitHub ↗
import fs from 'node:fs';
import path from 'node:path';
import { createHash } from 'node:crypto';
import https from 'node:https';
import http from 'node:http';
import process from 'node:process';
import type { StyleConfig, HtmlDocumentMeta } from './md/types.js';
import { DEFAULT_STYLE, THEME_STYLE_DEFAULTS } from './md/constants.js';
import { loadThemeCss, normalizeThemeCss } from './md/themes.js';
import { initRenderer, renderMarkdown, postProcessHtml } from './md/renderer.js';
import {
  buildCss, loadCodeThemeCss, buildHtmlDocument,
  inlineCss, normalizeInlineCss, modifyHtmlStructure, removeFirstHeading,
} from './md/html-builder.js';

interface ImageInfo {
  placeholder: string;
  localPath: string;
  originalPath: string;
}

interface ParsedResult {
  title: string;
  author: string;
  summary: string;
  htmlPath: string;
  backupPath?: string;
  contentImages: ImageInfo[];
}

function formatTimestamp(date = new Date()): string {
  const pad = (v: number) => String(v).padStart(2, '0');
  return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`;
}

function downloadFile(url: string, destPath: string): Promise<void> {
  return 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);
          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', (err) => {
      file.close();
      fs.unlink(destPath, () => {});
      reject(err);
    });

    request.setTimeout(30000, () => {
      request.destroy();
      reject(new Error('Download timeout'));
    });
  });
}

function getImageExtension(urlOrPath: string): string {
  const match = urlOrPath.match(/\.(jpg|jpeg|png|gif|webp)(\?|$)/i);
  return match ? match[1]!.toLowerCase() : 'png';
}

async function resolveImagePath(imagePath: string, baseDir: string, tempDir: string): 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(`[markdown-to-html] 正在下载: ${imagePath}`);
      await downloadFile(imagePath, localPath);
    }
    return localPath;
  }

  if (path.isAbsolute(imagePath)) {
    return imagePath;
  }

  return path.resolve(baseDir, imagePath);
}

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]! };
}

export async function convertMarkdown(markdownPath: string, options?: { title?: string; theme?: string; keepTitle?: boolean }): Promise<ParsedResult> {
  const baseDir = path.dirname(markdownPath);
  const content = fs.readFileSync(markdownPath, 'utf-8');
  const theme = options?.theme ?? 'default';
  const keepTitle = options?.keepTitle ?? false;

  const { frontmatter, body } = parseFrontmatter(content);

  const stripQuotes = (s?: string): string => {
    if (!s) return '';
    if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
      return s.slice(1, -1);
    }
    if ((s.startsWith('\u201c') && s.endsWith('\u201d')) || (s.startsWith('\u2018') && s.endsWith('\u2019'))) {
      return s.slice(1, -1);
    }
    return s;
  };

  let title = options?.title ?? stripQuotes(frontmatter.title) ?? '';
  if (!title) {
    const lines = body.split('\n');
    for (const line of lines) {
      const trimmed = line.trim();
      if (!trimmed) continue;
      const headingMatch = trimmed.match(/^#{1,2}\s+(.+)$/);
      if (headingMatch) title = headingMatch[1]!;
      break;
    }
  }
  if (!title) title = path.basename(markdownPath, path.extname(markdownPath));
  const author = stripQuotes(frontmatter.author);
  let summary = stripQuotes(frontmatter.description) || stripQuotes(frontmatter.summary);

  if (!summary) {
    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;

      const cleanText = trimmed
        .replace(/\*\*(.+?)\*\*/g, '$1')
        .replace(/\*(.+?)\*/g, '$1')
        .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
        .replace(/`([^`]+)`/g, '$1');

      if (cleanText.length > 20) {
        summary = cleanText.length > 120 ? cleanText.slice(0, 117) + '...' : cleanText;
        break;
      }
    }
  }

  const images: Array<{ src: string; placeholder: string }> = [];
  let imageCounter = 0;

  const modifiedBody = body.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (match, alt, src) => {
    const placeholder = `MDTOHTMLIMGPH_${++imageCounter}`;
    images.push({ src, placeholder });
    return placeholder;
  });

  const modifiedMarkdown = `---\n${Object.entries(frontmatter).map(([k, v]) => `${k}: ${v}`).join('\n')}\n---\n${modifiedBody}`;

  console.error(`[markdown-to-html] 使用主题渲染: ${theme}, 保留标题: ${keepTitle}`);

  const themeDefaults = THEME_STYLE_DEFAULTS[theme] ?? {};
  const style: StyleConfig = { ...DEFAULT_STYLE, ...themeDefaults };
  const { baseCss, themeCss } = loadThemeCss(theme);
  const css = normalizeThemeCss(buildCss(baseCss, themeCss, style));
  const codeThemeCss = loadCodeThemeCss('github');

  const renderer = initRenderer({});
  const { html: baseHtml, readingTime } = renderMarkdown(modifiedMarkdown, renderer);
  let htmlContent = postProcessHtml(baseHtml, readingTime, renderer);
  if (!keepTitle) htmlContent = removeFirstHeading(htmlContent);

  const meta: HtmlDocumentMeta = { title, author, description: summary };
  const fullHtml = buildHtmlDocument(meta, css, htmlContent, codeThemeCss);
  const inlinedHtml = normalizeInlineCss(await inlineCss(fullHtml), style);
  const renderedHtml = modifyHtmlStructure(inlinedHtml);

  const finalHtmlPath = markdownPath.replace(/\.md$/i, '.html');
  let backupPath: string | undefined;

  if (fs.existsSync(finalHtmlPath)) {
    backupPath = `${finalHtmlPath}.bak-${formatTimestamp()}`;
    console.error(`[markdown-to-html] 正在备份已有文件到: ${backupPath}`);
    fs.renameSync(finalHtmlPath, backupPath);
  }

  fs.writeFileSync(finalHtmlPath, renderedHtml, 'utf-8');

  const contentImages: ImageInfo[] = [];
  let tempDir: string | undefined;
  for (const img of images) {
    if (!tempDir && (img.src.startsWith('http://') || img.src.startsWith('https://'))) {
      const os = await import('node:os');
      tempDir = fs.mkdtempSync(path.join(os.default.tmpdir(), 'markdown-to-html-'));
    }
    const localPath = await resolveImagePath(img.src, baseDir, tempDir ?? baseDir);
    contentImages.push({
      placeholder: img.placeholder,
      localPath,
      originalPath: img.src,
    });
  }

  let finalContent = fs.readFileSync(finalHtmlPath, 'utf-8');
  for (const img of contentImages) {
    const imgTag = `<img src="${img.originalPath}" data-local-path="${img.localPath}" style="display: block; width: 100%; margin: 1.5em auto;">`;
    finalContent = finalContent.replace(img.placeholder, imgTag);
  }
  fs.writeFileSync(finalHtmlPath, finalContent, 'utf-8');

  console.error(`[markdown-to-html] HTML 已保存到: ${finalHtmlPath}`);

  return {
    title,
    author,
    summary,
    htmlPath: finalHtmlPath,
    backupPath,
    contentImages,
  };
}

function printUsage(): never {
  console.log(`将 Markdown 转换为带样式的 HTML

用法:
  npx -y bun main.ts <markdown_file> [选项]

选项:
  --title <title>     覆盖标题
  --theme <name>      主题名称(default, grace, simple)。默认: default
  --keep-title        保留内容中的第一个标题。默认: false(移除)
  --help              显示帮助

输出:
  HTML 文件保存到与输入 Markdown 文件相同的目录。
  示例: article.md -> article.html

  如果 HTML 文件已存在,会先备份:
  article.html -> article.html.bak-YYYYMMDDHHMMSS

输出 JSON 格式:
{
  "title": "文章标题",
  "htmlPath": "/path/to/article.html",
  "backupPath": "/path/to/article.html.bak-20260128180000",
  "contentImages": [...]
}

示例:
  npx -y bun main.ts article.md
  npx -y bun main.ts article.md --theme grace
`);
  process.exit(0);
}

async function main(): Promise<void> {
  const args = process.argv.slice(2);
  if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
    printUsage();
  }

  let markdownPath: string | undefined;
  let title: string | undefined;
  let theme: string | undefined;
  let keepTitle = false;

  for (let i = 0; i < args.length; i++) {
    const arg = args[i]!;
    if (arg === '--title' && args[i + 1]) {
      title = args[++i];
    } else if (arg === '--theme' && args[i + 1]) {
      theme = args[++i];
    } else if (arg === '--keep-title') {
      keepTitle = true;
    } else if (!arg.startsWith('-')) {
      markdownPath = arg;
    }
  }

  if (!markdownPath) {
    console.error('错误: 需要 Markdown 文件路径');
    process.exit(1);
  }

  if (!fs.existsSync(markdownPath)) {
    console.error(`错误: 文件未找到: ${markdownPath}`);
    process.exit(1);
  }

  const result = await convertMarkdown(markdownPath, { title, theme, keepTitle });
  console.log(JSON.stringify(result, null, 2));
}

await main().catch((err) => {
  console.error(`错误: ${err instanceof Error ? err.message : String(err)}`);
  process.exit(1);
});

Related skills

Backend & APIsbackendintegrations

This week in AI coding

Five minutes, every Monday - the tools, releases and tactics for developers.

unsubscribe anytime.