mirror of
https://github.com/JimLiu/baoyu-skills.git
synced 2026-07-19 17:09:47 +08:00
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:
@@ -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: <,,>
|
||||
*/
|
||||
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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'")
|
||||
.replace(/`/g, "`");
|
||||
}
|
||||
|
||||
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, """);
|
||||
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/>,并将空格转换为
|
||||
formatted = formatted.replace(/\r\n/g, `<br/>`).replace(/\n/g, `<br/>`).replace(/(>[^<]+)|(^[^<]+)/g, (str: string) => str.replace(/\s/g, ` `))
|
||||
}
|
||||
else {
|
||||
// 只将空格转换为
|
||||
formatted = formatted.replace(/(>[^<]+)|(^[^<]+)/g, (str: string) => str.replace(/\s/g, ` `))
|
||||
}
|
||||
|
||||
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 === `` ? ` ` : 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(/"/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`)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user