Hexo 主题开发系列教程(二):扩展系统详解(下篇)
前言
在上篇教程中,我们学习了 Console、Deployer 和 Filter 三个扩展组件。本篇将继续介绍剩余的七个扩展组件,它们更加深入地涉及页面生成、模板渲染和文件处理。
本篇内容:
- Generator(生成器) - 自定义页面生成逻辑
- Helper(辅助函数) - 模板工具函数
- Injector(注入器) - 动态注入代码片段
- Migrator(迁移器) - 数据迁移工具
- Processor(处理器) - 文件处理逻辑
- Renderer(渲染引擎) - 自定义渲染器
- Tag(标签) - 创建自定义模板标签
4. Generator(生成器)
概念介绍
Generator 负责生成网站的页面和路由。每个 Generator 返回一个或多个路由对象,告诉 Hexo 应该生成哪些页面、使用什么数据、使用什么布局。
- Generator 创建路由系统中的路由
- Generator 使用本地变量作为数据源
- Generator 指定使用哪个模板进行渲染
基础语法
1 2 3 4 5 6 7 8
| hexo.extend.generator.register(name, function(locals) { return { path: 'path/to/page.html', data: { }, layout: 'layout-name' }; });
|
路由对象结构
1 2 3 4 5 6 7 8
| { path: 'about/index.html', data: { title: '关于', content: '...' }, layout: ['about', 'page'], }
|
实战案例一:创建归档页面
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68
|
hexo.extend.generator.register('archive', function(locals) { const posts = locals.posts.sort('date', -1); const perPage = this.config.per_page || 10; const paginationDir = this.config.pagination_dir || 'page'; const postsByYear = {}; posts.forEach(post => { const year = post.date.year(); if (!postsByYear[year]) { postsByYear[year] = []; } postsByYear[year].push(post); }); const routes = []; const totalPages = Math.ceil(posts.length / perPage); for (let i = 0; i < totalPages; i++) { const pagePosts = posts.slice(i * perPage, (i + 1) * perPage); routes.push({ path: i === 0 ? 'archives/index.html' : `archives/${paginationDir}/${i + 1}/index.html`, data: { title: '归档', posts: pagePosts, total: posts.length, current: i + 1, total_pages: totalPages, prev: i > 0 ? (i === 1 ? 'archives/' : `archives/${paginationDir}/${i}/`) : null, next: i < totalPages - 1 ? `archives/${paginationDir}/${i + 2}/` : null }, layout: ['archive', 'index'] }); } Object.keys(postsByYear).forEach(year => { const yearPosts = postsByYear[year]; const yearPages = Math.ceil(yearPosts.length / perPage); for (let i = 0; i < yearPages; i++) { const pagePosts = yearPosts.slice(i * perPage, (i + 1) * perPage); routes.push({ path: i === 0 ? `archives/${year}/index.html` : `archives/${year}/${paginationDir}/${i + 1}/index.html`, data: { title: `${year} 年归档`, year: year, posts: pagePosts, total: yearPosts.length, current: i + 1, total_pages: yearPages, prev: i > 0 ? (i === 1 ? `archives/${year}/` : `archives/${year}/${paginationDir}/${i}/`) : null, next: i < yearPages - 1 ? `archives/${year}/${paginationDir}/${i + 2}/` : null }, layout: ['archive-year', 'archive', 'index'] }); } }); return routes; });
|
实战案例二:标签云页面生成器
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43
|
hexo.extend.generator.register('tagcloud', function(locals) { if (!this.theme.tagcloud || !this.theme.tagcloud.enable) { return []; } const tags = locals.tags.sort('length', -1); const maxCount = tags.first() ? tags.first().length : 1; const minCount = tags.last() ? tags.last().length : 1; const range = maxCount - minCount || 1; const tagData = tags.map(tag => { const weight = (tag.length - minCount) / range; return { name: tag.name, slug: tag.slug, path: tag.path, count: tag.length, weight: weight, size: 12 + Math.round(weight * 20), color: getColorByWeight(weight) }; }); return { path: 'tags/cloud.html', data: { title: '标签云', tags: tagData, total_tags: tags.length, total_posts: locals.posts.length }, layout: 'tagcloud' }; function getColorByWeight(weight) { const hue = 200 + weight * 60; return `hsl(${hue}, 70%, 50%)`; } });
|
实战案例三:站点地图生成器
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73
|
hexo.extend.generator.register('sitemap', function(locals) { const config = this.config; const posts = locals.posts.sort('date', -1); const pages = locals.pages; const urls = []; urls.push({ loc: config.url + '/', lastmod: posts.first() ? posts.first().updated : new Date(), changefreq: 'daily', priority: 1.0 }); posts.forEach(post => { urls.push({ loc: config.url + '/' + post.path, lastmod: post.updated, changefreq: 'weekly', priority: 0.8 }); }); pages.forEach(page => { urls.push({ loc: config.url + '/' + page.path, lastmod: page.updated, changefreq: 'monthly', priority: 0.6 }); }); locals.categories.forEach(cat => { urls.push({ loc: config.url + '/' + cat.path, lastmod: new Date(), changefreq: 'weekly', priority: 0.5 }); }); locals.tags.forEach(tag => { urls.push({ loc: config.url + '/' + tag.path, lastmod: new Date(), changefreq: 'weekly', priority: 0.5 }); }); const xml = `<?xml version="1.0" encoding="UTF-8"?> <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> ${urls.map(url => ` <url> <loc>${url.loc}</loc> <lastmod>${url.lastmod.toISOString()}</lastmod> <changefreq>${url.changefreq}</changefreq> <priority>${url.priority}</priority> </url>`).join('\n')} </urlset>`; return { path: 'sitemap.xml', data: xml }; });
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40
|
hexo.extend.generator.register('feed', function(locals) { const config = this.config; const feedConfig = config.feed || {}; if (!feedConfig.enable) return []; const posts = locals.posts .sort('date', -1) .limit(feedConfig.limit || 20); const xml = `<?xml version="1.0" encoding="UTF-8"?> <rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"> <channel> <title><![CDATA[${config.title}]]></title> <link>${config.url}/</link> <description><![CDATA[${config.description}]]></description> <language>${config.language || 'zh-CN'}</language> <pubDate>${new Date().toUTCString()}</pubDate> <lastBuildDate>${posts.first() ? posts.first().updated.toUTCString() : new Date().toUTCString()}</lastBuildDate> <atom:link href="${config.url}/feed.xml" rel="self" type="application/rss+xml"/> ${posts.map(post => ` <item> <title><![CDATA[${post.title}]]></title> <link>${config.url}/${post.path}</link> <guid>${config.url}/${post.path}</guid> <pubDate>${post.date.toUTCString()}</pubDate> <description><![CDATA[${post.excerpt || post.content}]]></description> ${post.categories ? post.categories.map(cat => `<category><![CDATA[${cat.name}]]></category>` ).join('\n ') : ''} </item>`).join('\n')} </channel> </rss>`; return { path: 'feed.xml', data: xml }; });
|
实战案例五:搜索索引生成器
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
|
hexo.extend.generator.register('search', function(locals) { const config = this.config; const posts = locals.posts.sort('date', -1); const searchData = posts.map(post => { const content = post.content .replace(/<[^>]+>/g, '') .replace(/\s+/g, ' ') .trim() .substring(0, 500); return { title: post.title, url: post.path, date: post.date.format('YYYY-MM-DD'), categories: post.categories ? post.categories.map(c => c.name) : [], tags: post.tags ? post.tags.map(t => t.name) : [], content: content }; }); return { path: 'search.json', data: JSON.stringify(searchData) }; });
|
Generator 最佳实践
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43
| hexo.extend.generator.register('conditional', function(locals) { if (!this.theme.feature_enabled) { return []; } });
hexo.extend.generator.register('dynamic-path', function(locals) { return locals.posts.map(post => ({ path: `${post.slug}/index.html`, data: post, layout: 'post' })); });
hexo.extend.generator.register('fallback-layout', function(locals) { return { path: 'special-page.html', data: {}, layout: ['special', 'page', 'default'] }; });
hexo.extend.generator.register('optimized', function(locals) { const cache = this._generatorCache = this._generatorCache || {}; const cacheKey = `posts-${locals.posts.length}`; if (!cache[cacheKey]) { cache[cacheKey] = expensiveCalculation(locals.posts); } return { path: 'stats.html', data: cache[cacheKey], layout: 'stats' }; });
|
5. Helper(辅助函数)
概念介绍
Helper 是在模板中可以直接调用的工具函数,用于格式化数据、生成 HTML 片段或执行常见操作。Hexo 内置了许多 Helper,你也可以注册自定义的。
- Helper 扩展本地变量的能力
- Helper 在模板渲染时被调用
- Helper 可以访问 Hexo 的所有 API
基础语法
1 2 3 4
| hexo.extend.helper.register(name, function(...args) { return result; });
|
内置 Helper 回顾
Hexo 提供了丰富的内置 Helper:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| <!-- URL 相关 --> <%- url_for(path) %> <%- relative_url(from, to) %> <%- gravatar(email) %>
<!-- 日期相关 --> <%= date(date, format) %> <%= time(date, format) %> <%= full_date(date, format) %> <%= moment(date).format('YYYY-MM-DD') %>
<!-- 字符串相关 --> <%= strip_html(string) %> <%= trim(string) %> <%= truncate(string, {length: 20}) %>
<!-- 模板相关 --> <%- partial('_partial/header') %> <%- fragment_cache('sidebar', function() { %> <!-- 缓存的内容 --> <% }) %>
<!-- 文章相关 --> <%- list_categories() %> <%- list_tags() %> <%- list_archives() %> <%- tagcloud() %>
|
实战案例一:阅读进度计算
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
|
hexo.extend.helper.register('reading_progress', function(content) { if (!content) return null; const text = content.replace(/<[^>]+>/g, ''); const words = text.split(/\s+/).length; const readingSpeed = 200; const minutes = Math.ceil(words / readingSpeed); return { total_words: words, reading_time: minutes, reading_speed: readingSpeed, format_time: function() { if (minutes < 1) return '不到 1 分钟'; if (minutes === 1) return '1 分钟'; return `${minutes} 分钟`; } }; });
|
在模板中使用:
1 2 3 4 5
| <% const progress = reading_progress(page.content) %> <div class="reading-info"> <span>📖 <%= progress.total_words %> 字</span> <span>⏱️ 约 <%= progress.format_time() %></span> </div>
|
实战案例二:相对时间显示
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36
|
hexo.extend.helper.register('time_ago', function(date) { const now = Date.now(); const past = date.valueOf(); const diff = now - past; const minute = 60 * 1000; const hour = 60 * minute; const day = 24 * hour; const week = 7 * day; const month = 30 * day; const year = 365 * day; if (diff < minute) { return '刚刚'; } else if (diff < hour) { const minutes = Math.floor(diff / minute); return `${minutes} 分钟前`; } else if (diff < day) { const hours = Math.floor(diff / hour); return `${hours} 小时前`; } else if (diff < week) { const days = Math.floor(diff / day); return `${days} 天前`; } else if (diff < month) { const weeks = Math.floor(diff / week); return `${weeks} 周前`; } else if (diff < year) { const months = Math.floor(diff / month); return `${months} 个月前`; } else { const years = Math.floor(diff / year); return `${years} 年前`; } });
|
实战案例三:图片处理 Helper
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45
|
hexo.extend.helper.register('responsive_image', function(src, alt, options = {}) { const url = this.url_for(src); const sizes = options.sizes || [320, 640, 960, 1280]; const srcset = sizes .map(size => `${url}?w=${size} ${size}w`) .join(', '); const attrs = { src: url, alt: alt || '', loading: options.lazy ? 'lazy' : 'eager', srcset: srcset, sizes: options.sizes_attr || '(max-width: 768px) 100vw, 50vw' }; if (options.class) { attrs.class = options.class; } const attrString = Object.entries(attrs) .map(([key, value]) => `${key}="${value}"`) .join(' '); return `<img ${attrString}>`; });
hexo.extend.helper.register('lazy_image', function(src, alt, placeholder) { const url = this.url_for(src); const placeholderUrl = placeholder || 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"%3E%3C/svg%3E'; return ` <img src="${placeholderUrl}" data-src="${url}" alt="${alt || ''}" class="lazyload" loading="lazy" > `; });
|
实战案例四:目录(TOC)生成器
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73
|
hexo.extend.helper.register('toc', function(content, options = {}) { const cheerio = require('cheerio'); const $ = cheerio.load(content); const minLevel = options.min_depth || 1; const maxLevel = options.max_depth || 6; const selector = Array.from({length: maxLevel - minLevel + 1}, (_, i) => `h${minLevel + i}`).join(', '); const headings = []; $(selector).each(function(index) { const $heading = $(this); const level = parseInt(this.name.substring(1)); const text = $heading.text(); const id = $heading.attr('id') || text.toLowerCase().replace(/\s+/g, '-').replace(/[^\w-]/g, ''); if (!$heading.attr('id')) { $heading.attr('id', id); } headings.push({ level: level, text: text, id: id, index: index }); }); if (headings.length === 0) { return ''; } let html = '<nav class="toc"><ol class="toc-list">'; let currentLevel = minLevel; headings.forEach((heading, index) => { const nextHeading = headings[index + 1]; const diff = heading.level - currentLevel; if (diff > 0) { html += '<ol class="toc-list">'.repeat(diff); } else if (diff < 0) { html += '</li></ol>'.repeat(-diff) + '</li>'; } else if (index > 0) { html += '</li>'; } html += `<li class="toc-item toc-level-${heading.level}">`; html += `<a class="toc-link" href="#${heading.id}">`; html += `<span class="toc-text">${heading.text}</span>`; html += `</a>`; currentLevel = heading.level; if (!nextHeading) { html += '</li>'; html += '</ol>'.repeat(heading.level - minLevel + 1); } }); html += '</nav>'; return html; });
|
在模板中使用:
1 2 3 4 5 6
| <% if (page.toc !== false) { %> <aside class="sidebar-toc"> <h3>目录</h3> <%- toc(page.content, {min_depth: 2, max_depth: 4}) %> </aside> <% } %>
|
实战案例五:代码高亮增强
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55
|
hexo.extend.helper.register('code_block', function(code, options = {}) { const lang = options.lang || 'text'; const caption = options.caption || ''; const showLineNumbers = options.line_numbers !== false; const highlight = options.highlight || []; const lines = code.split('\n'); let html = '<div class="code-block">'; if (caption || lang) { html += '<div class="code-header">'; if (caption) { html += `<span class="code-caption">${caption}</span>`; } html += `<span class="code-lang">${lang}</span>`; html += '<button class="copy-btn">复制</button>'; html += '</div>'; } html += '<pre><code>'; lines.forEach((line, index) => { const lineNum = index + 1; const isHighlight = highlight.includes(lineNum); const lineClass = isHighlight ? 'line highlight' : 'line'; if (showLineNumbers) { html += `<span class="${lineClass}" data-line="${lineNum}">`; html += `<span class="line-number">${lineNum}</span>`; html += `<span class="line-content">${escapeHtml(line)}</span>`; html += '</span>\n'; } else { html += `<span class="${lineClass}">${escapeHtml(line)}</span>\n`; } }); html += '</code></pre></div>'; return html; function escapeHtml(text) { return text .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } });
|
实战案例六:社交分享按钮
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53
|
hexo.extend.helper.register('share_buttons', function(options = {}) { const page = this.page; const config = this.config; const url = encodeURIComponent(config.url + '/' + page.path); const title = encodeURIComponent(page.title); const summary = encodeURIComponent(page.excerpt || page.title); const platforms = options.platforms || ['twitter', 'facebook', 'linkedin', 'weibo']; const shareLinks = { twitter: `https://twitter.com/intent/tweet?text=${title}&url=${url}`, facebook: `https://www.facebook.com/sharer/sharer.php?u=${url}`, linkedin: `https://www.linkedin.com/sharing/share-offsite/?url=${url}`, weibo: `https://service.weibo.com/share/share.php?url=${url}&title=${title}`, telegram: `https://t.me/share/url?url=${url}&text=${title}`, whatsapp: `https://api.whatsapp.com/send?text=${title}%20${url}` }; const icons = { twitter: '🐦', facebook: '📘', linkedin: '💼', weibo: '🎐', telegram: '✈️', whatsapp: '💬' }; let html = '<div class="share-buttons">'; platforms.forEach(platform => { if (shareLinks[platform]) { html += ` <a href="${shareLinks[platform]}" class="share-btn share-${platform}" target="_blank" rel="noopener noreferrer" title="分享到 ${platform}" > <span class="icon">${icons[platform]}</span> <span class="name">${platform}</span> </a> `; } }); html += '</div>'; return html; });
|
Helper 最佳实践
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60
| hexo.extend.helper.register('my_helper', function() { const config = this.config; const page = this.page; const theme = this.theme; });
hexo.extend.helper.register('safe_html', function(text) { const escaped = text .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>'); return escaped; });
hexo.extend.helper.register('flexible_helper', function(data, options = {}) { const defaults = { format: 'short', max: 10 }; const opts = Object.assign({}, defaults, options); });
const helperCache = new Map();
hexo.extend.helper.register('cached_helper', function(key) { if (!helperCache.has(key)) { const result = expensiveCalculation(key); helperCache.set(key, result); } return helperCache.get(key); });
hexo.extend.helper.register('chainable', function(data) { return { format: function(fmt) { return this; }, limit: function(n) { return this; }, toString: function() { return result; } }; });
|
6. Injector(注入器)
概念介绍
Injector 允许你在生成的 HTML 的特定位置注入代码片段,比如在 <head> 或 </body> 标签前注入脚本、样式等。
- Injector 在渲染引擎完成后介入
- Injector 修改最终的 HTML 输出
基础语法
1
| hexo.extend.injector.register(entry_point, value, to);
|
注入点(Entry Points)
1 2 3 4
| 'head_begin' 'head_end' 'body_begin' 'body_end'
|
实战案例一:注入分析代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39
|
hexo.extend.injector.register('head_end', function() { const analytics = this.theme.analytics; if (!analytics || !analytics.enable) { return ''; } if (analytics.google_id) { return ` <script async src="https://www.googletagmanager.com/gtag/js?id=${analytics.google_id}"></script> <script> window.dataLayer = window.dataLayer || []; function gtag(){dataLayer.push(arguments);} gtag('js', new Date()); gtag('config', '${analytics.google_id}'); </script> `; } if (analytics.baidu_id) { return ` <script> var _hmt = _hmt || []; (function() { var hm = document.createElement("script"); hm.src = "https://hm.baidu.com/hm.js?${analytics.baidu_id}"; var s = document.getElementsByTagName("script")[0]; s.parentNode.insertBefore(hm, s); })(); </script> `; } return ''; });
|
实战案例二:注入暗色模式切换器
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54
|
hexo.extend.injector.register('head_end', function() { if (!this.theme.dark_mode || !this.theme.dark_mode.enable) { return ''; } return ` <script> (function() { const theme = localStorage.getItem('theme') || '${this.theme.dark_mode.default || 'light'}'; document.documentElement.setAttribute('data-theme', theme); })(); </script> <style> :root[data-theme="dark"] { --bg-color: #1a1a1a; --text-color: #e0e0e0; --link-color: #4dabf7; } :root[data-theme="light"] { --bg-color: #ffffff; --text-color: #333333; --link-color: #1971c2; } body { background-color: var(--bg-color); color: var(--text-color); } </style> `; });
hexo.extend.injector.register('body_end', function() { if (!this.theme.dark_mode || !this.theme.dark_mode.enable) { return ''; } return ` <button id="theme-toggle" aria-label="切换主题"> <span class="icon-sun">☀️</span> <span class="icon-moon">🌙</span> </button> <script> const toggle = document.getElementById('theme-toggle'); toggle.addEventListener('click', () => { const current = document.documentElement.getAttribute('data-theme'); const next = current === 'dark' ? 'light' : 'dark'; document.documentElement.setAttribute('data-theme', next); localStorage.setItem('theme', next); }); </script> `; });
|
实战案例三:注入性能监控
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
|
hexo.extend.injector.register('body_end', function() { if (this.env.cmd !== 'generate' || !this.theme.performance_monitor) { return ''; } return ` <script> window.addEventListener('load', function() { const perfData = window.performance.timing; const loadTime = perfData.loadEventEnd - perfData.navigationStart; const domReadyTime = perfData.domContentLoadedEventEnd - perfData.navigationStart; console.log('Page Load Time:', loadTime + 'ms'); console.log('DOM Ready Time:', domReadyTime + 'ms'); // 发送到分析服务 if (typeof gtag !== 'undefined') { gtag('event', 'timing_complete', { 'name': 'load', 'value': loadTime, 'event_category': 'Performance' }); } }); </script> `; });
|
实战案例四:注入搜索功能
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
|
hexo.extend.injector.register('body_end', function() { if (!this.theme.search || !this.theme.search.enable) { return ''; } return ` <div id="search-overlay" class="search-overlay"> <div class="search-container"> <input type="search" id="search-input" placeholder="搜索文章..." autocomplete="off" > <div id="search-results"></div> </div> </div> <script src="${this.url_for('/js/search.js')}"></script> <script> const searchData = '${this.url_for('/search.json')}'; const search = new Search(searchData); document.addEventListener('keydown', function(e) { if ((e.ctrlKey || e.metaKey) && e.key === 'k') { e.preventDefault(); document.getElementById('search-overlay').classList.add('active'); document.getElementById('search-input').focus(); } }); </script> `; });
|
实战案例五:条件性注入
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59
|
hexo.extend.injector.register('body_end', function() { const page = this.page; if (page.layout !== 'post' || page.comments === false) { return ''; } const comments = this.theme.comments; if (!comments || !comments.enable) { return ''; } if (comments.provider === 'disqus') { return ` <div id="disqus_thread"></div> <script> var disqus_config = function () { this.page.url = '${this.config.url}/${page.path}'; this.page.identifier = '${page._id}'; }; (function() { var d = document, s = d.createElement('script'); s.src = 'https://${comments.disqus.shortname}.disqus.com/embed.js'; s.setAttribute('data-timestamp', +new Date()); (d.head || d.body).appendChild(s); })(); </script> `; } if (comments.provider === 'gitalk') { return ` <div id="gitalk-container"></div> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/gitalk@1/dist/gitalk.css"> <script src="https://cdn.jsdelivr.net/npm/gitalk@1/dist/gitalk.min.js"></script> <script> const gitalk = new Gitalk({ clientID: '${comments.gitalk.client_id}', clientSecret: '${comments.gitalk.client_secret}', repo: '${comments.gitalk.repo}', owner: '${comments.gitalk.owner}', admin: ['${comments.gitalk.admin}'], id: '${page._id}', distractionFreeMode: false }); gitalk.render('gitalk-container'); </script> `; } return ''; });
|
Injector 最佳实践
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| hexo.extend.injector.register('head_end', function() { return `<meta name="author" content="${this.config.author}">`; });
hexo.extend.injector.register('body_end', function() { if (this.page.custom_script) { return `<script src="${this.page.custom_script}"></script>`; } return ''; });
hexo.extend.injector.register('head_end', '<style>/* 样式1 */</style>', 'home'); hexo.extend.injector.register('head_end', '<style>/* 样式2 */</style>', 'post'); hexo.extend.injector.register('head_end', '<style>/* 样式3 */</style>', 'default');
hexo.extend.injector.register('head_end', function() { if (this._injectedCustomCode) { return ''; } this._injectedCustomCode = true; return '<script>/* 只注入一次 */</script>'; });
|
7. Migrator(迁移器)
概念介绍
Migrator 用于从其他博客平台迁移内容到 Hexo。虽然不是主题开发的核心,但了解它有助于为用户提供迁移工具。
基础语法
1 2 3
| hexo.extend.migrator.register(name, function(args) { });
|
实战案例:从 WordPress 迁移
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64
|
hexo.extend.migrator.register('wordpress', function(args) { const fs = require('hexo-fs'); const path = require('path'); const xml2js = require('xml2js'); const xmlFile = args._[0]; if (!xmlFile) { hexo.log.error('请指定 WordPress 导出的 XML 文件'); hexo.log.info('用法: hexo migrate wordpress <file.xml>'); return; } return new Promise((resolve, reject) => { fs.readFile(xmlFile, 'utf8', (err, data) => { if (err) { return reject(err); } xml2js.parseString(data, (err, result) => { if (err) { return reject(err); } const posts = result.rss.channel[0].item || []; let converted = 0; posts.forEach(item => { const post = { title: item.title[0], date: new Date(item.pubDate[0]), content: item['content:encoded'][0], categories: item.category ? item.category.filter(c => c.$.domain === 'category').map(c => c._) : [], tags: item.category ? item.category.filter(c => c.$.domain === 'post_tag').map(c => c._) : [] }; const filename = post.title.toLowerCase().replace(/\s+/g, '-').replace(/[^\w-]/g, ''); const filepath = path.join(hexo.source_dir, '_posts', `${filename}.md`); const frontMatter = `--- title: ${post.title} date: ${post.date.toISOString()} categories: ${post.categories.map(c => ` - ${c}`).join('\n')} tags: ${post.tags.map(t => ` - ${t}`).join('\n')} ---
${post.content}`; fs.writeFileSync(filepath, frontMatter); converted++; hexo.log.info(`已转换: ${post.title}`); }); hexo.log.success(`成功迁移 ${converted} 篇文章!`); resolve(); }); }); }); });
|
8. Processor(处理器)
概念介绍
Processor 处理源文件夹中的文件,决定如何解析和存储这些文件的数据。
- Processor 操作 Box 中的文件
- Processor 创建文章数据对象
- Processor 在事件系统的早期阶段执行
基础语法
1 2 3
| hexo.extend.processor.register(pattern, function(file) { });
|
实战案例:处理数据文件
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
|
hexo.extend.processor.register('_data/**/*.{json,yaml,yml}', function(file) { const path = require('path'); const yaml = require('js-yaml'); const type = file.type; const filePath = file.path; if (type === 'delete') { const name = path.basename(filePath, path.extname(filePath)); delete hexo.locals.get('data')[name]; return; } const name = path.basename(filePath, path.extname(filePath)); const ext = path.extname(filePath); return file.read().then(content => { let data; if (ext === '.json') { data = JSON.parse(content); } else { data = yaml.load(content); } hexo.locals.set(name, () => data); hexo.log.info(`数据文件已加载: ${name}`); }); });
|
9. Renderer(渲染引擎)
概念介绍
Renderer 将特定格式的文件转换为其他格式,最常见的是将 Markdown 转换为 HTML。
- Renderer 是渲染引擎的具体实现
- Renderer 在文章处理流程中被调用
基础语法
1 2 3
| hexo.extend.renderer.register(inputExt, outputExt, function(data, options) { }, sync);
|
实战案例:自定义 Markdown 渲染器
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52
|
hexo.extend.renderer.register('md', 'html', function(data, options) { const marked = require('marked'); const renderer = new marked.Renderer(); renderer.heading = function(text, level) { const id = text.toLowerCase().replace(/\s+/g, '-').replace(/[^\w-]/g, ''); return ` <h${level} id="${id}" class="article-heading"> <a href="#${id}" class="heading-anchor">#</a> ${text} </h${level}> `; }; renderer.link = function(href, title, text) { const isExternal = /^https?:\/\//.test(href); const attrs = isExternal ? ' target="_blank" rel="noopener noreferrer"' : ''; const titleAttr = title ? ` title="${title}"` : ''; return `<a href="${href}"${titleAttr}${attrs}>${text}</a>`; }; renderer.code = function(code, language) { const lang = language || 'text'; return ` <div class="code-block"> <div class="code-header"> <span class="lang">${lang}</span> <button class="copy-btn">复制</button> </div> <pre><code class="language-${lang}">${code}</code></pre> </div> `; }; marked.setOptions({ renderer: renderer, gfm: true, breaks: true, smartLists: true, smartypants: true }); return marked(data.text); }, true);
|
10. Tag(标签)
概念介绍
Tag 允许你在文章中使用自定义标签,这些标签会在渲染时被处理成特定的 HTML。
- Tag 扩展了模板脚手架的能力
- Tag 在渲染引擎处理时被解析
基础语法
1 2 3 4 5 6 7 8 9
| hexo.extend.tag.register(name, function(args, content) { });
hexo.extend.tag.register(name, function(args, content) { }, {ends: true});
|
实战案例一:提示框标签
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
|
hexo.extend.tag.register('note', function(args, content) { const type = args[0] || 'default'; const title = args.slice(1).join(' '); const icons = { default: 'ℹ️', success: '✅', warning: '⚠️', danger: '❌', info: 'ℹ️' }; const icon = icons[type] || icons.default; const titleHtml = title ? `<div class="note-title">${icon} ${title}</div>` : ''; return ` <div class="note note-${type}"> ${titleHtml} <div class="note-content"> ${hexo.render.renderSync({text: content, engine: 'markdown'})} </div> </div> `; }, {ends: true});
|
使用方法:
1 2 3 4 5 6 7
| {% note success 成功提示 %} 这是一条成功消息! {% endnote %}
{% note warning %} 这是一条警告消息! {% endnote %}
|
实战案例二:标签页标签
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40
|
let tabId = 0;
hexo.extend.tag.register('tabs', function(args, content) { const id = `tabs-${tabId++}`; const tabs = content.split('<!-- tab').filter(Boolean); let tabsHtml = `<div class="tabs" id="${id}">`; tabsHtml += '<ul class="tab-nav">'; tabs.forEach((tab, index) => { const match = tab.match(/^([^-]+)-->/); const name = match ? match[1].trim() : `Tab ${index + 1}`; const active = index === 0 ? 'active' : ''; tabsHtml += ` <li class="tab-nav-item ${active}" data-tab="${index}"> ${name} </li> `; }); tabsHtml += '</ul><div class="tab-content">'; tabs.forEach((tab, index) => { const content = tab.replace(/^[^>]+>/, '').replace(/<!-- endtab -->/, '').trim(); const active = index === 0 ? 'active' : ''; tabsHtml += ` <div class="tab-pane ${active}" data-index="${index}"> ${hexo.render.renderSync({text: content, engine: 'markdown'})} </div> `; }); tabsHtml += '</div></div>'; return tabsHtml; }, {ends: true});
|
使用方法:
1 2 3 4 5 6 7 8 9
| {% tabs %} <!-- tab JavaScript --> 这是 JavaScript 的内容 <!-- endtab -->
<!-- tab Python --> 这是 Python 的内容 <!-- endtab --> {% endtabs %}
|
实战案例三:时间轴标签
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
|
hexo.extend.tag.register('timeline', function(args, content) { const items = content.split('<!-- item').filter(Boolean); let html = '<div class="timeline">'; items.forEach((item, index) => { const match = item.match(/^([^-]+)-->/); const date = match ? match[1].trim() : ''; const itemContent = item.replace(/^[^>]+>/, '').replace(/<!-- enditem -->/, '').trim(); html += ` <div class="timeline-item"> <div class="timeline-dot"></div> <div class="timeline-date">${date}</div> <div class="timeline-content"> ${hexo.render.renderSync({text: itemContent, engine: 'markdown'})} </div> </div> `; }); html += '</div>'; return html; }, {ends: true});
|
实战案例四:按钮标签
1 2 3 4 5 6 7 8 9 10 11 12 13 14
|
hexo.extend.tag.register('button', function(args) { const url = args[0]; const text = args.slice(1).join(' '); const icon = args.icon || ''; return ` <a href="${url}" class="btn-tag" target="_blank" rel="noopener noreferrer"> ${icon ? `<i class="${icon}"></i>` : ''} <span>${text}</span> </a> `; });
|
Tag 最佳实践
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| hexo.extend.tag.register('async_tag', async function(args) { const data = await fetchSomeData(); return renderData(data); }, {async: true});
hexo.extend.tag.register('safe_tag', function(args, content) { try { return processContent(content); } catch (err) { hexo.log.error('Tag 处理失败:', err); return `<div class="error">标签渲染失败</div>`; } }, {ends: true});
hexo.extend.tag.register('markdown_tag', function(args, content) { const processed = hexo.render.renderSync({ text: content, engine: 'markdown' }); return `<div class="custom">${processed}</div>`; }, {ends: true});
|
完整示例:综合运用所有扩展
让我们创建一个完整的功能,综合运用本章学到的所有扩展:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72
|
hexo.extend.console.register('showcase:doc', '生成功能展示文档', function() { hexo.log.info('功能展示系统包含以下组件:'); hexo.log.info('- Generator: 生成展示页面'); hexo.log.info('- Helper: 提供模板函数'); hexo.log.info('- Tag: 支持自定义标签'); hexo.log.info('- Filter: 数据增强'); });
hexo.extend.generator.register('showcase', function(locals) { return { path: 'showcase/index.html', data: { title: '功能展示', features: getFeatures() }, layout: 'showcase' }; function getFeatures() { return [ {name: 'Console', desc: '命令行工具'}, {name: 'Generator', desc: '页面生成器'}, {name: 'Helper', desc: '模板辅助函数'}, {name: 'Tag', desc: '自定义标签'} ]; } });
hexo.extend.helper.register('showcase_badge', function(feature) { return `<span class="badge badge-${feature.toLowerCase()}">${feature}</span>`; });
hexo.extend.filter.register('before_post_render', function(data) { if (data.showcase) { data.has_showcase = true; } return data; });
hexo.extend.tag.register('showcase', function(args, content) { const feature = args[0]; return ` <div class="showcase-block"> <h3>${feature}</h3> <div class="showcase-content">${content}</div> </div> `; }, {ends: true});
hexo.extend.injector.register('head_end', ` <style> .showcase-block { border: 2px solid #4dabf7; border-radius: 8px; padding: 20px; margin: 20px 0; } .badge { padding: 4px 8px; border-radius: 4px; font-size: 12px; } </style> `);
|
小结
本篇(下篇)完成了 Hexo 扩展系统的学习,介绍了:
- Generator(生成器) - 创建自定义页面和路由
- Helper(辅助函数) - 在模板中使用的工具函数
- Injector(注入器) - 动态注入代码片段
- Migrator(迁移器) - 数据迁移工具
- Processor(处理器) - 处理源文件
- Renderer(渲染引擎) - 自定义文件渲染
- Tag(标签) - 创建自定义模板标签
至此,我们已经完整学习了 Hexo 的十大扩展系统。这些扩展让 Hexo 拥有了强大的可定制性和扩展性。
扩展系统总结
扩展的协作关系
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| 数据流 涉及的扩展 ────────────────────────────────────────────── 源文件 → Processor ↓ 解析数据 → Renderer ↓ 处理数据 → Filter (before_post_render) ↓ 渲染内容 → Renderer ↓ 增强数据 → Filter (after_post_render) ↓ 生成路由 → Generator ↓ 渲染模板 → Helper + Tag ↓ 输出 HTML → Filter (after_render) ↓ 注入代码 → Injector ↓ 部署 → Deployer
|
选择合适的扩展
- 修改文章内容 → Filter
- 生成新页面 → Generator
- 模板工具函数 → Helper
- 文章中的特殊语法 → Tag
- 添加脚本/样式 → Injector
- 自动化任务 → Console
- 自定义部署 → Deployer