hexo-shiki-highlight
Version:
个人博客[vluv's space](https://vluv.space/)使用的代码高亮插件,基于 [Shiki](https://shiki.style/) 实现。
275 lines (231 loc) • 8.77 kB
JavaScript
const { createHighlighter, bundledLanguages, bundledThemes } = require("shiki");
const fs = require('hexo-fs');
const path = require('path');
// Constants
const ASSET_PATHS = {
css: 'css/code_block/shiki.css',
js: 'js/code_block/shiki.js'
};
const CODE_BLOCK_REGEX = /(?<quote>[> ]*)(?<ul>(-|\d+\.)?)(?<start>\s*)(?<tick>~{3,}|`{3,}) *(?<lang>\S+)? *(?<title>.*?)\n(?<code>[\s\S]*?)\k<quote>\s*\k<tick>(?<end>\s*)$/gm;
// Utility functions
function stripIndent(code, indentLength) {
if (indentLength <= 0) return code;
return code.split('\n')
.map(line => {
const lineIndentMatch = line.match(/^[ \t]+/);
const lineIndentLength = lineIndentMatch ? lineIndentMatch[0].length : 0;
const stripLength = Math.min(indentLength, lineIndentLength);
return stripLength > 0 ? line.slice(stripLength) : line;
})
.join('\n');
}
function getIndentLength(text) {
const lines = text.split('\n');
const firstNonEmptyLine = lines.find(line => line.trim() !== '');
const indentMatch = firstNonEmptyLine?.match(/^[ \t]*/);
return indentMatch ? indentMatch[0].length : 0;
}
function escapeHTML(text) {
return require('hexo-util').escapeHTML(text);
}
// Initialize highlighter
async function initializeHighlighter() {
try {
return await createHighlighter({
themes: Object.keys(bundledThemes),
langs: Object.keys(bundledLanguages),
});
} catch (err) {
console.error("Failed to initialize Shiki highlighter:", err);
return null;
}
}
// Configuration setup
function setupConfiguration() {
const config = hexo.config.shiki;
if (!config) return null;
const language_aliases = new Map(Object.entries(config.language_aliases || {}));
const settings = {
theme: config.theme || "catppuccin-mocha",
line_number: config.line_number,
exclude_languages: config.exclude_languages || [],
language_aliases,
features: {
highlight_linenumber_toggle: config.highlight_linenumber_toggle,
highlight_wrap_toggle: config.highlight_wrap_toggle,
highlight_lang: config.highlight_lang,
highlight_title: config.highlight_title,
highlight_copy: config.highlight_copy,
highlight_raw: config.highlight_raw,
is_highlight_shrink: config.is_highlight_shrink,
highlight_height_limit: config.highlight_height_limit,
},
copy: {
success: config.copy?.success || 'Copied!',
error: config.copy?.error || 'Copy failed!'
}
};
return { config, settings };
}
// Asset registration
function registerAssets() {
// Register generator to copy local CSS and JS files
hexo.extend.generator.register('shiki_local_assets', () => [
{
path: ASSET_PATHS.css,
data: () => fs.createReadStream(path.join(__dirname, 'code_block/shiki.css'))
},
{
path: ASSET_PATHS.js,
data: () => fs.createReadStream(path.join(__dirname, 'code_block/shiki.js'))
}
]);
// Inject CSS and JS
hexo.extend.injector.register('head_end', () => {
return `<link rel="stylesheet" href="${hexo.config.root}${ASSET_PATHS.css}">`;
});
hexo.extend.injector.register('body_end', () => {
return `<script src="${hexo.config.root}${ASSET_PATHS.js}"></script>`;
});
}
// Theme CSS injection
function injectThemeCSS(themeInfo) {
const cssVariables = [
`--hl-bg: ${themeInfo.bg};`,
`--hl-color: ${themeInfo.fg};`,
`--hl-code-type: ${themeInfo.type};`,
`--hl-code-name: ${themeInfo.name};`,
`--hl-code-display-name: ${themeInfo.displayName || "none"};`,
];
// Add color replacements
if (themeInfo.colorReplacements) {
Object.entries(themeInfo.colorReplacements).forEach(([key, value]) => {
cssVariables.push(`--hl-code-color-${key.slice(1)}: ${value};`);
});
}
// Add VS Code colors
if (themeInfo.colors) {
Object.entries(themeInfo.colors).forEach(([key, value]) => {
cssVariables.push(`--hl-code-colors-${key}: ${value};`);
});
}
// Add other fields
cssVariables.push(
`--hl-code-schema: ${themeInfo.$schema || "none"};`,
`--hl-code-semantic-highlighting: ${themeInfo.semanticHighlighting || "false"};`
);
hexo.extend.injector.register("head_end", () => {
return `<style>:root { ${cssVariables.join(' ')} }</style>`;
});
}
// Configuration injection
function injectConfiguration(settings) {
const configScript = `
<script>
const CODE_CONFIG = {
highlightLineNumberToggle: ${settings.features.highlight_linenumber_toggle},
highlightWrapToggle: ${settings.features.highlight_wrap_toggle},
highlightLang: ${settings.features.highlight_lang},
highlightTitle: ${settings.features.highlight_title},
highlightCopy: ${settings.features.highlight_copy},
highlightRaw: ${settings.features.highlight_raw},
isHighlightShrink: ${settings.features.is_highlight_shrink},
highlightHeightLimit: ${settings.features.highlight_height_limit},
copy: {
success: '${settings.copy.success}',
error: '${settings.copy.error}',
}
};
console.log("hexo shiki highlight loaded");
</script>
`;
hexo.extend.injector.register("body_end", () => configScript);
}
// Height limit CSS injection
function injectHeightLimitCSS(heightLimit) {
if (!heightLimit) return;
const heightLimitCSS = `
<style>
.code-expand-btn:not(.expand-done) ~ div.codeblock,
.code-expand-btn:not(.expand-done) ~ * div.codeblock {
overflow: hidden;
height: ${heightLimit}px;
}
</style>
`;
hexo.extend.injector.register("head_end", () => heightLimitCSS);
}
// Code processing
function processCodeBlock(code, lang, title, settings, highlighter) {
// Handle excluded languages - return original code without any processing
if (config.exclude_languages.includes(lang)) {
return code;
}
// Process with Shiki
try {
const normalizedLang = lang?.toLowerCase() || '';
const realLang = settings.language_aliases.get(normalizedLang) || normalizedLang;
// Check if the language is supported
if (realLang && !bundledLanguages[realLang]) {
console.warn(`Shiki does not support language: ${realLang}, falling back to plain text.`);
return buildSimpleCodeBlock(code, lang);
}
let highlightedCode = highlighter.codeToHtml(code, {
lang: realLang,
theme: settings.theme,
});
// Remove inline styles from pre tag
highlightedCode = highlightedCode.replace(/<pre[^>]*>/, (match) => {
return match.replace(/\s*style\s*=\s*"[^"]*"\s*tabindex="0"/, "");
});
return buildCodeBlock(highlightedCode, code, lang, title, settings);
} catch (error) {
console.warn('Shiki highlighting failed:', error);
return buildSimpleCodeBlock(code, lang);
}
}
function buildSimpleCodeBlock(code, lang) {
return `<div class='codeblock'><div class="code"><pre><code class="${lang || ''}">${escapeHTML(code)}</code></pre></div>`;
}
function buildCodeBlock(highlightedCode, originalCode, lang, title, settings) {
const lines = originalCode.split('\n');
const lineNumbers = lines.map((_, i) => `<span class="line">${i + 1}</span><br>`).join('');
const gutterStyle = settings.line_number ? '' : 'display: none;';
return `<figure class="shiki${lang ? ` ${lang}` : ""}" data_title="${title || ""}">
<div class='codeblock'>
<div class="gutter" style="${gutterStyle}"><pre>${lineNumbers}</pre></div>
<div class="code">${highlightedCode}</div>
</div>
</figure>`;
}
// Main execution
const configResult = setupConfiguration();
if (!configResult) return;
const { config, settings } = configResult;
// Register assets and inject configurations
registerAssets();
injectConfiguration(settings);
injectHeightLimitCSS(settings.features.highlight_height_limit);
// Initialize and setup highlighter
initializeHighlighter().then((highlighter) => {
if (!highlighter) return;
const themeInfo = highlighter.getTheme(settings.theme);
injectThemeCSS(themeInfo);
// Register code block processor
hexo.extend.filter.register("before_post_render", (post) => {
post.content = post.content.replace(CODE_BLOCK_REGEX, (...args) => {
const groups = args.pop();
let { quote, ul, start, end, lang, title, code } = groups;
// Process indentation
const indentLength = getIndentLength(args[0]);
const match = new RegExp(`^${quote.trimEnd()}`, "gm");
code = code.replace(match, "");
code = stripIndent(code, indentLength);
// Clean up parameters
lang = lang || "";
title = title?.trim() || "";
const result = processCodeBlock(code, lang, title, settings, highlighter);
return `${quote + ul + start}<hexoPostRenderCodeBlock>${result}</hexoPostRenderCodeBlock>${end}`;
});
});
});