前言

在前三章中,我们已经完成了:

现在,Aurora 主题已经具备了基本的功能。本章将进一步提升主题的实用性和性能,实现高级功能:

  • 🔍 本地搜索 - 快速全文搜索
  • 🔍 Algolia 搜索 - 云端搜索服务
  • 💬 多评论系统 - 灵活切换评论平台
  • 📊 统计分析 - 访问统计与行为分析
  • 性能优化 - 加载速度优化
  • 📱 PWA 支持 - 渐进式 Web 应用
  • 🎨 高级定制 - 深度个性化
  • 🚀 SEO 优化 - 搜索引擎优化
  • 📦 主题发布 - 打包与分发

第一部分:搜索功能实现

1.1 本地搜索

本地搜索通过生成搜索索引文件,在客户端进行全文检索,无需服务器支持。

生成搜索索引

themes/aurora/scripts/generators/search.js

/**
 * 搜索索引生成器
 */

hexo.extend.generator.register('search', function(locals) {
  const config = this.config;
  const searchConfig = this.theme.search;
  
  // 如果搜索未启用或使用外部服务,不生成索引
  if (!searchConfig || !searchConfig.enable || searchConfig.provider !== 'local') {
    return;
  }
  
  const posts = locals.posts.sort('-date');
  const pages = locals.pages;
  
  // 合并文章和页面
  const allContent = [];
  
  // 处理文章
  posts.forEach(post => {
    if (post.indexing === false) return; // 跳过不需要索引的文章
    
    allContent.push({
      title: post.title,
      url: post.path,
      content: stripHtml(post.content),
      categories: post.categories ? post.categories.map(cat => cat.name) : [],
      tags: post.tags ? post.tags.map(tag => tag.name) : [],
      date: post.date.format('YYYY-MM-DD')
    });
  });
  
  // 处理页面
  pages.forEach(page => {
    if (page.indexing === false) return;
    
    allContent.push({
      title: page.title,
      url: page.path,
      content: stripHtml(page.content),
      categories: [],
      tags: [],
      date: page.date ? page.date.format('YYYY-MM-DD') : ''
    });
  });
  
  // 生成搜索索引
  return {
    path: 'search.json',
    data: JSON.stringify(allContent)
  };
  
  // 辅助函数:移除 HTML 标签
  function stripHtml(html) {
    if (!html) return '';
    
    return html
      .replace(/<style[^>]*>.*?<\/style>/gis, '')
      .replace(/<script[^>]*>.*?<\/script>/gis, '')
      .replace(/<[^>]+>/g, '')
      .replace(/\s+/g, ' ')
      .trim()
      .substring(0, 5000); // 限制内容长度
  }
});

搜索界面

themes/aurora/layout/_partial/search.ejs

<div class="search-overlay" id="search-overlay">
  <div class="search-container">
    <!-- 搜索框 -->
    <div class="search-header">
      <div class="search-input-wrapper">
        <i class="icon-search search-icon"></i>
        <input 
          type="search" 
          id="search-input" 
          class="search-input"
          placeholder="搜索文章..."
          autocomplete="off"
          spellcheck="false"
        >
        <button class="search-clear" id="search-clear" aria-label="清除">
          <i class="icon-x"></i>
        </button>
      </div>
      <button class="search-close" id="search-close" aria-label="关闭">
        <i class="icon-x"></i>
      </button>
    </div>
    
    <!-- 搜索提示 -->
    <div class="search-hint" id="search-hint">
      <p>输入关键词开始搜索</p>
      <div class="search-shortcuts">
        <kbd>↑</kbd> <kbd>↓</kbd> 选择结果
        <kbd>Enter</kbd> 打开
        <kbd>Esc</kbd> 关闭
      </div>
    </div>
    
    <!-- 搜索结果 -->
    <div class="search-results" id="search-results"></div>
    
    <!-- 加载状态 -->
    <div class="search-loading" id="search-loading">
      <div class="spinner"></div>
      <p>加载搜索索引...</p>
    </div>
    
    <!-- 无结果 -->
    <div class="search-no-results" id="search-no-results">
      <i class="icon-search-x"></i>
      <p>未找到相关结果</p>
    </div>
  </div>
</div>

搜索脚本

themes/aurora/source/js/search.js

/**
 * 本地搜索功能
 */

class LocalSearch {
  constructor(options = {}) {
    this.searchData = null;
    this.dataUrl = options.dataUrl || '/search.json';
    this.maxResults = options.maxResults || 20;
    this.highlightTag = options.highlightTag || 'em';
    
    this.overlay = document.getElementById('search-overlay');
    this.input = document.getElementById('search-input');
    this.results = document.getElementById('search-results');
    this.hint = document.getElementById('search-hint');
    this.noResults = document.getElementById('search-no-results');
    this.loading = document.getElementById('search-loading');
    this.clearBtn = document.getElementById('search-clear');
    this.closeBtn = document.getElementById('search-close');
    
    this.selectedIndex = -1;
    
    this.init();
  }
  
  init() {
    // 绑定事件
    this.bindEvents();
    
    // 预加载搜索数据
    this.loadSearchData();
  }
  
  bindEvents() {
    // 搜索按钮点击
    const searchBtn = document.getElementById('search-button');
    if (searchBtn) {
      searchBtn.addEventListener('click', () => this.open());
    }
    
    // 快捷键
    document.addEventListener('keydown', (e) => {
      // Ctrl/Cmd + K 打开搜索
      if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
        e.preventDefault();
        this.open();
      }
      
      // ESC 关闭搜索
      if (e.key === 'Escape' && this.isOpen()) {
        this.close();
      }
    });
    
    // 输入事件
    if (this.input) {
      this.input.addEventListener('input', this.debounce(() => {
        this.search();
      }, 300));
      
      // 键盘导航
      this.input.addEventListener('keydown', (e) => {
        this.handleKeyboard(e);
      });
    }
    
    // 清除按钮
    if (this.clearBtn) {
      this.clearBtn.addEventListener('click', () => {
        this.input.value = '';
        this.clearResults();
        this.input.focus();
      });
    }
    
    // 关闭按钮
    if (this.closeBtn) {
      this.closeBtn.addEventListener('click', () => this.close());
    }
    
    // 点击遮罩关闭
    if (this.overlay) {
      this.overlay.addEventListener('click', (e) => {
        if (e.target === this.overlay) {
          this.close();
        }
      });
    }
  }
  
  async loadSearchData() {
    if (this.searchData) return;
    
    try {
      this.showLoading();
      
      const response = await fetch(this.dataUrl);
      if (!response.ok) throw new Error('Failed to load search data');
      
      this.searchData = await response.json();
      this.hideLoading();
      
      console.log(`Search index loaded: ${this.searchData.length} items`);
    } catch (error) {
      console.error('Error loading search data:', error);
      this.hideLoading();
      this.showError('搜索索引加载失败');
    }
  }
  
  search() {
    const query = this.input.value.trim().toLowerCase();
    
    if (!query) {
      this.clearResults();
      return;
    }
    
    if (!this.searchData) {
      this.showError('搜索索引未加载');
      return;
    }
    
    // 搜索
    const results = this.performSearch(query);
    
    // 显示结果
    this.displayResults(results, query);
  }
  
  performSearch(query) {
    const keywords = query.split(/\s+/).filter(k => k.length > 0);
    const results = [];
    
    this.searchData.forEach(item => {
      let score = 0;
      const matchedKeywords = new Set();
      
      keywords.forEach(keyword => {
        // 标题匹配(权重最高)
        const titleIndex = item.title.toLowerCase().indexOf(keyword);
        if (titleIndex !== -1) {
          score += 10;
          if (titleIndex === 0) score += 5; // 开头匹配额外加分
          matchedKeywords.add(keyword);
        }
        
        // 分类/标签匹配
        const categories = item.categories || [];
        const tags = item.tags || [];
        if (categories.some(c => c.toLowerCase().includes(keyword)) ||
            tags.some(t => t.toLowerCase().includes(keyword))) {
          score += 5;
          matchedKeywords.add(keyword);
        }
        
        // 内容匹配
        if (item.content && item.content.toLowerCase().includes(keyword)) {
          score += 1;
          matchedKeywords.add(keyword);
        }
      });
      
      // 只有匹配到所有关键词才加入结果
      if (matchedKeywords.size === keywords.length && score > 0) {
        results.push({
          ...item,
          score: score
        });
      }
    });
    
    // 按分数排序
    return results.sort((a, b) => b.score - a.score).slice(0, this.maxResults);
  }
  
  displayResults(results, query) {
    this.hideHint();
    this.hideNoResults();
    
    if (results.length === 0) {
      this.showNoResults();
      return;
    }
    
    const keywords = query.split(/\s+/);
    const html = results.map((item, index) => {
      const title = this.highlight(item.title, keywords);
      const excerpt = this.getExcerpt(item.content, keywords);
      
      return `
        <div class="search-result-item" data-index="${index}">
          <a href="/${item.url}" class="search-result-link">
            <h3 class="search-result-title">${title}</h3>
            ${excerpt ? `<p class="search-result-excerpt">${excerpt}</p>` : ''}
            <div class="search-result-meta">
              ${item.date ? `<span class="meta-date">${item.date}</span>` : ''}
              ${item.categories && item.categories.length > 0 ? 
                `<span class="meta-category">${item.categories[0]}</span>` : ''}
            </div>
          </a>
        </div>
      `;
    }).join('');
    
    this.results.innerHTML = html;
    this.results.style.display = 'block';
    this.selectedIndex = -1;
    
    // 绑定点击事件
    this.results.querySelectorAll('.search-result-link').forEach((link, index) => {
      link.addEventListener('click', (e) => {
        // 记录搜索统计
        this.trackSearch(query, item.url);
      });
    });
  }
  
  highlight(text, keywords) {
    if (!text) return '';
    
    let result = text;
    keywords.forEach(keyword => {
      const regex = new RegExp(`(${this.escapeRegex(keyword)})`, 'gi');
      result = result.replace(regex, `<${this.highlightTag}>$1</${this.highlightTag}>`);
    });
    
    return result;
  }
  
  getExcerpt(content, keywords) {
    if (!content) return '';
    
    // 查找第一个关键词的位置
    let index = -1;
    let matchedKeyword = '';
    
    for (const keyword of keywords) {
      const pos = content.toLowerCase().indexOf(keyword.toLowerCase());
      if (pos !== -1 && (index === -1 || pos < index)) {
        index = pos;
        matchedKeyword = keyword;
      }
    }
    
    if (index === -1) return '';
    
    // 提取摘要
    const start = Math.max(0, index - 50);
    const end = Math.min(content.length, index + 150);
    let excerpt = content.substring(start, end);
    
    // 添加省略号
    if (start > 0) excerpt = '...' + excerpt;
    if (end < content.length) excerpt = excerpt + '...';
    
    // 高亮关键词
    return this.highlight(excerpt, keywords);
  }
  
  handleKeyboard(e) {
    const items = this.results.querySelectorAll('.search-result-item');
    if (items.length === 0) return;
    
    switch(e.key) {
      case 'ArrowDown':
        e.preventDefault();
        this.selectedIndex = Math.min(this.selectedIndex + 1, items.length - 1);
        this.updateSelection(items);
        break;
        
      case 'ArrowUp':
        e.preventDefault();
        this.selectedIndex = Math.max(this.selectedIndex - 1, -1);
        this.updateSelection(items);
        break;
        
      case 'Enter':
        e.preventDefault();
        if (this.selectedIndex >= 0) {
          const link = items[this.selectedIndex].querySelector('a');
          if (link) link.click();
        }
        break;
    }
  }
  
  updateSelection(items) {
    items.forEach((item, index) => {
      item.classList.toggle('selected', index === this.selectedIndex);
    });
    
    if (this.selectedIndex >= 0) {
      items[this.selectedIndex].scrollIntoView({
        block: 'nearest',
        behavior: 'smooth'
      });
    }
  }
  
  open() {
    if (this.overlay) {
      this.overlay.classList.add('active');
      document.body.style.overflow = 'hidden';
      
      // 聚焦输入框
      setTimeout(() => {
        if (this.input) this.input.focus();
      }, 300);
      
      // 如果还没加载数据,立即加载
      if (!this.searchData) {
        this.loadSearchData();
      }
    }
  }
  
  close() {
    if (this.overlay) {
      this.overlay.classList.remove('active');
      document.body.style.overflow = '';
      this.clearResults();
    }
  }
  
  isOpen() {
    return this.overlay && this.overlay.classList.contains('active');
  }
  
  clearResults() {
    if (this.results) {
      this.results.innerHTML = '';
      this.results.style.display = 'none';
    }
    this.showHint();
    this.hideNoResults();
    this.selectedIndex = -1;
  }
  
  showHint() {
    if (this.hint) this.hint.style.display = 'block';
  }
  
  hideHint() {
    if (this.hint) this.hint.style.display = 'none';
  }
  
  showNoResults() {
    if (this.noResults) this.noResults.style.display = 'block';
  }
  
  hideNoResults() {
    if (this.noResults) this.noResults.style.display = 'none';
  }
  
  showLoading() {
    if (this.loading) this.loading.style.display = 'flex';
  }
  
  hideLoading() {
    if (this.loading) this.loading.style.display = 'none';
  }
  
  showError(message) {
    console.error(message);
  }
  
  trackSearch(query, url) {
    // 可以集成统计分析
    if (typeof gtag !== 'undefined') {
      gtag('event', 'search', {
        search_term: query,
        result_url: url
      });
    }
  }
  
  // 工具函数
  escapeRegex(str) {
    return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  }
  
  debounce(func, wait) {
    let timeout;
    return function(...args) {
      clearTimeout(timeout);
      timeout = setTimeout(() => func.apply(this, args), wait);
    };
  }
}

// 初始化
document.addEventListener('DOMContentLoaded', () => {
  if (document.getElementById('search-overlay')) {
    window.localSearch = new LocalSearch({
      dataUrl: '/search.json',
      maxResults: 20
    });
  }
});

1.2 Algolia 搜索集成

Algolia 提供强大的云端搜索服务,适合大型博客。

安装 Algolia

npm install hexo-algoliasearch --save

配置 Algolia

_config.yml

algolia:
  applicationID: 'your_application_id'
  apiKey: 'your_api_key'
  adminApiKey: 'your_admin_api_key'
  indexName: 'your_index_name'
  chunkSize: 5000
  fields:
    - title
    - slug
    - path
    - excerpt
    - content:strip
    - categories
    - tags

上传索引

# 生成并上传索引
hexo algolia

Algolia 搜索界面

themes/aurora/layout/_partial/search-algolia.ejs

<div class="search-overlay" id="algolia-search">
  <div class="search-container">
    <div class="search-header">
      <div id="algolia-search-input"></div>
      <button class="search-close" aria-label="关闭">
        <i class="icon-x"></i>
      </button>
    </div>
    
    <div id="algolia-hits"></div>
    <div id="algolia-pagination"></div>
    
    <div class="algolia-powered">
      <a href="https://www.algolia.com/" target="_blank">
        Search by Algolia
      </a>
    </div>
  </div>
</div>

themes/aurora/source/js/algolia.js

/**
 * Algolia 搜索
 */

// 引入 Algolia 库
import algoliasearch from 'algoliasearch/lite';
import instantsearch from 'instantsearch.js';
import { searchBox, hits, pagination } from 'instantsearch.js/es/widgets';

class AlgoliaSearch {
  constructor(options) {
    this.appId = options.appId;
    this.apiKey = options.apiKey;
    this.indexName = options.indexName;
    
    this.searchClient = algoliasearch(this.appId, this.apiKey);
    this.search = null;
    
    this.init();
  }
  
  init() {
    this.search = instantsearch({
      indexName: this.indexName,
      searchClient: this.searchClient,
      routing: true
    });
    
    // 配置搜索框
    this.search.addWidgets([
      searchBox({
        container: '#algolia-search-input',
        placeholder: '搜索文章...',
        showReset: true,
        showSubmit: false,
        autofocus: true
      }),
      
      // 配置结果展示
      hits({
        container: '#algolia-hits',
        templates: {
          item: (hit) => this.renderHit(hit),
          empty: '未找到相关结果'
        }
      }),
      
      // 配置分页
      pagination({
        container: '#algolia-pagination',
        padding: 2,
        showFirst: false,
        showLast: false
      })
    ]);
    
    this.search.start();
    this.bindEvents();
  }
  
  renderHit(hit) {
    const title = hit._highlightResult.title.value;
    const excerpt = hit._highlightResult.excerpt 
      ? hit._highlightResult.excerpt.value 
      : '';
    
    return `
      <div class="algolia-hit">
        <a href="${hit.path}" class="hit-link">
          <h3 class="hit-title">${title}</h3>
          ${excerpt ? `<p class="hit-excerpt">${excerpt}</p>` : ''}
          <div class="hit-meta">
            ${hit.date ? `<span class="meta-date">${hit.date}</span>` : ''}
            ${hit.categories && hit.categories.length > 0 ? 
              `<span class="meta-category">${hit.categories[0]}</span>` : ''}
          </div>
        </a>
      </div>
    `;
  }
  
  bindEvents() {
    // 打开搜索
    const searchBtn = document.getElementById('search-button');
    if (searchBtn) {
      searchBtn.addEventListener('click', () => this.open());
    }
    
    // 关闭搜索
    const closeBtn = document.querySelector('#algolia-search .search-close');
    if (closeBtn) {
      closeBtn.addEventListener('click', () => this.close());
    }
    
    // 快捷键
    document.addEventListener('keydown', (e) => {
      if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
        e.preventDefault();
        this.open();
      }
      
      if (e.key === 'Escape') {
        this.close();
      }
    });
  }
  
  open() {
    const overlay = document.getElementById('algolia-search');
    if (overlay) {
      overlay.classList.add('active');
      document.body.style.overflow = 'hidden';
    }
  }
  
  close() {
    const overlay = document.getElementById('algolia-search');
    if (overlay) {
      overlay.classList.remove('active');
      document.body.style.overflow = '';
    }
  }
}

// 初始化
if (window.algoliaConfig) {
  new AlgoliaSearch(window.algoliaConfig);
}

第二部分:多评论系统集成

2.1 评论系统架构

设计灵活的评论系统切换机制。

themes/aurora/scripts/helpers/comments.js

/**
 * 评论系统 Helper
 */

hexo.extend.helper.register('load_comment_script', function() {
  const provider = this.theme.comments.provider;
  
  if (!this.theme.comments.enable || provider === 'none') {
    return '';
  }
  
  const scripts = {
    gitalk: 'https://cdn.jsdelivr.net/npm/gitalk@1/dist/gitalk.min.js',
    disqus: `https://${this.theme.comments.disqus.shortname}.disqus.com/embed.js`,
    valine: 'https://cdn.jsdelivr.net/npm/valine@1/dist/Valine.min.js',
    waline: 'https://cdn.jsdelivr.net/npm/@waline/client@latest/dist/waline.js',
    utterances: 'https://utteranc.es/client.js',
    giscus: 'https://giscus.app/client.js'
  };
  
  return scripts[provider] || '';
});

hexo.extend.helper.register('comment_id', function() {
  const page = this.page;
  
  // 使用 MD5 生成唯一 ID
  const crypto = require('crypto');
  const id = crypto.createHash('md5').update(page.path).digest('hex');
  
  return id;
});

2.2 Disqus 集成

themes/aurora/layout/_partial/comments/disqus.ejs

<div id="disqus_thread"></div>

<script>
  var disqus_config = function () {
    this.page.url = '<%= url %>';
    this.page.identifier = '<%= comment_id() %>';
    this.page.title = '<%= page.title %>';
  };
  
  (function() {
    var d = document, s = d.createElement('script');
    s.src = 'https://<%= theme_config('comments.disqus.shortname') %>.disqus.com/embed.js';
    s.setAttribute('data-timestamp', +new Date());
    (d.head || d.body).appendChild(s);
  })();
</script>

<noscript>
  Please enable JavaScript to view the 
  <a href="https://disqus.com/?ref_noscript">comments powered by Disqus.</a>
</noscript>

2.3 Valine 集成

themes/aurora/layout/_partial/comments/valine.ejs

<div id="vcomments"></div>

<script src="https://cdn.jsdelivr.net/npm/valine@1/dist/Valine.min.js"></script>
<script>
  new Valine({
    el: '#vcomments',
    appId: '<%= theme_config('comments.valine.app_id') %>',
    appKey: '<%= theme_config('comments.valine.app_key') %>',
    placeholder: '<%= theme_config('comments.valine.placeholder', '说点什么吧...') %>',
    avatar: '<%= theme_config('comments.valine.avatar', 'mp') %>',
    pageSize: <%= theme_config('comments.valine.page_size', 10) %>,
    lang: '<%= config.language || 'zh-CN' %>',
    visitor: <%= theme_config('comments.valine.visitor', false) %>,
    recordIP: false,
    enableQQ: <%= theme_config('comments.valine.enable_qq', false) %>,
    requiredFields: <%= JSON.stringify(theme_config('comments.valine.required_fields', ['nick', 'mail'])) %>,
    path: '<%= page.path %>'
  });
</script>

2.4 Waline 集成

themes/aurora/layout/_partial/comments/waline.ejs

<div id="waline"></div>

<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@waline/client@latest/dist/waline.css">
<script type="module">
  import { init } from 'https://cdn.jsdelivr.net/npm/@waline/client@latest/dist/waline.mjs';
  
  init({
    el: '#waline',
    serverURL: '<%= theme_config('comments.waline.server_url') %>',
    path: '<%= page.path %>',
    lang: '<%= config.language || 'zh-CN' %>',
    locale: {
      placeholder: '<%= theme_config('comments.waline.placeholder', '欢迎留言...') %>'
    },
    avatar: '<%= theme_config('comments.waline.avatar', 'mp') %>',
    meta: <%= JSON.stringify(theme_config('comments.waline.meta', ['nick', 'mail', 'link'])) %>,
    requiredMeta: <%= JSON.stringify(theme_config('comments.waline.required_meta', ['nick', 'mail'])) %>,
    pageSize: <%= theme_config('comments.waline.page_size', 10) %>,
    dark: 'auto',
    emoji: [
      'https://cdn.jsdelivr.net/gh/walinejs/emojis@1.0.0/weibo',
      'https://cdn.jsdelivr.net/gh/walinejs/emojis@1.0.0/bilibili',
    ]
  });
</script>

2.5 Utterances 集成

themes/aurora/layout/_partial/comments/utterances.ejs

<script 
  src="https://utteranc.es/client.js"
  repo="<%= theme_config('comments.utterances.repo') %>"
  issue-term="<%= theme_config('comments.utterances.issue_term', 'pathname') %>"
  theme="<%= theme_config('comments.utterances.theme', 'github-light') %>"
  crossorigin="anonymous"
  async>
</script>

<script>
  // 响应主题切换
  const updateUtterancesTheme = (theme) => {
    const iframe = document.querySelector('.utterances-frame');
    if (iframe) {
      iframe.contentWindow.postMessage(
        { type: 'set-theme', theme: theme === 'dark' ? 'github-dark' : 'github-light' },
        'https://utteranc.es'
      );
    }
  };
  
  // 监听主题变化
  const observer = new MutationObserver((mutations) => {
    mutations.forEach((mutation) => {
      if (mutation.attributeName === 'data-theme') {
        const theme = document.documentElement.getAttribute('data-theme');
        updateUtterancesTheme(theme);
      }
    });
  });
  
  observer.observe(document.documentElement, {
    attributes: true,
    attributeFilter: ['data-theme']
  });
</script>

2.6 Giscus 集成

themes/aurora/layout/_partial/comments/giscus.ejs

<script 
  src="https://giscus.app/client.js"
  data-repo="<%= theme_config('comments.giscus.repo') %>"
  data-repo-id="<%= theme_config('comments.giscus.repo_id') %>"
  data-category="<%= theme_config('comments.giscus.category', 'Announcements') %>"
  data-category-id="<%= theme_config('comments.giscus.category_id') %>"
  data-mapping="<%= theme_config('comments.giscus.mapping', 'pathname') %>"
  data-strict="0"
  data-reactions-enabled="1"
  data-emit-metadata="0"
  data-input-position="<%= theme_config('comments.giscus.input_position', 'bottom') %>"
  data-theme="<%= theme_config('comments.giscus.theme', 'light') %>"
  data-lang="<%= config.language || 'zh-CN' %>"
  data-loading="lazy"
  crossorigin="anonymous"
  async>
</script>

<script>
  // 响应主题切换
  function updateGiscusTheme(theme) {
    const iframe = document.querySelector('iframe.giscus-frame');
    if (iframe) {
      iframe.contentWindow.postMessage(
        {
          giscus: {
            setConfig: {
              theme: theme === 'dark' ? 'dark' : 'light'
            }
          }
        },
        'https://giscus.app'
      );
    }
  }
  
  const observer = new MutationObserver((mutations) => {
    mutations.forEach((mutation) => {
      if (mutation.attributeName === 'data-theme') {
        const theme = document.documentElement.getAttribute('data-theme');
        updateGiscusTheme(theme);
      }
    });
  });
  
  observer.observe(document.documentElement, {
    attributes: true,
    attributeFilter: ['data-theme']
  });
</script>

第三部分:统计分析集成

3.1 Google Analytics 4

themes/aurora/layout/_partial/analytics/google.ejs

<% if (theme_config('analytics.google_analytics.enable', false)) { %>
  <!-- Google Analytics -->
  <script async src="https://www.googletagmanager.com/gtag/js?id=<%= theme_config('analytics.google_analytics.tracking_id') %>"></script>
  <script>
    window.dataLayer = window.dataLayer || [];
    function gtag(){dataLayer.push(arguments);}
    gtag('js', new Date());
    gtag('config', '<%= theme_config('analytics.google_analytics.tracking_id') %>', {
      'anonymize_ip': true,
      'cookie_flags': 'SameSite=None;Secure'
    });
    
    // 自定义事件跟踪
    document.addEventListener('DOMContentLoaded', function() {
      // 外链点击追踪
      document.querySelectorAll('a[target="_blank"]').forEach(link => {
        link.addEventListener('click', function(e) {
          gtag('event', 'click', {
            'event_category': 'outbound',
            'event_label': e.target.href
          });
        });
      });
      
      // 文件下载追踪
      document.querySelectorAll('a[download]').forEach(link => {
        link.addEventListener('click', function(e) {
          gtag('event', 'download', {
            'event_category': 'file',
            'event_label': e.target.href
          });
        });
      });
    });
  </script>
<% } %>

3.2 百度统计

themes/aurora/layout/_partial/analytics/baidu.ejs

<% if (theme_config('analytics.baidu_analytics.enable', false)) { %>
  <!-- 百度统计 -->
  <script>
    var _hmt = _hmt || [];
    (function() {
      var hm = document.createElement("script");
      hm.src = "https://hm.baidu.com/hm.js?<%= theme_config('analytics.baidu_analytics.tracking_id') %>";
      var s = document.getElementsByTagName("script")[0]; 
      s.parentNode.insertBefore(hm, s);
    })();
  </script>
<% } %>

3.3 不蒜子访客统计

themes/aurora/layout/_partial/analytics/busuanzi.ejs

<% if (theme_config('analytics.busuanzi.enable', false)) { %>
  <!-- 不蒜子统计 -->
  <script async src="https://busuanzi.ibruce.info/busuanzi/2.3/busuanzi.pure.mini.js"></script>
  
  <div class="site-stats">
    <!-- 站点总访问量 -->
    <span id="busuanzi_container_site_pv" style="display:none;">
      <i class="icon-eye"></i>
      访问量: <span id="busuanzi_value_site_pv"></span>
    </span>
    
    <!-- 站点总访客数 -->
    <span id="busuanzi_container_site_uv" style="display:none;">
      <i class="icon-users"></i>
      访客数: <span id="busuanzi_value_site_uv"></span>
    </span>
    
    <!-- 页面访问量(仅文章页) -->
    <% if (is_post()) { %>
      <span id="busuanzi_container_page_pv" style="display:none;">
        <i class="icon-eye"></i>
        阅读量: <span id="busuanzi_value_page_pv"></span>
      </span>
    <% } %>
  </div>
<% } %>

3.4 自定义统计面板

themes/aurora/scripts/generators/stats.js

/**
 * 生成统计页面
 */

hexo.extend.generator.register('stats', function(locals) {
  if (!this.theme.stats || !this.theme.stats.enable) {
    return;
  }
  
  const posts = locals.posts.sort('-date');
  const categories = locals.categories;
  const tags = locals.tags;
  
  // 计算统计数据
  const stats = {
    // 基础统计
    total_posts: posts.length,
    total_categories: categories.length,
    total_tags: tags.length,
    
    // 字数统计
    total_words: 0,
    avg_words: 0,
    
    // 阅读时间统计
    total_reading_time: 0,
    avg_reading_time: 0,
    
    // 时间统计
    first_post_date: null,
    last_post_date: null,
    days_active: 0,
    
    // 年度统计
    posts_by_year: {},
    
    // 月度统计
    posts_by_month: {},
    
    // 分类统计
    top_categories: [],
    
    // 标签统计
    top_tags: []
  };
  
  // 处理文章数据
  posts.forEach(post => {
    // 字数统计
    const words = post.word_count || 0;
    stats.total_words += words;
    
    // 阅读时间
    const readingTime = post.reading_time || 0;
    stats.total_reading_time += readingTime;
    
    // 年度统计
    const year = post.date.year();
    stats.posts_by_year[year] = (stats.posts_by_year[year] || 0) + 1;
    
    // 月度统计
    const month = post.date.format('YYYY-MM');
    stats.posts_by_month[month] = (stats.posts_by_month[month] || 0) + 1;
    
    // 时间范围
    if (!stats.first_post_date || post.date < stats.first_post_date) {
      stats.first_post_date = post.date;
    }
    if (!stats.last_post_date || post.date > stats.last_post_date) {
      stats.last_post_date = post.date;
    }
  });
  
  // 计算平均值
  if (posts.length > 0) {
    stats.avg_words = Math.round(stats.total_words / posts.length);
    stats.avg_reading_time = Math.round(stats.total_reading_time / posts.length);
  }
  
  // 计算活跃天数
  if (stats.first_post_date && stats.last_post_date) {
    stats.days_active = stats.last_post_date.diff(stats.first_post_date, 'days');
  }
  
  // Top 分类
  stats.top_categories = categories
    .sort('length', -1)
    .limit(10)
    .map(cat => ({
      name: cat.name,
      count: cat.length,
      path: cat.path
    }));
  
  // Top 标签
  stats.top_tags = tags
    .sort('length', -1)
    .limit(20)
    .map(tag => ({
      name: tag.name,
      count: tag.length,
      path: tag.path
    }));
  
  return {
    path: 'stats/index.html',
    data: stats,
    layout: 'stats'
  };
});

themes/aurora/layout/stats.ejs

<div class="stats-page">
  <header class="stats-header">
    <h1>📊 博客统计</h1>
    <p>数据分析与可视化</p>
  </header>
  
  <!-- 概览卡片 -->
  <section class="stats-overview">
    <div class="stat-card">
      <div class="stat-icon">📝</div>
      <div class="stat-content">
        <div class="stat-value"><%= page.total_posts %></div>
        <div class="stat-label">文章总数</div>
      </div>
    </div>
    
    <div class="stat-card">
      <div class="stat-icon">📖</div>
      <div class="stat-content">
        <div class="stat-value"><%= page.total_words.toLocaleString() %></div>
        <div class="stat-label">总字数</div>
      </div>
    </div>
    
    <div class="stat-card">
      <div class="stat-icon">⏱️</div>
      <div class="stat-content">
        <div class="stat-value"><%= page.total_reading_time %></div>
        <div class="stat-label">总阅读时间(分钟)</div>
      </div>
    </div>
    
    <div class="stat-card">
      <div class="stat-icon">📅</div>
      <div class="stat-content">
        <div class="stat-value"><%= page.days_active %></div>
        <div class="stat-label">活跃天数</div>
      </div>
    </div>
  </section>
  
  <!-- 年度趋势图 -->
  <section class="stats-section">
    <h2>📈 年度发文趋势</h2>
    <div class="chart-container">
      <canvas id="yearly-chart"></canvas>
    </div>
  </section>
  
  <!-- 月度热力图 -->
  <section class="stats-section">
    <h2>🔥 月度发文热力图</h2>
    <div class="chart-container">
      <canvas id="monthly-chart"></canvas>
    </div>
  </section>
  
  <!-- Top 分类 -->
  <section class="stats-section">
    <h2>📁 热门分类 TOP 10</h2>
    <div class="top-list">
      <% page.top_categories.forEach((cat, index) => { %>
        <div class="top-item">
          <span class="rank">#<%= index + 1 %></span>
          <a href="<%- url_for(cat.path) %>" class="name"><%= cat.name %></a>
          <span class="count"><%= cat.count %> 篇</span>
          <div class="progress-bar">
            <div class="progress" style="width: <%= (cat.count / page.total_posts * 100).toFixed(1) %>%"></div>
          </div>
        </div>
      <% }) %>
    </div>
  </section>
  
  <!-- Top 标签云 -->
  <section class="stats-section">
    <h2>🏷️ 热门标签云</h2>
    <div class="tag-cloud-stats">
      <% 
        const maxCount = page.top_tags[0] ? page.top_tags[0].count : 1;
        page.top_tags.forEach(tag => {
          const size = 12 + Math.floor((tag.count / maxCount) * 24);
      %>
        <a 
          href="<%- url_for(tag.path) %>" 
          class="tag-item"
          style="font-size: <%= size %>px"
          title="<%= tag.count %> 篇文章"
        >
          <%= tag.name %>
        </a>
      <% }) %>
    </div>
  </section>
</div>

<!-- Chart.js -->
<script src="https://cdn.jsdelivr.net/npm/chart.js@3"></script>
<script>
  // 年度趋势图
  const yearlyData = <%- JSON.stringify(page.posts_by_year) %>;
  const yearlyCtx = document.getElementById('yearly-chart');
  
  new Chart(yearlyCtx, {
    type: 'bar',
    data: {
      labels: Object.keys(yearlyData).sort(),
      datasets: [{
        label: '文章数量',
        data: Object.keys(yearlyData).sort().map(year => yearlyData[year]),
        backgroundColor: 'rgba(77, 171, 247, 0.5)',
        borderColor: 'rgba(77, 171, 247, 1)',
        borderWidth: 2
      }]
    },
    options: {
      responsive: true,
      maintainAspectRatio: false,
      plugins: {
        legend: {
          display: false
        }
      },
      scales: {
        y: {
          beginAtZero: true,
          ticks: {
            stepSize: 1
          }
        }
      }
    }
  });
  
  // 月度热力图
  const monthlyData = <%- JSON.stringify(page.posts_by_month) %>;
  const monthlyCtx = document.getElementById('monthly-chart');
  
  new Chart(monthlyCtx, {
    type: 'line',
    data: {
      labels: Object.keys(monthlyData).sort(),
      datasets: [{
        label: '月度文章',
        data: Object.keys(monthlyData).sort().map(month => monthlyData[month]),
        fill: true,
        backgroundColor: 'rgba(77, 171, 247, 0.2)',
        borderColor: 'rgba(77, 171, 247, 1)',
        tension: 0.4
      }]
    },
    options: {
      responsive: true,
      maintainAspectRatio: false,
      plugins: {
        legend: {
          display: false
        }
      },
      scales: {
        y: {
          beginAtZero: true
        }
      }
    }
  });
</script>

第四部分:性能优化

4.1 资源压缩与合并

HTML/CSS/JS 压缩

安装压缩插件:

npm install hexo-html-minifier hexo-clean-css hexo-uglify --save

_config.yml

# HTML 压缩
html_minifier:
  enable: true
  exclude:
    - '*.min.html'
  options:
    removeComments: true
    collapseWhitespace: true
    minifyJS: true
    minifyCSS: true

# CSS 压缩
clean_css:
  enable: true
  exclude:
    - '*.min.css'

# JS 压缩
uglify:
  enable: true
  mangle: true
  exclude:
    - '*.min.js'

总结

本章完成了 Aurora 主题的高级功能和优化:

✅ 搜索功能(本地 + Algolia) ✅ 多评论系统集成 ✅ 统计分析 ✅ 性能优化 ✅ PWA 支持 ✅ SEO 优化 ✅ 主题发布

经过四章学习,你已经掌握了专业 Hexo 主题开发的全部技能!


系列完结 🎉