mirror of
https://github.com/JimLiu/baoyu-skills.git
synced 2026-08-01 06:39:49 +08:00
refactor: share markdown renderer via baoyu-md
This commit is contained in:
@@ -1,17 +1,19 @@
|
||||
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 fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
|
||||
import {
|
||||
buildCss, loadCodeThemeCss, buildHtmlDocument,
|
||||
inlineCss, normalizeInlineCss, modifyHtmlStructure, removeFirstHeading,
|
||||
} from './md/html-builder.js';
|
||||
extractSummaryFromBody,
|
||||
extractTitleFromMarkdown,
|
||||
formatTimestamp,
|
||||
parseFrontmatter,
|
||||
renderMarkdownDocument,
|
||||
replaceMarkdownImagesWithPlaceholders,
|
||||
resolveContentImages,
|
||||
serializeFrontmatter,
|
||||
stripWrappingQuotes,
|
||||
} from "baoyu-md";
|
||||
|
||||
interface ImageInfo {
|
||||
placeholder: string;
|
||||
@@ -28,192 +30,50 @@ interface ParsedResult {
|
||||
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] Downloading: ${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; citeStatus?: boolean }
|
||||
options?: { title?: string; theme?: string; keepTitle?: boolean; citeStatus?: boolean },
|
||||
): Promise<ParsedResult> {
|
||||
const baseDir = path.dirname(markdownPath);
|
||||
const content = fs.readFileSync(markdownPath, 'utf-8');
|
||||
const theme = options?.theme ?? 'default';
|
||||
const content = fs.readFileSync(markdownPath, "utf-8");
|
||||
const theme = options?.theme;
|
||||
const keepTitle = options?.keepTitle ?? false;
|
||||
const citeStatus = options?.citeStatus ?? 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) ?? '';
|
||||
let title = stripWrappingQuotes(options?.title ?? "")
|
||||
|| stripWrappingQuotes(frontmatter.title ?? "")
|
||||
|| extractTitleFromMarkdown(body);
|
||||
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;
|
||||
}
|
||||
title = path.basename(markdownPath, path.extname(markdownPath));
|
||||
}
|
||||
if (!title) title = path.basename(markdownPath, path.extname(markdownPath));
|
||||
const author = stripQuotes(frontmatter.author);
|
||||
let summary = stripQuotes(frontmatter.description) || stripQuotes(frontmatter.summary);
|
||||
|
||||
const author = stripWrappingQuotes(frontmatter.author ?? "");
|
||||
let summary = stripWrappingQuotes(frontmatter.description ?? "")
|
||||
|| stripWrappingQuotes(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;
|
||||
}
|
||||
}
|
||||
summary = extractSummaryFromBody(body, 120);
|
||||
}
|
||||
|
||||
const images: Array<{ src: string; placeholder: string }> = [];
|
||||
let imageCounter = 0;
|
||||
const { images, markdown: rewrittenBody } = replaceMarkdownImagesWithPlaceholders(
|
||||
body,
|
||||
"MDTOHTMLIMGPH_",
|
||||
);
|
||||
const rewrittenMarkdown = `${serializeFrontmatter(frontmatter)}${rewrittenBody}`;
|
||||
|
||||
const modifiedBody = body.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (match, alt, src) => {
|
||||
const placeholder = `MDTOHTMLIMGPH_${++imageCounter}`;
|
||||
images.push({ src, placeholder });
|
||||
return placeholder;
|
||||
console.error(
|
||||
`[markdown-to-html] Rendering with theme: ${theme ?? "default"}, keepTitle: ${keepTitle}, citeStatus: ${citeStatus}`,
|
||||
);
|
||||
|
||||
const { html } = await renderMarkdownDocument(rewrittenMarkdown, {
|
||||
citeStatus,
|
||||
defaultTitle: title,
|
||||
keepTitle,
|
||||
theme,
|
||||
});
|
||||
|
||||
const modifiedMarkdown = `---\n${Object.entries(frontmatter).map(([k, v]) => `${k}: ${v}`).join('\n')}\n---\n${modifiedBody}`;
|
||||
|
||||
console.error(`[markdown-to-html] Rendering with theme: ${theme}, keepTitle: ${keepTitle}, citeStatus: ${citeStatus}`);
|
||||
|
||||
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({ citeStatus });
|
||||
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');
|
||||
const finalHtmlPath = markdownPath.replace(/\.md$/i, ".html");
|
||||
let backupPath: string | undefined;
|
||||
|
||||
if (fs.existsSync(finalHtmlPath)) {
|
||||
@@ -222,29 +82,22 @@ export async function convertMarkdown(
|
||||
fs.renameSync(finalHtmlPath, backupPath);
|
||||
}
|
||||
|
||||
fs.writeFileSync(finalHtmlPath, renderedHtml, 'utf-8');
|
||||
fs.writeFileSync(finalHtmlPath, html, "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,
|
||||
});
|
||||
}
|
||||
const hasRemoteImages = images.some((image) =>
|
||||
image.originalPath.startsWith("http://") || image.originalPath.startsWith("https://"),
|
||||
);
|
||||
const tempDir = hasRemoteImages
|
||||
? fs.mkdtempSync(path.join(os.tmpdir(), "markdown-to-html-"))
|
||||
: baseDir;
|
||||
const contentImages = await resolveContentImages(images, baseDir, tempDir, "markdown-to-html");
|
||||
|
||||
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);
|
||||
let finalContent = fs.readFileSync(finalHtmlPath, "utf-8");
|
||||
for (const image of contentImages) {
|
||||
const imgTag = `<img src="${image.originalPath}" data-local-path="${image.localPath}" style="display: block; width: 100%; margin: 1.5em auto;">`;
|
||||
finalContent = finalContent.replace(image.placeholder, imgTag);
|
||||
}
|
||||
fs.writeFileSync(finalHtmlPath, finalContent, 'utf-8');
|
||||
fs.writeFileSync(finalHtmlPath, finalContent, "utf-8");
|
||||
|
||||
console.error(`[markdown-to-html] HTML saved to: ${finalHtmlPath}`);
|
||||
|
||||
@@ -296,7 +149,7 @@ Example:
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = process.argv.slice(2);
|
||||
if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
|
||||
if (args.length === 0 || args.includes("--help") || args.includes("-h")) {
|
||||
printUsage();
|
||||
}
|
||||
|
||||
@@ -308,21 +161,21 @@ async function main(): Promise<void> {
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i]!;
|
||||
if (arg === '--title' && args[i + 1]) {
|
||||
if (arg === "--title" && args[i + 1]) {
|
||||
title = args[++i];
|
||||
} else if (arg === '--theme' && args[i + 1]) {
|
||||
} else if (arg === "--theme" && args[i + 1]) {
|
||||
theme = args[++i];
|
||||
} else if (arg === '--cite') {
|
||||
} else if (arg === "--cite") {
|
||||
citeStatus = true;
|
||||
} else if (arg === '--keep-title') {
|
||||
} else if (arg === "--keep-title") {
|
||||
keepTitle = true;
|
||||
} else if (!arg.startsWith('-')) {
|
||||
} else if (!arg.startsWith("-")) {
|
||||
markdownPath = arg;
|
||||
}
|
||||
}
|
||||
|
||||
if (!markdownPath) {
|
||||
console.error('Error: Markdown file path is required');
|
||||
console.error("Error: Markdown file path is required");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -335,7 +188,7 @@ async function main(): Promise<void> {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
}
|
||||
|
||||
await main().catch((err) => {
|
||||
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
||||
await main().catch((error) => {
|
||||
console.error(`Error: ${error instanceof Error ? error.message : String(error)}`);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user