feat(baoyu-markdown-to-html): add markdown to HTML converter skill

Converts markdown to styled HTML with inline CSS, code highlighting,
math, PlantUML, footnotes, alerts, and WeChat-compatible themes.
This commit is contained in:
Jim Liu 宝玉
2026-01-28 21:37:30 -06:00
parent 40363194be
commit 5f994589b3
19 changed files with 3595 additions and 0 deletions
+172
View File
@@ -0,0 +1,172 @@
---
name: baoyu-markdown-to-html
description: Converts Markdown to styled HTML with WeChat-compatible themes. Supports code highlighting, math, PlantUML, footnotes, alerts, and infographics. Use when user asks for "markdown to html", "convert md to html", "md转html", or needs styled HTML output from markdown.
---
# Markdown to HTML Converter
Converts Markdown files to beautifully styled HTML with inline CSS, optimized for WeChat Official Account and other platforms.
## Script Directory
**Agent Execution**: Determine this SKILL.md directory as `SKILL_DIR`, then use `${SKILL_DIR}/scripts/<name>.ts`.
| Script | Purpose |
|--------|---------|
| `scripts/main.ts` | Main entry point |
## Preferences (EXTEND.md)
Use Bash to check EXTEND.md existence (priority order):
```bash
# Check project-level first
test -f .baoyu-skills/baoyu-markdown-to-html/EXTEND.md && echo "project"
# Then user-level (cross-platform: $HOME works on macOS/Linux/WSL)
test -f "$HOME/.baoyu-skills/baoyu-markdown-to-html/EXTEND.md" && echo "user"
```
┌──────────────────────────────────────────────────────────────┬───────────────────┐
│ Path │ Location │
├──────────────────────────────────────────────────────────────┼───────────────────┤
│ .baoyu-skills/baoyu-markdown-to-html/EXTEND.md │ Project directory │
├──────────────────────────────────────────────────────────────┼───────────────────┤
│ $HOME/.baoyu-skills/baoyu-markdown-to-html/EXTEND.md │ User home │
└──────────────────────────────────────────────────────────────┴───────────────────┘
┌───────────┬───────────────────────────────────────────────────────────────────────────┐
│ Result │ Action │
├───────────┼───────────────────────────────────────────────────────────────────────────┤
│ Found │ Read, parse, apply settings │
├───────────┼───────────────────────────────────────────────────────────────────────────┤
│ Not found │ Use defaults │
└───────────┴───────────────────────────────────────────────────────────────────────────┘
**EXTEND.md Supports**: Default theme | Custom CSS variables | Code block style
## Workflow
### Step 1: Confirm Theme
Before converting, use AskUserQuestion to confirm the theme (unless user already specified):
| Theme | Description |
|-------|-------------|
| `default` (Recommended) | 经典主题 - 传统排版,标题居中带底边,二级标题白字彩底 |
| `grace` | 优雅主题 - 文字阴影,圆角卡片,精致引用块 |
| `simple` | 简洁主题 - 现代极简风,不对称圆角,清爽留白 |
### Step 2: Convert
```bash
npx -y bun ${SKILL_DIR}/scripts/main.ts <markdown_file> --theme <theme>
```
### Step 3: Report Result
Display the output path from JSON result. If backup was created, mention it.
## Usage
```bash
npx -y bun ${SKILL_DIR}/scripts/main.ts <markdown_file> [options]
```
**Options:**
| Option | Description | Default |
|--------|-------------|---------|
| `--theme <name>` | Theme name (default, grace, simple) | default |
| `--title <title>` | Override title from frontmatter | |
| `--keep-title` | Keep the first heading in content | false (removed) |
| `--help` | Show help | |
**Examples:**
```bash
# Basic conversion (uses default theme, removes first heading)
npx -y bun ${SKILL_DIR}/scripts/main.ts article.md
# With specific theme
npx -y bun ${SKILL_DIR}/scripts/main.ts article.md --theme grace
# Keep the first heading in content
npx -y bun ${SKILL_DIR}/scripts/main.ts article.md --keep-title
# Override title
npx -y bun ${SKILL_DIR}/scripts/main.ts article.md --title "My Article"
```
## Output
**File location**: Same directory as input markdown file.
- Input: `/path/to/article.md`
- Output: `/path/to/article.html`
**Conflict handling**: If HTML file already exists, it will be backed up first:
- Backup: `/path/to/article.html.bak-YYYYMMDDHHMMSS`
**JSON output to stdout:**
```json
{
"title": "Article Title",
"author": "Author Name",
"summary": "Article summary...",
"htmlPath": "/path/to/article.html",
"backupPath": "/path/to/article.html.bak-20260128180000",
"contentImages": [
{
"placeholder": "MDTOHTMLIMGPH_1",
"localPath": "/path/to/img.png",
"originalPath": "imgs/image.png"
}
]
}
```
## Themes
| Theme | Description |
|-------|-------------|
| `default` | 经典主题 - 传统排版,标题居中带底边,二级标题白字彩底 |
| `grace` | 优雅主题 - 文字阴影,圆角卡片,精致引用块 (by @brzhang) |
| `simple` | 简洁主题 - 现代极简风,不对称圆角,清爽留白 (by @okooo5km) |
## Supported Markdown Features
| Feature | Syntax |
|---------|--------|
| Headings | `# H1` to `###### H6` |
| Bold/Italic | `**bold**`, `*italic*` |
| Code blocks | ` ```lang ` with syntax highlighting |
| Inline code | `` `code` `` |
| Tables | GitHub-flavored markdown tables |
| Images | `![alt](src)` |
| Links | `[text](url)` with footnote references |
| Blockquotes | `> quote` |
| Lists | `-` unordered, `1.` ordered |
| Alerts | `> [!NOTE]`, `> [!WARNING]`, etc. |
| Footnotes | `[^1]` references |
| Ruby text | `{base|annotation}` |
| Mermaid | ` ```mermaid ` diagrams |
| PlantUML | ` ```plantuml ` diagrams |
## Frontmatter
Supports YAML frontmatter for metadata:
```yaml
---
title: Article Title
author: Author Name
description: Article summary
---
```
If no title is found, extracts from first H1/H2 heading or uses filename.
## Extension Support
Custom configurations via EXTEND.md. See **Preferences** section for paths and supported options.
@@ -0,0 +1,314 @@
import fs from 'node:fs';
import path from 'node:path';
import { writeFile } from 'node:fs/promises';
import os from 'node:os';
import { createHash } from 'node:crypto';
import { fileURLToPath } from 'node:url';
import https from 'node:https';
import http from 'node:http';
import { spawnSync } from 'node:child_process';
import process from 'node:process';
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] 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 }): 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);
let title = options?.title ?? 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 = frontmatter.author || '';
let summary = frontmatter.description || 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}`;
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'markdown-to-html-'));
const tempMdPath = path.join(tempDir, 'temp-article.md');
await writeFile(tempMdPath, modifiedMarkdown, 'utf-8');
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const renderScript = path.join(__dirname, 'md', 'render.ts');
console.error(`[markdown-to-html] Rendering with theme: ${theme}, keepTitle: ${keepTitle}`);
const args = ['-y', 'bun', renderScript, tempMdPath, '--theme', theme];
if (keepTitle) args.push('--keep-title');
const result = spawnSync('npx', args, {
stdio: ['inherit', 'pipe', 'pipe'],
cwd: baseDir,
});
if (result.status !== 0) {
const stderr = result.stderr?.toString() || '';
throw new Error(`Render failed: ${stderr}`);
}
const tempHtmlPath = tempMdPath.replace(/\.md$/i, '.html');
if (!fs.existsSync(tempHtmlPath)) {
throw new Error(`HTML file not generated: ${tempHtmlPath}`);
}
const finalHtmlPath = markdownPath.replace(/\.md$/i, '.html');
let backupPath: string | undefined;
if (fs.existsSync(finalHtmlPath)) {
backupPath = `${finalHtmlPath}.bak-${formatTimestamp()}`;
console.error(`[markdown-to-html] Backing up existing file to: ${backupPath}`);
fs.renameSync(finalHtmlPath, backupPath);
}
fs.copyFileSync(tempHtmlPath, finalHtmlPath);
console.error(`[markdown-to-html] HTML saved to: ${finalHtmlPath}`);
const contentImages: ImageInfo[] = [];
for (const img of images) {
const localPath = await resolveImagePath(img.src, baseDir, tempDir);
contentImages.push({
placeholder: img.placeholder,
localPath,
originalPath: img.src,
});
}
return {
title,
author,
summary,
htmlPath: finalHtmlPath,
backupPath,
contentImages,
};
}
function printUsage(): never {
console.log(`Convert Markdown to styled HTML
Usage:
npx -y bun main.ts <markdown_file> [options]
Options:
--title <title> Override title
--theme <name> Theme name (default, grace, simple). Default: default
--keep-title Keep the first heading in content. Default: false (removed)
--help Show this help
Output:
HTML file saved to same directory as input markdown file.
Example: article.md -> article.html
If HTML file already exists, it will be backed up first:
article.html -> article.html.bak-YYYYMMDDHHMMSS
Output JSON format:
{
"title": "Article Title",
"htmlPath": "/path/to/article.html",
"backupPath": "/path/to/article.html.bak-20260128180000",
"contentImages": [...]
}
Example:
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('Error: Markdown file path is required');
process.exit(1);
}
if (!fs.existsSync(markdownPath)) {
console.error(`Error: File not found: ${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(`Error: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
});
@@ -0,0 +1,18 @@
This directory contains code adapted from the doocs/md project.
Original project: https://github.com/doocs/md
License: WTFPL (Do What The Fuck You Want To Public License)
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
Version 2, December 2004
Copyright (C) 2025 Doocs <admin@doocs.org>
Everyone is permitted to copy and distribute verbatim or modified
copies of this license document, and changing it is allowed as long
as the name is changed.
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. You just DO WHAT THE FUCK YOU WANT TO.
@@ -0,0 +1,284 @@
import type { MarkedExtension, Tokens } from 'marked'
export interface AlertOptions {
className?: string
variants?: AlertVariantItem[]
withoutStyle?: boolean
}
export interface AlertVariantItem {
type: string
icon: string
title?: string
titleClassName?: string
}
function ucfirst(str: string) {
return str.slice(0, 1).toUpperCase() + str.slice(1).toLowerCase()
}
/**
* https://github.com/bent10/marked-extensions/tree/main/packages/alert
* To support theme, we need to modify the source code.
* A [marked](https://marked.js.org/) extension to support [GFM alerts](https://github.com/orgs/community/discussions/16925).
*/
export function markedAlert(options: AlertOptions = {}): MarkedExtension {
const { className = `markdown-alert`, variants = [], withoutStyle = false } = options
const resolvedVariants = resolveVariants(variants)
// 提取公共的元数据构建逻辑
function buildMeta(variantType: string, matchedVariant: AlertVariantItem, fromContainer = false) {
return {
className,
variant: variantType,
icon: matchedVariant.icon,
title: matchedVariant.title ?? ucfirst(variantType),
titleClassName: `${className}-title`,
fromContainer,
}
}
// 提取公共的渲染逻辑
function renderAlert(token: any) {
const { meta, tokens = [] } = token
// @ts-expect-error marked renderer context has parser property
const text = this.parser.parse(tokens)
// 新主题系统:使用 CSS 选择器而非内联样式
let tmpl = `<blockquote class="${meta.className} ${meta.className}-${meta.variant}">\n`
tmpl += `<p class="${meta.titleClassName} alert-title-${meta.variant}">`
if (!withoutStyle) {
// 给 SVG 添加 class,通过 CSS 控制颜色
tmpl += meta.icon.replace(
`<svg`,
`<svg class="alert-icon-${meta.variant}"`,
)
}
tmpl += meta.title
tmpl += `</p>\n`
tmpl += text
tmpl += `</blockquote>\n`
return tmpl
}
return {
walkTokens(token) {
if (token.type !== `blockquote`)
return
const matchedVariant = resolvedVariants.find(({ type }) =>
new RegExp(createSyntaxPattern(type), `i`).test(token.text),
)
if (matchedVariant) {
const { type: variantType } = matchedVariant
const typeRegexp = new RegExp(createSyntaxPattern(variantType), `i`)
Object.assign(token, {
type: `alert`,
meta: buildMeta(variantType, matchedVariant),
})
const firstLine = token.tokens?.[0] as Tokens.Paragraph
const firstLineText = firstLine.raw?.replace(typeRegexp, ``).trim()
if (firstLineText) {
const patternToken = firstLine.tokens[0] as Tokens.Text
Object.assign(patternToken, {
raw: patternToken.raw.replace(typeRegexp, ``),
text: patternToken.text.replace(typeRegexp, ``),
})
if (firstLine.tokens[1]?.type === `br`) {
firstLine.tokens.splice(1, 1)
}
}
else {
token.tokens?.shift()
}
}
},
extensions: [
{
name: `alert`,
level: `block`,
renderer: renderAlert,
},
{
name: `alertContainer`,
level: `block`,
start(src) {
return src.match(/^:::/)?.index
},
tokenizer(src, _tokens) {
// eslint-disable-next-line regexp/no-super-linear-backtracking
const match = /^:::\s*(\w+)\s*\n([\s\S]*?)\n:::/.exec(src)
if (match) {
const [raw, variant, content] = match
const matchedVariant = resolvedVariants.find(v => v.type === variant)
if (!matchedVariant)
return
return {
type: `alert`,
raw,
text: content.trim(),
tokens: this.lexer.blockTokens(content.trim()),
meta: buildMeta(variant, matchedVariant, true),
}
}
},
renderer: renderAlert,
},
],
}
}
/**
* The default configuration for alert variants.
*/
const defaultAlertVariant: AlertVariantItem[] = [
{
type: `note`,
icon: `<svg class="octicon octicon-info" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.5 7.75A.75.75 0 0 1 7.25 7h1a.75.75 0 0 1 .75.75v2.75h.25a.75.75 0 0 1 0 1.5h-2a.75.75 0 0 1 0-1.5h.25v-2h-.25a.75.75 0 0 1-.75-.75ZM8 6a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z"></path></svg>`,
},
{
type: `info`,
icon: `<svg class="octicon octicon-info" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.5 7.75A.75.75 0 0 1 7.25 7h1a.75.75 0 0 1 .75.75v2.75h.25a.75.75 0 0 1 0 1.5h-2a.75.75 0 0 1 0-1.5h.25v-2h-.25a.75.75 0 0 1-.75-.75ZM8 6a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z"></path></svg>`,
},
{
type: `tip`,
icon: `<svg class="octicon octicon-light-bulb" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M8 1.5c-2.363 0-4 1.69-4 3.75 0 .984.424 1.625.984 2.304l.214.253c.223.264.47.556.673.848.284.411.537.896.621 1.49a.75.75 0 0 1-1.484.211c-.04-.282-.163-.547-.37-.847a8.456 8.456 0 0 0-.542-.68c-.084-.1-.173-.205-.268-.32C3.201 7.75 2.5 6.766 2.5 5.25 2.5 2.31 4.863 0 8 0s5.5 2.31 5.5 5.25c0 1.516-.701 2.5-1.328 3.259-.095.115-.184.22-.268.319-.207.245-.383.453-.541.681-.208.3-.33.565-.37.847a.751.751 0 0 1-1.485-.212c.084-.593.337-1.078.621-1.489.203-.292.45-.584.673-.848.075-.088.147-.173.213-.253.561-.679.985-1.32.985-2.304 0-2.06-1.637-3.75-4-3.75ZM5.75 12h4.5a.75.75 0 0 1 0 1.5h-4.5a.75.75 0 0 1 0-1.5ZM6 15.25a.75.75 0 0 1 .75-.75h2.5a.75.75 0 0 1 0 1.5h-2.5a.75.75 0 0 1-.75-.75Z"></path></svg>`,
},
{
type: `important`,
icon: `<svg class="octicon octicon-report" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M0 1.75C0 .784.784 0 1.75 0h12.5C15.216 0 16 .784 16 1.75v9.5A1.75 1.75 0 0 1 14.25 13H8.06l-2.573 2.573A1.458 1.458 0 0 1 3 14.543V13H1.75A1.75 1.75 0 0 1 0 11.25Zm1.75-.25a.25.25 0 0 0-.25.25v9.5c0 .138.112.25.25.25h2a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h6.5a.25.25 0 0 0 .25-.25v-9.5a.25.25 0 0 0-.25-.25Zm7 2.25v2.5a.75.75 0 0 1-1.5 0v-2.5a.75.75 0 0 1 1.5 0ZM9 9a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z"></path></svg>`,
},
{
type: `warning`,
icon: `<svg class="octicon octicon-alert" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M6.457 1.047c.659-1.234 2.427-1.234 3.086 0l6.082 11.378A1.75 1.75 0 0 1 14.082 15H1.918a1.75 1.75 0 0 1-1.543-2.575Zm1.763.707a.25.25 0 0 0-.44 0L1.698 13.132a.25.25 0 0 0 .22.368h12.164a.25.25 0 0 0 .22-.368Zm.53 3.996v2.5a.75.75 0 0 1-1.5 0v-2.5a.75.75 0 0 1 1.5 0ZM9 11a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z"></path></svg>`,
},
{
type: `caution`,
icon: `<svg class="octicon octicon-stop" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M4.47.22A.749.749 0 0 1 5 0h6c.199 0 .389.079.53.22l4.25 4.25c.141.14.22.331.22.53v6a.749.749 0 0 1-.22.53l-4.25 4.25A.749.749 0 0 1 11 16H5a.749.749 0 0 1-.53-.22L.22 11.53A.749.749 0 0 1 0 11V5c0-.199.079-.389.22-.53Zm.84 1.28L1.5 5.31v5.38l3.81 3.81h5.38l3.81-3.81V5.31L10.69 1.5ZM8 4a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5A.75.75 0 0 1 8 4Zm0 8a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z"></path></svg>`,
},
// Obsidian-style callouts
{
type: `abstract`,
title: `Abstract`,
icon: `<svg class="octicon octicon-clipboard" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M5.75 1a.75.75 0 0 0-.75.75v.5c0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75v-.5a.75.75 0 0 0-.75-.75Zm4.5-1.5a2.25 2.25 0 0 1 2.122 1.5H13a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V3a2 2 0 0 1 2-2h.628A2.25 2.25 0 0 1 5.75-.5ZM3.5 3v10a.5.5 0 0 0 .5.5h8a.5.5 0 0 0 .5-.5V3a.5.5 0 0 0-.5-.5h-8a.5.5 0 0 0-.5.5Z"></path></svg>`,
},
{
type: `summary`,
title: `Summary`,
icon: `<svg class="octicon octicon-clipboard" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M5.75 1a.75.75 0 0 0-.75.75v.5c0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75v-.5a.75.75 0 0 0-.75-.75Zm4.5-1.5a2.25 2.25 0 0 1 2.122 1.5H13a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V3a2 2 0 0 1 2-2h.628A2.25 2.25 0 0 1 5.75-.5ZM3.5 3v10a.5.5 0 0 0 .5.5h8a.5.5 0 0 0 .5-.5V3a.5.5 0 0 0-.5-.5h-8a.5.5 0 0 0-.5.5Z"></path></svg>`,
},
{
type: `tldr`,
title: `TL;DR`,
icon: `<svg class="octicon octicon-clipboard" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M5.75 1a.75.75 0 0 0-.75.75v.5c0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75v-.5a.75.75 0 0 0-.75-.75Zm4.5-1.5a2.25 2.25 0 0 1 2.122 1.5H13a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V3a2 2 0 0 1 2-2h.628A2.25 2.25 0 0 1 5.75-.5ZM3.5 3v10a.5.5 0 0 0 .5.5h8a.5.5 0 0 0 .5-.5V3a.5.5 0 0 0-.5-.5h-8a.5.5 0 0 0-.5.5Z"></path></svg>`,
},
{
type: `todo`,
title: `Todo`,
icon: `<svg class="octicon octicon-checklist" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M2.5 1.75v11.5c0 .138.112.25.25.25h3.17a.75.75 0 0 1 0 1.5H2.75A1.75 1.75 0 0 1 1 13.25V1.75C1 .784 1.784 0 2.75 0h8.5C12.216 0 13 .784 13 1.75v7.736a.75.75 0 0 1-1.5 0V1.75a.25.25 0 0 0-.25-.25h-8.5a.25.25 0 0 0-.25.25Zm10.97 8.72a.75.75 0 0 1 1.06 0l2 2a.75.75 0 0 1-1.06 1.06l-1.22-1.22v4.94a.75.75 0 0 1-1.5 0v-4.94l-1.22 1.22a.75.75 0 0 1-1.06-1.06Z"></path></svg>`,
},
{
type: `success`,
title: `Success`,
icon: `<svg class="octicon octicon-check-circle" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M8 16A8 8 0 1 1 8 0a8 8 0 0 1 0 16Zm3.78-9.72a.751.751 0 0 0-.018-1.042.751.751 0 0 0-1.042-.018L6.75 9.19 5.28 7.72a.751.751 0 0 0-1.042.018.751.751 0 0 0-.018 1.042l2 2a.75.75 0 0 0 1.06 0Z"></path></svg>`,
},
{
type: `done`,
title: `Done`,
icon: `<svg class="octicon octicon-check-circle" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M8 16A8 8 0 1 1 8 0a8 8 0 0 1 0 16Zm3.78-9.72a.751.751 0 0 0-.018-1.042.751.751 0 0 0-1.042-.018L6.75 9.19 5.28 7.72a.751.751 0 0 0-1.042.018.751.751 0 0 0-.018 1.042l2 2a.75.75 0 0 0 1.06 0Z"></path></svg>`,
},
{
type: `question`,
title: `Question`,
icon: `<svg class="octicon octicon-question" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.92 6.085h.001a.749.749 0 1 1-1.342-.67c.169-.339.436-.701.849-.977C6.845 4.16 7.369 4 8 4a2.756 2.756 0 0 1 1.637.525c.503.377.863.965.863 1.725 0 .448-.115.83-.329 1.15-.205.307-.47.513-.692.662-.109.072-.22.138-.313.195l-.006.004a6.24 6.24 0 0 0-.26.16.952.952 0 0 0-.276.245.75.75 0 0 1-1.248-.832c.184-.264.42-.489.692-.661.103-.067.207-.132.313-.195l.007-.004c.1-.061.182-.11.258-.161a.969.969 0 0 0 .277-.245C8.96 6.514 9 6.427 9 6.25a.612.612 0 0 0-.262-.525A1.27 1.27 0 0 0 8 5.5c-.369 0-.595.09-.74.187a1.01 1.01 0 0 0-.34.398ZM9 11a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z"></path></svg>`,
},
{
type: `help`,
title: `Help`,
icon: `<svg class="octicon octicon-question" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.92 6.085h.001a.749.749 0 1 1-1.342-.67c.169-.339.436-.701.849-.977C6.845 4.16 7.369 4 8 4a2.756 2.756 0 0 1 1.637.525c.503.377.863.965.863 1.725 0 .448-.115.83-.329 1.15-.205.307-.47.513-.692.662-.109.072-.22.138-.313.195l-.006.004a6.24 6.24 0 0 0-.26.16.952.952 0 0 0-.276.245.75.75 0 0 1-1.248-.832c.184-.264.42-.489.692-.661.103-.067.207-.132.313-.195l.007-.004c.1-.061.182-.11.258-.161a.969.969 0 0 0 .277-.245C8.96 6.514 9 6.427 9 6.25a.612.612 0 0 0-.262-.525A1.27 1.27 0 0 0 8 5.5c-.369 0-.595.09-.74.187a1.01 1.01 0 0 0-.34.398ZM9 11a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z"></path></svg>`,
},
{
type: `faq`,
title: `FAQ`,
icon: `<svg class="octicon octicon-question" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.92 6.085h.001a.749.749 0 1 1-1.342-.67c.169-.339.436-.701.849-.977C6.845 4.16 7.369 4 8 4a2.756 2.756 0 0 1 1.637.525c.503.377.863.965.863 1.725 0 .448-.115.83-.329 1.15-.205.307-.47.513-.692.662-.109.072-.22.138-.313.195l-.006.004a6.24 6.24 0 0 0-.26.16.952.952 0 0 0-.276.245.75.75 0 0 1-1.248-.832c.184-.264.42-.489.692-.661.103-.067.207-.132.313-.195l.007-.004c.1-.061.182-.11.258-.161a.969.969 0 0 0 .277-.245C8.96 6.514 9 6.427 9 6.25a.612.612 0 0 0-.262-.525A1.27 1.27 0 0 0 8 5.5c-.369 0-.595.09-.74.187a1.01 1.01 0 0 0-.34.398ZM9 11a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z"></path></svg>`,
},
{
type: `failure`,
title: `Failure`,
icon: `<svg class="octicon octicon-x-circle" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M2.343 13.657A8 8 0 1 1 13.658 2.343 8 8 0 0 1 2.343 13.657ZM6.03 4.97a.751.751 0 0 0-1.042.018.751.751 0 0 0-.018 1.042L6.94 8 4.97 9.97a.749.749 0 0 0 .326 1.275.749.749 0 0 0 .734-.215L8 9.06l1.97 1.97a.749.749 0 0 0 1.275-.326.749.749 0 0 0-.215-.734L9.06 8l1.97-1.97a.749.749 0 0 0-.326-1.275.749.749 0 0 0-.734.215L8 6.94Z"></path></svg>`,
},
{
type: `fail`,
title: `Fail`,
icon: `<svg class="octicon octicon-x-circle" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M2.343 13.657A8 8 0 1 1 13.658 2.343 8 8 0 0 1 2.343 13.657ZM6.03 4.97a.751.751 0 0 0-1.042.018.751.751 0 0 0-.018 1.042L6.94 8 4.97 9.97a.749.749 0 0 0 .326 1.275.749.749 0 0 0 .734-.215L8 9.06l1.97 1.97a.749.749 0 0 0 1.275-.326.749.749 0 0 0-.215-.734L9.06 8l1.97-1.97a.749.749 0 0 0-.326-1.275.749.749 0 0 0-.734.215L8 6.94Z"></path></svg>`,
},
{
type: `missing`,
title: `Missing`,
icon: `<svg class="octicon octicon-x-circle" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M2.343 13.657A8 8 0 1 1 13.658 2.343 8 8 0 0 1 2.343 13.657ZM6.03 4.97a.751.751 0 0 0-1.042.018.751.751 0 0 0-.018 1.042L6.94 8 4.97 9.97a.749.749 0 0 0 .326 1.275.749.749 0 0 0 .734-.215L8 9.06l1.97 1.97a.749.749 0 0 0 1.275-.326.749.749 0 0 0-.215-.734L9.06 8l1.97-1.97a.749.749 0 0 0-.326-1.275.749.749 0 0 0-.734.215L8 6.94Z"></path></svg>`,
},
{
type: `danger`,
title: `Danger`,
icon: `<svg class="octicon octicon-zap" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M9.504.43a1.516 1.516 0 0 1 2.437 1.713L10.415 5.5h2.123c1.57 0 2.346 1.909 1.22 3.004l-7.34 7.142a1.249 1.249 0 0 1-.871.354h-.302a1.25 1.25 0 0 1-1.157-1.723L5.633 10.5H3.462c-1.57 0-2.346-1.909-1.22-3.004ZM9.98 1.873a.016.016 0 0 0-.016.006L2.252 9.021a.75.75 0 0 0 .488 1.212h3.838a.75.75 0 0 1 .694 1.034L5.545 15.02a.016.016 0 0 0 .003.017.017.017 0 0 0 .018.004h.302a.016.016 0 0 0 .012-.005l7.34-7.142a.75.75 0 0 0-.488-1.212h-3.838a.75.75 0 0 1-.694-1.034l1.527-3.628a.016.016 0 0 0-.003-.017.017.017 0 0 0-.018-.004h-.302Z"></path></svg>`,
},
{
type: `error`,
title: `Error`,
icon: `<svg class="octicon octicon-zap" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M9.504.43a1.516 1.516 0 0 1 2.437 1.713L10.415 5.5h2.123c1.57 0 2.346 1.909 1.22 3.004l-7.34 7.142a1.249 1.249 0 0 1-.871.354h-.302a1.25 1.25 0 0 1-1.157-1.723L5.633 10.5H3.462c-1.57 0-2.346-1.909-1.22-3.004ZM9.98 1.873a.016.016 0 0 0-.016.006L2.252 9.021a.75.75 0 0 0 .488 1.212h3.838a.75.75 0 0 1 .694 1.034L5.545 15.02a.016.016 0 0 0 .003.017.017.017 0 0 0 .018.004h.302a.016.016 0 0 0 .012-.005l7.34-7.142a.75.75 0 0 0-.488-1.212h-3.838a.75.75 0 0 1-.694-1.034l1.527-3.628a.016.016 0 0 0-.003-.017.017.017 0 0 0-.018-.004h-.302Z"></path></svg>`,
},
{
type: `bug`,
title: `Bug`,
icon: `<svg class="octicon octicon-bug" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M4.72.22a.75.75 0 0 1 1.06 0l1 .999a3.488 3.488 0 0 1 2.441 0l.999-1a.748.748 0 0 1 1.265.332.75.75 0 0 1-.205.729l-.775.776c.616.63.995 1.493.995 2.444v.327c0 .1-.009.197-.025.292l.727.726a.75.75 0 1 1-1.06 1.06l-.727-.727a2.17 2.17 0 0 1-.292.026H9.25V7.5a.75.75 0 0 1-1.5 0V6.125H6.875a2.17 2.17 0 0 1-.292-.026l-.727.727a.75.75 0 1 1-1.06-1.06l.727-.726a2.17 2.17 0 0 1-.025-.292V4.5c0-.951.379-1.814.995-2.444l-.775-.776a.75.75 0 0 1 0-1.06Zm6.437 6.003A.608.608 0 0 0 11 6.072v-.026a3.999 3.999 0 0 0-.11-.936 2.488 2.488 0 0 0-5.78 0 3.992 3.992 0 0 0-.11.936v.026c0 .05.008.098.02.147h4.937a.612.612 0 0 0 .2-.02ZM2.25 7.5a.75.75 0 0 0 0 1.5h.5v1.25a4.75 4.75 0 0 0 2.478 4.166l.247.137a.75.75 0 1 0 .722-1.313l-.246-.137A3.25 3.25 0 0 1 4.25 10.25V9h7.5v1.25a3.25 3.25 0 0 1-1.701 2.853l-.246.137a.75.75 0 1 0 .722 1.313l.247-.137A4.75 4.75 0 0 0 13.25 10.25V9h.5a.75.75 0 0 0 0-1.5Z"></path></svg>`,
},
{
type: `example`,
title: `Example`,
icon: `<svg class="octicon octicon-list-unordered" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M5.75 2.5h8.5a.75.75 0 0 1 0 1.5h-8.5a.75.75 0 0 1 0-1.5Zm0 5h8.5a.75.75 0 0 1 0 1.5h-8.5a.75.75 0 0 1 0-1.5Zm0 5h8.5a.75.75 0 0 1 0 1.5h-8.5a.75.75 0 0 1 0-1.5ZM2 14a1 1 0 1 1 0-2 1 1 0 0 1 0 2Zm1-6a1 1 0 1 1-2 0 1 1 0 0 1 2 0ZM2 4a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z"></path></svg>`,
},
{
type: `quote`,
title: `Quote`,
icon: `<svg class="octicon octicon-quote" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M1.75 2h12.5c.966 0 1.75.784 1.75 1.75v8.5A1.75 1.75 0 0 1 14.25 14H1.75A1.75 1.75 0 0 1 0 12.25v-8.5C0 2.784.784 2 1.75 2ZM1.5 12.25c0 .138.112.25.25.25h12.5a.25.25 0 0 0 .25-.25v-8.5a.25.25 0 0 0-.25-.25H1.75a.25.25 0 0 0-.25.25ZM4 5.25a.75.75 0 0 1 .75-.75h6.5a.75.75 0 0 1 0 1.5h-6.5A.75.75 0 0 1 4 5.25Zm0 4a.75.75 0 0 1 .75-.75h6.5a.75.75 0 0 1 0 1.5h-6.5a.75.75 0 0 1-.75-.75Z"></path></svg>`,
},
{
type: `cite`,
title: `Cite`,
icon: `<svg class="octicon octicon-quote" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M1.75 2h12.5c.966 0 1.75.784 1.75 1.75v8.5A1.75 1.75 0 0 1 14.25 14H1.75A1.75 1.75 0 0 1 0 12.25v-8.5C0 2.784.784 2 1.75 2ZM1.5 12.25c0 .138.112.25.25.25h12.5a.25.25 0 0 0 .25-.25v-8.5a.25.25 0 0 0-.25-.25H1.75a.25.25 0 0 0-.25.25ZM4 5.25a.75.75 0 0 1 .75-.75h6.5a.75.75 0 0 1 0 1.5h-6.5A.75.75 0 0 1 4 5.25Zm0 4a.75.75 0 0 1 .75-.75h6.5a.75.75 0 0 1 0 1.5h-6.5a.75.75 0 0 1-.75-.75Z"></path></svg>`,
},
]
/**
* Resolves the variants configuration, combining the provided variants with
* the default variants.
*/
export function resolveVariants(variants: AlertVariantItem[]) {
if (!variants.length)
return defaultAlertVariant
return Object.values(
[...defaultAlertVariant, ...variants].reduce(
(map, item) => {
map[item.type] = item
return map
},
{} as { [key: string]: AlertVariantItem },
),
)
}
/**
* Returns regex pattern to match alert syntax.
*/
export function createSyntaxPattern(type: string) {
return `^(?:\\[!${type}])\\s*?\n*`
}
@@ -0,0 +1,89 @@
import type { MarkedExtension, Tokens } from 'marked'
/**
* A marked extension to support footnotes syntax.
* Syntax:
* This is a footnote reference[^1][^2].
*
* [^1]: .....
* [^2]: .....
*/
interface MapContent {
index: number
text: string
}
const fnMap = new Map<string, MapContent>()
export function markedFootnotes(): MarkedExtension {
return {
extensions: [
{
name: `footnoteDef`,
level: `block`,
start(src: string) {
fnMap.clear()
return src.match(/^\[\^/)?.index
},
tokenizer(src: string) {
const match = src.match(/^\[\^(.*)\]:(.*)/)
if (match) {
const [raw, fnId, text] = match
const index = fnMap.size + 1
fnMap.set(fnId, { index, text })
return {
type: `footnoteDef`,
raw,
fnId,
index,
text,
}
}
return undefined
},
renderer(token: Tokens.Generic) {
const { index, text, fnId } = token
const fnInner = `
<code>${index}.</code>
<span>${text}</span>
<a id="fnDef-${fnId}" href="#fnRef-${fnId}" style="color: var(--md-primary-color);">\u21A9\uFE0E</a>
<br>`
if (index === 1) {
return `
<p style="font-size: 80%;margin: 0.5em 8px;word-break:break-all;">${fnInner}`
}
if (index === fnMap.size) {
return `${fnInner}</p>`
}
return fnInner
},
},
{
name: `footnoteRef`,
level: `inline`,
start(src: string) {
return src.match(/\[\^/)?.index
},
tokenizer(src: string) {
const match = src.match(/^\[\^(.*?)\]/)
if (match) {
const [raw, fnId] = match
if (fnMap.has(fnId)) {
return {
type: `footnoteRef`,
raw,
fnId,
}
}
}
},
renderer(token: Tokens.Generic) {
const { fnId } = token
const { index } = fnMap.get(fnId) as MapContent
return `<sup style="color: var(--md-primary-color);">
<a href="#fnDef-${fnId}" id="fnRef-${fnId}">\[${index}\]</a>
</sup>`
},
},
],
}
}
@@ -0,0 +1,10 @@
// Markdown 扩展导出
export * from './alert.js'
export * from './footnotes.js'
export * from './infographic.js'
export * from './katex.js'
export * from './markup.js'
export * from './plantuml.js'
export * from './ruby.js'
export * from './slider.js'
export * from './toc.js'
@@ -0,0 +1,119 @@
import type { MarkedExtension } from 'marked'
interface InfographicOptions {
themeMode?: 'dark' | 'light'
}
async function renderInfographic(containerId: string, code: string, options?: InfographicOptions) {
if (typeof window === 'undefined')
return
try {
const { Infographic, setDefaultFont, setFontExtendFactor, exportToSVG } = await import('@antv/infographic')
setFontExtendFactor(1.1)
setDefaultFont('-apple-system-font, "system-ui", "Helvetica Neue", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei UI", "Microsoft YaHei", Arial, sans-serif')
const findContainer = (retries = 5, delay = 100) => {
const container = document.getElementById(containerId)
if (container) {
const isDark = options?.themeMode === 'dark'
// 从 CSS 变量中读取主题颜色
const root = document.documentElement
const computedStyle = getComputedStyle(root)
const primaryColor = computedStyle.getPropertyValue('--md-primary-color').trim()
const backgroundColor = computedStyle.getPropertyValue('--background').trim()
// 转换 HSL 格式
const toHSLString = (variant: string) => {
const vars = variant.split(' ')
if (vars.length === 3)
return `hsl(${vars.join(', ')})`
if (vars.length === 4)
return `hsla(${vars.join(', ')})`
return ''
}
const instance = new Infographic({
container,
svg: {
style: {
width: '100%',
height: '100%',
background: isDark ? '#000' : 'transparent',
},
background: false,
},
theme: isDark ? 'dark' : 'default',
themeConfig: {
colorPrimary: primaryColor || undefined,
colorBg: toHSLString(backgroundColor) || undefined,
},
})
instance.on('loaded', ({ node }) => {
exportToSVG(node, { removeIds: true }).then((svg) => {
container.replaceChildren(svg)
})
})
instance.render(code)
return
}
if (retries > 0) {
setTimeout(() => findContainer(retries - 1, delay), delay)
}
}
findContainer()
}
catch (error) {
console.error('Failed to render Infographic:', error)
const container = document.getElementById(containerId)
if (container) {
container.innerHTML = `<div style="color: red; padding: 10px; border: 1px solid red;">Infographic 渲染失败: ${error instanceof Error ? error.message : String(error)}</div>`
}
}
}
export function markedInfographic(options?: InfographicOptions): MarkedExtension {
const className = 'infographic-diagram'
return {
extensions: [
{
name: 'infographic',
level: 'block',
start(src: string) {
return src.match(/^```infographic/m)?.index
},
tokenizer(src: string) {
const match = /^```infographic\r?\n([\s\S]*?)\r?\n```/.exec(src)
if (match) {
return {
type: 'infographic',
raw: match[0],
text: match[1].trim(),
}
}
},
renderer(token: any) {
const id = `infographic-${Math.random().toString(36).slice(2, 11)}`
const code = token.text
renderInfographic(id, code, options)
return `<div id="${id}" class="${className}" style="width: 100%;">正在加载 Infographic...</div>`
},
},
],
walkTokens(token: any) {
if (token.type === 'code' && token.lang === 'infographic') {
token.type = 'infographic'
}
},
}
}
@@ -0,0 +1,162 @@
import type { MarkedExtension } from 'marked'
export interface MarkedKatexOptions {
nonStandard?: boolean
}
const inlineRule = /^(\${1,2})(?!\$)((?:\\.|[^\\\n])*?(?:\\.|[^\\\n$]))\1(?=[\s?!.,:?!。,:]|$)/
const inlineRuleNonStandard = /^(\${1,2})(?!\$)((?:\\.|[^\\\n])*?(?:\\.|[^\\\n$]))\1/ // Non-standard, even if there are no spaces before and after $ or $$, try to parse
const blockRule = /^\s{0,3}(\${1,2})[ \t]*\n([\s\S]+?)\n\s{0,3}\1[ \t]*(?:\n|$)/
// LaTeX style rules for \( ... \) and \[ ... \]
const inlineLatexRule = /^\\\(([^\\]*(?:\\.[^\\]*)*?)\\\)/
const blockLatexRule = /^\\\[([^\\]*(?:\\.[^\\]*)*?)\\\]/
function createRenderer(display: boolean, withStyle: boolean = true) {
return (token: any) => {
// @ts-expect-error MathJax is a global variable
window.MathJax.texReset()
// @ts-expect-error MathJax is a global variable
const mjxContainer = window.MathJax.tex2svg(token.text, { display })
const svg = mjxContainer.firstChild
const width = svg.style[`min-width`] || svg.getAttribute(`width`)
svg.removeAttribute(`width`)
// 行内公式对齐 https://groups.google.com/g/mathjax-users/c/zThKffrrCvE?pli=1
// 直接覆盖 style 会覆盖 MathJax 的样式,需要手动设置
// svg.style = `max-width: 300vw !important; display: initial; flex-shrink: 0;`
if (withStyle) {
svg.style.display = `initial`
svg.style.setProperty(`max-width`, `300vw`, `important`)
svg.style.flexShrink = `0`
svg.style.width = width
}
if (!display) {
// 新主题系统:使用 class 而非内联样式
return `<span class="katex-inline">${svg.outerHTML}</span>`
}
return `<section class="katex-block">${svg.outerHTML}</section>`
}
}
function inlineKatex(options: MarkedKatexOptions | undefined, renderer: any) {
const nonStandard = options && options.nonStandard
const ruleReg = nonStandard ? inlineRuleNonStandard : inlineRule
return {
name: `inlineKatex`,
level: `inline`,
start(src: string) {
let index
let indexSrc = src
while (indexSrc) {
index = indexSrc.indexOf(`$`)
if (index === -1) {
return
}
const f = nonStandard ? index > -1 : index === 0 || indexSrc.charAt(index - 1) === ` `
if (f) {
const possibleKatex = indexSrc.substring(index)
if (possibleKatex.match(ruleReg)) {
return index
}
}
indexSrc = indexSrc.substring(index + 1).replace(/^\$+/, ``)
}
},
tokenizer(src: string) {
const match = src.match(ruleReg)
if (match) {
return {
type: `inlineKatex`,
raw: match[0],
text: match[2].trim(),
displayMode: match[1].length === 2,
}
}
},
renderer,
}
}
function blockKatex(_options: MarkedKatexOptions | undefined, renderer: any) {
return {
name: `blockKatex`,
level: `block`,
tokenizer(src: string) {
const match = src.match(blockRule)
if (match) {
return {
type: `blockKatex`,
raw: match[0],
text: match[2].trim(),
displayMode: match[1].length === 2,
}
}
},
renderer,
}
}
function inlineLatexKatex(_options: MarkedKatexOptions | undefined, renderer: any) {
return {
name: `inlineLatexKatex`,
level: `inline`,
start(src: string) {
const index = src.indexOf(`\\(`)
return index !== -1 ? index : undefined
},
tokenizer(src: string) {
const match = src.match(inlineLatexRule)
if (match) {
return {
type: `inlineLatexKatex`,
raw: match[0],
text: match[1].trim(),
displayMode: false,
}
}
},
renderer,
}
}
function blockLatexKatex(_options: MarkedKatexOptions | undefined, renderer: any) {
return {
name: `blockLatexKatex`,
level: `block`,
start(src: string) {
const index = src.indexOf(`\\[`)
return index !== -1 ? index : undefined
},
tokenizer(src: string) {
const match = src.match(blockLatexRule)
if (match) {
return {
type: `blockLatexKatex`,
raw: match[0],
text: match[1].trim(),
displayMode: true,
}
}
},
renderer,
}
}
export function MDKatex(options: MarkedKatexOptions | undefined, withStyle: boolean = true): MarkedExtension {
return {
extensions: [
inlineKatex(options, createRenderer(false, withStyle)),
blockKatex(options, createRenderer(true, withStyle)),
inlineLatexKatex(options, createRenderer(false, withStyle)),
blockLatexKatex(options, createRenderer(true, withStyle)),
],
}
}
@@ -0,0 +1,87 @@
import type { MarkedExtension } from 'marked'
/**
* 扩展标记语法:
* - 高亮: ==文本==
* - 下划线: ++文本++
* - 波浪线: ~文本~
*/
export function markedMarkup(): MarkedExtension {
return {
extensions: [
// 高亮语法 ==文本==
{
name: `markup_highlight`,
level: `inline`,
start(src: string) {
return src.match(/==(?!=)/)?.index
},
tokenizer(src: string) {
const rule = /^==((?:[^=]|=(?!=))+)==/
const match = rule.exec(src)
if (match) {
return {
type: `markup_highlight`,
raw: match[0],
text: match[1],
}
}
},
renderer(token: any) {
// 新主题系统:使用 class 而非内联样式
return `<span class="markup-highlight">${token.text}</span>`
},
},
// 下划线语法 ++文本++
{
name: `markup_underline`,
level: `inline`,
start(src: string) {
return src.match(/\+\+(?!\+)/)?.index
},
tokenizer(src: string) {
const rule = /^\+\+((?:[^+]|\+(?!\+))+)\+\+/
const match = rule.exec(src)
if (match) {
return {
type: `markup_underline`,
raw: match[0],
text: match[1],
}
}
},
renderer(token: any) {
// 新主题系统:使用 class 而非内联样式
return `<span class="markup-underline">${token.text}</span>`
},
},
// 波浪线语法 ~文本~
{
name: `markup_wavyline`,
level: `inline`,
start(src: string) {
// 查找单个 ~ 但不是连续的 ~~
return src.match(/~(?!~)/)?.index
},
tokenizer(src: string) {
// 匹配 ~文本~ 但确保不是 ~~文本~~
const rule = /^~([^~\n]+)~(?!~)/
const match = rule.exec(src)
if (match) {
return {
type: `markup_wavyline`,
raw: match[0],
text: match[1],
}
}
},
renderer(token: any) {
// 新主题系统:使用 class 而非内联样式
return `<span class="markup-wavyline">${token.text}</span>`
},
},
],
}
}
@@ -0,0 +1,289 @@
import type { MarkedExtension, Tokens } from 'marked'
import { deflateSync } from 'fflate'
export interface PlantUMLOptions {
/**
* PlantUML 服务器地址
* @default 'https://www.plantuml.com/plantuml'
*/
serverUrl?: string
/**
* 渲染格式
* @default 'svg'
*/
format?: `svg` | `png`
/**
* CSS 类名
* @default 'plantuml-diagram'
*/
className?: string
/**
* 是否内嵌SVG内容(用于微信公众号等不支持外链图片的环境)
* @default false
*/
inlineSvg?: boolean
/**
* 自定义样式
*/
styles?: {
container?: Record<string, string | number>
}
}
/**
* PlantUML 专用的 6-bit 编码函数
* 基于官方文档 https://plantuml.com/text-encoding
*/
function encode6bit(b: number): string {
if (b < 10) {
return String.fromCharCode(48 + b)
}
b -= 10
if (b < 26) {
return String.fromCharCode(65 + b)
}
b -= 26
if (b < 26) {
return String.fromCharCode(97 + b)
}
b -= 26
if (b === 0) {
return `-`
}
if (b === 1) {
return `_`
}
return `?`
}
/**
* 将 3 个字节附加到编码字符串中
* 基于官方文档 https://plantuml.com/text-encoding
*/
function append3bytes(b1: number, b2: number, b3: number): string {
const c1 = b1 >> 2
const c2 = ((b1 & 0x3) << 4) | (b2 >> 4)
const c3 = ((b2 & 0xF) << 2) | (b3 >> 6)
const c4 = b3 & 0x3F
let r = ``
r += encode6bit(c1 & 0x3F)
r += encode6bit(c2 & 0x3F)
r += encode6bit(c3 & 0x3F)
r += encode6bit(c4 & 0x3F)
return r
}
/**
* PlantUML 专用的 base64 编码函数
* 基于官方文档 https://plantuml.com/text-encoding
*/
function encode64(data: string): string {
let r = ``
for (let i = 0; i < data.length; i += 3) {
if (i + 2 === data.length) {
r += append3bytes(data.charCodeAt(i), data.charCodeAt(i + 1), 0)
}
else if (i + 1 === data.length) {
r += append3bytes(data.charCodeAt(i), 0, 0)
}
else {
r += append3bytes(data.charCodeAt(i), data.charCodeAt(i + 1), data.charCodeAt(i + 2))
}
}
return r
}
/**
* 使用 fflate 库进行 Deflate 压缩
* 按照官方规范进行压缩
*/
function performDeflate(input: string): string {
try {
// 将字符串转换为字节数组
const inputBytes = new TextEncoder().encode(input)
// 使用 fflate 进行 deflate 压缩(最高压缩级别 9)
const compressed = deflateSync(inputBytes, { level: 9 })
// 将压缩后的字节数组转换为二进制字符串
return String.fromCharCode(...compressed)
}
catch (error) {
console.warn(`Deflate compression failed:`, error)
// 如果压缩失败,返回原始输入
return input
}
}
/**
* 编码 PlantUML 代码为服务器可识别的格式
* 按照官方规范:UTF-8 编码 -> Deflate 压缩 -> PlantUML Base64 编码
*/
function encodePlantUML(plantumlCode: string): string {
try {
// 步骤 1 & 2: UTF-8 编码 + Deflate 压缩
const deflated = performDeflate(plantumlCode)
// 步骤 3: PlantUML 专用的 base64 编码
return encode64(deflated)
}
catch (error) {
// 如果编码失败,回退到简单方案
console.warn(`PlantUML encoding failed, using fallback:`, error)
const utf8Bytes = new TextEncoder().encode(plantumlCode)
const base64 = btoa(String.fromCharCode(...utf8Bytes))
return `~1${base64.replace(/\+/g, `-`).replace(/\//g, `_`).replace(/=/g, ``)}`
}
}
/**
* 生成 PlantUML 图片 URL
*/
function generatePlantUMLUrl(code: string, options: Required<PlantUMLOptions>): string {
const encoded = encodePlantUML(code)
const formatPath = options.format === `svg` ? `svg` : `png`
return `${options.serverUrl}/${formatPath}/${encoded}`
}
/**
* 渲染 PlantUML 图表
*/
function renderPlantUMLDiagram(token: Tokens.Code, options: Required<PlantUMLOptions>): string {
const { text: code } = token
// 检查代码是否包含 PlantUML 标记
const finalCode = (!code.trim().includes(`@start`) || !code.trim().includes(`@end`))
? `@startuml\n${code.trim()}\n@enduml`
: code
const imageUrl = generatePlantUMLUrl(finalCode, options)
// 如果启用了内嵌SVG且格式是SVG
if (options.inlineSvg && options.format === `svg`) {
// 由于marked是同步的,我们需要返回一个占位符,然后异步替换
const placeholder = `plantuml-placeholder-${Math.random().toString(36).slice(2, 11)}`
// 异步获取SVG内容并替换
fetchSvgContent(imageUrl).then((svgContent) => {
const placeholderElement = document.querySelector(`[data-placeholder="${placeholder}"]`)
if (placeholderElement) {
placeholderElement.outerHTML = createPlantUMLHTML(imageUrl, options, svgContent)
}
})
const containerStyles = options.styles.container
? Object.entries(options.styles.container)
.map(([key, value]) => `${key.replace(/([A-Z])/g, `-$1`).toLowerCase()}: ${value}`)
.join(`; `)
: ``
return `<div class="${options.className}" style="${containerStyles}" data-placeholder="${placeholder}">
<div style="color: #666; font-style: italic;">正在加载PlantUML图表...</div>
</div>`
}
return createPlantUMLHTML(imageUrl, options)
}
/**
* 获取SVG内容
*/
async function fetchSvgContent(svgUrl: string): Promise<string> {
try {
const response = await fetch(svgUrl)
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
const svgContent = await response.text()
// 移除SVG根元素的固定尺寸,使其响应式
return svgContent
// 移除width和height属性
.replace(/(<svg[^>]*)\swidth="[^"]*"/g, `$1`)
.replace(/(<svg[^>]*)\sheight="[^"]*"/g, `$1`)
// 移除style中的width和height
.replace(/(<svg[^>]*style="[^"]*?)width:[^;]*;?/g, `$1`)
.replace(/(<svg[^>]*style="[^"]*?)height:[^;]*;?/g, `$1`)
}
catch (error) {
console.warn(`Failed to fetch SVG content from ${svgUrl}:`, error)
return `<div style="color: #666; font-style: italic;">PlantUML图表加载失败</div>`
}
}
/**
* 创建 PlantUML HTML 元素
*/
function createPlantUMLHTML(imageUrl: string, options: Required<PlantUMLOptions>, svgContent?: string): string {
const containerStyles = options.styles.container
? Object.entries(options.styles.container)
.map(([key, value]) => `${key.replace(/([A-Z])/g, `-$1`).toLowerCase()}: ${value}`)
.join(`; `)
: ``
// 如果有SVG内容,直接嵌入
if (svgContent) {
return `<div class="${options.className}" style="${containerStyles}">
${svgContent}
</div>`
}
// 否则使用图片链接
return `<div class="${options.className}" style="${containerStyles}">
<img src="${imageUrl}" alt="PlantUML Diagram" style="max-width: 100%; height: auto;" />
</div>`
}
/**
* PlantUML marked 扩展
*/
export function markedPlantUML(options: PlantUMLOptions = {}): MarkedExtension {
const resolvedOptions: Required<PlantUMLOptions> = {
serverUrl: options.serverUrl || `https://www.plantuml.com/plantuml`,
format: options.format || `svg`,
className: options.className || `plantuml-diagram`,
inlineSvg: options.inlineSvg || false,
styles: {
container: {
textAlign: `center`,
margin: `16px 8px`,
overflowX: `auto`,
...options.styles?.container,
},
},
}
return {
extensions: [
{
name: `plantuml`,
level: `block`,
start(src: string) {
// 匹配 ```plantuml 代码块
return src.match(/^```plantuml/m)?.index
},
tokenizer(src: string) {
// 匹配完整的 plantuml 代码块
const match = /^```plantuml\r?\n([\s\S]*?)\r?\n```/.exec(src)
if (match) {
const [raw, code] = match
return {
type: `plantuml`,
raw,
text: code.trim(),
}
}
},
renderer(token: any) {
return renderPlantUMLDiagram(token, resolvedOptions)
},
},
],
walkTokens(token: any) {
// 处理现有的代码块,如果语言是 plantuml 就转换类型
if (token.type === `code` && token.lang === `plantuml`) {
token.type = `plantuml`
}
},
}
}
@@ -0,0 +1,125 @@
import type { MarkedExtension } from 'marked'
/**
* 注音/拼音标注扩展
* https://talk.commonmark.org/t/proper-ruby-text-rb-syntax-support-in-markdown/2279
* https://www.w3.org/TR/ruby/
*
* 支持的格式:
* 1. [文字]{注音}
* 2. [文字]^(注音)
*
* 分隔符:
* - `・` (中点)
* - `` (全角句点)
* - `。` (中文句号)
* - `-` (英文减号)
*/
export function markedRuby(): MarkedExtension {
return {
extensions: [
{
name: `ruby`,
level: `inline`,
start(src: string) {
// 匹配以 [ 开头的格式
return src.match(/\[/)?.index
},
tokenizer(src: string) {
// 1. [文字]{注音}
const rule1 = /^\[([^\]]+)\]\{([^}]+)\}/
let match = rule1.exec(src)
if (match) {
return {
type: `ruby`,
raw: match[0],
text: match[1].trim(),
ruby: match[2].trim(),
format: `basic`,
}
}
// 2. [文字]^(注音)
const rule2 = /^\[([^\]]+)\]\^\(([^)]+)\)/
match = rule2.exec(src)
if (match) {
return {
type: `ruby`,
raw: match[0],
text: match[1].trim(),
ruby: match[2].trim(),
format: `basic-hat`,
}
}
return undefined
},
renderer(token: any) {
const { text, ruby, format } = token
// 检查是否有分隔符
const separatorRegex = /[・.。-]/g
const hasSeparators = separatorRegex.test(ruby)
if (hasSeparators) {
// 分割注音部分
const rubyParts = ruby.split(separatorRegex).filter((part: string) => part.trim() !== ``)
const textChars = text.split(``)
const result = []
if (textChars.length >= rubyParts.length) {
// 文字字符数量 >= 注音部分数量
// 按注音部分数量分割文字
let currentIndex = 0
for (let i = 0; i < rubyParts.length; i++) {
const rubyPart = rubyParts[i]
const remainingChars = textChars.length - currentIndex
const remainingParts = rubyParts.length - i
// 计算当前部分应该包含多少个字符,默认为 1
let charCount = 1
if (remainingParts === 1) {
// 最后一个部分,包含所有剩余字符
charCount = remainingChars
}
// 提取当前部分的文字
const currentText = textChars.slice(currentIndex, currentIndex + charCount).join(``)
result.push(`<ruby data-text="${currentText}" data-ruby="${rubyPart}" data-format="${format}">${currentText}<rp>(</rp><rt>${rubyPart}</rt><rp>)</rp></ruby>`)
currentIndex += charCount
}
// 处理剩余的字符
if (currentIndex < textChars.length) {
result.push(textChars.slice(currentIndex).join(``))
}
}
else {
// 文字字符数量 < 注音部分数量
// 每个字符对应一个注音部分,多余的注音被忽略
for (let i = 0; i < textChars.length; i++) {
const char = textChars[i]
const rubyPart = rubyParts[i] || ``
if (rubyPart) {
result.push(`<ruby data-text="${char}" data-ruby="${rubyPart}" data-format="${format}">${char}<rp>(</rp><rt>${rubyPart}</rt><rp>)</rp></ruby>`)
}
else {
result.push(char)
}
}
}
return result.join(``)
}
return `<ruby data-text="${text}" data-ruby="${ruby}" data-format="${format}">${text}<rp>(</rp><rt>${ruby}</rt><rp>)</rp></ruby>`
},
},
],
}
}
@@ -0,0 +1,73 @@
import type { MarkedExtension, Tokens } from 'marked'
/**
* A marked extension to support horizontal sliding images.
* Syntax: <![alt1](url1),![alt2](url2),![alt3](url3)>
*/
export function markedSlider(): MarkedExtension {
return {
extensions: [
{
name: `horizontalSlider`,
level: `block`,
start(src: string) {
return src.match(/^<!\[/)?.index
},
tokenizer(src: string) {
const rule = /^<(!\[.*?\]\(.*?\)(?:,!\[.*?\]\(.*?\))*)>/
const match = src.match(rule)
if (match) {
return {
type: `horizontalSlider`,
raw: match[0],
text: match[1],
}
}
return undefined
},
renderer(token: Tokens.Generic) {
const { text } = token
const imageMatches = text.match(/!\[(.*?)\]\((.*?)\)/g) || []
if (imageMatches.length === 0) {
return ``
}
const images = imageMatches.map((img: string) => {
const altMatch = img.match(/!\[(.*?)\]/) || []
const srcMatch = img.match(/\]\((.*?)\)/) || []
const alt = altMatch[1] || ``
const src = srcMatch[1] || ``
// 新主题系统:不再需要内联样式
return { src, alt }
})
// 使用微信公众号兼容的滑动容器布局
// 使用微信支持的section标签和特殊样式组合
return `
<section style="box-sizing: border-box; font-size: 16px;">
<section data-role="outer" style="font-family: 微软雅黑; font-size: 16px;">
<section data-role="paragraph" style="margin: 0px auto; box-sizing: border-box; width: 100%;">
<section style="margin: 0px auto; text-align: center;">
<section style="display: inline-block; width: 100%;">
<!-- 微信公众号支持的滑动图片容器 -->
<section style="overflow-x: scroll; -webkit-overflow-scrolling: touch; white-space: nowrap; width: 100%; text-align: center;">
${images.map((img: { src: string, alt: string }, _index: number) => `<section style="display: inline-block; width: 100%; margin-right: 0; vertical-align: top;">
<img src="${img.src}" alt="${img.alt}" title="${img.alt}" style="width: 100%; height: auto; border-radius: 4px; vertical-align: top;"/>
<p style="margin-top: 5px; font-size: 14px; color: #666; text-align: center; white-space: normal;">${img.alt}</p>
</section>`).join(``)}
</section>
</section>
</section>
</section>
</section>
<p style="font-size: 14px; color: #999; text-align: center; margin-top: 5px;"><<< 左右滑动看更多 >>></p>
</section>
`
},
},
],
}
}
@@ -0,0 +1,74 @@
import type { MarkedExtension } from 'marked'
/**
* marked 插件:支持 [TOC] 语法,自动生成嵌套目录
*/
export function markedToc(): MarkedExtension {
let headings: { text: string, depth: number, index: number }[] = []
let firstToken = true
return {
walkTokens(token) {
if (firstToken) {
headings = []
firstToken = false
}
if (token.type === `heading`) {
const text = token.text || ``
const depth = token.depth || 1
const index = headings.length
headings.push({ text, depth, index })
}
},
extensions: [
{
name: `toc`,
level: `block`,
start(src) {
// 只匹配独立一行的 [TOC],避免误伤
const match = src.match(/^\s*\[TOC\]\s*$/m)
return match ? match.index : undefined
},
tokenizer(src) {
const match = /^\[TOC\]/.exec(src)
if (match) {
return {
type: `toc`,
raw: match[0],
}
}
},
renderer() {
if (!headings.length)
return ``
let html = `<nav class="markdown-toc"><ul class="toc-ul toc-level-1 pl-4 border-l ml-2">`
let lastDepth = 1
headings.forEach(({ text, depth, index }) => {
if (depth > lastDepth) {
for (let i = lastDepth + 1; i <= depth; i++) {
html += `<ul class="toc-ul toc-level-${i} pl-4 border-l ml-2">`
}
}
else if (depth < lastDepth) {
for (let i = lastDepth; i > depth; i--) {
html += `</ul>`
}
}
html += `<li class="toc-li toc-level-${depth} mb-1"><a class="text-gray-700 hover:text-blue-600 underline transition-colors" href="#${index}">${text}</a></li>`
lastDepth = depth
})
for (let i = lastDepth; i > 1; i--) {
html += `</ul>`
}
html += `</ul></nav>`
firstToken = true
return html
},
},
],
}
}
@@ -0,0 +1,809 @@
#!/usr/bin/env npx tsx
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import frontMatter from "front-matter";
import hljs from "highlight.js/lib/core";
import { marked, type RendererObject, type Tokens } from "marked";
import readingTime, { type ReadTimeResults } from "reading-time";
import {
markedAlert,
markedFootnotes,
markedInfographic,
markedMarkup,
markedPlantUML,
markedRuby,
markedSlider,
markedToc,
MDKatex,
} from "./extensions/index.js";
import {
COMMON_LANGUAGES,
highlightAndFormatCode,
} from "./utils/languages.js";
type ThemeName = string;
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
const THEME_DIR = path.resolve(SCRIPT_DIR, "themes");
const EXTERNAL_THEME_CONFIG_PATH =
process.env.MD_THEME_CONFIG_PATH
|| "/Users/jimliu/GitHub/md/packages/shared/src/configs/theme.ts";
const EXTERNAL_THEME_DIR =
process.env.MD_THEME_DIR
|| path.resolve(path.dirname(EXTERNAL_THEME_CONFIG_PATH), "theme-css");
const FALLBACK_THEMES: ThemeName[] = ["default", "grace", "simple"];
const DEFAULT_STYLE = {
primaryColor: "#0F4C81",
fontFamily:
"-apple-system-font,BlinkMacSystemFont, Helvetica Neue, PingFang SC, Hiragino Sans GB , Microsoft YaHei UI , Microsoft YaHei ,Arial,sans-serif",
fontSize: "16px",
foreground: "0 0% 3.9%",
blockquoteBackground: "#f7f7f7",
};
Object.entries(COMMON_LANGUAGES).forEach(([name, lang]) => {
hljs.registerLanguage(name, lang);
});
export { hljs };
function stripOutputScope(cssContent: string): string {
let css = cssContent;
css = css.replace(/#output\s*\{/g, "body {");
css = css.replace(/#output\s+/g, "");
css = css.replace(/^#output\s*/gm, "");
return css;
}
function discoverThemesFromDir(dir: string): string[] {
if (!fs.existsSync(dir)) {
return [];
}
return fs
.readdirSync(dir)
.filter((name) => name.endsWith(".css"))
.map((name) => name.replace(/\.css$/i, ""))
.filter((name) => name.toLowerCase() !== "base");
}
function readThemeNamesFromConfig(configPath: string): string[] {
if (!fs.existsSync(configPath)) {
return [];
}
const content = fs.readFileSync(configPath, "utf-8");
const match = content.match(/themeOptionsMap\s*=\s*\{([\s\S]*?)\n\}/);
if (!match) {
return [];
}
return Array.from(match[1].matchAll(/^\s*([a-zA-Z0-9_-]+)\s*:/gm)).map(
(item) => item[1]!
);
}
function resolveThemeNames(): ThemeName[] {
const localThemes = discoverThemesFromDir(THEME_DIR);
const externalThemes = discoverThemesFromDir(EXTERNAL_THEME_DIR);
const configThemes = readThemeNamesFromConfig(EXTERNAL_THEME_CONFIG_PATH);
const combined = new Set<ThemeName>([
...localThemes,
...externalThemes,
...configThemes,
]);
const resolved = Array.from(combined).filter((name) =>
fs.existsSync(path.join(THEME_DIR, `${name}.css`))
|| fs.existsSync(path.join(EXTERNAL_THEME_DIR, `${name}.css`))
);
return resolved.length ? resolved : FALLBACK_THEMES;
}
const THEME_NAMES: ThemeName[] = resolveThemeNames();
marked.setOptions({
breaks: true,
});
marked.use(markedSlider());
interface IOpts {
legend?: string;
citeStatus?: boolean;
countStatus?: boolean;
isMacCodeBlock?: boolean;
isShowLineNumber?: boolean;
themeMode?: "light" | "dark";
}
interface RendererAPI {
reset: (newOpts: Partial<IOpts>) => void;
setOptions: (newOpts: Partial<IOpts>) => void;
getOpts: () => IOpts;
parseFrontMatterAndContent: (markdown: string) => {
yamlData: Record<string, any>;
markdownContent: string;
readingTime: ReadTimeResults;
};
buildReadingTime: (reading: ReadTimeResults) => string;
buildFootnotes: () => string;
buildAddition: () => string;
createContainer: (html: string) => string;
}
interface ParseResult {
yamlData: Record<string, any>;
markdownContent: string;
readingTime: ReadTimeResults;
}
function escapeHtml(text: string): string {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;")
.replace(/`/g, "&#96;");
}
function buildAddition(): string {
return `
<style>
.preview-wrapper pre::before {
position: absolute;
top: 0;
right: 0;
color: #ccc;
text-align: center;
font-size: 0.8em;
padding: 5px 10px 0;
line-height: 15px;
height: 15px;
font-weight: 600;
}
</style>
`;
}
function buildFootnoteArray(footnotes: [number, string, string][]): string {
return footnotes
.map(([index, title, link]) =>
link === title
? `<code style="font-size: 90%; opacity: 0.6;">[${index}]</code>: <i style="word-break: break-all">${title}</i><br/>`
: `<code style="font-size: 90%; opacity: 0.6;">[${index}]</code> ${title}: <i style="word-break: break-all">${link}</i><br/>`
)
.join("\n");
}
function transform(legend: string, text: string | null, title: string | null): string {
const options = legend.split("-");
for (const option of options) {
if (option === "alt" && text) {
return text;
}
if (option === "title" && title) {
return title;
}
}
return "";
}
const macCodeSvg = `
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" x="0px" y="0px" width="45px" height="13px" viewBox="0 0 450 130">
<ellipse cx="50" cy="65" rx="50" ry="52" stroke="rgb(220,60,54)" stroke-width="2" fill="rgb(237,108,96)" />
<ellipse cx="225" cy="65" rx="50" ry="52" stroke="rgb(218,151,33)" stroke-width="2" fill="rgb(247,193,81)" />
<ellipse cx="400" cy="65" rx="50" ry="52" stroke="rgb(27,161,37)" stroke-width="2" fill="rgb(100,200,86)" />
</svg>
`.trim();
function parseFrontMatterAndContent(markdownText: string): ParseResult {
try {
const parsed = frontMatter(markdownText);
const yamlData = parsed.attributes;
const markdownContent = parsed.body;
const readingTimeResult = readingTime(markdownContent);
return {
yamlData: yamlData as Record<string, any>,
markdownContent,
readingTime: readingTimeResult,
};
} catch (error) {
console.error("Error parsing front-matter:", error);
return {
yamlData: {},
markdownContent: markdownText,
readingTime: readingTime(markdownText),
};
}
}
export function initRenderer(opts: IOpts = {}): RendererAPI {
const footnotes: [number, string, string][] = [];
let footnoteIndex = 0;
let codeIndex = 0;
const listOrderedStack: boolean[] = [];
const listCounters: number[] = [];
const isBrowser = typeof window !== "undefined";
function getOpts(): IOpts {
return opts;
}
function styledContent(styleLabel: string, content: string, tagName?: string): string {
const tag = tagName ?? styleLabel;
const className = `${styleLabel.replace(/_/g, "-")}`;
const headingAttr = /^h\d$/.test(tag) ? " data-heading=\"true\"" : "";
return `<${tag} class="${className}"${headingAttr}>${content}</${tag}>`;
}
function addFootnote(title: string, link: string): number {
const existingFootnote = footnotes.find(([, , existingLink]) => existingLink === link);
if (existingFootnote) {
return existingFootnote[0];
}
footnotes.push([++footnoteIndex, title, link]);
return footnoteIndex;
}
function reset(newOpts: Partial<IOpts>): void {
footnotes.length = 0;
footnoteIndex = 0;
setOptions(newOpts);
}
function setOptions(newOpts: Partial<IOpts>): void {
opts = { ...opts, ...newOpts };
marked.use(markedAlert());
if (isBrowser) {
marked.use(MDKatex({ nonStandard: true }, true));
}
marked.use(markedMarkup());
marked.use(markedInfographic({ themeMode: opts.themeMode }));
}
function buildReadingTime(readingTimeResult: ReadTimeResults): string {
if (!opts.countStatus) {
return "";
}
if (!readingTimeResult.words) {
return "";
}
return `
<blockquote class="md-blockquote">
<p class="md-blockquote-p">字数 ${readingTimeResult?.words},阅读大约需 ${Math.ceil(readingTimeResult?.minutes)} 分钟</p>
</blockquote>
`;
}
const buildFootnotes = () => {
if (!footnotes.length) {
return "";
}
return (
styledContent("h4", "引用链接")
+ styledContent("footnotes", buildFootnoteArray(footnotes), "p")
);
};
const renderer: RendererObject = {
heading({ tokens, depth }: Tokens.Heading) {
const text = this.parser.parseInline(tokens);
const tag = `h${depth}`;
return styledContent(tag, text);
},
paragraph({ tokens }: Tokens.Paragraph): string {
const text = this.parser.parseInline(tokens);
const isFigureImage = text.includes("<figure") && text.includes("<img");
const isEmpty = text.trim() === "";
if (isFigureImage || isEmpty) {
return text;
}
return styledContent("p", text);
},
blockquote({ tokens }: Tokens.Blockquote): string {
const text = this.parser.parse(tokens);
return styledContent("blockquote", text);
},
code({ text, lang = "" }: Tokens.Code): string {
if (lang.startsWith("mermaid")) {
if (isBrowser) {
clearTimeout(codeIndex as any);
codeIndex = setTimeout(async () => {
const windowRef = typeof window !== "undefined" ? (window as any) : undefined;
if (windowRef && windowRef.mermaid) {
const mermaid = windowRef.mermaid;
await mermaid.run();
} else {
const mermaid = await import("mermaid");
await mermaid.default.run();
}
}, 0) as any as number;
}
return `<pre class="mermaid">${text}</pre>`;
}
const langText = lang.split(" ")[0];
const isLanguageRegistered = hljs.getLanguage(langText);
const language = isLanguageRegistered ? langText : "plaintext";
const highlighted = highlightAndFormatCode(
text,
language,
hljs,
!!opts.isShowLineNumber
);
const span = `<span class="mac-sign" style="padding: 10px 14px 0;">${macCodeSvg}</span>`;
let pendingAttr = "";
if (!isLanguageRegistered && langText !== "plaintext") {
const escapedText = text.replace(/"/g, "&quot;");
pendingAttr = ` data-language-pending="${langText}" data-raw-code="${escapedText}" data-show-line-number="${opts.isShowLineNumber}"`;
}
const code = `<code class="language-${lang}"${pendingAttr}>${highlighted}</code>`;
return `<pre class="hljs code__pre">${span}${code}</pre>`;
},
codespan({ text }: Tokens.Codespan): string {
const escapedText = escapeHtml(text);
return styledContent("codespan", escapedText, "code");
},
list({ ordered, items, start = 1 }: Tokens.List) {
listOrderedStack.push(ordered);
listCounters.push(Number(start));
const html = items.map((item) => this.listitem(item)).join("");
listOrderedStack.pop();
listCounters.pop();
return styledContent(ordered ? "ol" : "ul", html);
},
listitem(token: Tokens.ListItem) {
const ordered = listOrderedStack[listOrderedStack.length - 1];
const idx = listCounters[listCounters.length - 1]!;
listCounters[listCounters.length - 1] = idx + 1;
const prefix = ordered ? `${idx}. ` : "• ";
let content: string;
try {
content = this.parser.parseInline(token.tokens);
} catch {
content = this.parser
.parse(token.tokens)
.replace(/^<p(?:\s[^>]*)?>([\s\S]*?)<\/p>/, "$1");
}
return styledContent("listitem", `${prefix}${content}`, "li");
},
image({ href, title, text }: Tokens.Image): string {
const newText = opts.legend ? transform(opts.legend, text, title) : "";
const subText = newText ? styledContent("figcaption", newText) : "";
const titleAttr = title ? ` title="${title}"` : "";
return `<figure><img src="${href}"${titleAttr} alt="${text}"/>${subText}</figure>`;
},
link({ href, title, text, tokens }: Tokens.Link): string {
const parsedText = this.parser.parseInline(tokens);
if (/^https?:\/\/mp\.weixin\.qq\.com/.test(href)) {
return `<a href="${href}" title="${title || text}">${parsedText}</a>`;
}
if (href === text) {
return parsedText;
}
if (opts.citeStatus) {
const ref = addFootnote(title || text, href);
return `<a href="${href}" title="${title || text}">${parsedText}<sup>[${ref}]</sup></a>`;
}
return `<a href="${href}" title="${title || text}">${parsedText}</a>`;
},
strong({ tokens }: Tokens.Strong): string {
return styledContent("strong", this.parser.parseInline(tokens));
},
em({ tokens }: Tokens.Em): string {
return styledContent("em", this.parser.parseInline(tokens));
},
table({ header, rows }: Tokens.Table): string {
const headerRow = header
.map((cell) => {
const text = this.parser.parseInline(cell.tokens);
return styledContent("th", text);
})
.join("");
const body = rows
.map((row) => {
const rowContent = row.map((cell) => this.tablecell(cell)).join("");
return styledContent("tr", rowContent);
})
.join("");
return `
<section style="max-width: 100%; overflow: auto">
<table class="preview-table">
<thead>${headerRow}</thead>
<tbody>${body}</tbody>
</table>
</section>
`;
},
tablecell(token: Tokens.TableCell): string {
const text = this.parser.parseInline(token.tokens);
return styledContent("td", text);
},
hr(_: Tokens.Hr): string {
return styledContent("hr", "");
},
};
marked.use({ renderer });
marked.use(markedMarkup());
marked.use(markedToc());
marked.use(markedSlider());
marked.use(markedAlert({}));
if (isBrowser) {
marked.use(MDKatex({ nonStandard: true }, true));
}
marked.use(markedFootnotes());
marked.use(
markedPlantUML({
inlineSvg: isBrowser,
})
);
marked.use(markedInfographic());
marked.use(markedRuby());
return {
buildAddition,
buildFootnotes,
setOptions,
reset,
parseFrontMatterAndContent,
buildReadingTime,
createContainer(content: string) {
return styledContent("container", content, "section");
},
getOpts,
};
}
function printUsage(): void {
console.error(
[
"Usage:",
" npx tsx src/md/render.ts <markdown_file> [--theme <name>]",
"",
"Options:",
` --theme Theme name (${THEME_NAMES.join(", ")})`,
].join("\n")
);
}
function parseArgs(argv: string[]): CliOptions | null {
let inputPath = "";
let theme: ThemeName = "default";
let keepTitle = false;
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (!arg.startsWith("--") && !inputPath) {
inputPath = arg;
continue;
}
if (arg === "--theme") {
theme = (argv[i + 1] || "") as ThemeName;
i += 1;
continue;
}
if (arg.startsWith("--theme=")) {
theme = arg.slice("--theme=".length) as ThemeName;
continue;
}
if (arg === "--keep-title") {
keepTitle = true;
continue;
}
if (arg === "--help" || arg === "-h") {
return null;
}
console.error(`Unknown argument: ${arg}`);
return null;
}
if (!inputPath) {
return null;
}
if (!THEME_NAMES.includes(theme)) {
console.error(`Unknown theme: ${theme}`);
return null;
}
return {
inputPath,
theme,
keepTitle,
};
}
interface CliOptions {
inputPath: string;
theme: ThemeName;
keepTitle: boolean;
}
function renderMarkdown(raw: string, renderer: RendererAPI): {
html: string;
readingTime: ReadTimeResults;
} {
const { markdownContent, readingTime: readingTimeResult } =
renderer.parseFrontMatterAndContent(raw);
const html = marked.parse(markdownContent) as string;
return { html, readingTime: readingTimeResult };
}
function postProcessHtml(
baseHtml: string,
reading: ReadTimeResults,
renderer: RendererAPI
): string {
let html = baseHtml;
html = renderer.buildReadingTime(reading) + html;
html += renderer.buildFootnotes();
html += renderer.buildAddition();
html += `
<style>
.hljs.code__pre > .mac-sign {
display: ${renderer.getOpts().isMacCodeBlock ? "flex" : "none"};
}
</style>
`;
html += `
<style>
h2 strong {
color: inherit !important;
}
</style>
`;
return renderer.createContainer(html);
}
function formatTimestamp(date = new Date()): string {
const pad = (value: number) => String(value).padStart(2, "0");
return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(
date.getDate()
)}${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`;
}
function ensureMarkdownPath(inputPath: string): void {
if (!inputPath.toLowerCase().endsWith(".md")) {
throw new Error("Input file must end with .md");
}
}
function loadThemeCss(theme: ThemeName): {
baseCss: string;
themeCss: string;
} {
const basePathCandidates = [
path.join(THEME_DIR, "base.css"),
path.join(EXTERNAL_THEME_DIR, "base.css"),
];
const themePathCandidates = [
path.join(THEME_DIR, `${theme}.css`),
path.join(EXTERNAL_THEME_DIR, `${theme}.css`),
];
const basePath = basePathCandidates.find((candidate) =>
fs.existsSync(candidate)
);
const themePath = themePathCandidates.find((candidate) =>
fs.existsSync(candidate)
);
if (!basePath) {
throw new Error(
`Missing base CSS. Checked: ${basePathCandidates.join(", ")}`
);
}
if (!themePath) {
throw new Error(
`Missing theme CSS for "${theme}". Checked: ${themePathCandidates.join(", ")}`
);
}
return {
baseCss: fs.readFileSync(basePath, "utf-8"),
themeCss: fs.readFileSync(themePath, "utf-8"),
};
}
function buildCss(baseCss: string, themeCss: string): string {
const variables = `
:root {
--md-primary-color: ${DEFAULT_STYLE.primaryColor};
--md-font-family: ${DEFAULT_STYLE.fontFamily};
--md-font-size: ${DEFAULT_STYLE.fontSize};
--foreground: ${DEFAULT_STYLE.foreground};
--blockquote-background: ${DEFAULT_STYLE.blockquoteBackground};
}
body {
margin: 0;
padding: 24px;
background: #ffffff;
}
#output {
max-width: 860px;
margin: 0 auto;
}
`.trim();
return [variables, baseCss, themeCss].join("\n\n");
}
function normalizeThemeCss(css: string): string {
return stripOutputScope(css);
}
function buildHtmlDocument(title: string, css: string, html: string): string {
return [
"<!doctype html>",
"<html>",
"<head>",
' <meta charset="utf-8" />',
' <meta name="viewport" content="width=device-width, initial-scale=1" />',
` <title>${title}</title>`,
` <style>${css}</style>`,
"</head>",
"<body>",
' <div id="output">',
html,
" </div>",
"</body>",
"</html>",
].join("\n");
}
async function inlineCss(html: string): Promise<string> {
try {
const { default: juice } = await import("juice");
return juice(html, {
inlinePseudoElements: true,
preserveImportant: true,
resolveCSSVariables: false,
});
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
throw new Error(
`Missing dependency "juice" for CSS inlining. Install it first (e.g. "bun add juice" or "npm add juice"). Original error: ${detail}`
);
}
}
function normalizeCssText(cssText: string): string {
return cssText
.replace(/var\(--md-primary-color\)/g, DEFAULT_STYLE.primaryColor)
.replace(/var\(--md-font-family\)/g, DEFAULT_STYLE.fontFamily)
.replace(/var\(--md-font-size\)/g, DEFAULT_STYLE.fontSize)
.replace(/var\(--blockquote-background\)/g, DEFAULT_STYLE.blockquoteBackground)
.replace(/hsl\(var\(--foreground\)\)/g, "#3f3f3f")
.replace(/--md-primary-color:\s*[^;"']+;?/g, "")
.replace(/--md-font-family:\s*[^;"']+;?/g, "")
.replace(/--md-font-size:\s*[^;"']+;?/g, "")
.replace(/--blockquote-background:\s*[^;"']+;?/g, "")
.replace(/--foreground:\s*[^;"']+;?/g, "");
}
function normalizeInlineCss(html: string): string {
let output = html;
output = output.replace(
/<style([^>]*)>([\s\S]*?)<\/style>/gi,
(_match, attrs: string, cssText: string) =>
`<style${attrs}>${normalizeCssText(cssText)}</style>`
);
output = output.replace(
/style="([^"]*)"/gi,
(_match, cssText: string) => `style="${normalizeCssText(cssText)}"`
);
output = output.replace(
/style='([^']*)'/gi,
(_match, cssText: string) => `style='${normalizeCssText(cssText)}'`
);
return output;
}
function modifyHtmlStructure(htmlString: string): string {
let output = htmlString;
const pattern =
/<li([^>]*)>([\s\S]*?)(<ul[\s\S]*?<\/ul>|<ol[\s\S]*?<\/ol>)<\/li>/i;
while (pattern.test(output)) {
output = output.replace(pattern, "<li$1>$2</li>$3");
}
return output;
}
function removeFirstHeading(html: string): string {
return html.replace(/<h[12][^>]*>[\s\S]*?<\/h[12]>/, "");
}
async function main(): Promise<void> {
const options = parseArgs(process.argv.slice(2));
if (!options) {
printUsage();
process.exit(1);
}
const inputPath = path.resolve(process.cwd(), options.inputPath);
ensureMarkdownPath(inputPath);
if (!fs.existsSync(inputPath)) {
console.error(`File not found: ${inputPath}`);
process.exit(1);
}
const outputPath = path.resolve(
process.cwd(),
options.inputPath.replace(/\.md$/i, ".html")
);
const { baseCss, themeCss } = loadThemeCss(options.theme);
const css = normalizeThemeCss(buildCss(baseCss, themeCss));
const markdown = fs.readFileSync(inputPath, "utf-8");
const renderer = initRenderer({});
const { html: baseHtml, readingTime: readingTimeResult } = renderMarkdown(
markdown,
renderer
);
let content = postProcessHtml(baseHtml, readingTimeResult, renderer);
if (!options.keepTitle) {
content = removeFirstHeading(content);
}
const title = path.basename(outputPath, ".html");
const html = buildHtmlDocument(title, css, content);
const inlinedHtml = normalizeInlineCss(await inlineCss(html));
const finalHtml = modifyHtmlStructure(inlinedHtml);
let backupPath = "";
if (fs.existsSync(outputPath)) {
backupPath = `${outputPath}.bak-${formatTimestamp()}`;
fs.renameSync(outputPath, backupPath);
}
fs.writeFileSync(outputPath, finalHtml, "utf-8");
if (backupPath) {
console.log(`Backup created: ${backupPath}`);
}
console.log(`HTML written: ${outputPath}`);
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
@@ -0,0 +1,34 @@
/**
* MD 基础主题样式
* 包含所有元素的基础样式和 CSS 变量定义
*/
/* ==================== 容器样式 ==================== */
section,
container {
font-family: var(--md-font-family);
font-size: var(--md-font-size);
line-height: 1.75;
text-align: left;
}
/* 确保 #output 容器应用基础样式 */
#output {
font-family: var(--md-font-family);
font-size: var(--md-font-size);
line-height: 1.75;
text-align: left;
}
/* ==================== Global resets ==================== */
blockquote {
margin-top: 0;
margin-right: 0;
margin-bottom: 0;
margin-left: 0;
}
/* 去除第一个元素的 margin-top */
#output section > :first-child {
margin-top: 0 !important;
}
@@ -0,0 +1,433 @@
/**
* MD 默认主题(经典主题)
* 按 Alt/Option + Shift + F 可格式化
* 如需使用主题色,请使用 var(--md-primary-color) 代替颜色值
*/
/* ==================== 一级标题 ==================== */
h1 {
display: table;
padding: 0 1em;
border-bottom: 2px solid var(--md-primary-color);
margin: 2em auto 1em;
color: hsl(var(--foreground));
font-size: calc(var(--md-font-size) * 1.2);
font-weight: bold;
text-align: center;
}
/* ==================== 二级标题 ==================== */
h2 {
display: table;
padding: 0 0.2em;
margin: 4em auto 2em;
color: #fff;
background: var(--md-primary-color);
font-size: calc(var(--md-font-size) * 1.2);
font-weight: bold;
text-align: center;
}
/* ==================== 三级标题 ==================== */
h3 {
padding-left: 8px;
border-left: 3px solid var(--md-primary-color);
margin: 2em 8px 0.75em 0;
color: hsl(var(--foreground));
font-size: calc(var(--md-font-size) * 1.1);
font-weight: bold;
line-height: 1.2;
}
/* ==================== 四级标题 ==================== */
h4 {
margin: 2em 8px 0.5em;
color: var(--md-primary-color);
font-size: calc(var(--md-font-size) * 1);
font-weight: bold;
}
/* ==================== 五级标题 ==================== */
h5 {
margin: 1.5em 8px 0.5em;
color: var(--md-primary-color);
font-size: calc(var(--md-font-size) * 1);
font-weight: bold;
}
/* ==================== 六级标题 ==================== */
h6 {
margin: 1.5em 8px 0.5em;
font-size: calc(var(--md-font-size) * 1);
color: var(--md-primary-color);
}
/* ==================== 段落 ==================== */
p {
margin: 1.5em 8px;
letter-spacing: 0.1em;
color: hsl(var(--foreground));
}
/* ==================== 引用块 ==================== */
blockquote {
font-style: normal;
padding: 1em;
border-left: 4px solid var(--md-primary-color);
border-radius: 6px;
color: hsl(var(--foreground));
background: var(--blockquote-background);
margin-bottom: 1em;
}
blockquote > p {
display: block;
font-size: 1em;
letter-spacing: 0.1em;
color: hsl(var(--foreground));
margin: 0;
}
/* ==================== GFM 警告块 ==================== */
.alert-title-note,
.alert-title-tip,
.alert-title-info,
.alert-title-important,
.alert-title-warning,
.alert-title-caution,
.alert-title-abstract,
.alert-title-summary,
.alert-title-tldr,
.alert-title-todo,
.alert-title-success,
.alert-title-done,
.alert-title-question,
.alert-title-help,
.alert-title-faq,
.alert-title-failure,
.alert-title-fail,
.alert-title-missing,
.alert-title-danger,
.alert-title-error,
.alert-title-bug,
.alert-title-example,
.alert-title-quote,
.alert-title-cite {
display: flex;
align-items: center;
gap: 0.5em;
margin-bottom: 0.5em;
}
.alert-title-note {
color: #478be6;
}
.alert-title-tip {
color: #57ab5a;
}
.alert-title-info {
color: #93c5fd;
}
.alert-title-important {
color: #986ee2;
}
.alert-title-warning {
color: #c69026;
}
.alert-title-caution {
color: #e5534b;
}
/* Obsidian-style callout colors */
.alert-title-abstract,
.alert-title-summary,
.alert-title-tldr {
color: #00bfff;
}
.alert-title-todo {
color: #478be6;
}
.alert-title-success,
.alert-title-done {
color: #57ab5a;
}
.alert-title-question,
.alert-title-help,
.alert-title-faq {
color: #c69026;
}
.alert-title-failure,
.alert-title-fail,
.alert-title-missing {
color: #e5534b;
}
.alert-title-danger,
.alert-title-error {
color: #e5534b;
}
.alert-title-bug {
color: #e5534b;
}
.alert-title-example {
color: #986ee2;
}
.alert-title-quote,
.alert-title-cite {
color: #9ca3af;
}
/* GFM Alert SVG 图标颜色 */
.alert-icon-note {
fill: #478be6;
}
.alert-icon-tip {
fill: #57ab5a;
}
.alert-icon-info {
fill: #93c5fd;
}
.alert-icon-important {
fill: #986ee2;
}
.alert-icon-warning {
fill: #c69026;
}
.alert-icon-caution {
fill: #e5534b;
}
/* Obsidian-style callout icon colors */
.alert-icon-abstract,
.alert-icon-summary,
.alert-icon-tldr {
fill: #00bfff;
}
.alert-icon-todo {
fill: #478be6;
}
.alert-icon-success,
.alert-icon-done {
fill: #57ab5a;
}
.alert-icon-question,
.alert-icon-help,
.alert-icon-faq {
fill: #c69026;
}
.alert-icon-failure,
.alert-icon-fail,
.alert-icon-missing {
fill: #e5534b;
}
.alert-icon-danger,
.alert-icon-error {
fill: #e5534b;
}
.alert-icon-bug {
fill: #e5534b;
}
.alert-icon-example {
fill: #986ee2;
}
.alert-icon-quote,
.alert-icon-cite {
fill: #9ca3af;
}
/* ==================== 代码块 ==================== */
pre.code__pre,
.hljs.code__pre {
font-size: 90%;
overflow-x: auto;
border-radius: 8px;
padding: 0 !important;
line-height: 1.5;
margin: 10px 8px;
}
/* ==================== 图片 ==================== */
img {
display: block;
max-width: 100%;
margin: 0.1em auto 0.5em;
border-radius: 4px;
}
/* ==================== 列表 ==================== */
ol {
padding-left: 1em;
margin-left: 0;
color: hsl(var(--foreground));
}
ul {
list-style: circle;
padding-left: 1em;
margin-left: 0;
color: hsl(var(--foreground));
}
li {
display: block;
margin: 0.2em 8px;
color: hsl(var(--foreground));
}
/* ==================== 脚注 ==================== */
/* footnotes 在 buildFootnotes() 中渲染为 <p> 标签 */
p.footnotes {
margin: 0.5em 8px;
font-size: 80%;
color: hsl(var(--foreground));
}
/* ==================== 图表 ==================== */
figure {
margin: 1.5em 8px;
color: hsl(var(--foreground));
}
figcaption,
.md-figcaption {
text-align: center;
color: #888;
font-size: 0.8em;
}
/* ==================== 分隔线 ==================== */
hr {
border-style: solid;
border-width: 2px 0 0;
border-color: rgba(0, 0, 0, 0.1);
-webkit-transform-origin: 0 0;
-webkit-transform: scale(1, 0.5);
transform-origin: 0 0;
transform: scale(1, 0.5);
height: 0.4em;
margin: 1.5em 0;
}
/* ==================== 行内代码 ==================== */
code {
font-size: 90%;
color: #d14;
background: rgba(27, 31, 35, 0.05);
padding: 3px 5px;
border-radius: 4px;
}
/* 代码块内的 code 标签需要特殊处理(覆盖行内 code 样式) */
pre.code__pre > code,
.hljs.code__pre > code {
display: -webkit-box;
padding: 0.5em 1em 1em;
overflow-x: auto;
text-indent: 0;
color: inherit;
background: none;
white-space: nowrap;
margin: 0;
}
/* ==================== 强调 ==================== */
em {
font-style: italic;
font-size: inherit;
}
/* ==================== 链接 ==================== */
a {
color: #576b95;
text-decoration: none;
}
/* ==================== 粗体 ==================== */
strong {
color: var(--md-primary-color);
font-weight: bold;
font-size: inherit;
}
/* ==================== 表格 ==================== */
table {
color: hsl(var(--foreground));
}
thead {
font-weight: bold;
color: hsl(var(--foreground));
}
th {
border: 1px solid #dfdfdf;
padding: 0.25em 0.5em;
color: hsl(var(--foreground));
word-break: keep-all;
background: rgba(0, 0, 0, 0.05);
}
td {
border: 1px solid #dfdfdf;
padding: 0.25em 0.5em;
color: hsl(var(--foreground));
word-break: keep-all;
}
/* ==================== KaTeX 公式 ==================== */
.katex-inline {
max-width: 100%;
overflow-x: auto;
}
.katex-block {
max-width: 100%;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
padding: 0.5em 0;
text-align: center;
}
/* ==================== 标记高亮 ==================== */
.markup-highlight {
background-color: var(--md-primary-color);
padding: 2px 4px;
border-radius: 2px;
color: #fff;
}
.markup-underline {
text-decoration: underline;
text-decoration-color: var(--md-primary-color);
}
.markup-wavyline {
text-decoration: underline wavy;
text-decoration-color: var(--md-primary-color);
text-decoration-thickness: 2px;
}
@@ -0,0 +1,136 @@
/**
* MD 优雅主题 (@brzhang)
* 在默认主题基础上添加优雅的视觉效果
*/
/* ==================== 标题样式 ==================== */
h1 {
padding: 0.5em 1em;
border-bottom: 2px solid var(--md-primary-color);
font-size: calc(var(--md-font-size) * 1.4);
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.1);
}
h2 {
padding: 0.3em 1em;
border-radius: 8px;
font-size: calc(var(--md-font-size) * 1.3);
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
h3 {
padding-left: 12px;
font-size: calc(var(--md-font-size) * 1.2);
border-left: 4px solid var(--md-primary-color);
border-bottom: 1px dashed var(--md-primary-color);
}
h4 {
font-size: calc(var(--md-font-size) * 1.1);
}
h5 {
font-size: var(--md-font-size);
}
h6 {
font-size: var(--md-font-size);
}
/* ==================== 引用块 ==================== */
blockquote {
font-style: italic;
padding: 1em 1em 1em 2em;
border-left: 4px solid var(--md-primary-color);
border-radius: 6px;
color: rgba(0, 0, 0, 0.6);
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
margin-bottom: 1em;
}
.markdown-alert {
font-style: italic;
}
/* ==================== 代码块 ==================== */
pre.code__pre,
.hljs.code__pre {
box-shadow: inset 0 0 10px rgba(0, 0, 0, 0.05);
}
pre.code__pre > code,
.hljs.code__pre > code {
font-family:
'Fira Code',
Menlo,
Operator Mono,
Consolas,
Monaco,
monospace;
}
/* ==================== 图片 ==================== */
img {
border-radius: 8px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
figcaption,
.md-figcaption {
text-align: center;
color: #888;
font-size: 0.8em;
}
/* ==================== 列表 ==================== */
ol {
padding-left: 1.5em;
}
ul {
list-style: none;
padding-left: 1.5em;
}
li {
margin: 0.5em 8px;
}
/* ==================== 分隔线 ==================== */
hr {
height: 1px;
border: none;
margin: 2em 0;
background: linear-gradient(to right, rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.1), rgba(0, 0, 0, 0));
}
/* ==================== 表格 ==================== */
table {
border-collapse: separate;
border-spacing: 0;
border-radius: 8px;
margin: 1em 8px;
color: hsl(var(--foreground));
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
thead {
color: #fff;
}
td {
padding: 0.5em 1em;
}
/* ==================== 强调 ==================== */
em {
font-style: italic;
font-size: inherit;
}
/* ==================== 链接 ==================== */
a {
color: #576b95;
text-decoration: none;
}
@@ -0,0 +1,129 @@
/**
* MD 简洁主题 (@okooo5km)
* 简洁现代的设计风格
*/
/* ==================== 标题样式 ==================== */
h1 {
padding: 0.5em 1em;
font-size: calc(var(--md-font-size) * 1.4);
text-shadow: 1px 1px 3px rgba(0, 0, 0, 0.05);
}
h2 {
padding: 0.3em 1.2em;
font-size: calc(var(--md-font-size) * 1.3);
border-radius: 8px 24px 8px 24px;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.06);
}
h3 {
padding-left: 12px;
font-size: calc(var(--md-font-size) * 1.2);
border-radius: 6px;
line-height: 2.4em;
border-left: 4px solid var(--md-primary-color);
border-right: 1px solid color-mix(in srgb, var(--md-primary-color) 10%, transparent);
border-bottom: 1px solid color-mix(in srgb, var(--md-primary-color) 10%, transparent);
border-top: 1px solid color-mix(in srgb, var(--md-primary-color) 10%, transparent);
background: color-mix(in srgb, var(--md-primary-color) 8%, transparent);
}
h4 {
font-size: calc(var(--md-font-size) * 1.1);
border-radius: 6px;
}
h5 {
font-size: var(--md-font-size);
border-radius: 6px;
}
h6 {
font-size: var(--md-font-size);
border-radius: 6px;
}
/* ==================== 引用块 ==================== */
blockquote {
font-style: italic;
padding: 1em 1em 1em 2em;
color: rgba(0, 0, 0, 0.6);
border-bottom: 0.2px solid rgba(0, 0, 0, 0.04);
border-top: 0.2px solid rgba(0, 0, 0, 0.04);
border-right: 0.2px solid rgba(0, 0, 0, 0.04);
}
/* GFM Alert 样式覆盖 */
.markdown-alert-note,
.markdown-alert-tip,
.markdown-alert-info,
.markdown-alert-important,
.markdown-alert-warning,
.markdown-alert-caution {
font-style: italic;
}
/* ==================== 代码块 ==================== */
pre.code__pre,
.hljs.code__pre {
border: 1px solid rgba(0, 0, 0, 0.04);
}
pre.code__pre > code,
.hljs.code__pre > code {
font-family:
'Fira Code',
Menlo,
Operator Mono,
Consolas,
Monaco,
monospace;
}
/* ==================== 图片 ==================== */
img {
border-radius: 8px;
border: 1px solid rgba(0, 0, 0, 0.04);
}
figcaption,
.md-figcaption {
text-align: center;
color: #888;
font-size: 0.8em;
}
/* ==================== 列表 ==================== */
ol {
padding-left: 1.5em;
}
ul {
list-style: none;
padding-left: 1.5em;
}
li {
margin: 0.5em 8px;
}
/* ==================== 分隔线 ==================== */
hr {
height: 1px;
border: none;
margin: 2em 0;
background: linear-gradient(to right, rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.1), rgba(0, 0, 0, 0));
}
/* ==================== 强调 ==================== */
em {
font-style: italic;
font-size: inherit;
}
/* ==================== 链接 ==================== */
a {
color: #576b95;
text-decoration: none;
}
@@ -0,0 +1,238 @@
import type { LanguageFn } from 'highlight.js'
import bash from 'highlight.js/lib/languages/bash'
import c from 'highlight.js/lib/languages/c'
import cpp from 'highlight.js/lib/languages/cpp'
import csharp from 'highlight.js/lib/languages/csharp'
import css from 'highlight.js/lib/languages/css'
import diff from 'highlight.js/lib/languages/diff'
import go from 'highlight.js/lib/languages/go'
import graphql from 'highlight.js/lib/languages/graphql'
import ini from 'highlight.js/lib/languages/ini'
import java from 'highlight.js/lib/languages/java'
import javascript from 'highlight.js/lib/languages/javascript'
import json from 'highlight.js/lib/languages/json'
import kotlin from 'highlight.js/lib/languages/kotlin'
import less from 'highlight.js/lib/languages/less'
import lua from 'highlight.js/lib/languages/lua'
import makefile from 'highlight.js/lib/languages/makefile'
import markdown from 'highlight.js/lib/languages/markdown'
import objectivec from 'highlight.js/lib/languages/objectivec'
import perl from 'highlight.js/lib/languages/perl'
import php from 'highlight.js/lib/languages/php'
import phpTemplate from 'highlight.js/lib/languages/php-template'
import plaintext from 'highlight.js/lib/languages/plaintext'
import python from 'highlight.js/lib/languages/python'
import pythonRepl from 'highlight.js/lib/languages/python-repl'
import r from 'highlight.js/lib/languages/r'
import ruby from 'highlight.js/lib/languages/ruby'
import rust from 'highlight.js/lib/languages/rust'
import scss from 'highlight.js/lib/languages/scss'
import shell from 'highlight.js/lib/languages/shell'
import sql from 'highlight.js/lib/languages/sql'
import swift from 'highlight.js/lib/languages/swift'
import typescript from 'highlight.js/lib/languages/typescript'
import vbnet from 'highlight.js/lib/languages/vbnet'
import wasm from 'highlight.js/lib/languages/wasm'
import xml from 'highlight.js/lib/languages/xml'
import yaml from 'highlight.js/lib/languages/yaml'
export const COMMON_LANGUAGES: Record<string, LanguageFn> = {
bash,
c,
cpp,
csharp,
css,
diff,
go,
graphql,
ini,
java,
javascript,
json,
kotlin,
less,
lua,
makefile,
markdown,
objectivec,
perl,
php,
'php-template': phpTemplate,
plaintext,
python,
'python-repl': pythonRepl,
r,
ruby,
rust,
scss,
shell,
sql,
swift,
typescript,
vbnet,
wasm,
xml,
yaml,
}
// highlight.js CDN 配置
const HLJS_VERSION = `11.11.1`
const HLJS_CDN_BASE = `https://cdn-doocs.oss-cn-shenzhen.aliyuncs.com/npm/highlightjs/${HLJS_VERSION}`
// 缓存正在加载的语言
const loadingLanguages = new Map<string, Promise<void>>()
/**
* 生成语言包的 CDN URL
*/
function grammarUrlFor(language: string): string {
return `${HLJS_CDN_BASE}/es/languages/${language}.min.js`
}
/**
* 动态加载并注册语言
* @param language 语言名称
* @param hljs highlight.js 实例
*/
export async function loadAndRegisterLanguage(language: string, hljs: any): Promise<void> {
// 如果已经注册,直接返回
if (hljs.getLanguage(language)) {
return
}
// 如果正在加载,等待加载完成
if (loadingLanguages.has(language)) {
await loadingLanguages.get(language)
return
}
// 开始加载
const loadPromise = (async () => {
try {
const module = await import(/* @vite-ignore */ grammarUrlFor(language))
hljs.registerLanguage(language, module.default)
}
catch (error) {
console.warn(`Failed to load language: ${language}`, error)
throw error
}
finally {
loadingLanguages.delete(language)
}
})()
loadingLanguages.set(language, loadPromise)
await loadPromise
}
/**
* 格式化高亮后的代码,处理空格和制表符
*/
function formatHighlightedCode(html: string, preserveNewlines = false): string {
let formatted = html
// 将 span 之间的空格移到 span 内部
formatted = formatted.replace(/(<span[^>]*>[^<]*<\/span>)(\s+)(<span[^>]*>[^<]*<\/span>)/g, (_: string, span1: string, spaces: string, span2: string) => span1 + span2.replace(/^(<span[^>]*>)/, `$1${spaces}`))
formatted = formatted.replace(/(\s+)(<span[^>]*>)/g, (_: string, spaces: string, span: string) => span.replace(/^(<span[^>]*>)/, `$1${spaces}`))
// 替换制表符为4个空格
formatted = formatted.replace(/\t/g, ` `)
if (preserveNewlines) {
// 替换换行符为 <br/>,并将空格转换为 &nbsp;
formatted = formatted.replace(/\r\n/g, `<br/>`).replace(/\n/g, `<br/>`).replace(/(>[^<]+)|(^[^<]+)/g, (str: string) => str.replace(/\s/g, `&nbsp;`))
}
else {
// 只将空格转换为 &nbsp;
formatted = formatted.replace(/(>[^<]+)|(^[^<]+)/g, (str: string) => str.replace(/\s/g, `&nbsp;`))
}
return formatted
}
/**
* 高亮代码并格式化(支持行号)
* @param text 原始代码文本
* @param language 语言名称
* @param hljs highlight.js 实例
* @param showLineNumber 是否显示行号
* @returns 格式化后的 HTML
*/
export function highlightAndFormatCode(text: string, language: string, hljs: any, showLineNumber: boolean): string {
let highlighted = ``
if (showLineNumber) {
const rawLines = text.replace(/\r\n/g, `\n`).split(`\n`)
const highlightedLines = rawLines.map((lineRaw) => {
const lineHtml = hljs.highlight(lineRaw, { language }).value
const formatted = formatHighlightedCode(lineHtml, false)
return formatted === `` ? `&nbsp;` : formatted
})
const lineNumbersHtml = highlightedLines.map((_, idx) => `<section style="padding:0 10px 0 0;line-height:1.75">${idx + 1}</section>`).join(``)
const codeInnerHtml = highlightedLines.join(`<br/>`)
const codeLinesHtml = `<div style="white-space:pre;min-width:max-content;line-height:1.75">${codeInnerHtml}</div>`
const lineNumberColumnStyles = `text-align:right;padding:8px 0;border-right:1px solid rgba(0,0,0,0.04);user-select:none;background:var(--code-bg,transparent);`
highlighted = `
<section style="display:flex;align-items:flex-start;overflow-x:hidden;overflow-y:auto;width:100%;max-width:100%;padding:0;box-sizing:border-box">
<section class="line-numbers" style="${lineNumberColumnStyles}">${lineNumbersHtml}</section>
<section class="code-scroll" style="flex:1 1 auto;overflow-x:auto;overflow-y:visible;padding:8px;min-width:0;box-sizing:border-box">${codeLinesHtml}</section>
</section>
`
}
else {
const rawHighlighted = hljs.highlight(text, { language }).value
highlighted = formatHighlightedCode(rawHighlighted, true)
}
return highlighted
}
export function highlightCodeBlock(codeBlock: Element, language: string, hljs: any): void {
const rawCode = codeBlock.getAttribute(`data-raw-code`)
const showLineNumber = codeBlock.getAttribute(`data-show-line-number`) === `true`
if (!rawCode)
return
const text = rawCode.replace(/&quot;/g, `"`)
const highlighted = highlightAndFormatCode(text, language, hljs, showLineNumber)
codeBlock.innerHTML = highlighted
codeBlock.removeAttribute(`data-language-pending`)
codeBlock.removeAttribute(`data-raw-code`)
codeBlock.removeAttribute(`data-show-line-number`)
}
/**
* 高亮 DOM 中待处理的代码块
* 查找带有 data-language-pending 属性的代码块,动态加载语言后重新高亮
* @param hljs highlight.js 实例
* @param container 容器元素(可选,默认为 document)
*/
export function highlightPendingBlocks(hljs: any, container: Document | Element = document): void {
const pendingBlocks = container.querySelectorAll(`code[data-language-pending]`)
pendingBlocks.forEach((codeBlock) => {
const language = codeBlock.getAttribute(`data-language-pending`)
if (!language)
return
if (hljs.getLanguage(language)) {
// 语言已加载,直接高亮
highlightCodeBlock(codeBlock, language, hljs)
}
else {
// 动态加载语言后重新高亮
loadAndRegisterLanguage(language, hljs).then(() => {
highlightCodeBlock(codeBlock, language, hljs)
}).catch(() => {
// 加载失败,移除标记
codeBlock.removeAttribute(`data-language-pending`)
codeBlock.removeAttribute(`data-raw-code`)
codeBlock.removeAttribute(`data-show-line-number`)
})
}
})
}